From 3f0bb607e5378a8ba013fec04884ebc159204457 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 16 Sep 2026 21:15:11 +1000 Subject: [PATCH 1/2] Bring browser to front --- lib/solidui.dart | 1 + lib/src/utils/solid_login_browser_focus.dart | 220 ++++++++++++++++++ lib/src/widgets/solid_login_auth_handler.dart | 8 + lib/src/widgets/solid_logout_dialog.dart | 11 +- lib/src/widgets/solid_popup_login.dart | 8 + 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 lib/src/utils/solid_login_browser_focus.dart diff --git a/lib/solidui.dart b/lib/solidui.dart index 10def779..a267b3bb 100644 --- a/lib/solidui.dart +++ b/lib/solidui.dart @@ -132,6 +132,7 @@ export 'src/utils/is_desktop.dart'; export 'src/utils/is_text_file.dart'; export 'src/utils/loading_dialog_controller.dart'; export 'src/utils/solid_file_operations.dart'; +export 'src/utils/solid_login_browser_focus.dart'; export 'src/utils/solid_file_operations_print.dart'; export 'src/utils/is_phone.dart'; export 'src/utils/solid_alert.dart'; diff --git a/lib/src/utils/solid_login_browser_focus.dart b/lib/src/utils/solid_login_browser_focus.dart new file mode 100644 index 00000000..40589c2c --- /dev/null +++ b/lib/src/utils/solid_login_browser_focus.dart @@ -0,0 +1,220 @@ +/// Keep the external login browser window in front of the app window. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Time-stamp: +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Tony Chen + +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint; + +import 'package:universal_io/io.dart' show Platform, Process; +import 'package:window_manager/window_manager.dart'; + +import 'package:solidui/src/utils/is_desktop.dart'; + +/// Puts the browser that runs the Solid login — or logout — in front of the +/// app window. +/// +/// On desktop the Solid-OIDC flow hands the login and end-session pages to the +/// user's default browser (`package:oidc` → `url_launcher` → `NSWorkspace.open()` on macOS), +/// so the page lives in a window that belongs to another application. No +/// desktop platform lets one application pin another application's window on +/// top, so instead of trying to raise that window directly we make sure +/// nothing of ours sits in front of it: +/// +/// * our own window gives up the foreground and is ordered to the back, and +/// * the default browser is activated with `open -b `, which lifts +/// its windows above every other application and follows the browser to the +/// space it is on — the macOS case where the login page landed in a window +/// that stayed hidden behind everything else. +/// +/// Both of those are macOS specific. Windows and Linux already foreground the +/// browser as they launch it, so there we only make sure the app window is not +/// pinned on top of it. +/// +/// The browser is only launched part way through the handshake (after the +/// WebID and discovery-document lookups), so [start] retries a few times +/// rather than acting once. Call [stop] when the handshake returns; it cancels +/// any pending attempt and brings the app window back to the front. +/// +/// Every step is best effort: failures are logged and ignored, and on the web +/// and on mobile — where the flow never leaves the app — all of this is a +/// no-op. + +class SolidLoginBrowserFocus { + // A static-only helper; there is a single app window to manage. + + SolidLoginBrowserFocus._(); + + /// Delays after [start] at which we try to put the browser in front. The + /// first attempt covers a prewarmed OIDC manager, the later ones a server + /// that is slow to answer the discovery request. + + static const _attemptDelays = [ + Duration(milliseconds: 600), + Duration(milliseconds: 1800), + Duration(milliseconds: 4000), + ]; + + static final _pending = []; + + static bool _ordered = false; + + /// Begins handing the foreground over to the login browser. Safe to call + /// more than once: an earlier series of attempts is cancelled first. + + static void start() { + if (!isDesktop) return; + + _cancelPending(); + for (final delay in _attemptDelays) { + _pending.add(Timer(delay, () => unawaited(_raiseBrowser()))); + } + } + + /// Ends the handover. Pending attempts are dropped and, when we did move + /// the app window out of the way, it is raised again so the user is not + /// left looking at the browser after the login has finished. + + static void stop() { + _cancelPending(); + if (!_ordered) return; + + _ordered = false; + unawaited(_raiseApp()); + } + + static void _cancelPending() { + for (final timer in _pending) { + timer.cancel(); + } + _pending.clear(); + } + + // window_manager force-unwraps its window handle, so an app that never + // called ensureInitialized() in main() would crash on the first call. It is + // idempotent, so simply making it part of our own setup is enough. + + static var _initialised = false; + + static Future _ensureWindowManager() async { + if (_initialised) return; + await windowManager.ensureInitialized(); + _initialised = true; + } + + static Future _raiseBrowser() async { + try { + await _ensureWindowManager(); + + // Never compete with the browser for the foreground: an app that was + // pinned on top would otherwise cover the login page. + + if (await windowManager.isAlwaysOnTop()) { + await windowManager.setAlwaysOnTop(false); + } + + // Order our own window behind the browser, on macOS only: the Windows + // implementation of blur() foregrounds whichever window happens to come + // next in the Z-order, which could just as easily drop another app on + // top of the login page, and on Linux it does nothing at all. Both of + // those already hand the foreground to the browser as it is launched. + + if (Platform.isMacOS) { + await windowManager.blur(); + _ordered = true; + } + } on Object catch (e) { + debugPrint('SolidLoginBrowserFocus: could not lower the app window: $e'); + } + + if (Platform.isMacOS) await _activateMacosBrowser(); + } + + static Future _raiseApp() async { + try { + await _ensureWindowManager(); + await windowManager.show(); + await windowManager.focus(); + } on Object catch (e) { + debugPrint('SolidLoginBrowserFocus: could not raise the app window: $e'); + } + } + + // Activates the application registered as the handler for https, which is + // the browser url_launcher has just opened the login page in. Nothing is + // launched when the lookup fails, so we never risk activating — or worse, + // starting — a browser the user does not use. + + static Future _activateMacosBrowser() async { + final bundleId = await _macosDefaultBrowser(); + if (bundleId == null) return; + + try { + await Process.run('/usr/bin/open', ['-b', bundleId]); + } on Object catch (e) { + debugPrint('SolidLoginBrowserFocus: could not activate $bundleId: $e'); + } + } + + // The https handler recorded by LaunchServices, e.g. com.google.chrome. + // The preferences dump lists one block per scheme, with the role key + // immediately ahead of the scheme key, so a single pattern picks out the + // browser without parsing the whole plist. No entry at all means the user + // has never changed the default, which is Safari. + + static final _httpsHandler = RegExp( + r'LSHandlerRole(?:All|Viewer)\s*=\s*"?([\w.-]+)"?;\s*' + r'LSHandlerURLScheme\s*=\s*https;', + ); + + static const _safari = 'com.apple.safari'; + + static String? _cachedBundleId; + + static Future _macosDefaultBrowser() async { + if (_cachedBundleId != null) return _cachedBundleId; + + try { + final result = await Process.run('/usr/bin/defaults', [ + 'read', + 'com.apple.LaunchServices/com.apple.launchservices.secure', + 'LSHandlers', + ]); + if (result.exitCode != 0) return _cachedBundleId = _safari; + + final match = _httpsHandler.firstMatch('${result.stdout}'); + return _cachedBundleId = match?.group(1) ?? _safari; + } on Object catch (e) { + debugPrint('SolidLoginBrowserFocus: no default browser found: $e'); + return null; + } + } +} diff --git a/lib/src/widgets/solid_login_auth_handler.dart b/lib/src/widgets/solid_login_auth_handler.dart index 86435a51..97b26aca 100644 --- a/lib/src/widgets/solid_login_auth_handler.dart +++ b/lib/src/widgets/solid_login_auth_handler.dart @@ -52,6 +52,7 @@ import 'package:solidui/src/constants/initial_setup.dart' show initialStructureSnackbarMsg, initialUpdateSnackbarMsg; import 'package:solidui/src/screens/initial_setup_screen.dart'; import 'package:solidui/src/services/solid_login_status_notifier.dart'; +import 'package:solidui/src/utils/solid_login_browser_focus.dart'; import 'package:solidui/src/utils/solid_pod_helpers.dart' show getKeyFromUserIfRequired, isPodUpdateMode; import 'package:solidui/src/widgets/solid_animation_dialog.dart'; @@ -303,6 +304,11 @@ class SolidLoginAuthHandler { Timer? browserMessageTimer; if (!wasAlreadyLoggedIn) { + // A browser window is about to open for the OIDC handshake, so step the + // app window aside and bring that browser to the front. + + SolidLoginBrowserFocus.start(); + browserMessageTimer = Timer(const Duration(milliseconds: 200), () { if (context.mounted) { showSnackbar( @@ -388,6 +394,8 @@ class SolidLoginAuthHandler { return false; } + } finally { + SolidLoginBrowserFocus.stop(); } browserMessageTimer?.cancel(); diff --git a/lib/src/widgets/solid_logout_dialog.dart b/lib/src/widgets/solid_logout_dialog.dart index 8edde2f5..62ab59f0 100644 --- a/lib/src/widgets/solid_logout_dialog.dart +++ b/lib/src/widgets/solid_logout_dialog.dart @@ -36,6 +36,7 @@ import 'package:solidpod/solidpod.dart' import 'package:solidui/src/services/solid_login_status_notifier.dart'; import 'package:solidui/src/services/solid_owner_profile_service.dart'; import 'package:solidui/src/services/solid_profile_service.dart'; +import 'package:solidui/src/utils/solid_login_browser_focus.dart'; import 'package:solidui/src/utils/web_id_parser.dart'; /// A pop up widget for user to logout. @@ -81,7 +82,15 @@ class _LogoutDialogState extends State { ElevatedButton( child: const Text('OK'), onPressed: () async { - if (await logoutPod()) { + SolidLoginBrowserFocus.start(); + final bool loggedOut; + try { + loggedOut = await logoutPod(); + } finally { + SolidLoginBrowserFocus.stop(); + } + + if (loggedOut) { SolidProfileService.instance.clearCache(); SolidOwnerProfileService.instance.clearCache(); solidLoginStatusNotifier.markLoggedOut(); diff --git a/lib/src/widgets/solid_popup_login.dart b/lib/src/widgets/solid_popup_login.dart index f5456c1c..676ada21 100644 --- a/lib/src/widgets/solid_popup_login.dart +++ b/lib/src/widgets/solid_popup_login.dart @@ -46,6 +46,7 @@ import 'package:solidui/src/constants/solid_config.dart'; import 'package:solidui/src/constants/ui.dart'; import 'package:solidui/src/screens/initial_setup_screen.dart'; import 'package:solidui/src/services/solid_login_status_notifier.dart'; +import 'package:solidui/src/utils/solid_login_browser_focus.dart'; import 'package:solidui/src/utils/solid_pod_helpers.dart' show isPodUpdateMode; import 'package:solidui/src/widgets/solid_loading_screen.dart'; import 'package:solidui/src/widgets/solid_login_auth_handler.dart'; @@ -159,6 +160,11 @@ class _SolidPopupLoginState extends State { List? postLogoutRedirectUris, ) async { try { + // Step the app window aside and bring the browser that runs the OIDC + // handshake to the front. + + SolidLoginBrowserFocus.start(); + await solidAuthenticate( webId, context, @@ -214,6 +220,8 @@ class _SolidPopupLoginState extends State { } return false; + } finally { + SolidLoginBrowserFocus.stop(); } } From 1c0a63172b1edd53474e7b44c3af20cb585fd9d4 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Thu, 17 Sep 2026 21:00:48 +1000 Subject: [PATCH 2/2] =?UTF-8?q?Fix=20the=20issue=20that=20if=20the=20app?= =?UTF-8?q?=20can=E2=80=99t=20find=20a=20default=20browser,=20it=20falls?= =?UTF-8?q?=20back=20to=20`com.apple.safari`=20and=20invokes=20it=20using?= =?UTF-8?q?=20`open=20-b`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/utils/solid_login_browser_focus.dart | 96 ++++++++++++++++---- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/lib/src/utils/solid_login_browser_focus.dart b/lib/src/utils/solid_login_browser_focus.dart index 40589c2c..f9eae579 100644 --- a/lib/src/utils/solid_login_browser_focus.dart +++ b/lib/src/utils/solid_login_browser_focus.dart @@ -53,7 +53,9 @@ import 'package:solidui/src/utils/is_desktop.dart'; /// * the default browser is activated with `open -b `, which lifts /// its windows above every other application and follows the browser to the /// space it is on — the macOS case where the login page landed in a window -/// that stayed hidden behind everything else. +/// that stayed hidden behind everything else. We only ever activate a +/// browser LaunchServices names as the https handler and that is already +/// running, so no second browser is started on top of the login page. /// /// Both of those are macOS specific. Windows and Linux already foreground the /// browser as they launch it, so there we only make sure the app window is not @@ -169,14 +171,20 @@ class SolidLoginBrowserFocus { } // Activates the application registered as the handler for https, which is - // the browser url_launcher has just opened the login page in. Nothing is - // launched when the lookup fails, so we never risk activating — or worse, - // starting — a browser the user does not use. + // the browser url_launcher has just opened the login page in. static Future _activateMacosBrowser() async { final bundleId = await _macosDefaultBrowser(); + + // We could not say which browser holds the login page. + if (bundleId == null) return; + // It is not running, so it cannot be holding the login page either, and + // activating it would start a browser the user never asked for. + + if (!await _isRunning(bundleId)) return; + try { await Process.run('/usr/bin/open', ['-b', bundleId]); } on Object catch (e) { @@ -184,37 +192,87 @@ class SolidLoginBrowserFocus { } } - // The https handler recorded by LaunchServices, e.g. com.google.chrome. - // The preferences dump lists one block per scheme, with the role key - // immediately ahead of the scheme key, so a single pattern picks out the - // browser without parsing the whole plist. No entry at all means the user - // has never changed the default, which is Safari. + // Strips the nested LSHandlerPreferredVersions dictionary, which repeats + // the role key with a placeholder value, out of an LSHandlers entry. - static final _httpsHandler = RegExp( - r'LSHandlerRole(?:All|Viewer)\s*=\s*"?([\w.-]+)"?;\s*' - r'LSHandlerURLScheme\s*=\s*https;', + static final _nestedVersions = RegExp( + r'LSHandlerPreferredVersions\s*=\s*\{[^}]*\};', ); - static const _safari = 'com.apple.safari'; + static final _httpsScheme = RegExp(r'LSHandlerURLScheme\s*=\s*"?https"?;'); - static String? _cachedBundleId; + static final _handlerRole = RegExp( + r'LSHandlerRole(?:All|Viewer)\s*=\s*"?([\w.-]+)"?;', + ); - static Future _macosDefaultBrowser() async { - if (_cachedBundleId != null) return _cachedBundleId; + static final _modified = RegExp(r'LSHandlerModificationDate\s*=\s*(\d+);'); + + // The https handler recorded by LaunchServices, e.g. com.brave.browser, or + // null when it cannot be established — which is the answer whenever the + // preferences are unreadable (a sandboxed build cannot read another + // application's domain) or the user has never picked a browser. Deliberately + // no fallback: an unidentified browser is one we leave alone. + // + // The dump holds one entry per scheme or content type. The keys within an + // entry are not in a guaranteed order, so we look for the entry carrying the + // https scheme and read the role key out of that same entry. The list can + // also hold more than one https entry — a stale one left by a browser the + // user has since moved away from — so the most recently modified entry wins + // rather than the first one encountered. + static Future _macosDefaultBrowser() async { try { final result = await Process.run('/usr/bin/defaults', [ 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure', 'LSHandlers', ]); - if (result.exitCode != 0) return _cachedBundleId = _safari; + if (result.exitCode != 0) return null; + + final entries = + '${result.stdout}'.replaceAll(_nestedVersions, '').split('},'); + + String? browser; + var newest = -1; + + for (final entry in entries) { + if (!_httpsScheme.hasMatch(entry)) continue; + + final role = _handlerRole.firstMatch(entry); + if (role == null) continue; - final match = _httpsHandler.firstMatch('${result.stdout}'); - return _cachedBundleId = match?.group(1) ?? _safari; + final modified = + int.tryParse(_modified.firstMatch(entry)?.group(1) ?? '') ?? 0; + if (modified < newest) continue; + + browser = role.group(1); + newest = modified; + } + + return browser; } on Object catch (e) { debugPrint('SolidLoginBrowserFocus: no default browser found: $e'); return null; } } + + // Whether an application with this bundle id is already running. The ids + // LaunchServices records in its preferences are lower-cased, while the ones + // the running applications report keep their original spelling, so the + // comparison has to ignore case. + + static Future _isRunning(String bundleId) async { + try { + final result = await Process.run('/usr/bin/lsappinfo', ['list']); + if (result.exitCode != 0) return false; + + return RegExp( + 'bundleID\\s*=\\s*"${RegExp.escape(bundleId)}"', + caseSensitive: false, + ).hasMatch('${result.stdout}'); + } on Object catch (e) { + debugPrint('SolidLoginBrowserFocus: could not list applications: $e'); + return false; + } + } }