From cc910b35085d566968a8455320ffbca8ea77606a Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 16:40:44 -0400 Subject: [PATCH 1/6] feat: enhance release workflow with build provenance and Scoop bucket update --- .github/workflows/release.yml | 67 +++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea55830..26b923a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,11 @@ jobs: timeout-minutes: 10 name: Create GitHub Release + permissions: + contents: write + id-token: write + attestations: write + steps: - uses: actions/checkout@v6 with: @@ -84,6 +89,13 @@ jobs: - name: List artifacts run: find artifacts/ -type f + - uses: actions/attest-build-provenance@v4 + with: + subject-path: | + artifacts/windows/**/*_Setup.exe + artifacts/windows/**/*_store.msix* + artifacts/macos/**/*.dmg + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: @@ -220,3 +232,58 @@ jobs: run: | msstore publish "${{ steps.find_msix.outputs.MSIX_PATH }}" \ --appId "${{ vars.STORE_APP_ID }}" + + update-scoop-bucket: + runs-on: ubuntu-latest + needs: github-release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + timeout-minutes: 5 + name: Update Scoop Bucket + + steps: + - name: Update Scoop Bucket + env: + GH_TOKEN: ${{ secrets.GIST_TOKEN }} + run: | + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#v}" + BASE="https://github.com/${{ github.repository }}/releases/download/${TAG}" + SETUP="LinkUnbound_${VERSION}_x64_Setup.exe" + + if [[ "$VERSION" == *-* ]]; then + NAME="linkunbound-beta"; SUFFIX=" (beta)" + else + NAME="linkunbound"; SUFFIX="" + fi + + curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}" + SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') + + git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/scoop-bucket.git" /tmp/bucket + cd /tmp/bucket + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + mkdir -p bucket + + jq -n \ + --arg version "$VERSION" \ + --arg desc "Smart browser router for HTTP(S) links${SUFFIX}" \ + --arg home "https://github.com/${{ github.repository }}" \ + --arg url "${BASE}/${SETUP}" \ + --arg hash "$SHA" \ + '{ + version: $version, + description: $desc, + homepage: $home, + license: "GPL-3.0-only", + architecture: {"64bit": {url: $url, hash: $hash}}, + innosetup: true, + extract_dir: "{app}", + shortcuts: [["linkunbound.exe", "LinkUnbound"]] + }' > "bucket/${NAME}.json" + + git add "bucket/${NAME}.json" + git commit -m "${NAME} ${VERSION}" + git push origin main + + echo "Scoop bucket updated: ${NAME} → ${VERSION}" From f36a8e57b352130858e7e49b7f18789fd4923a7e Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 17:28:42 -0400 Subject: [PATCH 2/6] feat: enhance Edge protocol handling and add safe link unwrapping tests --- .../windows/win_registration_service.dart | 34 +++++++++++++++++-- packages/core/lib/src/url_utils.dart | 21 +++++++++--- packages/core/test/url_utils_test.dart | 25 ++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/apps/linkunbound/lib/platform/windows/win_registration_service.dart b/apps/linkunbound/lib/platform/windows/win_registration_service.dart index 74d73f3..3759651 100644 --- a/apps/linkunbound/lib/platform/windows/win_registration_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_registration_service.dart @@ -151,11 +151,19 @@ final class WinRegistrationService implements RegistrationService { _deleteKeyTree(r'Software\Classes\LinkUnboundURL'); _deleteKeyTree(r'Software\Clients\StartMenuInternet\LinkUnbound'); _deleteKeyTree(r'Software\LinkUnbound'); + _removeEdgeProtocolCapture(); _removeRegisteredApplication(); _removeOpenWithProgIds(); _notifyShell(); } + void _removeEdgeProtocolCapture() { + _deleteKeyTree(r'Software\Classes\LinkUnboundEdgeProto'); + if (_ownsEdgeProtocol) { + _deleteKeyTree(_edgeProtocolPath); + } + } + /// The `shell\open\command` value currently recorded for our ProgId, or null /// when the app is not registered per-user. String? _readRegisteredCommand() { @@ -200,10 +208,15 @@ final class WinRegistrationService implements RegistrationService { } final quotedExe = '"${executablePath.replaceAll('/', '\\')}"'; _writeEdgeProtocolProgId(quotedExe); + if (_edgeProtocolOverriddenByUserChoice) { + _log.warning( + 'Edge protocol keys written but a UserChoice association overrides ' + 'them: packaged callers such as Teams will not reach the picker', + ); + } _log.info('Edge protocol capture enabled'); } else { - _deleteKeyTree(r'Software\Classes\LinkUnboundEdgeProto'); - _deleteKeyTree(r'Software\Classes\microsoft-edge'); + _removeEdgeProtocolCapture(); _log.info('Edge protocol capture disabled'); } _notifyShell(); @@ -212,10 +225,20 @@ final class WinRegistrationService implements RegistrationService { @override Future get capturesEdgeProtocol async { if (isRunningInMsix()) return false; + return _ownsEdgeProtocol && !_edgeProtocolOverriddenByUserChoice; + } + + static const _edgeProtocolPath = r'Software\Classes\microsoft-edge'; + + static const _edgeProtocolUserChoicePath = + r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations' + r'\microsoft-edge\UserChoice'; + + bool get _ownsEdgeProtocol { try { final key = Registry.openPath( RegistryHive.currentUser, - path: r'Software\Classes\microsoft-edge\shell\open\command', + path: '$_edgeProtocolPath\\shell\\open\\command', ); final command = key.getValueAsString(''); key.close(); @@ -225,6 +248,11 @@ final class WinRegistrationService implements RegistrationService { } } + bool get _edgeProtocolOverriddenByUserChoice { + final progId = _readUserChoiceProgId(_edgeProtocolUserChoicePath); + return progId != null && !progIdMatchesLinkUnbound(progId); + } + void _writeEdgeProtocolProgId(String quotedExe) { final classes = Registry.openPath( RegistryHive.currentUser, diff --git a/packages/core/lib/src/url_utils.dart b/packages/core/lib/src/url_utils.dart index 3c29273..8cf7e6c 100644 --- a/packages/core/lib/src/url_utils.dart +++ b/packages/core/lib/src/url_utils.dart @@ -15,15 +15,26 @@ String stripEdgeProtocol(String raw) { return raw; } +const _safeLinkHosts = { + 'statics.teams.cdn.office.net', + 'teams.public.onecdn.static.microsoft', +}; + +const _safeLinkPath = '/evergreen-assets/safelinks/'; + +bool _servesSafeLinks(Uri uri) { + final host = uri.host.toLowerCase(); + if (host.endsWith('.safelinks.protection.outlook.com')) return true; + if (_safeLinkHosts.contains(host)) return true; + return uri.path.toLowerCase().startsWith(_safeLinkPath) && + (host.endsWith('.microsoft') || host.endsWith('.office.net')); +} + String unwrapSafeLink(String raw) { final uri = Uri.tryParse(raw); if (uri == null) return raw; - final host = uri.host.toLowerCase(); - final isSafeLink = - host.endsWith('.safelinks.protection.outlook.com') || - host == 'statics.teams.cdn.office.net'; - if (!isSafeLink) return raw; + if (!_servesSafeLinks(uri)) return raw; // `queryParameters` already percent-decodes; decoding again would resolve a // double-encoded `%2520` into a real character and change the destination. diff --git a/packages/core/test/url_utils_test.dart b/packages/core/test/url_utils_test.dart index b2c3349..7c042d5 100644 --- a/packages/core/test/url_utils_test.dart +++ b/packages/core/test/url_utils_test.dart @@ -88,6 +88,31 @@ void main() { expect(unwrapSafeLink(wrapped), inner); }); + test('unwraps the current Teams SafeLinks CDN', () { + const inner = 'https://gitlab.example.tech/group/project/-/pipelines/12'; + final wrapped = + 'https://teams.public.onecdn.static.microsoft/evergreen-assets/' + 'safelinks/2/atp-safelinks.html' + '?url=${Uri.encodeComponent(inner)}&locale=es-mx' + '&dest=${Uri.encodeComponent('https://teams.microsoft.com/api/mt')}'; + expect(unwrapSafeLink(wrapped), inner); + }); + + test('unwraps a renamed CDN host via the interstitial path', () { + const inner = 'https://example.com/doc'; + final wrapped = + 'https://teams.future.cdn.microsoft/evergreen-assets/safelinks/2/' + 'atp-safelinks.html?url=${Uri.encodeComponent(inner)}'; + expect(unwrapSafeLink(wrapped), inner); + }); + + test('does not unwrap the interstitial path on a non-Microsoft host', () { + final wrapped = + 'https://evil.example.com/evergreen-assets/safelinks/2/' + 'atp-safelinks.html?url=${Uri.encodeComponent('https://x.com')}'; + expect(unwrapSafeLink(wrapped), wrapped); + }); + test('returns original when inner url parameter is missing', () { const wrapped = 'https://nam12.safelinks.protection.outlook.com/?other=1'; expect(unwrapSafeLink(wrapped), wrapped); From 3f0c899c58e2bd21f5a08d51b566d4a8aa77aae9 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 18:02:57 -0400 Subject: [PATCH 3/6] feat: add retry logic for asset downloads and improve git push handling in release workflows --- .github/workflows/release.yml | 61 +++++++++++++++++++++++++---- apps/linkunbound/lib/bootstrap.dart | 5 +++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26b923a..4c9c00f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,15 @@ jobs: DMG_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DMG_NAME}" echo "Downloading DMG to compute SHA256..." - curl -fSL -o "/tmp/${DMG_NAME}" "${DMG_URL}" + for attempt in 1 2 3 4 5; do + curl -fSL -o "/tmp/${DMG_NAME}" "${DMG_URL}" && break + echo "Asset not downloadable yet (attempt ${attempt}/5); retrying..." + sleep $((attempt * 10)) + done + if [[ ! -s "/tmp/${DMG_NAME}" ]]; then + echo "Could not download ${DMG_NAME}" + exit 1 + fi DMG_SHA256=$(sha256sum "/tmp/${DMG_NAME}" | awk '{print $1}') rm -f "/tmp/${DMG_NAME}" @@ -176,10 +184,24 @@ jobs: CASK_EOF git add "${CASK_FILE}" + if git diff --cached --quiet; then + echo "Cask already up to date; nothing to publish" + exit 0 + fi git commit -m "Update ${CASK_NAME} to ${VERSION}" - git push origin main - echo "Homebrew Tap updated: cask ${CASK_NAME} → ${VERSION}" + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + echo "Homebrew Tap updated: cask ${CASK_NAME} → ${VERSION}" + exit 0 + fi + echo "Push rejected (attempt ${attempt}/5); rebasing onto concurrent release..." + git fetch origin main && git rebase origin/main + sleep $((RANDOM % 5 + 3)) + done + + echo "Could not push ${CASK_NAME} ${VERSION} after 5 attempts" + exit 1 publish-to-store: runs-on: windows-latest @@ -256,7 +278,15 @@ jobs: NAME="linkunbound"; SUFFIX="" fi - curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}" + for attempt in 1 2 3 4 5; do + curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}" && break + echo "Asset not downloadable yet (attempt ${attempt}/5); retrying..." + sleep $((attempt * 10)) + done + if [[ ! -s "/tmp/${SETUP}" ]]; then + echo "Could not download ${SETUP} from ${BASE}" + exit 1 + fi SHA=$(sha256sum "/tmp/${SETUP}" | awk '{print $1}') git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/scoop-bucket.git" /tmp/bucket @@ -279,11 +309,28 @@ jobs: architecture: {"64bit": {url: $url, hash: $hash}}, innosetup: true, extract_dir: "{app}", - shortcuts: [["linkunbound.exe", "LinkUnbound"]] + shortcuts: [["linkunbound.exe", "LinkUnbound"]], + post_install: [ + "Start-Process -FilePath \"$dir\\linkunbound.exe\" -ArgumentList \"--register\" -Wait" + ] }' > "bucket/${NAME}.json" git add "bucket/${NAME}.json" + if git diff --cached --quiet; then + echo "Manifest already up to date; nothing to publish" + exit 0 + fi git commit -m "${NAME} ${VERSION}" - git push origin main - echo "Scoop bucket updated: ${NAME} → ${VERSION}" + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + echo "Scoop bucket updated: ${NAME} → ${VERSION}" + exit 0 + fi + echo "Push rejected (attempt ${attempt}/5); rebasing onto concurrent release..." + git fetch origin main && git rebase origin/main + sleep $((RANDOM % 5 + 3)) + done + + echo "Could not push ${NAME} ${VERSION} after 5 attempts" + exit 1 diff --git a/apps/linkunbound/lib/bootstrap.dart b/apps/linkunbound/lib/bootstrap.dart index 90f19de..6d4b96c 100644 --- a/apps/linkunbound/lib/bootstrap.dart +++ b/apps/linkunbound/lib/bootstrap.dart @@ -46,6 +46,11 @@ Future bootstrap(PlatformBindings bindings, List args) async { _log.warning('Registration reconciliation failed (non-fatal)', e, st); } + if (args.contains('--register')) { + _log.info('Registration-only run; exiting without starting the UI'); + _exitAfterFlush(); + } + try { if (await bindings.tryDelegate(bindings.initialEvent)) { _exitAfterFlush(); From 6877a9c1ec1b64d22de8d05c35f739ee2318e2d3 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Thu, 6 Aug 2026 18:17:01 -0400 Subject: [PATCH 4/6] feat: add retry logic for asset downloads in release workflow --- .github/workflows/release.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c9c00f..0178e15 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,12 +129,16 @@ jobs: DMG_URL="https://github.com/${{ github.repository }}/releases/download/${TAG}/${DMG_NAME}" echo "Downloading DMG to compute SHA256..." + DOWNLOADED="" for attempt in 1 2 3 4 5; do - curl -fSL -o "/tmp/${DMG_NAME}" "${DMG_URL}" && break + if curl -fSL -o "/tmp/${DMG_NAME}" "${DMG_URL}"; then + DOWNLOADED=1 + break + fi echo "Asset not downloadable yet (attempt ${attempt}/5); retrying..." sleep $((attempt * 10)) done - if [[ ! -s "/tmp/${DMG_NAME}" ]]; then + if [[ -z "$DOWNLOADED" || ! -s "/tmp/${DMG_NAME}" ]]; then echo "Could not download ${DMG_NAME}" exit 1 fi @@ -278,12 +282,16 @@ jobs: NAME="linkunbound"; SUFFIX="" fi + DOWNLOADED="" for attempt in 1 2 3 4 5; do - curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}" && break + if curl -fSL -o "/tmp/${SETUP}" "${BASE}/${SETUP}"; then + DOWNLOADED=1 + break + fi echo "Asset not downloadable yet (attempt ${attempt}/5); retrying..." sleep $((attempt * 10)) done - if [[ ! -s "/tmp/${SETUP}" ]]; then + if [[ -z "$DOWNLOADED" || ! -s "/tmp/${SETUP}" ]]; then echo "Could not download ${SETUP} from ${BASE}" exit 1 fi From 2fe24d021c569e74ec75f5ed36bc3bf4268b23fd Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 7 Aug 2026 09:19:29 -0400 Subject: [PATCH 5/6] feat: enhance bootstrap process with exit signal handling and delegation tests --- apps/linkunbound/lib/bootstrap.dart | 104 +++------------ apps/linkunbound/test/bootstrap_test.dart | 156 ++++++++++++++++++++-- 2 files changed, 167 insertions(+), 93 deletions(-) diff --git a/apps/linkunbound/lib/bootstrap.dart b/apps/linkunbound/lib/bootstrap.dart index 6d4b96c..d30f0e0 100644 --- a/apps/linkunbound/lib/bootstrap.dart +++ b/apps/linkunbound/lib/bootstrap.dart @@ -29,15 +29,15 @@ Never _exitAfterFlush() { exit(0); } -Future bootstrap(PlatformBindings bindings, List args) async { +Future bootstrap( + PlatformBindings bindings, + List args, { + Never Function() exitProcess = _exitAfterFlush, +}) async { initLogging(bindings.logFile); _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, @@ -48,17 +48,20 @@ Future bootstrap(PlatformBindings bindings, List args) async { if (args.contains('--register')) { _log.info('Registration-only run; exiting without starting the UI'); - _exitAfterFlush(); + exitProcess(); } - try { - if (await bindings.tryDelegate(bindings.initialEvent)) { - _exitAfterFlush(); + Future delegate(String failureMessage) async { + try { + return await bindings.tryDelegate(bindings.initialEvent); + } on Object catch (e, st) { + _log.warning(failureMessage, e, st); + return false; } - } on Object catch (e, st) { - _log.warning('Delegation check failed', e, st); } + if (await delegate('Delegation check failed')) exitProcess(); + bool claimed; try { claimed = await bindings.claim(); @@ -68,17 +71,7 @@ Future bootstrap(PlatformBindings bindings, List args) async { } if (!claimed) { - // claim() returned false means the mutex was held; the resident's pipe is - // now guaranteed to be listening (claim waits for readiness). Retry once. - try { - if (await bindings.tryDelegate(bindings.initialEvent)) { - _exitAfterFlush(); - } - } on Object catch (e, st) { - _log.warning('Post-claim delegation retry failed', e, st); - } - // Delegation failed again: the resident may have exited in between. Make - // one last attempt to become the resident before dropping the event. + if (await delegate('Post-claim delegation retry failed')) exitProcess(); try { claimed = await bindings.claim(); } on Object catch (e, st) { @@ -86,15 +79,7 @@ Future bootstrap(PlatformBindings bindings, List args) async { claimed = false; } if (!claimed) { - // Last-resort delegation before giving up: the resident that raced us may - // now be ready to receive the pipe message. - try { - if (await bindings.tryDelegate(bindings.initialEvent)) { - _exitAfterFlush(); - } - } on Object catch (e, st) { - _log.warning('Final delegation attempt failed', e, st); - } + if (await delegate('Final delegation attempt failed')) exitProcess(); final eventType = bindings.initialEvent?.runtimeType; if (eventType != null) { _log.severe( @@ -102,7 +87,7 @@ Future bootstrap(PlatformBindings bindings, List args) async { '(type=$eventType)', ); } - _exitAfterFlush(); + exitProcess(); } } @@ -120,10 +105,7 @@ 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; + /isFirstBoot = true; } on Object catch (e, st) { _log.warning('Browser reset failed', e, st); } @@ -149,18 +131,11 @@ 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, size: Size(640, 700), center: false, - // Force a fully opaque background so compositors that lack Mica / - // DWM acrylic (Windows 10 integrated GPUs, Remote Desktop) don't try - // to render a transparent frame and crash the Flutter engine. backgroundColor: Color(0xFF1E1E1E), ), ); @@ -178,8 +153,6 @@ Future bootstrap(PlatformBindings bindings, List args) async { _log.severe('Window manager init failed', e, st); } - // Created here so that the exitApp callback can call hotkeyService.dispose() - // without needing to update the override after the container is built. final hotkeyService = HotkeyService(); final container = ProviderContainer( @@ -211,23 +184,13 @@ Future bootstrap(PlatformBindings bindings, List args) async { } on Object catch (e, st) { _log.warning('Release failed during exit', e, st); } - _exitAfterFlush(); + exitProcess(); }), ], ); final macWindow = Platform.isMacOS ? MacWindowChannel() : null; - // 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; @@ -408,12 +371,6 @@ Future _applyAppMode( } } -/// 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, @@ -422,15 +379,10 @@ Future _showPicker( try { await macWindow?.setPickerMode(); final browsers = container.read(browsersProvider); - // Read the system text size here rather than baking in 1.0: the window is - // sized before its content is laid out, so an accessibility setting the - // layout knows nothing about would push the footer outside the frame. final winSize = PickerLayout.windowSize( browsers.length, textScale: PlatformDispatcher.instance.textScaleFactor, ); - // 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(), @@ -459,9 +411,6 @@ Future _showPicker( 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); @@ -475,14 +424,9 @@ Future _showPicker( } } -/// `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({ required BrowserService browserService, required Directory iconsDir, @@ -495,10 +439,6 @@ Future _firstBootEarlyPhase({ } } -/// 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, @@ -541,17 +481,11 @@ Future _handleUrl( } 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'); diff --git a/apps/linkunbound/test/bootstrap_test.dart b/apps/linkunbound/test/bootstrap_test.dart index 3d07182..385eece 100644 --- a/apps/linkunbound/test/bootstrap_test.dart +++ b/apps/linkunbound/test/bootstrap_test.dart @@ -472,6 +472,43 @@ final class _ClaimFalseFirstBindings extends _FakeBindings { } } +final class _ExitSignal implements Exception { + const _ExitSignal(); +} + +Never _signalExit() => throw const _ExitSignal(); + +final class _DelegatingBindings extends _FakeBindings { + _DelegatingBindings({required super.rootDir}); + + @override + Future tryDelegate(InboundEvent? event) async { + tryDelegateCalls++; + return true; + } +} + +final class _UnclaimableBindings extends _FakeBindings { + _UnclaimableBindings({required super.rootDir, this.delegateOnCall = 0}); + + final int delegateOnCall; + + @override + InboundEvent? get initialEvent => const OpenUrlEvent('https://example.com'); + + @override + Future claim() async { + claimCalls++; + return false; + } + + @override + Future tryDelegate(InboundEvent? event) async { + tryDelegateCalls++; + return tryDelegateCalls == delegateOnCall; + } +} + final class _ThrowingBrowserDetector implements BrowserDetector { @override Future> detect() => Future.error(Exception('detection failed')); @@ -608,15 +645,9 @@ void main() { _FakeBindings bindings, List args, ) async { - // bootstrap() performs real dart:io and platform-channel operations - // (file reads, tray init, AppLocalizations.delegate.load, runApp) that - // need the real event loop. tester.runAsync escapes FakeAsync so those - // futures can complete, then we pump to process widget frames. - // The extra runAsync gives post-frame callbacks (tray init, icon - // extraction) time to complete before we assert on their side effects. await tester.runAsync(() async { await HttpOverrides.runZoned( - () => bootstrap(bindings, args), + () => bootstrap(bindings, args, exitProcess: _signalExit), createHttpClient: (_) => _FailingHttpClient(), ); }); @@ -631,6 +662,25 @@ void main() { await tester.pump(); } + Future bootUntilExit( + WidgetTester tester, + _FakeBindings bindings, + List args, + ) async { + Object? thrown; + await tester.runAsync(() async { + try { + await HttpOverrides.runZoned( + () => bootstrap(bindings, args, exitProcess: _signalExit), + createHttpClient: (_) => _FailingHttpClient(), + ); + } on _ExitSignal catch (e) { + thrown = e; + } + }); + return thrown; + } + testWidgets('first boot scans browsers, extracts icons, and opens settings', ( tester, ) async { @@ -1365,7 +1415,97 @@ void main() { await tester.pump(); await tester.pump(); - // The footer offers a rule about the app, not about the domain. expect(find.text('Always open links from slack here'), findsOneWidget); }); + + testWidgets('--register reconciles the handler and exits before the UI', ( + tester, + ) async { + final bindings = _FakeBindings(rootDir: tempDir); + addTearDown(bindings.close); + + final exited = await bootUntilExit(tester, bindings, const ['--register']); + + expect(exited, isA<_ExitSignal>()); + expect(bindings.registrationRecorder.registerCalls, [ + bindings.executablePath, + ]); + expect(bindings.tryDelegateCalls, 0); + expect(bindings.claimCalls, 0); + expect(find.byType(SettingsWindow), findsNothing); + }); + + testWidgets('a delegated launch exits without claiming the mutex', ( + tester, + ) async { + final bindings = _DelegatingBindings(rootDir: tempDir); + addTearDown(bindings.close); + + final exited = await bootUntilExit(tester, bindings, const []); + + expect(exited, isA<_ExitSignal>()); + expect(bindings.tryDelegateCalls, 1); + expect(bindings.claimCalls, 0); + }); + + testWidgets('delegation retry after a lost claim exits', (tester) async { + final bindings = _UnclaimableBindings(rootDir: tempDir, delegateOnCall: 2); + addTearDown(bindings.close); + + final exited = await bootUntilExit(tester, bindings, const []); + + expect(exited, isA<_ExitSignal>()); + expect(bindings.claimCalls, 1); + expect(bindings.tryDelegateCalls, 2); + }); + + testWidgets('last-resort delegation exits after two failed claims', ( + tester, + ) async { + final bindings = _UnclaimableBindings(rootDir: tempDir, delegateOnCall: 3); + addTearDown(bindings.close); + + final exited = await bootUntilExit(tester, bindings, const []); + + expect(exited, isA<_ExitSignal>()); + expect(bindings.claimCalls, 2); + expect(bindings.tryDelegateCalls, 3); + }); + + testWidgets('an unreachable resident drops the initial event and exits', ( + tester, + ) async { + final bindings = _UnclaimableBindings(rootDir: tempDir); + addTearDown(bindings.close); + + final exited = await bootUntilExit(tester, bindings, const []); + + expect(exited, isA<_ExitSignal>()); + expect(bindings.claimCalls, 2); + expect(bindings.tryDelegateCalls, 3); + }); + + testWidgets('tray exit releases the instance and exits', (tester) async { + final bindings = _FakeBindings(rootDir: tempDir); + addTearDown(bindings.close); + + await boot(tester, bindings, const []); + + final exitItem = bindings.fakeTray.menuItems.firstWhere( + (item) => item.label == 'Exit', + ); + + // onClick is a VoidCallback holding an async body, so the signal surfaces + // as an unhandled asynchronous error rather than a synchronous throw. + Object? captured; + await tester.runAsync(() async { + await runZonedGuarded(() async { + exitItem.onClick!(); + await Future.delayed(const Duration(milliseconds: 100)); + }, (error, stack) => captured ??= error); + }); + + expect(captured, isA<_ExitSignal>()); + expect(bindings.releaseCalls, 1); + }); } From 256b6be9d56c1138ec4791148ec12aa62696da56 Mon Sep 17 00:00:00 2001 From: rgdevment Date: Fri, 7 Aug 2026 09:26:52 -0400 Subject: [PATCH 6/6] feat: refactor delegation handling in bootstrap and update test bindings --- apps/linkunbound/lib/bootstrap.dart | 4 ++-- apps/linkunbound/test/bootstrap_test.dart | 24 +++++++++++------------ 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/apps/linkunbound/lib/bootstrap.dart b/apps/linkunbound/lib/bootstrap.dart index d30f0e0..fe27b7b 100644 --- a/apps/linkunbound/lib/bootstrap.dart +++ b/apps/linkunbound/lib/bootstrap.dart @@ -71,7 +71,7 @@ Future bootstrap( } if (!claimed) { - if (await delegate('Post-claim delegation retry failed')) exitProcess(); + if (await delegate('Post-claim delegation retry failed')) exitProcess(); try { claimed = await bindings.claim(); } on Object catch (e, st) { @@ -105,7 +105,7 @@ Future bootstrap( _log.severe('Browser config corrupted, resetting', e, st); try { await browserService.reset(); - /isFirstBoot = true; + isFirstBoot = true; } on Object catch (e, st) { _log.warning('Browser reset failed', e, st); } diff --git a/apps/linkunbound/test/bootstrap_test.dart b/apps/linkunbound/test/bootstrap_test.dart index 385eece..7a38934 100644 --- a/apps/linkunbound/test/bootstrap_test.dart +++ b/apps/linkunbound/test/bootstrap_test.dart @@ -478,16 +478,6 @@ final class _ExitSignal implements Exception { Never _signalExit() => throw const _ExitSignal(); -final class _DelegatingBindings extends _FakeBindings { - _DelegatingBindings({required super.rootDir}); - - @override - Future tryDelegate(InboundEvent? event) async { - tryDelegateCalls++; - return true; - } -} - final class _UnclaimableBindings extends _FakeBindings { _UnclaimableBindings({required super.rootDir, this.delegateOnCall = 0}); @@ -1438,7 +1428,7 @@ void main() { testWidgets('a delegated launch exits without claiming the mutex', ( tester, ) async { - final bindings = _DelegatingBindings(rootDir: tempDir); + final bindings = _UnclaimableBindings(rootDir: tempDir, delegateOnCall: 1); addTearDown(bindings.close); final exited = await bootUntilExit(tester, bindings, const []); @@ -1470,6 +1460,10 @@ void main() { expect(exited, isA<_ExitSignal>()); expect(bindings.claimCalls, 2); expect(bindings.tryDelegateCalls, 3); + expect( + bindings.logFile.readAsStringSync(), + isNot(contains('Discarding initial event')), + ); }); testWidgets('an unreachable resident drops the initial event and exits', ( @@ -1483,6 +1477,10 @@ void main() { expect(exited, isA<_ExitSignal>()); expect(bindings.claimCalls, 2); expect(bindings.tryDelegateCalls, 3); + expect( + bindings.logFile.readAsStringSync(), + contains('Discarding initial event'), + ); }); testWidgets('tray exit releases the instance and exits', (tester) async { @@ -1495,8 +1493,8 @@ void main() { (item) => item.label == 'Exit', ); - // onClick is a VoidCallback holding an async body, so the signal surfaces - // as an unhandled asynchronous error rather than a synchronous throw. + // onClick is a VoidCallback wrapping an async body: the signal surfaces as + // an unhandled asynchronous error, not a synchronous throw. Object? captured; await tester.runAsync(() async { await runZonedGuarded(() async {