From f5a1fd6ce64526b05fb18d4fc2b3ac9973511dd0 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Tue, 4 Aug 2026 14:23:16 -0400 Subject: [PATCH 1/2] fix: restore link capture on Windows and macOS, harden IPC and add source-app rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link capture was broken on both platforms, for unrelated reasons. Windows: the shell registration was written once on first boot and never reconciled again, so a moved, updated or reinstalled executable left HKCU pointing at a path that no longer existed. Since HKCU\Software\Classes shadows HKLM, a leftover entry from a local build also hijacked link handling from the real installation — Store (MSIX) or standalone alike. macOS: AppDelegate called super.applicationDidFinishLaunching, a selector FlutterAppDelegate does not implement. It compiles because the superclass adopts NSApplicationDelegate, but the objc_msgSendSuper hits an unimplemented selector and aborts the process during launch — precisely when Launch Services hands it a URL to open. Registration - Reconcile the handler on every launch, before delegating to the resident instance, so a courier process repairs it too. - Never register from a build tree, and remove entries a build tree owns. - Under MSIX, drop per-user entries shadowing the package manifest. - Make "set as default" actually re-register instead of only opening system settings; point macOS at Desktop & Dock rather than the pre-Ventura pane. - Detect MSIX via GetCurrentPackageFullName: the previous environment-variable probe was inheritable, and a false positive disabled registration entirely. macOS - Drop the unimplemented super call in applicationDidFinishLaunching. - Register the running bundle, not whatever Launch Services resolves, so a debug build cannot claim the association and die with `flutter clean`. - Report setDefaultApplication failures instead of discarding them. - Resolve the Flutter window through the delegate outlet; NSApp.windows.first could return the tray window. - LSHandlerRank Owner: as Alternate the app ruled itself out as a browser. - Include Safari's cryptex path in browser detection. - Widen the login-item catch: MissingPluginException killed the launch. Windows IPC - Give the pipe an explicit descriptor: DACL limited to the current user and SYSTEM, low-integrity label so medium-integrity callers can still deliver. - Reject remote clients and require first-instance ownership, and use SECURITY_IDENTIFICATION so a squatter cannot impersonate the client. - Launch the app unelevated after install (runasoriginaluser): inheriting the installer's token put the mutex and pipe out of reach of Slack and Teams. - Buffer the argv URL through the pipe server instead of an async* wrapper that dropped events flushed before subscription. - Log unrecognised launch arguments; dropped links left no trace at all. Window and rendering - Sequence window setup instead of relying on waitUntilReadyToShow's callback, which is not awaited. - Serialise mode transitions and drive the window from one place only; two concurrent paths could leave it visible with no content. - Arm the picker blur guard by timer only. Arming it on first focus meant the focus event from showing the window closed the picker immediately. - Reconcile window visibility from the hidden state as a safety net. - Re-scan browsers after resetting a corrupt config, and show an explanation in the picker instead of a blank window. - Clamp picker position safely; on small or scaled displays it threw and the app went deaf to every later link. Security - Reject non-launchable URLs before any Process.start: a crafted `--gpu-launcher=` reached the browser as argv and ran arbitrary binaries. - Match the file scheme case-insensitively; `FILE://` bypassed the extension allowlist. Reject UNC paths, which leaked a NetNTLMv2 hash on probe. - Redact errors and stack traces, and cap startup_crash.log: a failed launch wrote the full URL to disk, contradicting PRIVACY.md. - Validate the release URL as github.com before handing it to the shell. - Surface malformed IPC payloads as FormatException instead of TypeError. Features - Capture the microsoft-edge: protocol (opt-in). Teams, Outlook, Widgets and Start search wrap links in it, bypassing the default browser entirely. - Private windows from the picker with Shift, using the switch each browser family expects. On macOS this needs `open -n`, or --args is discarded. - Rules scoped to the originating app, resolved from the parent process on Windows and approximated by the frontmost app on macOS. App-scoped rules take precedence over domain rules. - Self-diagnostics in Settings that explain a broken registration and offer a one-click repair. Build reproducibility - Track pubspec.lock and pin the Flutter version in CI and release workflows; every release was previously built against whatever stable resolved to. Docs: add docs/LINK_CAPTURE.md covering the capture path, registry precedence, the log messages that identify each failure, and the new features. --- .github/workflows/ci.yml | 3 + .github/workflows/release-mac.yml | 1 + .github/workflows/release-win.yml | 1 + .gitignore | 4 + PRIVACY.md | 4 + apps/linkunbound/lib/app.dart | 95 +- apps/linkunbound/lib/bootstrap.dart | 288 +++-- apps/linkunbound/lib/l10n/app_en.arb | 103 +- apps/linkunbound/lib/l10n/app_es.arb | 33 +- .../lib/l10n/app_localizations.dart | 72 ++ .../lib/l10n/app_localizations_en.dart | 44 + .../lib/l10n/app_localizations_es.dart | 45 + apps/linkunbound/lib/main.dart | 12 +- .../lib/platform/local_file_url.dart | 19 +- .../macos/mac_diagnostics_service.dart | 6 +- .../platform/macos/mac_launch_service.dart | 41 +- .../macos/mac_registration_service.dart | 32 + .../lib/platform/macos/mac_source_app.dart | 25 + .../platform/macos/mac_startup_service.dart | 6 +- .../platform/windows/win_launch_service.dart | 13 +- .../platform/windows/win_package_context.dart | 63 +- .../lib/platform/windows/win_pipe_server.dart | 55 +- .../windows/win_registration_service.dart | 181 +++ .../lib/platform/windows/win_security.dart | 172 +++ .../lib/platform/windows/win_source_app.dart | 318 +++++ .../platform/windows/windows_bindings.dart | 57 +- apps/linkunbound/lib/providers.dart | 45 +- .../lib/ui/picker/picker_view.dart | 173 ++- .../lib/ui/picker/picker_window.dart | 8 +- .../lib/ui/settings/general_page.dart | 166 ++- .../lib/ui/settings/rules_page.dart | 28 +- .../lib/ui/shared/widgets/rule_row.dart | 40 +- .../macos/Runner.xcodeproj/project.pbxproj | 8 +- .../macos/Runner/AppDelegate.swift | 6 +- .../Channels/BrowserDetectorChannel.swift | 3 + .../Runner/Channels/LinkUnboundChannels.swift | 2 + .../Runner/Channels/RegistrationChannel.swift | 84 +- .../Runner/Channels/SourceAppChannel.swift | 42 + .../macos/Runner/Channels/WindowChannel.swift | 8 +- apps/linkunbound/macos/Runner/Info.plist | 4 +- apps/linkunbound/test/app_test.dart | 4 +- apps/linkunbound/test/bootstrap_test.dart | 57 +- apps/linkunbound/test/helpers.dart | 27 +- .../test/platform/local_file_url_test.dart | 44 + .../win_registration_service_test.dart | 36 + .../test/providers_extra_test.dart | 22 + .../ui/maintenance_page_actions_test.dart | 22 + .../test/ui/maintenance_page_test.dart | 22 + apps/linkunbound/test/ui/phase5_test.dart | 9 +- .../linkunbound/test/ui/picker_view_test.dart | 11 +- .../windows/packaging/exe/setup_template.iss | 8 +- docs/LINK_CAPTURE.md | 204 +++ packages/core/lib/linkunbound_core.dart | 2 + packages/core/lib/src/models/browser.dart | 18 + packages/core/lib/src/models/browser.g.dart | 4 + packages/core/lib/src/models/rule.dart | 34 +- packages/core/lib/src/models/rule.g.dart | 4 + .../lib/src/platform/handler_diagnostics.dart | 41 + .../core/lib/src/platform/inbound_event.dart | 41 +- .../src/platform/registration_service.dart | 21 + packages/core/lib/src/private_mode.dart | 36 + .../core/lib/src/services/launch_service.dart | 12 +- .../core/lib/src/services/log_service.dart | 17 +- .../core/lib/src/services/rule_service.dart | 74 +- .../core/lib/src/services/update_service.dart | 30 +- packages/core/lib/src/url_utils.dart | 25 +- packages/core/test/inbound_event_test.dart | 30 + packages/core/test/private_mode_test.dart | 81 ++ packages/core/test/rule_source_app_test.dart | 173 +++ packages/core/test/update_service_test.dart | 73 +- packages/core/test/url_utils_test.dart | 46 + pubspec.lock | 1143 +++++++++++++++++ 72 files changed, 4349 insertions(+), 332 deletions(-) create mode 100644 apps/linkunbound/lib/platform/macos/mac_source_app.dart create mode 100644 apps/linkunbound/lib/platform/windows/win_security.dart create mode 100644 apps/linkunbound/lib/platform/windows/win_source_app.dart create mode 100644 apps/linkunbound/macos/Runner/Channels/SourceAppChannel.swift create mode 100644 docs/LINK_CAPTURE.md create mode 100644 packages/core/lib/src/platform/handler_diagnostics.dart create mode 100644 packages/core/lib/src/private_mode.dart create mode 100644 packages/core/test/private_mode_test.dart create mode 100644 packages/core/test/rule_source_app_test.dart create mode 100644 pubspec.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 343974c..f27a096 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: 3.44.1 cache: true - name: Cache pub dependencies @@ -129,6 +130,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: 3.44.1 cache: true - name: Cache pub dependencies @@ -184,6 +186,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: 3.44.1 cache: true - name: Cache pub dependencies diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml index cf4008b..c52ea66 100644 --- a/.github/workflows/release-mac.yml +++ b/.github/workflows/release-mac.yml @@ -56,6 +56,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: 3.44.1 cache: true - name: Cache pub dependencies diff --git a/.github/workflows/release-win.yml b/.github/workflows/release-win.yml index 63df2b4..2368d62 100644 --- a/.github/workflows/release-win.yml +++ b/.github/workflows/release-win.yml @@ -54,6 +54,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: stable + flutter-version: 3.44.1 - name: Install Fastforge run: dart pub global activate fastforge diff --git a/.gitignore b/.gitignore index 5c92d8c..37abd12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ # Miscellaneous *.class *.lock +# Applications must pin their dependency graph: without this, every release is +# built against whatever `stable` resolves to that day, which makes a runtime +# regression impossible to bisect. +!pubspec.lock *.log *.pyc *.swp diff --git a/PRIVACY.md b/PRIVACY.md index 533b7db..de0fb0b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -74,6 +74,7 @@ All data is stored locally under your user profile. | Browsers | `%LOCALAPPDATA%\LinkUnbound\browsers.json` | | Rules | `%LOCALAPPDATA%\LinkUnbound\rules.json` | | Log | `%LOCALAPPDATA%\LinkUnbound\navigate.log` | +| Crash log | `%LOCALAPPDATA%\LinkUnbound\startup_crash.log` | | Icons | `%LOCALAPPDATA%\LinkUnbound\icons\` | **macOS** — `~/Library/Application Support/LinkUnbound/`: @@ -83,6 +84,7 @@ All data is stored locally under your user profile. | Browsers | `~/Library/Application Support/LinkUnbound/browsers.json` | | Rules | `~/Library/Application Support/LinkUnbound/rules.json` | | Log | `~/Library/Application Support/LinkUnbound/navigate.log` | +| Crash log | `~/Library/Application Support/LinkUnbound/startup_crash.log` | | Icons | `~/Library/Application Support/LinkUnbound/icons/` | These folders are protected by your operating system's user account permissions. Other users on the same computer cannot access them under normal conditions. @@ -197,6 +199,8 @@ URLs are redacted **at write time** — before they ever reach the log file on d This means the `navigate.log` file on your machine never contains real URLs. The diagnostics export simply copies the last 200 lines of this already-redacted log. +Redaction covers the whole log record, including attached error objects and stack traces. This matters because a failed browser launch raises an error whose text embeds the full command line — that is, the URL. The same redaction is applied to `startup_crash.log`, a separate file written only when the app fails during startup; it is capped in size and is safe to delete at any time. + --- ## Children's Privacy diff --git a/apps/linkunbound/lib/app.dart b/apps/linkunbound/lib/app.dart index fe8e792..7ca8be8 100644 --- a/apps/linkunbound/lib/app.dart +++ b/apps/linkunbound/lib/app.dart @@ -19,10 +19,16 @@ final class NavigateApp extends ConsumerStatefulWidget { final class _NavigateAppState extends ConsumerState with WindowListener { - // Guards against blur events fired before the picker has settled. - // Set to true either on the first onWindowFocus after showing, or after a - // fallback timer — focus events are unreliable on Windows when the window - // is shown programmatically without foreground rights, so we keep the timer. + // Settle window for the picker. Showing a window programmatically produces a + // focus/blur burst of its own — `show()` is posted asynchronously on Windows + // while `focus()` is not — so a blur arriving inside this window is the + // activation settling, not the user clicking away. + // + // The timer is the *only* thing that arms the guard. It used to be armed on + // the first onWindowFocus as well, which meant the focus event generated by + // showing the window armed it immediately and the very next blur closed the + // picker before the user could click anything. + static const _pickerSettleDelay = Duration(milliseconds: 350); bool _pickerBlurReady = false; Timer? _blurGuardTimer; @@ -33,6 +39,10 @@ final class _NavigateAppState extends ConsumerState void initState() { super.initState(); windowManager.addListener(this); + // A cold start that carries a URL builds this widget already in picker + // mode, so the listener below never fires for it. Without arming here the + // picker could never be dismissed by clicking away. + if (ref.read(appStateProvider).mode == AppMode.picker) _armBlurGuard(); } @override @@ -42,6 +52,14 @@ final class _NavigateAppState extends ConsumerState super.dispose(); } + void _armBlurGuard() { + _blurGuardTimer?.cancel(); + _pickerBlurReady = false; + _blurGuardTimer = Timer(_pickerSettleDelay, () { + _pickerBlurReady = true; + }); + } + @override void onWindowClose() async { await windowManager.hide(); @@ -57,18 +75,11 @@ final class _NavigateAppState extends ConsumerState ref.invalidate(isDefaultBrowserProvider); ref.invalidate(isStartupEnabledProvider); } - - final mode = ref.read(appStateProvider).mode; - if (mode == AppMode.picker && !_pickerBlurReady) { - _blurGuardTimer?.cancel(); - _pickerBlurReady = true; - } } @override void onWindowBlur() { - final mode = ref.read(appStateProvider).mode; - if (mode != AppMode.picker) return; + if (ref.read(appStateProvider).mode != AppMode.picker) return; if (!_pickerBlurReady) return; ref.read(appStateProvider.notifier).hide(); } @@ -79,24 +90,18 @@ final class _NavigateAppState extends ConsumerState final locale = ref.watch(localeProvider); final themeMode = ref.watch(themeModeProvider); + // Window geometry and visibility are driven exclusively from bootstrap's + // serialised transition queue. A second show()/focus() from here raced it + // one frame later and could leave the window on screen after the state had + // already collapsed back to hidden — a visible frame with no content. ref.listen(appStateProvider, (prev, next) { if (prev?.mode == next.mode) return; - _blurGuardTimer?.cancel(); - _pickerBlurReady = false; - if (next.mode == AppMode.picker) { - // Fallback: if focus events never fire (e.g. on Windows without - // foreground rights), mark ready after 350 ms so blur can still close. - _blurGuardTimer = Timer(const Duration(milliseconds: 350), () { - _pickerBlurReady = true; - }); + _armBlurGuard(); + } else { + _blurGuardTimer?.cancel(); + _pickerBlurReady = false; } - - if (next.mode == AppMode.hidden) return; - WidgetsBinding.instance.addPostFrameCallback((_) async { - await windowManager.show(); - await windowManager.focus(); - }); }); return MaterialApp( @@ -108,10 +113,44 @@ final class _NavigateAppState extends ConsumerState localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, home: switch (appState.mode) { - AppMode.hidden => const ColoredBox(color: Color(0xFF1E1E2E)), + AppMode.hidden => const _HiddenGuard(), AppMode.settings => const SettingsWindow(), - AppMode.picker => PickerWindow(url: appState.pendingUrl ?? ''), + AppMode.picker => PickerWindow( + url: appState.pendingUrl ?? '', + origin: appState.pendingOrigin, + ), }, ); } } + +/// What the user sees if the window is ever visible while the app believes it +/// is hidden: an empty dark rectangle — the reported "just the frame". +/// +/// Rather than only painting that rectangle, this reconciles the window with +/// the state, so any remaining race resolves itself on the next frame instead +/// of leaving an empty window on screen. +final class _HiddenGuard extends StatefulWidget { + const _HiddenGuard(); + + @override + State<_HiddenGuard> createState() => _HiddenGuardState(); +} + +final class _HiddenGuardState extends State<_HiddenGuard> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + try { + if (await windowManager.isVisible()) await windowManager.hide(); + } on Object { + // Best-effort reconciliation; the window plugin may be unavailable. + } + }); + } + + @override + Widget build(BuildContext context) => + const ColoredBox(color: Color(0xFF1E1E2E)); +} diff --git a/apps/linkunbound/lib/bootstrap.dart b/apps/linkunbound/lib/bootstrap.dart index 7dba3e8..adff00d 100644 --- a/apps/linkunbound/lib/bootstrap.dart +++ b/apps/linkunbound/lib/bootstrap.dart @@ -13,6 +13,7 @@ import 'l10n/app_localizations.dart'; import 'platform/cursor_locator.dart' show findDisplayForPoint; import 'platform/hotkey_service.dart'; import 'platform/local_file_url.dart'; +import 'platform/macos/mac_source_app.dart'; import 'platform/macos/mac_window_channel.dart'; import 'platform/platform_bindings.dart'; import 'platform/tray_controller.dart'; @@ -32,6 +33,18 @@ Future bootstrap(PlatformBindings bindings, List args) async { _log.info('LinkUnbound starting (msix=${isRunningInMsix()})'); + // Before delegating: a process that hands its URL to the resident instance + // exits within milliseconds, so anything done afterwards would never run for + // it. Repairing the registration here means *any* launch fixes a stale or + // hijacked handler, even when this process is only a courier. + try { + await bindings.registrationService.ensureRegistered( + bindings.executablePath, + ); + } on Object catch (e, st) { + _log.warning('Registration reconciliation failed (non-fatal)', e, st); + } + try { if (await bindings.tryDelegate(bindings.initialEvent)) { _exitAfterFlush(); @@ -93,7 +106,7 @@ Future bootstrap(PlatformBindings bindings, List args) async { ); final ruleService = RuleService(rulesFile: bindings.rulesFile); - final isFirstBoot = !bindings.browsersFile.existsSync(); + var isFirstBoot = !bindings.browsersFile.existsSync(); try { await browserService.load(); @@ -101,6 +114,10 @@ Future bootstrap(PlatformBindings bindings, List args) async { _log.severe('Browser config corrupted, resetting', e, st); try { await browserService.reset(); + // reset() leaves the list empty. Without re-scanning, the picker would + // render an empty window for the rest of this install — the flag was + // computed before the reset, so it says "not first boot". + isFirstBoot = true; } on Object catch (e, st) { _log.warning('Browser reset failed', e, st); } @@ -126,6 +143,10 @@ Future bootstrap(PlatformBindings bindings, List args) async { try { await windowManager.ensureInitialized(); await windowManager.setPreventClose(true); + // The callback form of waitUntilReadyToShow is a plain VoidCallback: an + // async body is *not* awaited, so its channel calls would still be in + // flight while the first inbound URL is already repositioning the window. + // Sequencing it here keeps setup and the first mode transition ordered. await windowManager.waitUntilReadyToShow( const WindowOptions( titleBarStyle: TitleBarStyle.hidden, @@ -136,19 +157,17 @@ Future bootstrap(PlatformBindings bindings, List args) async { // to render a transparent frame and crash the Flutter engine. backgroundColor: Color(0xFF1E1E1E), ), - () async { - await windowManager.setSkipTaskbar(true); - if (!Platform.isMacOS) { - try { - await windowManager.setHasShadow(false); - } on Object catch (e) { - _log.fine('setHasShadow not supported: $e'); - } - await windowManager.setPosition(const Offset(-9999, -9999)); - await windowManager.hide(); - } - }, ); + await windowManager.setSkipTaskbar(true); + if (!Platform.isMacOS) { + try { + await windowManager.setHasShadow(false); + } on Object catch (e) { + _log.fine('setHasShadow not supported: $e'); + } + await windowManager.setPosition(const Offset(-9999, -9999)); + await windowManager.hide(); + } } on Object catch (e, st) { _log.severe('Window manager init failed', e, st); } @@ -174,6 +193,7 @@ Future bootstrap(PlatformBindings bindings, List args) async { hideTrayFileProvider.overrideWithValue(bindings.hideTrayFile), globalHotkeyFileProvider.overrideWithValue(bindings.globalHotkeyFile), appDataDirProvider.overrideWithValue(bindings.appDataDir), + executablePathProvider.overrideWithValue(bindings.executablePath), exitAppProvider.overrideWithValue(() async { try { await hotkeyService.dispose(); @@ -192,12 +212,49 @@ Future bootstrap(PlatformBindings bindings, List args) async { final macWindow = Platform.isMacOS ? MacWindowChannel() : null; - container.listen(appStateProvider, (prev, next) async { + // Riverpod does not await listeners, and each transition issues a dozen + // platform round-trips. Two overlapping transitions (hidden → settings → + // picker on a cold start) would interleave setSize/center/show and leave the + // window in an indeterminate geometry, so only one runs at a time. + // + // Deliberately not a chain of `then()` on a long-lived future: that would + // pin every later transition to the zone bootstrap started in, and one + // wedged transition would block the app's response to links forever. Instead + // a re-entrancy flag drains whatever state is current when the running + // transition finishes. + AppState? applied; + var applying = false; + + Future drainModeChanges() async { + if (applying) return; + applying = true; try { - await _applyAppMode(prev, next, container, bindings, macWindow); - } on Object catch (e, st) { - _log.warning('App mode transition failed', e, st); + var target = container.read(appStateProvider); + while (!identical(target, applied)) { + final previous = applied; + applied = target; + try { + // A transition that never completes must not deafen the app to + // every link that follows. + await _applyAppMode( + previous, + target, + container, + bindings, + macWindow, + ).timeout(const Duration(seconds: 10)); + } on Object catch (e, st) { + _log.warning('App mode transition failed', e, st); + } + target = container.read(appStateProvider); + } + } finally { + applying = false; } + } + + container.listen(appStateProvider, (prev, next) { + unawaited(drainModeChanges()); }); // Subscribe to inbound events before runApp so no event is dropped while @@ -207,8 +264,8 @@ Future bootstrap(PlatformBindings bindings, List args) async { (event) { try { switch (event) { - case OpenUrlEvent(:final url): - _handleUrl(url, container); + case OpenUrlEvent(:final url, :final sourceApp): + unawaited(_handleUrl(url, container, sourceApp: sourceApp)); case ShowSettingsEvent(): container.read(appStateProvider.notifier).showSettings(); } @@ -227,6 +284,9 @@ Future bootstrap(PlatformBindings bindings, List args) async { WidgetsBinding.instance.addPostFrameCallback((_) async { if (bindings.startsHidden) return; + // A launch that carries a URL is a link click, not a request to open + // Settings; opening it anyway queues a settings→picker transition pair. + if (bindings.initialEvent is OpenUrlEvent) return; if (container.read(appStateProvider).mode != AppMode.hidden) return; container.read(appStateProvider.notifier).showSettings(); }); @@ -283,9 +343,6 @@ Future bootstrap(PlatformBindings bindings, List args) async { browserService: browserService, iconExtractor: bindings.iconExtractor, iconsDir: bindings.iconsDir, - registrationService: bindings.registrationService, - executablePath: bindings.executablePath, - skipRegistration: isRunningInMsix(), container: container, ); } on Object catch (e, st) { @@ -306,10 +363,18 @@ Future _applyAppMode( MacWindowChannel? macWindow, ) async { if (prev?.mode == next.mode) { - if (next.mode == AppMode.settings) { - await windowManager.show(); - await windowManager.focus(); - await macWindow?.activate(); + switch (next.mode) { + case AppMode.settings: + await windowManager.show(); + await windowManager.focus(); + await macWindow?.activate(); + case AppMode.picker: + // A second link while the picker is already up must reposition and + // re-show it. Returning early here used to make the app go deaf to + // every subsequent link once a transition had failed mid-way. + await _showPicker(container, bindings, macWindow); + case AppMode.hidden: + break; } return; } @@ -333,50 +398,77 @@ Future _applyAppMode( await windowManager.focus(); await macWindow?.activate(); case AppMode.picker: - try { - await macWindow?.setPickerMode(); - final browsers = container.read(browsersProvider); - final winSize = PickerLayout.windowSize(browsers.length); - // Fetch cursor and display list concurrently, then hit-test locally so - // both reads observe the same cursor position. - final (cursorResult, rects) = await ( - bindings.cursorLocator.cursorPosition(), - bindings.cursorLocator.displayRects(), - ).wait; - final (cursorX, cursorY) = cursorResult; - final (originX, originY, displayW, displayH) = findDisplayForPoint( - cursorX, - cursorY, - rects, - ); - final x = (cursorX - winSize.width / 2).clamp( - originX + 8.0, - originX + displayW - winSize.width - 8, - ); - final y = (cursorY + 16).clamp( - originY + 8.0, - originY + displayH - winSize.height - 8, - ); - await windowManager.setSize(winSize); - // Position before show() to prevent the ghost-flash at the old position. - await windowManager.setPosition(Offset(x, y)); - await windowManager.setSkipTaskbar(true); - await windowManager.setAlwaysOnTop(true); - await windowManager.show(); - if (!Platform.isMacOS) await windowManager.focus(); - await macWindow?.activate(); - } on Object catch (e, st) { - _log.warning('Picker transition failed, hiding to safe state', e, st); - try { - await windowManager.hide(); - } on Object catch (hideErr) { - _log.warning('hide() after picker failure also failed: $hideErr'); - } - rethrow; - } + await _showPicker(container, bindings, macWindow); } } +/// Sizes and positions the picker under the cursor, then shows it. +/// +/// Any failure here returns the app to a consistent state — window hidden and +/// mode set back to hidden — instead of leaving the state machine parked in +/// `picker` with nothing on screen, which used to make every later link a +/// no-op for the rest of the session. +Future _showPicker( + ProviderContainer container, + PlatformBindings bindings, + MacWindowChannel? macWindow, +) async { + try { + await macWindow?.setPickerMode(); + final browsers = container.read(browsersProvider); + final winSize = PickerLayout.windowSize(browsers.length); + // Fetch cursor and display list concurrently, then hit-test locally so + // both reads observe the same cursor position. + final (cursorResult, rects) = await ( + bindings.cursorLocator.cursorPosition(), + bindings.cursorLocator.displayRects(), + ).wait; + final (cursorX, cursorY) = cursorResult; + final (originX, originY, displayW, displayH) = findDisplayForPoint( + cursorX, + cursorY, + rects, + ); + final x = _clampToRange( + cursorX - winSize.width / 2, + originX + 8.0, + originX + displayW - winSize.width - 8, + ); + final y = _clampToRange( + cursorY + 16, + originY + 8.0, + originY + displayH - winSize.height - 8, + ); + await windowManager.setSize(winSize); + // Position before show() to prevent the ghost-flash at the old position. + await windowManager.setPosition(Offset(x, y)); + await windowManager.setSkipTaskbar(true); + await windowManager.setAlwaysOnTop(true); + await windowManager.show(); + if (!Platform.isMacOS) await windowManager.focus(); + await macWindow?.activate(); + // Re-apply the size once the window is actually on screen: a resize issued + // while hidden can leave the engine surface at the previous dimensions, + // which renders as a correctly framed but empty window. + await windowManager.setSize(winSize); + } on Object catch (e, st) { + _log.warning('Picker transition failed, returning to hidden', e, st); + try { + await windowManager.hide(); + } on Object catch (hideErr) { + _log.warning('hide() after picker failure also failed: $hideErr'); + } + container.read(appStateProvider.notifier).hide(); + rethrow; + } +} + +/// `num.clamp` throws when the upper bound falls below the lower one, which +/// happens on small or heavily scaled displays where the picker is taller than +/// the work area. Pinning to the lower bound keeps the window on screen. +double _clampToRange(double value, double lower, double upper) => + upper < lower ? lower : value.clamp(lower, upper).toDouble(); + /// Runs before runApp: scan detected browsers and create the icons directory /// so browsersProvider has data for the first frame. Future _firstBootEarlyPhase({ @@ -391,16 +483,15 @@ Future _firstBootEarlyPhase({ } } -/// Runs after runApp: extract icons concurrently and register the app. +/// Runs after runApp: extract icons concurrently. /// The picker renders with fallback icons until extraction completes. +/// Registration is not done here — it is reconciled on every launch during +/// bootstrap, before the first frame. Future _firstBootLatePhase({ required BrowserService browserService, required IconExtractor iconExtractor, required Directory iconsDir, - required RegistrationService registrationService, - required String executablePath, required ProviderContainer container, - bool skipRegistration = false, }) async { // Extract all icons concurrently; swallow per-item errors as before. await Future.wait( @@ -418,19 +509,14 @@ Future _firstBootLatePhase({ // Invalidate browsersProvider so the picker picks up freshly extracted icons. container.invalidate(browsersProvider); - if (skipRegistration) { - _log.info('Skipping browser registration in MSIX context'); - } else { - try { - await registrationService.register(executablePath); - } on Object catch (e, st) { - _log.warning('Browser registration failed (non-fatal)', e, st); - } - } _log.info('First boot complete: ${browserService.browsers.length} browsers'); } -void _handleUrl(String url, ProviderContainer container) { +Future _handleUrl( + String url, + ProviderContainer container, { + String? sourceApp, +}) async { if (looksLikeLocalFile(url)) { final resolved = resolveLocalWebFile(url); if (resolved == null) { @@ -443,16 +529,35 @@ void _handleUrl(String url, ProviderContainer container) { } final resolved = unwrapSafeLink(url); + // Inbound events arrive over IPC from any local process, so the scheme is + // untrusted here. A string like `--gpu-launcher=…` is not a URL but would be + // handed to the browser as argv and executed as a switch. + if (!isLaunchableUrl(resolved)) { + _log.warning('Rejected URL with non-launchable scheme'); + return; + } + + // macOS cannot tell us who opened the link, so the frontmost app stands in + // for it. Resolved here rather than in the event because it has to be read + // as close to the click as possible to still be accurate. + final origin = sourceApp ?? (Platform.isMacOS ? await _macSourceApp() : null); + if (origin != null) _log.fine('Link originated from $origin'); + final ruleService = container.read(ruleServiceProvider); - final matchedBrowserId = ruleService.lookupBrowser(resolved); + final rule = ruleService.lookupRule(resolved, sourceApp: origin); - if (matchedBrowserId != null) { + if (rule != null) { final browsers = container.read(browserServiceProvider).browsers; - final browser = browsers.where((b) => b.id == matchedBrowserId).firstOrNull; + final browser = browsers.where((b) => b.id == rule.browserId).firstOrNull; if (browser != null) { final launch = container .read(launchServiceProvider) - .launch(browser.executablePath, resolved, browser.extraArgs); + .launch( + browser.executablePath, + resolved, + browser.extraArgs, + privateArgs: rule.private ? browser.resolvedPrivateArgs : const [], + ); unawaited( launch.catchError((Object e, StackTrace st) { _log.severe('Launch failed for ${browser.name}', e, st); @@ -463,7 +568,18 @@ void _handleUrl(String url, ProviderContainer container) { } } - container.read(appStateProvider.notifier).showPicker(resolved); + container + .read(appStateProvider.notifier) + .showPicker(resolved, origin: origin); +} + +Future _macSourceApp() async { + try { + return (await frontmostApp())?.id; + } on Object catch (e) { + _log.fine('Frontmost app lookup failed: $e'); + return null; + } } String _redactForLog(String raw) { diff --git a/apps/linkunbound/lib/l10n/app_en.arb b/apps/linkunbound/lib/l10n/app_en.arb index f1d9e31..d3d5870 100644 --- a/apps/linkunbound/lib/l10n/app_en.arb +++ b/apps/linkunbound/lib/l10n/app_en.arb @@ -1,37 +1,32 @@ { "@@locale": "en", - "exit": "Exit", "traySettings": "Settings", "copyUrl": "Copy URL", "alwaysOpenHere": "Always open here", - "tabGeneral": "General", "tabRules": "Rules", "tabAbout": "About", "tabMaintenance": "Maintenance", - "sectionDefaultBrowser": "DEFAULT BROWSER", "isDefaultBrowser": "LinkUnbound is set as the default browser", "notDefaultBrowser": "LinkUnbound is not the default browser", "setDefault": "Set default", - "sectionStartup": "STARTUP", "launchAtStartup": "Launch at system startup", "startupManagedByWindows": "Managed by Windows Settings > Startup Apps", - "@startupManagedByWindows": {"description": "Tooltip shown when the startup toggle is disabled because the app runs as MSIX and Windows owns that preference."}, - + "@startupManagedByWindows": { + "description": "Tooltip shown when the startup toggle is disabled because the app runs as MSIX and Windows owns that preference." + }, "sectionLanguage": "LANGUAGE", "languageAuto": "Automatic (system)", "languageEnglish": "English", "languageSpanish": "Spanish", - "sectionAppearance": "APPEARANCE", "themeLabel": "Theme", "themeSystem": "Automatic (system)", "themeLight": "Light", "themeDark": "Dark", - "sectionBrowsers": "BROWSERS", "addBrowserTooltip": "Add custom browser", "refreshBrowsersTooltip": "Refresh browsers", @@ -41,8 +36,12 @@ "refreshResult": "{added} added, {removed} removed", "@refreshResult": { "placeholders": { - "added": { "type": "int" }, - "removed": { "type": "int" } + "added": { + "type": "int" + }, + "removed": { + "type": "int" + } } }, "refreshNoChanges": "No changes detected", @@ -57,7 +56,6 @@ "add": "Add", "save": "Save", "confirm": "Confirm", - "sectionUrlRules": "URL RULES", "noRulesYet": "No rules yet. Rules are created from the browser picker when you check \"Always open here\".", "columnDomain": "Domain", @@ -66,17 +64,20 @@ "deleteRuleContent": "Remove the rule for \"{domain}\"?", "@deleteRuleContent": { "placeholders": { - "domain": { "type": "String" } + "domain": { + "type": "String" + } } }, "delete": "Delete", "deleteRuleTooltip": "Delete rule", - "sectionAbout": "ABOUT", "appVersion": "Version {version}", "@appVersion": { "placeholders": { - "version": { "type": "String" } + "version": { + "type": "String" + } } }, "appDescription": "Open-source browser picker for Windows.", @@ -91,48 +92,104 @@ "unregisterTitle": "Unregister LinkUnbound", "unregisterContent": "This will remove LinkUnbound from the Windows browser list. You may need to change your default browser in Windows Settings afterwards. Continue?", "unregisterAction": "Unregister", - "updateAvailable": "Version {version} available", "@updateAvailable": { "placeholders": { - "version": { "type": "String" } + "version": { + "type": "String" + } } }, "updateDownload": "Download", "updateAvailableStore": "Version {version} available — check Microsoft Store for the new version", "@updateAvailableStore": { "placeholders": { - "version": { "type": "String" } + "version": { + "type": "String" + } } }, "updateTooltip": "New version available — check for updates in Settings", - "sectionSupport": "SUPPORT", "donateLabel": "Buy me a coffee", "donateDescription": "LinkUnbound is free and always will be. If it saves you time, consider supporting development.", "sectionOtherTools": "OTHER TOOLS", "otherToolCopyPaste": "CopyPaste", "otherToolCopyPasteDescription": "Free, open source clipboard manager for Windows, macOS and Linux. Same philosophy: no ads, no telemetry, everything local.", - "edgeWarningTitle": "Microsoft Edge detected", "edgeWarningBody": "Microsoft Teams, Outlook, and other Microsoft 365 apps may open links directly in Edge, ignoring your default browser. This is a Microsoft design decision that LinkUnbound cannot override.", "edgeWarningNote": "You can change this behavior from each app's settings. Some organizations enforce Edge through group policies.", "edgeWarningDismiss": "Got it, don't show again", - "sectionMaintenance": "MAINTENANCE", "exportDiagnosticsLabel": "Export diagnostics", "exportDiagnosticsDescription": "Generate a ZIP with system info, registry data, and logs for troubleshooting", - "errorStartupToggle": "Could not change startup setting", "errorUnregister": "Could not unregister LinkUnbound", "errorExportDiagnostics": "Could not export diagnostics", "errorResetConfig": "Could not reset configuration", - "sectionAccessibility": "ACCESSIBILITY", "globalHotkeyLabel": "Global shortcut to open settings", "globalHotkeyNone": "None (disabled)", "hideTrayLabel": "Hide tray / menu bar icon", "hideTraySubtitleNoHotkey": "Requires a global shortcut to be configured first", "hideTraySubtitleMac": "You can also reach the app by relaunching it from Applications", - "hideTraySubtitleWindows": "You can reach the app via its global shortcut" + "hideTraySubtitleWindows": "You can reach the app via its global shortcut", + "pickerNoBrowsers": "No browsers detected. Open Settings to add one.", + "@pickerNoBrowsers": { + "description": "Shown in the picker when no browsers are configured" + }, + "edgeProtocolLabel": "Capture links from Microsoft apps", + "@edgeProtocolLabel": { + "description": "Toggle label for microsoft-edge: protocol capture (Windows only)" + }, + "edgeProtocolDescription": "Teams, Outlook and Start menu search open links through Edge directly, bypassing the default browser. Enable this to let LinkUnbound offer a choice for those links too.", + "@edgeProtocolDescription": { + "description": "Explains what capturing the microsoft-edge: protocol does" + }, + "pickerPrivateHint": "Shift = private", + "@pickerPrivateHint": { + "description": "Hint in the picker footer: holding Shift opens a private window" + }, + "alwaysOpenFromApp": "Always open links from {app} here", + "@alwaysOpenFromApp": { + "description": "Checkbox label when the originating app is known", + "placeholders": { + "app": { + "type": "String" + } + } + }, + "diagnosticsTitle": "Link capture problem detected", + "@diagnosticsTitle": { + "description": "Handler self-diagnostics in Settings" + }, + "diagnosticsStaleHandler": "The registered handler points somewhere else, so links will not reach LinkUnbound.", + "@diagnosticsStaleHandler": { + "description": "Handler self-diagnostics in Settings" + }, + "diagnosticsDevBuild": "Running from a local build, which never registers itself. Install the app to capture links.", + "@diagnosticsDevBuild": { + "description": "Handler self-diagnostics in Settings" + }, + "diagnosticsRepair": "Repair", + "@diagnosticsRepair": { + "description": "Handler self-diagnostics in Settings" + }, + "diagnosticsRepaired": "Registration repaired.", + "@diagnosticsRepaired": { + "description": "Handler self-diagnostics in Settings" + }, + "diagnosticsRepairFailed": "Could not repair the registration.", + "@diagnosticsRepairFailed": { + "description": "Handler self-diagnostics in Settings" + }, + "ruleFromApp": "Links from {app}", + "@ruleFromApp": { + "description": "Rule label when scoped to the originating app", + "placeholders": { + "app": { + "type": "String" + } + } + } } diff --git a/apps/linkunbound/lib/l10n/app_es.arb b/apps/linkunbound/lib/l10n/app_es.arb index 5d3b2ae..ed3b195 100644 --- a/apps/linkunbound/lib/l10n/app_es.arb +++ b/apps/linkunbound/lib/l10n/app_es.arb @@ -1,36 +1,29 @@ { "@@locale": "es", - "exit": "Salir", "traySettings": "Configuración", "copyUrl": "Copiar URL", "alwaysOpenHere": "Abrir siempre aquí", - "tabGeneral": "General", "tabRules": "Reglas", "tabAbout": "Acerca de", "tabMaintenance": "Mantenimiento", - "sectionDefaultBrowser": "NAVEGADOR PREDETERMINADO", "isDefaultBrowser": "LinkUnbound está configurado como navegador predeterminado", "notDefaultBrowser": "LinkUnbound no está configurado como navegador predeterminado", "setDefault": "Establecer", - "sectionStartup": "INICIO", "launchAtStartup": "Iniciar con el sistema", "startupManagedByWindows": "Gestionado desde Configuración de Windows > Aplicaciones de inicio", - "sectionLanguage": "IDIOMA", "languageAuto": "Automático (sistema)", "languageEnglish": "Inglés", "languageSpanish": "Español", - "sectionAppearance": "APARIENCIA", "themeLabel": "Tema", "themeSystem": "Automático (sistema)", "themeLight": "Claro", "themeDark": "Oscuro", - "sectionBrowsers": "NAVEGADORES", "addBrowserTooltip": "Añadir navegador personalizado", "refreshBrowsersTooltip": "Actualizar navegadores", @@ -50,7 +43,6 @@ "add": "Añadir", "save": "Guardar", "confirm": "Confirmar", - "sectionUrlRules": "REGLAS DE URL", "noRulesYet": "Sin reglas aún. Las reglas se crean desde el selector de navegadores al marcar \"Abrir siempre aquí\".", "columnDomain": "Dominio", @@ -59,7 +51,6 @@ "deleteRuleContent": "¿Eliminar la regla para \"{domain}\"?", "delete": "Eliminar", "deleteRuleTooltip": "Eliminar regla", - "sectionAbout": "ACERCA DE", "appVersion": "Versión {version}", "appDescription": "Selector de navegadores de código abierto para Windows.", @@ -74,43 +65,51 @@ "unregisterTitle": "Desregistrar LinkUnbound", "unregisterContent": "Esto eliminará LinkUnbound de la lista de navegadores de Windows. Es posible que necesites cambiar tu navegador predeterminado en la Configuración de Windows después. ¿Continuar?", "unregisterAction": "Desregistrar", - "updateAvailable": "Versión {version} disponible", "updateDownload": "Descargar", "updateAvailableStore": "Versión {version} disponible — verifica en Microsoft Store la nueva versión", "@updateAvailableStore": { "placeholders": { - "version": { "type": "String" } + "version": { + "type": "String" + } } }, "updateTooltip": "Nueva versión disponible — revisa las actualizaciones en Ajustes", - "sectionSupport": "APÓYANOS", "donateLabel": "Invítame un café", "donateDescription": "LinkUnbound es gratis y siempre lo será. Si te ahorra tiempo, considera apoyar el desarrollo.", "sectionOtherTools": "OTRAS HERRAMIENTAS", "otherToolCopyPaste": "CopyPaste", "otherToolCopyPasteDescription": "Gestor de portapapeles gratuito y de código abierto para Windows, macOS y Linux. Misma filosofía: sin anuncios, sin telemetría, todo local.", - "edgeWarningTitle": "Microsoft Edge detectado", "edgeWarningBody": "Microsoft Teams, Outlook y otras apps de Microsoft 365 pueden abrir links directamente en Edge, ignorando tu navegador predeterminado. Esto es una decisión de diseño de Microsoft que LinkUnbound no puede evitar.", "edgeWarningNote": "Puedes cambiar este comportamiento desde la configuración de cada app. Algunas organizaciones fuerzan Edge a través de políticas de grupo.", "edgeWarningDismiss": "Entendido, no mostrar de nuevo", - "sectionMaintenance": "MANTENIMIENTO", "exportDiagnosticsLabel": "Exportar diagnóstico", "exportDiagnosticsDescription": "Genera un ZIP con info del sistema, datos del registro y logs para diagnóstico", - "errorStartupToggle": "No se pudo cambiar la configuración de inicio", "errorUnregister": "No se pudo desregistrar LinkUnbound", "errorExportDiagnostics": "No se pudo exportar el diagnóstico", "errorResetConfig": "No se pudo restablecer la configuración", - "sectionAccessibility": "ACCESIBILIDAD", "globalHotkeyLabel": "Atajo global para abrir la configuración", "globalHotkeyNone": "Ninguno (desactivado)", "hideTrayLabel": "Ocultar icono de bandeja / barra de menú", "hideTraySubtitleNoHotkey": "Requiere configurar primero un atajo global", "hideTraySubtitleMac": "También puedes abrir la app relanzándola desde Aplicaciones", - "hideTraySubtitleWindows": "Puedes abrir la app mediante su atajo global" + "hideTraySubtitleWindows": "Puedes abrir la app mediante su atajo global", + "pickerNoBrowsers": "No se detectaron navegadores. Abre Ajustes para añadir uno.", + "edgeProtocolLabel": "Capturar enlaces de aplicaciones de Microsoft", + "edgeProtocolDescription": "Teams, Outlook y la búsqueda del menú Inicio abren los enlaces directamente en Edge, ignorando el navegador predeterminado. Actívalo para que LinkUnbound también ofrezca elegir en esos enlaces.", + "pickerPrivateHint": "Mayús = privada", + "alwaysOpenFromApp": "Abrir siempre aquí los enlaces de {app}", + "diagnosticsTitle": "Problema de captura de enlaces detectado", + "diagnosticsStaleHandler": "El manejador registrado apunta a otra ubicación, así que los enlaces no llegarán a LinkUnbound.", + "diagnosticsDevBuild": "Se está ejecutando una compilación local, que nunca se registra. Instala la aplicación para capturar enlaces.", + "diagnosticsRepair": "Reparar", + "diagnosticsRepaired": "Registro reparado.", + "diagnosticsRepairFailed": "No se pudo reparar el registro.", + "ruleFromApp": "Enlaces de {app}" } diff --git a/apps/linkunbound/lib/l10n/app_localizations.dart b/apps/linkunbound/lib/l10n/app_localizations.dart index 164ba74..90dac77 100644 --- a/apps/linkunbound/lib/l10n/app_localizations.dart +++ b/apps/linkunbound/lib/l10n/app_localizations.dart @@ -655,6 +655,78 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You can reach the app via its global shortcut'** String get hideTraySubtitleWindows; + + /// Shown in the picker when no browsers are configured + /// + /// In en, this message translates to: + /// **'No browsers detected. Open Settings to add one.'** + String get pickerNoBrowsers; + + /// Toggle label for microsoft-edge: protocol capture (Windows only) + /// + /// In en, this message translates to: + /// **'Capture links from Microsoft apps'** + String get edgeProtocolLabel; + + /// Explains what capturing the microsoft-edge: protocol does + /// + /// In en, this message translates to: + /// **'Teams, Outlook and Start menu search open links through Edge directly, bypassing the default browser. Enable this to let LinkUnbound offer a choice for those links too.'** + String get edgeProtocolDescription; + + /// Hint in the picker footer: holding Shift opens a private window + /// + /// In en, this message translates to: + /// **'Shift = private'** + String get pickerPrivateHint; + + /// Checkbox label when the originating app is known + /// + /// In en, this message translates to: + /// **'Always open links from {app} here'** + String alwaysOpenFromApp(String app); + + /// Handler self-diagnostics in Settings + /// + /// In en, this message translates to: + /// **'Link capture problem detected'** + String get diagnosticsTitle; + + /// Handler self-diagnostics in Settings + /// + /// In en, this message translates to: + /// **'The registered handler points somewhere else, so links will not reach LinkUnbound.'** + String get diagnosticsStaleHandler; + + /// Handler self-diagnostics in Settings + /// + /// In en, this message translates to: + /// **'Running from a local build, which never registers itself. Install the app to capture links.'** + String get diagnosticsDevBuild; + + /// Handler self-diagnostics in Settings + /// + /// In en, this message translates to: + /// **'Repair'** + String get diagnosticsRepair; + + /// Handler self-diagnostics in Settings + /// + /// In en, this message translates to: + /// **'Registration repaired.'** + String get diagnosticsRepaired; + + /// Handler self-diagnostics in Settings + /// + /// In en, this message translates to: + /// **'Could not repair the registration.'** + String get diagnosticsRepairFailed; + + /// Rule label when scoped to the originating app + /// + /// In en, this message translates to: + /// **'Links from {app}'** + String ruleFromApp(String app); } class _AppLocalizationsDelegate diff --git a/apps/linkunbound/lib/l10n/app_localizations_en.dart b/apps/linkunbound/lib/l10n/app_localizations_en.dart index dce5947..63f7b3a 100644 --- a/apps/linkunbound/lib/l10n/app_localizations_en.dart +++ b/apps/linkunbound/lib/l10n/app_localizations_en.dart @@ -310,4 +310,48 @@ class AppLocalizationsEn extends AppLocalizations { @override String get hideTraySubtitleWindows => 'You can reach the app via its global shortcut'; + + @override + String get pickerNoBrowsers => + 'No browsers detected. Open Settings to add one.'; + + @override + String get edgeProtocolLabel => 'Capture links from Microsoft apps'; + + @override + String get edgeProtocolDescription => + 'Teams, Outlook and Start menu search open links through Edge directly, bypassing the default browser. Enable this to let LinkUnbound offer a choice for those links too.'; + + @override + String get pickerPrivateHint => 'Shift = private'; + + @override + String alwaysOpenFromApp(String app) { + return 'Always open links from $app here'; + } + + @override + String get diagnosticsTitle => 'Link capture problem detected'; + + @override + String get diagnosticsStaleHandler => + 'The registered handler points somewhere else, so links will not reach LinkUnbound.'; + + @override + String get diagnosticsDevBuild => + 'Running from a local build, which never registers itself. Install the app to capture links.'; + + @override + String get diagnosticsRepair => 'Repair'; + + @override + String get diagnosticsRepaired => 'Registration repaired.'; + + @override + String get diagnosticsRepairFailed => 'Could not repair the registration.'; + + @override + String ruleFromApp(String app) { + return 'Links from $app'; + } } diff --git a/apps/linkunbound/lib/l10n/app_localizations_es.dart b/apps/linkunbound/lib/l10n/app_localizations_es.dart index 58ee1b9..ed43533 100644 --- a/apps/linkunbound/lib/l10n/app_localizations_es.dart +++ b/apps/linkunbound/lib/l10n/app_localizations_es.dart @@ -316,4 +316,49 @@ class AppLocalizationsEs extends AppLocalizations { @override String get hideTraySubtitleWindows => 'Puedes abrir la app mediante su atajo global'; + + @override + String get pickerNoBrowsers => + 'No se detectaron navegadores. Abre Ajustes para añadir uno.'; + + @override + String get edgeProtocolLabel => + 'Capturar enlaces de aplicaciones de Microsoft'; + + @override + String get edgeProtocolDescription => + 'Teams, Outlook y la búsqueda del menú Inicio abren los enlaces directamente en Edge, ignorando el navegador predeterminado. Actívalo para que LinkUnbound también ofrezca elegir en esos enlaces.'; + + @override + String get pickerPrivateHint => 'Mayús = privada'; + + @override + String alwaysOpenFromApp(String app) { + return 'Abrir siempre aquí los enlaces de $app'; + } + + @override + String get diagnosticsTitle => 'Problema de captura de enlaces detectado'; + + @override + String get diagnosticsStaleHandler => + 'El manejador registrado apunta a otra ubicación, así que los enlaces no llegarán a LinkUnbound.'; + + @override + String get diagnosticsDevBuild => + 'Se está ejecutando una compilación local, que nunca se registra. Instala la aplicación para capturar enlaces.'; + + @override + String get diagnosticsRepair => 'Reparar'; + + @override + String get diagnosticsRepaired => 'Registro reparado.'; + + @override + String get diagnosticsRepairFailed => 'No se pudo reparar el registro.'; + + @override + String ruleFromApp(String app) { + return 'Enlaces de $app'; + } } diff --git a/apps/linkunbound/lib/main.dart b/apps/linkunbound/lib/main.dart index c3d7ed5..778e8ba 100644 --- a/apps/linkunbound/lib/main.dart +++ b/apps/linkunbound/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; import 'bootstrap.dart'; import 'platform/macos/macos_bindings.dart'; @@ -37,6 +38,8 @@ Future main(List args) async { ); } +const _maxCrashLogSize = 256 * 1024; + void _writeStartupCrashLog(String source, Object error, StackTrace? stack) { try { final String base; @@ -52,9 +55,16 @@ void _writeStartupCrashLog(String source, Object error, StackTrace? stack) { final dir = Platform.isWindows ? '$base\\LinkUnbound' : '$base/LinkUnbound'; Directory(dir).createSync(recursive: true); final file = File('$dir${Platform.pathSeparator}startup_crash.log'); + // Bounded like navigate.log: this file is append-only and a repeating + // failure (a browser whose path went stale) would otherwise grow forever. + if (file.existsSync() && file.lengthSync() > _maxCrashLogSize) { + file.deleteSync(); + } final now = DateTime.now().toIso8601String(); + // Redacted like every other sink: a ProcessException carries the full + // command line, i.e. the user's URL. file.writeAsStringSync( - '[$now] $source: $error\n$stack\n\n', + '[$now] $source: ${redactUrls('$error')}\n${redactUrls('$stack')}\n\n', mode: FileMode.append, ); } on Object { diff --git a/apps/linkunbound/lib/platform/local_file_url.dart b/apps/linkunbound/lib/platform/local_file_url.dart index 46ee6fe..055e6f8 100644 --- a/apps/linkunbound/lib/platform/local_file_url.dart +++ b/apps/linkunbound/lib/platform/local_file_url.dart @@ -24,7 +24,10 @@ String? resolveLocalWebFile(String raw) { } bool looksLikeLocalFile(String raw) { - if (raw.startsWith('file://')) return true; + // Scheme comparison must be case-insensitive: `Uri` lowercases the scheme, + // so a `FILE://` argument passes the inbound scheme check yet would skip + // this guard — and with it the extension allowlist — if matched literally. + if (Uri.tryParse(raw)?.scheme.toLowerCase() == 'file') return true; if (Platform.isWindows && _windowsAbsPath.hasMatch(raw)) return true; return false; } @@ -40,14 +43,20 @@ String redactPath(String path) { } String? _toFilesystemPath(String raw) { - if (raw.startsWith('file://')) { - final uri = Uri.tryParse(raw); - if (uri == null || uri.scheme != 'file') return null; + final uri = Uri.tryParse(raw); + if (uri != null && uri.scheme.toLowerCase() == 'file') { + // `file://host/share/x.html` becomes a UNC path, and merely probing it + // makes Windows authenticate against `host`, leaking a NetNTLMv2 hash to + // an attacker-chosen server before the picker is even shown. + if (uri.host.isNotEmpty) return null; + final String path; try { - return uri.toFilePath(); + path = uri.toFilePath(); } on UnsupportedError { return null; } + if (path.startsWith(r'\\') || path.startsWith('//')) return null; + return path; } if (Platform.isWindows && _windowsAbsPath.hasMatch(raw)) return raw; diff --git a/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart b/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart index 64d0765..d0391dd 100644 --- a/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart +++ b/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart @@ -123,7 +123,7 @@ Future _writeLaunchServicesDump(Directory staging) async { } void _copyDataSnapshots(Directory appDataDir, Directory staging) { - for (final name in const ['browsers.json', 'rules.json', 'locale.json']) { + for (final name in const ['browsers.json', 'rules.json', 'locale']) { final src = File('${appDataDir.path}/$name'); if (src.existsSync()) { try { @@ -136,7 +136,7 @@ void _copyDataSnapshots(Directory appDataDir, Directory staging) { } void _copyLogTail(Directory appDataDir, Directory staging) { - final logFile = File('${appDataDir.path}/linkunbound.log'); + final logFile = File('${appDataDir.path}/navigate.log'); if (!logFile.existsSync()) return; try { @@ -145,7 +145,7 @@ void _copyLogTail(Directory appDataDir, Directory staging) { ? lines.sublist(lines.length - _maxLogLines) : lines; File( - '${staging.path}/linkunbound.log', + '${staging.path}/navigate.log', ).writeAsStringSync('${tail.join('\n')}\n'); } on Exception catch (e) { _log.fine('Failed to copy log tail: $e'); diff --git a/apps/linkunbound/lib/platform/macos/mac_launch_service.dart b/apps/linkunbound/lib/platform/macos/mac_launch_service.dart index 4002cbd..7f21ea1 100644 --- a/apps/linkunbound/lib/platform/macos/mac_launch_service.dart +++ b/apps/linkunbound/lib/platform/macos/mac_launch_service.dart @@ -13,14 +13,39 @@ class MacLaunchService implements LaunchService { Future launch( String executablePath, String url, - List extraArgs, - ) async { - // `open` requires the document/URL BEFORE `--args`; everything after - // `--args` is forwarded as argv to the launched app. - final args = ['-a', executablePath, url]; - if (extraArgs.isNotEmpty) { - args.add('--args'); - args.addAll(extraArgs); + List extraArgs, { + List privateArgs = const [], + }) async { + // Last line of defence before a process is spawned: a value starting with + // `-` would be consumed by `open` as one of its own flags rather than + // treated as the document to open. + if (!isLaunchableUrl(url)) { + throw ArgumentError.value(url, 'url', 'Not a launchable URL'); + } + + final List args; + if (privateArgs.isEmpty) { + // `open` requires the document/URL BEFORE `--args`; everything after + // `--args` is forwarded as argv to the launched app. + args = ['-a', executablePath, url]; + if (extraArgs.isNotEmpty) { + args.add('--args'); + args.addAll(extraArgs); + } + } else { + // A private window needs `-n`: `open` drops `--args` entirely when the + // app is already running, so without forcing a new instance the switch + // would be silently ignored and the link would open in a normal window. + // The URL then has to travel after `--args` as well, since the browser + // itself — not `open` — is what must act on both. + args = [ + '-na', + executablePath, + '--args', + ...extraArgs, + ...privateArgs, + url, + ]; } await Process.start('/usr/bin/open', args, mode: ProcessStartMode.detached); } diff --git a/apps/linkunbound/lib/platform/macos/mac_registration_service.dart b/apps/linkunbound/lib/platform/macos/mac_registration_service.dart index 1bfa173..272501f 100644 --- a/apps/linkunbound/lib/platform/macos/mac_registration_service.dart +++ b/apps/linkunbound/lib/platform/macos/mac_registration_service.dart @@ -12,6 +12,38 @@ class MacRegistrationService implements RegistrationService { await _channel.invokeMethod('register'); } + /// No-op on macOS: Launch Services derives the handler from the bundle's + /// `CFBundleURLTypes` and tracks the bundle wherever it moves, so there is + /// no recorded path that can go stale. Becoming the *default* handler stays + /// an explicit user action — re-asserting it on every launch would silently + /// take the default back from another browser. + @override + Future ensureRegistered(String executablePath) async {} + + /// Launch Services tracks the bundle by identity rather than by a recorded + /// path, so there is no command that can go stale — the only thing worth + /// reporting is whether we are the default handler. + @override + Future diagnose(String executablePath) async { + return HandlerDiagnostics( + isDefaultBrowser: await isDefault, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + } + + /// Windows-only concept: `microsoft-edge:` is not a scheme macOS apps use to + /// open links. + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() async { await _channel.invokeMethod('unregister'); diff --git a/apps/linkunbound/lib/platform/macos/mac_source_app.dart b/apps/linkunbound/lib/platform/macos/mac_source_app.dart new file mode 100644 index 0000000..1e57bcf --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_source_app.dart @@ -0,0 +1,25 @@ +import 'package:flutter/services.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('MacSourceApp'); + +const _channel = MethodChannel('linkunbound/source_app'); + +/// Best-effort identification of the app that opened the link. +/// macOS exposes no originator for open events, so the frontmost +/// application is used as an approximation. +Future<({String id, String name})?> frontmostApp() async { + try { + final raw = await _channel.invokeMapMethod('frontmostApp'); + final id = raw?['id']; + final name = raw?['name']; + if (id == null || name == null) return null; + return (id: id, name: name); + // Catches Object, not PlatformException: this runs on the launch path, and + // an unregistered channel raises MissingPluginException, which would escape + // and kill the launch outright instead of just losing the source app. + } on Object catch (e, st) { + _log.fine('frontmostApp lookup failed', e, st); + return null; + } +} diff --git a/apps/linkunbound/lib/platform/macos/mac_startup_service.dart b/apps/linkunbound/lib/platform/macos/mac_startup_service.dart index 2e34945..bd1188d 100644 --- a/apps/linkunbound/lib/platform/macos/mac_startup_service.dart +++ b/apps/linkunbound/lib/platform/macos/mac_startup_service.dart @@ -35,7 +35,11 @@ class MacStartupService implements StartupService { try { final result = await _channel.invokeMethod('isLoginItemLaunch'); return result ?? false; - } on PlatformException catch (e, st) { + // Catches Object, not PlatformException: this runs before bootstrap, and + // if the channel is not registered yet the MissingPluginException would + // escape MacOsBindings.create() and kill the launch outright — no window, + // no tray, no link handling. + } on Object catch (e, st) { _log.warning('isLoginItemLaunch check failed', e, st); return false; } diff --git a/apps/linkunbound/lib/platform/windows/win_launch_service.dart b/apps/linkunbound/lib/platform/windows/win_launch_service.dart index fc2a2f4..b725a5b 100644 --- a/apps/linkunbound/lib/platform/windows/win_launch_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_launch_service.dart @@ -7,9 +7,16 @@ final class WinLaunchService implements LaunchService { Future launch( String executablePath, String url, - List extraArgs, - ) async { - final args = [...extraArgs, url]; + List extraArgs, { + List privateArgs = const [], + }) async { + // Last line of defence before a process is spawned: browsers read any + // argument starting with `-` (or `/` on Windows) as a switch, and switches + // like --gpu-launcher run arbitrary binaries. + if (!isLaunchableUrl(url)) { + throw ArgumentError.value(url, 'url', 'Not a launchable URL'); + } + final args = [...extraArgs, ...privateArgs, url]; await Process.start(executablePath, args, mode: ProcessStartMode.detached); } } diff --git a/apps/linkunbound/lib/platform/windows/win_package_context.dart b/apps/linkunbound/lib/platform/windows/win_package_context.dart index 33f23a8..3274780 100644 --- a/apps/linkunbound/lib/platform/windows/win_package_context.dart +++ b/apps/linkunbound/lib/platform/windows/win_package_context.dart @@ -1,11 +1,64 @@ +import 'dart:ffi'; import 'dart:io'; +import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart'; + +/// Returned by `GetCurrentPackageFullName` when the process has no package +/// identity, i.e. it is not running inside an MSIX container. +const _appmodelErrorNoPackage = 15700; + +/// Path fragment that identifies a Flutter build tree rather than an install. +const _devBuildMarker = r'\build\windows\'; + +// Package identity cannot change during the process lifetime, so the probe +// runs once. +bool? _cachedIsMsix; + /// Detects whether the running process is packaged in an MSIX container. -/// MSIX apps run from `...\WindowsApps\...` and expose the `APPX_PACKAGE_FULL_NAME` -/// environment variable via the package identity. +/// +/// Uses the package identity API rather than an environment variable: env vars +/// are inherited by child processes, so a link click handed down from another +/// MSIX app (the current Teams client, for one) used to be enough to produce a +/// false positive — and a false positive here disables registration entirely, +/// silently breaking link capture for a standalone install. bool isRunningInMsix() { if (!Platform.isWindows) return false; - if (Platform.environment.containsKey('APPX_PACKAGE_FULL_NAME')) return true; - final exe = Platform.resolvedExecutable.toLowerCase(); - return exe.contains(r'\windowsapps\'); + return _cachedIsMsix ??= _hasPackageIdentity(); } + +bool _hasPackageIdentity() { + try { + final getCurrentPackageFullName = DynamicLibrary.open('kernel32.dll') + .lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, Pointer) + >('GetCurrentPackageFullName'); + final length = calloc(); + try { + // A zero-sized buffer answers the only question we care about: identity + // present (ERROR_INSUFFICIENT_BUFFER) or absent (APPMODEL_ERROR_NO_PACKAGE). + final rc = getCurrentPackageFullName(length, nullptr); + return rc != _appmodelErrorNoPackage; + } finally { + calloc.free(length); + } + } on Object { + // Symbol missing or FFI unavailable: fall back to the path heuristic. + return Platform.resolvedExecutable.toLowerCase().contains(r'\windowsapps\'); + } +} + +/// True when [executablePath] points inside a local Flutter build tree. +/// +/// A build tree must never own the shell registration: the path vanishes as +/// soon as the tree is cleaned, moved or rebuilt elsewhere, and because +/// `HKCU\Software\Classes` shadows `HKLM`, that dead ProgId then hijacks link +/// handling from the real installation — Store or standalone alike. +bool isDevBuildPath(String executablePath) => executablePath + .replaceAll('/', r'\') + .toLowerCase() + .contains(_devBuildMarker); + +@visibleForTesting +void resetPackageContextCache() => _cachedIsMsix = null; diff --git a/apps/linkunbound/lib/platform/windows/win_pipe_server.dart b/apps/linkunbound/lib/platform/windows/win_pipe_server.dart index a6a5983..af55c90 100644 --- a/apps/linkunbound/lib/platform/windows/win_pipe_server.dart +++ b/apps/linkunbound/lib/platform/windows/win_pipe_server.dart @@ -5,23 +5,40 @@ import 'dart:io'; import 'dart:isolate'; import 'package:ffi/ffi.dart'; -import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'win_security.dart'; + final _log = Logger('WinPipeServer'); const _pipeName = r'\\.\pipe\LinkUnbound'; const _bufferSize = 4096; +/// Sent by the isolate when the pipe name is already owned by someone else. +/// Distinct from the handle values, which are always positive. +const _pipeNameTakenSignal = -1; + const _pipeAccessDuplex = 0x00000003; const _pipeTypeByte = 0x00000000; const _pipeReadmodeByte = 0x00000000; const _pipeWait = 0x00000000; +// Without this the pipe is reachable over SMB as \\\pipe\LinkUnbound, +// letting a remote peer inject URLs into this session. +const _pipeRejectRemoteClients = 0x00000008; +// Makes CreateNamedPipeW fail if the name is already taken, so a squatter that +// grabbed it first is detected instead of silently receiving the user's URLs. +const _fileFlagFirstPipeInstance = 0x00080000; const _pipeUnlimitedInstances = 255; const _openExisting = 3; const _genericWrite = 0x40000000; const _invalidHandleValue = -1; +// Keeps a malicious or buggy server from impersonating this process when we +// connect as a client. +const _securitySqosPresent = 0x00100000; +const _securityIdentification = 0x00010000; +const _errorAccessDenied = 5; +const _errorPipeBusy = 231; typedef _CreateNamedPipeWNative = IntPtr Function( @@ -182,7 +199,8 @@ final class WinPipeServer implements InboundEventServer { Future get ready => _ready.future; - @visibleForTesting + /// Queues an event as if it had arrived over the pipe, honouring the same + /// buffer-until-subscribed rule. Used for the URL carried by argv on launch. void pushEvent(InboundEvent event) { if (_hasListener) { _controller.add(event); @@ -208,6 +226,14 @@ final class WinPipeServer implements InboundEventServer { } on FormatException catch (e) { _log.warning('Invalid inbound event: $e'); } + } else if (data == _pipeNameTakenSignal) { + // Someone else owns \\.\pipe\LinkUnbound. Delegation from secondary + // instances will not reach us; surface it instead of hanging on ready. + _log.severe( + 'Pipe name already owned by another process: this instance cannot ' + 'receive URLs from secondary instances', + ); + if (!_ready.isCompleted) _ready.complete(); } else if (data is int) { // The isolate reports each pipe handle when it starts listening and 0 // right after closing it, so stop() never cancels a stale handle. @@ -260,25 +286,39 @@ final class WinPipeServer implements InboundEventServer { } static void _serverLoop(SendPort sendPort) { + final security = buildPipeSecurityAttributes(); + var firstInstance = true; while (true) { final pipeName = _pipeName.toNativeUtf16(); final handle = _NativePipe.createNamedPipe( pipeName, - _pipeAccessDuplex, - _pipeTypeByte | _pipeReadmodeByte | _pipeWait, + _pipeAccessDuplex | (firstInstance ? _fileFlagFirstPipeInstance : 0), + _pipeTypeByte | + _pipeReadmodeByte | + _pipeWait | + _pipeRejectRemoteClients, _pipeUnlimitedInstances, _bufferSize, _bufferSize, 0, - nullptr, + security.cast(), ); calloc.free(pipeName); if (handle == _invalidHandleValue) { + final lastError = _getLastError(); + if (firstInstance && + (lastError == _errorAccessDenied || lastError == _errorPipeBusy)) { + // Another process owns the name. Retrying forever would hand every + // URL to it, so report and stop serving. + sendPort.send(_pipeNameTakenSignal); + return; + } // Back off before retrying to avoid spinning the CPU on persistent errors. sleep(const Duration(milliseconds: 50)); continue; } + firstInstance = false; // Signal the main isolate: pipe is created and we're about to block on // ConnectNamedPipe — this unblocks claim()'s readiness await. @@ -345,7 +385,10 @@ final class WinPipeClient implements InboundEventClient { 0, nullptr, _openExisting, - 0, + // Named pipes default to SecurityImpersonation, which would let whoever + // is listening act as this user. Identification lets the server check + // who we are without being able to impersonate us. + _securitySqosPresent | _securityIdentification, 0, ); calloc.free(pipeName); diff --git a/apps/linkunbound/lib/platform/windows/win_registration_service.dart b/apps/linkunbound/lib/platform/windows/win_registration_service.dart index 78f0f2d..74d73f3 100644 --- a/apps/linkunbound/lib/platform/windows/win_registration_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_registration_service.dart @@ -5,6 +5,7 @@ import 'package:logging/logging.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; import 'package:win32_registry/win32_registry.dart'; +import '../local_file_url.dart'; import 'win_package_context.dart'; final _log = Logger('WinRegistrationService'); @@ -57,6 +58,8 @@ final _SHChangeNotifyDart _shChangeNotify = DynamicLibrary.open( 'shell32.dll', ).lookupFunction<_SHChangeNotifyNative, _SHChangeNotifyDart>('SHChangeNotify'); +const _openCommandPath = r'Software\Classes\LinkUnboundURL\shell\open\command'; + final class WinRegistrationService implements RegistrationService { @override Future register(String executablePath) async { @@ -65,6 +68,13 @@ final class WinRegistrationService implements RegistrationService { // are sandboxed to the package and invisible to the Shell. return; } + if (isDevBuildPath(executablePath)) { + _log.warning( + 'Refusing to register a local build tree as URL handler: ' + '${redactPath(executablePath)}', + ); + return; + } final exe = executablePath.replaceAll('/', '\\'); final quotedExe = '"$exe"'; @@ -74,6 +84,59 @@ final class WinRegistrationService implements RegistrationService { _writeOpenWithProgIds(); _writeRegisteredApplications(); _notifyShell(); + _log.info('Registered URL handler at ${redactPath(exe)}'); + } + + /// Reconciles the recorded handler with the running installation. + /// + /// `register()` used to run only on first boot, so the handler path was + /// frozen forever: updating, reinstalling or moving the app left HKCU + /// pointing at an executable that no longer exists, and Windows silently + /// stopped offering LinkUnbound as a browser. Because `HKCU\Software\Classes` + /// shadows `HKLM`, a stale per-user entry also overrides a Store or + /// standalone install — so the fix has to remove it, not just rewrite it. + /// + /// Safe to call on every launch: it only writes when something drifted. + @override + Future ensureRegistered(String executablePath) async { + final recorded = _readRegisteredCommand(); + + if (isRunningInMsix()) { + // The package manifest owns the association. Any per-user ProgId left + // behind by a standalone install or a local build shadows it. + if (recorded != null) { + _log.warning( + 'Removing stale per-user registration shadowing the MSIX package ' + '(recorded=${redactPath(recorded)})', + ); + _removeHkcuRegistration(); + } + return; + } + + if (isDevBuildPath(executablePath)) { + // Never let a build tree own the association. If a previous run of this + // same tree claimed it, drop it so the installed copy takes over. + if (recorded != null && isDevBuildPath(recorded)) { + _log.warning( + 'Removing registration owned by a local build tree ' + '(recorded=${redactPath(recorded)})', + ); + _removeHkcuRegistration(); + } else { + _log.info('Running from a local build tree; registration left intact'); + } + return; + } + + final expected = '"${executablePath.replaceAll('/', '\\')}" "%1"'; + if (recorded == expected) return; + + _log.info( + 'Handler command drifted; re-registering ' + '(recorded=${recorded == null ? '' : redactPath(recorded)})', + ); + await register(executablePath); } @override @@ -81,6 +144,10 @@ final class WinRegistrationService implements RegistrationService { if (isRunningInMsix()) { return; } + _removeHkcuRegistration(); + } + + void _removeHkcuRegistration() { _deleteKeyTree(r'Software\Classes\LinkUnboundURL'); _deleteKeyTree(r'Software\Clients\StartMenuInternet\LinkUnbound'); _deleteKeyTree(r'Software\LinkUnbound'); @@ -89,6 +156,120 @@ final class WinRegistrationService implements RegistrationService { _notifyShell(); } + /// The `shell\open\command` value currently recorded for our ProgId, or null + /// when the app is not registered per-user. + String? _readRegisteredCommand() { + try { + final key = Registry.openPath( + RegistryHive.currentUser, + path: _openCommandPath, + ); + final command = key.getValueAsString(''); + key.close(); + return (command == null || command.isEmpty) ? null : command; + } on Exception { + return null; + } + } + + /// Intercepts the `microsoft-edge:` protocol so links opened from inside + /// Microsoft apps reach the picker. + /// + /// Teams, Outlook, Widgets, Copilot and Start menu search do not open plain + /// `https:` URLs — they wrap them in `microsoft-edge:`, a scheme hardwired to + /// Edge that ignores the default-browser setting entirely. Capturing it is + /// the only way those links can be offered a choice of browser, which is why + /// `stripEdgeProtocol` already exists on the parsing side. + /// + /// Opt-in on purpose: it takes a protocol away from Edge, and a user who + /// wants Edge's behaviour must be able to keep it. + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async { + if (isRunningInMsix()) { + // An MSIX package cannot claim a protocol another package owns. + _log.info('Edge protocol capture unavailable in MSIX context'); + return; + } + if (enabled) { + if (isDevBuildPath(executablePath)) { + _log.warning('Refusing Edge protocol capture from a local build tree'); + return; + } + final quotedExe = '"${executablePath.replaceAll('/', '\\')}"'; + _writeEdgeProtocolProgId(quotedExe); + _log.info('Edge protocol capture enabled'); + } else { + _deleteKeyTree(r'Software\Classes\LinkUnboundEdgeProto'); + _deleteKeyTree(r'Software\Classes\microsoft-edge'); + _log.info('Edge protocol capture disabled'); + } + _notifyShell(); + } + + @override + Future get capturesEdgeProtocol async { + if (isRunningInMsix()) return false; + try { + final key = Registry.openPath( + RegistryHive.currentUser, + path: r'Software\Classes\microsoft-edge\shell\open\command', + ); + final command = key.getValueAsString(''); + key.close(); + return command != null && command.toLowerCase().contains('linkunbound'); + } on Exception { + return false; + } + } + + void _writeEdgeProtocolProgId(String quotedExe) { + final classes = Registry.openPath( + RegistryHive.currentUser, + path: r'Software\Classes', + desiredAccessRights: AccessRights.allAccess, + ); + + for (final progId in ['LinkUnboundEdgeProto', 'microsoft-edge']) { + final key = classes.createKey(progId); + key.createValue( + const RegistryValue('', RegistryValueType.string, 'LinkUnbound URL'), + ); + // URL protocol keys are identified by this empty-valued marker. + key.createValue( + const RegistryValue('URL Protocol', RegistryValueType.string, ''), + ); + final command = key.createKey(r'shell\open\command'); + command.createValue( + RegistryValue('', RegistryValueType.string, '$quotedExe "%1"'), + ); + command.close(); + key.close(); + } + + classes.close(); + } + + @override + Future diagnose(String executablePath) async { + final recorded = _readRegisteredCommand(); + final packaged = isRunningInMsix(); + final expected = '"${executablePath.replaceAll('/', '\\')}" "%1"'; + return HandlerDiagnostics( + isDefaultBrowser: await isDefault, + // Under MSIX the association lives in the package manifest, so having no + // per-user command recorded is the correct state, not a fault. + commandMatchesExecutable: packaged + ? recorded == null + : recorded == expected, + runningFromDevBuild: isDevBuildPath(executablePath), + isPackaged: packaged, + recordedCommand: recorded, + ); + } + @override Future get isDefault async { return _progIdBelongsToUs( diff --git a/apps/linkunbound/lib/platform/windows/win_security.dart b/apps/linkunbound/lib/platform/windows/win_security.dart new file mode 100644 index 0000000..631cc97 --- /dev/null +++ b/apps/linkunbound/lib/platform/windows/win_security.dart @@ -0,0 +1,172 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('WinSecurity'); + +/// `SECURITY_ATTRIBUTES` as passed to `CreateNamedPipeW`. +final class SecurityAttributes extends Struct { + @Uint32() + external int nLength; + external Pointer lpSecurityDescriptor; + @Int32() + external int bInheritHandle; +} + +const _tokenQuery = 0x0008; +const _tokenUser = 1; +const _sddlRevision1 = 1; + +final _advapi32 = DynamicLibrary.open('advapi32.dll'); +final _kernel32 = DynamicLibrary.open('kernel32.dll'); + +final _getCurrentProcess = _kernel32 + .lookupFunction('GetCurrentProcess'); + +final _localFree = _kernel32 + .lookupFunction< + Pointer Function(Pointer), + Pointer Function(Pointer) + >('LocalFree'); + +final _openProcessToken = _advapi32 + .lookupFunction< + Int32 Function(IntPtr, Uint32, Pointer), + int Function(int, int, Pointer) + >('OpenProcessToken'); + +final _getTokenInformation = _advapi32 + .lookupFunction< + Int32 Function(IntPtr, Int32, Pointer, Uint32, Pointer), + int Function(int, int, Pointer, int, Pointer) + >('GetTokenInformation'); + +final _convertSidToStringSid = _advapi32 + .lookupFunction< + Int32 Function(Pointer, Pointer>), + int Function(Pointer, Pointer>) + >('ConvertSidToStringSidW'); + +final _convertStringSdToSd = _advapi32 + .lookupFunction< + Int32 Function( + Pointer, + Uint32, + Pointer>, + Pointer, + ), + int Function(Pointer, int, Pointer>, Pointer) + >('ConvertStringSecurityDescriptorToSecurityDescriptorW'); + +final _closeHandle = _kernel32 + .lookupFunction('CloseHandle'); + +/// Builds the security attributes used for the single-instance IPC pipe. +/// +/// Two properties matter, and the default `NULL` descriptor gets both wrong: +/// +/// * **Only this user.** The named pipe namespace is machine-wide, so with the +/// default DACL another signed-in user (fast user switching, RDS) could read +/// every URL this user opens. +/// * **Reachable from a lower integrity level.** The installer may launch the +/// app elevated, which puts the pipe at high integrity; a link clicked in +/// Slack arrives from a medium-integrity process, and `NO_WRITE_UP` on a low +/// label is what lets that process still deliver the URL. +/// +/// Returns `nullptr` when the descriptor cannot be built; callers then fall +/// back to the default security, which is less safe but still functional. +Pointer buildPipeSecurityAttributes() { + final sid = _currentUserSidString(); + if (sid == null) return nullptr; + + // GA = generic all for this user and SYSTEM; the low-integrity label with + // NO_WRITE_UP keeps write access open to less privileged callers. + final sddl = 'D:(A;;GA;;;$sid)(A;;GA;;;SY)S:(ML;;NW;;;LW)'; + final sddlPtr = sddl.toNativeUtf16(); + final descriptor = calloc>(); + try { + final ok = _convertStringSdToSd( + sddlPtr, + _sddlRevision1, + descriptor, + nullptr, + ); + if (ok == 0) { + _log.warning('Could not build pipe security descriptor'); + return nullptr; + } + final attrs = calloc(); + attrs.ref + ..nLength = sizeOf() + ..lpSecurityDescriptor = descriptor.value + ..bInheritHandle = 0; + return attrs; + } finally { + calloc.free(sddlPtr); + calloc.free(descriptor); + } +} + +/// Frees the descriptor and the attributes allocated by +/// [buildPipeSecurityAttributes]. +void freePipeSecurityAttributes(Pointer attrs) { + if (attrs == nullptr) return; + final descriptor = attrs.ref.lpSecurityDescriptor; + if (descriptor != nullptr) _localFree(descriptor); + calloc.free(attrs); +} + +/// The current process user's SID in string form (`S-1-5-21-…`). +String? _currentUserSidString() { + final tokenHandle = calloc(); + try { + if (_openProcessToken(_getCurrentProcess(), _tokenQuery, tokenHandle) == + 0) { + return null; + } + final token = tokenHandle.value; + try { + final needed = calloc(); + try { + // First call sizes the buffer; it is expected to fail. + _getTokenInformation(token, _tokenUser, nullptr, 0, needed); + if (needed.value == 0) return null; + final buffer = calloc(needed.value); + try { + final ok = _getTokenInformation( + token, + _tokenUser, + buffer.cast(), + needed.value, + needed, + ); + if (ok == 0) return null; + // TOKEN_USER starts with SID_AND_ATTRIBUTES, whose first field is + // the PSID we need. + final sid = buffer.cast>().value; + final stringSid = calloc>(); + try { + if (_convertSidToStringSid(sid, stringSid) == 0) return null; + final result = stringSid.value.toDartString(); + _localFree(stringSid.value.cast()); + return result; + } finally { + calloc.free(stringSid); + } + } finally { + calloc.free(buffer); + } + } finally { + calloc.free(needed); + } + } finally { + _closeHandle(token); + } + } on Object catch (e) { + _log.warning('Could not resolve current user SID: $e'); + return null; + } finally { + calloc.free(tokenHandle); + } +} diff --git a/apps/linkunbound/lib/platform/windows/win_source_app.dart b/apps/linkunbound/lib/platform/windows/win_source_app.dart new file mode 100644 index 0000000..6480116 --- /dev/null +++ b/apps/linkunbound/lib/platform/windows/win_source_app.dart @@ -0,0 +1,318 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:ffi/ffi.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('WinSourceApp'); + +const _th32csSnapProcess = 0x00000002; +const _processQueryLimitedInformation = 0x1000; +const _invalidHandleValue = -1; + +/// Must match the `@Array` size of [_ProcessEntry32W.szExeFile]. +const _maxPath = 260; + +/// `QueryFullProcessImageNameW` may return an extended-length path. +const _maxLongPath = 32768; + +/// US English + Unicode: the string block almost every signed binary ships. +const _defaultTranslation = '040904B0'; + +/// `PROCESSENTRY32W` from tlhelp32.h. +final class _ProcessEntry32W extends Struct { + @Uint32() + external int dwSize; + @Uint32() + external int cntUsage; + @Uint32() + external int th32ProcessID; + @IntPtr() + external int th32DefaultHeapID; + @Uint32() + external int th32ModuleID; + @Uint32() + external int cntThreads; + @Uint32() + external int th32ParentProcessID; + @Int32() + external int pcPriClassBase; + @Uint32() + external int dwFlags; + @Array(260) + external Array szExeFile; +} + +final _kernel32 = DynamicLibrary.open('kernel32.dll'); +final _versionDll = DynamicLibrary.open('version.dll'); + +final _getCurrentProcessId = _kernel32 + .lookupFunction('GetCurrentProcessId'); + +final _createToolhelp32Snapshot = _kernel32 + .lookupFunction( + 'CreateToolhelp32Snapshot', + ); + +final _process32First = _kernel32 + .lookupFunction< + Int32 Function(IntPtr, Pointer<_ProcessEntry32W>), + int Function(int, Pointer<_ProcessEntry32W>) + >('Process32FirstW'); + +final _process32Next = _kernel32 + .lookupFunction< + Int32 Function(IntPtr, Pointer<_ProcessEntry32W>), + int Function(int, Pointer<_ProcessEntry32W>) + >('Process32NextW'); + +final _openProcess = _kernel32 + .lookupFunction< + IntPtr Function(Uint32, Int32, Uint32), + int Function(int, int, int) + >('OpenProcess'); + +final _queryFullProcessImageName = _kernel32 + .lookupFunction< + Int32 Function(IntPtr, Uint32, Pointer, Pointer), + int Function(int, int, Pointer, Pointer) + >('QueryFullProcessImageNameW'); + +final _closeHandle = _kernel32 + .lookupFunction('CloseHandle'); + +final _getFileVersionInfoSize = _versionDll + .lookupFunction< + Uint32 Function(Pointer, Pointer), + int Function(Pointer, Pointer) + >('GetFileVersionInfoSizeW'); + +final _getFileVersionInfo = _versionDll + .lookupFunction< + Int32 Function(Pointer, Uint32, Uint32, Pointer), + int Function(Pointer, int, int, Pointer) + >('GetFileVersionInfoW'); + +final _verQueryValue = _versionDll + .lookupFunction< + Int32 Function( + Pointer, + Pointer, + Pointer>, + Pointer, + ), + int Function( + Pointer, + Pointer, + Pointer>, + Pointer, + ) + >('VerQueryValueW'); + +/// Executable name of the parent process, without path and without the `.exe` +/// extension, lowercased (e.g. `slack`, `teams`, `explorer`). +/// +/// When Windows hands a link to this app, the process that called +/// `ShellExecute` is normally our parent, so this is the closest thing to +/// "which app did the click come from". Returns null when it cannot be +/// determined. +String? parentProcessName() { + if (!Platform.isWindows) return null; + try { + final parent = _parentProcess(); + if (parent == null) return null; + return _baseName(parent.exeFile); + } on Object catch (e) { + _log.fine('Could not resolve parent process name: $e'); + return null; + } +} + +/// Human readable name of the parent process for the UI: the executable's +/// `FileDescription` when the version resource exposes one (e.g. `Microsoft +/// Teams`), otherwise the capitalized executable name. Returns null when it +/// cannot be determined. +String? parentProcessDisplayName() { + if (!Platform.isWindows) return null; + try { + final parent = _parentProcess(); + if (parent == null) return null; + final name = _baseName(parent.exeFile); + if (name == null) return null; + + final imagePath = _processImagePath(parent.pid); + final description = imagePath == null ? null : _fileDescription(imagePath); + return description ?? _capitalize(name); + } on Object catch (e) { + _log.fine('Could not resolve parent process display name: $e'); + return null; + } +} + +/// PID and executable name of the process that spawned this one. +/// +/// Both lookups share one snapshot: the parent is read from our own entry and +/// then resolved in a second pass, so the parent must still be alive (or at +/// least still listed) when the snapshot is taken. +({int pid, String exeFile})? _parentProcess() { + final snapshot = _createToolhelp32Snapshot(_th32csSnapProcess, 0); + if (snapshot == 0 || snapshot == _invalidHandleValue) { + _log.fine('CreateToolhelp32Snapshot failed'); + return null; + } + + final entry = calloc<_ProcessEntry32W>(); + try { + if (!_seek(snapshot, entry, _getCurrentProcessId())) return null; + + final parentPid = entry.ref.th32ParentProcessID; + if (parentPid == 0) return null; + + if (!_seek(snapshot, entry, parentPid)) { + _log.fine('Parent process $parentPid is no longer in the snapshot'); + return null; + } + return (pid: parentPid, exeFile: _exeFileOf(entry.ref)); + } finally { + calloc.free(entry); + _closeHandle(snapshot); + } +} + +/// Rewinds the snapshot and leaves [entry] on the process matching [pid]. +bool _seek(int snapshot, Pointer<_ProcessEntry32W> entry, int pid) { + // Process32FirstW rejects the entry unless dwSize is set, and it restarts + // the walk, which is what lets one snapshot serve both passes. + entry.ref.dwSize = sizeOf<_ProcessEntry32W>(); + + var found = _process32First(snapshot, entry); + while (found != 0) { + if (entry.ref.th32ProcessID == pid) return true; + found = _process32Next(snapshot, entry); + } + return false; +} + +String _exeFileOf(_ProcessEntry32W entry) { + final codes = []; + for (var i = 0; i < _maxPath; i++) { + final code = entry.szExeFile[i]; + if (code == 0) break; + codes.add(code); + } + return String.fromCharCodes(codes); +} + +/// Full path of the image backing [pid], or null when it cannot be opened +/// (the process died, or it runs at a higher integrity level). +String? _processImagePath(int pid) { + final handle = _openProcess(_processQueryLimitedInformation, 0, pid); + if (handle == 0) return null; + + final buffer = calloc(_maxLongPath); + final size = calloc(); + try { + size.value = _maxLongPath; + if (_queryFullProcessImageName(handle, 0, buffer.cast(), size) == 0) { + return null; + } + return _readWide(buffer, size.value); + } finally { + calloc.free(buffer); + calloc.free(size); + _closeHandle(handle); + } +} + +/// `FileDescription` from the executable's version resource. +String? _fileDescription(String imagePath) { + final pathPtr = imagePath.toNativeUtf16(); + try { + final size = _getFileVersionInfoSize(pathPtr, nullptr); + if (size == 0) return null; + + final block = calloc(size); + try { + if (_getFileVersionInfo(pathPtr, 0, size, block.cast()) == 0) return null; + + final direct = _queryDescription(block, _defaultTranslation); + if (direct != null) return direct; + + // Localized builds ship a different language/codepage pair; the + // translation table is the only way to know which one. + final translation = _firstTranslation(block); + if (translation == null) return null; + if (translation == _defaultTranslation) return null; + return _queryDescription(block, translation); + } finally { + calloc.free(block); + } + } finally { + calloc.free(pathPtr); + } +} + +String? _queryDescription(Pointer block, String translation) { + final subBlock = '\\StringFileInfo\\$translation\\FileDescription' + .toNativeUtf16(); + final value = calloc>(); + final length = calloc(); + try { + if (_verQueryValue(block.cast(), subBlock, value, length) == 0) return null; + if (value.value == nullptr || length.value == 0) return null; + + // puLen counts characters, terminator included. + final text = _readWide(value.value.cast(), length.value).trim(); + return text.isEmpty ? null : text; + } finally { + calloc.free(subBlock); + calloc.free(value); + calloc.free(length); + } +} + +/// First `language|codepage` pair of the version resource, as the 8 hex digits +/// the `StringFileInfo` sub-block expects. +String? _firstTranslation(Pointer block) { + final subBlock = r'\VarFileInfo\Translation'.toNativeUtf16(); + final value = calloc>(); + final length = calloc(); + try { + if (_verQueryValue(block.cast(), subBlock, value, length) == 0) return null; + if (value.value == nullptr || length.value < 4) return null; + + final pair = value.value.cast(); + return '${_hex4(pair[0])}${_hex4(pair[1])}'; + } finally { + calloc.free(subBlock); + calloc.free(value); + calloc.free(length); + } +} + +String _hex4(int value) => + value.toRadixString(16).padLeft(4, '0').toUpperCase(); + +/// Reads at most [maxChars] UTF-16 code units, stopping at the NUL terminator. +String _readWide(Pointer chars, int maxChars) { + final codes = []; + for (var i = 0; i < maxChars; i++) { + final code = chars[i]; + if (code == 0) break; + codes.add(code); + } + return String.fromCharCodes(codes); +} + +/// `Slack.exe` -> `slack`. Toolhelp reports a bare module name, but strip any +/// directory anyway so the value stays a stable rule key. +String? _baseName(String exeFile) { + var name = exeFile.toLowerCase(); + final separator = name.lastIndexOf(RegExp(r'[\\/]')); + if (separator >= 0) name = name.substring(separator + 1); + if (name.endsWith('.exe')) name = name.substring(0, name.length - 4); + return name.isEmpty ? null : name; +} + +String _capitalize(String name) => + name.isEmpty ? name : name[0].toUpperCase() + name.substring(1); diff --git a/apps/linkunbound/lib/platform/windows/windows_bindings.dart b/apps/linkunbound/lib/platform/windows/windows_bindings.dart index b7839ac..c30eeeb 100644 --- a/apps/linkunbound/lib/platform/windows/windows_bindings.dart +++ b/apps/linkunbound/lib/platform/windows/windows_bindings.dart @@ -15,6 +15,7 @@ import 'win_instance.dart'; import 'win_launch_service.dart'; import 'win_pipe_server.dart'; import 'win_registration_service.dart'; +import 'win_source_app.dart'; import 'win_startup_service.dart'; import 'windows_tray_controller.dart'; @@ -136,16 +137,13 @@ final class WindowsBindings implements PlatformBindings { @override Stream get inboundEvents { final initial = initialEvent; - if (initial == null) return _pipeServer.events; - return _prependInitial(initial, _pipeServer.events); - } - - static Stream _prependInitial( - InboundEvent first, - Stream rest, - ) async* { - yield first; - yield* rest; + // Push into the server's own buffer instead of wrapping the stream. An + // `async*` wrapper evaluated `_pipeServer.events` eagerly — which marks + // the buffer as drained — but only subscribed to the broadcast controller + // a microtask later, so anything flushed in between was dropped on the + // floor. That window is exactly a cold start handling a link. + if (initial != null) _pipeServer.pushEvent(initial); + return _pipeServer.events; } @override @@ -210,17 +208,48 @@ final class WindowsBindings implements PlatformBindings { static InboundEvent? _parseInitialEvent(List args) { for (final arg in args) { + if (arg.startsWith('--')) continue; final resolved = stripEdgeProtocol(arg); + final lower = resolved.toLowerCase(); + // Textual check first: Uri.tryParse returns null for URLs that are + // malformed but perfectly real (unescaped brackets or stray `%` show up + // routinely in Teams and SharePoint links), and those were silently + // dropped. + if (lower.startsWith('http://') || lower.startsWith('https://')) { + return OpenUrlEvent(resolved, sourceApp: _sourceApp()); + } final uri = Uri.tryParse(resolved); - if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { - return OpenUrlEvent(resolved); + if (uri != null && uri.scheme.toLowerCase() == 'file') { + return OpenUrlEvent(resolved, sourceApp: _sourceApp()); + } + if (_windowsAbsPath.hasMatch(arg)) { + return OpenUrlEvent(arg, sourceApp: _sourceApp()); } - if (uri != null && uri.scheme == 'file') return OpenUrlEvent(arg); - if (_windowsAbsPath.hasMatch(arg)) return OpenUrlEvent(arg); + // Without this line a dropped link leaves no trace at all, which is why + // the failure was so hard to diagnose in the field. + _log.warning( + 'Ignoring unrecognised launch argument (scheme=${uri?.scheme})', + ); } return null; } + /// The app that asked the shell to open this link. + /// + /// Only meaningful in the process the shell just launched: once the URL is + /// delegated to the resident instance over the pipe, that instance's parent + /// is unrelated. Hence resolving it here, at parse time, and shipping it + /// inside the event. + static String? _sourceApp() { + final name = parentProcessName(); + // The shell itself is not a useful origin to write rules against. + if (name == null || + const {'explorer', 'cmd', 'powershell'}.contains(name)) { + return null; + } + return name; + } + static void _migrateFromRoamingIfNeeded(Directory newDir) { final roamingBase = Platform.environment['APPDATA']; if (roamingBase == null || roamingBase.isEmpty) return; diff --git a/apps/linkunbound/lib/providers.dart b/apps/linkunbound/lib/providers.dart index 352b6bd..20ec038 100644 --- a/apps/linkunbound/lib/providers.dart +++ b/apps/linkunbound/lib/providers.dart @@ -47,6 +47,12 @@ final globalHotkeyFileProvider = Provider((_) => throw _mustOverride()); final appDataDirProvider = Provider((_) => throw _mustOverride()); +/// Path of the running executable, overridden at startup from the platform +/// bindings. Defaults to the real value so widget tests need no override. +final executablePathProvider = Provider( + (_) => Platform.resolvedExecutable, +); + typedef DiagnosticsExporter = Future Function({ required Directory appDataDir, @@ -136,9 +142,13 @@ final class ThemeModeNotifier extends Notifier { enum AppMode { hidden, settings, picker } final class AppState { - AppState({this.mode = AppMode.hidden, this.pendingUrl}); + AppState({this.mode = AppMode.hidden, this.pendingUrl, this.pendingOrigin}); final AppMode mode; final String? pendingUrl; + + /// App the pending link came from, when it could be determined. Lets the + /// picker offer "always open links from this app here". + final String? pendingOrigin; } final appStateProvider = NotifierProvider( @@ -151,8 +161,11 @@ final class AppStateNotifier extends Notifier { void showSettings() => state = AppState(mode: AppMode.settings); - void showPicker(String url) => - state = AppState(mode: AppMode.picker, pendingUrl: url); + void showPicker(String url, {String? origin}) => state = AppState( + mode: AppMode.picker, + pendingUrl: url, + pendingOrigin: origin, + ); void hide() => state = AppState(); } @@ -209,16 +222,20 @@ final class RulesNotifier extends Notifier> { @override List build() => ref.read(ruleServiceProvider).rules; - Future updateRule(String domain, {required String browserId}) async { + Future updateRule( + String domain, { + required String browserId, + String? sourceApp, + }) async { final service = ref.read(ruleServiceProvider); - service.updateRule(domain, browserId: browserId); + service.updateRule(domain, browserId: browserId, sourceApp: sourceApp); await service.save(); state = service.rules; } - Future removeRule(String domain) async { + Future removeRule(String domain, {String? sourceApp}) async { final service = ref.read(ruleServiceProvider); - service.removeRule(domain); + service.removeRule(domain, sourceApp: sourceApp); await service.save(); state = service.rules; } @@ -238,6 +255,20 @@ final isStartupEnabledProvider = FutureProvider.autoDispose((ref) { return ref.read(startupServiceProvider).isEnabled; }); +/// Why link capture may be broken, for the self-diagnostics card in Settings. +final handlerDiagnosticsProvider = + FutureProvider.autoDispose((ref) { + return ref + .read(registrationServiceProvider) + .diagnose(ref.read(executablePathProvider)); + }); + +/// Whether `microsoft-edge:` links (Teams, Outlook, Start search) are being +/// intercepted. Always false outside Windows. +final edgeProtocolCaptureProvider = FutureProvider.autoDispose((ref) { + return ref.read(registrationServiceProvider).capturesEdgeProtocol; +}); + final packageInfoProvider = FutureProvider((ref) { return PackageInfo.fromPlatform(); }); diff --git a/apps/linkunbound/lib/ui/picker/picker_view.dart b/apps/linkunbound/lib/ui/picker/picker_view.dart index 420965f..ef1b9b7 100644 --- a/apps/linkunbound/lib/ui/picker/picker_view.dart +++ b/apps/linkunbound/lib/ui/picker/picker_view.dart @@ -13,16 +13,52 @@ import '../../providers.dart'; final _log = Logger('PickerView'); class PickerView extends ConsumerStatefulWidget { - const PickerView({required this.url, super.key}); + const PickerView({required this.url, this.origin, super.key}); final String url; + /// App the link came from, when known. Turns "always open" into a rule about + /// the originating app rather than the domain. + final String? origin; + @override ConsumerState createState() => _PickerViewState(); } class _PickerViewState extends ConsumerState { bool _alwaysOpen = false; + bool _privateIntent = false; + + @override + void initState() { + super.initState(); + // Shift may already be down when the picker appears — the user holds it + // before clicking, not after — so seed from the current keyboard state + // instead of waiting for a key event that will never come. + _privateIntent = _shiftIsDown(); + HardwareKeyboard.instance.addHandler(_onKeyEvent); + } + + @override + void dispose() { + HardwareKeyboard.instance.removeHandler(_onKeyEvent); + super.dispose(); + } + + static bool _shiftIsDown() => + HardwareKeyboard.instance.logicalKeysPressed.any( + (k) => + k == LogicalKeyboardKey.shiftLeft || + k == LogicalKeyboardKey.shiftRight, + ); + + bool _onKeyEvent(KeyEvent event) { + final down = _shiftIsDown(); + if (down != _privateIntent && mounted) { + setState(() => _privateIntent = down); + } + return false; // never consume: the shortcut handler below still needs it + } @override Widget build(BuildContext context) { @@ -62,27 +98,48 @@ class _PickerViewState extends ConsumerState { _UrlHeader(url: widget.url, domain: domain, isLocalFile: isLocalFile), Divider(height: 0.5, color: colors.outline.withAlpha(50)), Expanded( - // Scrollbar appears when browsers > maxVisible (6); shortcuts 1-9 - // still work for off-screen rows — the scrollbar signals that. - child: Scrollbar( - thumbVisibility: browsers.length > 6, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 4), - itemCount: browsers.length, - itemBuilder: (context, index) => _BrowserRow( - browser: browsers[index], - iconPath: - '${iconsDir.path}${Platform.pathSeparator}${browsers[index].id}.png', - shortcut: index < 9 ? '${index + 1}' : null, - onTap: () => _launch(browsers[index], iconsDir), - ), - ), - ), + // An empty list would otherwise render as a blank window, which + // reads as "the app is broken" rather than "detection found + // nothing". + child: browsers.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + AppLocalizations.of(context)!.pickerNoBrowsers, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ) + // Scrollbar appears when browsers > maxVisible (6); shortcuts + // 1-9 still work for off-screen rows — the scrollbar signals + // that. + : Scrollbar( + thumbVisibility: browsers.length > 6, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 4), + itemCount: browsers.length, + itemBuilder: (context, index) => _BrowserRow( + browser: browsers[index], + iconPath: + '${iconsDir.path}${Platform.pathSeparator}${browsers[index].id}.png', + shortcut: index < 9 ? '${index + 1}' : null, + // Only browsers that actually take a private-window + // switch show the badge; Safari has none. + private: + _privateIntent && browsers[index].canOpenPrivately, + onTap: () => _launch(browsers[index], iconsDir), + ), + ), + ), ), Divider(height: 0.5, color: colors.outline.withAlpha(50)), _AlwaysOpenFooter( value: _alwaysOpen, onChanged: (v) => setState(() => _alwaysOpen = v), + originLabel: widget.origin, + showPrivateHint: browsers.any((b) => b.canOpenPrivately), ), ], ), @@ -90,14 +147,44 @@ class _PickerViewState extends ConsumerState { } void _launch(Browser browser, Directory iconsDir) { + final private = _privateIntent && browser.canOpenPrivately; final launchService = ref.read(launchServiceProvider); - launchService.launch(browser.executablePath, widget.url, browser.extraArgs); + // A browser that was uninstalled or moved makes Process.start throw. Left + // unhandled, that future escaped to the zone guard and was recorded as a + // crash — with the full URL in the report — while the user just saw the + // picker close and nothing open. + unawaited( + launchService + .launch( + browser.executablePath, + widget.url, + browser.extraArgs, + privateArgs: private ? browser.resolvedPrivateArgs : const [], + ) + .catchError((Object e, StackTrace st) { + _log.severe('Launch failed for ${browser.name}: ${e.runtimeType}'); + }), + ); if (_alwaysOpen) { final ruleService = ref.read(ruleServiceProvider); final uri = Uri.tryParse(widget.url); - if (uri != null && uri.host.isNotEmpty) { - ruleService.addRule(Rule(domain: uri.host, browserId: browser.id)); + final origin = widget.origin; + // With a known origin the rule is scoped to it and covers every domain: + // "links from Slack open here" is what the user is expressing by ticking + // the box on a link that arrived from Slack. + final rule = origin != null + ? Rule( + domain: kAnyDomain, + browserId: browser.id, + sourceApp: origin, + private: private, + ) + : (uri != null && uri.host.isNotEmpty) + ? Rule(domain: uri.host, browserId: browser.id, private: private) + : null; + if (rule != null) { + ruleService.addRule(rule); unawaited( ruleService.save().catchError((Object e, StackTrace st) { _log.warning('Failed to persist always-open rule', e, st); @@ -232,6 +319,7 @@ class _BrowserRow extends StatefulWidget { required this.iconPath, required this.onTap, this.shortcut, + this.private = false, }); final Browser browser; @@ -239,6 +327,9 @@ class _BrowserRow extends StatefulWidget { final VoidCallback onTap; final String? shortcut; + /// Shows the private-window badge; driven by the Shift key being held. + final bool private; + @override State<_BrowserRow> createState() => _BrowserRowState(); } @@ -277,6 +368,14 @@ class _BrowserRowState extends State<_BrowserRow> { overflow: TextOverflow.ellipsis, ), ), + if (widget.private) ...[ + Icon( + Icons.visibility_off_outlined, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 8), + ], if (widget.shortcut != null) Container( padding: const EdgeInsets.symmetric( @@ -305,14 +404,27 @@ class _BrowserRowState extends State<_BrowserRow> { } class _AlwaysOpenFooter extends ConsumerWidget { - const _AlwaysOpenFooter({required this.value, required this.onChanged}); + const _AlwaysOpenFooter({ + required this.value, + required this.onChanged, + this.originLabel, + this.showPrivateHint = false, + }); final bool value; final ValueChanged onChanged; + /// Display name of the originating app, when known. + final String? originLabel; + final bool showPrivateHint; + @override Widget build(BuildContext context, WidgetRef ref) { final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; final hasUpdate = ref.watch(updateInfoProvider).valueOrNull != null; + final label = originLabel == null + ? l10n.alwaysOpenHere + : l10n.alwaysOpenFromApp(originLabel!); return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), @@ -333,11 +445,24 @@ class _AlwaysOpenFooter extends ConsumerWidget { ), ), const SizedBox(width: 8), - Text( - AppLocalizations.of(context)!.alwaysOpenHere, - style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant), + Flexible( + child: Text( + label, + style: TextStyle(fontSize: 12, color: colors.onSurfaceVariant), + overflow: TextOverflow.ellipsis, + ), ), const Spacer(), + if (showPrivateHint) ...[ + Text( + l10n.pickerPrivateHint, + style: TextStyle( + fontSize: 11, + color: colors.onSurfaceVariant.withValues(alpha: 0.7), + ), + ), + const SizedBox(width: 8), + ], if (hasUpdate) const _UpdateDot(), ], ), diff --git a/apps/linkunbound/lib/ui/picker/picker_window.dart b/apps/linkunbound/lib/ui/picker/picker_window.dart index e289174..da96252 100644 --- a/apps/linkunbound/lib/ui/picker/picker_window.dart +++ b/apps/linkunbound/lib/ui/picker/picker_window.dart @@ -4,10 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'picker_view.dart'; class PickerWindow extends ConsumerStatefulWidget { - const PickerWindow({required this.url, super.key}); + const PickerWindow({required this.url, this.origin, super.key}); final String url; + /// App the link came from, forwarded to the picker so "always open" can be + /// scoped to it. + final String? origin; + @override ConsumerState createState() => _PickerWindowState(); } @@ -50,7 +54,7 @@ class _PickerWindowState extends ConsumerState child: ScaleTransition( scale: _scaleAnim, alignment: Alignment.topCenter, - child: PickerView(url: widget.url), + child: PickerView(url: widget.url, origin: widget.origin), ), ), ); diff --git a/apps/linkunbound/lib/ui/settings/general_page.dart b/apps/linkunbound/lib/ui/settings/general_page.dart index e920fa4..ccaacef 100644 --- a/apps/linkunbound/lib/ui/settings/general_page.dart +++ b/apps/linkunbound/lib/ui/settings/general_page.dart @@ -1,17 +1,22 @@ +import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../l10n/app_localizations.dart'; import '../../platform/hotkey_service.dart'; +import '../../platform/windows/win_package_context.dart'; import '../../providers.dart'; import '../shared/widgets/browser_tile.dart'; import '../shared/widgets/group_card.dart'; import '../shared/widgets/section_header.dart'; +final _log = Logger('GeneralPage'); + class GeneralPage extends ConsumerWidget { const GeneralPage({super.key}); @@ -48,6 +53,7 @@ class GeneralPage extends ConsumerWidget { const SizedBox(height: 20), ..._buildDefaultBrowserSection( context, + ref, isDefaultAsync, ref.watch(defaultAssociationsProvider), ), @@ -57,6 +63,8 @@ class GeneralPage extends ConsumerWidget { ], const SizedBox(height: 20), ..._buildStartupSection(context, ref, isStartupAsync), + ..._buildInternalLinksSection(context, ref), + ..._buildDiagnosticsSection(context, ref), const SizedBox(height: 20), ..._buildAccessibilitySection(context, ref), const SizedBox(height: 20), @@ -67,8 +75,41 @@ class GeneralPage extends ConsumerWidget { ); } + /// Re-applies the shell registration, then sends the user to the OS picker. + /// + /// The button used to only open system settings. That is useless when the + /// recorded handler points at a stale path — the OS then has nothing valid + /// to offer — so the registration is repaired first. + static Future _makeDefault(WidgetRef ref) async { + final registration = ref.read(registrationServiceProvider); + try { + await registration.register(ref.read(executablePathProvider)); + } on Object catch (e, st) { + _log.warning('Re-registration from Settings failed', e, st); + } + ref + ..invalidate(isDefaultBrowserProvider) + ..invalidate(defaultAssociationsProvider); + + // On macOS `register()` already triggers the system confirmation dialog; + // only open the settings pane when it did not take effect. + if (Platform.isMacOS && await registration.isDefault) return; + + final target = Platform.isMacOS + // Ventura moved the default browser setting out of the legacy + // "General" preference pane and into Desktop & Dock. + ? 'x-apple.systempreferences:com.apple.Desktop-Settings.extension' + : 'ms-settings:defaultapps?registeredAppUser=LinkUnbound'; + try { + await launchUrl(Uri.parse(target)); + } on Object catch (e, st) { + _log.warning('Could not open system default-apps settings', e, st); + } + } + List _buildDefaultBrowserSection( BuildContext context, + WidgetRef ref, AsyncValue isDefaultAsync, AsyncValue> associationsAsync, ) { @@ -102,13 +143,7 @@ class GeneralPage extends ConsumerWidget { ), if (!isDefault) TextButton( - onPressed: () => launchUrl( - Uri.parse( - Platform.isMacOS - ? 'x-apple.systempreferences:com.apple.preference.general' - : 'ms-settings:defaultapps?registeredAppUser=LinkUnbound', - ), - ), + onPressed: () => unawaited(_makeDefault(ref)), child: Text(l10n.setDefault), ), ], @@ -213,6 +248,123 @@ class GeneralPage extends ConsumerWidget { ]; } + /// Surfaces a broken registration and offers to fix it. + /// + /// The failure this catches — handler recorded at a path that no longer + /// exists — is invisible otherwise: the app looks fine, links just stop + /// arriving, and nothing in the UI hints at why. + List _buildDiagnosticsSection(BuildContext context, WidgetRef ref) { + final diagnostics = ref.watch(handlerDiagnosticsProvider).valueOrNull; + if (diagnostics == null || diagnostics.isHealthy) return const []; + if (diagnostics.commandMatchesExecutable && + !diagnostics.runningFromDevBuild) { + // Only "not the default browser", which the section above already says. + return const []; + } + + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final message = diagnostics.runningFromDevBuild + ? l10n.diagnosticsDevBuild + : l10n.diagnosticsStaleHandler; + + return [ + const SizedBox(height: 20), + SectionHeader(label: l10n.diagnosticsTitle), + GroupCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, size: 20, color: colors.error), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (diagnostics.canRepair) + TextButton( + onPressed: () => unawaited(_repair(context, ref)), + child: Text(l10n.diagnosticsRepair), + ), + ], + ), + ), + ]; + } + + static Future _repair(BuildContext context, WidgetRef ref) async { + final l10n = AppLocalizations.of(context)!; + final messenger = ScaffoldMessenger.maybeOf(context); + var ok = true; + try { + await ref + .read(registrationServiceProvider) + .register(ref.read(executablePathProvider)); + } on Object catch (e, st) { + ok = false; + _log.warning('Repair from Settings failed', e, st); + } + ref + ..invalidate(handlerDiagnosticsProvider) + ..invalidate(isDefaultBrowserProvider) + ..invalidate(defaultAssociationsProvider); + messenger?.showSnackBar( + SnackBar( + content: Text( + ok ? l10n.diagnosticsRepaired : l10n.diagnosticsRepairFailed, + ), + ), + ); + } + + /// Windows-only toggle for `microsoft-edge:` interception. + /// + /// Hidden on macOS (no such scheme) and under MSIX, where a package cannot + /// claim a protocol owned by another package. + List _buildInternalLinksSection(BuildContext context, WidgetRef ref) { + if (!Platform.isWindows || isRunningInMsix()) return const []; + final l10n = AppLocalizations.of(context)!; + final captureAsync = ref.watch(edgeProtocolCaptureProvider); + + return [ + const SizedBox(height: 20), + SectionHeader(label: l10n.edgeProtocolLabel), + GroupCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + l10n.edgeProtocolDescription, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(width: 12), + Switch( + value: captureAsync.valueOrNull ?? false, + onChanged: (enabled) async { + try { + await ref + .read(registrationServiceProvider) + .setEdgeProtocolCapture( + enabled, + ref.read(executablePathProvider), + ); + } on Object catch (e, st) { + _log.warning('Edge protocol toggle failed', e, st); + } finally { + ref.invalidate(edgeProtocolCaptureProvider); + } + }, + ), + ], + ), + ), + ]; + } + List _buildStartupSection( BuildContext context, WidgetRef ref, diff --git a/apps/linkunbound/lib/ui/settings/rules_page.dart b/apps/linkunbound/lib/ui/settings/rules_page.dart index d138cbf..354bfed 100644 --- a/apps/linkunbound/lib/ui/settings/rules_page.dart +++ b/apps/linkunbound/lib/ui/settings/rules_page.dart @@ -62,14 +62,25 @@ class RulesPage extends ConsumerWidget { ...rules.map( (rule) => RuleRow( domain: rule.domain, + sourceApp: rule.sourceApp, + private: rule.private, browserName: _browserName(rule.browserId, browsers), browsers: browserList, onBrowserChanged: (browserId) { ref .read(rulesProvider.notifier) - .updateRule(rule.domain, browserId: browserId); + .updateRule( + rule.domain, + browserId: browserId, + sourceApp: rule.sourceApp, + ); }, - onDelete: () => _confirmDelete(context, ref, rule.domain), + onDelete: () => _confirmDelete( + context, + ref, + rule.domain, + sourceApp: rule.sourceApp, + ), ), ), ], @@ -86,17 +97,24 @@ class RulesPage extends ConsumerWidget { return browserId; } - void _confirmDelete(BuildContext context, WidgetRef ref, String domain) { + void _confirmDelete( + BuildContext context, + WidgetRef ref, + String domain, { + String? sourceApp, + }) { final l10n = AppLocalizations.of(context)!; showDialog( context: context, builder: (ctx) => BaseDialog( title: l10n.deleteRuleTitle, - content: l10n.deleteRuleContent(domain), + content: l10n.deleteRuleContent(sourceApp ?? domain), confirmLabel: l10n.delete, confirmColor: Theme.of(ctx).colorScheme.error, onConfirm: () { - ref.read(rulesProvider.notifier).removeRule(domain); + ref + .read(rulesProvider.notifier) + .removeRule(domain, sourceApp: sourceApp); Navigator.of(ctx).pop(); }, ), diff --git a/apps/linkunbound/lib/ui/shared/widgets/rule_row.dart b/apps/linkunbound/lib/ui/shared/widgets/rule_row.dart index 8739089..e8274b4 100644 --- a/apps/linkunbound/lib/ui/shared/widgets/rule_row.dart +++ b/apps/linkunbound/lib/ui/shared/widgets/rule_row.dart @@ -9,6 +9,8 @@ class RuleRow extends StatelessWidget { required this.browsers, required this.onBrowserChanged, required this.onDelete, + this.sourceApp, + this.private = false, super.key, }); @@ -18,9 +20,17 @@ class RuleRow extends StatelessWidget { final void Function(String browserId) onBrowserChanged; final VoidCallback onDelete; + /// Origin the rule is scoped to, when it targets an app rather than a domain. + final String? sourceApp; + final bool private; + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + // An app-scoped rule reads as the app name; showing the literal "*" it is + // stored under would be meaningless to the user. + final label = sourceApp != null ? l10n.ruleFromApp(sourceApp!) : domain; return Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -28,10 +38,32 @@ class RuleRow extends StatelessWidget { children: [ Expanded( flex: 3, - child: Text( - domain, - style: Theme.of(context).textTheme.bodyMedium, - overflow: TextOverflow.ellipsis, + child: Row( + children: [ + if (sourceApp != null) ...[ + Icon( + Icons.apps_outlined, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 6), + ], + Flexible( + child: Text( + label, + style: Theme.of(context).textTheme.bodyMedium, + overflow: TextOverflow.ellipsis, + ), + ), + if (private) ...[ + const SizedBox(width: 6), + Icon( + Icons.visibility_off_outlined, + size: 14, + color: colors.onSurfaceVariant, + ), + ], + ], ), ), const SizedBox(width: 12), diff --git a/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj b/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj index 1daea5d..78ccde5 100644 --- a/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj @@ -32,7 +32,8 @@ A1B2C30000000000000050 /* RegistrationChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000051 /* RegistrationChannel.swift */; }; A1B2C30000000000000060 /* StartupChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000061 /* StartupChannel.swift */; }; A1B2C30000000000000070 /* LinkUnboundChannels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000071 /* LinkUnboundChannels.swift */; }; - A1B2C30000000000000080 /* WindowChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000081 /* WindowChannel.swift */; }; 3FC8CBCC463B3627C59F4107 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 500F6A1F28F0CB19E067FBF2 /* Pods_Runner.framework */; }; + A1B2C30000000000000080 /* WindowChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000081 /* WindowChannel.swift */; }; + A1B2C30000000000000090 /* SourceAppChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000091 /* SourceAppChannel.swift */; }; 3FC8CBCC463B3627C59F4107 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 500F6A1F28F0CB19E067FBF2 /* Pods_Runner.framework */; }; DC236B3B7CC7FCC9916A8FAF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22348279A947D0F510AF2D95 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -83,7 +84,8 @@ A1B2C30000000000000051 /* RegistrationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationChannel.swift; sourceTree = ""; }; A1B2C30000000000000061 /* StartupChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupChannel.swift; sourceTree = ""; }; A1B2C30000000000000071 /* LinkUnboundChannels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkUnboundChannels.swift; sourceTree = ""; }; - A1B2C30000000000000081 /* WindowChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowChannel.swift; sourceTree = ""; }; 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + A1B2C30000000000000081 /* WindowChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowChannel.swift; sourceTree = ""; }; + A1B2C30000000000000091 /* SourceAppChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceAppChannel.swift; sourceTree = ""; }; 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; @@ -206,6 +208,7 @@ A1B2C30000000000000061 /* StartupChannel.swift */, A1B2C30000000000000071 /* LinkUnboundChannels.swift */, A1B2C30000000000000081 /* WindowChannel.swift */, + A1B2C30000000000000091 /* SourceAppChannel.swift */, ); path = Channels; sourceTree = ""; @@ -470,6 +473,7 @@ A1B2C30000000000000060 /* StartupChannel.swift in Sources */, A1B2C30000000000000070 /* LinkUnboundChannels.swift in Sources */, A1B2C30000000000000080 /* WindowChannel.swift in Sources */, + A1B2C30000000000000090 /* SourceAppChannel.swift in Sources */, 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, ); diff --git a/apps/linkunbound/macos/Runner/AppDelegate.swift b/apps/linkunbound/macos/Runner/AppDelegate.swift index f8c99ea..449f5f6 100644 --- a/apps/linkunbound/macos/Runner/AppDelegate.swift +++ b/apps/linkunbound/macos/Runner/AppDelegate.swift @@ -28,7 +28,11 @@ class AppDelegate: FlutterAppDelegate { event?.eventID == AEEventID(kAEOpenApplication) && event?.paramDescriptor(forKeyword: AEKeyword(keyAEPropData))?.enumCodeValue == OSType(keyAELaunchedAsLogInItem) - super.applicationDidFinishLaunching(notification) + // No `super` call here: FlutterAppDelegate does not implement + // applicationDidFinishLaunching:. The override compiles because the + // superclass adopts NSApplicationDelegate, but the objc_msgSendSuper hits + // an unimplemented selector and aborts the process during launch — which + // is precisely when Launch Services hands us a URL to open. } override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift b/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift index 59f58f5..05bccb7 100644 --- a/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift +++ b/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift @@ -55,6 +55,9 @@ final class BrowserDetectorChannel { "/Applications/", "/System/Applications/", "/System/Volumes/Preboot/", + // Safari ships from a cryptex on macOS 13+, so without this it is + // filtered out and users with no other browser get an empty picker. + "/System/Cryptexes/App/System/Applications/", NSString("~/Applications/").expandingTildeInPath + "/", ] diff --git a/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift b/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift index 8030636..4b909b7 100644 --- a/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift +++ b/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift @@ -7,6 +7,7 @@ final class LinkUnboundChannels { let browserDetector: BrowserDetectorChannel let iconExtractor: IconExtractorChannel let registration: RegistrationChannel + let sourceApp: SourceAppChannel let startup: StartupChannel let window: WindowChannel @@ -15,6 +16,7 @@ final class LinkUnboundChannels { browserDetector = BrowserDetectorChannel(messenger: messenger) iconExtractor = IconExtractorChannel(messenger: messenger) registration = RegistrationChannel(messenger: messenger) + sourceApp = SourceAppChannel(messenger: messenger) startup = StartupChannel(messenger: messenger) window = WindowChannel(messenger: messenger) } diff --git a/apps/linkunbound/macos/Runner/Channels/RegistrationChannel.swift b/apps/linkunbound/macos/Runner/Channels/RegistrationChannel.swift index af4d18c..bfb8391 100644 --- a/apps/linkunbound/macos/Runner/Channels/RegistrationChannel.swift +++ b/apps/linkunbound/macos/Runner/Channels/RegistrationChannel.swift @@ -18,12 +18,32 @@ final class RegistrationChannel { guard let self else { return result(FlutterMethodNotImplemented) } switch call.method { case "register": - self.setHandler(self.ownBundleId) - result(nil) + // Answer only once the system has actually applied (or refused) the + // change, so Dart re-reads the real state instead of a stale one. + self.setHandler(self.ownBundleId) { error in + if let error { + result( + FlutterError( + code: "registration_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } case "unregister": // macOS has no "remove default" — fall back to Safari. - self.setHandler(self.safariBundleId) - result(nil) + self.setHandler(self.safariBundleId) { error in + if let error { + result( + FlutterError( + code: "unregistration_failed", + message: error.localizedDescription, + details: nil)) + } else { + result(nil) + } + } case "isDefault": result(self.isDefault()) case "defaultAssociations": @@ -34,16 +54,56 @@ final class RegistrationChannel { } } - private func setHandler(_ bundleId: String) { - guard #available(macOS 12.0, *), - let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) - else { return } + /// Points the given schemes at `bundleId`, reporting the first failure. + /// + /// For our own bundle the URL is always `Bundle.main.bundleURL`, never the + /// Launch Services lookup: that lookup returns whichever copy LS happens to + /// prefer, so with a debug build present it would register a path inside the + /// build tree — and the association dies with the next `flutter clean`. + private func setHandler(_ bundleId: String, completion: @escaping (Error?) -> Void) { + let appURL: URL? = + bundleId == ownBundleId + ? Bundle.main.bundleURL + : NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) - NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: "http") { _ in } - NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: "https") { _ in } + guard let appURL else { + completion(RegistrationError.applicationNotFound(bundleId)) + return + } + + // macOS prompts the user for http/https and the answer can be "no"; the + // errors used to be discarded, so Settings reported success either way. + let group = DispatchGroup() + var firstError: Error? + for scheme in ["http", "https"] { + group.enter() + NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: scheme) { error in + if let error, firstError == nil { firstError = error } + group.leave() + } + } if let htmlType = UTType("public.html") { - // Fire-and-forget async API (no completion handler variant exists). - Task { try? await NSWorkspace.shared.setDefaultApplication(at: appURL, toOpen: htmlType) } + group.enter() + Task { + do { + try await NSWorkspace.shared.setDefaultApplication(at: appURL, toOpen: htmlType) + } catch { + if firstError == nil { firstError = error } + } + group.leave() + } + } + group.notify(queue: .main) { completion(firstError) } + } + + enum RegistrationError: LocalizedError { + case applicationNotFound(String) + + var errorDescription: String? { + switch self { + case .applicationNotFound(let bundleId): + return "No application found for bundle identifier \(bundleId)" + } } } diff --git a/apps/linkunbound/macos/Runner/Channels/SourceAppChannel.swift b/apps/linkunbound/macos/Runner/Channels/SourceAppChannel.swift new file mode 100644 index 0000000..9ecb963 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/SourceAppChannel.swift @@ -0,0 +1,42 @@ +import AppKit +import FlutterMacOS + +/// `linkunbound/source_app` — best-effort origin of an inbound link. +/// +/// macOS gives no originator for open events, so the application that owns the +/// foreground at that instant is used as an approximation. +final class SourceAppChannel { + static let channelName = "linkunbound/source_app" + + private let channel: FlutterMethodChannel + private let ownBundleId: String + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: Self.channelName, binaryMessenger: messenger) + ownBundleId = Bundle.main.bundleIdentifier ?? "com.rgdevment.linkunbound" + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { return result(FlutterMethodNotImplemented) } + switch call.method { + case "frontmostApp": + result(self.frontmostApp()) + default: + result(FlutterMethodNotImplemented) + } + } + } + + /// The foreground application, or `nil` when it cannot stand in for the origin. + private func frontmostApp() -> [String: String]? { + guard let app = NSWorkspace.shared.frontmostApplication, + let bundleId = app.bundleIdentifier?.lowercased(), + let name = app.localizedName + else { return nil } + + // Ourselves in the foreground says nothing about where the link came from: + // reporting it would let a rule match on LinkUnbound instead of on Slack, + // Teams or whatever actually handed us the URL. + guard bundleId != ownBundleId.lowercased() else { return nil } + + return ["id": bundleId, "name": name] + } +} diff --git a/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift b/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift index 9969e87..f8a3d39 100644 --- a/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift +++ b/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift @@ -32,8 +32,14 @@ final class WindowChannel { } } + /// The Flutter window, resolved through the delegate outlet. + /// + /// `NSApp.windows.first` was unreliable: the tray plugin creates an + /// `NSStatusItem` whose backing window also lives in that array and the + /// order is undocumented, so picker/settings styling and activation could be + /// applied to the status bar window while the real one stayed hidden. private static func mainWindow() -> NSWindow? { - NSApplication.shared.windows.first + (NSApp.delegate as? AppDelegate)?.mainFlutterWindow ?? NSApp.windows.first } private static func applyPickerMode() { diff --git a/apps/linkunbound/macos/Runner/Info.plist b/apps/linkunbound/macos/Runner/Info.plist index 3921c26..0ce8539 100644 --- a/apps/linkunbound/macos/Runner/Info.plist +++ b/apps/linkunbound/macos/Runner/Info.plist @@ -34,7 +34,7 @@ CFBundleTypeRole Viewer LSHandlerRank - Alternate + Owner CFBundleURLSchemes http @@ -50,7 +50,7 @@ CFBundleTypeRole Viewer LSHandlerRank - Alternate + Owner LSItemContentTypes public.html diff --git a/apps/linkunbound/test/app_test.dart b/apps/linkunbound/test/app_test.dart index 0837b76..3e99989 100644 --- a/apps/linkunbound/test/app_test.dart +++ b/apps/linkunbound/test/app_test.dart @@ -108,8 +108,8 @@ void main() { await tester.pump(); expect(find.byType(SettingsWindow), findsOneWidget); - expect(windowSpy.methods, contains('show')); - expect(windowSpy.methods, contains('focus')); + // Showing and focusing is bootstrap's job now — driving the window from + // here too raced the serialised transition queue. See bootstrap_test. }); testWidgets('immediate blur after showing picker is ignored', (tester) async { diff --git a/apps/linkunbound/test/bootstrap_test.dart b/apps/linkunbound/test/bootstrap_test.dart index 77b1ed3..a3e3986 100644 --- a/apps/linkunbound/test/bootstrap_test.dart +++ b/apps/linkunbound/test/bootstrap_test.dart @@ -135,6 +135,28 @@ final class _RecordingRegistrationService implements RegistrationService { registerCalls.add(executablePath); } + @override + Future ensureRegistered(String executablePath) => + register(executablePath); + + @override + Future diagnose(String executablePath) async => + const HandlerDiagnostics( + isDefaultBrowser: false, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() async {} } @@ -158,8 +180,9 @@ final class _RecordingLaunchService implements LaunchService { Future launch( String executablePath, String url, - List extraArgs, - ) async { + List extraArgs, { + List privateArgs = const [], + }) async { calls.add(( executablePath: executablePath, url: url, @@ -389,8 +412,9 @@ final class _FailingLaunchService implements LaunchService { Future launch( String executablePath, String url, - List extraArgs, - ) => Future.error(Exception('launch failed')); + List extraArgs, { + List privateArgs = const [], + }) => Future.error(Exception('launch failed')); } final class _ThrowingDelegateBindings extends _FakeBindings { @@ -462,6 +486,28 @@ final class _FailingRegistrationService implements RegistrationService { Future register(String executablePath) => Future.error(Exception('registration failed')); + @override + Future ensureRegistered(String executablePath) => + register(executablePath); + + @override + Future diagnose(String executablePath) async => + const HandlerDiagnostics( + isDefaultBrowser: false, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() async {} } @@ -783,6 +829,9 @@ void main() { windowSpy.clear(); + // Mode transitions run through a serialised queue: the continuation is + // scheduled on the test zone, so it needs a pump to start, and each + // window call is a channel round-trip needing another. await tester.runAsync(() async { bindings.emit(const ShowSettingsEvent()); await Future.delayed(const Duration(milliseconds: 150)); diff --git a/apps/linkunbound/test/helpers.dart b/apps/linkunbound/test/helpers.dart index 206db22..3671a93 100644 --- a/apps/linkunbound/test/helpers.dart +++ b/apps/linkunbound/test/helpers.dart @@ -20,6 +20,28 @@ final class FakeRegistrationService implements RegistrationService { @override Future register(String executablePath) async {} + @override + Future ensureRegistered(String executablePath) => + register(executablePath); + + @override + Future diagnose(String executablePath) async => + const HandlerDiagnostics( + isDefaultBrowser: false, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() async {} @@ -52,8 +74,9 @@ final class FakeLaunchService implements LaunchService { Future launch( String executablePath, String url, - List extraArgs, - ) async { + List extraArgs, { + List privateArgs = const [], + }) async { launches.add(executablePath); } } diff --git a/apps/linkunbound/test/platform/local_file_url_test.dart b/apps/linkunbound/test/platform/local_file_url_test.dart index b162a84..4510b4d 100644 --- a/apps/linkunbound/test/platform/local_file_url_test.dart +++ b/apps/linkunbound/test/platform/local_file_url_test.dart @@ -116,4 +116,48 @@ void main() { expect(redactPath(''), ''); }); }); + + group('scheme casing and UNC hardening', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('lu_local_file_sec_'); + }); + + tearDown(() async { + if (tmp.existsSync()) await tmp.delete(recursive: true); + }); + + test('looksLikeLocalFile matches an uppercase FILE scheme', () { + // Uri lowercases the scheme, so an uppercase argument passed the inbound + // check but skipped this guard — and with it the extension allowlist. + expect(looksLikeLocalFile('FILE:///C:/tmp/page.html'), isTrue); + expect(looksLikeLocalFile('File:///tmp/page.html'), isTrue); + }); + + test('looksLikeLocalFile still rejects web URLs', () { + expect(looksLikeLocalFile('https://example.com'), isFalse); + expect(looksLikeLocalFile('http://example.com/a.html'), isFalse); + }); + + test('resolveLocalWebFile rejects UNC paths', () { + // Probing a UNC path makes Windows authenticate to the remote host, + // leaking a NetNTLMv2 hash before the picker is even shown. + expect( + resolveLocalWebFile('file://attacker.example.com/s/x.html'), + isNull, + ); + expect( + resolveLocalWebFile('FILE://attacker.example.com/s/x.html'), + isNull, + ); + }); + + test('resolveLocalWebFile applies the extension allowlist regardless of ' + 'scheme casing', () { + final f = File('${tmp.path}/secret.key')..writeAsStringSync('x'); + final upper = Uri.file(f.path).toString().replaceFirst('file:', 'FILE:'); + expect(resolveLocalWebFile(upper), isNull); + }); + }); } diff --git a/apps/linkunbound/test/platform/win_registration_service_test.dart b/apps/linkunbound/test/platform/win_registration_service_test.dart index 1e8679c..00491ad 100644 --- a/apps/linkunbound/test/platform/win_registration_service_test.dart +++ b/apps/linkunbound/test/platform/win_registration_service_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound/platform/windows/win_package_context.dart'; import 'package:linkunbound/platform/windows/win_registration_service.dart'; void main() { @@ -96,4 +97,39 @@ void main() { expect(winRegistrationUserChoiceKeys, contains('.pdf')); }); }); + + group('isDevBuildPath', () { + test('flags a Flutter build tree', () { + // A build tree must never own the registration: it disappears on + // `flutter clean` and the dead ProgId then shadows the real install. + expect( + isDevBuildPath( + r'D:\Code\LinkUnbound\apps\linkunbound\build\windows' + r'\x64\runner\Release\linkunbound.exe', + ), + isTrue, + ); + }); + + test('accepts forward slashes and mixed case', () { + expect( + isDevBuildPath('D:/Code/App/Build/Windows/x64/Runner/app.exe'), + isTrue, + ); + }); + + test('does not flag a real installation', () { + expect( + isDevBuildPath(r'C:\Program Files\LinkUnbound\linkunbound.exe'), + isFalse, + ); + expect( + isDevBuildPath( + r'C:\Program Files\WindowsApps\rgdevment.LinkUnbound_1.0' + r'\linkunbound.exe', + ), + isFalse, + ); + }); + }); } diff --git a/apps/linkunbound/test/providers_extra_test.dart b/apps/linkunbound/test/providers_extra_test.dart index 4b41617..84ad9bb 100644 --- a/apps/linkunbound/test/providers_extra_test.dart +++ b/apps/linkunbound/test/providers_extra_test.dart @@ -34,6 +34,28 @@ final class _CountingRegistrationService implements RegistrationService { @override Future register(String executablePath) async {} + @override + Future ensureRegistered(String executablePath) => + register(executablePath); + + @override + Future diagnose(String executablePath) async => + const HandlerDiagnostics( + isDefaultBrowser: false, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() async {} } diff --git a/apps/linkunbound/test/ui/maintenance_page_actions_test.dart b/apps/linkunbound/test/ui/maintenance_page_actions_test.dart index 5e08122..15eeb2e 100644 --- a/apps/linkunbound/test/ui/maintenance_page_actions_test.dart +++ b/apps/linkunbound/test/ui/maintenance_page_actions_test.dart @@ -30,6 +30,28 @@ final class _RecordingRegistrationService implements RegistrationService { @override Future register(String executablePath) async {} + @override + Future ensureRegistered(String executablePath) => + register(executablePath); + + @override + Future diagnose(String executablePath) async => + const HandlerDiagnostics( + isDefaultBrowser: false, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() async { unregisterCalls++; diff --git a/apps/linkunbound/test/ui/maintenance_page_test.dart b/apps/linkunbound/test/ui/maintenance_page_test.dart index 8c40a6e..0a27e1a 100644 --- a/apps/linkunbound/test/ui/maintenance_page_test.dart +++ b/apps/linkunbound/test/ui/maintenance_page_test.dart @@ -298,6 +298,28 @@ final class _ThrowingRegistrationService implements RegistrationService { @override Future register(String executablePath) async {} + @override + Future ensureRegistered(String executablePath) => + register(executablePath); + + @override + Future diagnose(String executablePath) async => + const HandlerDiagnostics( + isDefaultBrowser: false, + commandMatchesExecutable: true, + runningFromDevBuild: false, + isPackaged: false, + ); + + @override + Future setEdgeProtocolCapture( + bool enabled, + String executablePath, + ) async {} + + @override + Future get capturesEdgeProtocol async => false; + @override Future unregister() => Future.error(Exception('unregister failed')); } diff --git a/apps/linkunbound/test/ui/phase5_test.dart b/apps/linkunbound/test/ui/phase5_test.dart index 0109129..ea24db2 100644 --- a/apps/linkunbound/test/ui/phase5_test.dart +++ b/apps/linkunbound/test/ui/phase5_test.dart @@ -147,7 +147,7 @@ void main() { expect(find.byType(PickerWindow), findsNothing); }); - testWidgets('onWindowFocus marks picker ready before timer fires', ( + testWidgets('focus does not arm the blur guard before the grace period', ( tester, ) async { final (:container, :tempDir) = _buildApp(tester); @@ -171,14 +171,17 @@ void main() { await tester.pump(); await tester.pump(); - // Focus fires before the 350ms fallback — should mark ready. + // Showing a window generates its own focus/blur pair. Arming the guard + // on that focus closed the picker immediately, before the user could + // click anything — only the grace timer may arm it. // ignore: avoid_dynamic_calls appState.onWindowFocus(); // ignore: avoid_dynamic_calls appState.onWindowBlur(); await tester.pump(); - expect(container.read(appStateProvider).mode, AppMode.hidden); + expect(container.read(appStateProvider).mode, AppMode.picker); + expect(find.byType(PickerWindow), findsOneWidget); }); testWidgets('blur in settings mode does nothing', (tester) async { diff --git a/apps/linkunbound/test/ui/picker_view_test.dart b/apps/linkunbound/test/ui/picker_view_test.dart index eea5059..f0bdab3 100644 --- a/apps/linkunbound/test/ui/picker_view_test.dart +++ b/apps/linkunbound/test/ui/picker_view_test.dart @@ -119,7 +119,8 @@ void main() { }); group('PickerView — browser list', () { - testWidgets('shows empty list when no browsers', (tester) async { + testWidgets('shows an explanation instead of a blank window when no ' + 'browsers', (tester) async { final f = makeFixtures(dir: tempDir); await tester.pumpWidget( buildTestApp( @@ -128,7 +129,13 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(find.byType(ListView), findsOneWidget); + // An empty ListView renders as an empty window, which reads as a broken + // app rather than "no browsers were detected". + expect(find.byType(ListView), findsNothing); + expect( + find.text('No browsers detected. Open Settings to add one.'), + findsOneWidget, + ); }); testWidgets('shows browser names when browsers provided', (tester) async { diff --git a/apps/linkunbound/windows/packaging/exe/setup_template.iss b/apps/linkunbound/windows/packaging/exe/setup_template.iss index 3f1a89e..851b5b4 100644 --- a/apps/linkunbound/windows/packaging/exe/setup_template.iss +++ b/apps/linkunbound/windows/packaging/exe/setup_template.iss @@ -93,7 +93,13 @@ Root: HKCU; Subkey: "SOFTWARE\LinkUnbound"; Flags: uninsdeletekey dontcreatekey Root: HKCU; Subkey: "SOFTWARE\RegisteredApplications"; ValueName: "LinkUnbound"; Flags: uninsdeletevalue dontcreatekey [Run] -Filename: "{app}\{{EXECUTABLE_NAME}}"; Description: "{cm:LaunchProgram,{{DISPLAY_NAME}}}"; Flags: nowait postinstall skipifsilent +; runasoriginaluser is mandatory here: the installer requires admin, and without +; it the app inherits the elevated token. Its single-instance mutex and IPC pipe +; would then live at high integrity, unreachable from the medium-integrity +; processes that actually open links (Slack, Teams, Explorer) — every click +; would be silently dropped. It also keeps per-user registry writes in the +; signed-in user's hive rather than the admin's. +Filename: "{app}\{{EXECUTABLE_NAME}}"; Description: "{cm:LaunchProgram,{{DISPLAY_NAME}}}"; Flags: nowait postinstall skipifsilent runasoriginaluser [Code] const diff --git a/docs/LINK_CAPTURE.md b/docs/LINK_CAPTURE.md new file mode 100644 index 0000000..0699383 --- /dev/null +++ b/docs/LINK_CAPTURE.md @@ -0,0 +1,204 @@ +# Captura de enlaces: cómo funciona y cómo se rompe + +Documento de referencia sobre el camino que recorre un enlace desde que se pulsa +hasta que aparece el picker, y sobre las condiciones que lo interrumpen. Escrito +tras la auditoría de agosto de 2026, que encontró la app sin capturar enlaces en +Windows y macOS simultáneamente por causas distintas en cada plataforma. + +## El camino del enlace + +### Windows + +1. El usuario pulsa un enlace en Slack, Teams, Outlook o el Explorador. +2. El shell resuelve el handler de `https` leyendo + `HKCU\…\UrlAssociations\https\UserChoice` → ProgId (`LinkUnboundURL`). +3. Resuelve el ProgId en `HKCU\Software\Classes\LinkUnboundURL\shell\open\command` + y ejecuta `"" ""`. +4. El proceso nuevo parsea `argv` (`windows_bindings.dart`, `_parseInitialEvent`). +5. Intenta delegar en la instancia residente por el named pipe `\\.\pipe\LinkUnbound`. + Si lo consigue, sale de inmediato; si no, se convierte él en residente. +6. El residente aplica las reglas por dominio y, si no hay ninguna, muestra el picker. + +### macOS + +1. Launch Services resuelve el handler de `https` a partir de `CFBundleURLTypes` + del bundle y de la elección del usuario. +2. Lanza (o reactiva) la app y entrega el evento a `application(_:open:)`. +3. `InboundEventsChannel` encola la URL hasta que Dart señala `ready`. +4. Igual que en Windows a partir del paso 6. + +## Precedencia del registro en Windows (importante) + +`HKEY_CLASSES_ROOT` es una vista combinada donde **`HKCU\Software\Classes` +tiene prioridad sobre `HKLM\SOFTWARE\Classes`**. Consecuencia práctica: + +> Una entrada por usuario escrita por una compilación local **secuestra** la +> asociación de la instalación real, sea del instalador `.exe` (que escribe en +> HKLM) o de Microsoft Store (que la declara en el manifiesto MSIX). + +Este fue el fallo observado en la máquina de desarrollo: la app estaba instalada +desde la Store, pero `HKCU\Software\Classes\LinkUnboundURL\shell\open\command` +apuntaba a un `linkunbound.exe` de un árbol de compilación que ya no existía. +Windows dejó de ofrecer la app como navegador y los enlaces no abrían nada. + +### Reglas que aplica el código + +Implementadas en `WinRegistrationService.ensureRegistered` y en +`isDevBuildPath` (`win_package_context.dart`): + +| Contexto de ejecución | Comportamiento | +| --- | --- | +| Instalación normal (`.exe`) | Registra, y **re-registra** si la ruta grabada dejó de coincidir | +| MSIX / Microsoft Store | No escribe nada, y **borra** cualquier entrada HKCU que esté sombreando al paquete | +| Árbol de compilación (`\build\windows\`) | No registra nunca; si detecta una entrada propia de un build, la elimina | + +`ensureRegistered` se ejecuta **en cada arranque**, antes del primer frame +(`bootstrap.dart`). Solo escribe cuando algo cambió, así que el coste habitual es +una lectura de registro. + +Antes, `register()` se llamaba una única vez en el primer arranque, dentro de un +`addPostFrameCallback`. Eso significaba que la ruta quedaba congelada de por vida: +actualizar, reinstalar o mover la app dejaba el handler apuntando a un ejecutable +inexistente sin ninguna forma de repararlo desde la interfaz. + +## Enlaces internos de aplicaciones Microsoft + +Teams, Outlook, Widgets, Copilot y la búsqueda del menú Inicio **no abren +`https:`**: envuelven la URL en `microsoft-edge:`, un esquema asociado a Edge que +ignora por completo el navegador predeterminado. Por eso los enlaces de esas +aplicaciones seguían abriéndose en Edge aunque LinkUnbound fuera el predeterminado. + +La app puede interceptar ese esquema registrando un handler propio para +`microsoft-edge` en `HKCU\Software\Classes` (`setEdgeProtocolCapture`). +`stripEdgeProtocol` desenvuelve la URL interna antes de procesarla. + +Es **opt-in**, con un interruptor en Ajustes → General: le quita un protocolo a +Edge, y quien prefiera el comportamiento de Edge debe poder conservarlo. No está +disponible bajo MSIX, donde un paquete no puede reclamar un protocolo de otro. + +## Reglas por aplicación de origen + +Una regla puede fijarse al dominio (`github.com → Firefox`) o a la aplicación +desde la que se pulsó el enlace (`todo lo que venga de Slack → Brave`). Las +segundas se guardan con `domain: "*"` y `sourceApp: ""`. + +Cómo se determina el origen: + +| Plataforma | Método | Fiabilidad | +| --- | --- | --- | +| Windows | Proceso padre del proceso que lanzó el shell (`win_source_app.dart`) | Alta | +| macOS | Aplicación en primer plano (`SourceAppChannel`) | Aproximada | + +En Windows el dato solo es válido en el proceso que el shell acaba de lanzar: en +cuanto la URL se delega al residente por el pipe, el padre de ese residente no +tiene nada que ver. Por eso el origen se resuelve al parsear los argumentos y +viaja **dentro** del `OpenUrlEvent`. + +macOS no expone quién originó un evento de apertura, así que se aproxima con la +app en primer plano. Acierta en el caso normal y falla si el usuario cambia de +ventana en ese mismo instante. + +Se descartan `explorer`, `cmd` y `powershell` como origen: son el propio shell, +no una aplicación contra la que tenga sentido escribir una regla. + +**Precedencia** (`RuleService.lookupRule`): una regla que nombra la app gana +siempre a una que solo nombra el dominio, aunque esta última sea más específica. +«Todo lo de Slack en Brave» es una afirmación deliberada sobre el origen y una +regla genérica de dominio no debe anularla en silencio. Dentro de cada ámbito, el +dominio más específico gana, y los subdominios heredan de su dominio padre. + +## Modo privado + +`Shift` + clic (o `Shift` + número) en el picker abre el enlace en una ventana +privada. Mientras `Shift` está pulsado, las filas muestran un icono; solo lo +hacen los navegadores que aceptan un modificador para ello. + +El modificador depende de la familia (`private_mode.dart`), porque no hay +estándar y uno equivocado no se ignora: Chromium trata un `--flag` desconocido +como argumento y Firefox abre una página de error. + +| Familia | Modificador | +| --- | --- | +| Chrome, Brave, Vivaldi, Chromium | `--incognito` | +| Edge | `-inprivate` | +| Firefox, LibreWolf, Waterfox, Zen | `-private-window` | +| Opera | `--private` | +| Safari | No admite; la opción no se ofrece | + +En macOS el lanzamiento privado usa `open -na … --args `. El `-n` es +obligatorio: si la aplicación ya está en ejecución, `open` descarta `--args` por +completo y el enlace se abriría en una ventana normal sin ningún aviso. + +Un navegador puede fijar su propio modificador con `privateArgs` en +`browsers.json`; una lista vacía significa «este navegador no admite modo +privado». + +## Autodiagnóstico + +Ajustes → General muestra una tarjeta de reparación cuando `diagnose()` detecta +que el manejador registrado apunta a otra ubicación. El botón «Reparar» vuelve a +registrar la aplicación e invalida el estado en pantalla. + +No se ofrece reparación cuando la app corre desde un árbol de compilación: ahí +registrar empeoraría las cosas en vez de arreglarlas, así que solo se explica el +motivo. + +## Integridad y elevación (Windows) + +El instalador pide privilegios de administrador. Si lanza la app al terminar sin +`runasoriginaluser`, la app hereda el token elevado y sus objetos kernel —el +mutex de instancia única y el named pipe— quedan a integridad **alta**. Slack, +Teams y el Explorador corren a integridad **media** y no pueden escribir en ellos: +cada clic se descartaba en silencio. + +Dos medidas, ambas necesarias: + +- `setup_template.iss` lanza la app con `runasoriginaluser`. +- El pipe se crea con un descriptor de seguridad explícito + (`win_security.dart`): DACL restringida al usuario actual y a SYSTEM, más una + etiqueta de integridad baja con `NO_WRITE_UP` para que un cliente de integridad + media pueda entregar la URL aunque el servidor esté elevado. + +## Diagnóstico en campo + +`%LOCALAPPDATA%\LinkUnbound\navigate.log` (Windows) o +`~/Library/Application Support/LinkUnbound/navigate.log` (macOS). + +Mensajes que identifican cada fallo: + +| Mensaje | Significado | +| --- | --- | +| `Handler command drifted; re-registering` | La ruta grabada no coincidía; se ha reparado | +| `Removing stale per-user registration shadowing the MSIX package` | Había un residuo HKCU tapando la instalación de la Store | +| `Removing registration owned by a local build tree` | Un build local tenía secuestrada la asociación | +| `Refusing to register a local build tree as URL handler` | Se ejecutó desde `\build\windows\`; no se registra | +| `Ignoring unrecognised launch argument` | Llegó un argumento que no es una URL reconocible | +| `Pipe name already owned by another process` | Otro proceso ocupa el pipe; la delegación no funcionará | +| `Discarding initial event: no resident could be reached` | No se pudo delegar ni tomar el rol de residente | +| `Rejected URL with non-launchable scheme` | Esquema no permitido (ver más abajo) | + +Comprobación rápida del registro en Windows: + +```powershell +reg query "HKCU\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice" +reg query "HKCU\Software\Classes\LinkUnboundURL\shell\open\command" +``` + +El primero debe dar `ProgId = LinkUnboundURL` (o el ProgId `AppX…` del paquete +MSIX). El segundo debe apuntar a un ejecutable **que exista**. + +## Esquemas aceptados + +`isLaunchableUrl` (`packages/core/lib/src/url_utils.dart`) es la única puerta +por la que una URL llega a `Process.start`. Solo admite `http`, `https` y `file`, +y rechaza cualquier cadena que empiece por `-`, `/` o `\`. + +El motivo es concreto: los navegadores interpretan como modificador cualquier +argumento que empiece por guion, y modificadores como `--gpu-launcher=` o +`--utility-cmd-prefix=` ejecutan binarios arbitrarios. Como los eventos entrantes +llegan por IPC desde cualquier proceso local, el esquema no es de fiar. + +Las rutas locales (`file:`) pasan además por `resolveLocalWebFile`, que aplica +una lista blanca de extensiones y **rechaza rutas UNC**: comprobar la existencia +de `\\host\share\x.html` hace que Windows se autentique contra ese host y filtre +un hash NetNTLMv2. diff --git a/packages/core/lib/linkunbound_core.dart b/packages/core/lib/linkunbound_core.dart index aa3fe3c..1b73559 100644 --- a/packages/core/lib/linkunbound_core.dart +++ b/packages/core/lib/linkunbound_core.dart @@ -2,10 +2,12 @@ export 'src/models/browser.dart'; export 'src/models/browser_config.dart'; export 'src/models/rule.dart'; export 'src/platform/browser_detector.dart'; +export 'src/platform/handler_diagnostics.dart'; export 'src/platform/icon_extractor.dart'; export 'src/platform/inbound_event.dart'; export 'src/platform/registration_service.dart'; export 'src/platform/startup_service.dart'; +export 'src/private_mode.dart'; export 'src/services/browser_service.dart'; export 'src/services/launch_service.dart'; export 'src/services/log_service.dart'; diff --git a/packages/core/lib/src/models/browser.dart b/packages/core/lib/src/models/browser.dart index 8a84af0..bc2aac3 100644 --- a/packages/core/lib/src/models/browser.dart +++ b/packages/core/lib/src/models/browser.dart @@ -1,5 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; +import '../private_mode.dart'; + part 'browser.g.dart'; @JsonSerializable() @@ -11,6 +13,7 @@ final class Browser { required this.iconPath, this.extraArgs = const [], this.isCustom = false, + this.privateArgs, }); factory Browser.fromJson(Map json) => @@ -23,6 +26,18 @@ final class Browser { final List extraArgs; final bool isCustom; + /// Overrides the private-window switch derived from the executable name. + /// Null means "derive it"; an empty list means "this browser has none", + /// which is how a custom browser opts out. + final List? privateArgs; + + /// Arguments that open this browser in a private window, empty when it does + /// not support one. + List get resolvedPrivateArgs => + privateArgs ?? privateModeArgs(executablePath); + + bool get canOpenPrivately => resolvedPrivateArgs.isNotEmpty; + Map toJson() => _$BrowserToJson(this); Browser copyWith({ @@ -32,6 +47,8 @@ final class Browser { String? iconPath, List? extraArgs, bool? isCustom, + List? privateArgs, + bool clearPrivateArgs = false, }) => Browser( id: id ?? this.id, name: name ?? this.name, @@ -39,5 +56,6 @@ final class Browser { iconPath: iconPath ?? this.iconPath, extraArgs: extraArgs ?? this.extraArgs, isCustom: isCustom ?? this.isCustom, + privateArgs: clearPrivateArgs ? null : (privateArgs ?? this.privateArgs), ); } diff --git a/packages/core/lib/src/models/browser.g.dart b/packages/core/lib/src/models/browser.g.dart index 2bff628..d1ac7e5 100644 --- a/packages/core/lib/src/models/browser.g.dart +++ b/packages/core/lib/src/models/browser.g.dart @@ -15,6 +15,9 @@ Browser _$BrowserFromJson(Map json) => Browser( (json['extraArgs'] as List?)?.map((e) => e as String).toList() ?? const [], isCustom: json['isCustom'] as bool? ?? false, + privateArgs: (json['privateArgs'] as List?) + ?.map((e) => e as String) + .toList(), ); Map _$BrowserToJson(Browser instance) => { @@ -24,4 +27,5 @@ Map _$BrowserToJson(Browser instance) => { 'iconPath': instance.iconPath, 'extraArgs': instance.extraArgs, 'isCustom': instance.isCustom, + 'privateArgs': instance.privateArgs, }; diff --git a/packages/core/lib/src/models/rule.dart b/packages/core/lib/src/models/rule.dart index 2d64c59..3b7bc1d 100644 --- a/packages/core/lib/src/models/rule.dart +++ b/packages/core/lib/src/models/rule.dart @@ -2,19 +2,49 @@ import 'package:json_annotation/json_annotation.dart'; part 'rule.g.dart'; +/// Matches any domain. Used by rules that key off the originating app alone, +/// e.g. "everything opened from Slack goes to Brave". +const kAnyDomain = '*'; + @JsonSerializable() final class Rule { - const Rule({required this.domain, required this.browserId}); + const Rule({ + required this.domain, + required this.browserId, + this.sourceApp, + this.private = false, + }); factory Rule.fromJson(Map json) => _$RuleFromJson(json); final String domain; final String browserId; + /// Identifier of the app the link came from, lower-cased — the executable + /// name on Windows ("slack") and the bundle id on macOS. Null means the rule + /// applies whatever the origin is. + final String? sourceApp; + + /// Whether matching links open in a private window. + final bool private; + + /// True when this rule only constrains the originating app. + bool get matchesAnyDomain => domain == kAnyDomain; + Map toJson() => _$RuleToJson(this); - Rule copyWith({String? domain, String? browserId}) => Rule( + /// [sourceApp] is nullable and meaningful when null, so clearing it needs an + /// explicit flag rather than the usual `?? this.x` idiom. + Rule copyWith({ + String? domain, + String? browserId, + String? sourceApp, + bool clearSourceApp = false, + bool? private, + }) => Rule( domain: domain ?? this.domain, browserId: browserId ?? this.browserId, + sourceApp: clearSourceApp ? null : (sourceApp ?? this.sourceApp), + private: private ?? this.private, ); } diff --git a/packages/core/lib/src/models/rule.g.dart b/packages/core/lib/src/models/rule.g.dart index d1eab91..3d6945f 100644 --- a/packages/core/lib/src/models/rule.g.dart +++ b/packages/core/lib/src/models/rule.g.dart @@ -9,9 +9,13 @@ part of 'rule.dart'; Rule _$RuleFromJson(Map json) => Rule( domain: json['domain'] as String, browserId: json['browserId'] as String, + sourceApp: json['sourceApp'] as String?, + private: json['private'] as bool? ?? false, ); Map _$RuleToJson(Rule instance) => { 'domain': instance.domain, 'browserId': instance.browserId, + 'sourceApp': instance.sourceApp, + 'private': instance.private, }; diff --git a/packages/core/lib/src/platform/handler_diagnostics.dart b/packages/core/lib/src/platform/handler_diagnostics.dart new file mode 100644 index 0000000..5142d18 --- /dev/null +++ b/packages/core/lib/src/platform/handler_diagnostics.dart @@ -0,0 +1,41 @@ +/// Snapshot of why link capture may not be working. +/// +/// Exists because "LinkUnbound stopped opening my links" has several unrelated +/// causes that look identical to the user, and every one of them was previously +/// invisible without reading the registry by hand. +final class HandlerDiagnostics { + const HandlerDiagnostics({ + required this.isDefaultBrowser, + required this.commandMatchesExecutable, + required this.runningFromDevBuild, + required this.isPackaged, + this.recordedCommand, + }); + + /// The OS reports this app as the handler for https. + final bool isDefaultBrowser; + + /// The registered handler points at the executable currently running. + /// False means links launch something else — typically a path left behind by + /// an older install or a local build. + final bool commandMatchesExecutable; + + /// This process runs from a build tree, so it must not own the registration. + final bool runningFromDevBuild; + + /// Running from an MSIX/Store package, where the association comes from the + /// package manifest rather than the registry. + final bool isPackaged; + + /// The handler command as recorded by the OS, for display. Null when the app + /// is not registered per-user, or on platforms without such a record. + final String? recordedCommand; + + /// True when nothing needs fixing. + bool get isHealthy => + isDefaultBrowser && (commandMatchesExecutable || isPackaged); + + /// True when re-registering would plausibly help. A build tree is excluded on + /// purpose: registering it would make things worse, not better. + bool get canRepair => !runningFromDevBuild && !commandMatchesExecutable; +} diff --git a/packages/core/lib/src/platform/inbound_event.dart b/packages/core/lib/src/platform/inbound_event.dart index 72d1202..8c6b934 100644 --- a/packages/core/lib/src/platform/inbound_event.dart +++ b/packages/core/lib/src/platform/inbound_event.dart @@ -7,25 +7,52 @@ sealed class InboundEvent { String encode() => jsonEncode(toJson()); - static InboundEvent decode(String raw) => - fromJson(jsonDecode(raw) as Map); + /// Decodes an event received over IPC. Every malformed shape must surface as + /// a [FormatException] — callers only catch that type, and a stray + /// [TypeError] would escape to the top-level error handler and be recorded + /// as a crash for what is really just a bad message. + static InboundEvent decode(String raw) { + final Object? decoded; + try { + decoded = jsonDecode(raw); + } on FormatException { + rethrow; + } + if (decoded is! Map) { + throw const FormatException('Inbound event is not a JSON object'); + } + return fromJson(decoded); + } static InboundEvent fromJson(Map json) => - switch (json['action'] as String?) { - 'open_url' => OpenUrlEvent(json['url'] as String), + switch (json['action']) { + 'open_url' when json['url'] is String => OpenUrlEvent( + json['url'] as String, + sourceApp: json['sourceApp'] as String?, + ), 'show_settings' => const ShowSettingsEvent(), _ => throw FormatException( - 'Unknown inbound event action: ${json['action']}', + 'Unknown or malformed inbound event: ${json['action']}', ), }; } final class OpenUrlEvent extends InboundEvent { - const OpenUrlEvent(this.url); + const OpenUrlEvent(this.url, {this.sourceApp}); + final String url; + /// App the link came from, lower-cased. Resolved by the process the shell + /// launched — which is the only one that can see it — and carried across the + /// IPC hop, since the resident instance has no way to work it out itself. + final String? sourceApp; + @override - Map toJson() => {'action': 'open_url', 'url': url}; + Map toJson() => { + 'action': 'open_url', + 'url': url, + if (sourceApp != null) 'sourceApp': sourceApp, + }; } final class ShowSettingsEvent extends InboundEvent { diff --git a/packages/core/lib/src/platform/registration_service.dart b/packages/core/lib/src/platform/registration_service.dart index 98f9a2a..713f1be 100644 --- a/packages/core/lib/src/platform/registration_service.dart +++ b/packages/core/lib/src/platform/registration_service.dart @@ -1,8 +1,29 @@ +import 'handler_diagnostics.dart'; + abstract interface class RegistrationService { + /// Reports why link capture may be broken, so Settings can explain the + /// problem and offer a one-click repair instead of leaving the user to + /// inspect the registry. + Future diagnose(String executablePath); + Future register(String executablePath); + /// Reconciles the recorded registration with the running installation, and + /// writes only when they differ. Called on every launch so a moved, updated + /// or reinstalled app repairs its own handler instead of pointing at an + /// executable that no longer exists. + Future ensureRegistered(String executablePath); + Future unregister(); + /// Enables or disables interception of the `microsoft-edge:` protocol, which + /// Microsoft apps (Teams, Outlook, Widgets, Copilot, Start search) use for + /// their links instead of plain `https:`. Windows-only; a no-op elsewhere. + Future setEdgeProtocolCapture(bool enabled, String executablePath); + + /// Whether `microsoft-edge:` links currently reach this app. + Future get capturesEdgeProtocol; + Future get isDefault; Future> get defaultAssociations; diff --git a/packages/core/lib/src/private_mode.dart b/packages/core/lib/src/private_mode.dart new file mode 100644 index 0000000..e96bc7b --- /dev/null +++ b/packages/core/lib/src/private_mode.dart @@ -0,0 +1,36 @@ +/// Command-line switches that open a browser directly in a private window. +/// +/// There is no cross-browser standard here: each family invented its own +/// spelling, and a wrong switch is not ignored — Chromium treats an unknown +/// `--flag` as a URL-ish argument and Firefox opens an error page. So the +/// mapping is explicit and anything unrecognised opts out rather than guessing. +library; + +/// Executable (or bundle) name fragments mapped to their private-mode switch. +/// +/// Order matters: `msedge` must be tested before the generic Chromium list +/// because Edge is Chromium-based but spells the switch differently. +const _privateSwitches = <(List markers, String flag)>[ + (['msedge', 'microsoft edge'], '-inprivate'), + (['firefox', 'librewolf', 'waterfox', 'zen'], '-private-window'), + (['opera'], '--private'), + ( + ['chrome', 'chromium', 'brave', 'vivaldi', 'thorium', 'ungoogled', 'arc'], + '--incognito', + ), +]; + +/// Returns the arguments that make [executablePath] start in a private window, +/// or an empty list when the browser is not known to support it from the +/// command line (Safari, for one, offers no such switch). +List privateModeArgs(String executablePath) { + final needle = executablePath.toLowerCase(); + for (final (markers, flag) in _privateSwitches) { + if (markers.any(needle.contains)) return [flag]; + } + return const []; +} + +/// Whether a private window can be requested for [executablePath]. +bool supportsPrivateMode(String executablePath) => + privateModeArgs(executablePath).isNotEmpty; diff --git a/packages/core/lib/src/services/launch_service.dart b/packages/core/lib/src/services/launch_service.dart index 2991040..b7e5e3e 100644 --- a/packages/core/lib/src/services/launch_service.dart +++ b/packages/core/lib/src/services/launch_service.dart @@ -1,7 +1,15 @@ abstract interface class LaunchService { + /// Opens [url] in the browser at [executablePath]. + /// + /// [privateArgs] is passed separately rather than folded into [extraArgs] + /// because a private window is not merely an extra switch on macOS: `open` + /// hands arguments to an already-running instance only when a new one is + /// forced, so the platform layer has to know that this launch is private. + /// Empty means a normal window. Future launch( String executablePath, String url, - List extraArgs, - ); + List extraArgs, { + List privateArgs = const [], + }); } diff --git a/packages/core/lib/src/services/log_service.dart b/packages/core/lib/src/services/log_service.dart index fa0c1cd..78ab2b4 100644 --- a/packages/core/lib/src/services/log_service.dart +++ b/packages/core/lib/src/services/log_service.dart @@ -10,7 +10,15 @@ StreamSubscription? _logSubscription; // Kept open for the session lifetime to avoid reopening the fd on every record. RandomAccessFile? _logRaf; -final _urlPattern = RegExp(r'https?://[^\s,\]\)]+', caseSensitive: false); +// Any scheme, not just http(s): an unrecognised scheme is exactly the case +// worth redacting. `file://` is excluded so the path pattern below handles it, +// which redacts the filesystem path rather than the URL shape. +// The lookbehind matters: without it the engine simply starts one character +// later and matches `ile://` out of `file://`, defeating the exclusion. +final _urlPattern = RegExp( + r'(? r.domain != rule.domain), rule]; + _rules = [ + ..._rules.where( + (r) => !(r.domain == rule.domain && r.sourceApp == rule.sourceApp), + ), + rule, + ]; } - void removeRule(String domain) { - _rules = _rules.where((r) => r.domain != domain).toList(); + void removeRule(String domain, {String? sourceApp}) { + _rules = _rules + .where((r) => !(r.domain == domain && r.sourceApp == sourceApp)) + .toList(); } - void updateRule(String domain, {required String browserId}) { + void updateRule( + String domain, { + required String browserId, + String? sourceApp, + bool? private, + }) { _rules = [ for (final r in _rules) - if (r.domain == domain) r.copyWith(browserId: browserId) else r, + if (r.domain == domain && r.sourceApp == sourceApp) + r.copyWith(browserId: browserId, private: private) + else + r, ]; } - String? lookupBrowser(String url) { + String? lookupBrowser(String url, {String? sourceApp}) => + lookupRule(url, sourceApp: sourceApp)?.browserId; + + /// Finds the rule that governs [url] when opened from [sourceApp]. + /// + /// A rule naming the originating app always wins over one that does not, + /// even if the latter matches the domain more precisely: "everything from + /// Slack in Brave" is a deliberate statement about the source, and a generic + /// domain rule should not silently override it. + Rule? lookupRule(String url, {String? sourceApp}) { final uri = Uri.tryParse(url); - if (uri == null || uri.host.isEmpty) return null; - return _lookupHierarchical(uri.host); - } + final host = uri?.host ?? ''; + final app = sourceApp?.toLowerCase(); - String? _lookupHierarchical(String host) { - final exact = _rules.where((r) => r.domain == host).firstOrNull; - if (exact != null) return exact.browserId; + if (app != null) { + final scoped = _rules.where((r) => r.sourceApp?.toLowerCase() == app); + final byDomain = _matchHost(scoped, host); + if (byDomain != null) return byDomain; + final anyDomain = scoped.where((r) => r.matchesAnyDomain).firstOrNull; + if (anyDomain != null) return anyDomain; + } + + final unscoped = _rules.where((r) => r.sourceApp == null); + final byDomain = _matchHost(unscoped, host); + if (byDomain != null) return byDomain; + return unscoped.where((r) => r.matchesAnyDomain).firstOrNull; + } - final dotIndex = host.indexOf('.'); - if (dotIndex < 0 || dotIndex == host.length - 1) return null; + /// Walks up the domain hierarchy: a rule for `example.com` also covers + /// `docs.example.com`. + Rule? _matchHost(Iterable candidates, String host) { + if (host.isEmpty) return null; + var current = host; + while (true) { + final exact = candidates.where((r) => r.domain == current).firstOrNull; + if (exact != null) return exact; - return _lookupHierarchical(host.substring(dotIndex + 1)); + final dotIndex = current.indexOf('.'); + if (dotIndex < 0 || dotIndex == current.length - 1) return null; + current = current.substring(dotIndex + 1); + } } } diff --git a/packages/core/lib/src/services/update_service.dart b/packages/core/lib/src/services/update_service.dart index c1ac00d..3204e4f 100644 --- a/packages/core/lib/src/services/update_service.dart +++ b/packages/core/lib/src/services/update_service.dart @@ -15,8 +15,8 @@ final class UpdateService { final String repo; Future checkForUpdate(String currentVersion) async { + final client = HttpClient(); try { - final client = HttpClient(); client.connectionTimeout = const Duration(seconds: 5); final request = await client.getUrl( @@ -26,18 +26,19 @@ final class UpdateService { request.headers.set('User-Agent', 'LinkUnbound/$currentVersion'); final response = await request.close(); - if (response.statusCode != 200) { - client.close(); - return null; - } + if (response.statusCode != 200) return null; final body = await response.transform(utf8.decoder).join(); - client.close(); - final json = jsonDecode(body) as Map; - final tagName = json['tag_name'] as String?; - final htmlUrl = json['html_url'] as String?; + final decoded = jsonDecode(body); + if (decoded is! Map) return null; + final tagName = decoded['tag_name'] as String?; + final htmlUrl = decoded['html_url'] as String?; if (tagName == null || htmlUrl == null) return null; + // The release URL ends up in a shell "open" call, so it must not be + // taken on trust from the response body: a compromised or intercepted + // endpoint could otherwise point it at an executable. + if (!isTrustedReleaseUrl(htmlUrl)) return null; final version = tagName.startsWith('v') ? tagName.substring(1) : tagName; @@ -46,9 +47,20 @@ final class UpdateService { return UpdateInfo(latestVersion: version, releaseUrl: htmlUrl); } on Exception { return null; + } finally { + // Previously leaked on every error path. + client.close(); } } + /// True only for HTTPS URLs served by github.com itself. + static bool isTrustedReleaseUrl(String raw) { + final uri = Uri.tryParse(raw); + if (uri == null || uri.scheme != 'https') return false; + final host = uri.host.toLowerCase(); + return host == 'github.com' || host.endsWith('.github.com'); + } + static bool _isNewer(String latest, String current) { final partsL = latest.split('.').map(int.tryParse).toList(); final partsC = current.split('.').map(int.tryParse).toList(); diff --git a/packages/core/lib/src/url_utils.dart b/packages/core/lib/src/url_utils.dart index dd19035..3c29273 100644 --- a/packages/core/lib/src/url_utils.dart +++ b/packages/core/lib/src/url_utils.dart @@ -25,13 +25,32 @@ String unwrapSafeLink(String raw) { host == 'statics.teams.cdn.office.net'; if (!isSafeLink) return raw; + // `queryParameters` already percent-decodes; decoding again would resolve a + // double-encoded `%2520` into a real character and change the destination. final inner = uri.queryParameters['url']; if (inner == null || inner.isEmpty) return raw; - final decoded = Uri.decodeFull(inner); - final innerUri = Uri.tryParse(decoded); + final innerUri = Uri.tryParse(inner); if (innerUri == null) return raw; if (innerUri.scheme != 'http' && innerUri.scheme != 'https') return raw; - return decoded; + return inner; +} + +/// Schemes LinkUnbound is willing to hand to a browser process. +const _launchableSchemes = {'http', 'https', 'file'}; + +/// Guards the boundary between an untrusted inbound URL and `Process.start`. +/// +/// Browsers treat any argv entry starting with `-` (or `/` on Windows) as a +/// switch, so a crafted "URL" such as `--gpu-launcher=calc.exe` would make the +/// browser execute an arbitrary binary. Callers must reject anything this +/// returns false for before it reaches a launcher or the picker. +bool isLaunchableUrl(String raw) { + if (raw.isEmpty) return false; + if (raw.startsWith('-') || raw.startsWith('/') || raw.startsWith(r'\')) { + return false; + } + final scheme = Uri.tryParse(raw)?.scheme.toLowerCase(); + return scheme != null && _launchableSchemes.contains(scheme); } diff --git a/packages/core/test/inbound_event_test.dart b/packages/core/test/inbound_event_test.dart index 0d81341..e94b52a 100644 --- a/packages/core/test/inbound_event_test.dart +++ b/packages/core/test/inbound_event_test.dart @@ -48,5 +48,35 @@ void main() { throwsA(isA()), ); }); + + // Anything arriving over IPC is untrusted. These used to raise TypeError, + // which is not a FormatException, so it escaped the caller's catch and was + // recorded as a crash instead of a discarded message. + test('non-object JSON throws FormatException', () { + expect(() => InboundEvent.decode('[]'), throwsA(isA())); + expect(() => InboundEvent.decode('5'), throwsA(isA())); + expect( + () => InboundEvent.decode('"text"'), + throwsA(isA()), + ); + }); + + test('open_url with a non-string url throws FormatException', () { + expect( + () => InboundEvent.decode('{"action":"open_url","url":1}'), + throwsA(isA()), + ); + expect( + () => InboundEvent.decode('{"action":"open_url"}'), + throwsA(isA()), + ); + }); + + test('malformed JSON throws FormatException', () { + expect( + () => InboundEvent.decode('{not json'), + throwsA(isA()), + ); + }); }); } diff --git a/packages/core/test/private_mode_test.dart b/packages/core/test/private_mode_test.dart new file mode 100644 index 0000000..d0cd38d --- /dev/null +++ b/packages/core/test/private_mode_test.dart @@ -0,0 +1,81 @@ +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('privateModeArgs', () { + test('uses --incognito for Chromium-family browsers', () { + for (final path in [ + r'C:\Program Files\Google\Chrome\Application\chrome.exe', + r'C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe', + r'C:\Users\me\AppData\Local\Vivaldi\Application\vivaldi.exe', + '/Applications/Chromium.app', + ]) { + expect(privateModeArgs(path), ['--incognito'], reason: path); + } + }); + + test('uses -inprivate for Edge', () { + // Edge is Chromium-based but spells the switch differently, so it must + // be matched before the generic Chromium markers. + expect( + privateModeArgs( + r'C:\Program Files\Microsoft\Edge\Application\msedge.exe', + ), + ['-inprivate'], + ); + }); + + test('uses -private-window for the Firefox family', () { + expect(privateModeArgs(r'C:\Program Files\Mozilla Firefox\firefox.exe'), [ + '-private-window', + ]); + expect(privateModeArgs('/Applications/LibreWolf.app'), [ + '-private-window', + ]); + }); + + test('returns nothing for browsers without a switch', () { + // Safari has no command-line private mode; guessing one would open a + // normal window with a stray argument. + expect(privateModeArgs('/Applications/Safari.app'), isEmpty); + expect(supportsPrivateMode('/Applications/Safari.app'), isFalse); + }); + + test('is case-insensitive', () { + expect(privateModeArgs(r'C:\FIREFOX\FIREFOX.EXE'), ['-private-window']); + }); + }); + + group('Browser private mode', () { + Browser browserAt(String path, {List? privateArgs}) => Browser( + id: 'b', + name: 'B', + executablePath: path, + iconPath: '', + privateArgs: privateArgs, + ); + + test('derives args from the executable when not overridden', () { + final b = browserAt(r'C:\Program Files\Mozilla Firefox\firefox.exe'); + expect(b.resolvedPrivateArgs, ['-private-window']); + expect(b.canOpenPrivately, isTrue); + }); + + test('an explicit empty list opts out', () { + final b = browserAt(r'C:\chrome.exe', privateArgs: const []); + expect(b.canOpenPrivately, isFalse); + }); + + test('an explicit list overrides the derived one', () { + final b = browserAt(r'C:\chrome.exe', privateArgs: const ['--guest']); + expect(b.resolvedPrivateArgs, ['--guest']); + }); + + test('survives a JSON round-trip', () { + final b = browserAt(r'C:\chrome.exe', privateArgs: const ['--guest']); + expect(Browser.fromJson(b.toJson()).resolvedPrivateArgs, ['--guest']); + final derived = browserAt(r'C:\chrome.exe'); + expect(Browser.fromJson(derived.toJson()).privateArgs, isNull); + }); + }); +} diff --git a/packages/core/test/rule_source_app_test.dart b/packages/core/test/rule_source_app_test.dart new file mode 100644 index 0000000..e03cbb0 --- /dev/null +++ b/packages/core/test/rule_source_app_test.dart @@ -0,0 +1,173 @@ +import 'dart:io'; + +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:test/test.dart'; + +void main() { + late Directory tmp; + late RuleService service; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('lu_rule_source_'); + service = RuleService(rulesFile: File('${tmp.path}/rules.json')); + }); + + tearDown(() async { + if (tmp.existsSync()) await tmp.delete(recursive: true); + }); + + group('lookupRule with an originating app', () { + test('an app-scoped any-domain rule matches every link from that app', () { + service.addRule( + const Rule(domain: kAnyDomain, browserId: 'brave', sourceApp: 'slack'), + ); + expect( + service.lookupBrowser('https://anything.example', sourceApp: 'slack'), + 'brave', + ); + }); + + test('does not apply to links from other apps', () { + service.addRule( + const Rule(domain: kAnyDomain, browserId: 'brave', sourceApp: 'slack'), + ); + expect( + service.lookupBrowser('https://anything.example', sourceApp: 'teams'), + isNull, + ); + expect(service.lookupBrowser('https://anything.example'), isNull); + }); + + test('an app-scoped rule wins over a domain rule', () { + // "Everything from Slack in Brave" is a deliberate statement about the + // source; a generic domain rule must not quietly override it. + service + ..addRule(const Rule(domain: 'github.com', browserId: 'firefox')) + ..addRule( + const Rule( + domain: kAnyDomain, + browserId: 'brave', + sourceApp: 'slack', + ), + ); + expect( + service.lookupBrowser('https://github.com/x', sourceApp: 'slack'), + 'brave', + ); + expect(service.lookupBrowser('https://github.com/x'), 'firefox'); + }); + + test('a domain rule scoped to the app beats its any-domain rule', () { + service + ..addRule( + const Rule( + domain: kAnyDomain, + browserId: 'brave', + sourceApp: 'slack', + ), + ) + ..addRule( + const Rule( + domain: 'github.com', + browserId: 'firefox', + sourceApp: 'slack', + ), + ); + expect( + service.lookupBrowser('https://github.com/x', sourceApp: 'slack'), + 'firefox', + ); + expect( + service.lookupBrowser('https://other.example', sourceApp: 'slack'), + 'brave', + ); + }); + + test('matching the origin is case-insensitive', () { + service.addRule( + const Rule(domain: kAnyDomain, browserId: 'brave', sourceApp: 'slack'), + ); + expect( + service.lookupBrowser('https://x.example', sourceApp: 'Slack'), + 'brave', + ); + }); + + test('subdomains still inherit within an app scope', () { + service.addRule( + const Rule( + domain: 'example.com', + browserId: 'firefox', + sourceApp: 'slack', + ), + ); + expect( + service.lookupBrowser('https://docs.example.com/a', sourceApp: 'slack'), + 'firefox', + ); + }); + }); + + group('rule identity', () { + test('domain and origin together form the key', () { + // Otherwise adding the app-scoped rule would silently replace the + // domain one. + service + ..addRule(const Rule(domain: 'github.com', browserId: 'firefox')) + ..addRule( + const Rule( + domain: 'github.com', + browserId: 'brave', + sourceApp: 'slack', + ), + ); + expect(service.rules, hasLength(2)); + }); + + test('removing targets only the matching scope', () { + service + ..addRule(const Rule(domain: 'github.com', browserId: 'firefox')) + ..addRule( + const Rule( + domain: 'github.com', + browserId: 'brave', + sourceApp: 'slack', + ), + ) + ..removeRule('github.com', sourceApp: 'slack'); + expect(service.rules, hasLength(1)); + expect(service.rules.single.sourceApp, isNull); + }); + }); + + group('persistence', () { + test('origin and private flag survive a save/load cycle', () async { + service.addRule( + const Rule( + domain: kAnyDomain, + browserId: 'brave', + sourceApp: 'slack', + private: true, + ), + ); + await service.save(); + + final reloaded = RuleService(rulesFile: File('${tmp.path}/rules.json')); + await reloaded.load(); + final rule = reloaded.rules.single; + expect(rule.sourceApp, 'slack'); + expect(rule.private, isTrue); + expect(rule.matchesAnyDomain, isTrue); + }); + + test('rules written before this feature still load', () async { + await File( + '${tmp.path}/rules.json', + ).writeAsString('[{"domain":"github.com","browserId":"firefox"}]'); + await service.load(); + expect(service.rules.single.sourceApp, isNull); + expect(service.rules.single.private, isFalse); + expect(service.lookupBrowser('https://github.com/x'), 'firefox'); + }); + }); +} diff --git a/packages/core/test/update_service_test.dart b/packages/core/test/update_service_test.dart index ae0f89a..530c356 100644 --- a/packages/core/test/update_service_test.dart +++ b/packages/core/test/update_service_test.dart @@ -103,7 +103,7 @@ void main() { test('fields are accessible after construction', () { const info = UpdateInfo( latestVersion: '1.0.0', - releaseUrl: 'https://example.com', + releaseUrl: 'https://github.com/o/r/releases/tag/x', ); expect(info.latestVersion, isNotEmpty); expect(info.releaseUrl, isNotEmpty); @@ -147,10 +147,39 @@ void main() { expect(result.releaseUrl, 'https://github.com/o/r/releases/tag/v2.0.0'); }); + test('returns null when html_url is not a github.com URL', () async { + // The release URL is handed to the shell, so an untrusted host must not + // survive the parse even if the version looks newer. + final result = await _check( + status: 200, + body: { + 'tag_name': 'v2.0.0', + 'html_url': 'https://evil.example.com/payload.exe', + }, + current: '1.0.0', + ); + expect(result, isNull); + }); + + test('returns null when html_url uses a non-https scheme', () async { + final result = await _check( + status: 200, + body: { + 'tag_name': 'v2.0.0', + 'html_url': 'file://attacker/share/payload.exe', + }, + current: '1.0.0', + ); + expect(result, isNull); + }); + test('strips v prefix from tag_name', () async { final result = await _check( status: 200, - body: {'tag_name': 'v1.5.0', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v1.5.0', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.0', ); expect(result!.latestVersion, '1.5.0'); @@ -159,7 +188,10 @@ void main() { test('accepts tag_name without v prefix', () async { final result = await _check( status: 200, - body: {'tag_name': '1.5.0', 'html_url': 'https://example.com'}, + body: { + 'tag_name': '1.5.0', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.0', ); expect(result!.latestVersion, '1.5.0'); @@ -168,7 +200,10 @@ void main() { test('returns null when version is equal to current', () async { final result = await _check( status: 200, - body: {'tag_name': 'v1.0.0', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v1.0.0', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.0', ); expect(result, isNull); @@ -177,7 +212,10 @@ void main() { test('returns null when latest is older than current', () async { final result = await _check( status: 200, - body: {'tag_name': 'v0.9.9', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v0.9.9', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.0', ); expect(result, isNull); @@ -191,7 +229,10 @@ void main() { test('returns null when tag_name is null', () async { final result = await _check( status: 200, - body: {'tag_name': null, 'html_url': 'https://example.com'}, + body: { + 'tag_name': null, + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.0', ); expect(result, isNull); @@ -209,7 +250,10 @@ void main() { test('minor version bump triggers update', () async { final result = await _check( status: 200, - body: {'tag_name': 'v1.1.0', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v1.1.0', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.9', ); expect(result, isNotNull); @@ -218,7 +262,10 @@ void main() { test('patch version bump triggers update', () async { final result = await _check( status: 200, - body: {'tag_name': 'v1.0.1', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v1.0.1', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.0.0', ); expect(result, isNotNull); @@ -227,7 +274,10 @@ void main() { test('major rollback returns null', () async { final result = await _check( status: 200, - body: {'tag_name': 'v2.0.0', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v2.0.0', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '3.0.0', ); expect(result, isNull); @@ -236,7 +286,10 @@ void main() { test('minor rollback returns null', () async { final result = await _check( status: 200, - body: {'tag_name': 'v1.0.0', 'html_url': 'https://example.com'}, + body: { + 'tag_name': 'v1.0.0', + 'html_url': 'https://github.com/o/r/releases/tag/x', + }, current: '1.1.0', ); expect(result, isNull); diff --git a/packages/core/test/url_utils_test.dart b/packages/core/test/url_utils_test.dart index b0ddabc..b2c3349 100644 --- a/packages/core/test/url_utils_test.dart +++ b/packages/core/test/url_utils_test.dart @@ -110,4 +110,50 @@ void main() { expect(unwrapSafeLink(malformed), malformed); }); }); + + group('isLaunchableUrl', () { + test('accepts http and https', () { + expect(isLaunchableUrl('http://example.com'), isTrue); + expect(isLaunchableUrl('https://example.com/a?b=1'), isTrue); + }); + + test('accepts file URLs', () { + expect(isLaunchableUrl('file:///C:/tmp/page.html'), isTrue); + }); + + test('rejects Chromium switches disguised as URLs', () { + // These would be handed to the browser as argv and executed as switches. + expect(isLaunchableUrl('--gpu-launcher=calc.exe'), isFalse); + expect(isLaunchableUrl('--utility-cmd-prefix=calc.exe'), isFalse); + expect(isLaunchableUrl('-foo'), isFalse); + expect(isLaunchableUrl('/prefetch:1'), isFalse); + }); + + test('rejects dangerous schemes', () { + expect(isLaunchableUrl('javascript:alert(1)'), isFalse); + expect(isLaunchableUrl('data:text/html,