diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml index 7a8a3e9e..ad700fa6 100644 --- a/.github/workflows/android.yaml +++ b/.github/workflows/android.yaml @@ -17,7 +17,7 @@ jobs: uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.7.3' + flutter-version: '3.7.5' - run: | flutter config flutter --version diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index 50a80bce..998cc326 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest env: PYVER: 3.11 - FLUTTER: '3.7.3' + FLUTTER: '3.7.5' container: image: ubuntu:18.04 env: @@ -18,7 +18,7 @@ jobs: - name: Install dependencies run: | apt-get update - apt-get install -qq software-properties-common + apt-get install -qq software-properties-common libnotify-dev libayatana-appindicator3-dev patchelf add-apt-repository -y ppa:git-core/ppa add-apt-repository -y ppa:deadsnakes/ppa apt-get install -qq git python$PYVER-dev python$PYVER-venv @@ -82,6 +82,18 @@ jobs: - name: Check generated files run: git diff --exit-code + - name: Embedd appindicator + run: | + patchelf --set-rpath '$ORIGIN' build/linux/x64/release/bundle/lib/libtray_manager_plugin.so + cp -L /usr/lib/x86_64-linux-gnu/libayatana-appindicator3.so.1 build/linux/x64/release/bundle/lib/ + patchelf --set-rpath '$ORIGIN' build/linux/x64/release/bundle/lib/libayatana-appindicator3.so.1 + cp -L /usr/lib/x86_64-linux-gnu/libayatana-indicator3.so.7 build/linux/x64/release/bundle/lib/ + patchelf --set-rpath '$ORIGIN' build/linux/x64/release/bundle/lib/libayatana-indicator3.so.7 + cp -L /usr/lib/x86_64-linux-gnu/libdbusmenu-glib.so.4 build/linux/x64/release/bundle/lib/ + patchelf --set-rpath '$ORIGIN' build/linux/x64/release/bundle/lib/libdbusmenu-glib.so.4 + cp -L /usr/lib/x86_64-linux-gnu/libdbusmenu-gtk3.so.4 build/linux/x64/release/bundle/lib/ + patchelf --set-rpath '$ORIGIN' build/linux/x64/release/bundle/lib/libdbusmenu-gtk3.so.4 + - name: Rename and archive app run: | export REF=$(echo ${GITHUB_REF} | cut -d '/' -f 3) diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 33c02e44..8eeaf139 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -49,7 +49,7 @@ jobs: with: channel: 'stable' architecture: 'x64' - flutter-version: '3.7.3' + flutter-version: '3.7.5' - run: flutter config --enable-macos-desktop - run: flutter --version diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 76a932c2..1c1acd55 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -45,7 +45,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.7.3' + flutter-version: '3.7.5' - run: flutter config --enable-windows-desktop - run: flutter --version diff --git a/integration_test/oath_test_util.dart b/integration_test/oath_test_util.dart index 8627c312..ed8844b6 100644 --- a/integration_test/oath_test_util.dart +++ b/integration_test/oath_test_util.dart @@ -112,6 +112,7 @@ extension OathFunctions on WidgetTester { } await shortWait(); + /// find an AccountView with issuer/name in the account list var matchingAccounts = find.descendant( of: findAccountList(), @@ -146,8 +147,11 @@ extension OathFunctions on WidgetTester { expect(accountView, isNotNull); if (accountView != null) { - await ensureVisible(find.byWidget(accountView)); - await tap(find.byWidget(accountView)); + final accountFinder = find.byWidget(accountView); + await ensureVisible(accountFinder); + final codeButtonFinder = find.descendant( + of: accountFinder, matching: find.bySubtype()); + await tap(codeButtonFinder); await shortWait(); } } diff --git a/lib/about_page.dart b/lib/about_page.dart index ebac783a..201c5d55 100755 --- a/lib/about_page.dart +++ b/lib/about_page.dart @@ -162,6 +162,8 @@ class AboutPage extends ConsumerWidget { data.insert(0, { 'app_version': version, 'dart': Platform.version, + 'os': Platform.operatingSystem, + 'os_version': Platform.operatingSystemVersion, }); final text = const JsonEncoder.withIndent(' ').convert(data); await ref.read(clipboardProvider).setText(text); diff --git a/lib/app/models.dart b/lib/app/models.dart index 1670b3ec..b3363d5c 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -134,5 +134,6 @@ class WindowState with _$WindowState { required bool focused, required bool visible, required bool active, + @Default(false) bool hidden, }) = _WindowState; } diff --git a/lib/app/models.freezed.dart b/lib/app/models.freezed.dart index c7e8d4b1..945299b5 100644 --- a/lib/app/models.freezed.dart +++ b/lib/app/models.freezed.dart @@ -799,6 +799,7 @@ mixin _$WindowState { bool get focused => throw _privateConstructorUsedError; bool get visible => throw _privateConstructorUsedError; bool get active => throw _privateConstructorUsedError; + bool get hidden => throw _privateConstructorUsedError; @JsonKey(ignore: true) $WindowStateCopyWith get copyWith => @@ -811,7 +812,7 @@ abstract class $WindowStateCopyWith<$Res> { WindowState value, $Res Function(WindowState) then) = _$WindowStateCopyWithImpl<$Res, WindowState>; @useResult - $Res call({bool focused, bool visible, bool active}); + $Res call({bool focused, bool visible, bool active, bool hidden}); } /// @nodoc @@ -830,6 +831,7 @@ class _$WindowStateCopyWithImpl<$Res, $Val extends WindowState> Object? focused = null, Object? visible = null, Object? active = null, + Object? hidden = null, }) { return _then(_value.copyWith( focused: null == focused @@ -844,6 +846,10 @@ class _$WindowStateCopyWithImpl<$Res, $Val extends WindowState> ? _value.active : active // ignore: cast_nullable_to_non_nullable as bool, + hidden: null == hidden + ? _value.hidden + : hidden // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -856,7 +862,7 @@ abstract class _$$_WindowStateCopyWith<$Res> __$$_WindowStateCopyWithImpl<$Res>; @override @useResult - $Res call({bool focused, bool visible, bool active}); + $Res call({bool focused, bool visible, bool active, bool hidden}); } /// @nodoc @@ -873,6 +879,7 @@ class __$$_WindowStateCopyWithImpl<$Res> Object? focused = null, Object? visible = null, Object? active = null, + Object? hidden = null, }) { return _then(_$_WindowState( focused: null == focused @@ -887,6 +894,10 @@ class __$$_WindowStateCopyWithImpl<$Res> ? _value.active : active // ignore: cast_nullable_to_non_nullable as bool, + hidden: null == hidden + ? _value.hidden + : hidden // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -895,7 +906,10 @@ class __$$_WindowStateCopyWithImpl<$Res> class _$_WindowState implements _WindowState { _$_WindowState( - {required this.focused, required this.visible, required this.active}); + {required this.focused, + required this.visible, + required this.active, + this.hidden = false}); @override final bool focused; @@ -903,10 +917,13 @@ class _$_WindowState implements _WindowState { final bool visible; @override final bool active; + @override + @JsonKey() + final bool hidden; @override String toString() { - return 'WindowState(focused: $focused, visible: $visible, active: $active)'; + return 'WindowState(focused: $focused, visible: $visible, active: $active, hidden: $hidden)'; } @override @@ -916,11 +933,13 @@ class _$_WindowState implements _WindowState { other is _$_WindowState && (identical(other.focused, focused) || other.focused == focused) && (identical(other.visible, visible) || other.visible == visible) && - (identical(other.active, active) || other.active == active)); + (identical(other.active, active) || other.active == active) && + (identical(other.hidden, hidden) || other.hidden == hidden)); } @override - int get hashCode => Object.hash(runtimeType, focused, visible, active); + int get hashCode => + Object.hash(runtimeType, focused, visible, active, hidden); @JsonKey(ignore: true) @override @@ -933,7 +952,8 @@ abstract class _WindowState implements WindowState { factory _WindowState( {required final bool focused, required final bool visible, - required final bool active}) = _$_WindowState; + required final bool active, + final bool hidden}) = _$_WindowState; @override bool get focused; @@ -942,6 +962,8 @@ abstract class _WindowState implements WindowState { @override bool get active; @override + bool get hidden; + @override @JsonKey(ignore: true) _$$_WindowStateCopyWith<_$_WindowState> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/app/shortcuts.dart b/lib/app/shortcuts.dart index 625ebbf7..145249bd 100755 --- a/lib/app/shortcuts.dart +++ b/lib/app/shortcuts.dart @@ -24,6 +24,7 @@ import 'package:window_manager/window_manager.dart'; import '../about_page.dart'; import '../android/views/android_settings_page.dart'; import '../core/state.dart'; +import '../desktop/state.dart'; import '../oath/keys.dart'; import '../settings_page.dart'; import 'message.dart'; @@ -42,6 +43,10 @@ class CloseIntent extends Intent { const CloseIntent(); } +class HideIntent extends Intent { + const HideIntent(); +} + class SearchIntent extends Intent { const SearchIntent(); } @@ -77,6 +82,12 @@ Widget registerGlobalShortcuts( windowManager.close(); return null; }), + HideIntent: CallbackAction(onInvoke: (_) { + if (isDesktop) { + ref.read(desktopWindowStateProvider.notifier).setWindowHidden(true); + } + return null; + }), SearchIntent: CallbackAction(onInvoke: (intent) { // If the OATH view doesn't have focus, but is shown, find and select the search bar. final searchContext = searchAccountsField.currentContext; @@ -136,8 +147,7 @@ Widget registerGlobalShortcuts( child: Shortcuts( shortcuts: { LogicalKeySet(ctrlOrCmd, LogicalKeyboardKey.keyC): const CopyIntent(), - LogicalKeySet(ctrlOrCmd, LogicalKeyboardKey.keyW): - const CloseIntent(), + LogicalKeySet(ctrlOrCmd, LogicalKeyboardKey.keyW): const HideIntent(), LogicalKeySet(ctrlOrCmd, LogicalKeyboardKey.keyF): const SearchIntent(), if (isDesktop) ...{ diff --git a/lib/app/state.dart b/lib/app/state.dart index 09b4c11b..9317a040 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -15,11 +15,13 @@ */ import 'dart:async'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:yubico_authenticator/app/logging.dart'; import '../core/state.dart'; @@ -40,6 +42,32 @@ final supportedThemesProvider = StateProvider>( (ref) => throw UnimplementedError(), ); +final _l10nProvider = StateNotifierProvider<_L10nNotifier, AppLocalizations>( + (ref) => _L10nNotifier()); + +final l10nProvider = Provider( + (ref) => ref.watch(_l10nProvider), +); + +class _L10nNotifier extends StateNotifier + with WidgetsBindingObserver { + _L10nNotifier() : super(lookupAppLocalizations(window.locale)) { + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + @protected + void didChangeLocales(List? locales) { + state = lookupAppLocalizations(window.locale); + } +} + final themeModeProvider = StateNotifierProvider( (ref) => ThemeModeNotifier( ref.watch(prefProvider), ref.read(supportedThemesProvider)), diff --git a/lib/app/views/user_interaction.dart b/lib/app/views/user_interaction.dart index fbd1679a..888bcccf 100755 --- a/lib/app/views/user_interaction.dart +++ b/lib/app/views/user_interaction.dart @@ -17,6 +17,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:local_notifier/local_notifier.dart'; import '../message.dart'; @@ -130,6 +131,24 @@ UserInteractionController promptUserInteraction( required String description, Widget? icon, void Function()? onCancel, + bool headless = false, +}) { + if (headless) { + // No support for icon or onCancel. + return _notificationUserInteraction(context, + title: title, description: description); + } else { + return _dialogUserInteraction(context, + title: title, description: description, icon: icon, onCancel: onCancel); + } +} + +UserInteractionController _dialogUserInteraction( + BuildContext context, { + required String title, + required String description, + Widget? icon, + void Function()? onCancel, }) { var wasPopped = false; final controller = _UserInteractionController( @@ -164,3 +183,60 @@ UserInteractionController promptUserInteraction( return controller; } + +class _NotificationUserInteractionController extends UserInteractionController { + String title; + String description; + Widget? icon; + LocalNotification _notification; + _NotificationUserInteractionController({ + required this.title, + required this.description, + }) : _notification = LocalNotification( + title: title, + body: description, + )..show(); + + @override + void close() { + _notification.close(); + } + + Future _doUpdateNotification() async { + await _notification.close(); + await Future.delayed(const Duration(milliseconds: 200)); + _notification = LocalNotification(title: title, body: description); + await _notification.show(); + } + + @override + void updateContent({String? title, String? description, Widget? icon}) { + bool changed = false; + if (title != null) { + this.title = title; + changed = true; + } + if (description != null) { + this.description = description; + changed = true; + } + if (icon != null) { + this.icon = icon; + } + + if (changed) { + _doUpdateNotification(); + } + } +} + +UserInteractionController _notificationUserInteraction( + BuildContext context, { + required String title, + required String description, +}) { + return _NotificationUserInteractionController( + title: title, + description: description, + ); +} diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index dd3d0825..d90697ac 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -23,6 +23,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:local_notifier/local_notifier.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; @@ -47,14 +48,15 @@ import 'rpc.dart'; import 'devices.dart'; import 'qr_scanner.dart'; import 'state.dart'; +import 'systray.dart'; final _log = Logger('desktop.init'); const String _keyWidth = 'DESKTOP_WINDOW_WIDTH'; const String _keyHeight = 'DESKTOP_WINDOW_HEIGHT'; -class _WindowResizeListener extends WindowListener { +class _WindowEventListener extends WindowListener { final SharedPreferences _prefs; - _WindowResizeListener(this._prefs); + _WindowEventListener(this._prefs); @override void onWindowResize() async { @@ -62,6 +64,13 @@ class _WindowResizeListener extends WindowListener { await _prefs.setDouble(_keyWidth, size.width); await _prefs.setDouble(_keyHeight, size.height); } + + @override + void onWindowClose() async { + if (Platform.isMacOS) { + await windowManager.destroy(); + } + } } Future initialize(List argv) async { @@ -69,14 +78,24 @@ Future initialize(List argv) async { await windowManager.ensureInitialized(); final prefs = await SharedPreferences.getInstance(); + final isHidden = prefs.getBool(windowHidden) ?? false; - unawaited(windowManager.waitUntilReadyToShow().then((_) async { - await windowManager.setMinimumSize(const Size(270, 0)); - final width = prefs.getDouble(_keyWidth) ?? 400; - final height = prefs.getDouble(_keyHeight) ?? 720; - await windowManager.setSize(Size(width, height)); - await windowManager.show(); - windowManager.addListener(_WindowResizeListener(prefs)); + unawaited(windowManager + .waitUntilReadyToShow(WindowOptions( + minimumSize: const Size(270, 0), + size: Size( + prefs.getDouble(_keyWidth) ?? 400, + prefs.getDouble(_keyHeight) ?? 720, + ), + skipTaskbar: isHidden, + )) + .then((_) async { + if (isHidden) { + await windowManager.setSkipTaskbar(true); + } else { + await windowManager.show(); + } + windowManager.addListener(_WindowEventListener(prefs)); })); // Either use the _HELPER_PATH environment variable, or look relative to executable. @@ -106,6 +125,11 @@ Future initialize(List argv) async { final rpcFuture = _initHelper(exe!); _initLicenses(); + await localNotifier.setup( + appName: 'Yubico Authenticator', + shortcutPolicy: ShortcutPolicy.ignore, + ); + return ProviderScope( overrides: [ supportedAppsProvider.overrideWithValue([ @@ -155,6 +179,9 @@ Future initialize(List argv) async { ref.read(rpcProvider).valueOrNull?.setLogLevel(level); }); + // Initialize systray + ref.watch(systrayProvider); + // Show a loading or error page while the Helper isn't ready return ref.watch(rpcProvider).when( data: (data) => const MainPage(), diff --git a/lib/desktop/oath/state.dart b/lib/desktop/oath/state.dart index 9d662c1f..f0ac600c 100755 --- a/lib/desktop/oath/state.dart +++ b/lib/desktop/oath/state.dart @@ -20,6 +20,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:logging/logging.dart'; import '../../app/logging.dart'; @@ -192,9 +193,9 @@ class _DesktopOathStateNotifier extends OathStateNotifier { } final desktopOathCredentialListProvider = StateNotifierProvider.autoDispose - .family?, DevicePath>( + .family?, DevicePath>( (ref, devicePath) { - var notifier = _DesktopCredentialListNotifier( + var notifier = DesktopCredentialListNotifier( ref.watch(withContextProvider), ref.watch(_sessionProvider(devicePath)), ref.watch(oathStateProvider(devicePath) @@ -203,6 +204,7 @@ final desktopOathCredentialListProvider = StateNotifierProvider.autoDispose ref.listen(windowStateProvider, (_, windowState) { notifier._notifyWindowState(windowState); }, fireImmediately: true); + return notifier; }, ); @@ -225,12 +227,12 @@ String _formatSteam(String response) { return value; } -class _DesktopCredentialListNotifier extends OathCredentialListNotifier { +class DesktopCredentialListNotifier extends OathCredentialListNotifier { final WithContext _withContext; final RpcNodeSession _session; final bool _locked; Timer? _timer; - _DesktopCredentialListNotifier(this._withContext, this._session, this._locked) + DesktopCredentialListNotifier(this._withContext, this._session, this._locked) : super(); void _notifyWindowState(WindowState windowState) { @@ -251,7 +253,7 @@ class _DesktopCredentialListNotifier extends OathCredentialListNotifier { @override Future calculate(OathCredential credential, - {bool update = true}) async { + {bool update = true, bool headless = false}) async { var now = DateTime.now().millisecondsSinceEpoch ~/ 1000; if (update) { // Manually triggered, need to pad timer to avoid immediate expiration @@ -264,12 +266,16 @@ class _DesktopCredentialListNotifier extends OathCredentialListNotifier { signaler.signals.listen((signal) async { if (signal.status == 'touch') { controller = await _withContext( - (context) async => promptUserInteraction( - context, - icon: const Icon(Icons.touch_app), - title: 'Touch Required', - description: 'Touch the button on your YubiKey now.', - ), + (context) async { + final l10n = AppLocalizations.of(context)!; + return promptUserInteraction( + context, + icon: const Icon(Icons.touch_app), + title: l10n.oath_touch_required, + description: l10n.oath_touch_now, + headless: headless, + ); + }, ); } }); diff --git a/lib/desktop/state.dart b/lib/desktop/state.dart index c5cce453..4b332cc5 100755 --- a/lib/desktop/state.dart +++ b/lib/desktop/state.dart @@ -24,8 +24,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; -import 'package:yubico_authenticator/app/logging.dart'; +import '../app/logging.dart'; import '../app/models.dart'; import '../app/state.dart'; import '../core/state.dart'; @@ -58,20 +58,23 @@ class _RpcStateNotifier extends StateNotifier { } } -final _windowStateProvider = - StateNotifierProvider<_WindowStateNotifier, WindowState>( - (ref) => _WindowStateNotifier()); +final desktopWindowStateProvider = + StateNotifierProvider( + (ref) => DesktopWindowStateNotifier(ref.watch(prefProvider))); -final desktopWindowStateProvider = Provider( - (ref) => ref.watch(_windowStateProvider), -); +const String windowHidden = 'DESKTOP_WINDOW_HIDDEN'; -class _WindowStateNotifier extends StateNotifier +class DesktopWindowStateNotifier extends StateNotifier with WindowListener { + final SharedPreferences _prefs; Timer? _idleTimer; - _WindowStateNotifier() - : super(WindowState(focused: true, visible: true, active: true)) { + DesktopWindowStateNotifier(this._prefs) + : super(WindowState( + focused: true, + visible: true, + active: true, + hidden: _prefs.getBool(windowHidden) ?? false)) { _init(); } @@ -88,6 +91,17 @@ class _WindowStateNotifier extends StateNotifier } } + void setWindowHidden(bool hidden) async { + if (hidden) { + await windowManager.hide(); + } else { + await windowManager.show(); + } + await windowManager.setSkipTaskbar(hidden); + await _prefs.setBool(windowHidden, hidden); + state = state.copyWith(hidden: hidden); + } + @override void dispose() { windowManager.removeListener(this); @@ -101,6 +115,7 @@ class _WindowStateNotifier extends StateNotifier } @override + @protected void onWindowEvent(String eventName) { if (mounted) { switch (eventName) { @@ -144,7 +159,29 @@ class _DesktopClipboard extends AppClipboard { @override Future setText(String toClipboard, {bool isSensitive = false}) async { - await Clipboard.setData(ClipboardData(text: toClipboard)); + // Wayland requires the window to be focused to copy to clipboard + final needsFocus = Platform.isLinux && + Platform.environment['XDG_SESSION_TYPE'] == 'wayland'; + var hidden = false; + try { + if (needsFocus && !await windowManager.isFocused()) { + if (!await windowManager.isVisible()) { + hidden = true; + await windowManager.setOpacity(0.0); + await windowManager.show(); + } + await windowManager.focus(); + // Window focus isn't immediate, wait until focused with 10s timeout + await Future.doWhile(() async => !await windowManager.isFocused()) + .timeout(const Duration(seconds: 10)); + } + await Clipboard.setData(ClipboardData(text: toClipboard)); + } finally { + if (hidden) { + await windowManager.hide(); + await windowManager.setOpacity(1.0); + } + } } } diff --git a/lib/desktop/systray.dart b/lib/desktop/systray.dart new file mode 100755 index 00000000..15dbcc58 --- /dev/null +++ b/lib/desktop/systray.dart @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2023 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:local_notifier/local_notifier.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +import '../app/models.dart'; +import '../app/shortcuts.dart'; +import '../app/state.dart'; +import '../core/models.dart'; +import '../exception/cancellation_exception.dart'; +import '../oath/models.dart'; +import '../oath/state.dart'; +import '../oath/views/utils.dart'; +import 'oath/state.dart'; +import 'state.dart'; + +final _favoriteAccounts = + Provider.autoDispose>>( + (ref) { + final deviceData = ref.watch(currentDeviceDataProvider).valueOrNull; + if (deviceData != null) { + final credentials = + ref.watch(desktopOathCredentialListProvider(deviceData.node.path)); + final favorites = ref.watch(favoritesProvider); + final listed = credentials + ?.map((e) => e.credential) + .where((c) => favorites.contains(c.id)) + .toList() ?? + []; + return Pair(deviceData.node.path, listed); + } + return Pair(null, []); + }, +); + +final systrayProvider = Provider.autoDispose((ref) { + final systray = _Systray(ref, ref.watch(l10nProvider)); + + // Keep track of which accounts to show + ref.listen( + _favoriteAccounts, + (_, next) { + systray._updateCredentials(next); + }, + ); + + // Keep track of the shown/hidden state of the app + ref.listen(windowStateProvider.select((value) => value.hidden), (_, hidden) { + systray._setHidden(hidden); + }, fireImmediately: true); + + ref.onDispose(systray.dispose); + + return systray; +}); + +Future _calculateCode( + DevicePath devicePath, OathCredential credential, Ref ref) async { + try { + return await (ref + .read(desktopOathCredentialListProvider(devicePath).notifier)) + .calculate(credential, headless: true); + } on CancellationException catch (_) { + return null; + } +} + +String _getIcon() { + if (Platform.isMacOS) { + return 'resources/icons/systray-template.eps'; + } + if (Platform.isWindows) { + return 'resources/icons/com.yubico.yubioath.ico'; + } + return 'resources/icons/com.yubico.yubioath-32x32.png'; +} + +class _Systray extends TrayListener { + final Ref _ref; + final AppLocalizations _l10n; + int _lastClick = 0; + DevicePath _devicePath = DevicePath([]); + List _credentials = []; + bool _isHidden = false; + _Systray(this._ref, this._l10n) { + _init(); + } + + Future _init() async { + await trayManager.setIcon(_getIcon(), isTemplate: true); + if (!Platform.isLinux) { + await trayManager.setToolTip(_l10n.general_app_name); + } + await _updateContextMenu(); + + // Doesn't seem to work on Linux + trayManager.addListener(this); + } + + void dispose() { + trayManager.destroy(); + } + + void _updateCredentials(Pair> pair) { + if (!listEquals(_credentials, pair.second)) { + _devicePath = pair.first ?? _devicePath; + _credentials = pair.second; + _updateContextMenu(); + } + } + + Future _setHidden(bool hidden) async { + _isHidden = hidden; + await _updateContextMenu(); + } + + @override + void onTrayIconMouseDown() { + if (Platform.isMacOS) { + trayManager.popUpContextMenu(); + } else { + final now = DateTime.now().millisecondsSinceEpoch; + if (now - _lastClick < 500) { + _lastClick = 0; + if (_isHidden) { + _ref.read(desktopWindowStateProvider.notifier).setWindowHidden(false); + } else { + windowManager.focus(); + } + } else { + _lastClick = now; + } + } + } + + @override + void onTrayIconRightMouseDown() { + trayManager.popUpContextMenu(); + } + + Future _updateContextMenu() async { + await trayManager.setContextMenu( + Menu( + items: [ + ..._credentials.map( + (e) { + final label = getTextName(e); + return MenuItem( + label: label, + onClick: (_) async { + final code = await _calculateCode(_devicePath, e, _ref); + if (code != null) { + await _ref + .read(clipboardProvider) + .setText(code.value, isSensitive: true); + final notification = LocalNotification( + title: _l10n.systray_oath_copied, + body: _l10n.systray_oath_copied_to_clipboard(label), + silent: true, + ); + await notification.show(); + await Future.delayed(const Duration(seconds: 4)); + await notification.close(); + } + }, + ); + }, + ), + if (_credentials.isEmpty) + MenuItem( + label: _l10n.systray_no_pinned, + disabled: true, + ), + MenuItem.separator(), + MenuItem( + label: _isHidden + ? _l10n.general_show_window + : _l10n.general_hide_window, + onClick: (_) { + _ref + .read(desktopWindowStateProvider.notifier) + .setWindowHidden(!_isHidden); + }, + ), + MenuItem.separator(), + MenuItem( + label: _l10n.general_quit, + onClick: (_) { + _ref.read(withContextProvider)( + (context) async { + Actions.invoke(context, const CloseIntent()); + }, + ); + }), + ], + ), + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5e24a3ce..99bd7751 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -25,6 +25,8 @@ "oath_scanned_qr": "Scanned QR code", "oath_scan_qr": "Scan QR code", "oath_require_touch": "Require touch", + "oath_touch_required": "Touch Required", + "oath_touch_now": "Touch the button on your YubiKey now", "oath_sec": "sec", "oath_digits": "digits", "oath_success_delete_account": "Account deleted", @@ -114,6 +116,7 @@ "mgmt_toggle_applications": "Toggle applications", "mgmt_save": "Save", + "general_app_name": "Yubico Authenticator", "general_about": "About", "general_terms_of_use": "Terms of use", "general_privacy_policy": "Privacy policy", @@ -139,6 +142,9 @@ "general_setup": "Setup", "general_manage": "Manage", "general_configure_yubikey": "Configure YubiKey", + "general_show_window": "Show window", + "general_hide_window": "Hide window", + "general_quit": "Quit", "fido_press_fingerprint_begin": "Press your finger against the YubiKey to begin.", "fido_keep_touching_yubikey": "Keep touching your YubiKey repeatedly\u2026", @@ -281,5 +287,15 @@ "placeholders": { "version": {} } - } + }, + + + "systray_oath_copied": "Code copied", + "systray_oath_copied_to_clipboard": "{label} copied to clipboard.", + "@systray_oath_copied_to_clipboard" : { + "placeholders": { + "label": {} + } + }, + "systray_no_pinned": "No pinned accounts" } \ No newline at end of file diff --git a/lib/oath/state.dart b/lib/oath/state.dart index e638d6a0..ef133c9d 100755 --- a/lib/oath/state.dart +++ b/lib/oath/state.dart @@ -66,7 +66,14 @@ abstract class OathCredentialListNotifier @override @protected set state(List? value) { - super.state = value != null ? List.unmodifiable(value) : null; + super.state = value != null + ? List.unmodifiable(value + ..sort((a, b) { + String searchKey(OathCredential c) => + ((c.issuer ?? '') + c.name).toLowerCase(); + return searchKey(a.credential).compareTo(searchKey(b.credential)); + })) + : null; } Future calculate(OathCredential credential); @@ -193,11 +200,6 @@ class FilteredCredentialsNotifier extends StateNotifier> { .toLowerCase() .contains(query.toLowerCase())) .where((pair) => pair.credential.issuer != '_hidden') - .toList() - ..sort((a, b) { - String searchKey(OathCredential c) => - ((c.issuer ?? '') + c.name).toLowerCase(); - return searchKey(a.credential).compareTo(searchKey(b.credential)); - }), + .toList(), ); } \ No newline at end of file diff --git a/lib/oath/views/account_dialog.dart b/lib/oath/views/account_dialog.dart index 4cd65efc..4c27f684 100755 --- a/lib/oath/views/account_dialog.dart +++ b/lib/oath/views/account_dialog.dart @@ -156,7 +156,10 @@ class AccountDialog extends ConsumerWidget { if (helper.code == null && (isDesktop || node.transport == Transport.usb)) { Timer.run(() { - Actions.invoke(context, const CalculateIntent()); + // Only call if credential hasn't been deleted/renamed + if (ref.read(credentialsProvider)?.contains(credential) == true) { + Actions.invoke(context, const CalculateIntent()); + } }); } return FocusScope( diff --git a/lib/oath/views/delete_account_dialog.dart b/lib/oath/views/delete_account_dialog.dart index dd27a1e8..462523c9 100755 --- a/lib/oath/views/delete_account_dialog.dart +++ b/lib/oath/views/delete_account_dialog.dart @@ -26,6 +26,7 @@ import '../../widgets/responsive_dialog.dart'; import '../models.dart'; import '../state.dart'; import '../keys.dart' as keys; +import 'utils.dart'; class DeleteAccountDialog extends ConsumerWidget { final DeviceNode device; @@ -34,10 +35,6 @@ class DeleteAccountDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final label = credential.issuer != null - ? '${credential.issuer} (${credential.name})' - : credential.name; - return ResponsiveDialog( title: Text(AppLocalizations.of(context)!.oath_delete_account), actions: [ @@ -75,7 +72,8 @@ class DeleteAccountDialog extends ConsumerWidget { AppLocalizations.of(context)!.oath_warning_disable_this_cred, style: Theme.of(context).textTheme.bodyLarge, ), - Text('${AppLocalizations.of(context)!.oath_account} $label'), + Text( + '${AppLocalizations.of(context)!.oath_account} ${getTextName(credential)}'), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index 2eadba1c..580dbf88 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -126,7 +126,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { } return Actions( actions: { - SearchIntent: CallbackAction(onInvoke: (_) { + SearchIntent: CallbackAction(onInvoke: (_) { searchController.selection = TextSelection( baseOffset: 0, extentOffset: searchController.text.length); searchFocus.requestFocus(); diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index bc8993b6..24555377 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -96,10 +96,6 @@ class _RenameAccountDialogState extends ConsumerState { Widget build(BuildContext context) { final credential = widget.credential; - final label = credential.issuer != null - ? '${credential.issuer} (${credential.name})' - : credential.name; - final remaining = getRemainingKeySpace( oathType: credential.oathType, period: credential.period, @@ -142,7 +138,8 @@ class _RenameAccountDialogState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(AppLocalizations.of(context)!.oath_rename(label)), + Text(AppLocalizations.of(context)! + .oath_rename(getTextName(credential))), Text(AppLocalizations.of(context)! .oath_warning_will_change_account_displayed), TextFormField( diff --git a/lib/oath/views/utils.dart b/lib/oath/views/utils.dart index 3c863346..8e59f8d4 100755 --- a/lib/oath/views/utils.dart +++ b/lib/oath/views/utils.dart @@ -47,3 +47,10 @@ Pair getRemainingKeySpace( remaining - issuerSpace, ); } + +/// Gets a textual name for the account, based on the issuer and name. +String getTextName(OathCredential credential) { + return credential.issuer != null + ? '${credential.issuer} (${credential.name})' + : credential.name; +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 7e11ab27..245f7282 100755 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,7 +7,9 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include #include #include @@ -15,9 +17,15 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) desktop_drop_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); + g_autoptr(FlPluginRegistrar) local_notifier_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "LocalNotifierPlugin"); + local_notifier_plugin_register_with_registrar(local_notifier_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 01f60315..72bfabc3 100755 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,7 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop + local_notifier screen_retriever + tray_manager url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7b52f106..e3df0467 100755 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,17 +6,21 @@ import FlutterMacOS import Foundation import desktop_drop +import local_notifier import path_provider_foundation import screen_retriever import shared_preferences_foundation +import tray_manager import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) + LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index a8c8a156..5411ab5d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -2,6 +2,8 @@ PODS: - desktop_drop (0.0.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - local_notifier (0.1.0): + - FlutterMacOS - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -10,6 +12,8 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - tray_manager (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - window_manager (0.2.0): @@ -18,9 +22,11 @@ PODS: DEPENDENCIES: - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - FlutterMacOS (from `Flutter/ephemeral`) + - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) @@ -29,12 +35,16 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos FlutterMacOS: :path: Flutter/ephemeral + local_notifier: + :path: Flutter/ephemeral/.symlinks/plugins/local_notifier/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos screen_retriever: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: @@ -43,10 +53,12 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca - url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 + shared_preferences_foundation: 986fc17f3d3251412d18b0265f9c64113a8c2472 + tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 + url_launcher_macos: 5335912b679c073563f29d89d33d10d459f95451 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index d53ef643..91cfe9a7 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -4,6 +4,7 @@ import FlutterMacOS @NSApplicationMain class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + // Keep app running if window closes + return false } } diff --git a/pubspec.lock b/pubspec.lock index 16c4f7ad..db039449 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "569ddca58d535e601dd1584afa117710abc999d036c0cd2c51777fb257df78e8" + sha256: e440ac42679dfc04bbbefb58ed225c994bc7e07fccc8a68ec7d3631a127e5da9 url: "https://pub.dev" source: hosted - version: "53.0.0" + version: "54.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "10927c4b7c7c88b1adbca278c3d5531db92e2f4b4abf04e2919a800af965f3f5" + sha256: "2c2e3721ee9fb36de92faa060f3480c81b23e904352b087e5c64224b1a044427" url: "https://pub.dev" source: hosted - version: "5.5.0" + version: "5.6.0" archive: dependency: "direct main" description: @@ -69,18 +69,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "6bc5544ea6ce4428266e7ea680e945c68806c4aae2da0eb5e9ccf38df8d6acbf" + sha256: "757153e5d9cd88253cb13f28c2fb55a537dc31fefd98137549895b5beb7c6169" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "7c35a3a7868626257d8aee47b51c26b9dba11eaddf3431117ed2744951416aab" + sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" build_runner: dependency: "direct dev" description: @@ -396,6 +396,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + local_notifier: + dependency: "direct main" + description: + name: local_notifier + sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 + url: "https://pub.dev" + source: hosted + version: "0.1.5" logging: dependency: "direct main" description: @@ -420,6 +428,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -488,18 +504,18 @@ packages: dependency: transitive description: name: path_provider_linux - sha256: "2e32f1640f07caef0d3cb993680f181c79e54a3827b997d5ee221490d131fbd9" + sha256: "525ad5e07622d19447ad740b1ed5070031f7a5437f44355ae915ff56e986429a" url: "https://pub.dev" source: hosted - version: "2.1.8" + version: "2.1.9" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: f0abc8ebd7253741f05488b4813d936b4d07c6bae3e86148a09e342ee4b08e76 + sha256: "57585299a729335f1298b43245842678cb9f43a6310351b18fb577d6e33165ec" url: "https://pub.dev" source: hosted - version: "2.0.5" + version: "2.0.6" path_provider_windows: dependency: transitive description: @@ -528,10 +544,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a + sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" pool: dependency: transitive description: @@ -591,58 +607,58 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: "5949029e70abe87f75cfe59d17bf5c397619c4b74a099b10116baeb34786fad9" + sha256: ee6257848f822b8481691f20c3e6d2bfee2e9eccb2a3d249907fcfb198c55b41 url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.0.18" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "955e9736a12ba776bdd261cf030232b30eadfcd9c79b32a3250dd4a494e8c8f7" + sha256: a51a4f9375097f94df1c6e0a49c0374440d31ab026b59d58a7e7660675879db4 url: "https://pub.dev" source: hosted - version: "2.0.15" + version: "2.0.16" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "2b55c18636a4edc529fa5cd44c03d3f3100c00513f518c5127c951978efcccd0" + sha256: "6b84fdf06b32bb336f972d373cd38b63734f3461ba56ac2ba01b56d052796259" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: f8ea038aa6da37090093974ebdcf4397010605fd2ff65c37a66f9d28394cb874 + sha256: d7fb71e6e20cd3dfffcc823a28da3539b392e53ed5fc5c2b90b55fdaa8a7e8fa url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: da9431745ede5ece47bc26d5d73a9d3c6936ef6945c101a5aca46f62e52c1cf3 + sha256: "824bfd02713e37603b2bdade0842e47d56e7db32b1dcdd1cae533fb88e2913fc" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: a4b5bc37fe1b368bbc81f953197d55e12f49d0296e7e412dfe2d2d77d6929958 + sha256: "6737b757e49ba93de2a233df229d0b6a87728cea1684da828cbc718b65dcf9d7" url: "https://pub.dev" source: hosted - version: "2.0.4" + version: "2.0.5" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "5eaf05ae77658d3521d0e993ede1af962d4b326cd2153d312df716dc250f00c9" + sha256: bd014168e8484837c39ef21065b78f305810ceabc1d4f90be6e3b392ce81b46d url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" shelf: dependency: transitive description: @@ -659,6 +675,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sky_engine: dependency: transitive description: flutter @@ -760,6 +784,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: b1975a05e0c6999e983cf9a58a6a098318c896040ccebac5398a3cc9e43b9c69 + url: "https://pub.dev" + source: hosted + version: "0.2.0" typed_data: dependency: transitive description: @@ -772,66 +804,74 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: e8f2efc804810c0f2f5b485f49e7942179f56eabcfe81dce3387fec4bb55876b + sha256: "75f2846facd11168d007529d6cd8fcb2b750186bea046af9711f10b907e1587e" url: "https://pub.dev" source: hosted - version: "6.1.9" + version: "6.1.10" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "3e2f6dfd2c7d9cd123296cab8ef66cfc2c1a13f5845f42c7a0f365690a8a7dd1" + sha256: "1f4d9ebe86f333c15d318f81dcdc08b01d45da44af74552608455ebdc08d9732" url: "https://pub.dev" source: hosted - version: "6.0.23" + version: "6.0.24" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "0a5af0aefdd8cf820dd739886efb1637f1f24489900204f50984634c07a54815" + sha256: c9cd648d2f7ab56968e049d4e9116f96a85517f1dd806b96a86ea1018a3a82e5 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.1.1" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: "318c42cba924e18180c029be69caf0a1a710191b9ec49bb42b5998fdcccee3cc" + sha256: e29039160ab3730e42f3d811dc2a6d5f2864b90a70fb765ea60144b03307f682 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "41988b55570df53b3dd2a7fc90c76756a963de6a8c5f8e113330cb35992e2094" + sha256: "2dddb3291a57b074dade66b5e07e64401dd2487caefd4e9e2f467138d8c7eb06" url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "4eae912628763eb48fc214522e58e942fd16ce195407dbf45638239523c759a6" + sha256: "6c9ca697a5ae218ce56cece69d46128169a58aa8653c1b01d26fcd4aad8c4370" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "44d79408ce9f07052095ef1f9a693c258d6373dc3944249374e30eff7219ccb0" + sha256: "574cfbe2390666003c3a1d129bdc4574aaa6728f0c00a4829a81c316de69dd9b" url: "https://pub.dev" source: hosted - version: "2.0.14" + version: "2.0.15" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: b6217370f8eb1fd85c8890c539f5a639a01ab209a36db82c921ebeacefc7a615 + sha256: "97c9067950a0d09cbd93e2e3f0383d1403989362b97102fbf446473a48079a4b" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + url: "https://pub.dev" + source: hosted + version: "3.0.7" vector_graphics: dependency: "direct main" description: @@ -908,10 +948,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: "5bdd29dc5f1f3185fc90696373a571d77968e03e5e820fb1ecdbdade3f5d8fff" + sha256: "492806c69879f0d28e95472bbe5e8d5940ac8c6e99cc07052fe14946974555ba" url: "https://pub.dev" source: hosted - version: "0.3.0" + version: "0.3.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 16246c29..ba327ec9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,8 @@ dependencies: file_picker: ^5.2.5 archive: ^3.3.2 crypto: ^3.0.2 + tray_manager: ^0.2.0 + local_notifier: ^0.1.5 dev_dependencies: integration_test: @@ -99,6 +101,7 @@ flutter: - assets/graphics/ - assets/licenses/ - assets/licenses/raw/ + - resources/icons/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware diff --git a/resources/icons/com.yubico.yubioath-32x32.png b/resources/icons/com.yubico.yubioath-32x32.png new file mode 100755 index 00000000..372b9718 Binary files /dev/null and b/resources/icons/com.yubico.yubioath-32x32.png differ diff --git a/resources/icons/systray-template.eps b/resources/icons/systray-template.eps new file mode 100755 index 00000000..49bff2cb --- /dev/null +++ b/resources/icons/systray-template.eps @@ -0,0 +1,122 @@ +%!PS-Adobe-3.0 EPSF-3.0 +%%Creator: cairo 1.16.0 (https://cairographics.org) +%%CreationDate: Wed Feb 22 15:58:05 2023 +%%Pages: 1 +%%DocumentData: Clean7Bit +%%LanguageLevel: 2 +%%BoundingBox: 0 0 630 663 +%%EndComments +%%BeginProlog +50 dict begin +/q { gsave } bind def +/Q { grestore } bind def +/cm { 6 array astore concat } bind def +/w { setlinewidth } bind def +/J { setlinecap } bind def +/j { setlinejoin } bind def +/M { setmiterlimit } bind def +/d { setdash } bind def +/m { moveto } bind def +/l { lineto } bind def +/c { curveto } bind def +/h { closepath } bind def +/re { exch dup neg 3 1 roll 5 3 roll moveto 0 rlineto + 0 exch rlineto 0 rlineto closepath } bind def +/S { stroke } bind def +/f { fill } bind def +/f* { eofill } bind def +/n { newpath } bind def +/W { clip } bind def +/W* { eoclip } bind def +/BT { } bind def +/ET { } bind def +/BDC { mark 3 1 roll /BDC pdfmark } bind def +/EMC { mark /EMC pdfmark } bind def +/cairo_store_point { /cairo_point_y exch def /cairo_point_x exch def } def +/Tj { show currentpoint cairo_store_point } bind def +/TJ { + { + dup + type /stringtype eq + { show } { -0.001 mul 0 cairo_font_matrix dtransform rmoveto } ifelse + } forall + currentpoint cairo_store_point +} bind def +/cairo_selectfont { cairo_font_matrix aload pop pop pop 0 0 6 array astore + cairo_font exch selectfont cairo_point_x cairo_point_y moveto } bind def +/Tf { pop /cairo_font exch def /cairo_font_matrix where + { pop cairo_selectfont } if } bind def +/Td { matrix translate cairo_font_matrix matrix concatmatrix dup + /cairo_font_matrix exch def dup 4 get exch 5 get cairo_store_point + /cairo_font where { pop cairo_selectfont } if } bind def +/Tm { 2 copy 8 2 roll 6 array astore /cairo_font_matrix exch def + cairo_store_point /cairo_font where { pop cairo_selectfont } if } bind def +/g { setgray } bind def +/rg { setrgbcolor } bind def +/d1 { setcachedevice } bind def +/cairo_data_source { + CairoDataIndex CairoData length lt + { CairoData CairoDataIndex get /CairoDataIndex CairoDataIndex 1 add def } + { () } ifelse +} def +/cairo_flush_ascii85_file { cairo_ascii85_file status { cairo_ascii85_file flushfile } if } def +/cairo_image { image cairo_flush_ascii85_file } def +/cairo_imagemask { imagemask cairo_flush_ascii85_file } def +%%EndProlog +%%BeginSetup +%%EndSetup +%%Page: 1 1 +%%BeginPageSetup +%%PageBoundingBox: 0 0 630 663 +%%EndPageSetup +q 0 0 630 663 rectclip +1 0 0 -1 0 663 cm q +0 g +66.832 172.32 m 66.832 150.871 66.758 129.871 66.832 108.871 c 66.98 86.07 + 74.18 65.598 88.355 47.82 c 108.457 22.621 134.855 7.848 166.43 2.145 c + 196.281 -3.254 224.707 1.695 251.555 15.195 c 275.031 27.047 293.707 44.145 + 305.555 68.07 c 311.855 80.746 315.008 94.172 315.008 108.348 c 315.008 + 172.172 l 319.957 172.547 324.68 172.473 329.258 173.297 c 356.18 178.395 + 371.332 194.297 375.68 221.223 c 375.758 221.82 375.68 222.496 375.68 223.098 + c 375.68 296.973 l 375.68 297.57 375.605 298.246 375.605 298.32 c 295.355 + 336.57 253.055 399.57 249.156 488.973 c 245.633 488.973 l 180.906 488.973 + 116.18 489.496 51.383 488.746 c 24.906 488.445 5.031 473.672 0.758 445.02 + c 0.383 442.32 0.082 439.621 0.082 436.848 c 0.082 366.723 -0.145 296.598 + 0.156 226.473 c 0.23 199.547 15.383 179.895 41.633 174.121 c 48.605 172.621 + 55.957 172.996 63.156 172.473 c 64.281 172.395 65.332 172.395 66.832 172.32 + c h +268.055 172.246 m 267.457 152.672 267.605 133.547 266.105 114.57 c 264.457 + 93.945 255.383 76.32 240.008 62.297 c 216.906 41.223 184.133 36.047 157.656 + 50.145 c 128.633 65.598 115.281 91.395 113.48 123.348 c 112.656 138.422 + 113.332 153.57 113.332 168.723 c 113.332 172.246 l h +215.332 391.32 m 215.332 386.52 l 215.332 365.297 215.332 344.07 215.258 + 322.848 c 215.258 320.52 215.781 318.945 217.656 317.445 c 226.957 310.172 + 231.98 300.496 233.48 288.797 c 237.23 260.445 209.93 235.621 182.18 242.672 + c 165.082 247.02 153.98 258.121 150.383 275.297 c 146.781 292.473 152.332 + 307.02 166.281 317.973 c 168.156 319.395 168.832 320.895 168.832 323.223 + c 168.758 344.445 168.758 365.672 168.758 386.895 c 168.758 391.246 l 184.582 + 391.32 199.656 391.32 215.332 391.32 c h +215.332 391.32 m f +453.98 311.973 m 551.555 312.871 629.18 390.348 629.18 487.547 c 629.105 + 585.348 550.805 663.047 453.531 662.82 c 355.805 662.598 278.332 584.371 + 278.48 487.246 c 278.707 389.598 356.781 312.645 453.98 311.973 c h +404.855 597.871 m 420.98 597.871 436.281 597.797 451.656 597.945 c 454.508 + 597.945 455.707 596.973 456.758 594.422 c 479.707 537.723 502.73 481.098 + 525.68 424.473 c 528.758 416.895 531.832 409.246 534.98 401.445 c 534.082 + 401.297 533.633 401.145 533.18 401.145 c 518.707 401.145 504.156 401.223 + 489.68 401.07 c 486.98 401.07 486.68 402.723 486.082 404.445 c 478.957 +424.695 471.906 444.945 464.781 465.195 c 461.555 474.27 458.332 483.348 + 454.805 493.246 c 453.98 491.07 453.383 489.723 452.93 488.297 c 442.582 + 460.32 432.23 432.422 421.957 404.371 c 421.055 401.82 419.855 400.996 +417.156 401.07 c 403.281 401.223 389.406 401.145 375.531 401.145 c 374.48 + 401.145 373.355 401.297 372.008 401.371 c 372.531 402.871 372.906 403.996 + 373.355 405.121 c 376.582 413.371 379.805 421.621 383.031 429.871 c 397.281 + 466.246 411.457 502.621 425.781 538.922 c 427.133 542.371 427.43 545.297 + 425.781 548.82 c 422.707 555.195 420.156 561.871 417.383 568.395 c 413.258 + 577.996 409.133 587.598 404.855 597.871 c h +404.855 597.871 m f +Q Q +showpage +%%Trailer +end +%%EOF diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d35fb0a0..83aed1fe 100755 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,15 +7,21 @@ #include "generated_plugin_registrant.h" #include +#include #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { DesktopDropPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopDropPlugin")); + LocalNotifierPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("LocalNotifierPlugin")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0d6d49e8..9a6162a7 100755 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,7 +4,9 @@ list(APPEND FLUTTER_PLUGIN_LIST desktop_drop + local_notifier screen_retriever + tray_manager url_launcher_windows window_manager ) diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp index dab3e7ed..1249f952 100644 --- a/windows/runner/win32_window.cpp +++ b/windows/runner/win32_window.cpp @@ -172,7 +172,9 @@ bool Win32Window::Create(const std::wstring& title, } bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); + // We show the mindow manually + return true; + //return ShowWindow(window_handle_, SW_SHOWNORMAL); } // static