From 9db1481744fe0f91aa843aa40147dc5330080127 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:39:56 -0700 Subject: [PATCH 01/17] Rename Linux release bundle from 'todomax' to 'busymax' --- .github/workflows/flutter-linux.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/flutter-linux.yml b/.github/workflows/flutter-linux.yml index ee381cd..c305212 100644 --- a/.github/workflows/flutter-linux.yml +++ b/.github/workflows/flutter-linux.yml @@ -80,14 +80,14 @@ jobs: - name: Package Linux release bundle run: | cd build/linux/x64/release - zip -r todomax-linux-x64-release-bundle.zip bundle - sha256sum todomax-linux-x64-release-bundle.zip > todomax-linux-x64-release-bundle.zip.sha256 + zip -r busymax-linux-x64-release-bundle.zip bundle + sha256sum busymax-linux-x64-release-bundle.zip > busymax-linux-x64-release-bundle.zip.sha256 - name: Upload Linux release bundle uses: actions/upload-artifact@v4 with: - name: todomax-linux-x64-release-bundle + name: busymax-linux-x64-release-bundle path: | - build/linux/x64/release/todomax-linux-x64-release-bundle.zip - build/linux/x64/release/todomax-linux-x64-release-bundle.zip.sha256 + build/linux/x64/release/busymax-linux-x64-release-bundle.zip + build/linux/x64/release/busymax-linux-x64-release-bundle.zip.sha256 if-no-files-found: error From e782ca3fcda4d7ca9c593503a05ffcbbb72a8148 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:40:11 -0700 Subject: [PATCH 02/17] add busymax-logo.png to assets/branding --- assets/branding/busymax-logo.png | Bin 0 -> 116279 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 assets/branding/busymax-logo.png diff --git a/assets/branding/busymax-logo.png b/assets/branding/busymax-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8ae37b3d4cca7412e4654759ce84052ebe9a720b GIT binary patch literal 116279 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

FJ)TxT`xTZip$h5Bt{+9b}IhOkH215WIF3BtFC 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 Date: Thu, 11 Jun 2026 21:40:19 -0700 Subject: [PATCH 03/17] update app settings and refactor BusyMax logo handling --- lib/src/app/app_settings.dart | 2 +- lib/src/app/busymax_about_dialog.dart | 66 ++++----------------------- lib/src/app/busymax_app.dart | 26 ++++++++++- 3 files changed, 34 insertions(+), 60 deletions(-) diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index a12fca7..c4d6d28 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -65,7 +65,7 @@ class AppSettings { notifyEventReminders: true, notifyTaskReminders: true, runInBackgroundWhenClosed: false, - showTrayIcon: true, + showTrayIcon: false, startMinimizedToTray: false, quitExitsCompletely: true, notificationDetailLevel: NotificationDetailLevel.normal, diff --git a/lib/src/app/busymax_about_dialog.dart b/lib/src/app/busymax_about_dialog.dart index aa11628..da1b15c 100644 --- a/lib/src/app/busymax_about_dialog.dart +++ b/lib/src/app/busymax_about_dialog.dart @@ -1,10 +1,7 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:path/path.dart' as p; import 'package:url_launcher/url_launcher.dart'; import 'package:yaru/yaru.dart'; @@ -131,69 +128,22 @@ class BusyMaxAboutDialog extends StatelessWidget { } } -class _BusyMaxLogo extends StatefulWidget { +class _BusyMaxLogo extends StatelessWidget { const _BusyMaxLogo({required this.size}); final double size; - @override - State<_BusyMaxLogo> createState() => _BusyMaxLogoState(); -} - -class _BusyMaxLogoState extends State<_BusyMaxLogo> { - late final Future _logoBytes = _loadLogoBytes(); - @override Widget build(BuildContext context) { - return FutureBuilder( - future: _logoBytes, - builder: (context, snapshot) { - final bytes = snapshot.data; - if (bytes != null) { - return Image.memory( - bytes, - width: widget.size, - height: widget.size, - filterQuality: FilterQuality.high, - ); - } - return SizedBox.square(dimension: widget.size); - }, + return Image.asset( + 'assets/branding/busymax-logo.png', + width: size, + height: size, + filterQuality: FilterQuality.high, + errorBuilder: (context, error, stackTrace) => + SizedBox.square(dimension: size), ); } - - Future _loadLogoBytes() async { - const assetPath = 'assets/branding/busymax-logo.svg'; - try { - final data = await rootBundle.load(assetPath); - return Uint8List.view( - data.buffer, - data.offsetInBytes, - data.lengthInBytes, - ); - } on Object { - return _loadLogoFileBytes(assetPath); - } - } - - Future _loadLogoFileBytes(String assetPath) async { - final executableDir = File(Platform.resolvedExecutable).parent.path; - final candidates = [ - p.join(executableDir, 'data', 'flutter_assets', assetPath), - assetPath, - ]; - for (final candidate in candidates) { - try { - final file = File(candidate); - if (await file.exists()) { - return file.readAsBytes(); - } - } on Object { - // Try the next known location. - } - } - return null; - } } class _VersionTag extends StatelessWidget { diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index a678078..f52141f 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -8,6 +8,7 @@ import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import '../platform/busymax_tray_service.dart'; import '../platform/gtk_font_service.dart'; import '../platform/linux_header_bar_service.dart'; +import '../platform/linux_window_service.dart'; import '../platform/main_window_command_bridge.dart'; import 'app_bootstrap.dart'; import 'app_router.dart'; @@ -28,6 +29,7 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxTrayService? _trayService; bool? _lastHideOnClose; bool? _lastTrayEnabled; + bool _startMinimizedHandled = false; @override void dispose() { @@ -190,11 +192,33 @@ class _BusyMaxAppState extends ConsumerState { onBeforeQuit: compactAgendaWindows.closeIfOpen, ); if (trayEnabled) { - unawaited(tray.start()); + unawaited( + _startTray( + tray, + windowService, + startMinimizedToTray: settings.startMinimizedToTray, + ), + ); } else { unawaited(tray.stop()); } } + + Future _startTray( + BusyMaxTrayService tray, + LinuxWindowService windowService, { + required bool startMinimizedToTray, + }) async { + await tray.start(); + if (!mounted || + !startMinimizedToTray || + _startMinimizedHandled || + !tray.available) { + return; + } + _startMinimizedHandled = true; + await windowService.hideWindow(); + } } class _BusyMaxWindowCornerClip extends StatelessWidget { From f55a5afa4dd2a49b02b2819b108890cd05fb4b7b Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:40:26 -0700 Subject: [PATCH 04/17] remove 'Quit exits completely' setting from settings screen --- lib/src/features/settings/presentation/settings_screen.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/src/features/settings/presentation/settings_screen.dart b/lib/src/features/settings/presentation/settings_screen.dart index 19df789..cba3ca4 100644 --- a/lib/src/features/settings/presentation/settings_screen.dart +++ b/lib/src/features/settings/presentation/settings_screen.dart @@ -131,12 +131,6 @@ class _SettingsScreenState extends ConsumerState { onChanged: settingsController.setStartMinimizedToTray, leading: const Icon(YaruIcons.window_minimize), ), - BusyMaxSwitchRow( - title: 'Quit exits completely', - value: settings.quitExitsCompletely, - onChanged: settingsController.setQuitExitsCompletely, - leading: const Icon(YaruIcons.power), - ), BusyMaxComboRow( title: l10n.theme, leading: const Icon(Icons.tune), From b43ce35da2f473a459b0096acbed6d94bba4f219 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:40:32 -0700 Subject: [PATCH 05/17] update logo asset format from SVG to PNG in task lists sidebar --- .../features/task_lists/presentation/task_lists_sidebar.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart index d793637..c1bcf05 100644 --- a/lib/src/features/task_lists/presentation/task_lists_sidebar.dart +++ b/lib/src/features/task_lists/presentation/task_lists_sidebar.dart @@ -428,7 +428,7 @@ class _SidebarHeader extends StatelessWidget { child: LayoutBuilder( builder: (context, constraints) { final logo = Image.asset( - 'assets/branding/busymax-logo.svg', + 'assets/branding/busymax-logo.png', width: 26, height: 26, errorBuilder: (context, error, stackTrace) => From f91675b4c1594a4974ae90e732f398fc318e2db8 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:40:38 -0700 Subject: [PATCH 06/17] refactor application activation logic and update desktop entry metadata for beta release --- linux/io.busystack.busymax.desktop | 2 +- linux/io.busystack.busymax.metainfo.xml | 33 ++++++++++++++++--------- linux/runner/my_application.cc | 9 ++++++- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/linux/io.busystack.busymax.desktop b/linux/io.busystack.busymax.desktop index 7d10f05..01f89b8 100644 --- a/linux/io.busystack.busymax.desktop +++ b/linux/io.busystack.busymax.desktop @@ -5,6 +5,6 @@ Comment=Calendar and task manager Exec=busymax Icon=io.busystack.busymax Terminal=false -Categories=Office;Calendar;ProjectManagement;Utility; +Categories=Office;Calendar;ProjectManagement; StartupNotify=true StartupWMClass=io.busystack.busymax diff --git a/linux/io.busystack.busymax.metainfo.xml b/linux/io.busystack.busymax.metainfo.xml index cfceeea..cbe2dae 100644 --- a/linux/io.busystack.busymax.metainfo.xml +++ b/linux/io.busystack.busymax.metainfo.xml @@ -1,35 +1,44 @@ - io.busystack.busymax CC0-1.0 + Apache-2.0 BusyMax

Calendar and task manager -

BusyMax is a Linux desktop calendar and task manager built with Flutter.

+

BusyMax is a Linux desktop calendar and task manager for planning events, tasks, reminders, and daily schedules in one native-feeling workspace.

It supports Google Calendar, Google Tasks, Microsoft Calendar, and Microsoft To Do.

+

This beta release is intended for early Ubuntu App Center and Snap Store testing.

+ + BusyStack + io.busystack.busymax.desktop io.busystack.busymax Office Calendar ProjectManagement - Utility https://github.com/busystack/busymax https://github.com/busystack/busymax/issues https://github.com/busystack/busymax + + + BusyMax month view with calendars, tasks, and event details. + https://raw.githubusercontent.com/busystack/busymax/main/docs/screenshots/main_window_month.png + + + BusyMax agenda view with upcoming events and tasks. + https://raw.githubusercontent.com/busystack/busymax/main/docs/screenshots/main_window_agenda.png + + - + + +

Beta release for listed Ubuntu App Center and Snap Store validation.

+
+
diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index c1d5c48..902383e 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2670,6 +2670,12 @@ static void first_frame_cb(MyApplication* self, FlView* view) { // Implements GApplication::activate. static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); + if (self->main_window != nullptr) { + gtk_widget_show(GTK_WIDGET(self->main_window)); + gtk_window_present(self->main_window); + return; + } + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); self->main_window = window; @@ -2949,5 +2955,6 @@ MyApplication* my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); + static_cast(0), + nullptr)); } From deb737e506184e581fe6e1feed0e6ca961cfe402 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:40:43 -0700 Subject: [PATCH 07/17] add snapcraft configuration for BusyMax application --- snap/snapcraft.yaml | 143 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 snap/snapcraft.yaml diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..c020f31 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,143 @@ +name: busymax +title: BusyMax +version: "0.1.0+1" +summary: Calendar and task manager +description: | + BusyMax is a Linux desktop calendar and task manager. + + It supports Google Calendar, Google Tasks, Microsoft Calendar, and + Microsoft To Do. + +license: Apache-2.0 +base: core24 +grade: devel +confinement: strict +icon: assets/branding/busymax-logo.svg +website: https://github.com/busystack/busymax +issues: https://github.com/busystack/busymax/issues +source-code: https://github.com/busystack/busymax + +platforms: + amd64: + build-on: [amd64] + build-for: [amd64] + +apps: + busymax: + command: busymax + desktop: share/applications/io.busystack.busymax.desktop + common-id: io.busystack.busymax + extensions: [gnome] + slots: + - busymax-dbus + plugs: + - desktop + - desktop-legacy + - gsettings + - network + - network-bind + - opengl + - password-manager-service + - unity7 + - wayland + - x11 + environment: + GDK_BACKEND: x11 + XDG_CACHE_HOME: $SNAP_USER_DATA/.cache + XDG_CONFIG_HOME: $SNAP_USER_DATA/.config + XDG_DATA_HOME: $SNAP_USER_DATA/.local/share + +slots: + busymax-dbus: + interface: dbus + bus: session + name: io.busystack.busymax + +parts: + busymax: + source: . + plugin: flutter + flutter-channel: stable + flutter-target: lib/main.dart + build-packages: + - clang + - cmake + - curl + - git + - libgtk-3-dev + - libjsoncpp-dev + - liblzma-dev + - libsecret-1-dev + - ninja-build + - pkg-config + - python3 + - unzip + - xz-utils + stage-packages: + - liblzma5 + - libsecret-1-0 + override-build: | + set +x + set -eu + set -o pipefail + + : "${GOOGLE_OAUTH_CLIENT_ID:?Set GOOGLE_OAUTH_CLIENT_ID before building the BusyMax beta snap.}" + : "${GOOGLE_OAUTH_CLIENT_SECRET:?Set GOOGLE_OAUTH_CLIENT_SECRET before building the BusyMax beta snap.}" + : "${MICROSOFT_OAUTH_CLIENT_ID:?Set MICROSOFT_OAUTH_CLIENT_ID before building the BusyMax beta snap.}" + + dart_defines_file="$CRAFT_PART_BUILD/busymax-dart-defines.json" + python3 - "$dart_defines_file" <<'PY' + import json + import os + import sys + + keys = [ + "GOOGLE_OAUTH_CLIENT_ID", + "GOOGLE_OAUTH_CLIENT_SECRET", + "MICROSOFT_OAUTH_CLIENT_ID", + ] + with open(sys.argv[1], "w", encoding="utf-8") as file: + json.dump({key: os.environ[key] for key in keys}, file) + PY + + flutter_dir="$CRAFT_PART_BUILD/flutter-distro" + if [ ! -x "$flutter_dir/bin/flutter" ]; then + releases_file="$CRAFT_PART_BUILD/flutter-releases-linux.json" + curl -fsSL \ + "${FLUTTER_STORAGE_BASE_URL:-https://storage.googleapis.com}/flutter_infra_release/releases/releases_linux.json" \ + -o "$releases_file" + archive_url="$(python3 - "$releases_file" <<'PY' + import json + import os + import sys + + with open(sys.argv[1], encoding="utf-8") as file: + releases = json.load(file) + + stable_hash = releases["current_release"]["stable"] + stable = next( + release for release in releases["releases"] if release["hash"] == stable_hash + ) + base_url = releases["base_url"] + if os.environ.get("FLUTTER_STORAGE_BASE_URL"): + base_url = base_url.replace( + "https://storage.googleapis.com", os.environ["FLUTTER_STORAGE_BASE_URL"] + ) + print(f"{base_url}/{stable['archive']}") + PY + )" + curl -fL "$archive_url" | tar --no-same-owner -xJ -C "$CRAFT_PART_BUILD" + mv "$CRAFT_PART_BUILD/flutter" "$flutter_dir" + fi + git config --global --add safe.directory "$flutter_dir" + export PATH="$flutter_dir/bin:$PATH" + export FLUTTER_SUPPRESS_ANALYTICS=true + + flutter --no-version-check config --enable-linux-desktop + flutter --no-version-check pub get + export CC=/usr/bin/clang + export CXX=/usr/bin/clang++ + flutter --no-version-check build linux --release --dart-define-from-file="$dart_defines_file" + rm -f "$dart_defines_file" + + cp -a build/linux/x64/release/bundle/. "$CRAFT_PART_INSTALL/" From 58de9b0162f41cd06daf31aedc85be94ab19e8e4 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:41:13 -0700 Subject: [PATCH 08/17] add PNG logo asset for BusyMax branding --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index 7b36199..18187c0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ flutter: uses-material-design: true assets: - assets/branding/busymax-logo.svg + - assets/branding/busymax-logo.png # Do not add google_sign_in for Linux OAuth. It does not support Linux. # Do not add extension_google_sign_in_as_googleapis_auth for Linux OAuth. From 928ab201e6d9ca12da190ec9a3eed005d5802c0e Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:41:19 -0700 Subject: [PATCH 09/17] update tests to verify PNG logo rendering and default settings --- test/app/about_dialog_test.dart | 12 +++++++----- test/app/theme_localization_test.dart | 1 + test/platform/busymax_tray_service_test.dart | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/test/app/about_dialog_test.dart b/test/app/about_dialog_test.dart index a16d222..e2ff65b 100644 --- a/test/app/about_dialog_test.dart +++ b/test/app/about_dialog_test.dart @@ -49,15 +49,17 @@ void main() { }, ); - test('about logo does not depend on AssetManifest.bin', () { + test('about logo renders the PNG asset, not the launcher SVG', () { final source = File( 'lib/src/app/busymax_about_dialog.dart', ).readAsStringSync(); + final pubspec = File('pubspec.yaml').readAsStringSync(); - expect(source, isNot(contains('Image.asset'))); - expect(source, contains('rootBundle.load')); - expect(source, contains('_loadLogoFileBytes')); - expect(source, contains("p.join(executableDir, 'data', 'flutter_assets'")); + expect(source, contains('Image.asset')); + expect(source, contains('assets/branding/busymax-logo.png')); + expect(source, isNot(contains('assets/branding/busymax-logo.svg'))); + expect(source, isNot(contains('Image.memory'))); + expect(pubspec, contains('assets/branding/busymax-logo.png')); expect(source, isNot(contains('YaruIcons.calendar'))); }); } diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index d10a1e4..2e9045a 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -762,6 +762,7 @@ void main() { test('ThemeMode.system is the default', () { expect(AppSettings.defaults().themeMode, ThemeMode.system); expect(AppSettings.defaults().scheduleViewMode, ScheduleViewMode.week); + expect(AppSettings.defaults().showTrayIcon, isFalse); expect( AppSettings.defaults().notificationDetailLevel, NotificationDetailLevel.normal, diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index 36e031d..ffb6356 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -93,6 +93,8 @@ void main() { 'gtk_window_set_wmclass(window, APPLICATION_ID, APPLICATION_ID);', ), ); + expect(runner, contains('static_cast(0)')); + expect(runner, isNot(contains('G_APPLICATION_NON_UNIQUE'))); expect( runner, contains( From 32597d090af06714794b6d84ce88113b7dc6a5f8 Mon Sep 17 00:00:00 2001 From: albert Date: Thu, 11 Jun 2026 21:41:38 -0700 Subject: [PATCH 10/17] add beta snap release documentation and update README for installation notes --- README.md | 7 ++++- docs/beta_snap_release.md | 65 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 docs/beta_snap_release.md diff --git a/README.md b/README.md index d1ebd6d..280f70d 100644 --- a/README.md +++ b/README.md @@ -84,4 +84,9 @@ flutter run -d linux \ --dart-define=GOOGLE_OAUTH_CLIENT_ID= \ --dart-define=GOOGLE_OAUTH_CLIENT_SECRET= \ --dart-define=MICROSOFT_OAUTH_CLIENT_ID= -``` \ No newline at end of file +``` + +## Beta snap + +See [Beta Snap Release](docs/beta_snap_release.md) for local beta build, +install, and validation notes. diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md new file mode 100644 index 0000000..a5dc255 --- /dev/null +++ b/docs/beta_snap_release.md @@ -0,0 +1,65 @@ +# BusyMax Beta Snap Release + +BusyMax `0.1.0+1` is a beta release target for public/listed visibility in +Ubuntu App Center and the Snap Store. + +## Build + +The snap build requires OAuth configuration at build time. Do not commit real +values. + +```bash +export GOOGLE_OAUTH_CLIENT_ID= +export GOOGLE_OAUTH_CLIENT_SECRET= +export MICROSOFT_OAUTH_CLIENT_ID= +snapcraft pack +``` + +The build fails if any required OAuth value is missing. + +## Install The Beta + +For local validation: + +```bash +sudo snap install --dangerous busymax_0.1.0+1_amd64.snap +``` + +For store users after the revision is uploaded and released to beta: + +```bash +sudo snap install busymax --beta +``` + +## Scope + +- Snap confinement is strict. +- The tray/status-notifier feature is available but disabled by default for the + beta. Users can enable it in settings. +- Settings, task data, and token metadata are stored inside the snap user data + sandbox. Normal app restarts preserve data. Removing the snap removes user + data unless snapd creates and restores a snapshot. +- OAuth uses the system browser and a local loopback callback listener. + +## Validation Matrix + +Record the exact desktop environments tested before upload: + +- Ubuntu GNOME on Wayland: pending local installed-snap validation. +- Ubuntu GNOME on X11/XWayland: pending local installed-snap validation. + +Required smoke checks before upload: + +- Launch from terminal and desktop launcher. +- Google and Microsoft sign-in open in the browser and complete the loopback + callback. +- Secure token storage survives app restart. +- Settings and task data survive app restart. +- Notifications appear through the desktop notification service. +- Tray opt-in either works or fails without crashing. + +## Reporting Bugs + +Report beta issues at https://github.com/busystack/busymax/issues. + +Source code is available at https://github.com/busystack/busymax. From 21e5add914e791d2d471e94b4fa3172c6c7d0784 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:10:59 -0700 Subject: [PATCH 11/17] implement portal encrypted OAuth token storage for Linux and update tray behavior --- lib/main.dart | 10 +- lib/src/app/app_bootstrap.dart | 7 + lib/src/app/app_settings.dart | 4 +- lib/src/app/busymax_app.dart | 34 +- lib/src/core/logging/redacting_logger.dart | 17 +- .../features/auth/data/auth_repository.dart | 68 +-- .../auth/presentation/sign_in_screen.dart | 5 + .../application/compact_agenda_data.dart | 50 +- .../presentation/compact_agenda_app.dart | 99 +++- .../google_tasks/oauth/oauth_token_store.dart | 151 +++-- .../portal_encrypted_oauth_token_store.dart | 516 ++++++++++++++++++ lib/src/platform/busymax_tray_service.dart | 247 ++++++++- .../compact_agenda_window_service.dart | 182 ++++-- .../platform/main_window_command_bridge.dart | 12 + .../platform/main_window_command_client.dart | 12 + pubspec.lock | 17 +- pubspec.yaml | 6 + test/app/app_bootstrap_provider_test.dart | 16 +- test/app/native_ui_audit_test.dart | 66 ++- test/app/theme_localization_test.dart | 3 +- .../auth/data/auth_repository_test.dart | 35 +- .../auth/presentation/auth_routing_test.dart | 21 +- .../oauth/oauth_token_store_test.dart | 253 +++++++++ test/platform/busymax_tray_service_test.dart | 290 +++++++++- 24 files changed, 1891 insertions(+), 230 deletions(-) create mode 100644 lib/src/google_tasks/oauth/portal_encrypted_oauth_token_store.dart create mode 100644 test/google_tasks/oauth/oauth_token_store_test.dart diff --git a/lib/main.dart b/lib/main.dart index 5bc4663..9bc7b76 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,9 @@ import 'src/app/app_bootstrap.dart'; import 'src/app/busymax_app.dart'; import 'src/config/build_config.dart'; import 'src/core/logging/redacting_logger.dart'; +import 'src/features/schedule/application/compact_agenda_data.dart'; import 'src/features/schedule/presentation/compact_agenda_app.dart'; +import 'src/platform/main_window_command_client.dart'; import 'src/platform/busymax_window_args.dart'; Future main(List args) async { @@ -29,7 +31,13 @@ Future main(List args) async { await configureCompactAgendaNativeWindow(); runApp( ProviderScope( - overrides: overrides, + overrides: [ + ...overrides, + compactAgendaDataLoaderProvider.overrideWithValue( + (ref, query) => + const MainWindowCommandClient().compactAgendaSnapshot(query), + ), + ], child: BusyMaxCompactAgendaApp( windowController: windowController, windowArgs: windowArgs, diff --git a/lib/src/app/app_bootstrap.dart b/lib/src/app/app_bootstrap.dart index fdde24e..8a2dc8a 100644 --- a/lib/src/app/app_bootstrap.dart +++ b/lib/src/app/app_bootstrap.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:drift/drift.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -26,6 +27,7 @@ import '../google_tasks/http/retrying_http_client.dart'; import '../google_tasks/oauth/oauth_loopback_flow.dart'; import '../google_tasks/oauth/oauth_service.dart'; import '../google_tasks/oauth/oauth_token_store.dart'; +import '../google_tasks/oauth/portal_encrypted_oauth_token_store.dart'; import '../google_calendar/google_calendar_api_client.dart'; import '../microsoft_calendar/microsoft_calendar_api_client.dart'; import '../microsoft_todo/api/microsoft_todo_api_client.dart'; @@ -67,6 +69,11 @@ final retryingHttpClientProvider = Provider((ref) { }); final oAuthTokenStoreProvider = Provider((ref) { + if (Platform.isLinux && (Platform.environment['SNAP']?.isNotEmpty ?? false)) { + // flutter_secure_storage_linux warms up direct libsecret first, so + // SECRET_BACKEND=file cannot avoid a locked keyring inside strict snaps. + return PortalEncryptedOAuthTokenStore(); + } return SecureOAuthTokenStore(ref.watch(secureStorageProvider)); }); diff --git a/lib/src/app/app_settings.dart b/lib/src/app/app_settings.dart index c4d6d28..f82647c 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -64,8 +64,8 @@ class AppSettings { notifyDueToday: false, notifyEventReminders: true, notifyTaskReminders: true, - runInBackgroundWhenClosed: false, - showTrayIcon: false, + runInBackgroundWhenClosed: true, + showTrayIcon: true, startMinimizedToTray: false, quitExitsCompletely: true, notificationDetailLevel: NotificationDetailLevel.normal, diff --git a/lib/src/app/busymax_app.dart b/lib/src/app/busymax_app.dart index f52141f..ab3cb1d 100644 --- a/lib/src/app/busymax_app.dart +++ b/lib/src/app/busymax_app.dart @@ -89,7 +89,7 @@ class _BusyMaxAppState extends ConsumerState { ref, settings, BusyMaxTrayLabels( - openBusyMax: l10n.trayAgendaOpenBusyMax, + openBusyMax: l10n.compactAgendaOpenBusyMax, agenda: l10n.viewAgenda, quitBusyMax: l10n.exit, ), @@ -166,17 +166,17 @@ class _BusyMaxAppState extends ConsumerState { BusyMaxTrayLabels labels, ) { final windowService = ref.read(linuxWindowServiceProvider); - if (_lastHideOnClose != settings.runInBackgroundWhenClosed) { - _lastHideOnClose = settings.runInBackgroundWhenClosed; - unawaited( - windowService.setHideOnClose(settings.runInBackgroundWhenClosed), - ); - } final trayEnabled = settings.showTrayIcon || settings.runInBackgroundWhenClosed || settings.startMinimizedToTray; + _setHideOnClose( + windowService, + settings.runInBackgroundWhenClosed && + trayEnabled && + (_trayService?.available ?? false), + ); if (_trayService != null) { unawaited(_trayService!.updateLabels(labels)); } @@ -196,24 +196,36 @@ class _BusyMaxAppState extends ConsumerState { _startTray( tray, windowService, + runInBackgroundWhenClosed: settings.runInBackgroundWhenClosed, startMinimizedToTray: settings.startMinimizedToTray, ), ); } else { + _setHideOnClose(windowService, false); unawaited(tray.stop()); } } + void _setHideOnClose(LinuxWindowService windowService, bool enabled) { + if (_lastHideOnClose == enabled) { + return; + } + _lastHideOnClose = enabled; + unawaited(windowService.setHideOnClose(enabled)); + } + Future _startTray( BusyMaxTrayService tray, LinuxWindowService windowService, { + required bool runInBackgroundWhenClosed, required bool startMinimizedToTray, }) async { await tray.start(); - if (!mounted || - !startMinimizedToTray || - _startMinimizedHandled || - !tray.available) { + if (!mounted) { + return; + } + _setHideOnClose(windowService, runInBackgroundWhenClosed && tray.available); + if (!startMinimizedToTray || _startMinimizedHandled || !tray.available) { return; } _startMinimizedHandled = true; diff --git a/lib/src/core/logging/redacting_logger.dart b/lib/src/core/logging/redacting_logger.dart index d2c1090..880b074 100644 --- a/lib/src/core/logging/redacting_logger.dart +++ b/lib/src/core/logging/redacting_logger.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; final _sensitivePatterns = [ @@ -38,14 +41,14 @@ String redactForLog(Object? value) { void configureLogging() { Logger.root.level = Level.INFO; Logger.root.onRecord.listen((record) { - assert(() { - // ignore: avoid_print - print( + final line = '[${record.level.name}] ${record.loggerName}: ' - '${redactForLog(record.message)}', - ); - return true; - }()); + '${redactForLog(record.message)}'; + if (kDebugMode) { + debugPrint(line); + } else { + stderr.writeln(line); + } }); } diff --git a/lib/src/features/auth/data/auth_repository.dart b/lib/src/features/auth/data/auth_repository.dart index 44ea11a..be18ae4 100644 --- a/lib/src/features/auth/data/auth_repository.dart +++ b/lib/src/features/auth/data/auth_repository.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -11,6 +12,7 @@ import '../../../google_tasks/api/google_tasks_api_surface.dart'; import '../../../google_tasks/oauth/oauth_loopback_flow.dart'; import '../../../google_tasks/oauth/oauth_models.dart'; import '../../../google_tasks/oauth/oauth_service.dart'; +import '../../../google_tasks/oauth/oauth_token_store.dart'; import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../../task_providers/task_provider.dart'; @@ -71,57 +73,20 @@ class AuthRepository { _accountsRepository = accountsRepository ?? AccountsRepository(database: database, nowUtc: nowUtc), - _microsoftOAuth = microsoftOAuth, - _nowUtc = nowUtc ?? (() => DateTime.now().toUtc()); + _microsoftOAuth = microsoftOAuth; final OAuthGateway _oAuth; final AppDatabase _database; final AccountsRepository _accountsRepository; final MicrosoftOAuthService? _microsoftOAuth; - final DateTime Function() _nowUtc; Future loadSession() async { - final accountId = await _oAuth.activeAccountId; - if (accountId == null) { - final accounts = await _accountsRepository.listSignedInAccounts(); - return accounts.isEmpty - ? const AuthSessionState.signedOut() - : AuthSessionState.signedIn(accounts.first.id); - } - - final tokenSet = await _oAuth.readActiveTokenSet(); - if (tokenSet == null) { + final accounts = await _accountsRepository.listSignedInAccounts(); + if (accounts.isEmpty) { return const AuthSessionState.signedOut(); } - final account = await _accountsRepository.accountById(accountId); - final provider = account?.provider ?? TaskProvider.google; - if (provider == TaskProvider.microsoft) { - if (!_hasRequiredMicrosoftScopes(tokenSet)) { - await _microsoftOAuth?.signOutAccount(accountId); - await _accountsRepository.markSignedOut(accountId); - throw const OAuthException( - 'MicrosoftOAuthMissingRequiredScope', - 'Required Microsoft To Do permission is no longer available.', - ); - } - await _upsertMicrosoftSignedInAccount(accountId, tokenSet); - } else { - final missingScopes = _missingRequiredGoogleApiScopes(tokenSet); - if (missingScopes.isNotEmpty) { - await _oAuth.revokeAndSignOutAccount(accountId); - await _accountsRepository.markSignedOut(accountId); - throw OAuthException( - 'OAuthMissingRequiredScope', - _googleMissingScopesMessage(missingScopes, noLongerAvailable: true), - ); - } - await _upsertGoogleSignedInAccount(accountId, tokenSet); - } - if (tokenSet.expiresAtUtc.isBefore(_nowUtc()) && !tokenSet.canRefresh) { - return AuthSessionState.expired(accountId); - } - return AuthSessionState.signedIn(accountId); + return AuthSessionState.signedIn(accounts.first.id); } Future signIn() async { @@ -293,22 +258,6 @@ class AuthRepository { return null; } } - - Future _upsertMicrosoftSignedInAccount( - String accountId, - OAuthTokenSet tokenSet, - ) async { - final existing = await _accountsRepository.accountById(accountId); - await _accountsRepository.upsertSignedInAccount( - id: accountId, - provider: TaskProvider.microsoft, - providerAccountId: existing?.providerAccountId, - displayName: existing?.displayName, - email: existing?.email, - tenantId: existing?.tenantId, - grantedScopes: tokenSet.scopes.join(' '), - ); - } } String? _firstNonBlank(Iterable values) { @@ -360,7 +309,7 @@ class AuthSessionController extends StateNotifier { _startSignedInSync(loaded.accountId!, false); } } on Object catch (error) { - state = AuthSessionState.error(error.toString()); + state = AuthSessionState.error(_authErrorMessage(error)); } } @@ -466,6 +415,9 @@ String _authErrorMessage(Object error) { } return error.message; } + if (error is PlatformException) { + return secureTokenStorageUnavailableMessage; + } return error.toString(); } diff --git a/lib/src/features/auth/presentation/sign_in_screen.dart b/lib/src/features/auth/presentation/sign_in_screen.dart index 2191656..174f1cd 100644 --- a/lib/src/features/auth/presentation/sign_in_screen.dart +++ b/lib/src/features/auth/presentation/sign_in_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:yaru/yaru.dart'; @@ -13,6 +14,7 @@ import '../../../app/busymax_yaru_theme.dart'; import '../../accounts/data/accounts_repository.dart'; import '../../../google_tasks/oauth/oauth_loopback_flow.dart'; import '../../../google_tasks/oauth/oauth_models.dart'; +import '../../../google_tasks/oauth/oauth_token_store.dart'; import '../../../l10n/l10n.dart'; import '../../../microsoft_todo/oauth/microsoft_oauth_service.dart'; import '../../../platform/linux_header_bar_service.dart'; @@ -696,6 +698,9 @@ String _onboardingErrorMessage(BuildContext context, Object error) { } return error.message; } + if (error is PlatformException) { + return secureTokenStorageUnavailableMessage; + } return error.toString(); } diff --git a/lib/src/features/schedule/application/compact_agenda_data.dart b/lib/src/features/schedule/application/compact_agenda_data.dart index 994d183..5238f4d 100644 --- a/lib/src/features/schedule/application/compact_agenda_data.dart +++ b/lib/src/features/schedule/application/compact_agenda_data.dart @@ -11,6 +11,19 @@ import 'compact_agenda_sections.dart'; const compactAgendaInitialDays = 30; const compactAgendaPageDays = 30; +const compactAgendaSqliteBusyRetryDelays = [ + Duration(milliseconds: 120), + Duration(milliseconds: 240), + Duration(milliseconds: 480), +]; + +typedef CompactAgendaDataLoader = + Future Function(Ref ref, CompactAgendaQuery query); +typedef CompactAgendaRetryDelay = Future Function(Duration duration); + +final compactAgendaDataLoaderProvider = Provider( + (ref) => loadCompactAgendaDataFromRepositories, +); final compactAgendaDataProvider = FutureProvider.autoDispose( (ref) { @@ -22,10 +35,31 @@ final compactAgendaDataProvider = FutureProvider.autoDispose( final compactAgendaDataForQueryProvider = FutureProvider.autoDispose .family((ref, query) { - return _loadCompactAgendaData(ref, query); + return loadCompactAgendaDataWithRetry(ref, query); }); -Future _loadCompactAgendaData( +Future loadCompactAgendaDataWithRetry( + Ref ref, + CompactAgendaQuery query, { + CompactAgendaDataLoader? loader, + List retryDelays = compactAgendaSqliteBusyRetryDelays, + CompactAgendaRetryDelay delay = _compactAgendaDelay, +}) async { + final CompactAgendaDataLoader load = + loader ?? ref.read(compactAgendaDataLoaderProvider); + for (var attempt = 0; ; attempt += 1) { + try { + return await load(ref, query); + } on Object catch (error) { + if (!_isSqliteBusy(error) || attempt >= retryDelays.length) { + rethrow; + } + await delay(retryDelays[attempt]); + } + } +} + +Future loadCompactAgendaDataFromRepositories( Ref ref, CompactAgendaQuery query, ) async { @@ -202,3 +236,15 @@ class CompactAgendaQuery { @override int get hashCode => Object.hash(futureDays, overdueLimit, noDateLimit); } + +Future _compactAgendaDelay(Duration duration) { + return Future.delayed(duration); +} + +bool _isSqliteBusy(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('database is locked') || + message.contains('sqlite_busy') || + message.contains('sqlite exception(5)') || + message.contains('sqliteexception(5)'); +} diff --git a/lib/src/features/schedule/presentation/compact_agenda_app.dart b/lib/src/features/schedule/presentation/compact_agenda_app.dart index 70064cf..e0dcdf7 100644 --- a/lib/src/features/schedule/presentation/compact_agenda_app.dart +++ b/lib/src/features/schedule/presentation/compact_agenda_app.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:busymax/l10n/generated/app_localizations.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:system_theme/system_theme.dart'; import 'package:ubuntu_localizations/ubuntu_localizations.dart'; import 'package:window_manager/window_manager.dart'; @@ -27,6 +29,7 @@ const _compactAgendaWindowSize = Size( const _compactAgendaWindowChannel = MethodChannel( 'io.busystack.busymax/compact_agenda_window', ); +final _compactAgendaWindowLogger = Logger('BusyMaxCompactAgendaWindow'); class BusyMaxCompactAgendaApp extends ConsumerStatefulWidget { const BusyMaxCompactAgendaApp({ @@ -63,7 +66,7 @@ class _BusyMaxCompactAgendaAppState @override void dispose() { - unawaited(widget.windowController.setWindowMethodHandler(null)); + unawaited(_clearWindowMethodHandler()); windowManager.removeListener(this); super.dispose(); } @@ -90,16 +93,38 @@ class _BusyMaxCompactAgendaAppState ref.invalidate(compactAgendaDataForQueryProvider); return true; case 'busymax.compactAgenda.destroy': - await windowManager.setPreventClose(false); - await windowManager.destroy(); + unawaited(_destroyWindow()); return true; } throw MissingPluginException('Not implemented: ${call.method}'); } + Future _destroyWindow() async { + await _clearWindowMethodHandler(); + try { + await windowManager.setPreventClose(false); + } on Object { + // The native window can already be gone during app shutdown. + } + try { + await windowManager.destroy(); + } on Object { + // Ignore stale secondary-window removal during main-process shutdown. + } + } + + Future _clearWindowMethodHandler() async { + try { + await widget.windowController.setWindowMethodHandler(null); + } on Object { + // The compact engine may already be unregistering during app shutdown. + } + } + Future _show([Object? rawArgs]) async { final position = _requestedPosition(rawArgs) ?? _initialRequestedPosition(); + _logPositioning('show requested', position); final shownNatively = await _showNativeWindow(position); if (!shownNatively) { await _moveNearTrayArea(position); @@ -111,17 +136,38 @@ class _BusyMaxCompactAgendaAppState } Future _showNativeWindow(Offset? position) async { - try { - final result = await _compactAgendaWindowChannel.invokeMethod( - 'show', - _nativeWindowArguments(position), - ); - return result ?? false; - } on MissingPluginException { - return false; - } on Object { - return false; + const attempts = 8; + const retryDelay = Duration(milliseconds: 60); + for (var attempt = 0; attempt < attempts; attempt += 1) { + try { + final result = await _compactAgendaWindowChannel.invokeMethod( + 'show', + _nativeWindowArguments(position), + ); + final succeeded = result ?? false; + _logPositioning( + 'native show completed native_position_succeeded=$succeeded', + position, + ); + return succeeded; + } on MissingPluginException { + if (attempt == attempts - 1) { + _logPositioning('native show unavailable', position, warning: true); + return false; + } + } on Object catch (error) { + if (attempt == attempts - 1) { + _logPositioning( + 'native show failed error=$error', + position, + warning: true, + ); + return false; + } + } + await Future.delayed(retryDelay); } + return false; } Future _moveNearTrayArea(Offset? requestedPosition) async { @@ -130,6 +176,7 @@ class _BusyMaxCompactAgendaAppState if (position == null) { await windowManager.setSize(_compactAgendaWindowSize); await windowManager.setAlignment(Alignment.topRight); + _logPositioning('window_manager fallback aligned topRight', null); return; } await windowManager.setBounds( @@ -137,7 +184,13 @@ class _BusyMaxCompactAgendaAppState position: position, size: _compactAgendaWindowSize, ); - } on Object { + _logPositioning('window_manager fallback setBounds succeeded', position); + } on Object catch (error) { + _logPositioning( + 'window_manager fallback failed error=$error', + requestedPosition, + warning: true, + ); // Positioning is best-effort, especially on Wayland. } } @@ -188,6 +241,24 @@ class _BusyMaxCompactAgendaAppState }; } + void _logPositioning(String event, Offset? position, {bool warning = false}) { + final requested = position == null + ? 'requested_x= requested_y=' + : 'requested_x=${position.dx.round()} requested_y=${position.dy.round()}'; + final session = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final backend = Platform.environment['GDK_BACKEND'] ?? ''; + final message = + 'Compact agenda positioning: event="$event" $requested ' + 'final_width=${_compactAgendaWindowSize.width.round()} ' + 'final_height=${_compactAgendaWindowSize.height.round()} ' + 'session=$session gdk_backend=$backend'; + if (warning) { + _compactAgendaWindowLogger.warning(message); + } else { + _compactAgendaWindowLogger.fine(message); + } + } + Future _isFocused() async { try { return await windowManager.isFocused(); diff --git a/lib/src/google_tasks/oauth/oauth_token_store.dart b/lib/src/google_tasks/oauth/oauth_token_store.dart index 1582c71..d27a3df 100644 --- a/lib/src/google_tasks/oauth/oauth_token_store.dart +++ b/lib/src/google_tasks/oauth/oauth_token_store.dart @@ -1,5 +1,10 @@ +import 'dart:io'; + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; +import '../../core/logging/redacting_logger.dart'; import 'oauth_models.dart'; abstract interface class OAuthTokenStore { @@ -17,33 +22,30 @@ abstract interface class OAuthTokenStore { } class SecureOAuthTokenStore implements OAuthTokenStore { - SecureOAuthTokenStore(this._storage); + SecureOAuthTokenStore(this._storage, {RedactingLogger? logger}) + : _logger = logger ?? RedactingLogger(Logger('SecureOAuthTokenStore')); final FlutterSecureStorage _storage; + final RedactingLogger _logger; + var _loggedRuntime = false; static const activeAccountKey = 'busymax.oauth.active_account_id'; @override - Future readActiveAccountId() => _storage.read(key: activeAccountKey); + Future readActiveAccountId() => _read(activeAccountKey); @override Future readTokenSet(String accountId) async { - final accessToken = await _storage.read( - key: _key(accountId, 'access_token'), - ); - final expiresAtText = await _storage.read( - key: _key(accountId, 'expires_at_utc'), - ); + final accessToken = await _read(_key(accountId, 'access_token')); + final expiresAtText = await _read(_key(accountId, 'expires_at_utc')); if (accessToken == null || expiresAtText == null) { return null; } - final refreshToken = await _storage.read( - key: _key(accountId, 'refresh_token'), - ); - final tokenType = await _storage.read(key: _key(accountId, 'token_type')); - final scopeText = await _storage.read(key: _key(accountId, 'scope')); - final idToken = await _storage.read(key: _key(accountId, 'id_token')); + final refreshToken = await _read(_key(accountId, 'refresh_token')); + final tokenType = await _read(_key(accountId, 'token_type')); + final scopeText = await _read(_key(accountId, 'scope')); + final idToken = await _read(_key(accountId, 'id_token')); return OAuthTokenSet( accessToken: accessToken, @@ -60,58 +62,104 @@ class SecureOAuthTokenStore implements OAuthTokenStore { @override Future saveTokenSet(String accountId, OAuthTokenSet tokenSet) async { - await _storage.write( - key: _key(accountId, 'access_token'), - value: tokenSet.accessToken, - ); + await _write(_key(accountId, 'access_token'), tokenSet.accessToken); if (tokenSet.refreshToken != null) { - await _storage.write( - key: _key(accountId, 'refresh_token'), - value: tokenSet.refreshToken, - ); + await _write(_key(accountId, 'refresh_token'), tokenSet.refreshToken); } if (tokenSet.idToken != null) { - await _storage.write( - key: _key(accountId, 'id_token'), - value: tokenSet.idToken, - ); + await _write(_key(accountId, 'id_token'), tokenSet.idToken); } - await _storage.write( - key: _key(accountId, 'expires_at_utc'), - value: tokenSet.expiresAtUtc.toUtc().toIso8601String(), - ); - await _storage.write( - key: _key(accountId, 'token_type'), - value: tokenSet.tokenType, - ); - await _storage.write( - key: _key(accountId, 'scope'), - value: tokenSet.scopes.join(' '), + await _write( + _key(accountId, 'expires_at_utc'), + tokenSet.expiresAtUtc.toUtc().toIso8601String(), ); + await _write(_key(accountId, 'token_type'), tokenSet.tokenType); + await _write(_key(accountId, 'scope'), tokenSet.scopes.join(' ')); } @override Future setActiveAccountId(String accountId) { - return _storage.write(key: activeAccountKey, value: accountId); + return _write(activeAccountKey, accountId); } @override Future clearTokenSet(String accountId) async { - await _storage.delete(key: _key(accountId, 'access_token')); - await _storage.delete(key: _key(accountId, 'refresh_token')); - await _storage.delete(key: _key(accountId, 'id_token')); - await _storage.delete(key: _key(accountId, 'expires_at_utc')); - await _storage.delete(key: _key(accountId, 'token_type')); - await _storage.delete(key: _key(accountId, 'scope')); + await _delete(_key(accountId, 'access_token')); + await _delete(_key(accountId, 'refresh_token')); + await _delete(_key(accountId, 'id_token')); + await _delete(_key(accountId, 'expires_at_utc')); + await _delete(_key(accountId, 'token_type')); + await _delete(_key(accountId, 'scope')); } @override Future clearActiveAccount() { - return _storage.delete(key: activeAccountKey); + return _delete(activeAccountKey); } String _key(String accountId, String name) => 'busymax.oauth.$accountId.$name'; + + Future _read(String key) async { + _logRuntime(); + try { + return await _storage.read(key: key); + } on PlatformException catch (error) { + throw _secureStorageException('read', error); + } + } + + Future _write(String key, String? value) async { + _logRuntime(); + try { + await _storage.write(key: key, value: value); + } on PlatformException catch (error) { + throw _secureStorageException('write', error); + } + } + + Future _delete(String key) async { + _logRuntime(); + try { + await _storage.delete(key: key); + } on PlatformException catch (error) { + throw _secureStorageException('delete', error); + } + } + + void _logRuntime() { + if (_loggedRuntime) { + return; + } + _loggedRuntime = true; + _logger.info( + 'Secure token storage runtime: backend=flutter-secure-storage ' + 'snap=${_isRunningInSnap()} secret_backend=${_secretBackendLabel()}', + ); + } + + OAuthException _secureStorageException( + String operation, + PlatformException error, + ) { + _logger.warning( + 'Secure token storage $operation failed: ' + '${sanitizedFlutterSecureStorageError(error)}', + ); + return const OAuthException( + 'OAuthSecureStorageUnavailable', + secureTokenStorageUnavailableMessage, + ); + } +} + +const secureTokenStorageUnavailableMessage = + 'Secure token storage is locked. Unlock your system keyring and try again.'; + +String sanitizedFlutterSecureStorageError(PlatformException error) { + final message = redactForLog(error.message).replaceAll(RegExp(r'\s+'), ' '); + return 'domain=flutter_secure_storage_linux code=${error.code} ' + 'message=${message.trim()} details_type=${error.details.runtimeType}'; } class InMemoryOAuthTokenStore implements OAuthTokenStore { @@ -146,3 +194,16 @@ class InMemoryOAuthTokenStore implements OAuthTokenStore { _activeAccountId = accountId; } } + +bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; + +String _secretBackendLabel() { + final backend = Platform.environment['SECRET_BACKEND']; + if (backend == null || backend.isEmpty) { + return ''; + } + if (backend == 'file') { + return 'file'; + } + return ''; +} diff --git a/lib/src/google_tasks/oauth/portal_encrypted_oauth_token_store.dart b/lib/src/google_tasks/oauth/portal_encrypted_oauth_token_store.dart new file mode 100644 index 0000000..7a111f6 --- /dev/null +++ b/lib/src/google_tasks/oauth/portal_encrypted_oauth_token_store.dart @@ -0,0 +1,516 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:cryptography/cryptography.dart'; +import 'package:dbus/dbus.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; + +import '../../core/logging/redacting_logger.dart'; +import 'oauth_models.dart'; +import 'oauth_token_store.dart'; + +const _encryptedTokenStoreVersion = 1; + +class PortalEncryptedOAuthTokenStore implements OAuthTokenStore { + PortalEncryptedOAuthTokenStore({ + SecretPortalClient? portalClient, + File? storageFile, + RedactingLogger? logger, + }) : _portalClient = portalClient ?? XdgSecretPortalClient(), + _storageFile = storageFile ?? _defaultStorageFile(), + _logger = + logger ?? RedactingLogger(Logger('PortalEncryptedOAuthTokenStore')); + + final SecretPortalClient _portalClient; + final File _storageFile; + final RedactingLogger _logger; + final AesGcm _cipher = AesGcm.with256bits(); + final Hkdf _kdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32); + PortalSecret? _cachedSecret; + var _loggedRuntime = false; + + static const _activeAccountKey = SecureOAuthTokenStore.activeAccountKey; + static const _kdfInfo = 'io.busystack.busymax.oauth-token-store.v1'; + + @override + Future readActiveAccountId() => _read(_activeAccountKey); + + @override + Future readTokenSet(String accountId) async { + final accessToken = await _read(_key(accountId, 'access_token')); + final expiresAtText = await _read(_key(accountId, 'expires_at_utc')); + if (accessToken == null || expiresAtText == null) { + return null; + } + + final refreshToken = await _read(_key(accountId, 'refresh_token')); + final tokenType = await _read(_key(accountId, 'token_type')); + final scopeText = await _read(_key(accountId, 'scope')); + final idToken = await _read(_key(accountId, 'id_token')); + + return OAuthTokenSet( + accessToken: accessToken, + refreshToken: refreshToken, + idToken: idToken, + expiresAtUtc: DateTime.parse(expiresAtText).toUtc(), + tokenType: tokenType ?? 'Bearer', + scopes: (scopeText ?? '') + .split(RegExp(r'\s+')) + .where((scope) => scope.isNotEmpty) + .toSet(), + ); + } + + @override + Future saveTokenSet(String accountId, OAuthTokenSet tokenSet) async { + final values = await _readAll('write'); + values[_key(accountId, 'access_token')] = tokenSet.accessToken; + if (tokenSet.refreshToken != null) { + values[_key(accountId, 'refresh_token')] = tokenSet.refreshToken!; + } + if (tokenSet.idToken != null) { + values[_key(accountId, 'id_token')] = tokenSet.idToken!; + } + values[_key(accountId, 'expires_at_utc')] = tokenSet.expiresAtUtc + .toUtc() + .toIso8601String(); + values[_key(accountId, 'token_type')] = tokenSet.tokenType; + values[_key(accountId, 'scope')] = tokenSet.scopes.join(' '); + await _writeAll(values, 'write'); + } + + @override + Future setActiveAccountId(String accountId) { + return _write(_activeAccountKey, accountId); + } + + @override + Future clearTokenSet(String accountId) async { + final values = await _readAll('delete'); + final beforeLength = values.length; + values.remove(_key(accountId, 'access_token')); + values.remove(_key(accountId, 'refresh_token')); + values.remove(_key(accountId, 'id_token')); + values.remove(_key(accountId, 'expires_at_utc')); + values.remove(_key(accountId, 'token_type')); + values.remove(_key(accountId, 'scope')); + if (values.length == beforeLength) { + return; + } + await _writeAll(values, 'delete'); + } + + @override + Future clearActiveAccount() { + return _delete(_activeAccountKey); + } + + Future _read(String key) async { + final values = await _readAll('read'); + return values[key]; + } + + Future _write(String key, String value) async { + final values = await _readAll('write'); + values[key] = value; + await _writeAll(values, 'write'); + } + + Future _delete(String key) async { + final values = await _readAll('delete'); + if (!values.containsKey(key)) { + return; + } + values.remove(key); + await _writeAll(values, 'delete'); + } + + Future> _readAll(String operation) async { + _logRuntime(); + if (!await _storageFile.exists()) { + return {}; + } + + try { + final envelope = _asStringObjectMap( + jsonDecode(await _storageFile.readAsString()), + ); + final secret = await _retrieveSecret( + operation, + token: envelope['portal_token'], + ); + final salt = _decodeRequired(envelope, 'salt'); + final key = await _deriveKey(secret.bytes, salt); + final box = SecretBox( + _decodeRequired(envelope, 'ciphertext'), + nonce: _decodeRequired(envelope, 'nonce'), + mac: Mac(_decodeRequired(envelope, 'mac')), + ); + final clearBytes = await _cipher.decrypt(box, secretKey: key); + return _asStringMap(jsonDecode(utf8.decode(clearBytes))); + } on Object catch (error) { + if (error is OAuthException) { + rethrow; + } + throw _storageException(operation, error); + } + } + + Future _writeAll(Map values, String operation) async { + _logRuntime(); + try { + await _storageFile.parent.create(recursive: true); + final existing = await _readEnvelopeIfPresent(); + final salt = existing == null + ? _randomBytes(16) + : _decodeRequired(existing, 'salt'); + final secret = await _retrieveSecret( + operation, + token: existing?['portal_token'], + ); + final key = await _deriveKey(secret.bytes, salt); + final nonce = _cipher.newNonce(); + final box = await _cipher.encrypt( + utf8.encode(jsonEncode(values)), + secretKey: key, + nonce: nonce, + ); + final envelope = { + 'version': _encryptedTokenStoreVersion, + 'cipher': 'aes-256-gcm', + 'kdf': 'hkdf-sha256', + 'salt': base64Encode(salt), + 'nonce': base64Encode(box.nonce), + 'ciphertext': base64Encode(box.cipherText), + 'mac': base64Encode(box.mac.bytes), + if (secret.token != null && secret.token!.isNotEmpty) + 'portal_token': secret.token, + }; + final tempFile = File('${_storageFile.path}.tmp'); + await tempFile.writeAsString(jsonEncode(envelope), flush: true); + await tempFile.rename(_storageFile.path); + } on Object catch (error) { + if (error is OAuthException) { + rethrow; + } + throw _storageException(operation, error); + } + } + + Future?> _readEnvelopeIfPresent() async { + if (!await _storageFile.exists()) { + return null; + } + return _asStringObjectMap(jsonDecode(await _storageFile.readAsString())); + } + + Future _retrieveSecret( + String operation, { + String? token, + }) async { + final cached = _cachedSecret; + if (cached != null) { + return cached; + } + try { + final secret = await _portalClient.retrieveSecret(token: token); + _cachedSecret = secret; + _logger.info( + 'Secure token storage portal retrieve succeeded: ' + 'operation=$operation snap=${_isRunningInSnap()} ' + 'secret_backend=${_secretBackendLabel()} has_portal_token=${secret.token != null}', + ); + return secret; + } on Object catch (error) { + throw _storageException('portal.retrieveSecret/$operation', error); + } + } + + Future _deriveKey(List secret, List salt) { + return _kdf.deriveKey( + secretKey: SecretKey(secret), + nonce: salt, + info: utf8.encode(_kdfInfo), + ); + } + + void _logRuntime() { + if (_loggedRuntime) { + return; + } + _loggedRuntime = true; + _logger.info( + 'Secure token storage runtime: backend=xdg-secret-portal-file ' + 'snap=${_isRunningInSnap()} secret_backend=${_secretBackendLabel()}', + ); + } + + OAuthException _storageException(String operation, Object error) { + _logger.warning( + 'Secure token storage $operation failed: ' + '${sanitizedSecureStorageError(error)}', + ); + if (error is OAuthException) { + return error; + } + return const OAuthException( + 'OAuthSecureStorageUnavailable', + secureTokenStorageUnavailableMessage, + ); + } + + String _key(String accountId, String name) => + 'busymax.oauth.$accountId.$name'; +} + +abstract interface class SecretPortalClient { + Future retrieveSecret({String? token}); +} + +class PortalSecret { + const PortalSecret({required this.bytes, this.token}); + + final List bytes; + final String? token; +} + +class XdgSecretPortalClient implements SecretPortalClient { + XdgSecretPortalClient({DBusClient? client, Duration? timeout}) + : _client = client, + _timeout = timeout ?? const Duration(minutes: 2); + + final DBusClient? _client; + final Duration _timeout; + + @override + Future retrieveSecret({String? token}) async { + final ownsClient = _client == null; + final client = _client ?? DBusClient.session(); + Directory? tempDir; + RandomAccessFile? fd; + try { + await client.listNames(); + final handleToken = _requestToken(); + final sender = _portalSenderName(client.uniqueName); + final expectedPath = DBusObjectPath( + '/org/freedesktop/portal/desktop/request/$sender/$handleToken', + ); + final requestObject = DBusRemoteObject( + client, + name: 'org.freedesktop.portal.Desktop', + path: expectedPath, + ); + final responseFuture = DBusRemoteObjectSignalStream( + object: requestObject, + interface: 'org.freedesktop.portal.Request', + name: 'Response', + signature: DBusSignature('ua{sv}'), + ).first.timeout(_timeout); + + tempDir = await Directory.systemTemp.createTemp('busymax-secret-portal-'); + final secretFile = File(p.join(tempDir.path, 'secret')); + fd = await secretFile.open(mode: FileMode.write); + + final portalObject = DBusRemoteObject( + client, + name: 'org.freedesktop.portal.Desktop', + path: DBusObjectPath('/org/freedesktop/portal/desktop'), + ); + final options = { + 'handle_token': DBusString(handleToken), + if (token != null && token.isNotEmpty) 'token': DBusString(token), + }; + final response = await portalObject.callMethod( + 'org.freedesktop.portal.Secret', + 'RetrieveSecret', + [ + DBusUnixFd(ResourceHandle.fromFile(fd)), + DBusDict.stringVariant(options), + ], + replySignature: DBusSignature('o'), + ); + final returnedPath = response.returnValues[0].asObjectPath(); + final DBusSignal signal; + if (returnedPath.value == expectedPath.value) { + signal = await responseFuture; + } else { + unawaited( + responseFuture.catchError( + (Object _) => DBusSignal( + sender: null, + path: expectedPath, + interface: 'org.freedesktop.portal.Request', + name: 'Response', + ), + ), + ); + signal = await DBusRemoteObjectSignalStream( + object: DBusRemoteObject( + client, + name: 'org.freedesktop.portal.Desktop', + path: returnedPath, + ), + interface: 'org.freedesktop.portal.Request', + name: 'Response', + signature: DBusSignature('ua{sv}'), + ).first.timeout(_timeout); + } + + final responseCode = signal.values[0].asUint32(); + final results = signal.values[1].asStringVariantDict(); + if (responseCode == 1) { + throw const SecretPortalException( + code: 'PortalUserCancelled', + message: 'The Secret portal request was cancelled.', + ); + } + if (responseCode != 0) { + throw SecretPortalException( + code: 'PortalResponse$responseCode', + message: 'The Secret portal did not return a secret.', + ); + } + + await fd.close(); + fd = null; + final secretBytes = await secretFile.readAsBytes(); + if (secretBytes.isEmpty) { + throw const SecretPortalException( + code: 'PortalEmptySecret', + message: 'The Secret portal returned an empty secret.', + ); + } + return PortalSecret( + bytes: secretBytes, + token: results['token']?.asString(), + ); + } finally { + await fd?.close(); + await tempDir?.delete(recursive: true); + if (ownsClient) { + await client.close(); + } + } + } +} + +class SecretPortalException implements Exception { + const SecretPortalException({required this.code, required this.message}); + + final String code; + final String message; + + @override + String toString() => '$code: $message'; +} + +String sanitizedSecureStorageError(Object error) { + if (error is DBusMethodResponseException) { + return 'domain=dbus code=${error.errorName} ' + 'message=${_sanitize(error.response.values.isEmpty ? '' : error.response.values.first.toNative())}'; + } + if (error is SecretPortalException) { + return 'domain=org.freedesktop.portal.Secret code=${error.code} ' + 'message=${_sanitize(error.message)}'; + } + if (error is FormatException) { + return 'domain=dart code=FormatException message=${_sanitize(error.message)}'; + } + if (error is SecretBoxAuthenticationError) { + return 'domain=cryptography code=SecretBoxAuthenticationError ' + 'message=encrypted token store authentication failed'; + } + return 'domain=dart code=${error.runtimeType} message=${_sanitize(error)}'; +} + +File _defaultStorageFile() { + final dataHome = + Platform.environment['XDG_DATA_HOME'] ?? + p.join(Platform.environment['HOME'] ?? '.', '.local', 'share'); + return File(p.join(dataHome, 'busymax', 'oauth-tokens.v1.json')); +} + +Map _asStringMap(Object? value) { + if (value is! Map) { + throw const FormatException('Encrypted token store payload is not a map.'); + } + return value.map((key, value) { + if (key is! String || value is! String) { + throw const FormatException( + 'Encrypted token store payload has invalid entries.', + ); + } + return MapEntry(key, value); + }); +} + +Map _asStringObjectMap(Object? value) { + if (value is! Map) { + throw const FormatException('Encrypted token store envelope is not a map.'); + } + final version = value['version']; + if (version != _encryptedTokenStoreVersion) { + throw FormatException( + 'Unsupported encrypted token store version $version.', + ); + } + return value.map((key, value) { + if (key is! String) { + throw const FormatException( + 'Encrypted token store envelope has invalid keys.', + ); + } + return MapEntry(key, value?.toString() ?? ''); + }); +} + +List _decodeRequired(Map envelope, String key) { + final value = envelope[key]; + if (value == null || value.isEmpty) { + throw FormatException('Encrypted token store missing $key.'); + } + return base64Decode(value); +} + +List _randomBytes(int length) { + final random = Random.secure(); + return List.generate(length, (_) => random.nextInt(256)); +} + +String _requestToken() { + final bytes = _randomBytes(16); + final hex = bytes + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + return 'busymax_$hex'; +} + +String generatePortalRequestTokenForTesting() => _requestToken(); + +String _portalSenderName(String uniqueName) { + final trimmed = uniqueName.startsWith(':') + ? uniqueName.substring(1) + : uniqueName; + return trimmed.replaceAll('.', '_'); +} + +bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; + +String _secretBackendLabel() { + final backend = Platform.environment['SECRET_BACKEND']; + if (backend == null || backend.isEmpty) { + return ''; + } + if (backend == 'file') { + return 'file'; + } + return ''; +} + +String _sanitize(Object? value) { + final text = redactForLog(value); + return text.replaceAll(RegExp(r'\s+'), ' ').trim(); +} diff --git a/lib/src/platform/busymax_tray_service.dart b/lib/src/platform/busymax_tray_service.dart index 736a1d2..37e53d3 100644 --- a/lib/src/platform/busymax_tray_service.dart +++ b/lib/src/platform/busymax_tray_service.dart @@ -1,11 +1,19 @@ import 'dart:async'; import 'dart:io'; +import 'package:dbus/dbus.dart'; +import 'package:logging/logging.dart'; import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; +import '../core/logging/redacting_logger.dart'; import 'linux_window_service.dart'; const String busyMaxApplicationId = 'io.busystack.busymax'; +const String busyMaxTrayMenuPath = '/StatusNotifierItem/menu'; +const int _busyMaxTrayRootMenuId = 0; +const int _busyMaxTrayOpenMenuId = 1; +const int _busyMaxTrayAgendaMenuId = 2; +const int _busyMaxTrayQuitMenuId = 3; class BusyMaxTrayLabels { const BusyMaxTrayLabels({ @@ -45,6 +53,7 @@ class BusyMaxTrayService { final Future Function() _onOpenAgenda; final Future Function()? _onBeforeQuit; BusyMaxTrayLabels _labels; + final RedactingLogger _logger = RedactingLogger(Logger('BusyMaxTrayService')); StatusNotifierItemClient? _client; bool _available = false; @@ -52,27 +61,87 @@ class BusyMaxTrayService { bool get available => _available; Future start() async { + _logger.fine('Tray service start requested: snap=${_isRunningInSnap()}'); if (_client != null) { + _logger.fine('Tray initialization skipped: existing_client=true'); return; } + final iconName = _trayIconName(); + _logger.fine( + 'DBus menu creation starting: path=$busyMaxTrayMenuPath ' + 'root_id=$_busyMaxTrayRootMenuId ' + 'open_id=$_busyMaxTrayOpenMenuId agenda_id=$_busyMaxTrayAgendaMenuId ' + 'quit_id=$_busyMaxTrayQuitMenuId', + ); + final menu = buildBusyMaxTrayMenu( + labels: _labels, + onOpenBusyMax: _show, + onOpenAgenda: _showAgenda, + onQuit: _quit, + ); + _logger.fine( + 'DBus menu creation completed: path=$busyMaxTrayMenuPath ' + 'items=${menu.children.length} ' + 'ids=${[_busyMaxTrayOpenMenuId, _busyMaxTrayAgendaMenuId, _busyMaxTrayQuitMenuId].join(',')}', + ); + _logger.fine( + 'Tray initialization starting: snap=${_isRunningInSnap()} ' + 'icon=${_sanitizeIconForLog(iconName)} menu_items=${menu.children.length}', + ); final client = StatusNotifierItemClient( id: busyMaxApplicationId, title: 'BusyMax', - iconName: _trayIconName(), - menu: buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: _show, - onOpenAgenda: _showAgenda, - onQuit: _quit, - ), - onActivate: (_, _) => _show(), + iconName: iconName, + itemIsMenu: true, + menuPath: DBusObjectPath(busyMaxTrayMenuPath), + menu: menu, + diagnosticLog: (message) => + _logger.fine(_sanitizeForLog('StatusNotifier diagnostic: $message')), + onContextMenu: (x, y) async { + _logger.fine( + 'Tray context menu callback fired: snap=${_isRunningInSnap()} ' + 'x=$x y=$y fallback=showWindow', + ); + await _showFromStatusNotifierActivation( + action: 'StatusNotifierItem context menu callback', + ); + }, + onActivate: (x, y) { + _logger.fine( + 'Tray activation callback fired: snap=${_isRunningInSnap()} ' + 'x=$x y=$y', + ); + return _showFromStatusNotifierActivation( + action: 'StatusNotifierItem activation callback', + ); + }, + onSecondaryActivate: (x, y) async { + _logger.fine( + 'Tray secondary activation callback fired: snap=${_isRunningInSnap()} ' + 'x=$x y=$y fallback=showWindow', + ); + await _showFromStatusNotifierActivation( + action: 'StatusNotifierItem secondary activation callback', + ); + }, ); try { await client.connect(); _client = client; _available = true; - } on Object { + final menuVerified = await _verifyMenuExported(); + _logger.fine( + 'StatusNotifier registration succeeded: snap=${_isRunningInSnap()} ' + 'bus_name=${client.busName} item_path=${client.itemPath.value} ' + 'menu_path=${client.menuPath.value} menu_exported=$menuVerified ' + 'menu_items=${menu.children.length}', + ); + } on Object catch (error) { _available = false; + _logger.warning( + 'StatusNotifier registration failed: snap=${_isRunningInSnap()} ' + 'error=${_sanitizeForLog(error)}', + ); await client.close(); } } @@ -89,6 +158,7 @@ class BusyMaxTrayService { return; } _labels = labels; + _logger.fine('Tray menu labels updating: menu_items=3'); await _updateMenu(); } @@ -97,17 +167,30 @@ class BusyMaxTrayService { if (client == null) { return; } - await client.updateMenu( - buildBusyMaxTrayMenu( - labels: _labels, - onOpenBusyMax: _show, - onOpenAgenda: _showAgenda, - onQuit: _quit, - ), + final menu = buildBusyMaxTrayMenu( + labels: _labels, + onOpenBusyMax: _show, + onOpenAgenda: _showAgenda, + onQuit: _quit, + ); + await client.updateMenu(menu); + _logger.fine( + 'Tray menu update completed: menu_items=${menu.children.length}', + ); + } + + Future _showFromStatusNotifierActivation({required String action}) { + return _runLoggedTrayAction( + logger: _logger, + action: action, + callback: _show, ); } Future _show() { + _logger.fine( + 'Tray restore requested: action=showWindow target=main_window', + ); return _windowService.showWindow(); } @@ -116,10 +199,68 @@ class BusyMaxTrayService { } Future _quit() async { + _logger.fine('Tray quit requested: action=quitApp'); await _onBeforeQuit?.call(); - await stop(); + unawaited(_stopAfterQuitRequest()); await _windowService.quitApp(); } + + Future _stopAfterQuitRequest() async { + try { + await stop(); + _logger.fine('Tray client close completed after quit request'); + } on Object catch (error) { + _logger.warning( + 'Tray client close failed after quit request: ' + 'error=${_sanitizeForLog(error)}', + ); + } + } + + Future _verifyMenuExported() async { + final bus = DBusClient.session(); + try { + final response = await bus + .callMethod( + destination: 'org.kde.StatusNotifierItem-$pid-1', + path: DBusObjectPath(busyMaxTrayMenuPath), + interface: 'com.canonical.dbusmenu', + name: 'GetLayout', + values: [ + const DBusInt32(0), + const DBusInt32(-1), + DBusArray.string(const []), + ], + replySignature: DBusSignature('u(ia{sv}av)'), + ) + .timeout(const Duration(seconds: 2)); + final layout = response.returnValues[1].asStruct(); + final children = layout.length >= 3 ? layout[2].asArray() : []; + final childIds = children + .map((child) => _asMenuLayoutStruct(child)[0].asInt32()) + .join(','); + _logger.fine( + 'Tray menu DBus verification succeeded: path=$busyMaxTrayMenuPath ' + 'children=${children.length} child_ids=$childIds', + ); + return children.isNotEmpty; + } on Object catch (error) { + _logger.warning( + 'Tray menu DBus verification failed: path=$busyMaxTrayMenuPath ' + 'error=${_sanitizeForLog(error)}', + ); + return false; + } finally { + await bus.close(); + } + } +} + +List _asMenuLayoutStruct(DBusValue value) { + if (value.signature == DBusSignature('v')) { + return value.asVariant().asStruct(); + } + return value.asStruct(); } DBusMenuItem buildBusyMaxTrayMenu({ @@ -129,14 +270,66 @@ DBusMenuItem buildBusyMaxTrayMenu({ required Future Function() onQuit, }) { return DBusMenuItem( + id: _busyMaxTrayRootMenuId, + enabled: true, + visible: true, children: [ - DBusMenuItem(label: labels.openBusyMax, onClicked: onOpenBusyMax), - DBusMenuItem(label: labels.agenda, onClicked: onOpenAgenda), - DBusMenuItem(label: labels.quitBusyMax, onClicked: onQuit), + DBusMenuItem( + id: _busyMaxTrayOpenMenuId, + enabled: true, + visible: true, + label: labels.openBusyMax, + onClicked: () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray menu "Open BusyMax" callback', + callback: onOpenBusyMax, + ), + ), + DBusMenuItem( + id: _busyMaxTrayAgendaMenuId, + enabled: true, + visible: true, + label: labels.agenda, + onClicked: () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray menu "Agenda" callback', + callback: onOpenAgenda, + ), + ), + DBusMenuItem( + id: _busyMaxTrayQuitMenuId, + enabled: true, + visible: true, + label: labels.quitBusyMax, + onClicked: () => _runLoggedTrayAction( + logger: RedactingLogger(Logger('BusyMaxTrayService')), + action: 'Tray menu "Quit" callback', + callback: onQuit, + ), + ), ], ); } +Future _runLoggedTrayAction({ + required RedactingLogger logger, + required String action, + required Future Function() callback, +}) async { + logger.fine( + 'Tray callback fired: action="$action" snap=${_isRunningInSnap()}', + ); + try { + await callback(); + logger.fine('Tray callback completed: action="$action"'); + } on Object catch (error) { + logger.warning( + 'Tray callback failed: action="$action" error=${_sanitizeForLog(error)}', + ); + rethrow; + } +} + String _trayIconName() { final executableDir = File(Platform.resolvedExecutable).parent; final bundledLogo = File( @@ -147,3 +340,17 @@ String _trayIconName() { } return busyMaxApplicationId; } + +bool _isRunningInSnap() => Platform.environment['SNAP']?.isNotEmpty ?? false; + +String _sanitizeForLog(Object? value) { + return redactForLog(value).replaceAll(RegExp(r'\s+'), ' ').trim(); +} + +String _sanitizeIconForLog(String iconName) { + if (iconName == busyMaxApplicationId) { + return busyMaxApplicationId; + } + final basename = iconName.split(Platform.pathSeparator).last; + return basename.isEmpty ? '' : basename; +} diff --git a/lib/src/platform/compact_agenda_window_service.dart b/lib/src/platform/compact_agenda_window_service.dart index ac322f6..250c3f0 100644 --- a/lib/src/platform/compact_agenda_window_service.dart +++ b/lib/src/platform/compact_agenda_window_service.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/widgets.dart'; +import 'package:logging/logging.dart'; import 'package:screen_retriever/screen_retriever.dart'; import 'busymax_window_args.dart'; @@ -8,12 +11,24 @@ const _compactAgendaWindowWidth = 420.0; const _compactAgendaWindowHeight = 680.0; const _compactAgendaWindowShadowMargin = 32.0; const _compactAgendaPanelScreenGap = 6.0; +const _compactAgendaWindowFrameWidth = + _compactAgendaWindowWidth + _compactAgendaWindowShadowMargin * 2; +const _compactAgendaWindowFrameHeight = + _compactAgendaWindowHeight + _compactAgendaWindowShadowMargin * 2; + +@visibleForTesting +Offset compactAgendaTopRightWorkAreaPositionForTest(Rect workarea) { + return _topRightWorkAreaPlacement(workarea).finalPosition; +} class CompactAgendaWindowService { const CompactAgendaWindowService(); + static final Logger _logger = Logger('CompactAgendaWindowService'); + Future toggle() async { final position = await _preferredCompactAgendaPosition(); + _logPlacementRequest('toggle', position); final controller = await _findCompactAgendaWindow(); if (controller == null) { await _createCompactAgendaWindow(position); @@ -28,6 +43,7 @@ class CompactAgendaWindowService { Future show() async { final position = await _preferredCompactAgendaPosition(); + _logPlacementRequest('show', position); final controller = await _findCompactAgendaWindow(); if (controller == null) { await _createCompactAgendaWindow(position); @@ -57,7 +73,12 @@ class CompactAgendaWindowService { } Future _findCompactAgendaWindow() async { - final controllers = await WindowController.getAll(); + final List controllers; + try { + controllers = await WindowController.getAll(); + } on Object { + return null; + } for (final controller in controllers) { final args = BusyMaxWindowArgs.parse(controller.arguments); if (args.kind == BusyMaxWindowKind.compactAgenda) { @@ -68,6 +89,10 @@ class CompactAgendaWindowService { } Future _createCompactAgendaWindow(Offset position) async { + _logger.fine( + 'Compact agenda create requested: final_x=${position.dx.round()} ' + 'final_y=${position.dy.round()} ${_sessionDescription()}', + ); await WindowController.create( WindowConfiguration( arguments: BusyMaxWindowArgs.compactAgendaAt( @@ -92,9 +117,19 @@ class CompactAgendaWindowService { method, _positionMethodArguments(position), ); + _logger.fine( + 'Compact agenda native position invocation succeeded: method=$method ' + 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' + '${_sessionDescription()}', + ); return; - } on Object { + } on Object catch (error) { if (attempt == attempts - 1) { + _logger.warning( + 'Compact agenda native position invocation failed: method=$method ' + 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' + 'error=$error ${_sessionDescription()}', + ); return; } await Future.delayed(retryDelay); @@ -119,22 +154,43 @@ class CompactAgendaWindowService { final displays = await screenRetriever.getAllDisplays(); final cursor = await screenRetriever.getCursorScreenPoint(); final display = displays.firstWhere((display) { - final position = display.visiblePosition ?? Offset.zero; - final size = display.visibleSize ?? display.size; - return Rect.fromLTWH( - position.dx, - position.dy, - size.width, - size.height, - ).contains(cursor); + final frame = _visibleFrame(display); + return frame.contains(cursor); }, orElse: () => primaryDisplay); - return _topRightWorkAreaPosition(display); - } on Object { + final placement = _topRightWorkAreaPlacement(_visibleFrame(display)); + _logger.fine( + 'Compact agenda monitor placement resolved: cursor_x=${cursor.dx.round()} ' + 'cursor_y=${cursor.dy.round()} workarea=${_displayGeometry(display)} ' + 'raw_x=${placement.rawPosition.dx.round()} ' + 'raw_y=${placement.rawPosition.dy.round()} ' + 'final_x=${placement.finalPosition.dx.round()} ' + 'final_y=${placement.finalPosition.dy.round()} ' + 'window_width=${_compactAgendaWindowFrameWidth.round()} ' + 'window_height=${_compactAgendaWindowFrameHeight.round()} ' + '${_sessionDescription()}', + ); + return placement.finalPosition; + } on Object catch (error) { try { - return _topRightWorkAreaPosition( - await screenRetriever.getPrimaryDisplay(), + final display = await screenRetriever.getPrimaryDisplay(); + final placement = _topRightWorkAreaPlacement(_visibleFrame(display)); + _logger.warning( + 'Compact agenda cursor placement fallback used: ' + 'workarea=${_displayGeometry(display)} ' + 'raw_x=${placement.rawPosition.dx.round()} ' + 'raw_y=${placement.rawPosition.dy.round()} ' + 'final_x=${placement.finalPosition.dx.round()} ' + 'final_y=${placement.finalPosition.dy.round()} ' + 'window_width=${_compactAgendaWindowFrameWidth.round()} ' + 'window_height=${_compactAgendaWindowFrameHeight.round()} ' + 'error=$error ${_sessionDescription()}', + ); + return placement.finalPosition; + } on Object catch (fallbackError) { + _logger.warning( + 'Compact agenda placement fallback failed: error=$fallbackError ' + '${_sessionDescription()}', ); - } on Object { return Offset.zero; } } @@ -146,41 +202,85 @@ class CompactAgendaWindowService { }; } - Offset _topRightWorkAreaPosition(Display display) { + Rect _visibleFrame(Display display) { final visiblePosition = display.visiblePosition ?? Offset.zero; final visibleSize = display.visibleSize ?? display.size; - final visibleFrame = Rect.fromLTWH( + return Rect.fromLTWH( visiblePosition.dx, visiblePosition.dy, visibleSize.width, visibleSize.height, ); - final panelLeft = _clampToVisibleFrame( - visibleFrame.right - - _compactAgendaWindowWidth - - _compactAgendaPanelScreenGap, - visibleFrame.left + _compactAgendaPanelScreenGap, - visibleFrame.right - - _compactAgendaWindowWidth - - _compactAgendaPanelScreenGap, - ); - final panelTop = _clampToVisibleFrame( - visibleFrame.top + _compactAgendaPanelScreenGap, - visibleFrame.top + _compactAgendaPanelScreenGap, - visibleFrame.bottom - - _compactAgendaWindowHeight - - _compactAgendaPanelScreenGap, - ); - return Offset( - panelLeft - _compactAgendaWindowShadowMargin, - panelTop - _compactAgendaWindowShadowMargin, + } + + void _logPlacementRequest(String action, Offset position) { + _logger.fine( + 'Compact agenda placement requested: action=$action ' + 'final_x=${position.dx.round()} final_y=${position.dy.round()} ' + 'window_width=${_compactAgendaWindowFrameWidth.round()} ' + 'window_height=${_compactAgendaWindowFrameHeight.round()} ' + '${_sessionDescription()}', ); } - double _clampToVisibleFrame(double value, double min, double max) { - if (max < min) { - return min; - } - return value.clamp(min, max).toDouble(); + String _displayGeometry(Display display) { + final frame = _visibleFrame(display); + return '${frame.left.round()},${frame.top.round()},' + '${frame.width.round()}x${frame.height.round()}'; + } + + static String _sessionDescription() { + final session = Platform.environment['XDG_SESSION_TYPE'] ?? ''; + final backend = Platform.environment['GDK_BACKEND'] ?? ''; + return 'session=$session gdk_backend=$backend'; + } +} + +_CompactAgendaPlacement _topRightWorkAreaPlacement(Rect workarea) { + final rawPosition = Offset( + workarea.right - + _compactAgendaWindowFrameWidth - + _compactAgendaPanelScreenGap, + workarea.top + _compactAgendaPanelScreenGap, + ); + return _CompactAgendaPlacement( + rawPosition: rawPosition, + finalPosition: _clampWindowPositionToWorkArea(rawPosition, workarea), + ); +} + +Offset _clampWindowPositionToWorkArea(Offset position, Rect workarea) { + return Offset( + _clampToVisibleFrame( + position.dx, + workarea.left + _compactAgendaPanelScreenGap, + workarea.right - + _compactAgendaWindowFrameWidth - + _compactAgendaPanelScreenGap, + ), + _clampToVisibleFrame( + position.dy, + workarea.top + _compactAgendaPanelScreenGap, + workarea.bottom - + _compactAgendaWindowFrameHeight - + _compactAgendaPanelScreenGap, + ), + ); +} + +double _clampToVisibleFrame(double value, double min, double max) { + if (max < min) { + return min; } + return value.clamp(min, max).toDouble(); +} + +class _CompactAgendaPlacement { + const _CompactAgendaPlacement({ + required this.rawPosition, + required this.finalPosition, + }); + + final Offset rawPosition; + final Offset finalPosition; } diff --git a/lib/src/platform/main_window_command_bridge.dart b/lib/src/platform/main_window_command_bridge.dart index 9e21d24..c0cec67 100644 --- a/lib/src/platform/main_window_command_bridge.dart +++ b/lib/src/platform/main_window_command_bridge.dart @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/app_bootstrap.dart'; import '../app/app_router.dart'; +import '../features/schedule/application/compact_agenda_data.dart'; +import '../features/schedule/application/compact_agenda_snapshot.dart'; import '../schedule/schedule_commands.dart'; import 'main_window_command_client.dart'; @@ -47,6 +49,8 @@ class _MainWindowCommandBridgeState case 'busymax.main.refreshAll': await ref.read(allAccountsSyncRunnerProvider)(); return true; + case 'busymax.main.compactAgendaSnapshot': + return _compactAgendaSnapshot(call.arguments); case 'busymax.main.requestTaskSync': return _requestTaskSync(call.arguments); case 'busymax.main.requestCalendarSync': @@ -111,6 +115,14 @@ class _MainWindowCommandBridgeState return true; } + Future> _compactAgendaSnapshot(Object? rawArgs) async { + final query = decodeCompactAgendaQuery(rawArgs); + final data = await ref.read( + compactAgendaDataForQueryProvider(query).future, + ); + return encodeCompactAgendaData(data); + } + Future _requestTaskSync(Object? rawArgs) async { if (rawArgs is! Map) { return false; diff --git a/lib/src/platform/main_window_command_client.dart b/lib/src/platform/main_window_command_client.dart index ad24792..073f307 100644 --- a/lib/src/platform/main_window_command_client.dart +++ b/lib/src/platform/main_window_command_client.dart @@ -1,5 +1,7 @@ import 'package:desktop_multi_window/desktop_multi_window.dart'; +import '../features/schedule/application/compact_agenda_data.dart'; +import '../features/schedule/application/compact_agenda_snapshot.dart'; import '../schedule/schedule_item.dart'; import '../schedule/schedule_projection.dart'; @@ -41,6 +43,16 @@ class MainWindowCommandClient { ); } + Future compactAgendaSnapshot( + CompactAgendaQuery query, + ) async { + final response = await busyMaxMainWindowChannel.invokeMethod( + 'busymax.main.compactAgendaSnapshot', + encodeCompactAgendaQuery(query), + ); + return decodeCompactAgendaData(response); + } + Future requestTaskSync(String accountId) async { await busyMaxMainWindowChannel.invokeMethod( 'busymax.main.requestTaskSync', diff --git a/pubspec.lock b/pubspec.lock index be86215..ad7d353 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -217,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cryptography: + dependency: "direct main" + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" dart_style: dependency: transitive description: @@ -226,7 +234,7 @@ packages: source: hosted version: "3.1.7" dbus: - dependency: transitive + dependency: "direct main" description: name: dbus sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" @@ -1284,10 +1292,9 @@ packages: xdg_status_notifier_item: dependency: "direct main" description: - name: xdg_status_notifier_item - sha256: a9e045026621356af44659662410f4f8bb9be31694a69887bde28ae51b46ddc7 - url: "https://pub.dev" - source: hosted + path: "third_party/xdg_status_notifier_item" + relative: true + source: path version: "0.0.1" xml: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index 18187c0..962faf9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.0 + cryptography: ^2.9.0 + dbus: ^0.7.14 desktop_multi_window: ^0.3.0 desktop_notifications: ^0.6.3 drift: ^2.33.0 @@ -51,6 +53,10 @@ dev_dependencies: mocktail: ^1.0.0 very_good_analysis: ^10.1.0 +dependency_overrides: + xdg_status_notifier_item: + path: third_party/xdg_status_notifier_item + flutter: generate: true uses-material-design: true diff --git a/test/app/app_bootstrap_provider_test.dart b/test/app/app_bootstrap_provider_test.dart index 2ed8d76..c7c24ed 100644 --- a/test/app/app_bootstrap_provider_test.dart +++ b/test/app/app_bootstrap_provider_test.dart @@ -6,10 +6,12 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:busymax/src/app/app_bootstrap.dart'; import 'package:busymax/src/config/build_config.dart'; import 'package:busymax/src/db/app_database.dart'; +import 'package:busymax/src/features/accounts/data/accounts_repository.dart'; import 'package:busymax/src/features/auth/data/auth_repository.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; void main() { test('repositories are not created without an active account', () async { @@ -82,11 +84,12 @@ void main() { 'loaded session starts incremental sync without circular provider reads', () async { final database = AppDatabase(NativeDatabase.memory()); + await _seedSignedInGoogleAccount(database); final syncStarted = Completer(); final syncCalls = <_SyncCall>[]; final container = _container( database: database, - oAuth: _FakeOAuthGateway()..activeId = 'account-1', + oAuth: _FakeOAuthGateway(), signedInSyncRunner: (accountId, initial) async { syncCalls.add(_SyncCall(accountId, initial)); if (!syncStarted.isCompleted) { @@ -152,6 +155,17 @@ void main() { }); } +Future _seedSignedInGoogleAccount(AppDatabase database) { + return AccountsRepository( + database: database, + nowUtc: () => DateTime.utc(2026, 6, 4), + ).upsertSignedInAccount( + id: 'account-1', + provider: TaskProvider.google, + grantedScopes: googleBusyMaxOAuthScopes.join(' '), + ); +} + ProviderContainer _container({ required AppDatabase database, required _FakeOAuthGateway oAuth, diff --git a/test/app/native_ui_audit_test.dart b/test/app/native_ui_audit_test.dart index 6a368d6..8d950b1 100644 --- a/test/app/native_ui_audit_test.dart +++ b/test/app/native_ui_audit_test.dart @@ -144,7 +144,7 @@ void main() { expect(logo, isNot(contains('viewBox="254 120 232 272"'))); expect(source, isNot(contains('BusyMaxTrayAgendaSnapshot'))); expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); - expect(source, isNot(contains('buildBusyMaxTrayAgendaRows'))); + expect(source, isNot(contains('_buildAgendaSubmenuItems'))); expect(source, isNot(contains('busyMaxTrayAgendaSlotCount'))); expect(source, isNot(contains("iconName: 'busymax-symbolic'"))); expect(source, isNot(contains("label: 'Show BusyMax'"))); @@ -156,6 +156,27 @@ void main() { expect(source, isNot(contains("label: 'Quit BusyMax'"))); }); + test('snap uses portal-backed secret storage without keyring plug', () { + final snapcraft = File('snap/snapcraft.yaml').readAsStringSync(); + final bootstrap = File( + 'lib/src/app/app_bootstrap.dart', + ).readAsStringSync(); + final portalStore = File( + 'lib/src/google_tasks/oauth/portal_encrypted_oauth_token_store.dart', + ).readAsStringSync(); + + expect(snapcraft, contains('- desktop')); + expect(snapcraft, contains('- x11')); + expect(snapcraft, contains('GDK_BACKEND: wayland,x11')); + expect(snapcraft, contains('SECRET_BACKEND: file')); + expect(snapcraft, isNot(contains('password-manager-service'))); + expect(bootstrap, contains('PortalEncryptedOAuthTokenStore')); + expect(portalStore, contains('org.freedesktop.portal.Secret')); + expect(portalStore, contains('RetrieveSecret')); + expect(portalStore, contains('AesGcm.with256bits')); + expect(portalStore, contains('Hkdf(hmac: Hmac.sha256()')); + }); + test('compact agenda uses a separate desktop window', () { final pubspec = File('pubspec.yaml').readAsStringSync(); final runner = File('linux/runner/my_application.cc').readAsStringSync(); @@ -177,7 +198,7 @@ void main() { expect(pubspec, contains('desktop_multi_window:')); expect(pubspec, contains('window_manager:')); - expect(linuxMain, contains('gdk_set_allowed_backends("x11")')); + expect(linuxMain, contains('gdk_set_allowed_backends("wayland,x11")')); expect( runner, contains('desktop_multi_window_plugin_set_window_created_callback'), @@ -191,7 +212,15 @@ void main() { runner, contains('gtk_window_resize(window, kCompactAgendaWindowWidth'), ); + expect(runner, contains('move_compact_agenda_window_if_supported')); + expect(runner, contains('GDK_IS_X11_DISPLAY(display)')); expect(runner, contains('gtk_window_move(window, x, y)')); + expect(runner, contains('"skipped-non-x11"')); + expect( + runner, + contains('gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST)'), + ); + expect(runner, contains('BusyMax compact agenda positioning: phase=%s')); expect(runner, contains('apply_compact_agenda_geometry')); expect( runner, @@ -224,8 +253,10 @@ void main() { isNot(contains('gtk_window_set_titlebar(window, nullptr)')), ); expect(tray, contains('return _onOpenAgenda();')); - expect(tray, isNot(contains('BusyMaxTrayAgendaSnapshot'))); + expect(tray, isNot(contains('BusyMaxTrayAgendaMenu'))); expect(tray, isNot(contains('BusyMaxTrayAgendaEntry'))); + expect(tray, isNot(contains('onOpenAgendaEntry'))); + expect(tray, contains('id: _busyMaxTrayAgendaMenuId')); expect(router, isNot(contains('/tray-agenda'))); expect(compactApp, isNot(contains('linux_header_bar_service.dart'))); expect(compactApp, contains('gtk_font_service.dart')); @@ -245,17 +276,44 @@ void main() { compactApp, contains('_compactAgendaWindowChannel.invokeMethod'), ); + expect(compactApp, contains('unawaited(_destroyWindow());')); + expect(compactApp, contains('Future _clearWindowMethodHandler()')); + expect(compactApp, contains('Compact agenda positioning: event=')); expect( compactApp, contains('await windowManager.setSize(_compactAgendaWindowSize)'), ); expect(compactApp, contains('await windowManager.setBounds(')); - expect(compactApp, contains('await _moveNearTrayArea(')); + expect(compactApp, isNot(contains('windowManager.setPosition('))); + expect( + compactApp, + contains('final shownNatively = await _showNativeWindow(position);'), + ); expect(compactApp, isNot(contains('void onWindowBlur()'))); expect(compactApp, isNot(contains('_hideAfterBlurDelay'))); expect(compactPanel, contains('ClipRRect')); expect(compactPanel, contains('BusyMaxRadius.window')); expect(compactPanel, contains('BusyMaxShadow.windowShadowsFor')); + expect(compactWindowService, contains('getPrimaryDisplay()')); + expect(compactWindowService, contains('getCursorScreenPoint()')); + expect(compactWindowService, contains('getAllDisplays()')); + expect(compactWindowService, contains('_compactAgendaWindowFrameWidth')); + expect(compactWindowService, contains('_compactAgendaWindowFrameHeight')); + expect(compactWindowService, contains('_clampWindowPositionToWorkArea')); + expect(compactWindowService, contains('raw_x=')); + expect(compactWindowService, contains('final_x=')); + expect( + compactWindowService, + contains( + 'workarea.right -\n' + ' _compactAgendaWindowFrameWidth -\n' + ' _compactAgendaPanelScreenGap', + ), + ); + expect( + compactWindowService, + isNot(contains('panelTop - _compactAgendaWindowShadowMargin')), + ); expect(compactWindowService, isNot(contains('controller.show()'))); expect(main, isNot(contains('waitUntilReadyToShow'))); expect(main, isNot(contains('await windowManager.show();'))); diff --git a/test/app/theme_localization_test.dart b/test/app/theme_localization_test.dart index 2e9045a..708d2b6 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -762,7 +762,8 @@ void main() { test('ThemeMode.system is the default', () { expect(AppSettings.defaults().themeMode, ThemeMode.system); expect(AppSettings.defaults().scheduleViewMode, ScheduleViewMode.week); - expect(AppSettings.defaults().showTrayIcon, isFalse); + expect(AppSettings.defaults().runInBackgroundWhenClosed, isTrue); + expect(AppSettings.defaults().showTrayIcon, isTrue); expect( AppSettings.defaults().notificationDetailLevel, NotificationDetailLevel.normal, diff --git a/test/features/auth/data/auth_repository_test.dart b/test/features/auth/data/auth_repository_test.dart index ccd3029..a6678f4 100644 --- a/test/features/auth/data/auth_repository_test.dart +++ b/test/features/auth/data/auth_repository_test.dart @@ -95,19 +95,30 @@ void main() { }); test( - 'loadSession rejects stored token without required write scope', + 'loadSession does not touch token storage on signed-out startup', () async { + final state = await repository.loadSession(); + + expect(state.status, AuthSessionStatus.signedOut); + expect(oAuth.activeAccountIdReads, 0); + expect(oAuth.readActiveTokenSetCalls, 0); + }, + ); + + test( + 'loadSession trusts signed-in account rows without reading tokens', + () async { + await _insertAccount(database, 'account-1', TaskProvider.google); oAuth.activeId = 'account-1'; oAuth.nextTokenSet = _tokenSet(scopes: {googleTasksReadOnlyScope}); - await expectLater( - repository.loadSession(), - throwsA(isA()), - ); + final state = await repository.loadSession(); - expect(oAuth.revoked, isTrue); - expect(oAuth.revokedAccountId, 'account-1'); - expect(await database.select(database.accounts).get(), isEmpty); + expect(state.status, AuthSessionStatus.signedIn); + expect(state.accountId, 'account-1'); + expect(oAuth.activeAccountIdReads, 0); + expect(oAuth.readActiveTokenSetCalls, 0); + expect(oAuth.revoked, isFalse); }, ); @@ -252,17 +263,23 @@ class FakeOAuthGateway implements OAuthGateway { String? revokedAccountId; String? signedOutAccountId; String? activeId; + var activeAccountIdReads = 0; + var readActiveTokenSetCalls = 0; OAuthTokenSet nextTokenSet = _tokenSet(); GoogleUserInfo? nextUserInfo; @override - Future get activeAccountId async => activeId; + Future get activeAccountId async { + activeAccountIdReads += 1; + return activeId; + } @override Future cancelSignIn() async {} @override Future readActiveTokenSet() async { + readActiveTokenSetCalls += 1; if (activeId == null) { return null; } diff --git a/test/features/auth/presentation/auth_routing_test.dart b/test/features/auth/presentation/auth_routing_test.dart index 58f25a8..206998b 100644 --- a/test/features/auth/presentation/auth_routing_test.dart +++ b/test/features/auth/presentation/auth_routing_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:drift/native.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter/widgets.dart'; @@ -15,6 +16,7 @@ import 'package:busymax/src/features/tasks/presentation/tasks_workspace.dart'; import 'package:busymax/src/google_tasks/api/google_tasks_api_surface.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; import 'package:busymax/src/google_tasks/oauth/oauth_service.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; void main() { late AppDatabase database; @@ -159,6 +161,23 @@ void main() { await _disposeApp(tester); }); + testWidgets('secure storage failure shows friendly message', (tester) async { + oAuth.signInError = PlatformException( + code: 'KeyringLocked', + message: 'raw keyring message', + ); + await _pumpApp(tester, database: database, oAuth: oAuth); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Add Google account')); + await tester.pumpAndSettle(); + + expect(find.text(secureTokenStorageUnavailableMessage), findsOneWidget); + expect(find.textContaining('PlatformException'), findsNothing); + expect(find.textContaining('raw keyring message'), findsNothing); + await _disposeApp(tester); + }); + testWidgets('sign-in button is disabled while signing in', (tester) async { oAuth.signInCompleter = Completer(); await _pumpApp(tester, database: database, oAuth: oAuth); @@ -231,7 +250,7 @@ Future _pumpApp( class _FakeOAuthGateway implements OAuthGateway { String? activeId; - OAuthException? signInError; + Object? signInError; Completer? signInCompleter; OAuthTokenSet nextTokenSet = _tokenSet(); var signInCalls = 0; diff --git a/test/google_tasks/oauth/oauth_token_store_test.dart b/test/google_tasks/oauth/oauth_token_store_test.dart new file mode 100644 index 0000000..775db47 --- /dev/null +++ b/test/google_tasks/oauth/oauth_token_store_test.dart @@ -0,0 +1,253 @@ +import 'dart:io'; + +import 'package:busymax/src/google_tasks/oauth/oauth_models.dart'; +import 'package:busymax/src/google_tasks/oauth/oauth_token_store.dart'; +import 'package:busymax/src/google_tasks/oauth/portal_encrypted_oauth_token_store.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'busymax-token-store-test-', + ); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test( + 'secure storage platform failures become OAuth storage errors', + () async { + final store = SecureOAuthTokenStore( + _ThrowingSecureStorage( + PlatformException( + code: 'KeyringLocked', + message: 'raw keyring message', + ), + ), + ); + + await expectLater( + store.readActiveAccountId(), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'OAuthSecureStorageUnavailable', + ) + .having( + (error) => error.message, + 'message', + secureTokenStorageUnavailableMessage, + ), + ), + ); + }, + ); + + test( + 'portal encrypted token store does not write plaintext tokens', + () async { + final storageFile = File('${tempDir.path}/oauth-tokens.v1.json'); + final portal = _FakeSecretPortalClient( + const PortalSecret(bytes: _secretBytes, token: 'portal-token'), + ); + final store = PortalEncryptedOAuthTokenStore( + portalClient: portal, + storageFile: storageFile, + ); + final tokenSet = _tokenSet(); + + await store.saveTokenSet('account-1', tokenSet); + await store.setActiveAccountId('account-1'); + + final rawFile = await storageFile.readAsString(); + expect(rawFile, isNot(contains('access-secret'))); + expect(rawFile, isNot(contains('refresh-secret'))); + expect(rawFile, isNot(contains('id-secret'))); + expect(rawFile, contains('ciphertext')); + expect(rawFile, contains('portal-token')); + + final restoredStore = PortalEncryptedOAuthTokenStore( + portalClient: _FakeSecretPortalClient( + const PortalSecret(bytes: _secretBytes, token: 'portal-token'), + ), + storageFile: storageFile, + ); + + expect(await restoredStore.readActiveAccountId(), 'account-1'); + final restored = await restoredStore.readTokenSet('account-1'); + expect(restored?.accessToken, 'access-secret'); + expect(restored?.refreshToken, 'refresh-secret'); + expect(restored?.idToken, 'id-secret'); + expect(restored?.scopes, {'scope-a', 'scope-b'}); + }, + ); + + test('portal encrypted token store maps portal failures', () async { + final store = PortalEncryptedOAuthTokenStore( + portalClient: _ThrowingSecretPortalClient( + const SecretPortalException( + code: 'PortalUserCancelled', + message: 'The Secret portal request was cancelled.', + ), + ), + storageFile: File('${tempDir.path}/oauth-tokens.v1.json'), + ); + + await expectLater( + store.setActiveAccountId('account-1'), + throwsA( + isA() + .having( + (error) => error.code, + 'code', + 'OAuthSecureStorageUnavailable', + ) + .having( + (error) => error.message, + 'message', + secureTokenStorageUnavailableMessage, + ), + ), + ); + }); + + test('portal request tokens are valid D-Bus path elements', () { + final validPathElement = RegExp(r'^[A-Za-z_][A-Za-z0-9_]*$'); + + for (var i = 0; i < 128; i += 1) { + final token = generatePortalRequestTokenForTesting(); + + expect(token, matches(validPathElement)); + } + }); + + test('portal request tokens never contain hyphens', () { + for (var i = 0; i < 128; i += 1) { + final token = generatePortalRequestTokenForTesting(); + + expect(token, isNot(contains('-'))); + } + }); +} + +const _secretBytes = [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, +]; + +OAuthTokenSet _tokenSet() { + return OAuthTokenSet( + accessToken: 'access-secret', + refreshToken: 'refresh-secret', + idToken: 'id-secret', + expiresAtUtc: DateTime.utc(2026, 6, 4, 1), + tokenType: 'Bearer', + scopes: {'scope-a', 'scope-b'}, + ); +} + +class _FakeSecretPortalClient implements SecretPortalClient { + const _FakeSecretPortalClient(this.secret); + + final PortalSecret secret; + + @override + Future retrieveSecret({String? token}) async => secret; +} + +class _ThrowingSecretPortalClient implements SecretPortalClient { + const _ThrowingSecretPortalClient(this.error); + + final Object error; + + @override + Future retrieveSecret({String? token}) async { + throw error; + } +} + +class _ThrowingSecureStorage extends FlutterSecureStorage { + const _ThrowingSecureStorage(this.error); + + final PlatformException error; + + @override + Future read({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + throw error; + } + + @override + Future write({ + required String key, + required String? value, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + throw error; + } + + @override + Future delete({ + required String key, + AppleOptions? iOptions, + AndroidOptions? aOptions, + LinuxOptions? lOptions, + WebOptions? webOptions, + AppleOptions? mOptions, + WindowsOptions? wOptions, + }) async { + throw error; + } +} diff --git a/test/platform/busymax_tray_service_test.dart b/test/platform/busymax_tray_service_test.dart index ffb6356..558677a 100644 --- a/test/platform/busymax_tray_service_test.dart +++ b/test/platform/busymax_tray_service_test.dart @@ -1,10 +1,12 @@ import 'dart:io'; import 'package:busymax/src/platform/busymax_tray_service.dart'; +import 'package:dbus/dbus.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; void main() { - test('tray menu contains only app, agenda, and exit actions', () { + test('tray menu contains only app, agenda, and exit actions', () async { var openedApp = false; var openedAgenda = false; var quit = false; @@ -23,24 +25,50 @@ void main() { ); expect(menu.children, hasLength(3)); + expect(menu.id, 0); + expect(menu.children.map((item) => item.id), [1, 2, 3]); expect(menu.children.map((item) => item.label), [ - 'Open app', + 'Open BusyMax', 'Agenda', 'Exit', ]); - expect(menu.children.every((item) => item.enabled != false), isTrue); + expect(menu.enabled, isTrue); + expect(menu.visible, isTrue); + expect(menu.children.every((item) => item.enabled == true), isTrue); + expect(menu.children.every((item) => item.visible == true), isTrue); + expect(menu.children.every((item) => item.children.isEmpty), isTrue); - menu.children[0].onClicked?.call(); - menu.children[1].onClicked?.call(); - menu.children[2].onClicked?.call(); + await menu.children[0].onClicked?.call(); + await menu.children[1].onClicked?.call(); + await menu.children[2].onClicked?.call(); expect(openedApp, isTrue); expect(openedAgenda, isTrue); expect(quit, isTrue); }); + test('tray menu id 2 invokes Agenda callback', () async { + var openedAgenda = false; + final menu = buildBusyMaxTrayMenu( + labels: _labels, + onOpenBusyMax: () async {}, + onOpenAgenda: () async { + openedAgenda = true; + }, + onQuit: () async {}, + ); + + final agendaItem = menu.children.singleWhere((item) => item.id == 2); + await agendaItem.onClicked?.call(); + + expect(agendaItem.label, 'Agenda'); + expect(agendaItem.children, isEmpty); + expect(openedAgenda, isTrue); + }); + test('application id uses Busystack reverse DNS id', () { expect(busyMaxApplicationId, 'io.busystack.busymax'); + expect(busyMaxTrayMenuPath, '/StatusNotifierItem/menu'); }); test('Linux desktop identity matches the displayed BusyMax window', () { @@ -95,6 +123,27 @@ void main() { ); expect(runner, contains('static_cast(0)')); expect(runner, isNot(contains('G_APPLICATION_NON_UNIQUE'))); + expect(runner, contains('static void restore_main_window')); + expect(runner, contains('gtk_widget_show(GTK_WIDGET(self->main_window));')); + expect(runner, contains('gtk_window_deiconify(self->main_window);')); + expect( + runner, + contains( + 'gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME);', + ), + ); + expect(runner, contains('restore_main_window(self);')); + expect(runner, contains('BusyMax native showWindow method call received')); + expect( + runner, + contains('BusyMax native showWindow invoked: main_window_null=%s'), + ); + expect(runner, contains('BusyMax native hideWindow invoked')); + expect( + runner, + contains('BusyMax native hideWindow invocation: source=delete-event'), + ); + expect(runner, contains('BusyMax native quit invocation: method=quitApp')); expect( runner, contains( @@ -105,13 +154,30 @@ void main() { ); }); - test('agenda action no longer opens the main window', () { + test('agenda action opens compact agenda without restoring main first', () { final source = File( 'lib/src/platform/busymax_tray_service.dart', ).readAsStringSync(); expect(source, contains('Future _showAgenda()')); expect(source, contains('return _onOpenAgenda();')); + expect(source, isNot(contains('BusyMaxTrayAgendaMenu'))); + expect(source, isNot(contains('BusyMaxTrayAgendaEntry'))); + expect(source, contains('StatusNotifierItem activation callback')); + expect(source, contains('Tray service start requested')); + expect(source, contains('Tray initialization starting')); + expect(source, contains('StatusNotifier registration succeeded')); + expect(source, contains('StatusNotifier registration failed')); + expect(source, contains('DBus menu creation starting')); + expect(source, contains('DBus menu creation completed')); + expect(source, contains('menuPath: DBusObjectPath(busyMaxTrayMenuPath)')); + expect(source, contains('itemIsMenu: true')); + expect(source, contains('Tray menu DBus verification succeeded')); + expect(source, contains('Tray callback fired')); + expect(source, contains('Tray menu "Open BusyMax" callback')); + expect(source, contains('Tray menu "Agenda" callback')); + expect(source, contains('Tray menu "Quit" callback')); + expect(source, contains('return _windowService.showWindow();')); expect( source, isNot( @@ -123,10 +189,218 @@ void main() { ); expect(source, contains('await _onBeforeQuit?.call();')); }); + + test('DBus menu exposes layout, properties, and routes stable IDs', () async { + var openedApp = false; + var quit = false; + final logs = []; + + final object = DBusMenuObject( + DBusObjectPath(busyMaxTrayMenuPath), + buildBusyMaxTrayMenu( + labels: _labels, + onOpenBusyMax: () async { + openedApp = true; + }, + onOpenAgenda: () async {}, + onQuit: () async { + quit = true; + }, + ), + diagnosticLog: logs.add, + ); + + final layout = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'GetLayout', + values: [ + const DBusInt32(0), + const DBusInt32(-1), + DBusArray.string(const []), + ], + ), + ); + expect(layout, isA()); + final root = layout.returnValues[1].asStruct(); + expect(root[0].asInt32(), 0); + final children = root[2] + .asArray() + .map((child) => child.asVariant().asStruct()) + .toList(); + expect(children.map((child) => child[0].asInt32()).toList(), [1, 2, 3]); + for (final child in children) { + final properties = child[1].asStringVariantDict(); + expect(properties['label']?.asString(), isNotEmpty); + expect(properties['enabled']?.asBoolean(), isTrue); + expect(properties['visible']?.asBoolean(), isTrue); + expect(properties, isNot(contains('children-display'))); + } + + final groupProperties = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'GetGroupProperties', + values: [ + DBusArray.int32([1, 2, 3]), + DBusArray.string(const []), + ], + ), + ); + expect(groupProperties, isA()); + final groupedItems = groupProperties.returnValues.single.asArray(); + expect(groupedItems.map((item) => item.asStruct()[0].asInt32()).toList(), [ + 1, + 2, + 3, + ]); + + final openProperties = groupedItems.first + .asStruct()[1] + .asStringVariantDict(); + expect(openProperties['label']?.asString(), 'Open BusyMax'); + expect(openProperties['enabled']?.asBoolean(), isTrue); + expect(openProperties['visible']?.asBoolean(), isTrue); + + final labelProperty = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'GetProperty', + values: [const DBusInt32(1), const DBusString('label')], + ), + ); + expect(labelProperty, isA()); + expect( + labelProperty.returnValues.single.asVariant().asString(), + 'Open BusyMax', + ); + + final aboutToShow = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'AboutToShow', + values: [const DBusInt32(1)], + ), + ); + expect(aboutToShow, isA()); + expect(aboutToShow.returnValues.single.asBoolean(), isFalse); + + final aboutToShowGroup = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'AboutToShowGroup', + values: [ + DBusArray.int32([1, 2, 99]), + ], + ), + ); + expect(aboutToShowGroup, isA()); + expect(aboutToShowGroup.returnValues[0].asInt32Array(), isEmpty); + expect(aboutToShowGroup.returnValues[1].asInt32Array(), [99]); + + final version = await object.getProperty( + 'com.canonical.dbusmenu', + 'Version', + ); + expect(version, isA()); + expect(version.returnValues.single.asVariant().asUint32(), greaterThan(0)); + final status = await object.getProperty('com.canonical.dbusmenu', 'Status'); + expect(status.returnValues.single.asVariant().asString(), 'normal'); + final textDirection = await object.getProperty( + 'com.canonical.dbusmenu', + 'TextDirection', + ); + expect(textDirection.returnValues.single.asVariant().asString(), 'ltr'); + final iconThemePath = await object.getProperty( + 'com.canonical.dbusmenu', + 'IconThemePath', + ); + expect( + iconThemePath.returnValues.single.asVariant().asStringArray(), + isEmpty, + ); + + final event = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'Event', + values: [ + const DBusInt32(1), + const DBusString('clicked'), + const DBusVariant(DBusInt32(0)), + const DBusUint32(0), + ], + ), + ); + expect(event, isA()); + expect(openedApp, isTrue); + + final eventGroup = await object.handleMethodCall( + DBusMethodCall( + sender: 'test', + interface: 'com.canonical.dbusmenu', + name: 'EventGroup', + values: [ + DBusArray(DBusSignature('(isvu)'), [ + DBusStruct([ + const DBusInt32(3), + const DBusString('clicked'), + const DBusVariant(DBusInt32(0)), + const DBusUint32(0), + ]), + ]), + ], + ), + ); + expect(eventGroup, isA()); + expect(eventGroup.returnValues.single.asInt32Array(), isEmpty); + expect(quit, isTrue); + expect(logs, contains(startsWith('DBusMenu.GetLayout received'))); + expect(logs, contains(startsWith('DBusMenu.GetGroupProperties received'))); + expect(logs, contains(startsWith('DBusMenu.GetProperty received'))); + expect(logs, contains(startsWith('DBusMenu.AboutToShow received'))); + expect(logs, contains(startsWith('DBusMenu.AboutToShowGroup received'))); + expect(logs, contains(startsWith('DBusMenu.Event received'))); + expect(logs, contains(startsWith('DBusMenu.EventGroup received'))); + }); + + test('patched status notifier and DBus menu handle Ubuntu tray calls', () { + final source = File( + 'third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart', + ).readAsStringSync(); + final statusNotifier = File( + 'third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart', + ).readAsStringSync(); + + expect(source, contains('var values = event.asStruct();')); + expect(source, contains(r'DBusMenu.${methodCall.name} received')); + expect(source, contains('GetGroupProperties')); + expect(source, contains('DBusMenu.GetLayout details')); + expect(source, contains('DBusMenu.GetProperty details')); + expect(source, contains('DBusMenu.AboutToShow details')); + expect(source, contains('DBusMenu.AboutToShowGroup details')); + expect(source, contains("DBusString('normal')")); + expect(source, contains("DBusString('ltr')")); + expect(source, contains('DBusArray.string(const [])')); + expect(source, contains('_itemsById[id]')); + expect(statusNotifier, contains("'/StatusNotifierItem/menu'")); + expect(statusNotifier, contains('org.kde.StatusNotifierItem')); + expect(statusNotifier, contains('org.freedesktop.StatusNotifierItem')); + expect( + statusNotifier, + contains(r'StatusNotifierItem.${methodCall.name} received'), + ); + }); } const _labels = BusyMaxTrayLabels( - openBusyMax: 'Open app', + openBusyMax: 'Open BusyMax', agenda: 'Agenda', quitBusyMax: 'Exit', ); From 59f67d42c3ef9ef14e192b412c01d4fde2057f92 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:11:09 -0700 Subject: [PATCH 12/17] refactor window management for GNOME Wayland support and enhance debug logging --- linux/runner/main.cc | 9 ++-- linux/runner/my_application.cc | 94 ++++++++++++++++++++++++++++++---- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/linux/runner/main.cc b/linux/runner/main.cc index 3b8974e..66f8763 100644 --- a/linux/runner/main.cc +++ b/linux/runner/main.cc @@ -1,11 +1,10 @@ #include "my_application.h" int main(int argc, char** argv) { - // BusyMax uses a tray-attached compact Agenda window. GNOME Wayland does not - // allow normal GTK top-level windows to choose an absolute screen position, - // so the tray popup opens centered. X11/XWayland honors gtk_window_move(), - // which is required for this app-level tray surface. - gdk_set_allowed_backends("x11"); + // Prefer the native GNOME Wayland path. Compact Agenda positioning is exact + // only when the process is actually running on X11/XWayland; Mutter may + // center normal GTK top-level windows on Wayland. + gdk_set_allowed_backends("wayland,x11"); g_autoptr(MyApplication) app = my_application_new(); return g_application_run(G_APPLICATION(app), argc, argv); } diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc index 902383e..12da235 100644 --- a/linux/runner/my_application.cc +++ b/linux/runner/my_application.cc @@ -2301,12 +2301,28 @@ static gboolean window_delete_event_cb(GtkWidget* widget, gpointer user_data) { MyApplication* self = MY_APPLICATION(user_data); if (self->hide_on_close) { + g_debug( + "BusyMax native hideWindow invocation: source=delete-event " + "main_window_null=%s", + self->main_window == nullptr ? "true" : "false"); gtk_widget_hide(widget); return TRUE; } return FALSE; } +static void restore_main_window(MyApplication* self) { + g_debug("BusyMax native showWindow invoked: main_window_null=%s", + self->main_window == nullptr ? "true" : "false"); + if (self->main_window == nullptr) { + return; + } + gtk_widget_show(GTK_WIDGET(self->main_window)); + gtk_window_deiconify(self->main_window); + gtk_window_present_with_time(self->main_window, GDK_CURRENT_TIME); + gtk_window_present(self->main_window); +} + static void window_method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -2320,17 +2336,18 @@ static void window_method_call_cb(FlMethodChannel* channel, : FALSE; respond_success(method_call); } else if (strcmp(method, "hideWindow") == 0) { + g_debug("BusyMax native hideWindow invoked: main_window_null=%s", + self->main_window == nullptr ? "true" : "false"); if (self->main_window != nullptr) { gtk_widget_hide(GTK_WIDGET(self->main_window)); } respond_success(method_call); } else if (strcmp(method, "showWindow") == 0) { - if (self->main_window != nullptr) { - gtk_widget_show(GTK_WIDGET(self->main_window)); - gtk_window_present(self->main_window); - } + g_debug("BusyMax native showWindow method call received"); + restore_main_window(self); respond_success(method_call); } else if (strcmp(method, "quitApp") == 0) { + g_debug("BusyMax native quit invocation: method=quitApp"); self->hide_on_close = FALSE; g_application_quit(G_APPLICATION(self)); respond_success(method_call); @@ -2546,6 +2563,60 @@ static gboolean compact_agenda_position_arg(FlValue* args, return TRUE; } +static void log_compact_agenda_geometry(GtkWindow* window, + gboolean has_position, + gint x, + gint y, + gint width, + gint height, + const gchar* native_move_status, + const gchar* phase) { + GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); + const gchar* backend = + display != nullptr ? G_OBJECT_TYPE_NAME(display) : ""; + const gchar* session = g_getenv("XDG_SESSION_TYPE"); + if (session == nullptr || strlen(session) == 0) { + session = ""; + } + + GdkRectangle workarea = {-1, -1, -1, -1}; + if (display != nullptr) { + GdkMonitor* monitor = has_position + ? gdk_display_get_monitor_at_point(display, x, y) + : gdk_display_get_primary_monitor(display); + if (monitor != nullptr) { + gdk_monitor_get_workarea(monitor, &workarea); + } + } + + g_debug( + "BusyMax compact agenda positioning: phase=%s requested=%s " + "requested_x=%d requested_y=%d workarea=%d,%d,%dx%d final_size=%dx%d " + "backend=%s session=%s native_move_call_succeeded=%s", + phase, has_position ? "true" : "false", has_position ? x : -1, + has_position ? y : -1, workarea.x, workarea.y, workarea.width, + workarea.height, width, height, backend, session, native_move_status); +} + +static const gchar* move_compact_agenda_window_if_supported(GtkWindow* window, + gboolean has_position, + gint x, + gint y) { + if (!has_position) { + return "false"; + } + + GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window)); +#ifdef GDK_WINDOWING_X11 + if (display != nullptr && GDK_IS_X11_DISPLAY(display)) { + gtk_window_move(window, x, y); + return "true"; + } +#endif + + return "skipped-non-x11"; +} + static void apply_compact_agenda_geometry(GtkWindow* window, FlValue* args) { const gint width = compact_agenda_dimension_arg( args, "width", kCompactAgendaWindowWidth, kCompactAgendaWindowMinWidth, @@ -2555,6 +2626,7 @@ static void apply_compact_agenda_geometry(GtkWindow* window, FlValue* args) { kCompactAgendaWindowMaxHeight); gtk_window_set_position(window, GTK_WIN_POS_NONE); + gtk_window_set_gravity(window, GDK_GRAVITY_NORTH_EAST); gtk_window_set_default_size(window, width, height); gtk_window_resize(window, width, height); gtk_widget_set_size_request(GTK_WIDGET(window), width, height); @@ -2566,10 +2638,13 @@ static void apply_compact_agenda_geometry(GtkWindow* window, FlValue* args) { gint x = 0; gint y = 0; - if (compact_agenda_position_arg(args, "x", &x) && - compact_agenda_position_arg(args, "y", &y)) { - gtk_window_move(window, x, y); - } + const gboolean has_position = + compact_agenda_position_arg(args, "x", &x) && + compact_agenda_position_arg(args, "y", &y); + const gchar* native_move_status = + move_compact_agenda_window_if_supported(window, has_position, x, y); + log_compact_agenda_geometry(window, has_position, x, y, width, height, + native_move_status, "apply"); } static void compact_agenda_window_method_call_cb(FlMethodChannel* channel, @@ -2671,8 +2746,7 @@ static void first_frame_cb(MyApplication* self, FlView* view) { static void my_application_activate(GApplication* application) { MyApplication* self = MY_APPLICATION(application); if (self->main_window != nullptr) { - gtk_widget_show(GTK_WIDGET(self->main_window)); - gtk_window_present(self->main_window); + restore_main_window(self); return; } From 6f9cb94612bd12e67ade1f9fad20c8a765b52593 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:11:20 -0700 Subject: [PATCH 13/17] update snapcraft configuration for improved Wayland support and streamline build process --- snap/snapcraft.yaml | 97 ++++++--------------------------------------- 1 file changed, 12 insertions(+), 85 deletions(-) diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index c020f31..17ca689 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -37,12 +37,12 @@ apps: - network - network-bind - opengl - - password-manager-service - unity7 - wayland - x11 environment: - GDK_BACKEND: x11 + GDK_BACKEND: wayland,x11 + SECRET_BACKEND: file XDG_CACHE_HOME: $SNAP_USER_DATA/.cache XDG_CONFIG_HOME: $SNAP_USER_DATA/.config XDG_DATA_HOME: $SNAP_USER_DATA/.local/share @@ -55,89 +55,16 @@ slots: parts: busymax: - source: . - plugin: flutter - flutter-channel: stable - flutter-target: lib/main.dart - build-packages: - - clang - - cmake - - curl - - git - - libgtk-3-dev - - libjsoncpp-dev - - liblzma-dev - - libsecret-1-dev - - ninja-build - - pkg-config - - python3 - - unzip - - xz-utils + plugin: dump + source: build/linux/x64/release/bundle stage-packages: - liblzma5 - libsecret-1-0 - override-build: | - set +x - set -eu - set -o pipefail - - : "${GOOGLE_OAUTH_CLIENT_ID:?Set GOOGLE_OAUTH_CLIENT_ID before building the BusyMax beta snap.}" - : "${GOOGLE_OAUTH_CLIENT_SECRET:?Set GOOGLE_OAUTH_CLIENT_SECRET before building the BusyMax beta snap.}" - : "${MICROSOFT_OAUTH_CLIENT_ID:?Set MICROSOFT_OAUTH_CLIENT_ID before building the BusyMax beta snap.}" - - dart_defines_file="$CRAFT_PART_BUILD/busymax-dart-defines.json" - python3 - "$dart_defines_file" <<'PY' - import json - import os - import sys - - keys = [ - "GOOGLE_OAUTH_CLIENT_ID", - "GOOGLE_OAUTH_CLIENT_SECRET", - "MICROSOFT_OAUTH_CLIENT_ID", - ] - with open(sys.argv[1], "w", encoding="utf-8") as file: - json.dump({key: os.environ[key] for key in keys}, file) - PY - - flutter_dir="$CRAFT_PART_BUILD/flutter-distro" - if [ ! -x "$flutter_dir/bin/flutter" ]; then - releases_file="$CRAFT_PART_BUILD/flutter-releases-linux.json" - curl -fsSL \ - "${FLUTTER_STORAGE_BASE_URL:-https://storage.googleapis.com}/flutter_infra_release/releases/releases_linux.json" \ - -o "$releases_file" - archive_url="$(python3 - "$releases_file" <<'PY' - import json - import os - import sys - - with open(sys.argv[1], encoding="utf-8") as file: - releases = json.load(file) - - stable_hash = releases["current_release"]["stable"] - stable = next( - release for release in releases["releases"] if release["hash"] == stable_hash - ) - base_url = releases["base_url"] - if os.environ.get("FLUTTER_STORAGE_BASE_URL"): - base_url = base_url.replace( - "https://storage.googleapis.com", os.environ["FLUTTER_STORAGE_BASE_URL"] - ) - print(f"{base_url}/{stable['archive']}") - PY - )" - curl -fL "$archive_url" | tar --no-same-owner -xJ -C "$CRAFT_PART_BUILD" - mv "$CRAFT_PART_BUILD/flutter" "$flutter_dir" - fi - git config --global --add safe.directory "$flutter_dir" - export PATH="$flutter_dir/bin:$PATH" - export FLUTTER_SUPPRESS_ANALYTICS=true - - flutter --no-version-check config --enable-linux-desktop - flutter --no-version-check pub get - export CC=/usr/bin/clang - export CXX=/usr/bin/clang++ - flutter --no-version-check build linux --release --dart-define-from-file="$dart_defines_file" - rm -f "$dart_defines_file" - - cp -a build/linux/x64/release/bundle/. "$CRAFT_PART_INSTALL/" + override-prime: | + craftctl default + install -Dm644 "$CRAFT_PROJECT_DIR/linux/io.busystack.busymax.desktop" \ + "$CRAFT_PRIME/share/applications/io.busystack.busymax.desktop" + install -Dm644 "$CRAFT_PROJECT_DIR/linux/io.busystack.busymax.metainfo.xml" \ + "$CRAFT_PRIME/share/metainfo/io.busystack.busymax.metainfo.xml" + install -Dm644 "$CRAFT_PROJECT_DIR/assets/branding/busymax-logo.svg" \ + "$CRAFT_PRIME/share/icons/hicolor/scalable/apps/io.busystack.busymax.svg" From e2e73b01302497c5be1e124e868f583b6a003f64 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:11:45 -0700 Subject: [PATCH 14/17] update beta snap release documentation to clarify OAuth token storage and tray features --- docs/beta_snap_release.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index a5dc255..49d1511 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -9,13 +9,13 @@ The snap build requires OAuth configuration at build time. Do not commit real values. ```bash -export GOOGLE_OAUTH_CLIENT_ID= -export GOOGLE_OAUTH_CLIENT_SECRET= -export MICROSOFT_OAUTH_CLIENT_ID= -snapcraft pack +mkdir -p .snap-local +$EDITOR .snap-local/busymax-dart-defines.json +snapcraft pack --use-lxd ``` -The build fails if any required OAuth value is missing. +The ignored `.snap-local/busymax-dart-defines.json` file must contain the +required Dart defines. The build fails if that local file is missing. ## Install The Beta @@ -34,12 +34,21 @@ sudo snap install busymax --beta ## Scope - Snap confinement is strict. -- The tray/status-notifier feature is available but disabled by default for the - beta. Users can enable it in settings. +- The tray/status-notifier feature and background-on-close behavior are enabled + by default for the beta. Users can disable them in settings. +- The tray menu is intentionally simple: Open BusyMax, Agenda, and Quit. +- The tray Agenda action opens the compact Agenda utility window. On GNOME + Wayland this window is a normal top-level utility window and may be placed by + Mutter instead of under the tray icon. Under X11/XWayland, BusyMax requests a + top-right position when the backend supports absolute movement. - Settings, task data, and token metadata are stored inside the snap user data sandbox. Normal app restarts preserve data. Removing the snap removes user data unless snapd creates and restores a snapshot. - OAuth uses the system browser and a local loopback callback listener. +- OAuth tokens use the XDG Secret portal in the snap to retrieve a + per-application encryption secret, then store only AES-GCM ciphertext under + `XDG_DATA_HOME`. BusyMax must not require the `password-manager-service` + interface. ## Validation Matrix @@ -54,9 +63,13 @@ Required smoke checks before upload: - Google and Microsoft sign-in open in the browser and complete the loopback callback. - Secure token storage survives app restart. +- `grep -R` over snap user data does not show plaintext OAuth tokens. - Settings and task data survive app restart. - Notifications appear through the desktop notification service. -- Tray opt-in either works or fails without crashing. +- Tray icon appears, the menu opens, Open BusyMax restores the existing main + window, Agenda opens the compact Agenda window, and Quit exits cleanly. +- Compact Agenda data loads through the main-window bridge without opening a + second migrating SQLite connection. ## Reporting Bugs From d048e94a2aaabf15c83e4544a49cab14b67efd81 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:11:52 -0700 Subject: [PATCH 15/17] add .snap-local to .gitignore to exclude local Snap build artifacts --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 3820a95..ee425f6 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +.snap-local/ From 0c2c58f3930263db6344d81f554fa7f426c97bba Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:12:39 -0700 Subject: [PATCH 16/17] add compact agenda snapshot and data encoding/decoding functionality --- .../application/compact_agenda_snapshot.dart | 273 ++++++++++++++++++ .../application/compact_agenda_data_test.dart | 158 ++++++++++ .../compact_agenda_window_service_test.dart | 25 ++ 3 files changed, 456 insertions(+) create mode 100644 lib/src/features/schedule/application/compact_agenda_snapshot.dart create mode 100644 test/features/schedule/application/compact_agenda_data_test.dart create mode 100644 test/platform/compact_agenda_window_service_test.dart diff --git a/lib/src/features/schedule/application/compact_agenda_snapshot.dart b/lib/src/features/schedule/application/compact_agenda_snapshot.dart new file mode 100644 index 0000000..64d3bab --- /dev/null +++ b/lib/src/features/schedule/application/compact_agenda_snapshot.dart @@ -0,0 +1,273 @@ +import '../../../schedule/schedule_item.dart'; +import '../../../schedule/schedule_range.dart'; +import '../../../task_providers/task_provider.dart'; +import 'compact_agenda_data.dart'; +import 'compact_agenda_sections.dart'; + +Map encodeCompactAgendaQuery(CompactAgendaQuery query) { + return { + 'futureDays': query.futureDays, + 'overdueLimit': query.overdueLimit, + 'noDateLimit': query.noDateLimit, + }; +} + +CompactAgendaQuery decodeCompactAgendaQuery(Object? raw) { + if (raw is! Map) { + return CompactAgendaQuery.initial; + } + final map = raw.cast(); + return CompactAgendaQuery( + futureDays: _intValue(map, 'futureDays', compactAgendaInitialDays), + overdueLimit: _intValue( + map, + 'overdueLimit', + compactAgendaInitialOverdueLimit, + ), + noDateLimit: _intValue(map, 'noDateLimit', compactAgendaInitialNoDateLimit), + ); +} + +Map encodeCompactAgendaData(CompactAgendaData data) { + return { + 'today': data.today.toIso8601String(), + 'rangeStart': data.range.start.toIso8601String(), + 'rangeEnd': data.range.end.toIso8601String(), + 'items': data.items.map(encodeScheduleItem).toList(), + 'hasMoreOverdueTasks': data.hasMoreOverdueTasks, + 'hasMoreNoDateTasks': data.hasMoreNoDateTasks, + 'hasSignedInAccounts': data.hasSignedInAccounts, + 'hasSources': data.hasSources, + 'generatedAt': data.generatedAt.toIso8601String(), + }; +} + +CompactAgendaData decodeCompactAgendaData(Object? raw) { + final map = _mapValue(raw); + return CompactAgendaData( + today: _requiredDateTime(map, 'today'), + range: ScheduleRange( + start: _requiredDateTime(map, 'rangeStart'), + end: _requiredDateTime(map, 'rangeEnd'), + ), + items: _listValue(map['items']).map(decodeScheduleItem).toList(), + hasMoreOverdueTasks: _boolValue(map, 'hasMoreOverdueTasks'), + hasMoreNoDateTasks: _boolValue(map, 'hasMoreNoDateTasks'), + hasSignedInAccounts: _boolValue(map, 'hasSignedInAccounts'), + hasSources: _boolValue(map, 'hasSources'), + generatedAt: _requiredDateTime(map, 'generatedAt'), + ); +} + +Map encodeScheduleItem(ScheduleItem item) { + final common = { + 'kind': item is TaskScheduleItem ? 'task' : 'calendarEvent', + 'id': item.id, + 'accountId': item.accountId, + 'provider': item.provider.storageValue, + 'sourceId': item.sourceId, + 'title': item.title, + 'sourceName': item.sourceName, + 'accountDisplayName': item.accountDisplayName, + 'accountEmail': item.accountEmail, + 'start': item.start?.toIso8601String(), + 'end': item.end?.toIso8601String(), + 'allDay': item.allDay, + 'categories': item.categories, + }; + if (item is TaskScheduleItem) { + return { + ...common, + 'completed': item.completed, + 'notes': item.notes, + 'reminder': item.reminder?.toIso8601String(), + }; + } + final event = item as CalendarScheduleItem; + return { + ...common, + 'providerCalendarId': event.providerCalendarId, + 'startTimeZone': event.startTimeZone, + 'endTimeZone': event.endTimeZone, + 'location': event.location, + 'description': event.description, + 'descriptionContentType': event.descriptionContentType, + 'descriptionHtml': event.descriptionHtml, + 'colorHex': event.colorHex, + 'reminderMinutesBeforeStart': event.reminderMinutesBeforeStart, + }; +} + +ScheduleItem decodeScheduleItem(Object? raw) { + final map = _mapValue(raw); + final kind = _requiredString(map, 'kind'); + final provider = TaskProviderParsing.fromStorageValue( + _optionalString(map, 'provider'), + ); + final common = _ScheduleItemCommon( + id: _requiredString(map, 'id'), + accountId: _requiredString(map, 'accountId'), + provider: provider, + sourceId: _requiredString(map, 'sourceId'), + title: _requiredString(map, 'title'), + sourceName: _optionalString(map, 'sourceName'), + accountDisplayName: _optionalString(map, 'accountDisplayName'), + accountEmail: _optionalString(map, 'accountEmail'), + start: _optionalDateTime(map, 'start'), + end: _optionalDateTime(map, 'end'), + allDay: _boolValue(map, 'allDay'), + categories: _stringListValue(map['categories']), + ); + if (kind == 'task') { + return TaskScheduleItem( + id: common.id, + accountId: common.accountId, + provider: common.provider, + sourceId: common.sourceId, + title: common.title, + completed: _boolValue(map, 'completed'), + allDay: common.allDay, + start: common.start, + end: common.end, + notes: _optionalString(map, 'notes'), + categories: common.categories, + reminder: _optionalDateTime(map, 'reminder'), + sourceName: common.sourceName, + accountDisplayName: common.accountDisplayName, + accountEmail: common.accountEmail, + ); + } + if (kind != 'calendarEvent') { + throw FormatException('Unsupported compact agenda item kind $kind.'); + } + return CalendarScheduleItem( + id: common.id, + accountId: common.accountId, + provider: common.provider, + sourceId: common.sourceId, + providerCalendarId: + _optionalString(map, 'providerCalendarId') ?? common.sourceId, + title: common.title, + allDay: common.allDay, + start: common.start, + end: common.end, + startTimeZone: _optionalString(map, 'startTimeZone'), + endTimeZone: _optionalString(map, 'endTimeZone'), + location: _optionalString(map, 'location'), + description: _optionalString(map, 'description'), + descriptionContentType: _optionalString(map, 'descriptionContentType'), + descriptionHtml: _optionalString(map, 'descriptionHtml'), + colorHex: _optionalString(map, 'colorHex'), + categories: common.categories, + reminderMinutesBeforeStart: _intListValue( + map['reminderMinutesBeforeStart'], + ), + sourceName: common.sourceName, + accountDisplayName: common.accountDisplayName, + accountEmail: common.accountEmail, + ); +} + +class _ScheduleItemCommon { + const _ScheduleItemCommon({ + required this.id, + required this.accountId, + required this.provider, + required this.sourceId, + required this.title, + required this.sourceName, + required this.accountDisplayName, + required this.accountEmail, + required this.start, + required this.end, + required this.allDay, + required this.categories, + }); + + final String id; + final String accountId; + final TaskProvider provider; + final String sourceId; + final String title; + final String? sourceName; + final String? accountDisplayName; + final String? accountEmail; + final DateTime? start; + final DateTime? end; + final bool allDay; + final List categories; +} + +Map _mapValue(Object? value) { + if (value is! Map) { + throw const FormatException('Compact agenda snapshot is not a map.'); + } + return value.cast(); +} + +List _listValue(Object? value) { + if (value is! List) { + return const []; + } + return value.cast(); +} + +List _stringListValue(Object? value) { + return _listValue(value).map((item) => item.toString()).toList(); +} + +List _intListValue(Object? value) { + return _listValue(value) + .map((item) => item is int ? item : int.tryParse(item.toString())) + .nonNulls + .toList(); +} + +String _requiredString(Map map, String key) { + final value = map[key]; + if (value == null) { + throw FormatException('Compact agenda snapshot missing $key.'); + } + return value.toString(); +} + +String? _optionalString(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + final text = value.toString(); + return text.isEmpty ? null : text; +} + +DateTime _requiredDateTime(Map map, String key) { + final value = _optionalDateTime(map, key); + if (value == null) { + throw FormatException('Compact agenda snapshot missing $key.'); + } + return value; +} + +DateTime? _optionalDateTime(Map map, String key) { + final value = map[key]; + if (value == null) { + return null; + } + return DateTime.tryParse(value.toString()); +} + +bool _boolValue(Map map, String key) { + final value = map[key]; + if (value is bool) { + return value; + } + return value?.toString() == 'true'; +} + +int _intValue(Map map, String key, int fallback) { + final value = map[key]; + if (value is int) { + return value; + } + return int.tryParse(value?.toString() ?? '') ?? fallback; +} diff --git a/test/features/schedule/application/compact_agenda_data_test.dart b/test/features/schedule/application/compact_agenda_data_test.dart new file mode 100644 index 0000000..efa455f --- /dev/null +++ b/test/features/schedule/application/compact_agenda_data_test.dart @@ -0,0 +1,158 @@ +import 'package:busymax/src/app/app_bootstrap.dart'; +import 'package:busymax/src/features/schedule/application/compact_agenda_data.dart'; +import 'package:busymax/src/features/schedule/application/compact_agenda_snapshot.dart'; +import 'package:busymax/src/schedule/schedule_item.dart'; +import 'package:busymax/src/schedule/schedule_range.dart'; +import 'package:busymax/src/task_providers/task_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('compact agenda bridge loader does not open the database', () async { + var loadedFromBridge = false; + final expected = _agendaData(); + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWith((ref) { + throw StateError('compact agenda opened databaseProvider'); + }), + compactAgendaDataLoaderProvider.overrideWithValue((ref, query) async { + loadedFromBridge = true; + expect(query, CompactAgendaQuery.initial); + return expected; + }), + ], + ); + addTearDown(container.dispose); + + final data = await container.read(compactAgendaDataProvider.future); + + expect(loadedFromBridge, isTrue); + expect(data.generatedAt, expected.generatedAt); + expect(data.items.single.title, 'Bridge event'); + }); + + test('compact agenda retries temporary SQLITE_BUSY failures', () async { + var attempts = 0; + final expected = _agendaData(); + final container = ProviderContainer(); + addTearDown(container.dispose); + final provider = FutureProvider((ref) { + return loadCompactAgendaDataWithRetry( + ref, + CompactAgendaQuery.initial, + retryDelays: const [Duration.zero, Duration.zero], + delay: (_) async {}, + loader: (ref, query) async { + attempts += 1; + if (attempts < 3) { + throw Exception('SQLite exception(5): database is locked'); + } + return expected; + }, + ); + }); + + final data = await container.read(provider.future); + + expect(attempts, 3); + expect(data.items.single.title, 'Bridge event'); + }); + + test('compact agenda snapshot round-trips schedule data', () { + final expected = _agendaData( + items: [ + _event('Planning', start: DateTime(2026, 6, 10, 9)), + _task('Review notes', start: DateTime(2026, 6, 10, 11)), + ], + ); + + final decoded = decodeCompactAgendaData(encodeCompactAgendaData(expected)); + + expect(decoded.today, expected.today); + expect(decoded.range.end, expected.range.end); + expect(decoded.items, hasLength(2)); + expect(decoded.items[0], isA()); + expect(decoded.items[0].title, 'Planning'); + expect(decoded.items[1], isA()); + expect(decoded.items[1].title, 'Review notes'); + expect(decoded.hasSignedInAccounts, isTrue); + expect(decoded.hasSources, isTrue); + }); + + test('compact agenda query snapshot uses stable primitive fields', () { + const query = CompactAgendaQuery( + futureDays: 60, + overdueLimit: 16, + noDateLimit: 24, + ); + + final decoded = decodeCompactAgendaQuery(encodeCompactAgendaQuery(query)); + + expect(decoded.futureDays, 60); + expect(decoded.overdueLimit, 16); + expect(decoded.noDateLimit, 24); + }); +} + +CompactAgendaData _agendaData({List? items}) { + final today = DateTime(2026, 6, 10); + return CompactAgendaData( + today: today, + range: ScheduleRange( + start: today, + end: today.add(const Duration(days: 30)), + ), + items: items ?? [_event('Bridge event', start: today)], + hasMoreOverdueTasks: false, + hasMoreNoDateTasks: false, + hasSignedInAccounts: true, + hasSources: true, + generatedAt: today.add(const Duration(minutes: 5)), + ); +} + +CalendarScheduleItem _event(String title, {required DateTime start}) { + return CalendarScheduleItem( + id: title, + accountId: 'account', + provider: TaskProvider.google, + sourceId: 'calendar', + providerCalendarId: 'provider-calendar', + title: title, + allDay: false, + start: start, + end: start.add(const Duration(hours: 1)), + startTimeZone: 'America/Vancouver', + endTimeZone: 'America/Vancouver', + location: 'Room 1', + description: 'Description', + descriptionContentType: 'text/plain', + descriptionHtml: '

Description

', + colorHex: '#4477aa', + categories: const ['Work'], + reminderMinutesBeforeStart: const [10], + sourceName: 'Work', + accountDisplayName: 'Account', + accountEmail: 'account@example.com', + ); +} + +TaskScheduleItem _task(String title, {DateTime? start}) { + return TaskScheduleItem( + id: title, + accountId: 'account', + provider: TaskProvider.microsoft, + sourceId: 'tasks', + title: title, + completed: false, + allDay: true, + start: start, + notes: 'Notes', + categories: const ['Blue'], + reminder: start?.subtract(const Duration(minutes: 30)), + sourceName: 'Tasks', + accountDisplayName: 'Account', + accountEmail: 'account@example.com', + ); +} diff --git a/test/platform/compact_agenda_window_service_test.dart b/test/platform/compact_agenda_window_service_test.dart new file mode 100644 index 0000000..9195cab --- /dev/null +++ b/test/platform/compact_agenda_window_service_test.dart @@ -0,0 +1,25 @@ +import 'package:busymax/src/platform/compact_agenda_window_service.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('compact agenda placement', () { + test('top-right fallback keeps the full window frame on-screen', () { + final position = compactAgendaTopRightWorkAreaPositionForTest( + const Rect.fromLTWH(0, 0, 1920, 1080), + ); + + expect(position.dx, 1430); + expect(position.dy, 6); + }); + + test('top-right fallback clamps to the workarea minimum', () { + final position = compactAgendaTopRightWorkAreaPositionForTest( + const Rect.fromLTWH(0, 0, 320, 240), + ); + + expect(position.dx, 6); + expect(position.dy, 6); + }); + }); +} From bdbbecad84b0456e8cc866f13854beafc88835f1 Mon Sep 17 00:00:00 2001 From: albert Date: Sun, 14 Jun 2026 13:31:01 -0700 Subject: [PATCH 17/17] Vendor patched xdg_status_notifier_item for Linux tray support and update dependency documentation --- docs/beta_snap_release.md | 2 + third_party/README.md | 44 ++ .../xdg_status_notifier_item/CHANGELOG.md | 5 + .../xdg_status_notifier_item/CONTRIBUTING.md | 8 + third_party/xdg_status_notifier_item/LICENSE | 373 +++++++++ .../xdg_status_notifier_item/README.md | 28 + .../analysis_options.yaml | 10 + .../xdg_status_notifier_item/codecov.yaml | 1 + .../example/example.dart | 66 ++ .../lib/src/dbus_menu_object.dart | 712 ++++++++++++++++++ .../lib/src/status_notifier_item_client.dart | 549 ++++++++++++++ .../lib/xdg_status_notifier_item.dart | 2 + .../xdg_status_notifier_item/pubspec.yaml | 21 + 13 files changed, 1821 insertions(+) create mode 100644 third_party/README.md create mode 100644 third_party/xdg_status_notifier_item/CHANGELOG.md create mode 100644 third_party/xdg_status_notifier_item/CONTRIBUTING.md create mode 100644 third_party/xdg_status_notifier_item/LICENSE create mode 100644 third_party/xdg_status_notifier_item/README.md create mode 100644 third_party/xdg_status_notifier_item/analysis_options.yaml create mode 100644 third_party/xdg_status_notifier_item/codecov.yaml create mode 100644 third_party/xdg_status_notifier_item/example/example.dart create mode 100644 third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart create mode 100644 third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart create mode 100644 third_party/xdg_status_notifier_item/lib/xdg_status_notifier_item.dart create mode 100644 third_party/xdg_status_notifier_item/pubspec.yaml diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md index 49d1511..bc691fc 100644 --- a/docs/beta_snap_release.md +++ b/docs/beta_snap_release.md @@ -36,6 +36,8 @@ sudo snap install busymax --beta - Snap confinement is strict. - The tray/status-notifier feature and background-on-close behavior are enabled by default for the beta. Users can disable them in settings. +- Tray support currently uses a vendored patched `xdg_status_notifier_item` + StatusNotifierItem/DBusMenu dependency under `third_party`. - The tray menu is intentionally simple: Open BusyMax, Agenda, and Quit. - The tray Agenda action opens the compact Agenda utility window. On GNOME Wayland this window is a normal top-level utility window and may be placed by diff --git a/third_party/README.md b/third_party/README.md new file mode 100644 index 0000000..f6ab947 --- /dev/null +++ b/third_party/README.md @@ -0,0 +1,44 @@ +# Third-Party Dependencies + +This directory contains third-party source that is vendored into BusyMax. + +## xdg_status_notifier_item + +- Package: `xdg_status_notifier_item` +- Vendored path: `third_party/xdg_status_notifier_item` +- Original pub.dev package: `xdg_status_notifier_item` version `0.0.1` +- Original source: https://github.com/canonical/xdg_status_notifier_item.dart +- License: Mozilla Public License 2.0 (`MPL-2.0`) + +BusyMax vendors this package because Linux tray support depends on +StatusNotifierItem and DBusMenu behavior that is not available in the published +`0.0.1` pub.dev release. + +The vendored package keeps its upstream `LICENSE` file in +`third_party/xdg_status_notifier_item/LICENSE`. MPL-2.0 is compatible with +including the package in BusyMax's Apache-2.0 larger work, provided the +MPL-2.0-covered files and any modifications to those files remain available +under MPL-2.0 and the license notices are preserved. + +BusyMax-specific patches currently include: + +- Widening the package SDK constraint to support Dart 3. +- Exporting DBusMenu objects at the StatusNotifierItem menu path used by + BusyMax. +- Supporting explicit, stable DBusMenu item IDs. +- Adding DBusMenu `GetGroupProperties` support and menu object properties. +- Supporting both `org.kde.StatusNotifierItem` and + `org.freedesktop.StatusNotifierItem` interfaces. +- Fixing StatusNotifierItem callback argument handling for x/y, scroll delta, + and scroll orientation values. +- Adding `ItemIsMenu`, custom menu path, object path accessors, and diagnostic + logging hooks used by BusyMax tray tests and runtime diagnostics. + +Plan: + +- Keep the vendored package scoped to tray support only. +- Do not commit build artifacts, generated outputs, or dependency caches under + `third_party`. +- Upstream the StatusNotifierItem/DBusMenu fixes where practical, or replace + this vendored copy with a maintained pub.dev release once the required + behavior is available. diff --git a/third_party/xdg_status_notifier_item/CHANGELOG.md b/third_party/xdg_status_notifier_item/CHANGELOG.md new file mode 100644 index 0000000..30ea40d --- /dev/null +++ b/third_party/xdg_status_notifier_item/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1 + +* Initial release diff --git a/third_party/xdg_status_notifier_item/CONTRIBUTING.md b/third_party/xdg_status_notifier_item/CONTRIBUTING.md new file mode 100644 index 0000000..9c57508 --- /dev/null +++ b/third_party/xdg_status_notifier_item/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# xdg_status_notifier_item.dart Contribution Guide + +If you have a problem, please [file an issue](https://github.com/canonical/xdg_status_notifier_item.dart/issues/new). + +If you have a solution, then we accept contributions via [pull requests](https://github.com/canonical/xdg_status_notifier_item.dart/pulls). +All contributions require the author(s) to sign the [contributor license agreement](http://www.ubuntu.com/legal/contributors/). + +Thanks for your help! diff --git a/third_party/xdg_status_notifier_item/LICENSE b/third_party/xdg_status_notifier_item/LICENSE new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/third_party/xdg_status_notifier_item/LICENSE @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/third_party/xdg_status_notifier_item/README.md b/third_party/xdg_status_notifier_item/README.md new file mode 100644 index 0000000..1beb648 --- /dev/null +++ b/third_party/xdg_status_notifier_item/README.md @@ -0,0 +1,28 @@ +[![Pub Package](https://img.shields.io/pub/v/xdg_status_notifier_item.svg)](https://pub.dev/packages/xdg_status_notifier_item) +[![codecov](https://codecov.io/gh/canonical/xdg_status_notifier_item.dart/branch/main/graph/badge.svg?token=QW1N0AQQOY)](https://codecov.io/gh/canonical/xdg_status_notifier_item.dart) + +Allows status notifications (i.e. system tray) on Linux desktops using the [StatusNotifierItem specification](https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem/). + +```dart +import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; + +late final StatusNotifierItemClient client; + +void main() async { + client = StatusNotifierItemClient( + id: 'test-client', + iconName: 'computer-fail-symbolic', + menu: DBusMenuItem(children: [ + DBusMenuItem(label: 'Hello'), + DBusMenuItem(label: 'World', enabled: false), + DBusMenuItem.separator(), + DBusMenuItem( + label: 'Quit', onClicked: () async => await client.close()), + ])); + await client.connect(); +} +``` + +## Contributing to xdg_status_notifier_item.dart + +We welcome contributions! See the [contribution guide](CONTRIBUTING.md) for more details. diff --git a/third_party/xdg_status_notifier_item/analysis_options.yaml b/third_party/xdg_status_notifier_item/analysis_options.yaml new file mode 100644 index 0000000..c593f33 --- /dev/null +++ b/third_party/xdg_status_notifier_item/analysis_options.yaml @@ -0,0 +1,10 @@ +include: package:lints/recommended.yaml + +linter: + rules: + - always_declare_return_types + - prefer_single_quotes + - sort_child_properties_last + - unawaited_futures + - unsafe_html + - use_full_hex_values_for_flutter_colors diff --git a/third_party/xdg_status_notifier_item/codecov.yaml b/third_party/xdg_status_notifier_item/codecov.yaml new file mode 100644 index 0000000..69cb760 --- /dev/null +++ b/third_party/xdg_status_notifier_item/codecov.yaml @@ -0,0 +1 @@ +comment: false diff --git a/third_party/xdg_status_notifier_item/example/example.dart b/third_party/xdg_status_notifier_item/example/example.dart new file mode 100644 index 0000000..473b918 --- /dev/null +++ b/third_party/xdg_status_notifier_item/example/example.dart @@ -0,0 +1,66 @@ +import 'package:xdg_status_notifier_item/xdg_status_notifier_item.dart'; + +late StatusNotifierItemClient client; +var itemClicked = false; +var checkmarkIsActive = true; +var activeRadio = 1; + +DBusMenuItem buildMenu() { + return DBusMenuItem(children: [ + DBusMenuItem( + label: itemClicked ? 'Clicked Item' : 'Item', + onClicked: () async => await handleClick()), + DBusMenuItem(label: 'Disabled Item', enabled: false), + DBusMenuItem(label: 'Invisible Item', visible: false), + DBusMenuItem.separator(), + DBusMenuItem(label: 'Submenu', children: [ + DBusMenuItem( + label: 'Submenu 1', + onClicked: () async => print('Submenu item 1 clicked!')), + DBusMenuItem( + label: 'Submenu 2', + onClicked: () async => print('Submenu item 2 clicked!')), + DBusMenuItem( + label: 'Submenu 3', + onClicked: () async => print('Submenu item 3 clicked!')) + ]), + DBusMenuItem.separator(), + DBusMenuItem.checkmark('Checkmark', + state: checkmarkIsActive, + onClicked: () async => await toggleCheckmark()), + DBusMenuItem.separator(), + DBusMenuItem.checkmark('Radio 1', + state: activeRadio == 1, onClicked: () async => await setRadio(1)), + DBusMenuItem.checkmark('Radio 2', + state: activeRadio == 2, onClicked: () async => await setRadio(2)), + DBusMenuItem.checkmark('Radio 3', + state: activeRadio == 3, onClicked: () async => await setRadio(3)), + DBusMenuItem.separator(), + DBusMenuItem(label: 'Quit', onClicked: () async => await client.close()), + ]); +} + +Future rebuild() async { + await client.updateMenu(buildMenu()); +} + +Future handleClick() async { + itemClicked = true; + await rebuild(); +} + +Future toggleCheckmark() async { + checkmarkIsActive = !checkmarkIsActive; + await rebuild(); +} + +Future setRadio(int active) async { + activeRadio = active; + await rebuild(); +} + +void main() async { + client = StatusNotifierItemClient( + id: 'dart-test', iconName: 'computer-fail-symbolic', menu: buildMenu()); + await client.connect(); +} diff --git a/third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart b/third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart new file mode 100644 index 0000000..14234e1 --- /dev/null +++ b/third_party/xdg_status_notifier_item/lib/src/dbus_menu_object.dart @@ -0,0 +1,712 @@ +import 'dart:async'; +import 'package:dbus/dbus.dart'; + +typedef DBusMenuDiagnosticLog = void Function(String message); + +/// An item in the menu. +class DBusMenuItem { + final int? id; + final String? type; + final bool? enabled; + final bool? visible; + final String? label; + final int? toggleState; + final String? toggleType; + final List children; + + // Called when this menu item is about to be shown. Return true if this item needs updating. + final Future Function()? onAboutToShow; + + /// Called when the submenu under this item is opened. + final Future Function()? onOpened; + + /// Called when the submenu under this item is closed. + final Future Function()? onClosed; + + /// Called when this item is clicked. + final Future Function()? onClicked; + + /// Creates a new menu item. + DBusMenuItem({ + this.id, + this.type, + this.enabled, + this.visible, + this.label, + this.toggleState, + this.toggleType, + this.children = const [], + this.onAboutToShow, + this.onOpened, + this.onClosed, + this.onClicked, + }); + + /// Creates a new separator menu item. + DBusMenuItem.separator({int? id, bool visible = true}) + : this(id: id, type: 'separator', visible: visible); + + // Creates a new checkmark menu item. If [state] is true the item is checked. + DBusMenuItem.checkmark( + String label, { + int? id, + bool visible = true, + bool enabled = true, + bool state = false, + Future Function()? onClicked, + }) : this( + id: id, + visible: visible, + enabled: enabled, + label: label, + toggleType: 'checkmark', + toggleState: state ? 1 : 0, + onClicked: onClicked, + ); + + // Creates a new radio menu item. If [state] is true the item is active. + DBusMenuItem.radio( + String label, { + int? id, + bool visible = true, + bool enabled = true, + bool state = false, + Future Function()? onClicked, + }) : this( + id: id, + visible: visible, + enabled: enabled, + label: label, + toggleType: 'radio', + toggleState: state ? 1 : 0, + onClicked: onClicked, + ); +} + +class DBusMenuObject extends DBusObject { + // The menu being exported over DBus. + DBusMenuItem menu; + + final DBusMenuDiagnosticLog? diagnosticLog; + final _itemsById = {}; + final _idsByItem = {}; + var _nextGeneratedId = 0; + + DBusMenuObject(DBusObjectPath path, this.menu, {this.diagnosticLog}) + : super(path) { + _registerIds(menu); + } + + /// Export an updated [menu]. This must have the same number and layout of items as the previous menu. + Future update(DBusMenuItem menu) async { + // Calculate what has changed. + var updatedProperties = []; + var removedProperties = []; + _makeMenuItemPropertiesUpdated( + this.menu, + menu, + updatedProperties, + removedProperties, + ); + + // Replace old menu. + _itemsById.clear(); + _idsByItem.clear(); + _nextGeneratedId = 0; + this.menu = menu; + _registerIds(menu); + + await emitSignal('com.canonical.dbusmenu', 'ItemsPropertiesUpdated', [ + DBusArray(DBusSignature('(ia{sv})'), updatedProperties), + DBusArray(DBusSignature('(ias)'), removedProperties), + ]); + } + + void _makeMenuItemPropertiesUpdated( + DBusMenuItem originalItem, + DBusMenuItem newItem, + List allUpdatedProperties, + List allRemovedProperties, + ) { + var id = _idsByItem[originalItem]!; + var originalProperties = _makeMenuItemProperties(originalItem); + var newProperties = _makeMenuItemProperties(newItem); + var updatedProperties = _getUpdatedProperties( + originalProperties, + newProperties, + ); + if (updatedProperties.isNotEmpty) { + allUpdatedProperties.add( + DBusStruct([DBusInt32(id), DBusDict.stringVariant(updatedProperties)]), + ); + } + var removedProperties = _getRemovedProperties( + originalProperties, + newProperties, + ); + if (removedProperties.isNotEmpty) { + allRemovedProperties.add( + DBusStruct([DBusInt32(id), DBusArray.string(removedProperties)]), + ); + } + + assert(originalItem.children.length == newItem.children.length); + for (var i = 0; i < originalItem.children.length; i++) { + _makeMenuItemPropertiesUpdated( + originalItem.children[i], + newItem.children[i], + allUpdatedProperties, + allRemovedProperties, + ); + } + } + + @override + List introspect() { + return [ + DBusIntrospectInterface( + 'com.canonical.dbusmenu', + methods: [ + DBusIntrospectMethod( + 'AboutToShow', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'id', + ), + DBusIntrospectArgument( + DBusSignature('b'), + DBusArgumentDirection.out, + name: 'needsUpdate', + ), + ], + ), + DBusIntrospectMethod( + 'AboutToShowGroup', + args: [ + DBusIntrospectArgument( + DBusSignature('ai'), + DBusArgumentDirection.in_, + name: 'ids', + ), + DBusIntrospectArgument( + DBusSignature('ai'), + DBusArgumentDirection.out, + name: 'updatesNeeded', + ), + DBusIntrospectArgument( + DBusSignature('ai'), + DBusArgumentDirection.out, + name: 'idErrors', + ), + ], + ), + DBusIntrospectMethod( + 'Event', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'id', + ), + DBusIntrospectArgument( + DBusSignature('s'), + DBusArgumentDirection.in_, + name: 'eventId', + ), + DBusIntrospectArgument( + DBusSignature('v'), + DBusArgumentDirection.in_, + name: 'data', + ), + DBusIntrospectArgument( + DBusSignature('u'), + DBusArgumentDirection.in_, + name: 'timestamp', + ), + ], + ), + DBusIntrospectMethod( + 'EventGroup', + args: [ + DBusIntrospectArgument( + DBusSignature('a(isvu)'), + DBusArgumentDirection.in_, + name: 'events', + ), + DBusIntrospectArgument( + DBusSignature('ai'), + DBusArgumentDirection.out, + name: 'idErrors', + ), + ], + ), + DBusIntrospectMethod( + 'GetLayout', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'parentId', + ), + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'recursionDepth', + ), + DBusIntrospectArgument( + DBusSignature('as'), + DBusArgumentDirection.in_, + name: 'propertyNames', + ), + DBusIntrospectArgument( + DBusSignature('u'), + DBusArgumentDirection.out, + name: 'revision', + ), + DBusIntrospectArgument( + DBusSignature('(ia{sv}av)'), + DBusArgumentDirection.out, + name: 'layout', + ), + ], + ), + DBusIntrospectMethod( + 'GetGroupProperties', + args: [ + DBusIntrospectArgument( + DBusSignature('ai'), + DBusArgumentDirection.in_, + name: 'ids', + ), + DBusIntrospectArgument( + DBusSignature('as'), + DBusArgumentDirection.in_, + name: 'propertyNames', + ), + DBusIntrospectArgument( + DBusSignature('a(ia{sv})'), + DBusArgumentDirection.out, + name: 'properties', + ), + ], + ), + DBusIntrospectMethod( + 'GetProperty', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'id', + ), + DBusIntrospectArgument( + DBusSignature('s'), + DBusArgumentDirection.in_, + name: 'name', + ), + DBusIntrospectArgument( + DBusSignature('v'), + DBusArgumentDirection.out, + name: 'value', + ), + ], + ), + ], + signals: [ + DBusIntrospectSignal( + 'ItemsPropertiesUpdated', + args: [ + DBusIntrospectArgument( + DBusSignature('a(ia{sv})'), + DBusArgumentDirection.out, + name: 'updatedProps', + ), + DBusIntrospectArgument( + DBusSignature('a(ias)'), + DBusArgumentDirection.out, + name: 'removedProps', + ), + ], + ), + DBusIntrospectSignal( + 'LayoutUpdated', + args: [ + DBusIntrospectArgument( + DBusSignature('u'), + DBusArgumentDirection.out, + name: 'revision', + ), + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.out, + name: 'parent', + ), + ], + ), + DBusIntrospectSignal( + 'ItemActivationRequested', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.out, + name: 'id', + ), + DBusIntrospectArgument( + DBusSignature('u'), + DBusArgumentDirection.out, + name: 'timestamp', + ), + ], + ), + ], + properties: [ + DBusIntrospectProperty( + 'IconThemePath', + DBusSignature('as'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'Status', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'TextDirection', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'Version', + DBusSignature('u'), + access: DBusPropertyAccess.read, + ), + ], + ), + ]; + } + + @override + Future handleMethodCall(DBusMethodCall methodCall) async { + if (methodCall.interface != 'com.canonical.dbusmenu') { + return DBusMethodErrorResponse.unknownInterface(); + } + _log( + 'DBusMenu.${methodCall.name} received: ' + 'signature=${methodCall.signature}', + ); + + switch (methodCall.name) { + case 'AboutToShow': + if (methodCall.signature != DBusSignature('i')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var id = methodCall.values[0].asInt32(); + _log('DBusMenu.AboutToShow details: id=$id'); + var item = _getItem(id); + if (item == null) { + return DBusMethodErrorResponse('com.canonical.dbusmenu.UnknownId'); + } + var needsUpdate = await item.onAboutToShow?.call() ?? false; + return DBusMethodSuccessResponse([DBusBoolean(needsUpdate)]); + case 'AboutToShowGroup': + if (methodCall.signature != DBusSignature('ai')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var ids = methodCall.values[0].asInt32Array(); + _log('DBusMenu.AboutToShowGroup details: ids=${ids.join(',')}'); + var updatesNeeded = []; + var idErrors = []; + for (var id in ids) { + var item = _getItem(id); + if (item == null) { + idErrors.add(id); + } else { + var needsUpdate = await item.onAboutToShow?.call() ?? false; + if (needsUpdate) updatesNeeded.add(id); + } + } + return DBusMethodSuccessResponse([ + DBusArray.int32(updatesNeeded), + DBusArray.int32(idErrors), + ]); + case 'Event': + if (methodCall.signature != DBusSignature('isvu')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var id = methodCall.values[0].asInt32(); + var eventId = methodCall.values[1].asString(); + var data = methodCall.values[2].asVariant(); + var timestamp = methodCall.values[3].asUint32(); + _log( + 'DBusMenu.Event details: id=$id event=$eventId ' + 'timestamp=$timestamp data_signature=${data.signature}', + ); + var item = _getItem(id); + if (item == null) { + return DBusMethodErrorResponse('com.canonical.dbusmenu.UnknownId'); + } + await _handleEvent(item, eventId, data, timestamp); + return DBusMethodSuccessResponse(); + case 'EventGroup': + if (methodCall.signature != DBusSignature('a(isvu)')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var events = methodCall.values[0].asArray(); + _log('DBusMenu.EventGroup details: count=${events.length}'); + var idErrors = []; + for (var event in events) { + var values = event.asStruct(); + var id = values[0].asInt32(); + var eventId = values[1].asString(); + var data = values[2].asVariant(); + var timestamp = values[3].asUint32(); + _log( + 'DBusMenu.EventGroup item details: id=$id event=$eventId ' + 'timestamp=$timestamp data_signature=${data.signature}', + ); + var item = _getItem(id); + if (item == null) { + idErrors.add(id); + } else { + await _handleEvent(item, eventId, data, timestamp); + } + } + return DBusMethodSuccessResponse([DBusArray.int32(idErrors)]); + case 'GetLayout': + if (methodCall.signature != DBusSignature('iias')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var parentId = methodCall.values[0].asInt32(); + var recursionDepth = methodCall.values[1].asInt32(); + var propertyNames = methodCall.values[2].asStringArray(); + _log( + 'DBusMenu.GetLayout details: parent_id=$parentId ' + 'recursion_depth=$recursionDepth ' + 'properties=${propertyNames.join(',')}', + ); + var item = _getItem(parentId); + if (item == null) { + return DBusMethodErrorResponse('com.canonical.dbusmenu.UnknownId'); + } + var revision = 1; + return DBusMethodSuccessResponse([ + DBusUint32(revision), + _makeMenuItem(item, recursionDepth, propertyNames), + ]); + case 'GetGroupProperties': + if (methodCall.signature != DBusSignature('aias')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var ids = methodCall.values[0].asInt32Array(); + var propertyNames = methodCall.values[1].asStringArray(); + _log( + 'DBusMenu.GetGroupProperties details: ids=${ids.join(',')} ' + 'properties=${propertyNames.join(',')}', + ); + var itemProperties = []; + for (var id in ids) { + var item = _getItem(id); + if (item != null) { + itemProperties.add( + DBusStruct([ + DBusInt32(id), + DBusDict.stringVariant( + _makeMenuItemProperties(item, propertyNames), + ), + ]), + ); + } + } + return DBusMethodSuccessResponse([ + DBusArray(DBusSignature('(ia{sv})'), itemProperties), + ]); + case 'GetProperty': + if (methodCall.signature != DBusSignature('is')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var id = methodCall.values[0].asInt32(); + var name = methodCall.values[1].asString(); + _log('DBusMenu.GetProperty details: id=$id name=$name'); + var item = _getItem(id); + if (item == null) { + return DBusMethodErrorResponse('com.canonical.dbusmenu.UnknownId'); + } + var properties = _makeMenuItemProperties(item); + var property = properties[name]; + if (property == null) { + return DBusMethodErrorResponse( + 'com.canonical.dbusmenu.UnknownProperty', + ); + } + return DBusMethodSuccessResponse([DBusVariant(property)]); + default: + return DBusMethodErrorResponse.unknownMethod(); + } + } + + @override + Future getProperty(String interface, String name) async { + if (interface != 'com.canonical.dbusmenu') { + return DBusMethodErrorResponse.unknownProperty(); + } + _log('DBusMenu property read: name=$name'); + switch (name) { + case 'IconThemePath': + return DBusGetPropertyResponse(DBusArray.string(const [])); + case 'Status': + return DBusGetPropertyResponse(DBusString('normal')); + case 'TextDirection': + return DBusGetPropertyResponse(DBusString('ltr')); + case 'Version': + return DBusGetPropertyResponse(DBusUint32(4)); + default: + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future getAllProperties(String interface) async { + if (interface != 'com.canonical.dbusmenu') { + return DBusMethodErrorResponse.unknownProperty(); + } + _log('DBusMenu all properties read'); + return DBusGetAllPropertiesResponse({ + 'IconThemePath': DBusArray.string(const []), + 'Status': DBusString('normal'), + 'TextDirection': DBusString('ltr'), + 'Version': DBusUint32(4), + }); + } + + // Register a new [item] and assign it an id. + void _registerIds(DBusMenuItem item) { + var id = item.id ?? _nextAvailableGeneratedId(); + if (id < 0 || _itemsById.containsKey(id)) { + throw ArgumentError.value(id, 'id', 'must be a unique non-negative ID'); + } + _itemsById[id] = item; + _idsByItem[item] = id; + item.children.forEach(_registerIds); + } + + int _nextAvailableGeneratedId() { + while (_itemsById.containsKey(_nextGeneratedId)) { + _nextGeneratedId++; + } + return _nextGeneratedId++; + } + + // Build properties on menu items. + Map _makeMenuItemProperties( + DBusMenuItem item, [ + Iterable propertyNames = const [], + ]) { + var properties = {}; + if (item.type != null) { + properties['type'] = DBusString(item.type!); + } + if (item.enabled != null) { + properties['enabled'] = DBusBoolean(item.enabled!); + } + if (item.visible != null) { + properties['visible'] = DBusBoolean(item.visible!); + } + if (item.label != null) { + properties['label'] = DBusString(item.label!); + } + if (item.toggleType != null) { + properties['toggle-type'] = DBusString(item.toggleType!); + } + if (item.toggleState != null) { + properties['toggle-state'] = DBusInt32(item.toggleState!); + } + if (item.children.isNotEmpty) { + properties['children-display'] = DBusString('submenu'); + } + if (propertyNames.isNotEmpty) { + final names = propertyNames.toSet(); + properties.removeWhere((key, value) => !names.contains(key)); + } + return properties; + } + + // Returns properties in [newProperties] that are new or have changed values from [originalProperties]. + static Map _getUpdatedProperties( + Map originalProperties, + Map newProperties, + ) { + return Map.fromEntries( + newProperties.entries.where( + (entry) => + !originalProperties.containsKey(entry.key) || + originalProperties[entry.key] != entry.value, + ), + ); + } + + // Returns names of properties that are in [originalProperties] but not in [newProperties]. + static List _getRemovedProperties( + Map originalProperties, + Map newProperties, + ) { + return originalProperties.keys + .where((name) => !newProperties.containsKey(name)) + .toList(); + } + + // Build description of menu items. + DBusValue _makeMenuItem( + DBusMenuItem item, + int recursionDepth, [ + Iterable propertyNames = const [], + ]) { + List children = []; + if (recursionDepth != 0) { + var nextRecursionDepth = + recursionDepth < 0 ? recursionDepth : recursionDepth - 1; + for (var child in item.children) { + children.add(_makeMenuItem(child, nextRecursionDepth, propertyNames)); + } + } + return DBusStruct([ + DBusInt32(_idsByItem[item] ?? -1), + DBusDict.stringVariant(_makeMenuItemProperties(item, propertyNames)), + DBusArray.variant(children), + ]); + } + + // Get the item with the given [id]. + DBusMenuItem? _getItem(int id) { + return _itemsById[id]; + } + + // Handle a received event. + Future _handleEvent( + DBusMenuItem item, + String eventId, + DBusValue data, + int timestamp, + ) async { + final id = _idsByItem[item] ?? -1; + _log( + 'DBusMenu event routed: id=$id event=$eventId ' + 'timestamp=$timestamp data_signature=${data.signature}', + ); + switch (eventId) { + case 'opened': + await item.onOpened?.call(); + break; + case 'closed': + await item.onClosed?.call(); + break; + case 'clicked': + await item.onClicked?.call(); + break; + } + } + + void _log(String message) { + diagnosticLog?.call(message); + } +} diff --git a/third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart b/third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart new file mode 100644 index 0000000..af0710c --- /dev/null +++ b/third_party/xdg_status_notifier_item/lib/src/status_notifier_item_client.dart @@ -0,0 +1,549 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:dbus/dbus.dart'; + +import 'dbus_menu_object.dart'; + +typedef StatusNotifierDiagnosticLog = void Function(String message); + +const _kdeStatusNotifierItemInterface = 'org.kde.StatusNotifierItem'; +const _freedesktopStatusNotifierItemInterface = + 'org.freedesktop.StatusNotifierItem'; +const defaultStatusNotifierMenuPath = DBusObjectPath.unchecked( + '/StatusNotifierItem/menu', +); + +/// Category for notifier items. +enum StatusNotifierItemCategory { + applicationStatus, + communications, + systemServices, + hardware, +} + +/// Status for notifier items. +enum StatusNotifierItemStatus { passive, active } + +String _encodeCategory(StatusNotifierItemCategory value) => + { + StatusNotifierItemCategory.applicationStatus: 'ApplicationStatus', + StatusNotifierItemCategory.communications: 'Communications', + StatusNotifierItemCategory.systemServices: 'SystemServices', + StatusNotifierItemCategory.hardware: 'Hardware', + }[value] ?? + ''; + +String _encodeStatus(StatusNotifierItemStatus value) => + { + StatusNotifierItemStatus.passive: 'Passive', + StatusNotifierItemStatus.active: 'Active', + }[value] ?? + ''; + +class _StatusNotifierItemObject extends DBusObject { + final StatusNotifierItemCategory category; + final String id; + String title; + StatusNotifierItemStatus status; + final int windowId; + String iconName; + String overlayIconName; + String attentionIconName; + String attentionMovieName; + final bool itemIsMenu; + final DBusObjectPath menu; + Future Function(int x, int y)? onContextMenu; + Future Function(int x, int y)? onActivate; + Future Function(int x, int y)? onSecondaryActivate; + Future Function(int delta, String orientation)? onScroll; + final StatusNotifierDiagnosticLog? diagnosticLog; + + _StatusNotifierItemObject({ + this.category = StatusNotifierItemCategory.applicationStatus, + required this.id, + this.title = '', + this.status = StatusNotifierItemStatus.active, + this.windowId = 0, + this.iconName = '', + this.overlayIconName = '', + this.attentionIconName = '', + this.attentionMovieName = '', + this.itemIsMenu = false, + this.menu = DBusObjectPath.root, + this.onContextMenu, + this.onActivate, + this.onSecondaryActivate, + this.onScroll, + this.diagnosticLog, + }) : super(DBusObjectPath('/StatusNotifierItem')); + + @override + List introspect() { + return [ + _introspectStatusNotifierInterface(_kdeStatusNotifierItemInterface), + _introspectStatusNotifierInterface( + _freedesktopStatusNotifierItemInterface, + ), + ]; + } + + DBusIntrospectInterface _introspectStatusNotifierInterface(String name) { + return DBusIntrospectInterface( + name, + methods: [ + DBusIntrospectMethod( + 'ContextMenu', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'x', + ), + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'y', + ), + ], + ), + DBusIntrospectMethod( + 'Activate', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'x', + ), + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'y', + ), + ], + ), + DBusIntrospectMethod( + 'SecondaryActivate', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'x', + ), + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'y', + ), + ], + ), + DBusIntrospectMethod( + 'Scroll', + args: [ + DBusIntrospectArgument( + DBusSignature('i'), + DBusArgumentDirection.in_, + name: 'delta', + ), + DBusIntrospectArgument( + DBusSignature('s'), + DBusArgumentDirection.in_, + name: 'orientation', + ), + ], + ), + DBusIntrospectMethod( + 'ProvideXdgActivationToken', + args: [ + DBusIntrospectArgument( + DBusSignature('s'), + DBusArgumentDirection.in_, + name: 'token', + ), + ], + ), + ], + signals: [], + properties: [ + DBusIntrospectProperty( + 'Category', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'Id', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'Title', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'Status', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'WindowId', + DBusSignature('i'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'IconName', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'IconPixmap', + DBusSignature('a(iiay)'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'OverlayIconName', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'OverlayIconPixmap', + DBusSignature('a(iiay)'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'AttentionIconName', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'AttentionIconPixmap', + DBusSignature('a(iiay)'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'AttentionMovieName', + DBusSignature('s'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'ToolTip', + DBusSignature('(sa(iiay))'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'ItemIsMenu', + DBusSignature('b'), + access: DBusPropertyAccess.read, + ), + DBusIntrospectProperty( + 'Menu', + DBusSignature('o'), + access: DBusPropertyAccess.read, + ), + ], + ); + } + + @override + Future handleMethodCall(DBusMethodCall methodCall) async { + if (!_isStatusNotifierInterface(methodCall.interface)) { + return DBusMethodErrorResponse.unknownInterface(); + } + + switch (methodCall.name) { + case 'ContextMenu': + if (methodCall.signature != DBusSignature('ii')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var x = methodCall.values[0].asInt32(); + var y = methodCall.values[1].asInt32(); + _logStatusNotifierCall(methodCall, 'x=$x y=$y'); + await onContextMenu?.call(x, y); + return DBusMethodSuccessResponse(); + case 'Activate': + if (methodCall.signature != DBusSignature('ii')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var x = methodCall.values[0].asInt32(); + var y = methodCall.values[1].asInt32(); + _logStatusNotifierCall(methodCall, 'x=$x y=$y'); + await onActivate?.call(x, y); + return DBusMethodSuccessResponse(); + case 'SecondaryActivate': + if (methodCall.signature != DBusSignature('ii')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var x = methodCall.values[0].asInt32(); + var y = methodCall.values[1].asInt32(); + _logStatusNotifierCall(methodCall, 'x=$x y=$y'); + await onSecondaryActivate?.call(x, y); + return DBusMethodSuccessResponse(); + case 'Scroll': + if (methodCall.signature != DBusSignature('is')) { + return DBusMethodErrorResponse.invalidArgs(); + } + var delta = methodCall.values[0].asInt32(); + var orientation = methodCall.values[1].asString(); + _logStatusNotifierCall( + methodCall, + 'delta=$delta orientation=$orientation', + ); + await onScroll?.call(delta, orientation); + return DBusMethodSuccessResponse(); + case 'ProvideXdgActivationToken': + if (methodCall.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + _logStatusNotifierCall(methodCall, 'token='); + return DBusMethodSuccessResponse(); + default: + return DBusMethodErrorResponse.unknownMethod(); + } + } + + @override + Future getProperty(String interface, String name) async { + if (!_isStatusNotifierInterface(interface)) { + return DBusMethodErrorResponse.unknownProperty(); + } + + switch (name) { + case 'Category': + return DBusGetPropertyResponse(DBusString(_encodeCategory(category))); + case 'Id': + return DBusGetPropertyResponse(DBusString(id)); + case 'Title': + return DBusGetPropertyResponse(DBusString(title)); + case 'Status': + return DBusGetPropertyResponse(DBusString(_encodeStatus(status))); + case 'WindowId': + return DBusGetPropertyResponse(DBusInt32(windowId)); + case 'IconName': + return DBusGetPropertyResponse(DBusString(iconName)); + case 'IconPixmap': + return DBusGetPropertyResponse(DBusArray(DBusSignature('(iiay)'), [])); + case 'OverlayIconName': + return DBusGetPropertyResponse(DBusString(overlayIconName)); + case 'OverlayIconPixmap': + return DBusGetPropertyResponse(DBusArray(DBusSignature('(iiay)'), [])); + case 'AttentionIconName': + return DBusGetPropertyResponse(DBusString(attentionIconName)); + case 'AttentionIconPixmap': + return DBusGetPropertyResponse(DBusArray(DBusSignature('(iiay)'), [])); + case 'AttentionMovieName': + return DBusGetPropertyResponse(DBusString(attentionMovieName)); + case 'ToolTip': + return DBusGetPropertyResponse( + DBusStruct([ + DBusString(''), + DBusArray(DBusSignature('(iiay)'), []), + DBusString(''), + DBusString(''), + ]), + ); + case 'ItemIsMenu': + return DBusGetPropertyResponse(DBusBoolean(itemIsMenu)); + case 'Menu': + return DBusGetPropertyResponse(menu); + default: + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future getAllProperties(String interface) async { + if (!_isStatusNotifierInterface(interface)) { + return DBusMethodErrorResponse.unknownProperty(); + } + return DBusGetAllPropertiesResponse({ + 'Category': DBusString(_encodeCategory(category)), + 'Id': DBusString(id), + 'Title': DBusString(title), + 'Status': DBusString(_encodeStatus(status)), + 'WindowId': DBusInt32(windowId), + 'IconName': DBusString(iconName), + 'IconPixmap': DBusArray(DBusSignature('(iiay)'), []), + 'OverlayIconName': DBusString(overlayIconName), + 'OverlayIconPixmap': DBusArray(DBusSignature('(iiay)'), []), + 'AttentionIconName': DBusString(attentionIconName), + 'AttentionIconPixmap': DBusArray(DBusSignature('(iiay)'), []), + 'AttentionMovieName': DBusString(attentionMovieName), + 'ToolTip': DBusStruct([ + DBusString(''), + DBusArray(DBusSignature('(iiay)'), []), + DBusString(''), + DBusString(''), + ]), + 'ItemIsMenu': DBusBoolean(itemIsMenu), + 'Menu': menu, + }); + } + + bool _isStatusNotifierInterface(String? interface) { + return interface == _kdeStatusNotifierItemInterface || + interface == _freedesktopStatusNotifierItemInterface; + } + + void _logStatusNotifierCall(DBusMethodCall methodCall, String detail) { + diagnosticLog?.call( + 'StatusNotifierItem.${methodCall.name} received: ' + 'interface=${methodCall.interface} signature=${methodCall.signature} ' + '$detail', + ); + } +} + +/// A client that registers status notifier items. +class StatusNotifierItemClient { + /// The bus this client is connected to. + final DBusClient _bus; + final bool _closeBus; + final StatusNotifierDiagnosticLog? _diagnosticLog; + + late final DBusMenuObject _menuObject; + late final _StatusNotifierItemObject _notifierItemObject; + late final String _busName; + + // FIXME: status enum + /// Creates a new status notifier item client. If [bus] is provided connect to the given D-Bus server. + StatusNotifierItemClient({ + required String id, + StatusNotifierItemCategory category = + StatusNotifierItemCategory.applicationStatus, + String title = '', + StatusNotifierItemStatus status = StatusNotifierItemStatus.active, + int windowId = 0, + String iconName = '', + String overlayIconName = '', + String attentionIconName = '', + String attentionMovieName = '', + bool itemIsMenu = false, + DBusObjectPath menuPath = defaultStatusNotifierMenuPath, + required DBusMenuItem menu, + Future Function(int x, int y)? onContextMenu, + Future Function(int x, int y)? onActivate, + Future Function(int x, int y)? onSecondaryActivate, + Future Function(int delta, String orientation)? onScroll, + StatusNotifierDiagnosticLog? diagnosticLog, + DBusClient? bus, + }) : _bus = bus ?? DBusClient.session(), + _closeBus = bus == null, + _diagnosticLog = diagnosticLog { + _busName = 'org.kde.StatusNotifierItem-$pid-1'; + _menuObject = DBusMenuObject( + menuPath, + menu, + diagnosticLog: diagnosticLog, + ); + _notifierItemObject = _StatusNotifierItemObject( + id: id, + category: category, + title: title, + status: status, + windowId: windowId, + iconName: iconName, + overlayIconName: overlayIconName, + attentionIconName: attentionIconName, + attentionMovieName: attentionMovieName, + itemIsMenu: itemIsMenu, + menu: _menuObject.path, + onContextMenu: onContextMenu, + onActivate: onActivate, + onSecondaryActivate: onSecondaryActivate, + onScroll: onScroll, + diagnosticLog: diagnosticLog, + ); + } + + String get busName => _busName; + + DBusObjectPath get menuPath => _menuObject.path; + + DBusObjectPath get itemPath => _notifierItemObject.path; + + // Connect to D-Bus and register this notifier item. + Future connect() async { + _log( + 'StatusNotifierItem registration starting: bus_name=$_busName ' + 'item_path=${itemPath.value} menu_path=${menuPath.value}', + ); + DBusRequestNameReply requestResult; + try { + requestResult = await _bus.requestName(_busName); + } on Object catch (error) { + _log('StatusNotifierItem bus name request failed: error=$error'); + rethrow; + } + if (requestResult != DBusRequestNameReply.primaryOwner) { + _log( + 'StatusNotifierItem registration failed: ' + 'request_result=$requestResult', + ); + throw StateError( + 'Unable to own StatusNotifierItem bus name: $requestResult', + ); + } + _log('StatusNotifierItem bus name acquired: bus_name=$_busName'); + + // Register the menu. + try { + _log('DBus menu registration starting: path=${menuPath.value}'); + await _bus.registerObject(_menuObject); + _log('DBus menu registration succeeded: path=${menuPath.value}'); + } on Object catch (error) { + _log( + 'DBus menu registration failed: path=${menuPath.value} error=$error', + ); + rethrow; + } + + // Put the item on the bus. + try { + _log( + 'StatusNotifierItem object registration starting: path=${itemPath.value}'); + await _bus.registerObject(_notifierItemObject); + _log( + 'StatusNotifierItem object registration succeeded: path=${itemPath.value}'); + } on Object catch (error) { + _log( + 'StatusNotifierItem object registration failed: ' + 'path=${itemPath.value} error=$error', + ); + rethrow; + } + + // Register the item. + try { + await _bus.callMethod( + destination: 'org.kde.StatusNotifierWatcher', + path: DBusObjectPath('/StatusNotifierWatcher'), + interface: 'org.kde.StatusNotifierWatcher', + name: 'RegisterStatusNotifierItem', + values: [DBusString(_busName)], + replySignature: DBusSignature.empty, + ); + _log('StatusNotifierItem registration succeeded: bus_name=$_busName'); + } on Object catch (error) { + _log( + 'StatusNotifierItem watcher registration failed: ' + 'bus_name=$_busName error=$error', + ); + rethrow; + } + } + + /// Updates the menu shown. + Future updateMenu(DBusMenuItem menu) async { + await _menuObject.update(menu); + } + + /// Terminates all active connections. If a client remains unclosed, the Dart process may not terminate. + Future close() async { + if (_closeBus) { + await _bus.close(); + } + } + + void _log(String message) { + _diagnosticLog?.call(message); + } +} diff --git a/third_party/xdg_status_notifier_item/lib/xdg_status_notifier_item.dart b/third_party/xdg_status_notifier_item/lib/xdg_status_notifier_item.dart new file mode 100644 index 0000000..9ff7ccb --- /dev/null +++ b/third_party/xdg_status_notifier_item/lib/xdg_status_notifier_item.dart @@ -0,0 +1,2 @@ +export 'src/status_notifier_item_client.dart'; +export 'src/dbus_menu_object.dart'; diff --git a/third_party/xdg_status_notifier_item/pubspec.yaml b/third_party/xdg_status_notifier_item/pubspec.yaml new file mode 100644 index 0000000..90b3197 --- /dev/null +++ b/third_party/xdg_status_notifier_item/pubspec.yaml @@ -0,0 +1,21 @@ +name: xdg_status_notifier_item +version: 0.0.1 + +description: + Allows status notifications (i.e. system tray) on Linux desktops. + +homepage: https://github.com/canonical/xdg_status_notifier_item.dart + +environment: + sdk: '>=2.12.0 <4.0.0' + +platforms: + linux: + +dependencies: + dbus: ^0.7.8 + +dev_dependencies: + lints: ^2.0.0 + test: ^1.16.8 + test_cov: ^1.0.1