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 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/ 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/assets/branding/busymax-logo.png b/assets/branding/busymax-logo.png new file mode 100644 index 0000000..8ae37b3 Binary files /dev/null and b/assets/branding/busymax-logo.png differ diff --git a/docs/beta_snap_release.md b/docs/beta_snap_release.md new file mode 100644 index 0000000..bc691fc --- /dev/null +++ b/docs/beta_snap_release.md @@ -0,0 +1,80 @@ +# 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 +mkdir -p .snap-local +$EDITOR .snap-local/busymax-dart-defines.json +snapcraft pack --use-lxd +``` + +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 + +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 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 + 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 + +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. +- `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 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 + +Report beta issues at https://github.com/busystack/busymax/issues. + +Source code is available at https://github.com/busystack/busymax. 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 a12fca7..f82647c 100644 --- a/lib/src/app/app_settings.dart +++ b/lib/src/app/app_settings.dart @@ -64,7 +64,7 @@ class AppSettings { notifyDueToday: false, notifyEventReminders: true, notifyTaskReminders: true, - runInBackgroundWhenClosed: false, + runInBackgroundWhenClosed: true, showTrayIcon: true, startMinimizedToTray: false, quitExitsCompletely: true, 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..ab3cb1d 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() { @@ -87,7 +89,7 @@ class _BusyMaxAppState extends ConsumerState { ref, settings, BusyMaxTrayLabels( - openBusyMax: l10n.trayAgendaOpenBusyMax, + openBusyMax: l10n.compactAgendaOpenBusyMax, agenda: l10n.viewAgenda, quitBusyMax: l10n.exit, ), @@ -164,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)); } @@ -190,11 +192,45 @@ class _BusyMaxAppState extends ConsumerState { onBeforeQuit: compactAgendaWindows.closeIfOpen, ); if (trayEnabled) { - unawaited(tray.start()); + unawaited( + _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) { + return; + } + _setHideOnClose(windowService, runInBackgroundWhenClosed && tray.available); + if (!startMinimizedToTray || _startMinimizedHandled || !tray.available) { + return; + } + _startMinimizedHandled = true; + await windowService.hideWindow(); + } } class _BusyMaxWindowCornerClip extends StatelessWidget { 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/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/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/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), 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) => 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/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/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 c1d5c48..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, @@ -2670,6 +2745,11 @@ 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) { + restore_main_window(self); + return; + } + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); self->main_window = window; @@ -2949,5 +3029,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)); } 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 7b36199..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,11 +53,16 @@ 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 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. diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..17ca689 --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,70 @@ +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 + - unity7 + - wayland + - x11 + environment: + 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 + +slots: + busymax-dbus: + interface: dbus + bus: session + name: io.busystack.busymax + +parts: + busymax: + plugin: dump + source: build/linux/x64/release/bundle + stage-packages: + - liblzma5 + - libsecret-1-0 + 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" 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/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 d10a1e4..708d2b6 100644 --- a/test/app/theme_localization_test.dart +++ b/test/app/theme_localization_test.dart @@ -762,6 +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().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/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/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 36e031d..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', () { @@ -93,6 +121,29 @@ 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('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( @@ -103,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( @@ -121,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', ); 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); + }); + }); +} 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