diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 37ac504..eb2a216 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,7 +6,29 @@ on:
pull_request:
branches: [main]
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
jobs:
+ markdown:
+ name: Markdown Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Lint Markdown files
+ uses: DavidAnson/markdownlint-cli2-action@v19
+ with:
+ globs: |
+ *.md
+ .github/**/*.md
+ config: ".markdownlint.yaml"
+ continue-on-error: true
+
quality:
name: Quality
runs-on: ubuntu-latest
@@ -17,6 +39,14 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
+ cache: true
+
+ - name: Cache pub dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.pub-cache
+ key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
+ restore-keys: ${{ runner.os }}-pub-
- name: Install melos
run: dart pub global activate melos
@@ -30,6 +60,12 @@ jobs:
- name: Analyze
run: melos analyze
+ - name: Check auto-fixable issues
+ run: |
+ OUTPUT=$(dart fix --dry-run . 2>&1)
+ echo "$OUTPUT"
+ echo "$OUTPUT" | grep -q "Nothing to fix!" || { echo "::error::Auto-fixable issues found. Run 'dart fix --apply' and commit."; exit 1; }
+
- name: Run core tests with coverage
working-directory: packages/core
run: |
@@ -90,6 +126,14 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
+ cache: true
+
+ - name: Cache pub dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.pub-cache
+ key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
+ restore-keys: ${{ runner.os }}-pub-
- name: Resolve dependencies
run: |
@@ -117,9 +161,19 @@ jobs:
-Dsonar.coverage.exclusions=**/main.dart,**/app.dart,**/platform/**,**/update_service.dart,**/picker_window.dart,**/settings_window.dart,**/settings_view.dart,**/title_bar.dart
build:
- name: Build (Windows)
+ name: Build (${{ matrix.name }})
needs: quality
- runs-on: windows-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - os: windows-latest
+ name: Windows
+ build_cmd: flutter build windows --release
+ - os: macos-15
+ name: macOS
+ build_cmd: flutter build macos --release
+ runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
@@ -127,7 +181,21 @@ jobs:
- uses: subosito/flutter-action@v2
with:
channel: stable
+ cache: true
+
+ - name: Cache pub dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.pub-cache
+ key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
+ restore-keys: ${{ runner.os }}-pub-
+
+ - name: Install melos
+ run: dart pub global activate melos
+
+ - name: Bootstrap workspace
+ run: melos bootstrap
- - name: Build Windows release
+ - name: Build ${{ matrix.name }} release
working-directory: apps/linkunbound
- run: flutter build windows --release
+ run: ${{ matrix.build_cmd }}
diff --git a/.github/workflows/release-mac.yml b/.github/workflows/release-mac.yml
new file mode 100644
index 0000000..cf4008b
--- /dev/null
+++ b/.github/workflows/release-mac.yml
@@ -0,0 +1,233 @@
+name: Release (macOS)
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ required: true
+ type: string
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to use (e.g. 2.0.0). Defaults to 2.0.0-dev"
+ required: false
+ default: "2.0.0-dev"
+
+permissions:
+ contents: read
+
+jobs:
+ build-macos:
+ runs-on: macos-15
+ timeout-minutes: 45
+ name: Build macOS (Universal)
+
+ env:
+ MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }}
+ MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
+ APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ github.ref_name }}
+
+ - name: Resolve version
+ id: get_version
+ run: |
+ VERSION="${{ inputs.version }}"
+
+ IS_PRERELEASE="false"
+ if [[ "$VERSION" == *-* ]]; then
+ IS_PRERELEASE="true"
+ fi
+
+ BUILD_NAME="${VERSION%-*}"
+ BUILD_NUMBER=$(echo "$BUILD_NAME" | awk -F. '{printf "%d%03d%03d", $1, $2, $3}')
+
+ echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "BUILD_NAME=$BUILD_NAME" >> "$GITHUB_OUTPUT"
+ echo "BUILD_NUMBER=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
+ echo "IS_PRERELEASE=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
+ echo "Version: $VERSION Build: $BUILD_NAME ($BUILD_NUMBER) PreRelease: $IS_PRERELEASE"
+
+ - uses: subosito/flutter-action@v2
+ with:
+ channel: stable
+ cache: true
+
+ - name: Cache pub dependencies
+ uses: actions/cache@v5
+ with:
+ path: ~/.pub-cache
+ key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.yaml') }}
+ restore-keys: ${{ runner.os }}-pub-
+
+ - name: Install Melos
+ run: dart pub global activate melos
+
+ - name: Get dependencies
+ run: melos bootstrap
+
+ - name: Update pubspec version
+ run: |
+ VERSION="${{ steps.get_version.outputs.VERSION }}"
+ sed -i '' "s/^version:.*/version: $VERSION/" apps/linkunbound/pubspec.yaml
+ echo "Updated pubspec.yaml version to: $VERSION"
+
+ # ── Code Signing Setup ──
+ - name: Import signing certificate
+ if: env.MACOS_CERTIFICATE_P12 != ''
+ run: |
+ KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db"
+ KEYCHAIN_PASSWORD="$(openssl rand -base64 32)"
+
+ security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+
+ CERT_PATH="$RUNNER_TEMP/certificate.p12"
+ echo "$MACOS_CERTIFICATE_P12" | base64 --decode > "$CERT_PATH"
+
+ security import "$CERT_PATH" \
+ -k "$KEYCHAIN_PATH" \
+ -P "$MACOS_CERTIFICATE_PASSWORD" \
+ -T /usr/bin/codesign \
+ -T /usr/bin/security
+
+ security list-keychains -d user -s "$KEYCHAIN_PATH" login.keychain-db
+ security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
+
+ rm -f "$CERT_PATH"
+ echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
+ echo "Certificate imported successfully"
+
+ # ── Build (universal: arm64 + x86_64) ──
+ - name: Refresh CocoaPods lock
+ run: |
+ rm -f apps/linkunbound/macos/Podfile.lock
+ cd apps/linkunbound/macos && pod install --repo-update
+
+ - name: Build macOS release (universal)
+ run: |
+ cd apps/linkunbound
+ flutter build macos --release \
+ --build-name="${{ steps.get_version.outputs.BUILD_NAME }}" \
+ --build-number="${{ steps.get_version.outputs.BUILD_NUMBER }}" \
+ --dart-define="APP_VERSION=${{ steps.get_version.outputs.VERSION }}"
+
+ - name: Verify universal binary
+ run: |
+ APP="apps/linkunbound/build/macos/Build/Products/Release/LinkUnbound.app"
+ ARCHS=$(lipo -archs "$APP/Contents/MacOS/LinkUnbound")
+ echo "Architectures: $ARCHS"
+ if [[ "$ARCHS" != *"x86_64"* ]] || [[ "$ARCHS" != *"arm64"* ]]; then
+ echo "::error::Expected universal binary (x86_64 + arm64), got: $ARCHS"
+ exit 1
+ fi
+ echo "Universal binary verified (x86_64 + arm64)"
+
+ # ── Code Sign the .app ──
+ - name: Sign application
+ if: env.MACOS_CERTIFICATE_P12 != ''
+ run: |
+ APP_PATH="apps/linkunbound/build/macos/Build/Products/Release/LinkUnbound.app"
+
+ SIGN_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk '{print $2}')
+ echo "Signing with identity: $SIGN_IDENTITY"
+
+ codesign --deep --force --options runtime \
+ --entitlements "apps/linkunbound/macos/Runner/Release.entitlements" \
+ --sign "$SIGN_IDENTITY" \
+ "$APP_PATH"
+
+ echo "Verifying signature..."
+ codesign --verify --deep --strict "$APP_PATH"
+ echo "Signature verified"
+
+ - name: Ad-hoc sign (unsigned build)
+ if: env.MACOS_CERTIFICATE_P12 == ''
+ run: |
+ APP_PATH="apps/linkunbound/build/macos/Build/Products/Release/LinkUnbound.app"
+ codesign --deep --force --sign - "$APP_PATH"
+ echo "Ad-hoc signed (unsigned build)"
+
+ # ── Create DMG ──
+ - name: Install create-dmg
+ run: brew install create-dmg
+
+ - name: Create DMG
+ run: |
+ VERSION="${{ steps.get_version.outputs.VERSION }}"
+ APP_PATH="apps/linkunbound/build/macos/Build/Products/Release/LinkUnbound.app"
+ DMG_NAME="LinkUnbound_${VERSION}_universal.dmg"
+
+ mkdir -p apps/linkunbound/dist
+
+ create-dmg \
+ --volname "LinkUnbound" \
+ --window-pos 200 120 \
+ --window-size 660 400 \
+ --icon-size 80 \
+ --icon "LinkUnbound.app" 180 190 \
+ --app-drop-link 480 190 \
+ --hide-extension "LinkUnbound.app" \
+ --no-internet-enable \
+ "apps/linkunbound/dist/$DMG_NAME" \
+ "$APP_PATH" \
+ || true
+
+ if [[ ! -f "apps/linkunbound/dist/$DMG_NAME" ]]; then
+ echo "::error::DMG was not created"
+ exit 1
+ fi
+
+ echo "DMG created: $DMG_NAME ($(du -h "apps/linkunbound/dist/$DMG_NAME" | cut -f1))"
+
+ # ── Sign & Notarize DMG ──
+ - name: Sign DMG
+ if: env.MACOS_CERTIFICATE_P12 != ''
+ run: |
+ DMG_PATH="apps/linkunbound/dist/LinkUnbound_${{ steps.get_version.outputs.VERSION }}_universal.dmg"
+
+ SIGN_IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk '{print $2}')
+ echo "Signing DMG with identity: $SIGN_IDENTITY"
+
+ codesign --force --sign "$SIGN_IDENTITY" "$DMG_PATH"
+ codesign --verify "$DMG_PATH"
+ echo "DMG signed"
+
+ - name: Notarize DMG
+ if: env.APPLE_ID != '' && env.MACOS_CERTIFICATE_P12 != ''
+ run: |
+ DMG_PATH="apps/linkunbound/dist/LinkUnbound_${{ steps.get_version.outputs.VERSION }}_universal.dmg"
+
+ echo "Submitting for notarization..."
+ xcrun notarytool submit "$DMG_PATH" \
+ --apple-id "$APPLE_ID" \
+ --password "$APPLE_APP_PASSWORD" \
+ --team-id "$APPLE_TEAM_ID" \
+ --wait \
+ --timeout 600
+
+ echo "Stapling notarization ticket..."
+ xcrun stapler staple "$DMG_PATH"
+
+ echo "Verifying notarization..."
+ spctl --assess --type open --context context:primary-signature "$DMG_PATH"
+ echo "Notarization complete"
+
+ # ── Cleanup ──
+ - name: Cleanup keychain
+ if: always() && env.KEYCHAIN_PATH != ''
+ run: security delete-keychain "$KEYCHAIN_PATH" || true
+
+ # ── Upload ──
+ - name: Upload artifact
+ uses: actions/upload-artifact@v6
+ with:
+ name: release-macos
+ path: apps/linkunbound/dist/*.dmg
+ retention-days: 5
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c9db052..a3ae16b 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -41,9 +41,16 @@ jobs:
version: ${{ needs.extract-version.outputs.version }}
secrets: inherit
+ build-macos:
+ needs: extract-version
+ uses: ./.github/workflows/release-mac.yml
+ with:
+ version: ${{ needs.extract-version.outputs.version }}
+ secrets: inherit
+
github-release:
runs-on: ubuntu-latest
- needs: [extract-version, build-windows]
+ needs: [extract-version, build-windows, build-macos]
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
timeout-minutes: 10
name: Create GitHub Release
@@ -68,6 +75,12 @@ jobs:
name: release-windows
path: artifacts/windows
+ - name: Download macOS artifacts
+ uses: actions/download-artifact@v7
+ with:
+ name: release-macos
+ path: artifacts/macos
+
- name: List artifacts
run: find artifacts/ -type f
@@ -81,9 +94,81 @@ jobs:
files: |
artifacts/windows/**/*_Setup.exe
artifacts/windows/**/*_store.msix*
+ artifacts/macos/**/*.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ update-homebrew-cask:
+ runs-on: ubuntu-latest
+ needs: github-release
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
+ timeout-minutes: 5
+ name: Update Homebrew Tap
+
+ steps:
+ - name: Update Homebrew Tap
+ env:
+ GH_TOKEN: ${{ secrets.GIST_TOKEN }}
+ run: |
+ TAG="${GITHUB_REF_NAME}"
+ VERSION="${TAG#v}"
+
+ DMG_NAME="LinkUnbound_${VERSION}_universal.dmg"
+ 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}"
+ DMG_SHA256=$(sha256sum "/tmp/${DMG_NAME}" | awk '{print $1}')
+ rm -f "/tmp/${DMG_NAME}"
+
+ echo "Version: ${VERSION}"
+ echo "DMG SHA256: ${DMG_SHA256}"
+
+ if [[ "$VERSION" == *-* ]]; then
+ CASK_FILE="Casks/linkunbound-beta.rb"
+ CASK_NAME="linkunbound-beta"
+ CASK_DESC="Smart browser router for HTTP(S) links (beta)"
+ else
+ CASK_FILE="Casks/linkunbound.rb"
+ CASK_NAME="linkunbound"
+ CASK_DESC="Smart browser router for HTTP(S) links"
+ fi
+
+ git clone "https://x-access-token:${GH_TOKEN}@github.com/rgdevment/homebrew-tap.git" /tmp/homebrew-tap
+ cd /tmp/homebrew-tap
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ mkdir -p Casks
+
+ cat > "${CASK_FILE}" <<- CASK_EOF
+ cask "${CASK_NAME}" do
+ version "${VERSION}"
+ sha256 "${DMG_SHA256}"
+
+ url "${DMG_URL}"
+ name "LinkUnbound"
+ desc "${CASK_DESC}"
+ homepage "https://github.com/${{ github.repository }}"
+
+ depends_on macos: ">= :ventura"
+
+ app "LinkUnbound.app"
+
+ zap trash: [
+ "~/Library/Application Support/com.rgdevment.linkunbound",
+ "~/Library/Preferences/com.rgdevment.linkunbound.plist",
+ ]
+ end
+ CASK_EOF
+
+ git add "${CASK_FILE}"
+ git commit -m "Update ${CASK_NAME} to ${VERSION}"
+ git push origin main
+
+ echo "Homebrew Tap updated: cask ${CASK_NAME} → ${VERSION}"
+
# publish-to-store:
# runs-on: windows-latest
# needs: [extract-version, github-release]
diff --git a/.markdownlint.yaml b/.markdownlint.yaml
new file mode 100644
index 0000000..913ceed
--- /dev/null
+++ b/.markdownlint.yaml
@@ -0,0 +1,29 @@
+default: true
+
+MD013: false
+MD033: false
+MD041: false
+
+MD007:
+ indent: 4
+
+MD010:
+ code_blocks: false
+
+MD024:
+ siblings_only: true
+
+MD025:
+ front_matter_title: ""
+
+MD029:
+ style: "ordered"
+
+MD046:
+ style: "fenced"
+
+MD049:
+ style: "underscore"
+
+MD050:
+ style: "asterisk"
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 34211ab..7acf134 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,7 +33,7 @@ We believe in:
2. If it's new, open an issue with:
- Clear description of the problem
- Steps to reproduce
- - LinkUnbound version and Windows version
+ - LinkUnbound version and OS version (Windows 10/11 or macOS)
- Screenshots if applicable
### Contribute Code
@@ -75,7 +75,7 @@ linkunbound_workspace/
apps/
linkunbound/ # Flutter app — UI, platform implementations, providers
lib/
- platform/ # Windows-specific implementations
+ platform/ # Windows and macOS-specific implementations
ui/ # Picker, Settings, shared widgets
providers.dart # Riverpod providers
main.dart # Entry point
@@ -96,7 +96,8 @@ linkunbound_workspace/
- [Flutter SDK](https://docs.flutter.dev/get-started/install) (stable channel)
- [Melos](https://melos.invertase.dev/) (`dart pub global activate melos`)
-- Windows 10 or 11 (for running the app)
+- For Windows builds: Windows 10/11 with Visual Studio 2022 (Desktop development with C++)
+- For macOS builds: macOS 13 (Ventura) or newer with Xcode 15+ (open `apps/linkunbound/macos/Runner.xcworkspace` to edit Swift sources, native channels, signing or entitlements)
### Getting Started
@@ -110,7 +111,8 @@ melos bootstrap
```sh
cd apps/linkunbound
-flutter run -d windows
+flutter run -d windows # on Windows
+flutter run -d macos # on macOS
```
### Common Commands
diff --git a/PRIVACY.md b/PRIVACY.md
index 26d5c17..a6a3ba7 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -1,6 +1,6 @@
# Privacy Policy
-**Last updated:** April 16, 2026
+**Last updated:** April 21, 2026
---
@@ -65,7 +65,9 @@ Browser icons are extracted locally from installed browser executables and store
## Where Is Everything Stored?
-All data is stored locally under your user profile:
+All data is stored locally under your user profile.
+
+**Windows** — `%APPDATA%\LinkUnbound\`:
| Data | Location |
| :------- | :------------------------------------------ |
@@ -74,7 +76,16 @@ All data is stored locally under your user profile:
| Log | `%APPDATA%\LinkUnbound\navigate.log` |
| Icons | `%APPDATA%\LinkUnbound\icons\` |
-These folders are protected by your Windows user account permissions. Other users on the same computer cannot access them under normal conditions.
+**macOS** — `~/Library/Application Support/LinkUnbound/`:
+
+| Data | Location |
+| :------- | :--------------------------------------------------------------- |
+| Browsers | `~/Library/Application Support/LinkUnbound/browsers.json` |
+| Rules | `~/Library/Application Support/LinkUnbound/rules.json` |
+| Log | `~/Library/Application Support/LinkUnbound/navigate.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.
---
@@ -143,9 +154,17 @@ Settings → **Maintenance** tab provides:
### Complete Removal
+**Windows:**
+
1. Uninstall LinkUnbound (via Settings → Apps or the standalone uninstaller).
2. Delete the data folder: `%APPDATA%\LinkUnbound\`
+**macOS:**
+
+1. Drag `LinkUnbound.app` from `/Applications` to the Trash (or `brew uninstall --cask linkunbound`).
+2. Delete the data folder: `~/Library/Application Support/LinkUnbound/`
+3. Optional: remove preferences (`~/Library/Preferences/cl.apirest.linkunbound.plist`) and saved app state (`~/Library/Saved Application State/cl.apirest.linkunbound.savedState/`).
+
After these steps, no LinkUnbound data remains on your system.
---
@@ -156,11 +175,11 @@ LinkUnbound includes an optional **Export diagnostics** feature (Settings → Ma
### What the ZIP Contains
-| File | Content |
-| :---------------- | :------------------------------------------------------------ |
-| `system_info.txt` | OS version, locale, app version, executable path, data files |
-| `registry.txt` | LinkUnbound's own Windows registry entries |
-| `navigate.log` | Last 200 lines of the navigation log (URLs already redacted) |
+| File | Content |
+| :---------------- | :-------------------------------------------------------------------------------------------- |
+| `system_info.txt` | OS version, locale, app version, executable path, data files |
+| `registry.txt` | LinkUnbound's own Windows registry entries (Windows only) |
+| `navigate.log` | Last 200 lines of the navigation log (URLs already redacted) |
### What the ZIP Does NOT Contain
diff --git a/README.md b/README.md
index a26359b..daf7cb9 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
# LinkUnbound
-**A free, open source browser picker for Windows. Choose which browser opens every link.**
+**A free, open source browser picker for Windows and macOS. Choose which browser opens every link.**
No ads. No telemetry. No accounts. Everything local.
@@ -20,6 +20,7 @@ No ads. No telemetry. No accounts. Everything local.
+
@@ -29,7 +30,7 @@ No ads. No telemetry. No accounts. Everything local.
---
-**LinkUnbound** is a free, open source **browser picker** and **browser chooser** for Windows. Every link you click — in Teams, Outlook, Slack, a PDF, wherever — gets intercepted. If there's a domain rule, the assigned browser opens instantly. If not, a small **browser selection popup** appears near your cursor and lets you choose.
+**LinkUnbound** is a free, open source **browser picker** and **browser chooser** for Windows and macOS. Every link you click — in Teams, Outlook, Slack, a PDF, wherever — gets intercepted. If there's a domain rule, the assigned browser opens instantly. If not, a small **browser selection popup** appears near your cursor and lets you choose.
This isn't a company product. I'm a developer who needed a way to pick which browser opens each link, built it for myself, and decided to share it. No ads, no telemetry, no subscriptions, no data collection — just a lightweight **browser routing tool** that lives on your machine and nowhere else.
@@ -55,11 +56,11 @@ This isn't a company product. I'm a developer who needed a way to pick which bro
## What It Does
-- **Registers as default browser** in Windows — intercepts every link click system-wide
+- **Registers as default browser** — intercepts every link click system-wide on Windows and macOS
- **Shows a floating picker** near your cursor to choose a browser
- **Saves per-domain rules** — "always open this domain in X"
- **Resolves redirects** and Microsoft SafeLinks before matching rules
-- **Runs silently** in the system tray — launches on startup, stays out of the way
+- **Runs silently** in the system tray (or menu bar on macOS) — launches on startup, stays out of the way
- **Detects installed browsers** automatically — or add custom ones manually
- **Supports multiple languages** — English and Spanish, with automatic detection
@@ -69,7 +70,7 @@ This isn't a company product. I'm a developer who needed a way to pick which bro
**LinkUnbound is:**
-- A **local-first browser picker** and **default browser manager** for Windows
+- A **local-first browser picker** and **default browser manager** for Windows and macOS
- A lightweight **browser routing utility** that works offline
- An **open source** tool you can trust — GPL v3, inspect every line, fork it, contribute
@@ -102,14 +103,24 @@ For responsible disclosure and security contact info, see [SECURITY.md](SECURITY
### Requirements
-- Windows 10 or 11
+- Windows 10 or 11, **or** macOS 13 (Ventura) or newer
- At least two browsers installed
### Installation
**Microsoft Store** (coming soon) — one click, auto-updates, no security warnings.
-**Standalone installer** — download from [GitHub Releases](https://github.com/rgdevment/LinkUnbound/releases/latest).
+**Windows standalone installer** — download from [GitHub Releases](https://github.com/rgdevment/LinkUnbound/releases/latest).
+
+**macOS** — install via Homebrew (signed and notarized):
+
+```bash
+brew tap rgdevment/tap
+brew install --cask linkunbound # stable
+brew install --cask linkunbound-beta # pre-release
+```
+
+Or download the `.dmg` directly from [GitHub Releases](https://github.com/rgdevment/LinkUnbound/releases/latest).
Windows standalone: security warnings
@@ -124,11 +135,20 @@ Since LinkUnbound is an independent open source project, the installer uses a se
### Setup
+**Windows:**
+
1. Run `linkunbound.exe`
2. On first launch, LinkUnbound scans your installed browsers and registers itself
3. In the settings window, click **Set as default** — Windows Settings opens, select LinkUnbound
4. Done — every link now goes through LinkUnbound
+**macOS:**
+
+1. Launch **LinkUnbound** from Applications (or Spotlight)
+2. Open **Settings** from the menu bar icon → click **Set as default**
+3. macOS prompts you to choose the default browser → select LinkUnbound
+4. Done — every link now goes through LinkUnbound
+
---
## How It Works
@@ -156,12 +176,14 @@ Rules are created from the picker ("Always open here") and managed in the Rules
## Architecture
-One exe, two modes:
+One binary, two modes:
+
+- `linkunbound` (no args) → settings + tray/menu bar (resident process)
+- `linkunbound "https://..."` (link click) → routes the URL to the resident process and exits, or operates standalone
-- `linkunbound.exe` (no args) → settings + tray (resident process)
-- `linkunbound.exe "https://..."` (link click) → sends URL via named pipe to resident, or operates standalone
+**Windows.** A named pipe (`\\.\pipe\LinkUnbound`) links second instances to the resident process. A Windows mutex prevents duplicate residents. Default-browser registration goes through `IApplicationAssociationRegistration`.
-The resident process listens on a named pipe. Second instances send the URL and exit immediately. A Windows mutex prevents duplicate resident processes.
+**macOS.** Single-instance launching is handled by Launch Services; URLs arrive through `application:openURLs:` (Apple Events) which are forwarded to Dart via a `MethodChannel`. Default-browser registration uses `LSSetDefaultHandlerForURLScheme`. The app runs as `LSUIElement` so it lives in the menu bar instead of the Dock.
---
@@ -177,10 +199,10 @@ No. LinkUnbound does not track or transmit anything. URLs are processed in memor
No. LinkUnbound works fully offline. The only network request is a lightweight update check against the GitHub Releases API — no user data sent. The app works perfectly without a connection.
**Where is my data stored?**
-Everything is in `%APPDATA%\LinkUnbound\` — browser list (`browsers.json`), domain rules (`rules.json`), navigation log (`navigate.log`), and extracted icons.
+Everything stays on your machine — `%APPDATA%\LinkUnbound\` on Windows, `~/Library/Application Support/LinkUnbound/` on macOS. Browser list (`browsers.json`), domain rules (`rules.json`), navigation log (`navigate.log`), and extracted icons.
**Does it work with any browser?**
-Yes. LinkUnbound detects all browsers registered in Windows. You can also add custom browsers manually with any executable path and arguments.
+Yes. LinkUnbound detects all browsers registered with the operating system. You can also add custom browsers manually with any executable path and arguments.
**Can I use it with Microsoft SafeLinks?**
Yes. LinkUnbound resolves SafeLinks and other redirect wrappers before matching domain rules, so your rules work on the actual destination URL.
@@ -235,7 +257,7 @@ I build free, open source tools focused on privacy and productivity. If you like
## License
-**LinkUnbound** — A free, open source browser picker for Windows.
+**LinkUnbound** — A free, open source browser picker for Windows and macOS.
Copyright (C) 2026 Mario Hidalgo G. (rgdevment)
This program comes with ABSOLUTELY NO WARRANTY.
@@ -244,4 +266,4 @@ Distributed under the **GNU General Public License v3.0**. See [LICENSE](LICENSE
---
-I built LinkUnbound because I was tired of Windows not letting me choose which browser opens a link. This is a personal tool, built from a real need, shared because others might need it too. Free to use, free to inspect, free forever.
+I built LinkUnbound because I was tired of my OS not letting me choose which browser opens a link. This is a personal tool, built from a real need, shared because others might need it too. Free to use, free to inspect, free forever.
diff --git a/apps/linkunbound/.metadata b/apps/linkunbound/.metadata
index a314017..a21daf5 100644
--- a/apps/linkunbound/.metadata
+++ b/apps/linkunbound/.metadata
@@ -4,7 +4,7 @@
# This file should be version controlled and should not be manually edited.
version:
- revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a"
+ revision: "ff37bef603469fb030f2b72995ab929ccfc227f0"
channel: "stable"
project_type: app
@@ -13,11 +13,11 @@ project_type: app
migration:
platforms:
- platform: root
- create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- - platform: windows
- create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
- base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a
+ create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
+ base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
+ - platform: macos
+ create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
+ base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section
diff --git a/apps/linkunbound/README.md b/apps/linkunbound/README.md
index 93fb3e9..cc29aec 100644
--- a/apps/linkunbound/README.md
+++ b/apps/linkunbound/README.md
@@ -1,17 +1,52 @@
-# LinkUnbound
+# LinkUnbound (app)
-A new Flutter project.
+Flutter app for LinkUnbound — UI, platform implementations, and entry point. Business logic lives in `packages/core`.
-## Getting Started
+For the full project description, philosophy, installation, and architecture, see the [root README](../../README.md).
-This project is a starting point for a Flutter application.
+## Run locally
-A few resources to get you started if this is your first Flutter project:
+```sh
+cd apps/linkunbound
+flutter run -d windows # Windows 10/11 + Visual Studio 2022 (Desktop C++)
+flutter run -d macos # macOS 13+ + Xcode 15+
+```
-- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
-- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
-- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+## Tests and analysis
-For help getting started with Flutter development, view the
-[online documentation](https://docs.flutter.dev/), which offers tutorials,
-samples, guidance on mobile development, and a full API reference.
+```sh
+flutter test # 208+ widget/unit tests
+dart analyze --fatal-infos # zero issues
+flutter test --coverage # generates coverage/lcov.info
+```
+
+## Layout
+
+```
+lib/
+ main.dart # entry point
+ app.dart # MaterialApp + router
+ bootstrap.dart # platform-agnostic startup (single-instance, tray, IPC)
+ providers.dart # Riverpod providers
+ l10n/ # ARB-based localization (en, es)
+ platform/
+ platform_bindings.dart # abstract contract per OS
+ windows/ # named pipe + registry + tray (Win32)
+ macos/ # Apple Events + LSSetDefaultHandlerForURLScheme + LSUIElement
+ ui/
+ picker/ # floating browser picker
+ settings/ # General, Rules, About, Maintenance tabs
+ shared/ # theme + reusable widgets
+test/ # widget + unit tests for the app layer
+```
+
+## Platform notes
+
+**Windows.** Default-browser registration via `IApplicationAssociationRegistration`. Single-instance + IPC via a named pipe (`\\.\pipe\LinkUnbound`). Tray icon via `tray_manager`. Native packaging in `windows/packaging/{exe,msix}`.
+
+**macOS.** Default-browser registration via `LSSetDefaultHandlerForURLScheme`. URL events delivered through `application:openURLs:` and forwarded to Dart via `MethodChannel`. The app runs as `LSUIElement` (menu bar only, no Dock icon). Native sources live in `macos/Runner/`. Distribution is signed/notarized via `scripts/macos/release.sh` and shipped through the `rgdevment/tap` Homebrew cask.
+
+## Edit native code
+
+- Windows: open `windows/runner/` in Visual Studio (or VS Code with C++ extension) — uses CMake.
+- macOS: open `macos/Runner.xcworkspace` in Xcode 15+ for Swift sources, signing, entitlements, and Info.plist.
diff --git a/apps/linkunbound/assets/LinkUnbound_tray_32.png b/apps/linkunbound/assets/LinkUnbound_tray_32.png
new file mode 100644
index 0000000..017ba04
Binary files /dev/null and b/apps/linkunbound/assets/LinkUnbound_tray_32.png differ
diff --git a/apps/linkunbound/assets/LinkUnbound_tray_64.png b/apps/linkunbound/assets/LinkUnbound_tray_64.png
new file mode 100644
index 0000000..c3d79cc
Binary files /dev/null and b/apps/linkunbound/assets/LinkUnbound_tray_64.png differ
diff --git a/apps/linkunbound/lib/app.dart b/apps/linkunbound/lib/app.dart
index 3a6ce4b..9772180 100644
--- a/apps/linkunbound/lib/app.dart
+++ b/apps/linkunbound/lib/app.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:window_manager/window_manager.dart';
+import 'dart:async';
import 'l10n/app_localizations.dart';
import 'providers.dart';
@@ -17,6 +18,9 @@ final class NavigateApp extends ConsumerStatefulWidget {
final class _NavigateAppState extends ConsumerState
with WindowListener {
+ Timer? _blurGuardTimer;
+ bool _pickerBlurReady = false;
+
@override
void initState() {
super.initState();
@@ -25,6 +29,7 @@ final class _NavigateAppState extends ConsumerState
@override
void dispose() {
+ _blurGuardTimer?.cancel();
windowManager.removeListener(this);
super.dispose();
}
@@ -41,14 +46,30 @@ final class _NavigateAppState extends ConsumerState
ref.invalidate(isStartupEnabledProvider);
}
+ @override
+ void onWindowBlur() {
+ final mode = ref.read(appStateProvider).mode;
+ if (mode != AppMode.picker) return;
+ if (!_pickerBlurReady) return;
+ ref.read(appStateProvider.notifier).hide();
+ }
+
@override
Widget build(BuildContext context) {
final appState = ref.watch(appStateProvider);
final locale = ref.watch(localeProvider);
- // Show window after the new widget has been painted, not before.
ref.listen(appStateProvider, (prev, next) {
if (prev?.mode == next.mode) return;
+ _blurGuardTimer?.cancel();
+ if (next.mode == AppMode.picker) {
+ _pickerBlurReady = false;
+ _blurGuardTimer = Timer(const Duration(milliseconds: 400), () {
+ _pickerBlurReady = true;
+ });
+ } else {
+ _pickerBlurReady = false;
+ }
if (next.mode == AppMode.hidden) return;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await windowManager.show();
diff --git a/apps/linkunbound/lib/bootstrap.dart b/apps/linkunbound/lib/bootstrap.dart
new file mode 100644
index 0000000..6f07f3c
--- /dev/null
+++ b/apps/linkunbound/lib/bootstrap.dart
@@ -0,0 +1,272 @@
+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:window_manager/window_manager.dart';
+
+import 'app.dart';
+import 'l10n/app_localizations.dart';
+import 'platform/local_file_url.dart';
+import 'platform/macos/mac_window_channel.dart';
+import 'platform/platform_bindings.dart';
+import 'platform/tray_controller.dart';
+import 'providers.dart';
+import 'ui/picker/picker_layout.dart';
+
+final _log = Logger('Bootstrap');
+
+Future bootstrap(PlatformBindings bindings, List args) async {
+ initLogging(bindings.logFile);
+
+ _log.info('Started with args: $args');
+
+ if (await bindings.tryDelegate(bindings.initialEvent)) {
+ exit(0);
+ }
+
+ if (!await bindings.claim()) {
+ exit(0);
+ }
+
+ final browserService = BrowserService(
+ configFile: bindings.browsersFile,
+ browserDetector: bindings.browserDetector,
+ );
+ final ruleService = RuleService(rulesFile: bindings.rulesFile);
+
+ final isFirstBoot = !bindings.browsersFile.existsSync();
+ await browserService.load();
+
+ if (isFirstBoot) {
+ await _firstBoot(
+ browserService: browserService,
+ iconExtractor: bindings.iconExtractor,
+ iconsDir: bindings.iconsDir,
+ registrationService: bindings.registrationService,
+ executablePath: bindings.executablePath,
+ );
+ }
+
+ await ruleService.load();
+
+ await windowManager.ensureInitialized();
+ await windowManager.setPreventClose(true);
+ await windowManager.waitUntilReadyToShow(
+ const WindowOptions(
+ titleBarStyle: TitleBarStyle.hidden,
+ size: Size(640, 700),
+ center: false,
+ ),
+ () async {
+ await windowManager.setSkipTaskbar(true);
+ if (!Platform.isMacOS) {
+ await windowManager.setPosition(const Offset(-9999, -9999));
+ await windowManager.hide();
+ }
+ },
+ );
+
+ final container = ProviderContainer(
+ overrides: [
+ browserServiceProvider.overrideWithValue(browserService),
+ ruleServiceProvider.overrideWithValue(ruleService),
+ registrationServiceProvider.overrideWithValue(
+ bindings.registrationService,
+ ),
+ startupServiceProvider.overrideWithValue(bindings.startupService),
+ iconExtractorProvider.overrideWithValue(bindings.iconExtractor),
+ iconsDirProvider.overrideWithValue(bindings.iconsDir),
+ launchServiceProvider.overrideWithValue(bindings.launchService),
+ localeFileProvider.overrideWithValue(bindings.localeFile),
+ edgeWarningFileProvider.overrideWithValue(bindings.edgeWarningFile),
+ appDataDirProvider.overrideWithValue(bindings.appDataDir),
+ exitAppProvider.overrideWithValue(() async {
+ await bindings.release();
+ exit(0);
+ }),
+ ],
+ );
+
+ container.read(updateInfoProvider);
+
+ final macWindow = Platform.isMacOS ? MacWindowChannel() : null;
+
+ container.listen(appStateProvider, (prev, next) async {
+ if (prev?.mode == next.mode) {
+ if (next.mode == AppMode.settings) {
+ await windowManager.show();
+ await windowManager.focus();
+ await macWindow?.activate();
+ }
+ return;
+ }
+ switch (next.mode) {
+ case AppMode.hidden:
+ await windowManager.hide();
+ case AppMode.settings:
+ await macWindow?.setSettingsMode();
+ await windowManager.setSize(const Size(640, 700));
+ await windowManager.center();
+ await windowManager.setSkipTaskbar(false);
+ await windowManager.setAlwaysOnTop(false);
+ await windowManager.show();
+ await windowManager.focus();
+ await macWindow?.activate();
+ case AppMode.picker:
+ await macWindow?.setPickerMode();
+ final browsers = container.read(browsersProvider);
+ final winSize = PickerLayout.windowSize(browsers.length);
+ final (cursorX, cursorY) = await bindings.cursorLocator
+ .cursorPosition();
+ final (screenW, screenH) = await bindings.cursorLocator.screenSize();
+ final x = (cursorX - winSize.width / 2).clamp(
+ 8.0,
+ screenW - winSize.width - 8,
+ );
+ final y = (cursorY + 16).clamp(8.0, screenH - winSize.height - 8);
+ _log.info(
+ 'Picker: ${browsers.length} browsers, '
+ 'window=${winSize.width.toInt()}x${winSize.height.toInt()}, '
+ 'pos=(${x.toInt()}, ${y.toInt()})',
+ );
+ await windowManager.setSize(winSize);
+ await windowManager.setPosition(Offset(x, y));
+ await windowManager.setSkipTaskbar(true);
+ await windowManager.setAlwaysOnTop(true);
+ await windowManager.show();
+ await macWindow?.activate();
+ }
+ });
+
+ bindings.inboundEvents.listen((event) {
+ switch (event) {
+ case OpenUrlEvent(:final url):
+ _log.info('Inbound: open_url ${_redactForLog(url)}');
+ _handleUrl(url, container);
+ case ShowSettingsEvent():
+ _log.info('Inbound: show_settings');
+ container.read(appStateProvider.notifier).showSettings();
+ }
+ });
+
+ await _initTray(bindings, container);
+
+ runApp(
+ UncontrolledProviderScope(container: container, child: const NavigateApp()),
+ );
+
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (bindings.startsHidden) return;
+ if (container.read(appStateProvider).mode != AppMode.hidden) return;
+ container.read(appStateProvider.notifier).showSettings();
+ });
+}
+
+Future _firstBoot({
+ required BrowserService browserService,
+ required IconExtractor iconExtractor,
+ required Directory iconsDir,
+ required RegistrationService registrationService,
+ required String executablePath,
+}) async {
+ await browserService.scanAndMerge();
+ await iconsDir.create(recursive: true);
+ for (final browser in browserService.browsers) {
+ try {
+ final outputPath =
+ '${iconsDir.path}${Platform.pathSeparator}${browser.id}.png';
+ await iconExtractor.extractIcon(browser.executablePath, outputPath);
+ } on Exception catch (e) {
+ _log.warning('Icon extraction failed for ${browser.name}: $e');
+ }
+ }
+ await registrationService.register(executablePath);
+ _log.info(
+ 'First boot: scanned ${browserService.browsers.length} browsers, registered',
+ );
+}
+
+void _handleUrl(String url, ProviderContainer container) {
+ if (looksLikeLocalFile(url)) {
+ final resolved = resolveLocalWebFile(url);
+ if (resolved == null) {
+ _log.warning('Rejected local file: ${_redactForLog(url)}');
+ return;
+ }
+ _log.info('Local file accepted: ${redactPath(resolved)}');
+ final fileUri = Uri.file(resolved).toString();
+ container.read(appStateProvider.notifier).showPicker(fileUri);
+ return;
+ }
+
+ final resolved = unwrapSafeLink(url);
+ final ruleService = container.read(ruleServiceProvider);
+ final matchedBrowserId = ruleService.lookupBrowser(resolved);
+
+ if (matchedBrowserId != null) {
+ final browsers = container.read(browserServiceProvider).browsers;
+ final browser = browsers.where((b) => b.id == matchedBrowserId).firstOrNull;
+ if (browser != null) {
+ _log.info('Rule match: ${_redactForLog(resolved)} → ${browser.name}');
+ container
+ .read(launchServiceProvider)
+ .launch(browser.executablePath, resolved, browser.extraArgs);
+ return;
+ }
+ }
+
+ container.read(appStateProvider.notifier).showPicker(resolved);
+}
+
+String _redactForLog(String raw) {
+ if (!looksLikeLocalFile(raw)) return raw;
+ if (raw.startsWith('file://')) {
+ final uri = Uri.tryParse(raw);
+ if (uri == null) return 'file://';
+ try {
+ return 'file://${redactPath(uri.toFilePath())}';
+ } on UnsupportedError {
+ return 'file://';
+ }
+ }
+ return redactPath(raw);
+}
+
+Future _initTray(
+ PlatformBindings bindings,
+ ProviderContainer container,
+) async {
+ await bindings.trayController.init(
+ title: 'LinkUnbound',
+ iconPath: bindings.trayIconPath,
+ tooltip: 'LinkUnbound — Browser Picker',
+ );
+
+ bindings.trayController.onActivated(
+ () => container.read(appStateProvider.notifier).showSettings(),
+ );
+
+ // Resolve the active locale once so the tray menu matches the user's
+ // configured language (the tray runs outside the MaterialApp tree, so
+ // `AppLocalizations.of(context)` isn't available here).
+ final locale = container.read(localeProvider);
+ final l10n = await AppLocalizations.delegate.load(
+ locale ?? const Locale('en'),
+ );
+
+ await bindings.trayController.setMenu([
+ TrayMenuItem(
+ label: l10n.traySettings,
+ onClick: () => container.read(appStateProvider.notifier).showSettings(),
+ ),
+ const TrayMenuItem.separator(),
+ TrayMenuItem(
+ label: l10n.exit,
+ onClick: () async {
+ await container.read(exitAppProvider)();
+ },
+ ),
+ ]);
+}
diff --git a/apps/linkunbound/lib/l10n/app_en.arb b/apps/linkunbound/lib/l10n/app_en.arb
index b4dc7de..5290c70 100644
--- a/apps/linkunbound/lib/l10n/app_en.arb
+++ b/apps/linkunbound/lib/l10n/app_en.arb
@@ -1,6 +1,8 @@
{
"@@locale": "en",
+ "exit": "Exit",
+ "traySettings": "Settings",
"copyUrl": "Copy URL",
"alwaysOpenHere": "Always open here",
@@ -15,7 +17,7 @@
"setDefault": "Set default",
"sectionStartup": "STARTUP",
- "launchAtStartup": "Launch at Windows startup",
+ "launchAtStartup": "Launch at system startup",
"sectionLanguage": "LANGUAGE",
"languageAuto": "Automatic (system)",
diff --git a/apps/linkunbound/lib/l10n/app_es.arb b/apps/linkunbound/lib/l10n/app_es.arb
index e839a6c..9fdf248 100644
--- a/apps/linkunbound/lib/l10n/app_es.arb
+++ b/apps/linkunbound/lib/l10n/app_es.arb
@@ -1,6 +1,8 @@
{
"@@locale": "es",
+ "exit": "Salir",
+ "traySettings": "Configuración",
"copyUrl": "Copiar URL",
"alwaysOpenHere": "Abrir siempre aquí",
@@ -15,7 +17,7 @@
"setDefault": "Establecer",
"sectionStartup": "INICIO",
- "launchAtStartup": "Iniciar con Windows",
+ "launchAtStartup": "Iniciar con el sistema",
"sectionLanguage": "IDIOMA",
"languageAuto": "Automático (sistema)",
diff --git a/apps/linkunbound/lib/l10n/app_localizations.dart b/apps/linkunbound/lib/l10n/app_localizations.dart
index 74271d2..e094129 100644
--- a/apps/linkunbound/lib/l10n/app_localizations.dart
+++ b/apps/linkunbound/lib/l10n/app_localizations.dart
@@ -98,6 +98,18 @@ abstract class AppLocalizations {
Locale('es'),
];
+ /// No description provided for @exit.
+ ///
+ /// In en, this message translates to:
+ /// **'Exit'**
+ String get exit;
+
+ /// No description provided for @traySettings.
+ ///
+ /// In en, this message translates to:
+ /// **'Settings'**
+ String get traySettings;
+
/// No description provided for @copyUrl.
///
/// In en, this message translates to:
@@ -167,7 +179,7 @@ abstract class AppLocalizations {
/// No description provided for @launchAtStartup.
///
/// In en, this message translates to:
- /// **'Launch at Windows startup'**
+ /// **'Launch at system startup'**
String get launchAtStartup;
/// No description provided for @sectionLanguage.
diff --git a/apps/linkunbound/lib/l10n/app_localizations_en.dart b/apps/linkunbound/lib/l10n/app_localizations_en.dart
index aadb93f..8882308 100644
--- a/apps/linkunbound/lib/l10n/app_localizations_en.dart
+++ b/apps/linkunbound/lib/l10n/app_localizations_en.dart
@@ -8,6 +8,12 @@ import 'app_localizations.dart';
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
+ @override
+ String get exit => 'Exit';
+
+ @override
+ String get traySettings => 'Settings';
+
@override
String get copyUrl => 'Copy URL';
@@ -42,7 +48,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get sectionStartup => 'STARTUP';
@override
- String get launchAtStartup => 'Launch at Windows startup';
+ String get launchAtStartup => 'Launch at system startup';
@override
String get sectionLanguage => 'LANGUAGE';
diff --git a/apps/linkunbound/lib/l10n/app_localizations_es.dart b/apps/linkunbound/lib/l10n/app_localizations_es.dart
index 726c0fe..ed71065 100644
--- a/apps/linkunbound/lib/l10n/app_localizations_es.dart
+++ b/apps/linkunbound/lib/l10n/app_localizations_es.dart
@@ -8,6 +8,12 @@ import 'app_localizations.dart';
class AppLocalizationsEs extends AppLocalizations {
AppLocalizationsEs([String locale = 'es']) : super(locale);
+ @override
+ String get exit => 'Salir';
+
+ @override
+ String get traySettings => 'Configuración';
+
@override
String get copyUrl => 'Copiar URL';
@@ -44,7 +50,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get sectionStartup => 'INICIO';
@override
- String get launchAtStartup => 'Iniciar con Windows';
+ String get launchAtStartup => 'Iniciar con el sistema';
@override
String get sectionLanguage => 'IDIOMA';
diff --git a/apps/linkunbound/lib/main.dart b/apps/linkunbound/lib/main.dart
index 379676e..9088bc4 100644
--- a/apps/linkunbound/lib/main.dart
+++ b/apps/linkunbound/lib/main.dart
@@ -1,307 +1,21 @@
import 'dart:io';
-import 'package:flutter/material.dart';
-import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:logging/logging.dart';
-import 'package:linkunbound_core/linkunbound_core.dart';
-import 'package:system_tray/system_tray.dart';
-import 'package:window_manager/window_manager.dart';
+import 'package:flutter/widgets.dart';
-import 'app.dart';
-import 'platform/windows/win_browser_detector.dart';
-import 'platform/windows/win_icon_extractor.dart';
-import 'platform/windows/win_instance.dart';
-import 'platform/windows/win_launch_service.dart';
-import 'platform/windows/win_pipe_server.dart';
-import 'platform/windows/win_registration_service.dart';
-import 'platform/windows/win_startup_service.dart';
-import 'providers.dart';
-import 'ui/picker/picker_layout.dart';
+import 'bootstrap.dart';
+import 'platform/macos/macos_bindings.dart';
+import 'platform/platform_bindings.dart';
+import 'platform/windows/windows_bindings.dart';
-final _log = Logger('Main');
-
-void main(List args) async {
+Future main(List args) async {
WidgetsFlutterBinding.ensureInitialized();
- final appDataDir = Directory(
- '${Platform.environment['APPDATA']}\\LinkUnbound',
- );
- final browsersFile = File('${appDataDir.path}\\browsers.json');
- final rulesFile = File('${appDataDir.path}\\rules.json');
- final logFile = File('${appDataDir.path}\\navigate.log');
- final iconsDir = Directory('${appDataDir.path}\\icons');
-
- await appDataDir.create(recursive: true);
- initLogging(logFile);
-
- final url = _extractUrl(args);
- _log.info('Started with args: $args, extracted URL: $url');
-
- if (await _tryDelegate(url)) exit(0);
-
- final instance = WinInstance();
- if (!instance.acquire()) {
- _log.warning('Mutex held but pipe unreachable — exiting');
- exit(0);
- }
-
- final browserDetector = WinBrowserDetector();
- final iconExtractor = WinIconExtractor();
- final browserService = BrowserService(
- configFile: browsersFile,
- browserDetector: browserDetector,
- );
- final ruleService = RuleService(rulesFile: rulesFile);
- final registrationService = WinRegistrationService();
- final startupService = WinStartupService();
- final launchService = WinLaunchService();
-
- final pipeServer = WinPipeServer();
- try {
- await pipeServer.start();
- } on Exception catch (e) {
- _log.warning('Pipe server failed to start: $e');
- }
-
- final isFirstBoot = !browsersFile.existsSync();
- await browserService.load();
-
- if (isFirstBoot) {
- await _firstBoot(
- browserService,
- iconExtractor,
- iconsDir,
- registrationService,
- );
+ final PlatformBindings bindings;
+ if (Platform.isMacOS) {
+ bindings = await MacOsBindings.create();
+ } else {
+ bindings = await WindowsBindings.create(args);
}
- await ruleService.load();
-
- await windowManager.ensureInitialized();
- await windowManager.setPreventClose(true);
- await windowManager.waitUntilReadyToShow(
- const WindowOptions(
- titleBarStyle: TitleBarStyle.hidden,
- size: Size(640, 700),
- center: false,
- ),
- () async {
- await windowManager.setPosition(const Offset(-9999, -9999));
- await windowManager.setSkipTaskbar(true);
- },
- );
-
- final container = ProviderContainer(
- overrides: [
- browserServiceProvider.overrideWithValue(browserService),
- ruleServiceProvider.overrideWithValue(ruleService),
- registrationServiceProvider.overrideWithValue(registrationService),
- startupServiceProvider.overrideWithValue(startupService),
- iconExtractorProvider.overrideWithValue(iconExtractor),
- iconsDirProvider.overrideWithValue(iconsDir),
- launchServiceProvider.overrideWithValue(launchService),
- localeFileProvider.overrideWithValue(File('${appDataDir.path}\\locale')),
- edgeWarningFileProvider.overrideWithValue(
- File('${appDataDir.path}\\edge_warning_dismissed'),
- ),
- appDataDirProvider.overrideWithValue(appDataDir),
- ],
- );
-
- container.read(updateInfoProvider);
-
- container.listen(appStateProvider, (prev, next) async {
- if (prev?.mode == next.mode) return;
- switch (next.mode) {
- case AppMode.hidden:
- await windowManager.hide();
- case AppMode.settings:
- await windowManager.setSize(const Size(640, 700));
- await windowManager.center();
- await windowManager.setSkipTaskbar(false);
- await windowManager.setAlwaysOnTop(false);
- case AppMode.picker:
- final browsers = container.read(browsersProvider);
- final winSize = PickerLayout.windowSize(browsers.length);
- final (cursorX, cursorY) = WinInstance.getCursorPosition();
- final (screenW, screenH) = WinInstance.getScreenSize();
- final x = (cursorX - winSize.width / 2).clamp(
- 8.0,
- screenW - winSize.width - 8,
- );
- final y = (cursorY + 16).clamp(8.0, screenH - winSize.height - 8);
- _log.info(
- 'Picker: ${browsers.length} browsers, '
- 'window=${winSize.width.toInt()}x${winSize.height.toInt()}, '
- 'pos=(${x.toInt()}, ${y.toInt()})',
- );
- await windowManager.setSize(winSize);
- await windowManager.setPosition(Offset(x, y));
- await windowManager.setSkipTaskbar(true);
- await windowManager.setAlwaysOnTop(true);
- }
- });
-
- pipeServer.messages.listen((message) {
- switch (message) {
- case OpenUrlMessage(:final url):
- _log.info('Pipe received: open_url $url');
- _handleUrl(url, container);
- case ShowSettingsMessage():
- _log.info('Pipe received: show_settings');
- container.read(appStateProvider.notifier).showSettings();
- case PingMessage():
- _log.fine('Pipe received: ping');
- }
- });
-
- await _initTray(container, instance, pipeServer);
-
- runApp(
- UncontrolledProviderScope(container: container, child: const NavigateApp()),
- );
-
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (url != null) {
- _handleUrl(url, container);
- } else if (!args.contains('--background')) {
- container.read(appStateProvider.notifier).showSettings();
- }
- });
-}
-
-Future _tryDelegate(String? url) async {
- final client = WinPipeClient();
- final message = url != null
- ? OpenUrlMessage(url)
- : const ShowSettingsMessage();
- WinInstance.allowForeground();
-
- if (await client.send(message)) {
- _log.info('Delegated to existing instance, exiting');
- return true;
- }
- return false;
-}
-
-Future _firstBoot(
- BrowserService browserService,
- IconExtractor iconExtractor,
- Directory iconsDir,
- RegistrationService registrationService,
-) async {
- await browserService.scanAndMerge();
- await iconsDir.create(recursive: true);
- for (final browser in browserService.browsers) {
- try {
- final outputPath = '${iconsDir.path}\\${browser.id}.png';
- await iconExtractor.extractIcon(browser.executablePath, outputPath);
- } on Exception catch (e) {
- _log.warning('Icon extraction failed for ${browser.name}: $e');
- }
- }
- await registrationService.register(Platform.resolvedExecutable);
- _log.info(
- 'First boot: scanned ${browserService.browsers.length} browsers, registered',
- );
-}
-
-void _handleUrl(String url, ProviderContainer container) {
- final resolved = _unwrapSafeLink(url);
- final ruleService = container.read(ruleServiceProvider);
- final matchedBrowserId = ruleService.lookupBrowser(resolved);
-
- if (matchedBrowserId != null) {
- final browsers = container.read(browserServiceProvider).browsers;
- final browser = browsers.where((b) => b.id == matchedBrowserId).firstOrNull;
- if (browser != null) {
- _log.info('Rule match: $resolved → ${browser.name}');
- container
- .read(launchServiceProvider)
- .launch(browser.executablePath, resolved, browser.extraArgs);
- return;
- }
- }
-
- container.read(appStateProvider.notifier).showPicker(resolved);
-}
-
-final _windowsAbsPath = RegExp(r'^[a-zA-Z]:[\\/]');
-
-String? _extractUrl(List args) {
- for (final arg in args) {
- final resolved = stripEdgeProtocol(arg);
- final uri = Uri.tryParse(resolved);
- if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) {
- return _unwrapSafeLink(resolved);
- }
- if (uri != null && uri.scheme == 'file') return arg;
- if (_windowsAbsPath.hasMatch(arg)) return arg;
- }
- return null;
-}
-
-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;
-
- final inner = uri.queryParameters['url'];
- if (inner != null && inner.isNotEmpty) {
- final decoded = Uri.decodeFull(inner);
- final innerUri = Uri.tryParse(decoded);
- if (innerUri != null &&
- (innerUri.scheme == 'http' || innerUri.scheme == 'https')) {
- _log.info('Unwrapped SafeLink: $decoded');
- return decoded;
- }
- }
-
- return raw;
-}
-
-Future _initTray(
- ProviderContainer container,
- WinInstance instance,
- WinPipeServer pipeServer,
-) async {
- final tray = SystemTray();
- await tray.initSystemTray(
- title: 'LinkUnbound',
- iconPath: 'assets/app_icon.ico',
- toolTip: 'LinkUnbound — Browser Picker',
- );
-
- final menu = Menu();
- await menu.buildFrom([
- MenuItemLabel(
- label: 'Settings',
- onClicked: (_) =>
- container.read(appStateProvider.notifier).showSettings(),
- ),
- MenuSeparator(),
- MenuItemLabel(
- label: 'Exit',
- onClicked: (_) async {
- await pipeServer.stop();
- instance.release();
- exit(0);
- },
- ),
- ]);
- await tray.setContextMenu(menu);
-
- tray.registerSystemTrayEventHandler((eventName) {
- switch (eventName) {
- case kSystemTrayEventDoubleClick:
- container.read(appStateProvider.notifier).showSettings();
- case kSystemTrayEventRightClick:
- tray.popUpContextMenu();
- }
- });
+ await bootstrap(bindings, args);
}
diff --git a/apps/linkunbound/lib/platform/cursor_locator.dart b/apps/linkunbound/lib/platform/cursor_locator.dart
new file mode 100644
index 0000000..aa4be8d
--- /dev/null
+++ b/apps/linkunbound/lib/platform/cursor_locator.dart
@@ -0,0 +1,26 @@
+import 'dart:ui' show Offset;
+
+import 'package:screen_retriever/screen_retriever.dart';
+
+abstract interface class CursorLocator {
+ Future<(double, double)> cursorPosition();
+
+ Future<(double, double)> screenSize();
+}
+
+final class ScreenRetrieverCursorLocator implements CursorLocator {
+ const ScreenRetrieverCursorLocator();
+
+ @override
+ Future<(double, double)> cursorPosition() async {
+ final Offset point = await screenRetriever.getCursorScreenPoint();
+ return (point.dx, point.dy);
+ }
+
+ @override
+ Future<(double, double)> screenSize() async {
+ final display = await screenRetriever.getPrimaryDisplay();
+ final size = display.visibleSize ?? display.size;
+ return (size.width, size.height);
+ }
+}
diff --git a/apps/linkunbound/lib/platform/local_file_url.dart b/apps/linkunbound/lib/platform/local_file_url.dart
new file mode 100644
index 0000000..46ee6fe
--- /dev/null
+++ b/apps/linkunbound/lib/platform/local_file_url.dart
@@ -0,0 +1,65 @@
+import 'dart:io';
+
+Set get kLocalFileWebExtensions {
+ if (Platform.isWindows) {
+ return const {'.html', '.htm', '.xhtml', '.shtml', '.mhtml', '.pdf'};
+ }
+ return const {'.html', '.htm', '.xhtml'};
+}
+
+String? resolveLocalWebFile(String raw) {
+ final path = _toFilesystemPath(raw);
+ if (path == null) return null;
+
+ if (!kLocalFileWebExtensions.contains(_extension(path))) return null;
+
+ final file = File(path);
+ if (!file.existsSync()) return null;
+
+ try {
+ return file.resolveSymbolicLinksSync();
+ } on FileSystemException {
+ return null;
+ }
+}
+
+bool looksLikeLocalFile(String raw) {
+ if (raw.startsWith('file://')) return true;
+ if (Platform.isWindows && _windowsAbsPath.hasMatch(raw)) return true;
+ return false;
+}
+
+String redactPath(String path) {
+ final parts = path
+ .split(RegExp(r'[\\/]'))
+ .where((p) => p.isNotEmpty)
+ .toList();
+ if (parts.isEmpty) return '';
+ if (parts.length == 1) return '…/${parts.last}';
+ return '…/${parts[parts.length - 2]}/${parts.last}';
+}
+
+String? _toFilesystemPath(String raw) {
+ if (raw.startsWith('file://')) {
+ final uri = Uri.tryParse(raw);
+ if (uri == null || uri.scheme != 'file') return null;
+ try {
+ return uri.toFilePath();
+ } on UnsupportedError {
+ return null;
+ }
+ }
+
+ if (Platform.isWindows && _windowsAbsPath.hasMatch(raw)) return raw;
+ return null;
+}
+
+final RegExp _windowsAbsPath = RegExp(r'^[a-zA-Z]:[\\/]');
+
+String _extension(String path) {
+ final lastSep = path.lastIndexOf(RegExp(r'[\\/]'));
+ final name = lastSep >= 0 ? path.substring(lastSep + 1) : path;
+ final dot = name.lastIndexOf('.');
+ if (dot <= 0) return '';
+ return name.substring(dot).toLowerCase();
+}
diff --git a/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart b/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart
new file mode 100644
index 0000000..30261d3
--- /dev/null
+++ b/apps/linkunbound/lib/platform/macos/mac_browser_detector.dart
@@ -0,0 +1,25 @@
+import 'package:flutter/services.dart';
+import 'package:linkunbound_core/linkunbound_core.dart';
+
+class MacBrowserDetector implements BrowserDetector {
+ static const _channel = MethodChannel('linkunbound/browser_detector');
+
+ @override
+ Future> detect() async {
+ final raw = await _channel.invokeListMethod