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. Latest Release Platform: Windows + Platform: macOS License GPL-3.0 @@ -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>( + 'detect', + ); + if (raw == null) return const []; + return raw + .map((entry) { + final m = entry.cast(); + return Browser( + id: m['id'] as String, + name: m['name'] as String, + executablePath: m['executablePath'] as String, + iconPath: '', + ); + }) + .toList(growable: false); + } +} diff --git a/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart b/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart new file mode 100644 index 0000000..64d0765 --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_diagnostics_service.dart @@ -0,0 +1,153 @@ +import 'dart:io'; + +import 'package:logging/logging.dart'; + +final _log = Logger('MacDiagnosticsService'); + +const _maxLogLines = 200; + +/// macOS counterpart of `exportDiagnostics`. Bundles a system-info report, +/// the LaunchServices URL handler dump for http/https, the rules/browsers +/// JSON snapshots, and the tail of the log into a single zip placed under +/// `appDataDir`. Reveals the result in Finder. +Future exportMacDiagnostics({ + required Directory appDataDir, + required String appVersion, +}) async { + final timestamp = DateTime.now() + .toIso8601String() + .replaceAll(':', '-') + .split('.') + .first; + final staging = await Directory.systemTemp.createTemp('lu_diag_'); + + try { + _writeSystemInfo(staging, appDataDir, appVersion); + await _writeLaunchServicesDump(staging); + _copyDataSnapshots(appDataDir, staging); + _copyLogTail(appDataDir, staging); + + final zipPath = '${appDataDir.path}/linkunbound-diag-$timestamp.zip'; + + // `zip -r -j` flattens directory structure; `-X` strips macOS extras. + final result = await Process.run('zip', [ + '-r', + '-j', + '-X', + zipPath, + staging.path, + ]); + + if (result.exitCode != 0) { + _log.warning('zip failed: ${result.stderr}'); + throw Exception('zip failed: ${result.stderr}'); + } + + // Reveal in Finder (no-op if user has Finder disabled). + await Process.run('open', ['-R', zipPath]); + + return zipPath; + } on Exception catch (e) { + _log.warning('Diagnostics export failed: $e'); + rethrow; + } finally { + try { + if (staging.existsSync()) await staging.delete(recursive: true); + } on Exception catch (e) { + _log.fine('Failed to clean staging dir: $e'); + } + } +} + +void _writeSystemInfo( + Directory staging, + Directory appDataDir, + String appVersion, +) { + final buf = StringBuffer() + ..writeln('LinkUnbound Diagnostics Report') + ..writeln('Generated: ${DateTime.now().toIso8601String()}') + ..writeln() + ..writeln('--- System ---') + ..writeln('OS: ${Platform.operatingSystemVersion}') + ..writeln('Locale: ${Platform.localeName}') + ..writeln() + ..writeln('--- Application ---') + ..writeln('Version: $appVersion') + ..writeln('Executable: ${Platform.resolvedExecutable}') + ..writeln('Data: ${appDataDir.path}') + ..writeln() + ..writeln('--- Data Files ---'); + + try { + if (appDataDir.existsSync()) { + for (final entity in appDataDir.listSync()) { + final name = entity.path.split('/').last; + if (entity is File) { + buf.writeln(' $name (${entity.lengthSync()} bytes)'); + } else if (entity is Directory) { + buf.writeln(' $name/'); + } + } + } + } on Exception catch (e) { + buf.writeln(' '); + } + + File('${staging.path}/system_info.txt').writeAsStringSync(buf.toString()); +} + +Future _writeLaunchServicesDump(Directory staging) async { + final buf = StringBuffer() + ..writeln('--- Launch Services (URL handlers) ---') + ..writeln(); + + // `lsappinfo` enumerates running apps; `Launch Services Database` lookups + // need `lsregister -dump`, but that path is unstable across macOS versions + // — fall back to recording the bundle ids reported by `defaults read` + // on the LaunchServices preference plist. + try { + final result = await Process.run('defaults', [ + 'read', + 'com.apple.LaunchServices/com.apple.launchservices.secure', + 'LSHandlers', + ]); + buf.writeln( + result.exitCode == 0 ? result.stdout : '', + ); + } on Exception catch (e) { + buf.writeln(''); + } + + File('${staging.path}/launch_services.txt').writeAsStringSync(buf.toString()); +} + +void _copyDataSnapshots(Directory appDataDir, Directory staging) { + for (final name in const ['browsers.json', 'rules.json', 'locale.json']) { + final src = File('${appDataDir.path}/$name'); + if (src.existsSync()) { + try { + src.copySync('${staging.path}/$name'); + } on Exception catch (e) { + _log.fine('Failed to copy $name: $e'); + } + } + } +} + +void _copyLogTail(Directory appDataDir, Directory staging) { + final logFile = File('${appDataDir.path}/linkunbound.log'); + if (!logFile.existsSync()) return; + + try { + final lines = logFile.readAsLinesSync(); + final tail = lines.length > _maxLogLines + ? lines.sublist(lines.length - _maxLogLines) + : lines; + File( + '${staging.path}/linkunbound.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_icon_extractor.dart b/apps/linkunbound/lib/platform/macos/mac_icon_extractor.dart new file mode 100644 index 0000000..b6bac4b --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_icon_extractor.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; + +class MacIconExtractor implements IconExtractor { + static const _channel = MethodChannel('linkunbound/icon_extractor'); + + @override + Future extractIcon(String executablePath, String outputPath) async { + if (await File(outputPath).exists()) return outputPath; + final result = await _channel.invokeMethod('extract', { + 'appPath': executablePath, + 'outputPath': outputPath, + }); + return result ?? outputPath; + } +} diff --git a/apps/linkunbound/lib/platform/macos/mac_inbound_events.dart b/apps/linkunbound/lib/platform/macos/mac_inbound_events.dart new file mode 100644 index 0000000..c71bd76 --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_inbound_events.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; + +class MacInboundEvents implements InboundEventServer { + MacInboundEvents() : _channel = const MethodChannel(_channelName); + + static const String _channelName = 'linkunbound/inbound_events'; + + final MethodChannel _channel; + // Broadcast so the bootstrap (and tests) can attach a listener after + // `start()`. The `onListen` callback is what unblocks Swift: we only ask + // it to flush pending events once we are actually subscribed, otherwise + // events delivered between `start()` and the first `.listen()` would be + // dropped — broadcast streams do not buffer. + late final StreamController _controller = + StreamController.broadcast(onListen: _signalReadyOnce); + bool _started = false; + bool _readySent = false; + + @override + Stream get events => _controller.stream; + + @override + Future start() async { + if (_started) return; + _started = true; + _channel.setMethodCallHandler(_onMethodCall); + // Note: we do NOT call `ready` here. It is deferred until a Dart + // listener attaches (see `_signalReadyOnce`) so cold-start URL events + // queued by Swift in `preBootUrls`/`pending` are not flushed into a + // listener-less broadcast stream and lost. + } + + void _signalReadyOnce() { + if (_readySent || !_started) return; + _readySent = true; + // Fire-and-forget: Swift does not return anything meaningful and we do + // not want to block the listener attaching path on a platform round-trip. + unawaited(_channel.invokeMethod('ready')); + } + + @override + Future stop() async { + if (!_started) return; + _started = false; + _readySent = false; + _channel.setMethodCallHandler(null); + if (!_controller.isClosed) await _controller.close(); + } + + Future _onMethodCall(MethodCall call) async { + if (call.method != 'event') return; + final args = (call.arguments as Map?)?.cast(); + if (args == null) return; + try { + _controller.add(InboundEvent.fromJson(args)); + } on FormatException { + // ignore unknown actions for forward-compat + } + } +} diff --git a/apps/linkunbound/lib/platform/macos/mac_launch_service.dart b/apps/linkunbound/lib/platform/macos/mac_launch_service.dart new file mode 100644 index 0000000..4002cbd --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_launch_service.dart @@ -0,0 +1,27 @@ +import 'dart:io'; + +import 'package:linkunbound_core/linkunbound_core.dart'; + +/// Launches a browser via `/usr/bin/open`. +/// +/// `executablePath` is the absolute path to the `.app` bundle (as returned by +/// `MacBrowserDetector`). `extraArgs` are forwarded as program arguments via +/// `--args`. The child process is detached so closing LinkUnbound does not kill +/// the browser. +class MacLaunchService implements LaunchService { + @override + 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); + } + 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 new file mode 100644 index 0000000..192b63c --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_registration_service.dart @@ -0,0 +1,28 @@ +import 'package:flutter/services.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; + +class MacRegistrationService implements RegistrationService { + static const _channel = MethodChannel('linkunbound/registration'); + + @override + Future register(String executablePath) async { + await _channel.invokeMethod('register'); + } + + @override + Future unregister() async { + await _channel.invokeMethod('unregister'); + } + + @override + Future get isDefault async { + final result = await _channel.invokeMethod('isDefault'); + return result ?? false; + } + + @override + Future> get defaultAssociations async { + final list = await _channel.invokeListMethod('defaultAssociations'); + return (list ?? const []).toSet(); + } +} diff --git a/apps/linkunbound/lib/platform/macos/mac_startup_service.dart b/apps/linkunbound/lib/platform/macos/mac_startup_service.dart new file mode 100644 index 0000000..acbcc94 --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_startup_service.dart @@ -0,0 +1,22 @@ +import 'package:flutter/services.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; + +class MacStartupService implements StartupService { + static const _channel = MethodChannel('linkunbound/startup'); + + @override + Future enable(String executablePath) async { + await _channel.invokeMethod('enable'); + } + + @override + Future disable() async { + await _channel.invokeMethod('disable'); + } + + @override + Future get isEnabled async { + final result = await _channel.invokeMethod('isEnabled'); + return result ?? false; + } +} diff --git a/apps/linkunbound/lib/platform/macos/mac_window_channel.dart b/apps/linkunbound/lib/platform/macos/mac_window_channel.dart new file mode 100644 index 0000000..fcad516 --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/mac_window_channel.dart @@ -0,0 +1,12 @@ +import 'package:flutter/services.dart'; + +class MacWindowChannel { + static const _channel = MethodChannel('linkunbound/window'); + + Future setPickerMode() => _channel.invokeMethod('setPickerMode'); + + Future setSettingsMode() => + _channel.invokeMethod('setSettingsMode'); + + Future activate() => _channel.invokeMethod('activate'); +} diff --git a/apps/linkunbound/lib/platform/macos/macos_bindings.dart b/apps/linkunbound/lib/platform/macos/macos_bindings.dart new file mode 100644 index 0000000..827b9b5 --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/macos_bindings.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../cursor_locator.dart'; +import '../platform_bindings.dart'; +import '../tray_controller.dart'; +import 'mac_browser_detector.dart'; +import 'mac_icon_extractor.dart'; +import 'mac_inbound_events.dart'; +import 'mac_launch_service.dart'; +import 'mac_registration_service.dart'; +import 'mac_startup_service.dart'; +import 'macos_tray_controller.dart'; + +final _log = Logger('MacOsBindings'); + +final class MacOsBindings implements PlatformBindings { + MacOsBindings._({ + required this.browserDetector, + required this.iconExtractor, + required this.registrationService, + required this.startupService, + required this.launchService, + required this.trayController, + required this.cursorLocator, + required this.appDataDir, + required this.iconsDir, + required this.browsersFile, + required this.rulesFile, + required this.logFile, + required this.localeFile, + required this.edgeWarningFile, + required MacInboundEvents inboundServer, + }) : _inboundServer = inboundServer; + + static Future create() async { + final supportDir = await getApplicationSupportDirectory(); + final appDataDir = Directory('${supportDir.path}/LinkUnbound'); + await appDataDir.create(recursive: true); + + return MacOsBindings._( + browserDetector: MacBrowserDetector(), + iconExtractor: MacIconExtractor(), + registrationService: MacRegistrationService(), + startupService: MacStartupService(), + launchService: MacLaunchService(), + trayController: MacOsTrayController(), + cursorLocator: const ScreenRetrieverCursorLocator(), + appDataDir: appDataDir, + iconsDir: Directory('${appDataDir.path}/icons'), + browsersFile: File('${appDataDir.path}/browsers.json'), + rulesFile: File('${appDataDir.path}/rules.json'), + logFile: File('${appDataDir.path}/navigate.log'), + localeFile: File('${appDataDir.path}/locale'), + edgeWarningFile: File('${appDataDir.path}/edge_warning_dismissed'), + inboundServer: MacInboundEvents(), + ); + } + + @override + final BrowserDetector browserDetector; + @override + final IconExtractor iconExtractor; + @override + final RegistrationService registrationService; + @override + final StartupService startupService; + @override + final LaunchService launchService; + @override + final TrayController trayController; + @override + final CursorLocator cursorLocator; + @override + final Directory appDataDir; + @override + final Directory iconsDir; + @override + final File browsersFile; + @override + final File rulesFile; + @override + final File logFile; + @override + final File localeFile; + @override + final File edgeWarningFile; + + final MacInboundEvents _inboundServer; + + @override + InboundEvent? get initialEvent => null; + + @override + Stream get inboundEvents => _inboundServer.events; + + @override + String get executablePath => Platform.resolvedExecutable; + + @override + String get trayIconPath => 'assets/LinkUnbound_tray_64.png'; + + @override + bool get startsHidden => false; + + @override + Future tryDelegate(InboundEvent? event) async => false; + + @override + Future claim() async { + await _inboundServer.start(); + _log.info('macOS bindings ready'); + return true; + } + + @override + Future release() async { + await trayController.dispose(); + await _inboundServer.stop(); + } +} diff --git a/apps/linkunbound/lib/platform/macos/macos_tray_controller.dart b/apps/linkunbound/lib/platform/macos/macos_tray_controller.dart new file mode 100644 index 0000000..90723d5 --- /dev/null +++ b/apps/linkunbound/lib/platform/macos/macos_tray_controller.dart @@ -0,0 +1,86 @@ +import 'dart:ui' show VoidCallback; + +import 'package:tray_manager/tray_manager.dart' as tm; + +import '../tray_controller.dart'; + +/// macOS implementation of [TrayController] using the `tray_manager` package. +/// +/// Menu bar idiom: +/// - **Left click** on the icon → invokes the activation callback (Settings). +/// - **Right click** on the icon → shows the context menu. +/// +/// On macOS the context menu is NOT attached to `NSStatusItem.menu`, so both +/// clicks are delivered to the Flutter side via `TrayListener` callbacks and +/// we explicitly call `popUpContextMenu()` on right click. +final class MacOsTrayController implements TrayController, tm.TrayListener { + VoidCallback? _onActivated; + bool _listenerAttached = false; + + @override + Future init({ + required String title, + required String iconPath, + required String tooltip, + }) async { + if (!_listenerAttached) { + tm.trayManager.addListener(this); + _listenerAttached = true; + } + + // The bundled icon is a white silhouette (template), so let macOS auto-tint + // it for light/dark menu bar appearance. + await tm.trayManager.setIcon(iconPath, isTemplate: true); + await tm.trayManager.setToolTip(tooltip); + } + + @override + Future setMenu(List items) async { + final menu = tm.Menu( + items: [ + for (final item in items) + if (item.isSeparator) + tm.MenuItem.separator() + else + tm.MenuItem( + label: item.label!, + onClick: (_) => item.onClick?.call(), + ), + ], + ); + await tm.trayManager.setContextMenu(menu); + } + + @override + void onActivated(VoidCallback callback) { + _onActivated = callback; + } + + @override + Future dispose() async { + if (_listenerAttached) { + tm.trayManager.removeListener(this); + _listenerAttached = false; + } + await tm.trayManager.destroy(); + } + + // --- TrayListener ----------------------------------------------------- + + @override + void onTrayIconMouseDown() => _onActivated?.call(); + + @override + void onTrayIconMouseUp() {} + + @override + void onTrayIconRightMouseDown() { + tm.trayManager.popUpContextMenu(); + } + + @override + void onTrayIconRightMouseUp() {} + + @override + void onTrayMenuItemClick(tm.MenuItem menuItem) {} +} diff --git a/apps/linkunbound/lib/platform/platform_bindings.dart b/apps/linkunbound/lib/platform/platform_bindings.dart new file mode 100644 index 0000000..1c45cf5 --- /dev/null +++ b/apps/linkunbound/lib/platform/platform_bindings.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:linkunbound_core/linkunbound_core.dart'; + +import 'cursor_locator.dart'; +import 'tray_controller.dart'; + +abstract class PlatformBindings { + BrowserDetector get browserDetector; + IconExtractor get iconExtractor; + RegistrationService get registrationService; + StartupService get startupService; + LaunchService get launchService; + TrayController get trayController; + CursorLocator get cursorLocator; + + /// Event derived from process arguments (Windows: argv URL; macOS: always null). + InboundEvent? get initialEvent; + + /// Stream of inbound events from the OS once this process is the resident. + /// Includes the [initialEvent] (if any) followed by subsequent OS events. + Stream get inboundEvents; + + /// Try to forward [event] to an existing resident instance. + /// Returns true if delegation succeeded and the caller should exit. + Future tryDelegate(InboundEvent? event); + + /// Become the resident instance (single-instance lock + start listening). + /// Returns false if another resident already exists and could not be reached. + Future claim(); + + /// Release single-instance resources. + Future release(); + + String get executablePath; + String get trayIconPath; + + bool get startsHidden; + + Directory get appDataDir; + Directory get iconsDir; + File get browsersFile; + File get rulesFile; + File get logFile; + File get localeFile; + File get edgeWarningFile; +} diff --git a/apps/linkunbound/lib/platform/tray_controller.dart b/apps/linkunbound/lib/platform/tray_controller.dart new file mode 100644 index 0000000..03d1845 --- /dev/null +++ b/apps/linkunbound/lib/platform/tray_controller.dart @@ -0,0 +1,28 @@ +import 'dart:ui' show VoidCallback; + +abstract interface class TrayController { + Future init({ + required String title, + required String iconPath, + required String tooltip, + }); + + Future setMenu(List items); + + void onActivated(VoidCallback callback); + + Future dispose(); +} + +final class TrayMenuItem { + const TrayMenuItem({this.label, this.onClick, this.isSeparator = false}); + + const TrayMenuItem.separator() + : label = null, + onClick = null, + isSeparator = true; + + final String? label; + final VoidCallback? onClick; + final bool isSeparator; +} diff --git a/apps/linkunbound/lib/platform/windows/win_instance.dart b/apps/linkunbound/lib/platform/windows/win_instance.dart index 5e4064e..76835b5 100644 --- a/apps/linkunbound/lib/platform/windows/win_instance.dart +++ b/apps/linkunbound/lib/platform/windows/win_instance.dart @@ -101,35 +101,4 @@ final class WinInstance { >('AllowSetForegroundWindow'); allowSetForegroundWindow(_asfwAny); } - - static (double, double) getCursorPosition() { - final getCursorPos = _user32 - .lookupFunction< - Int32 Function(Pointer<_POINT>), - int Function(Pointer<_POINT>) - >('GetCursorPos'); - final point = calloc<_POINT>(); - try { - getCursorPos(point); - return (point.ref.x.toDouble(), point.ref.y.toDouble()); - } finally { - calloc.free(point); - } - } - - static (double, double) getScreenSize() { - final getSystemMetrics = _user32 - .lookupFunction( - 'GetSystemMetrics', - ); - return (getSystemMetrics(0).toDouble(), getSystemMetrics(1).toDouble()); - } -} - -final class _POINT extends Struct { - @Int32() - external int x; - - @Int32() - external int y; } diff --git a/apps/linkunbound/lib/platform/windows/win_launch_service.dart b/apps/linkunbound/lib/platform/windows/win_launch_service.dart index 9ec5c88..5c34c4c 100644 --- a/apps/linkunbound/lib/platform/windows/win_launch_service.dart +++ b/apps/linkunbound/lib/platform/windows/win_launch_service.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:logging/logging.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; +import '../local_file_url.dart'; + final _log = Logger('WinLaunchService'); final class WinLaunchService implements LaunchService { @@ -13,7 +15,24 @@ final class WinLaunchService implements LaunchService { List extraArgs, ) async { final args = [...extraArgs, url]; - _log.info('Launching $executablePath with args: $args'); + _log.info( + 'Launching ${redactPath(executablePath)} with ' + '${extraArgs.length} extra arg(s), target=${_redactTarget(url)}', + ); await Process.start(executablePath, args, mode: ProcessStartMode.detached); } + + String _redactTarget(String url) { + if (looksLikeLocalFile(url)) { + if (url.startsWith('file://')) { + try { + return 'file://${redactPath(Uri.parse(url).toFilePath())}'; + } on Exception { + return 'file://'; + } + } + return redactPath(url); + } + return url; + } } diff --git a/apps/linkunbound/lib/platform/windows/win_pipe_server.dart b/apps/linkunbound/lib/platform/windows/win_pipe_server.dart index 3e929c1..7ab02f8 100644 --- a/apps/linkunbound/lib/platform/windows/win_pipe_server.dart +++ b/apps/linkunbound/lib/platform/windows/win_pipe_server.dart @@ -144,14 +144,14 @@ final class _NativePipe { .lookupFunction<_CancelIoExNative, _CancelIoExDart>('CancelIoEx'); } -final class WinPipeServer implements PipeServer { - final _controller = StreamController.broadcast(); +final class WinPipeServer implements InboundEventServer { + final _controller = StreamController.broadcast(); Isolate? _isolate; ReceivePort? _receivePort; int _pipeHandle = 0; @override - Stream get messages => _controller.stream; + Stream get events => _controller.stream; @override Future start() async { @@ -161,10 +161,10 @@ final class WinPipeServer implements PipeServer { _receivePort!.listen((data) { if (data is String) { try { - final message = PipeMessage.decode(data); - _controller.add(message); + final event = InboundEvent.decode(data); + _controller.add(event); } on FormatException catch (e) { - _log.warning('Invalid pipe message: $e'); + _log.warning('Invalid inbound event: $e'); } } else if (data is int) { _pipeHandle = data; @@ -256,9 +256,9 @@ final class WinPipeServer implements PipeServer { } } -final class WinPipeClient implements PipeClient { +final class WinPipeClient implements InboundEventClient { @override - Future send(PipeMessage message) async { + Future send(InboundEvent event) async { final pipeName = _pipeName.toNativeUtf16(); final handle = _NativePipe.createFile( pipeName, @@ -277,7 +277,7 @@ final class WinPipeClient implements PipeClient { } try { - final data = utf8.encode(message.encode()); + final data = utf8.encode(event.encode()); final buffer = calloc(data.length); for (var i = 0; i < data.length; i++) { buffer[i] = data[i]; diff --git a/apps/linkunbound/lib/platform/windows/windows_bindings.dart b/apps/linkunbound/lib/platform/windows/windows_bindings.dart new file mode 100644 index 0000000..2ed0ce7 --- /dev/null +++ b/apps/linkunbound/lib/platform/windows/windows_bindings.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:logging/logging.dart'; + +import '../cursor_locator.dart'; +import '../platform_bindings.dart'; +import '../tray_controller.dart'; +import 'win_browser_detector.dart'; +import 'win_icon_extractor.dart'; +import 'win_instance.dart'; +import 'win_launch_service.dart'; +import 'win_pipe_server.dart'; +import 'win_registration_service.dart'; +import 'win_startup_service.dart'; +import 'windows_tray_controller.dart'; + +final _log = Logger('WindowsBindings'); +final _windowsAbsPath = RegExp(r'^[a-zA-Z]:[\\/]'); + +final class WindowsBindings implements PlatformBindings { + WindowsBindings._({ + required this.browserDetector, + required this.iconExtractor, + required this.registrationService, + required this.startupService, + required this.launchService, + required this.trayController, + required this.cursorLocator, + required this.appDataDir, + required this.iconsDir, + required this.browsersFile, + required this.rulesFile, + required this.logFile, + required this.localeFile, + required this.edgeWarningFile, + required this.initialEvent, + required this.startsHidden, + required WinInstance instance, + required WinPipeServer pipeServer, + }) : _instance = instance, + _pipeServer = pipeServer; + + static Future create(List args) async { + final appDataDir = Directory( + '${Platform.environment['APPDATA']}\\LinkUnbound', + ); + await appDataDir.create(recursive: true); + + return WindowsBindings._( + browserDetector: WinBrowserDetector(), + iconExtractor: WinIconExtractor(), + registrationService: WinRegistrationService(), + startupService: WinStartupService(), + launchService: WinLaunchService(), + trayController: WindowsTrayController(), + cursorLocator: const ScreenRetrieverCursorLocator(), + appDataDir: appDataDir, + iconsDir: Directory('${appDataDir.path}\\icons'), + browsersFile: File('${appDataDir.path}\\browsers.json'), + rulesFile: File('${appDataDir.path}\\rules.json'), + logFile: File('${appDataDir.path}\\navigate.log'), + localeFile: File('${appDataDir.path}\\locale'), + edgeWarningFile: File('${appDataDir.path}\\edge_warning_dismissed'), + initialEvent: _parseInitialEvent(args), + startsHidden: args.contains('--background'), + instance: WinInstance(), + pipeServer: WinPipeServer(), + ); + } + + @override + final BrowserDetector browserDetector; + @override + final IconExtractor iconExtractor; + @override + final RegistrationService registrationService; + @override + final StartupService startupService; + @override + final LaunchService launchService; + @override + final TrayController trayController; + @override + final CursorLocator cursorLocator; + @override + final Directory appDataDir; + @override + final Directory iconsDir; + @override + final File browsersFile; + @override + final File rulesFile; + @override + final File logFile; + @override + final File localeFile; + @override + final File edgeWarningFile; + @override + final InboundEvent? initialEvent; + @override + final bool startsHidden; + + final WinInstance _instance; + final WinPipeServer _pipeServer; + + @override + String get executablePath => Platform.resolvedExecutable; + + @override + String get trayIconPath => 'assets/app_icon.ico'; + + @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; + } + + @override + Future tryDelegate(InboundEvent? event) async { + final client = WinPipeClient(); + final payload = event ?? const ShowSettingsEvent(); + WinInstance.allowForeground(); + if (await client.send(payload)) { + _log.info('Delegated to existing instance'); + return true; + } + return false; + } + + @override + Future claim() async { + if (!_instance.acquire()) { + _log.warning('Mutex held but pipe unreachable'); + return false; + } + try { + await _pipeServer.start(); + } on Exception catch (e) { + _log.warning('Pipe server failed to start: $e'); + } + return true; + } + + @override + Future release() async { + await _pipeServer.stop(); + _instance.release(); + } + + static InboundEvent? _parseInitialEvent(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 OpenUrlEvent(resolved); + } + if (uri != null && uri.scheme == 'file') return OpenUrlEvent(arg); + if (_windowsAbsPath.hasMatch(arg)) return OpenUrlEvent(arg); + } + return null; + } +} diff --git a/apps/linkunbound/lib/platform/windows/windows_tray_controller.dart b/apps/linkunbound/lib/platform/windows/windows_tray_controller.dart new file mode 100644 index 0000000..046ebfa --- /dev/null +++ b/apps/linkunbound/lib/platform/windows/windows_tray_controller.dart @@ -0,0 +1,58 @@ +import 'dart:ui' show VoidCallback; + +import 'package:system_tray/system_tray.dart'; + +import '../tray_controller.dart'; + +final class WindowsTrayController implements TrayController { + final SystemTray _tray = SystemTray(); + VoidCallback? _onActivated; + + @override + Future init({ + required String title, + required String iconPath, + required String tooltip, + }) async { + await _tray.initSystemTray( + title: title, + iconPath: iconPath, + toolTip: tooltip, + ); + + _tray.registerSystemTrayEventHandler((eventName) { + switch (eventName) { + case kSystemTrayEventDoubleClick: + _onActivated?.call(); + case kSystemTrayEventRightClick: + _tray.popUpContextMenu(); + } + }); + } + + @override + Future setMenu(List items) async { + final menu = Menu(); + await menu.buildFrom([ + for (final item in items) + if (item.isSeparator) + MenuSeparator() + else + MenuItemLabel( + label: item.label!, + onClicked: (_) => item.onClick?.call(), + ), + ]); + await _tray.setContextMenu(menu); + } + + @override + void onActivated(VoidCallback callback) { + _onActivated = callback; + } + + @override + Future dispose() async { + await _tray.destroy(); + } +} diff --git a/apps/linkunbound/lib/providers.dart b/apps/linkunbound/lib/providers.dart index 49f0a5d..82c10e2 100644 --- a/apps/linkunbound/lib/providers.dart +++ b/apps/linkunbound/lib/providers.dart @@ -37,6 +37,11 @@ final edgeWarningFileProvider = Provider((_) => throw _mustOverride()); final appDataDirProvider = Provider((_) => throw _mustOverride()); +/// Async callback that releases platform resources and terminates the process. +/// Overridden at startup with `bindings.release()` + `exit(0)`. +typedef ExitAppCallback = Future Function(); +final exitAppProvider = Provider((_) => throw _mustOverride()); + final edgeWarningDismissedProvider = NotifierProvider(EdgeWarningNotifier.new); @@ -82,7 +87,7 @@ final class LocaleNotifier extends Notifier { enum AppMode { hidden, settings, picker } final class AppState { - const AppState({this.mode = AppMode.hidden, this.pendingUrl}); + AppState({this.mode = AppMode.hidden, this.pendingUrl}); final AppMode mode; final String? pendingUrl; } @@ -93,14 +98,14 @@ final appStateProvider = NotifierProvider( final class AppStateNotifier extends Notifier { @override - AppState build() => const AppState(); + AppState build() => AppState(); - void showSettings() => state = const AppState(mode: AppMode.settings); + void showSettings() => state = AppState(mode: AppMode.settings); void showPicker(String url) => state = AppState(mode: AppMode.picker, pendingUrl: url); - void hide() => state = const AppState(); + void hide() => state = AppState(); } final browsersProvider = NotifierProvider>( diff --git a/apps/linkunbound/lib/ui/picker/picker_view.dart b/apps/linkunbound/lib/ui/picker/picker_view.dart index a4c452a..cc4560f 100644 --- a/apps/linkunbound/lib/ui/picker/picker_view.dart +++ b/apps/linkunbound/lib/ui/picker/picker_view.dart @@ -4,13 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; -import 'package:logging/logging.dart'; import '../../l10n/app_localizations.dart'; import '../../providers.dart'; -final _log = Logger('PickerView'); - class PickerView extends ConsumerStatefulWidget { const PickerView({required this.url, super.key}); @@ -29,9 +26,13 @@ class _PickerViewState extends ConsumerState { final browsers = ref.watch(browsersProvider); final iconsDir = ref.read(iconsDirProvider); final uri = Uri.tryParse(widget.url); - final domain = uri?.host ?? widget.url; - - _log.info('Building: ${browsers.length} browsers, domain=$domain'); + final isLocalFile = uri?.scheme == 'file'; + // For local files show just the filename in the bold line — the full + // path is privacy-sensitive (it can leak `$HOME`/project paths) and is + // already redacted in logs. Hover the row to copy the URL if needed. + final domain = isLocalFile + ? (uri!.pathSegments.isNotEmpty ? uri.pathSegments.last : widget.url) + : (uri?.host ?? widget.url); return Focus( autofocus: true, @@ -54,7 +55,7 @@ class _PickerViewState extends ConsumerState { }, child: Column( children: [ - _UrlHeader(url: widget.url, domain: domain), + _UrlHeader(url: widget.url, domain: domain, isLocalFile: isLocalFile), Divider(height: 0.5, color: colors.outline.withAlpha(50)), Expanded( child: ListView.builder( @@ -62,7 +63,7 @@ class _PickerViewState extends ConsumerState { itemCount: browsers.length, itemBuilder: (context, index) => _BrowserRow( browser: browsers[index], - iconPath: '${iconsDir.path}\\${browsers[index].id}.png', + iconPath: '${iconsDir.path}/${browsers[index].id}.png', shortcut: index < 9 ? '${index + 1}' : null, onTap: () => _launch(browsers[index], iconsDir), ), @@ -110,19 +111,32 @@ class _PickerViewState extends ConsumerState { } class _UrlHeader extends StatelessWidget { - const _UrlHeader({required this.url, required this.domain}); + const _UrlHeader({ + required this.url, + required this.domain, + required this.isLocalFile, + }); final String url; final String domain; + final bool isLocalFile; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + // For local files, show only the parent directory in the secondary line + // (`…/parent/file.html`) instead of the full path — same redaction rule + // we use for logs. Hover on the copy button to copy the full URL. + final secondary = isLocalFile ? _redactedFilePath(url) : url; return Padding( padding: const EdgeInsets.fromLTRB(14, 12, 8, 10), child: Row( children: [ - Icon(Icons.link, size: 16, color: colors.primary), + Icon( + isLocalFile ? Icons.insert_drive_file_outlined : Icons.link, + size: 16, + color: colors.primary, + ), const SizedBox(width: 8), Expanded( child: Column( @@ -138,7 +152,7 @@ class _UrlHeader extends StatelessWidget { overflow: TextOverflow.ellipsis, ), Text( - url, + secondary, style: TextStyle( fontSize: 11, color: colors.onSurfaceVariant, @@ -162,6 +176,17 @@ class _UrlHeader extends StatelessWidget { } } +/// Returns `…/parent/file.html` for a `file://` URL — strips `$HOME` and +/// project paths from the visible UI. +String _redactedFilePath(String fileUrl) { + final uri = Uri.tryParse(fileUrl); + if (uri == null) return fileUrl; + final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (segs.isEmpty) return fileUrl; + if (segs.length == 1) return '…/${segs.last}'; + return '…/${segs[segs.length - 2]}/${segs.last}'; +} + class _BrowserRow extends StatefulWidget { const _BrowserRow({ required this.browser, diff --git a/apps/linkunbound/lib/ui/picker/picker_window.dart b/apps/linkunbound/lib/ui/picker/picker_window.dart index f116831..b0588c6 100644 --- a/apps/linkunbound/lib/ui/picker/picker_window.dart +++ b/apps/linkunbound/lib/ui/picker/picker_window.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; @@ -23,6 +25,7 @@ class _PickerWindowState extends ConsumerState late final Animation _fadeAnim; late final Animation _scaleAnim; bool _active = false; + Timer? _activeTimer; @override void initState() { @@ -38,14 +41,15 @@ class _PickerWindowState extends ConsumerState end: 1.0, ).animate(CurvedAnimation(parent: _animController, curve: Curves.easeOut)); _animController.forward(); - Future.delayed(const Duration(milliseconds: 200), () { - if (mounted) _active = true; + _activeTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) setState(() => _active = true); }); } @override void dispose() { windowManager.removeListener(this); + _activeTimer?.cancel(); _animController.dispose(); super.dispose(); } diff --git a/apps/linkunbound/lib/ui/settings/general_page.dart b/apps/linkunbound/lib/ui/settings/general_page.dart index 62cafcd..6fd8393 100644 --- a/apps/linkunbound/lib/ui/settings/general_page.dart +++ b/apps/linkunbound/lib/ui/settings/general_page.dart @@ -14,7 +14,12 @@ import '../shared/widgets/section_header.dart'; class GeneralPage extends ConsumerWidget { const GeneralPage({super.key}); + // On macOS LinkUnbound only registers as handler for http/https schemes + // (Launch Services treats `public.html` separately and rarely surfaces it + // to the user). The .htm/.html/.pdf extensions are Windows-only concepts + // exposed via the registry. static const _allAssociations = ['http', 'https', '.htm', '.html', '.pdf']; + static const _macAssociations = ['http', 'https']; @override Widget build(BuildContext context, WidgetRef ref) { @@ -58,6 +63,7 @@ class GeneralPage extends ConsumerWidget { final l10n = AppLocalizations.of(context)!; final isDefault = isDefaultAsync.valueOrNull == true; final associations = associationsAsync.valueOrNull ?? {}; + final assocList = Platform.isMacOS ? _macAssociations : _allAssociations; return [ SectionHeader(label: l10n.sectionDefaultBrowser), @@ -85,7 +91,9 @@ class GeneralPage extends ConsumerWidget { TextButton( onPressed: () => launchUrl( Uri.parse( - 'ms-settings:defaultapps?registeredAppUser=LinkUnbound', + Platform.isMacOS + ? 'x-apple.systempreferences:com.apple.preference.general' + : 'ms-settings:defaultapps?registeredAppUser=LinkUnbound', ), ), child: Text(l10n.setDefault), @@ -97,7 +105,7 @@ class GeneralPage extends ConsumerWidget { child: Text.rich( TextSpan( children: - _allAssociations + assocList .map((a) { final label = a.replaceAll('.', '').toUpperCase(); final active = associations.contains(a); @@ -339,7 +347,7 @@ class GeneralPage extends ConsumerWidget { return BrowserTile( key: ValueKey(b.id), name: b.name, - iconPath: '${iconsDir.path}\\${b.id}.png', + iconPath: '${iconsDir.path}/${b.id}.png', onTap: () => _showEditBrowserDialog(context, ref, b), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -418,7 +426,7 @@ class GeneralPage extends ConsumerWidget { try { await iconExtractor.extractIcon( browser.executablePath, - '${iconsDir.path}\\${browser.id}.png', + '${iconsDir.path}/${browser.id}.png', ); } on Exception { // Best-effort icon extraction @@ -471,8 +479,8 @@ class GeneralPage extends ConsumerWidget { await ref.read(browsersProvider.notifier).add(copy); final iconsDir = ref.read(iconsDirProvider); - final sourceIcon = File('${iconsDir.path}\\${source.id}.png'); - final destIcon = File('${iconsDir.path}\\$copyId.png'); + final sourceIcon = File('${iconsDir.path}/${source.id}.png'); + final destIcon = File('${iconsDir.path}/$copyId.png'); if (sourceIcon.existsSync()) { await sourceIcon.copy(destIcon.path); } @@ -621,7 +629,7 @@ class GeneralPage extends ConsumerWidget { ) async { final iconsDir = ref.read(iconsDirProvider); final iconSource = customIcon.isNotEmpty ? customIcon : exePath; - final iconDest = File('${iconsDir.path}\\$browserId.png'); + final iconDest = File('${iconsDir.path}/$browserId.png'); if (customIcon.isNotEmpty && iconDest.existsSync()) { await iconDest.delete(); @@ -630,7 +638,7 @@ class GeneralPage extends ConsumerWidget { try { await ref .read(iconExtractorProvider) - .extractIcon(iconSource, '${iconsDir.path}\\$browserId.png'); + .extractIcon(iconSource, '${iconsDir.path}/$browserId.png'); } on Exception { // Best-effort } diff --git a/apps/linkunbound/lib/ui/settings/maintenance_page.dart b/apps/linkunbound/lib/ui/settings/maintenance_page.dart index 4406f7b..12c5324 100644 --- a/apps/linkunbound/lib/ui/settings/maintenance_page.dart +++ b/apps/linkunbound/lib/ui/settings/maintenance_page.dart @@ -1,7 +1,10 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../l10n/app_localizations.dart'; +import '../../platform/macos/mac_diagnostics_service.dart'; import '../../platform/windows/win_diagnostics_service.dart'; import '../../providers.dart'; import '../shared/widgets/base_dialog.dart'; @@ -65,7 +68,11 @@ class MaintenancePage extends ConsumerWidget { final version = ref.read(packageInfoProvider).valueOrNull?.version ?? 'unknown'; - await exportDiagnostics(appDataDir: appDataDir, appVersion: version); + if (Platform.isMacOS) { + await exportMacDiagnostics(appDataDir: appDataDir, appVersion: version); + } else { + await exportDiagnostics(appDataDir: appDataDir, appVersion: version); + } } on Exception { // Best-effort } finally { @@ -93,7 +100,7 @@ class MaintenancePage extends ConsumerWidget { try { await iconExtractor.extractIcon( browser.executablePath, - '${iconsDir.path}\\${browser.id}.png', + '${iconsDir.path}/${browser.id}.png', ); } on Exception { // Best-effort diff --git a/apps/linkunbound/lib/ui/settings/settings_view.dart b/apps/linkunbound/lib/ui/settings/settings_view.dart index 34f50f6..f0bf837 100644 --- a/apps/linkunbound/lib/ui/settings/settings_view.dart +++ b/apps/linkunbound/lib/ui/settings/settings_view.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:linkunbound_core/linkunbound_core.dart'; @@ -62,6 +64,12 @@ class _SettingsViewState extends ConsumerState await windowManager.hide(); ref.read(appStateProvider.notifier).hide(); }, + onExit: Platform.isMacOS + ? () async { + await windowManager.hide(); + ref.read(appStateProvider.notifier).hide(); + } + : null, ), Divider( height: 0.5, diff --git a/apps/linkunbound/lib/ui/shared/widgets/title_bar.dart b/apps/linkunbound/lib/ui/shared/widgets/title_bar.dart index 0d8e38e..5b5bf07 100644 --- a/apps/linkunbound/lib/ui/shared/widgets/title_bar.dart +++ b/apps/linkunbound/lib/ui/shared/widgets/title_bar.dart @@ -1,21 +1,29 @@ +import 'dart:io' show Platform; + import 'package:flutter/material.dart'; import 'package:window_manager/window_manager.dart'; +import '../../../l10n/app_localizations.dart'; + class TitleBar extends StatelessWidget { const TitleBar({ required this.tabController, required this.tabs, required this.onClose, + this.onExit, super.key, }); final TabController tabController; final List tabs; final VoidCallback onClose; + final VoidCallback? onExit; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final isMac = Platform.isMacOS; + final l10n = AppLocalizations.of(context); return GestureDetector( onPanStart: (_) => windowManager.startDragging(), @@ -24,6 +32,8 @@ class TitleBar extends StatelessWidget { color: colors.surface, child: Row( children: [ + // macOS traffic lights are hidden via WindowChannel; reserve a + // small left padding to align the title with the rest of the UI. const SizedBox(width: 12), Image.asset( 'assets/app_icon.png', @@ -65,7 +75,33 @@ class TitleBar extends StatelessWidget { tabs: tabs.map((t) => Tab(height: 32, text: t)).toList(), ), ), - _CloseButton(onClose: onClose), + // On macOS the red traffic-light button hides the window; + // expose an explicit "Exit" button so the user can fully quit + // (matches the tray menu, since the dock icon is suppressed). + if (!isMac) _CloseButton(onClose: onClose), + if (isMac) ...[ + if (onExit != null) + Padding( + padding: const EdgeInsets.only(right: 8), + child: TextButton.icon( + onPressed: onExit, + icon: const Icon(Icons.power_settings_new, size: 14), + label: Text(l10n?.exit ?? 'Exit'), + style: TextButton.styleFrom( + foregroundColor: colors.onSurfaceVariant, + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: const Size(0, 28), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + else + const SizedBox(width: 12), + ], ], ), ), diff --git a/apps/linkunbound/macos/.gitignore b/apps/linkunbound/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/apps/linkunbound/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/apps/linkunbound/macos/Flutter/Flutter-Debug.xcconfig b/apps/linkunbound/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/apps/linkunbound/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/linkunbound/macos/Flutter/Flutter-Release.xcconfig b/apps/linkunbound/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/apps/linkunbound/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/linkunbound/macos/Podfile b/apps/linkunbound/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/apps/linkunbound/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj b/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1daea5d --- /dev/null +++ b/apps/linkunbound/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,833 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; A1B2C30000000000000010 /* InboundEventsChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000011 /* InboundEventsChannel.swift */; }; + A1B2C30000000000000030 /* BrowserDetectorChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000031 /* BrowserDetectorChannel.swift */; }; + A1B2C30000000000000040 /* IconExtractorChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C30000000000000041 /* IconExtractorChannel.swift */; }; + 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 */; }; + DC236B3B7CC7FCC9916A8FAF /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22348279A947D0F510AF2D95 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 22348279A947D0F510AF2D95 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* linkunbound.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = linkunbound.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; A1B2C30000000000000011 /* InboundEventsChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundEventsChannel.swift; sourceTree = ""; }; + A1B2C30000000000000031 /* BrowserDetectorChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDetectorChannel.swift; sourceTree = ""; }; + A1B2C30000000000000041 /* IconExtractorChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconExtractorChannel.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3F16D0147E9F010DF226B767 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 4DA10C8F12B3982347C19FD3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 4EB6699C1BACDAF167B64E92 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 500F6A1F28F0CB19E067FBF2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 51BBC15A3367514BB1B27BFC /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 68160B10F562BBF3986EDE41 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 979005B47DA014D5DF64A617 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + DC236B3B7CC7FCC9916A8FAF /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3FC8CBCC463B3627C59F4107 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + C3EB653D4EBE683BCCA76DA0 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* linkunbound.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + A1B2C30000000000000020 /* Channels */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + A1B2C30000000000000020 /* Channels */ = { + isa = PBXGroup; + children = ( + A1B2C30000000000000011 /* InboundEventsChannel.swift */, + A1B2C30000000000000031 /* BrowserDetectorChannel.swift */, + A1B2C30000000000000041 /* IconExtractorChannel.swift */, + A1B2C30000000000000051 /* RegistrationChannel.swift */, + A1B2C30000000000000061 /* StartupChannel.swift */, + A1B2C30000000000000071 /* LinkUnboundChannels.swift */, + A1B2C30000000000000081 /* WindowChannel.swift */, + ); + path = Channels; + sourceTree = ""; + }; + C3EB653D4EBE683BCCA76DA0 /* Pods */ = { + isa = PBXGroup; + children = ( + 68160B10F562BBF3986EDE41 /* Pods-Runner.debug.xcconfig */, + 4DA10C8F12B3982347C19FD3 /* Pods-Runner.release.xcconfig */, + 3F16D0147E9F010DF226B767 /* Pods-Runner.profile.xcconfig */, + 4EB6699C1BACDAF167B64E92 /* Pods-RunnerTests.debug.xcconfig */, + 51BBC15A3367514BB1B27BFC /* Pods-RunnerTests.release.xcconfig */, + 979005B47DA014D5DF64A617 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 500F6A1F28F0CB19E067FBF2 /* Pods_Runner.framework */, + 22348279A947D0F510AF2D95 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 3618057A3FDC7A53438A9F0B /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 269E3148C47ACDAC436DFB53 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 070A50F5E4F388B5F585F94F /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* linkunbound.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 070A50F5E4F388B5F585F94F /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 269E3148C47ACDAC436DFB53 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 3618057A3FDC7A53438A9F0B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + A1B2C30000000000000010 /* InboundEventsChannel.swift in Sources */, + A1B2C30000000000000030 /* BrowserDetectorChannel.swift in Sources */, + A1B2C30000000000000040 /* IconExtractorChannel.swift in Sources */, + A1B2C30000000000000050 /* RegistrationChannel.swift in Sources */, + A1B2C30000000000000060 /* StartupChannel.swift in Sources */, + A1B2C30000000000000070 /* LinkUnboundChannels.swift in Sources */, + A1B2C30000000000000080 /* WindowChannel.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4EB6699C1BACDAF167B64E92 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rgdevment.linkunbound.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/linkunbound.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/linkunbound"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 51BBC15A3367514BB1B27BFC /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rgdevment.linkunbound.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/linkunbound.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/linkunbound"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 979005B47DA014D5DF64A617 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rgdevment.linkunbound.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/linkunbound.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/linkunbound"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/apps/linkunbound/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/linkunbound/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/linkunbound/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/linkunbound/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/linkunbound/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..eb8a548 --- /dev/null +++ b/apps/linkunbound/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/linkunbound/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/linkunbound/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/apps/linkunbound/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/apps/linkunbound/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/linkunbound/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/linkunbound/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/linkunbound/macos/Runner/AppDelegate.swift b/apps/linkunbound/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..1aefcf3 --- /dev/null +++ b/apps/linkunbound/macos/Runner/AppDelegate.swift @@ -0,0 +1,57 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + /// Strong reference that keeps every native channel alive for the app lifetime. + /// Set by `MainFlutterWindow` once the FlutterViewController is ready. + var channels: LinkUnboundChannels? + + /// Convenience accessor; the inbound bridge is the channel the AppDelegate + /// itself talks to when forwarding `application(_:open:)`/`reopen` events. + var inboundEvents: InboundEventsChannel? { channels?.inboundEvents } + + /// URLs received before the channel exists are kept here and replayed after wiring. + private var preBootUrls: [String] = [] + private var preBootShouldShowSettings = false + + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + // LinkUnbound stays alive in the menu bar (LSUIElement); closing the + // settings window must not quit the app. + return false + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } + + override func application(_ application: NSApplication, open urls: [URL]) { + let strings = urls.map { $0.absoluteString } + if let channel = inboundEvents { + strings.forEach(channel.enqueueOpenUrl) + } else { + preBootUrls.append(contentsOf: strings) + } + } + + override func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if let channel = inboundEvents { + channel.enqueueShowSettings() + } else { + preBootShouldShowSettings = true + } + return true + } + + /// Called by `MainFlutterWindow` once the channels have been initialised. + func attachChannels(_ channels: LinkUnboundChannels) { + self.channels = channels + let inbound = channels.inboundEvents + preBootUrls.forEach(inbound.enqueueOpenUrl) + preBootUrls.removeAll() + if preBootShouldShowSettings { + inbound.enqueueShowSettings() + preBootShouldShowSettings = false + } + } +} diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..9a836df Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..50987b0 Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..37218c2 Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..60277ea Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..5c3b31a Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..84f575e Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..79c5cc7 Binary files /dev/null and b/apps/linkunbound/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/apps/linkunbound/macos/Runner/Base.lproj/MainMenu.xib b/apps/linkunbound/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/apps/linkunbound/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift b/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift new file mode 100644 index 0000000..59f58f5 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/BrowserDetectorChannel.swift @@ -0,0 +1,90 @@ +import AppKit +import FlutterMacOS + +final class BrowserDetectorChannel { + static let channelName = "linkunbound/browser_detector" + + 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 "detect": + result(self.detect()) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func detect() -> [[String: String]] { + let workspace = NSWorkspace.shared + var seen = Set() + var browsers: [[String: String]] = [] + + var candidateURLs: [URL] = [] + if let httpsURL = URL(string: "https://example.com") { + candidateURLs.append(contentsOf: workspace.urlsForApplications(toOpen: httpsURL)) + } + + let isPrimaryHttpHandler: (URL) -> Bool = { appURL in + guard + let info = Bundle(url: appURL)?.infoDictionary, + let urlTypes = info["CFBundleURLTypes"] as? [[String: Any]] + else { + return false + } + for entry in urlTypes { + let schemes = (entry["CFBundleURLSchemes"] as? [String] ?? []).map { + $0.lowercased() + } + guard schemes.contains("http") || schemes.contains("https") else { + continue + } + let rank = (entry["LSHandlerRank"] as? String)?.lowercased() ?? "default" + return rank == "default" || rank == "owner" + } + return false + } + + let allowedAppRoots: [String] = [ + "/Applications/", + "/System/Applications/", + "/System/Volumes/Preboot/", + NSString("~/Applications/").expandingTildeInPath + "/", + ] + + for appURL in candidateURLs { + guard let bundle = Bundle(url: appURL), + let bundleId = bundle.bundleIdentifier, + bundleId != ownBundleId, + isPrimaryHttpHandler(appURL), + !seen.contains(bundleId) + else { continue } + + let path = appURL.path + guard allowedAppRoots.contains(where: { path.hasPrefix($0) }) else { continue } + + seen.insert(bundleId) + + let name = (bundle.infoDictionary?["CFBundleDisplayName"] as? String) + ?? (bundle.infoDictionary?["CFBundleName"] as? String) + ?? appURL.deletingPathExtension().lastPathComponent + let id = bundleId + .lowercased() + .replacingOccurrences(of: ".", with: "-") + + browsers.append([ + "id": id, + "name": name, + "executablePath": appURL.path, + ]) + } + + return browsers + } +} diff --git a/apps/linkunbound/macos/Runner/Channels/IconExtractorChannel.swift b/apps/linkunbound/macos/Runner/Channels/IconExtractorChannel.swift new file mode 100644 index 0000000..f206a4c --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/IconExtractorChannel.swift @@ -0,0 +1,60 @@ +import AppKit +import FlutterMacOS + +/// `linkunbound/icon_extractor` — writes a PNG snapshot of an app's icon to disk. +final class IconExtractorChannel { + static let channelName = "linkunbound/icon_extractor" + + private let channel: FlutterMethodChannel + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: Self.channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { return result(FlutterMethodNotImplemented) } + switch call.method { + case "extract": + let args = call.arguments as? [String: Any] + guard let appPath = args?["appPath"] as? String, + let outputPath = args?["outputPath"] as? String + else { + result(FlutterError(code: "bad_args", message: "appPath and outputPath required", details: nil)) + return + } + result(self.extract(appPath: appPath, outputPath: outputPath)) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func extract(appPath: String, outputPath: String) -> String? { + let icon = NSWorkspace.shared.icon(forFile: appPath) + let target = NSSize(width: 64, height: 64) + let resized = NSImage(size: target) + resized.lockFocus() + icon.draw( + in: NSRect(origin: .zero, size: target), + from: NSRect(origin: .zero, size: icon.size), + operation: .copy, + fraction: 1.0 + ) + resized.unlockFocus() + + guard let tiff = resized.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) + else { return nil } + + let url = URL(fileURLWithPath: outputPath) + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + do { + try png.write(to: url, options: .atomic) + return outputPath + } catch { + return nil + } + } +} diff --git a/apps/linkunbound/macos/Runner/Channels/InboundEventsChannel.swift b/apps/linkunbound/macos/Runner/Channels/InboundEventsChannel.swift new file mode 100644 index 0000000..906aaa8 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/InboundEventsChannel.swift @@ -0,0 +1,55 @@ +import Cocoa +import FlutterMacOS + +/// Bridge between native macOS URL/reopen callbacks and the Dart `bootstrap`. +/// +/// Native side enqueues events as `[String: String]` (e.g. `{"action": "open_url", "url": "..."}`) +/// and forwards them on the `linkunbound/inbound_events` channel. +/// Dart calls `ready` once it has wired its handler so that any events queued +/// before the engine was alive are flushed. +final class InboundEventsChannel { + static let channelName = "linkunbound/inbound_events" + + private let channel: FlutterMethodChannel + private var pending: [[String: String]] = [] + private var dartReady = false + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: Self.channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard let self else { return result(FlutterMethodNotImplemented) } + switch call.method { + case "ready": + self.dartReady = true + let queued = self.pending + self.pending.removeAll() + for event in queued { + self.channel.invokeMethod("event", arguments: event) + } + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + func enqueueOpenUrl(_ url: String) { + let event: [String: String] = ["action": "open_url", "url": url] + forward(event) + } + + func enqueueShowSettings() { + let event: [String: String] = ["action": "show_settings"] + forward(event) + } + + private func forward(_ event: [String: String]) { + if dartReady { + DispatchQueue.main.async { [weak self] in + self?.channel.invokeMethod("event", arguments: event) + } + } else { + pending.append(event) + } + } +} diff --git a/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift b/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift new file mode 100644 index 0000000..8030636 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/LinkUnboundChannels.swift @@ -0,0 +1,21 @@ +import FlutterMacOS + +/// Single entry point that wires every native channel into the Flutter engine. +/// Call this from `MainFlutterWindow.awakeFromNib()` once the engine is alive. +final class LinkUnboundChannels { + let inboundEvents: InboundEventsChannel + let browserDetector: BrowserDetectorChannel + let iconExtractor: IconExtractorChannel + let registration: RegistrationChannel + let startup: StartupChannel + let window: WindowChannel + + init(messenger: FlutterBinaryMessenger) { + inboundEvents = InboundEventsChannel(messenger: messenger) + browserDetector = BrowserDetectorChannel(messenger: messenger) + iconExtractor = IconExtractorChannel(messenger: messenger) + registration = RegistrationChannel(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 new file mode 100644 index 0000000..f389b13 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/RegistrationChannel.swift @@ -0,0 +1,78 @@ +import AppKit +import FlutterMacOS +import UniformTypeIdentifiers + +/// `linkunbound/registration` — registers (or releases) the bundle as default +/// handler for http/https + public.html. +final class RegistrationChannel { + static let channelName = "linkunbound/registration" + + private let channel: FlutterMethodChannel + private let ownBundleId: String + private let safariBundleId = "com.apple.safari" + + 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 "register": + self.setHandler(self.ownBundleId) + result(nil) + case "unregister": + // macOS has no "remove default" — fall back to Safari. + self.setHandler(self.safariBundleId) + result(nil) + case "isDefault": + result(self.isDefault()) + case "defaultAssociations": + result(self.defaultAssociations()) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private func setHandler(_ bundleId: String) { + guard #available(macOS 12.0, *), + let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) + else { return } + + NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: "http") { _ in } + NSWorkspace.shared.setDefaultApplication(at: appURL, toOpenURLsWithScheme: "https") { _ in } + 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) } + } + } + + private func isDefault() -> Bool { + let httpsHandler = handlerBundleId(forScheme: "https") + return httpsHandler?.lowercased() == ownBundleId.lowercased() + } + + private func defaultAssociations() -> [String] { + var assoc: [String] = [] + if handlerBundleId(forScheme: "http")?.lowercased() == ownBundleId.lowercased() { + assoc.append("http") + } + if handlerBundleId(forScheme: "https")?.lowercased() == ownBundleId.lowercased() { + assoc.append("https") + } + if #available(macOS 12.0, *), + let htmlType = UTType("public.html"), + let htmlHandler = NSWorkspace.shared.urlForApplication(toOpen: htmlType), + Bundle(url: htmlHandler)?.bundleIdentifier?.lowercased() == ownBundleId.lowercased() { + assoc.append("public.html") + } + return assoc + } + + private func handlerBundleId(forScheme scheme: String) -> String? { + guard let url = URL(string: "\(scheme)://example.com"), + let appURL = NSWorkspace.shared.urlForApplication(toOpen: url) + else { return nil } + return Bundle(url: appURL)?.bundleIdentifier + } +} diff --git a/apps/linkunbound/macos/Runner/Channels/StartupChannel.swift b/apps/linkunbound/macos/Runner/Channels/StartupChannel.swift new file mode 100644 index 0000000..6024788 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/StartupChannel.swift @@ -0,0 +1,39 @@ +import FlutterMacOS +import ServiceManagement + +final class StartupChannel { + static let channelName = "linkunbound/startup" + + private let channel: FlutterMethodChannel + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: Self.channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard self != nil else { return result(FlutterMethodNotImplemented) } + switch call.method { + case "enable": + do { + try SMAppService.mainApp.register() + result(nil) + } catch { + result(FlutterError(code: "register_failed", + message: error.localizedDescription, + details: nil)) + } + case "disable": + do { + try SMAppService.mainApp.unregister() + result(nil) + } catch { + result(FlutterError(code: "unregister_failed", + message: error.localizedDescription, + details: nil)) + } + case "isEnabled": + result(SMAppService.mainApp.status == .enabled) + default: + result(FlutterMethodNotImplemented) + } + } + } +} diff --git a/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift b/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift new file mode 100644 index 0000000..6656fe8 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Channels/WindowChannel.swift @@ -0,0 +1,62 @@ +import Cocoa +import FlutterMacOS + +final class WindowChannel { + static let channelName = "linkunbound/window" + + private let channel: FlutterMethodChannel + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel(name: Self.channelName, binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard self != nil else { return result(FlutterMethodNotImplemented) } + switch call.method { + case "setPickerMode": + Self.applyPickerMode() + result(nil) + case "setSettingsMode": + Self.applySettingsMode() + result(nil) + case "activate": + Self.activate() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + } + + private static func mainWindow() -> NSWindow? { + NSApplication.shared.windows.first + } + + private static func applyPickerMode() { + guard let win = mainWindow() else { return } + DispatchQueue.main.async { + win.styleMask.remove(.resizable) + win.standardWindowButton(.closeButton)?.isHidden = true + win.standardWindowButton(.miniaturizeButton)?.isHidden = true + win.standardWindowButton(.zoomButton)?.isHidden = true + win.level = .statusBar + } + } + + private static func applySettingsMode() { + guard let win = mainWindow() else { return } + DispatchQueue.main.async { + win.styleMask.insert(.resizable) + win.standardWindowButton(.closeButton)?.isHidden = true + win.standardWindowButton(.miniaturizeButton)?.isHidden = true + win.standardWindowButton(.zoomButton)?.isHidden = true + win.level = .normal + } + } + + private static func activate() { + guard let win = mainWindow() else { return } + DispatchQueue.main.async { + NSApp.activate(ignoringOtherApps: true) + win.makeKeyAndOrderFront(nil) + } + } +} diff --git a/apps/linkunbound/macos/Runner/Configs/AppInfo.xcconfig b/apps/linkunbound/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..7b73094 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = LinkUnbound + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.rgdevment.linkunbound + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 rgdevment. All rights reserved. diff --git a/apps/linkunbound/macos/Runner/Configs/Debug.xcconfig b/apps/linkunbound/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/linkunbound/macos/Runner/Configs/Release.xcconfig b/apps/linkunbound/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/linkunbound/macos/Runner/Configs/Warnings.xcconfig b/apps/linkunbound/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/apps/linkunbound/macos/Runner/DebugProfile.entitlements b/apps/linkunbound/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..d35e43a --- /dev/null +++ b/apps/linkunbound/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/apps/linkunbound/macos/Runner/Info.plist b/apps/linkunbound/macos/Runner/Info.plist new file mode 100644 index 0000000..3921c26 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Info.plist @@ -0,0 +1,68 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + LinkUnbound + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + CFBundleURLTypes + + + CFBundleURLName + Web URL + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + CFBundleURLSchemes + + http + https + + + + CFBundleDocumentTypes + + + CFBundleTypeName + HTML document + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.html + public.xhtml + + + + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/apps/linkunbound/macos/Runner/MainFlutterWindow.swift b/apps/linkunbound/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..6d2eb0f --- /dev/null +++ b/apps/linkunbound/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,33 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + self.contentViewController = flutterViewController + + self.styleMask = [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView] + self.titlebarAppearsTransparent = true + self.titleVisibility = .hidden + self.isMovableByWindowBackground = true + + let initialSize = NSSize(width: 640, height: 700) + let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800) + let origin = NSPoint( + x: screenFrame.midX - initialSize.width / 2, + y: screenFrame.midY - initialSize.height / 2 + ) + self.setFrame(NSRect(origin: origin, size: initialSize), display: false) + + RegisterGeneratedPlugins(registry: flutterViewController) + + let channels = LinkUnboundChannels(messenger: flutterViewController.engine.binaryMessenger) + if let appDelegate = NSApplication.shared.delegate as? AppDelegate { + appDelegate.attachChannels(channels) + } + + super.awakeFromNib() + + self.orderOut(nil) + } +} diff --git a/apps/linkunbound/macos/Runner/Release.entitlements b/apps/linkunbound/macos/Runner/Release.entitlements new file mode 100644 index 0000000..08ba3a3 --- /dev/null +++ b/apps/linkunbound/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/apps/linkunbound/macos/RunnerTests/RunnerTests.swift b/apps/linkunbound/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/apps/linkunbound/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/linkunbound/pubspec.yaml b/apps/linkunbound/pubspec.yaml index 0797c91..58a911d 100644 --- a/apps/linkunbound/pubspec.yaml +++ b/apps/linkunbound/pubspec.yaml @@ -20,7 +20,9 @@ dependencies: flutter_riverpod: ^2.6.1 riverpod_annotation: ^2.6.1 window_manager: ^0.5.1 + screen_retriever: ^0.2.0 system_tray: ^2.0.3 + tray_manager: ^0.5.0 win32_registry: ^1.1.5 ffi: ^2.1.3 path_provider: ^2.1.5 @@ -41,3 +43,5 @@ flutter: - assets/app_icon.ico - assets/app_icon.png - assets/copypaste_icon.png + - assets/LinkUnbound_tray_32.png + - assets/LinkUnbound_tray_64.png diff --git a/apps/linkunbound/test/app_test.dart b/apps/linkunbound/test/app_test.dart new file mode 100644 index 0000000..ff77d49 --- /dev/null +++ b/apps/linkunbound/test/app_test.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; + +import 'package:linkunbound/app.dart'; +import 'package:linkunbound/providers.dart'; +import 'package:linkunbound/ui/picker/picker_window.dart'; +import 'package:linkunbound/ui/settings/settings_window.dart'; + +import 'helpers.dart'; + +const _windowChannel = MethodChannel('window_manager'); + +final class _WindowManagerSpy { + final List calls = []; + + List get methods => calls.map((call) => call.method).toList(); + + void clear() => calls.clear(); + + Future handle(MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'isFullScreen': + case 'isMaximized': + case 'isMinimized': + case 'isVisible': + case 'isFocused': + return false; + default: + return null; + } + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late _WindowManagerSpy windowSpy; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('navigate_app_test_'); + windowSpy = _WindowManagerSpy(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_windowChannel, windowSpy.handle); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_windowChannel, null); + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + Future pumpApp( + WidgetTester tester, { + UpdateInfo? updateInfo, + Future Function()? onExit, + }) async { + final fixtures = makeFixtures(dir: tempDir, updateInfo: updateInfo); + final overrides = [ + ...fixtures.overrides, + exitAppProvider.overrideWithValue(onExit ?? () async {}), + ]; + final container = ProviderContainer(overrides: overrides); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const NavigateApp(), + ), + ); + await tester.pump(); + + return container; + } + + testWidgets('starts hidden by default', (tester) async { + await pumpApp(tester); + + expect(find.byType(SettingsWindow), findsNothing); + expect(find.byType(PickerWindow), findsNothing); + expect( + find.byWidgetPredicate( + (widget) => + widget is ColoredBox && widget.color == const Color(0xFF1E1E2E), + ), + findsOneWidget, + ); + }); + + testWidgets('showSettings renders the settings window and focuses it', ( + tester, + ) async { + final container = await pumpApp(tester); + windowSpy.clear(); + + container.read(appStateProvider.notifier).showSettings(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(SettingsWindow), findsOneWidget); + expect(windowSpy.methods, contains('show')); + expect(windowSpy.methods, contains('focus')); + }); + + testWidgets('immediate blur after showing picker is ignored', (tester) async { + final container = await pumpApp(tester); + final dynamic state = tester.state(find.byType(NavigateApp)); + + container.read(appStateProvider.notifier).showPicker('https://example.com'); + await tester.pump(); + await tester.pump(); + // ignore: avoid_dynamic_calls + state.onWindowBlur(); + await tester.pump(); + + expect(find.byType(PickerWindow), findsOneWidget); + }); + + testWidgets('blur hides picker after the grace period', (tester) async { + final container = await pumpApp(tester); + final dynamic state = tester.state(find.byType(NavigateApp)); + + container.read(appStateProvider.notifier).showPicker('https://example.com'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + // ignore: avoid_dynamic_calls + state.onWindowBlur(); + await tester.pump(); + + expect(container.read(appStateProvider).mode, AppMode.hidden); + expect(find.byType(PickerWindow), findsNothing); + }); + + testWidgets('window close hides the app and resets state', (tester) async { + final container = await pumpApp(tester); + final dynamic state = tester.state(find.byType(NavigateApp)); + + container.read(appStateProvider.notifier).showSettings(); + await tester.pump(); + await tester.pump(); + windowSpy.clear(); + + // ignore: avoid_dynamic_calls + await state.onWindowClose(); + await tester.pump(); + + expect(container.read(appStateProvider).mode, AppMode.hidden); + expect(windowSpy.methods, contains('hide')); + }); + + testWidgets('settings view shows update banner and supports drag', ( + tester, + ) async { + await pumpApp( + tester, + updateInfo: const UpdateInfo( + latestVersion: '2.0.0', + releaseUrl: 'https://example.com/releases/2.0.0', + ), + ); + + final container = ProviderScope.containerOf( + tester.element(find.byType(NavigateApp)), + ); + container.read(appStateProvider.notifier).showSettings(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Version 2.0.0 available'), findsOneWidget); + + windowSpy.clear(); + await tester.drag(find.text('LinkUnbound'), const Offset(20, 0)); + await tester.pump(); + expect(windowSpy.methods, contains('startDragging')); + }); +} diff --git a/apps/linkunbound/test/bootstrap_test.dart b/apps/linkunbound/test/bootstrap_test.dart new file mode 100644 index 0000000..95d1df9 --- /dev/null +++ b/apps/linkunbound/test/bootstrap_test.dart @@ -0,0 +1,563 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:linkunbound/bootstrap.dart'; +import 'package:linkunbound/platform/cursor_locator.dart'; +import 'package:linkunbound/platform/platform_bindings.dart'; +import 'package:linkunbound/platform/tray_controller.dart'; +import 'package:linkunbound/ui/picker/picker_window.dart'; +import 'package:linkunbound/ui/settings/settings_window.dart'; + +const _windowChannel = MethodChannel('window_manager'); +const _macWindowChannel = MethodChannel('linkunbound/window'); +const _screenChannel = MethodChannel( + 'dev.leanflutter.plugins/screen_retriever', +); + +const _chrome = Browser( + id: 'chrome', + name: 'Google Chrome', + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + iconPath: 'chrome.png', +); + +final class _MethodChannelSpy { + final List calls = []; + + List get methods => calls.map((call) => call.method).toList(); + + void clear() => calls.clear(); + + Future handle(MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'isFullScreen': + case 'isMaximized': + case 'isMinimized': + case 'isVisible': + case 'isFocused': + return false; + case 'getBounds': + // window_manager casts the result to Map before + // parsing; returning null causes a TypeError, so return a fake rect. + return { + 'x': 0.0, + 'y': 0.0, + 'width': 800.0, + 'height': 600.0, + }; + default: + return null; + } + } +} + +/// Mocks the screen_retriever channel that window_manager's center() uses +/// to locate the primary display and cursor position. +final class _ScreenSpy { + final List calls = []; + + static const _fakeDisplay = { + 'id': '1', + 'name': 'Test Display', + 'size': {'width': 1280.0, 'height': 800.0}, + 'visiblePosition': {'dx': 0.0, 'dy': 0.0}, + 'visibleSize': {'width': 1280.0, 'height': 800.0}, + 'scaleFactor': 2.0, + }; + + Future handle(MethodCall call) async { + calls.add(call); + switch (call.method) { + case 'getPrimaryDisplay': + return _fakeDisplay; + case 'getAllDisplays': + return { + 'displays': [_fakeDisplay], + }; + case 'getCursorScreenPoint': + return {'dx': 640.0, 'dy': 400.0}; + default: + return null; + } + } +} + +final class _FailingHttpClient extends Fake implements HttpClient { + @override + Future getUrl(Uri url) { + throw const SocketException('blocked in tests'); + } +} + +final class _FakeBrowserDetector implements BrowserDetector { + _FakeBrowserDetector(this.detectedBrowsers); + + final List detectedBrowsers; + + @override + Future> detect() async => detectedBrowsers; +} + +final class _RecordingIconExtractor implements IconExtractor { + final List<(String executablePath, String outputPath)> calls = []; + + @override + Future extractIcon(String executablePath, String outputPath) async { + calls.add((executablePath, outputPath)); + return outputPath; + } +} + +final class _RecordingRegistrationService implements RegistrationService { + final List registerCalls = []; + + @override + Future> get defaultAssociations async => {}; + + @override + Future get isDefault async => false; + + @override + Future register(String executablePath) async { + registerCalls.add(executablePath); + } + + @override + Future unregister() async {} +} + +final class _FakeStartupService implements StartupService { + @override + Future disable() async {} + + @override + Future enable(String executablePath) async {} + + @override + Future get isEnabled async => false; +} + +final class _RecordingLaunchService implements LaunchService { + final List<({String executablePath, String url, List extraArgs})> + calls = []; + + @override + Future launch( + String executablePath, + String url, + List extraArgs, + ) async { + calls.add(( + executablePath: executablePath, + url: url, + extraArgs: List.from(extraArgs), + )); + } +} + +final class _FakeCursorLocator implements CursorLocator { + _FakeCursorLocator(); + + final (double, double) cursor = const (300.0, 200.0); + final (double, double) screen = const (1280.0, 900.0); + + @override + Future<(double, double)> cursorPosition() async => cursor; + + @override + Future<(double, double)> screenSize() async => screen; +} + +final class _FakeTrayController implements TrayController { + int initCalls = 0; + List menuItems = const []; + VoidCallback? activationCallback; + + @override + Future dispose() async {} + + @override + Future init({ + required String title, + required String iconPath, + required String tooltip, + }) async { + initCalls++; + } + + @override + void onActivated(VoidCallback callback) { + activationCallback = callback; + } + + @override + Future setMenu(List items) async { + menuItems = items; + } + + void activate() { + activationCallback?.call(); + } +} + +final class _FakeBindings implements PlatformBindings { + _FakeBindings({ + required this.rootDir, + List detectedBrowsers = const [], + }) : browserDetector = _FakeBrowserDetector(detectedBrowsers), + iconExtractor = _RecordingIconExtractor(), + registrationService = _RecordingRegistrationService(), + startupService = _FakeStartupService(), + launchService = _RecordingLaunchService(), + trayController = _FakeTrayController(), + cursorLocator = _FakeCursorLocator(), + _events = StreamController.broadcast() { + appDataDir.createSync(recursive: true); + iconsDir.createSync(recursive: true); + trayIconPathFile.writeAsStringSync('icon'); + } + + final Directory rootDir; + final InboundEvent? initial = null; + final StreamController _events; + + @override + final BrowserDetector browserDetector; + + @override + final _RecordingIconExtractor iconExtractor; + + @override + final _RecordingLaunchService launchService; + + @override + final _RecordingRegistrationService registrationService; + + @override + final _FakeStartupService startupService; + + @override + final _FakeTrayController trayController; + + @override + final CursorLocator cursorLocator; + + int claimCalls = 0; + int releaseCalls = 0; + int tryDelegateCalls = 0; + + @override + bool startsHidden = false; + + @override + Directory get appDataDir => Directory('${rootDir.path}/app-data'); + + @override + File get browsersFile => File('${appDataDir.path}/browsers.json'); + + @override + File get edgeWarningFile => File('${appDataDir.path}/edge_warning_dismissed'); + + @override + String get executablePath => + '/Applications/LinkUnbound.app/Contents/MacOS/LinkUnbound'; + + @override + Directory get iconsDir => Directory('${appDataDir.path}/icons'); + + @override + InboundEvent? get initialEvent => initial; + + @override + Stream get inboundEvents => _events.stream; + + @override + File get localeFile => File('${appDataDir.path}/locale'); + + @override + File get logFile => File('${appDataDir.path}/linkunbound.log'); + + @override + File get rulesFile => File('${appDataDir.path}/rules.json'); + + File get trayIconPathFile => File('${appDataDir.path}/tray.png'); + + @override + String get trayIconPath => trayIconPathFile.path; + + @override + Future claim() async { + claimCalls++; + return true; + } + + Future close() async { + await _events.close(); + } + + Future emit(InboundEvent event) async { + // Broadcast streams deliver synchronously; callers pump the tester + // themselves to drain any Riverpod state-change microtasks. + _events.add(event); + } + + @override + Future release() async { + releaseCalls++; + } + + Future seed({ + List browsers = const [], + List rules = const [], + }) async { + final browserService = BrowserService( + configFile: browsersFile, + browserDetector: browserDetector, + ); + for (final browser in browsers) { + browserService.addBrowser(browser); + } + await browserService.save(); + + final ruleService = RuleService(rulesFile: rulesFile); + for (final rule in rules) { + ruleService.addRule(rule); + } + await ruleService.save(); + } + + @override + Future tryDelegate(InboundEvent? event) async { + tryDelegateCalls++; + return false; + } +} + +void main() { + if (!(Platform.isMacOS || Platform.isWindows)) { + test('bootstrap suite skipped on this platform', () {}, skip: true); + return; + } + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + late _MethodChannelSpy windowSpy; + late _MethodChannelSpy macWindowSpy; + late _ScreenSpy screenSpy; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('bootstrap_test_'); + PackageInfo.setMockInitialValues( + appName: 'LinkUnbound', + packageName: 'dev.rg.LinkUnbound', + version: '1.0.0', + buildNumber: '1', + buildSignature: 'sig', + ); + windowSpy = _MethodChannelSpy(); + macWindowSpy = _MethodChannelSpy(); + screenSpy = _ScreenSpy(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_windowChannel, windowSpy.handle); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_macWindowChannel, macWindowSpy.handle); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_screenChannel, screenSpy.handle); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_windowChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_macWindowChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_screenChannel, null); + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + Future boot( + WidgetTester tester, + _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. + await tester.runAsync(() async { + await HttpOverrides.runZoned( + () => bootstrap(bindings, args), + createHttpClient: (_) => _FailingHttpClient(), + ); + }); + await tester.pump(); + await tester.pump(); + } + + testWidgets('first boot scans browsers, extracts icons, and opens settings', ( + tester, + ) async { + final bindings = _FakeBindings( + rootDir: tempDir, + detectedBrowsers: const [_chrome], + ); + addTearDown(bindings.close); + + await boot(tester, bindings, const []); + + expect(bindings.claimCalls, 1); + expect(bindings.tryDelegateCalls, 1); + expect(bindings.registrationService.registerCalls, [ + bindings.executablePath, + ]); + expect(bindings.iconExtractor.calls, hasLength(1)); + expect(bindings.iconExtractor.calls.single.$1, _chrome.executablePath); + expect(bindings.trayController.initCalls, 1); + expect( + bindings.trayController.menuItems + .map((item) => item.label) + .whereType(), + containsAll(['Settings', 'Exit']), + ); + expect(find.byType(SettingsWindow), findsOneWidget); + }); + + testWidgets('background launch stays hidden until tray activation', ( + tester, + ) async { + final bindings = _FakeBindings(rootDir: tempDir)..startsHidden = true; + addTearDown(bindings.close); + + await boot(tester, bindings, const ['--background']); + + expect(find.byType(SettingsWindow), findsNothing); + + bindings.trayController.activate(); + await tester.pump(); + await tester.pump(); + + expect(find.byType(SettingsWindow), findsOneWidget); + expect(macWindowSpy.methods, contains('setSettingsMode')); + expect(macWindowSpy.methods, contains('activate')); + }); + + testWidgets('matching rule launches browser instead of opening picker', ( + tester, + ) async { + final bindings = _FakeBindings(rootDir: tempDir)..startsHidden = true; + addTearDown(bindings.close); + await tester.runAsync( + () => bindings.seed( + browsers: const [_chrome], + rules: const [Rule(domain: 'example.com', browserId: 'chrome')], + ), + ); + + await boot(tester, bindings, const ['--background']); + await bindings.emit(const OpenUrlEvent('https://example.com/docs')); + await tester.pump(); + await tester.pump(); + + expect(bindings.launchService.calls, hasLength(1)); + expect( + bindings.launchService.calls.single.executablePath, + _chrome.executablePath, + ); + expect(bindings.launchService.calls.single.url, 'https://example.com/docs'); + expect(find.byType(PickerWindow), findsNothing); + }); + + testWidgets('safe links are unwrapped before rule-based launch', ( + tester, + ) async { + final bindings = _FakeBindings(rootDir: tempDir)..startsHidden = true; + addTearDown(bindings.close); + await tester.runAsync( + () => bindings.seed( + browsers: const [_chrome], + rules: const [Rule(domain: 'example.com', browserId: 'chrome')], + ), + ); + + await boot(tester, bindings, const ['--background']); + await bindings.emit( + OpenUrlEvent( + 'https://nam12.safelinks.protection.outlook.com/?url=${Uri.encodeComponent('https://example.com/report?id=7')}', + ), + ); + await tester.pump(); + await tester.pump(); + + expect(bindings.launchService.calls, hasLength(1)); + expect( + bindings.launchService.calls.single.url, + 'https://example.com/report?id=7', + ); + }); + + testWidgets('valid local html file opens the picker', (tester) async { + final bindings = _FakeBindings( + rootDir: tempDir, + detectedBrowsers: const [_chrome], + )..startsHidden = true; + addTearDown(bindings.close); + final htmlFile = File('${tempDir.path}/preview.html') + ..writeAsStringSync(''); + + await boot(tester, bindings, const ['--background']); + // bootstrap() runs in tester.runAsync, so its listeners are in the real + // event loop zone. emit + a small real-time delay lets all the async + // channel calls (setPickerMode, setSize, setPosition, show, …) complete + // before tearDown removes the spy handlers. + await tester.runAsync(() async { + bindings.emit(OpenUrlEvent(htmlFile.uri.toString())); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); + + expect(find.byType(PickerWindow), findsOneWidget); + expect(macWindowSpy.methods, contains('setPickerMode')); + }); + + testWidgets('unsupported local file is ignored', (tester) async { + final bindings = _FakeBindings(rootDir: tempDir)..startsHidden = true; + addTearDown(bindings.close); + final txtFile = File('${tempDir.path}/notes.txt')..writeAsStringSync('hi'); + + await boot(tester, bindings, const ['--background']); + await bindings.emit(OpenUrlEvent(txtFile.uri.toString())); + await tester.pump(); + await tester.pump(); + + expect(bindings.launchService.calls, isEmpty); + expect(find.byType(PickerWindow), findsNothing); + }); + + testWidgets('ShowSettingsEvent opens settings window', (tester) async { + final bindings = _FakeBindings(rootDir: tempDir)..startsHidden = true; + addTearDown(bindings.close); + + await boot(tester, bindings, const ['--background']); + expect(find.byType(SettingsWindow), findsNothing); + + await tester.runAsync(() async { + bindings.emit(const ShowSettingsEvent()); + await Future.delayed(const Duration(milliseconds: 100)); + }); + await tester.pump(); + await tester.pump(); + + expect(find.byType(SettingsWindow), findsOneWidget); + expect(macWindowSpy.methods, contains('setSettingsMode')); + }); +} diff --git a/apps/linkunbound/test/l10n/app_localizations_test.dart b/apps/linkunbound/test/l10n/app_localizations_test.dart new file mode 100644 index 0000000..6c4e166 --- /dev/null +++ b/apps/linkunbound/test/l10n/app_localizations_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:linkunbound/l10n/app_localizations.dart'; +import 'package:linkunbound/l10n/app_localizations_en.dart'; +import 'package:linkunbound/l10n/app_localizations_es.dart'; + +void _expectAllMessages(AppLocalizations l10n) { + final values = [ + l10n.exit, + l10n.traySettings, + l10n.copyUrl, + l10n.alwaysOpenHere, + l10n.tabGeneral, + l10n.tabRules, + l10n.tabAbout, + l10n.tabMaintenance, + l10n.sectionDefaultBrowser, + l10n.isDefaultBrowser, + l10n.notDefaultBrowser, + l10n.setDefault, + l10n.sectionStartup, + l10n.launchAtStartup, + l10n.sectionLanguage, + l10n.languageAuto, + l10n.languageEnglish, + l10n.languageSpanish, + l10n.sectionBrowsers, + l10n.addBrowserTooltip, + l10n.refreshBrowsersTooltip, + l10n.menuEdit, + l10n.menuDuplicate, + l10n.menuRemove, + l10n.refreshNoChanges, + l10n.editBrowserTitle, + l10n.addBrowserTitle, + l10n.fieldName, + l10n.fieldExecutablePath, + l10n.fieldExtraArgs, + l10n.fieldIconPath, + l10n.fieldIconHint, + l10n.cancel, + l10n.add, + l10n.save, + l10n.confirm, + l10n.sectionUrlRules, + l10n.noRulesYet, + l10n.columnDomain, + l10n.columnBrowser, + l10n.deleteRuleTitle, + l10n.delete, + l10n.deleteRuleTooltip, + l10n.sectionAbout, + l10n.appDescription, + l10n.mitLicense, + l10n.resetConfigLabel, + l10n.resetConfigDescription, + l10n.unregisterLabel, + l10n.unregisterDescription, + l10n.resetConfigTitle, + l10n.resetConfigContent, + l10n.reset, + l10n.unregisterTitle, + l10n.unregisterContent, + l10n.unregisterAction, + l10n.updateDownload, + l10n.updateTooltip, + l10n.sectionSupport, + l10n.donateLabel, + l10n.donateDescription, + l10n.sectionOtherTools, + l10n.otherToolCopyPaste, + l10n.otherToolCopyPasteDescription, + l10n.edgeWarningTitle, + l10n.edgeWarningBody, + l10n.edgeWarningNote, + l10n.edgeWarningDismiss, + l10n.sectionMaintenance, + l10n.exportDiagnosticsLabel, + l10n.exportDiagnosticsDescription, + ]; + + expect(values, everyElement(isNotEmpty)); + expect(l10n.refreshResult(2, 1), contains('2')); + expect(l10n.refreshResult(2, 1), contains('1')); + expect(l10n.deleteRuleContent('example.com'), contains('example.com')); + expect(l10n.appVersion('1.2.3'), contains('1.2.3')); + expect(l10n.updateAvailable('2.0.0'), contains('2.0.0')); +} + +void main() { + group('AppLocalizations delegate', () { + test('supports English and Spanish only', () { + expect(AppLocalizations.delegate.isSupported(const Locale('en')), isTrue); + expect(AppLocalizations.delegate.isSupported(const Locale('es')), isTrue); + expect( + AppLocalizations.delegate.isSupported(const Locale('fr')), + isFalse, + ); + }); + + test('loads Spanish strings', () async { + final l10n = await AppLocalizations.delegate.load(const Locale('es')); + expect(l10n.exit, 'Salir'); + expect(l10n.traySettings, 'Configuración'); + }); + + test('lookup throws for unsupported locale', () { + expect( + () => lookupAppLocalizations(const Locale('fr')), + throwsA(isA()), + ); + }); + + testWidgets('of returns localization from widget tree', (tester) async { + await tester.pumpWidget( + MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder(builder: (_) => const _LocalizedText()), + ), + ); + + expect(find.text('Salir'), findsOneWidget); + }); + }); + + group('AppLocalizations messages', () { + test('English getters and formatters are populated', () { + final l10n = AppLocalizationsEn(); + _expectAllMessages(l10n); + expect(l10n.exit, 'Exit'); + expect(l10n.launchAtStartup, 'Launch at system startup'); + expect(l10n.refreshResult(3, 2), '3 added, 2 removed'); + }); + + test('Spanish getters and formatters are populated', () { + final l10n = AppLocalizationsEs(); + _expectAllMessages(l10n); + expect(l10n.exit, 'Salir'); + expect(l10n.launchAtStartup, 'Iniciar con el sistema'); + expect(l10n.refreshResult(3, 2), '3 añadidos, 2 eliminados'); + }); + }); +} + +class _LocalizedText extends StatelessWidget { + const _LocalizedText(); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Text(l10n.exit, textDirection: TextDirection.ltr); + } +} diff --git a/apps/linkunbound/test/platform/local_file_url_test.dart b/apps/linkunbound/test/platform/local_file_url_test.dart new file mode 100644 index 0000000..b162a84 --- /dev/null +++ b/apps/linkunbound/test/platform/local_file_url_test.dart @@ -0,0 +1,119 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound/platform/local_file_url.dart'; + +void main() { + group('resolveLocalWebFile', () { + late Directory tmp; + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('lu_local_file_test_'); + }); + + tearDown(() async { + if (tmp.existsSync()) await tmp.delete(recursive: true); + }); + + test('returns absolute path for valid .html', () { + final f = File('${tmp.path}/page.html')..writeAsStringSync(''); + final url = Uri.file(f.path).toString(); + expect(resolveLocalWebFile(url), endsWith('page.html')); + }); + + test('accepts .htm and .xhtml', () { + final htm = File('${tmp.path}/a.htm')..writeAsStringSync('x'); + final xhtml = File('${tmp.path}/b.xhtml')..writeAsStringSync('x'); + expect( + resolveLocalWebFile(Uri.file(htm.path).toString()), + endsWith('a.htm'), + ); + expect( + resolveLocalWebFile(Uri.file(xhtml.path).toString()), + endsWith('b.xhtml'), + ); + }); + + test('PDF accepted only on Windows', () { + final f = File('${tmp.path}/doc.pdf')..writeAsStringSync('x'); + final out = resolveLocalWebFile(Uri.file(f.path).toString()); + if (Platform.isWindows) { + expect(out, endsWith('doc.pdf')); + } else { + expect(out, isNull); + } + }); + + test('rejects no-extension files', () { + final f = File('${tmp.path}/noext')..writeAsStringSync('x'); + expect(resolveLocalWebFile(Uri.file(f.path).toString()), isNull); + }); + + test('rejects non-existent file', () { + final url = Uri.file('${tmp.path}/missing.html').toString(); + expect(resolveLocalWebFile(url), isNull); + }); + + test('rejects http(s) URLs', () { + expect(resolveLocalWebFile('https://example.com/page.html'), isNull); + expect(resolveLocalWebFile('http://example.com/page.htm'), isNull); + }); + + test('rejects unparseable input', () { + expect(resolveLocalWebFile('not-a-url'), isNull); + }); + + test('accepts native Windows absolute path', () { + if (!Platform.isWindows) return; + final f = File('${tmp.path}/native.html')..writeAsStringSync('x'); + expect(resolveLocalWebFile(f.path), endsWith('native.html')); + }); + + test('rejects POSIX bare path on POSIX (must come via file://)', () { + if (Platform.isWindows) return; + final f = File('${tmp.path}/native.html')..writeAsStringSync('x'); + expect(resolveLocalWebFile(f.path), isNull); + }); + }); + + group('looksLikeLocalFile', () { + test('detects file:// URLs', () { + expect(looksLikeLocalFile('file:///tmp/foo.html'), isTrue); + }); + + test('rejects http(s)', () { + expect(looksLikeLocalFile('https://example.com'), isFalse); + expect(looksLikeLocalFile('http://example.com'), isFalse); + }); + + test('detects native absolute path on host platform', () { + if (Platform.isWindows) { + expect(looksLikeLocalFile(r'C:\Users\me\foo.html'), isTrue); + } else { + expect(looksLikeLocalFile('/tmp/foo.html'), isFalse); + } + }); + }); + + group('redactPath', () { + test('keeps only parent + filename', () { + expect(redactPath('/Users/me/projects/foo/bar.html'), '…/foo/bar.html'); + }); + + test('handles short paths', () { + expect(redactPath('/tmp/a.html'), '…/tmp/a.html'); + expect(redactPath('/a.html'), '…/a.html'); + }); + + test('handles Windows paths', () { + expect( + redactPath(r'C:\Users\me\projects\foo\bar.html'), + '…/foo/bar.html', + ); + }); + + test('handles empty', () { + expect(redactPath(''), ''); + }); + }); +} diff --git a/apps/linkunbound/test/platform/macos/mac_inbound_events_test.dart b/apps/linkunbound/test/platform/macos/mac_inbound_events_test.dart new file mode 100644 index 0000000..a68cd1b --- /dev/null +++ b/apps/linkunbound/test/platform/macos/mac_inbound_events_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; + +import 'package:linkunbound/platform/macos/mac_inbound_events.dart'; + +const _channel = MethodChannel('linkunbound/inbound_events'); +const _codec = StandardMethodCodec(); + +Future _dispatchPlatformCall(String method, [dynamic arguments]) async { + final ByteData data = _codec.encodeMethodCall(MethodCall(method, arguments)); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage(_channel.name, data, (_) {}); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List outboundCalls; + + setUp(() { + outboundCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + outboundCalls.add(call.method); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + }); + + test('start does not send ready until a listener subscribes', () async { + final events = MacInboundEvents(); + addTearDown(events.stop); + + await events.start(); + // Give microtasks a chance to run; ready must NOT have been sent yet. + await Future.delayed(Duration.zero); + expect(outboundCalls, isEmpty); + + final sub = events.events.listen((_) {}); + addTearDown(sub.cancel); + await Future.delayed(Duration.zero); + expect(outboundCalls, ['ready']); + }); + + test('ready is sent only once across multiple listeners', () async { + final events = MacInboundEvents(); + addTearDown(events.stop); + + await events.start(); + await events.start(); // start is idempotent + + final sub1 = events.events.listen((_) {}); + final sub2 = events.events.listen((_) {}); + addTearDown(sub1.cancel); + addTearDown(sub2.cancel); + await Future.delayed(Duration.zero); + + expect(outboundCalls, ['ready']); + }); + + test('emits open_url event from native channel', () async { + final events = MacInboundEvents(); + addTearDown(events.stop); + await events.start(); + + final future = expectLater( + events.events, + emits( + isA().having( + (event) => event.url, + 'url', + 'https://example.com', + ), + ), + ); + + await _dispatchPlatformCall('event', { + 'action': 'open_url', + 'url': 'https://example.com', + }); + + await future; + }); + + test('emits show_settings event from native channel', () async { + final events = MacInboundEvents(); + addTearDown(events.stop); + await events.start(); + + final future = expectLater(events.events, emits(isA())); + + await _dispatchPlatformCall('event', {'action': 'show_settings'}); + + await future; + }); + + test('ignores unsupported payloads', () async { + final events = MacInboundEvents(); + addTearDown(events.stop); + await events.start(); + + var emitted = false; + final sub = events.events.listen((_) => emitted = true); + addTearDown(sub.cancel); + + await _dispatchPlatformCall('event', {'action': 'unknown'}); + await _dispatchPlatformCall('noop', {'action': 'show_settings'}); + await Future.delayed(Duration.zero); + + expect(emitted, isFalse); + }); +} diff --git a/apps/linkunbound/test/platform/macos/mac_window_channel_test.dart b/apps/linkunbound/test/platform/macos/mac_window_channel_test.dart new file mode 100644 index 0000000..3cf9421 --- /dev/null +++ b/apps/linkunbound/test/platform/macos/mac_window_channel_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:linkunbound/platform/macos/mac_window_channel.dart'; + +const _channel = MethodChannel('linkunbound/window'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late List methodCalls; + + setUp(() { + methodCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + methodCalls.add(call.method); + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + }); + + test('setPickerMode invokes native channel', () async { + final channel = MacWindowChannel(); + + await channel.setPickerMode(); + + expect(methodCalls, ['setPickerMode']); + }); + + test('setSettingsMode invokes native channel', () async { + final channel = MacWindowChannel(); + + await channel.setSettingsMode(); + + expect(methodCalls, ['setSettingsMode']); + }); + + test('activate invokes native channel', () async { + final channel = MacWindowChannel(); + + await channel.activate(); + + expect(methodCalls, ['activate']); + }); +} diff --git a/apps/linkunbound/test/providers_extra_test.dart b/apps/linkunbound/test/providers_extra_test.dart new file mode 100644 index 0000000..26e4ace --- /dev/null +++ b/apps/linkunbound/test/providers_extra_test.dart @@ -0,0 +1,186 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:linkunbound/providers.dart'; + +final class _CountingRegistrationService implements RegistrationService { + _CountingRegistrationService({ + required this.isDefaultValue, + required this.associations, + }); + + final bool isDefaultValue; + final Set associations; + int isDefaultReads = 0; + int defaultAssociationsReads = 0; + + @override + Future> get defaultAssociations async { + defaultAssociationsReads++; + return associations; + } + + @override + Future get isDefault async { + isDefaultReads++; + return isDefaultValue; + } + + @override + Future register(String executablePath) async {} + + @override + Future unregister() async {} +} + +final class _CountingStartupService implements StartupService { + _CountingStartupService(this.isEnabledValue); + + final bool isEnabledValue; + int isEnabledReads = 0; + + @override + Future disable() async {} + + @override + Future enable(String executablePath) async {} + + @override + Future get isEnabled async { + isEnabledReads++; + return isEnabledValue; + } +} + +void main() { + group('override guards', () { + test('browserServiceProvider requires an override', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(() => container.read(browserServiceProvider), throwsStateError); + }); + }); + + group('EdgeWarningNotifier', () { + late Directory tempDir; + late File edgeWarningFile; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('edge_warning_test_'); + edgeWarningFile = File('${tempDir.path}/edge_warning_dismissed'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + ProviderContainer makeContainer() => ProviderContainer( + overrides: [edgeWarningFileProvider.overrideWithValue(edgeWarningFile)], + ); + + test('build returns false when dismissal file is missing', () { + final container = makeContainer(); + addTearDown(container.dispose); + + expect(container.read(edgeWarningDismissedProvider), isFalse); + }); + + test('build returns true when dismissal file exists', () { + edgeWarningFile.writeAsStringSync('1'); + final container = makeContainer(); + addTearDown(container.dispose); + + expect(container.read(edgeWarningDismissedProvider), isTrue); + }); + + test('dismiss writes the marker file and updates state', () { + final container = makeContainer(); + addTearDown(container.dispose); + + container.read(edgeWarningDismissedProvider.notifier).dismiss(); + + expect(edgeWarningFile.existsSync(), isTrue); + expect(edgeWarningFile.readAsStringSync(), '1'); + expect(container.read(edgeWarningDismissedProvider), isTrue); + }); + }); + + group('async providers', () { + test( + 'isDefaultBrowserProvider resolves from registration service', + () async { + final registration = _CountingRegistrationService( + isDefaultValue: true, + associations: {'http', 'https'}, + ); + final container = ProviderContainer( + overrides: [ + registrationServiceProvider.overrideWithValue(registration), + ], + ); + addTearDown(container.dispose); + + expect(await container.read(isDefaultBrowserProvider.future), isTrue); + expect(registration.isDefaultReads, 1); + }, + ); + + test( + 'defaultAssociationsProvider resolves from registration service', + () async { + final registration = _CountingRegistrationService( + isDefaultValue: false, + associations: {'.html', 'http'}, + ); + final container = ProviderContainer( + overrides: [ + registrationServiceProvider.overrideWithValue(registration), + ], + ); + addTearDown(container.dispose); + + expect(await container.read(defaultAssociationsProvider.future), { + '.html', + 'http', + }); + expect(registration.defaultAssociationsReads, 1); + }, + ); + + test('isStartupEnabledProvider resolves from startup service', () async { + final startup = _CountingStartupService(true); + final container = ProviderContainer( + overrides: [startupServiceProvider.overrideWithValue(startup)], + ); + addTearDown(container.dispose); + + expect(await container.read(isStartupEnabledProvider.future), isTrue); + expect(startup.isEnabledReads, 1); + }); + + test('packageInfoProvider returns mocked package metadata', () async { + PackageInfo.setMockInitialValues( + appName: 'LinkUnbound', + packageName: 'dev.rg.LinkUnbound', + version: '9.9.9', + buildNumber: '42', + buildSignature: 'sig', + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + final info = await container.read(packageInfoProvider.future); + + expect(info.appName, 'LinkUnbound'); + expect(info.version, '9.9.9'); + expect(info.buildNumber, '42'); + }); + }); +} diff --git a/apps/linkunbound/test/ui/general_page_test.dart b/apps/linkunbound/test/ui/general_page_test.dart index 9591b6a..76cd4b0 100644 --- a/apps/linkunbound/test/ui/general_page_test.dart +++ b/apps/linkunbound/test/ui/general_page_test.dart @@ -189,7 +189,7 @@ void main() { buildTestApp(const GeneralPage(), overrides: f.overrides), ); await tester.pumpAndSettle(); - expect(find.text('Launch at Windows startup'), findsOneWidget); + expect(find.text('Launch at system startup'), findsOneWidget); }); testWidgets('startup switch is off by default', (tester) async { @@ -214,6 +214,19 @@ void main() { expect(sw.value, isTrue); }); + testWidgets('tapping startup switch when off calls service enable', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir, isStartupEnabled: false); + await tester.pumpWidget( + buildTestApp(const GeneralPage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Switch)); + await tester.pumpAndSettle(); + expect(find.byType(Switch), findsOneWidget); + }); + testWidgets('tapping startup switch calls service disable', (tester) async { final f = makeFixtures(dir: tempDir, isStartupEnabled: true); await tester.pumpWidget( @@ -362,6 +375,36 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(Dialog), findsOneWidget); }); + + testWidgets('duplicating a browser adds a copy to the list', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir, browsers: [_chrome]); + await tester.pumpWidget( + buildTestApp(const GeneralPage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Duplicate')); + await tester.pumpAndSettle(); + expect(find.text('Google Chrome'), findsWidgets); + }); + + testWidgets('removing a browser removes it from the list', (tester) async { + final f = makeFixtures(dir: tempDir, browsers: [_chrome]); + await tester.pumpWidget( + buildTestApp(const GeneralPage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.more_vert)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove')); + // Pump once to let the synchronous removeBrowser() call execute. + await tester.pump(); + // The in-memory service list is updated synchronously before the async save. + expect(f.browserService.browsers, isEmpty); + }); }); group('GeneralPage — edge warning card', () { diff --git a/apps/linkunbound/test/ui/maintenance_page_actions_test.dart b/apps/linkunbound/test/ui/maintenance_page_actions_test.dart new file mode 100644 index 0000000..6cce62b --- /dev/null +++ b/apps/linkunbound/test/ui/maintenance_page_actions_test.dart @@ -0,0 +1,180 @@ +import 'dart:io'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:linkunbound/providers.dart'; +import 'package:linkunbound/ui/settings/maintenance_page.dart'; + +import '../helpers.dart'; + +const _chrome = Browser( + id: 'chrome', + name: 'Google Chrome', + executablePath: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + iconPath: 'chrome.png', +); + +final class _RecordingRegistrationService implements RegistrationService { + int unregisterCalls = 0; + + @override + Future> get defaultAssociations async => {}; + + @override + Future get isDefault async => false; + + @override + Future register(String executablePath) async {} + + @override + Future unregister() async { + unregisterCalls++; + } +} + +final class _RecordingIconExtractor implements IconExtractor { + final List<(String executablePath, String outputPath)> calls = []; + + @override + Future extractIcon(String executablePath, String outputPath) async { + calls.add((executablePath, outputPath)); + return outputPath; + } +} + +List _makeOverrides({ + required Directory tempDir, + required BrowserService browserService, + required RuleService ruleService, + required RegistrationService registrationService, + required IconExtractor iconExtractor, +}) { + final iconsDir = Directory('${tempDir.path}/icons')..createSync(); + + return [ + browserServiceProvider.overrideWithValue(browserService), + ruleServiceProvider.overrideWithValue(ruleService), + registrationServiceProvider.overrideWithValue(registrationService), + startupServiceProvider.overrideWithValue(FakeStartupService()), + launchServiceProvider.overrideWithValue(FakeLaunchService()), + iconExtractorProvider.overrideWithValue(iconExtractor), + iconsDirProvider.overrideWithValue(iconsDir), + localeFileProvider.overrideWithValue(File('${tempDir.path}/locale')), + edgeWarningFileProvider.overrideWithValue( + File('${tempDir.path}/edge_warning_dismissed'), + ), + appDataDirProvider.overrideWithValue(tempDir), + packageInfoProvider.overrideWith( + (ref) async => PackageInfo( + appName: 'LinkUnbound', + packageName: 'linkunbound', + version: '1.0.0', + buildNumber: '1', + buildSignature: 'sig', + ), + ), + updateInfoProvider.overrideWith((ref) async => null), + ]; +} + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync( + 'maintenance_page_actions_test_', + ); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + testWidgets('confirming reset rescans browsers and re-extracts icons', ( + tester, + ) async { + final browserService = + BrowserService( + configFile: File('${tempDir.path}/browsers.json'), + browserDetector: FakeBrowserDetector([_chrome]), + )..addBrowser( + const Browser( + id: 'old', + name: 'Old Browser', + executablePath: '/tmp/old-browser', + iconPath: 'old.png', + ), + ); + final ruleService = RuleService( + rulesFile: File('${tempDir.path}/rules.json'), + ); + final registration = _RecordingRegistrationService(); + final iconExtractor = _RecordingIconExtractor(); + + await tester.pumpWidget( + buildTestApp( + const MaintenancePage(), + overrides: _makeOverrides( + tempDir: tempDir, + browserService: browserService, + ruleService: ruleService, + registrationService: registration, + iconExtractor: iconExtractor, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Reset configuration')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reset')); + await tester.pumpAndSettle(); + + expect(browserService.browsers.map((browser) => browser.id).toList(), [ + 'chrome', + ]); + expect(iconExtractor.calls, hasLength(1)); + expect(iconExtractor.calls.single.$1, _chrome.executablePath); + expect(iconExtractor.calls.single.$2, endsWith('/chrome.png')); + }); + + testWidgets('confirming unregister calls registration service', ( + tester, + ) async { + final browserService = BrowserService( + configFile: File('${tempDir.path}/browsers.json'), + browserDetector: FakeBrowserDetector(), + ); + final ruleService = RuleService( + rulesFile: File('${tempDir.path}/rules.json'), + ); + final registration = _RecordingRegistrationService(); + + await tester.pumpWidget( + buildTestApp( + const MaintenancePage(), + overrides: _makeOverrides( + tempDir: tempDir, + browserService: browserService, + ruleService: ruleService, + registrationService: registration, + iconExtractor: FakeIconExtractor(), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Unregister LinkUnbound')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Unregister')); + await tester.pumpAndSettle(); + + expect(registration.unregisterCalls, 1); + }); +} diff --git a/apps/linkunbound/test/ui/maintenance_page_test.dart b/apps/linkunbound/test/ui/maintenance_page_test.dart index fca1990..ae7274e 100644 --- a/apps/linkunbound/test/ui/maintenance_page_test.dart +++ b/apps/linkunbound/test/ui/maintenance_page_test.dart @@ -66,6 +66,37 @@ void main() { }); group('MaintenancePage dialogs', () { + testWidgets('tapping Export diagnostics shows loading indicator', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir); + await tester.pumpWidget( + buildTestApp(const MaintenancePage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Export diagnostics')); + await tester.pump(); // show loading dialog + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // Don't pumpAndSettle: the export runs real Process.run on the host + // and the spinner animates indefinitely. Verify the dialog opened. + }); + + testWidgets('confirming Reset executes reset and closes dialog', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir); + await tester.pumpWidget( + buildTestApp(const MaintenancePage(), overrides: f.overrides), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reset configuration')); + await tester.pumpAndSettle(); + // Tap the destructive confirm button + await tester.tap(find.text('Reset')); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + }); + testWidgets('tapping Reset configuration shows confirmation dialog', ( tester, ) async { diff --git a/apps/linkunbound/test/ui/picker_view_test.dart b/apps/linkunbound/test/ui/picker_view_test.dart index 720af92..9d44c65 100644 --- a/apps/linkunbound/test/ui/picker_view_test.dart +++ b/apps/linkunbound/test/ui/picker_view_test.dart @@ -327,6 +327,91 @@ void main() { }); }); + group('PickerView — local file URLs', () { + testWidgets('file:// URL shows file icon instead of link icon', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir); + await tester.pumpWidget( + buildTestApp( + const PickerView(url: 'file:///home/user/documents/report.pdf'), + overrides: f.overrides, + ), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.insert_drive_file_outlined), findsOneWidget); + expect(find.byIcon(Icons.link), findsNothing); + }); + + testWidgets('file:// URL shows filename as primary domain text', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir); + await tester.pumpWidget( + buildTestApp( + const PickerView(url: 'file:///home/user/documents/report.pdf'), + overrides: f.overrides, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('report.pdf'), findsOneWidget); + }); + + testWidgets('file:// URL with deep path shows …/parent/file in secondary', ( + tester, + ) async { + final f = makeFixtures(dir: tempDir); + await tester.pumpWidget( + buildTestApp( + const PickerView(url: 'file:///home/user/documents/report.pdf'), + overrides: f.overrides, + ), + ); + await tester.pumpAndSettle(); + expect(find.text('…/documents/report.pdf'), findsOneWidget); + }); + + testWidgets( + 'file:// URL with single-segment path shows …/file in secondary', + (tester) async { + final f = makeFixtures(dir: tempDir); + await tester.pumpWidget( + buildTestApp( + const PickerView(url: 'file:///report.pdf'), + overrides: f.overrides, + ), + ); + await tester.pumpAndSettle(); + // domain = 'report.pdf'; secondary = '…/report.pdf' + expect(find.text('…/report.pdf'), findsOneWidget); + }, + ); + + testWidgets( + 'launching with always-open and file:// URL does not save a rule', + (tester) async { + final f = makeFixtures(dir: tempDir, browsers: [_chrome]); + await tester.pumpWidget( + buildTestApp( + const PickerView(url: 'file:///home/user/report.pdf'), + overrides: f.overrides, + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byType(Checkbox)); + await tester.pump(); + await tester.tap(find.text('Google Chrome')); + await tester.pumpAndSettle(); + // file:// URLs have no host → guard in _launch prevents rule creation + expect(f.launchService.launches, contains('chrome.exe')); + expect( + f.ruleService.lookupBrowser('file:///home/user/report.pdf'), + isNull, + ); + }, + ); + }); + group('PickerView — browser row hover', () { testWidgets('browser row changes color on mouse enter', (tester) async { final f = makeFixtures(dir: tempDir, browsers: [_chrome]); diff --git a/packages/core/lib/linkunbound_core.dart b/packages/core/lib/linkunbound_core.dart index f193eed..aa3fe3c 100644 --- a/packages/core/lib/linkunbound_core.dart +++ b/packages/core/lib/linkunbound_core.dart @@ -3,7 +3,7 @@ export 'src/models/browser_config.dart'; export 'src/models/rule.dart'; export 'src/platform/browser_detector.dart'; export 'src/platform/icon_extractor.dart'; -export 'src/platform/pipe_service.dart'; +export 'src/platform/inbound_event.dart'; export 'src/platform/registration_service.dart'; export 'src/platform/startup_service.dart'; export 'src/services/browser_service.dart'; diff --git a/packages/core/lib/src/platform/inbound_event.dart b/packages/core/lib/src/platform/inbound_event.dart new file mode 100644 index 0000000..72d1202 --- /dev/null +++ b/packages/core/lib/src/platform/inbound_event.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +sealed class InboundEvent { + const InboundEvent(); + + Map toJson(); + + String encode() => jsonEncode(toJson()); + + static InboundEvent decode(String raw) => + fromJson(jsonDecode(raw) as Map); + + static InboundEvent fromJson(Map json) => + switch (json['action'] as String?) { + 'open_url' => OpenUrlEvent(json['url'] as String), + 'show_settings' => const ShowSettingsEvent(), + _ => throw FormatException( + 'Unknown inbound event action: ${json['action']}', + ), + }; +} + +final class OpenUrlEvent extends InboundEvent { + const OpenUrlEvent(this.url); + final String url; + + @override + Map toJson() => {'action': 'open_url', 'url': url}; +} + +final class ShowSettingsEvent extends InboundEvent { + const ShowSettingsEvent(); + + @override + Map toJson() => {'action': 'show_settings'}; +} + +abstract interface class InboundEventServer { + Future start(); + + Stream get events; + + Future stop(); +} + +abstract interface class InboundEventClient { + Future send(InboundEvent event); +} diff --git a/packages/core/lib/src/platform/pipe_service.dart b/packages/core/lib/src/platform/pipe_service.dart deleted file mode 100644 index e9bbf44..0000000 --- a/packages/core/lib/src/platform/pipe_service.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:convert'; - -sealed class PipeMessage { - const PipeMessage(); - - Map toJson(); - - String encode() => jsonEncode(toJson()); - - static PipeMessage decode(String raw) => - fromJson(jsonDecode(raw) as Map); - - static PipeMessage fromJson(Map json) => - switch (json['action'] as String?) { - 'open_url' => OpenUrlMessage(json['url'] as String), - 'show_settings' => const ShowSettingsMessage(), - 'ping' => const PingMessage(), - _ => throw FormatException( - 'Unknown pipe message action: ${json['action']}', - ), - }; -} - -final class OpenUrlMessage extends PipeMessage { - const OpenUrlMessage(this.url); - final String url; - - @override - Map toJson() => {'action': 'open_url', 'url': url}; -} - -final class ShowSettingsMessage extends PipeMessage { - const ShowSettingsMessage(); - - @override - Map toJson() => {'action': 'show_settings'}; -} - -final class PingMessage extends PipeMessage { - const PingMessage(); - - @override - Map toJson() => {'action': 'ping'}; -} - -abstract interface class PipeServer { - Future start(); - - Stream get messages; - - Future stop(); -} - -abstract interface class PipeClient { - Future send(PipeMessage message); -} diff --git a/packages/core/lib/src/services/log_service.dart b/packages/core/lib/src/services/log_service.dart index 70eb744..e8c94f9 100644 --- a/packages/core/lib/src/services/log_service.dart +++ b/packages/core/lib/src/services/log_service.dart @@ -1,9 +1,12 @@ +import 'dart:async'; import 'dart:io'; import 'package:logging/logging.dart'; const _maxLogSize = 2 * 1024 * 1024; // 2 MB +StreamSubscription? _logSubscription; + final _urlPattern = RegExp(r'https?://[^\s,\]\)]+', caseSensitive: false); final _filePathPattern = RegExp( r'file:///[a-zA-Z]:[\\/][^\s,\]\)]*|[a-zA-Z]:\\[^\s,\]\)]*', @@ -26,11 +29,14 @@ String redactUrls(String text) { } void initLogging(File logFile) { + _logSubscription?.cancel(); + _logSubscription = null; + logFile.parent.createSync(recursive: true); _rotateIfNeeded(logFile); Logger.root.level = Level.ALL; - Logger.root.onRecord.listen((record) { + _logSubscription = Logger.root.onRecord.listen((record) { final message = redactUrls(record.message); final line = '${record.time.toIso8601String()} ' @@ -40,7 +46,11 @@ void initLogging(File logFile) { '${record.error != null ? '\n ${record.error}' : ''}' '${record.stackTrace != null ? '\n ${record.stackTrace}' : ''}'; stderr.writeln(line); - logFile.writeAsStringSync('$line\n', mode: FileMode.append); + try { + logFile.writeAsStringSync('$line\n', mode: FileMode.append); + } on FileSystemException { + // Log directory may have been removed (e.g. during tests). Drop silently. + } }); } diff --git a/packages/core/lib/src/url_utils.dart b/packages/core/lib/src/url_utils.dart index 065e1d7..dd19035 100644 --- a/packages/core/lib/src/url_utils.dart +++ b/packages/core/lib/src/url_utils.dart @@ -14,3 +14,24 @@ String stripEdgeProtocol(String raw) { } return raw; } + +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.isEmpty) return raw; + + final decoded = Uri.decodeFull(inner); + final innerUri = Uri.tryParse(decoded); + if (innerUri == null) return raw; + if (innerUri.scheme != 'http' && innerUri.scheme != 'https') return raw; + + return decoded; +} diff --git a/packages/core/test/inbound_event_test.dart b/packages/core/test/inbound_event_test.dart new file mode 100644 index 0000000..0d81341 --- /dev/null +++ b/packages/core/test/inbound_event_test.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; + +import 'package:linkunbound_core/linkunbound_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('OpenUrlEvent', () { + test('encode produces correct JSON', () { + const event = OpenUrlEvent('https://example.com'); + final json = jsonDecode(event.encode()) as Map; + expect(json['action'], 'open_url'); + expect(json['url'], 'https://example.com'); + }); + + test('decode round-trips', () { + const original = OpenUrlEvent('https://test.com'); + final decoded = InboundEvent.decode(original.encode()); + expect(decoded, isA()); + expect((decoded as OpenUrlEvent).url, 'https://test.com'); + }); + }); + + group('ShowSettingsEvent', () { + test('encode produces correct JSON', () { + const event = ShowSettingsEvent(); + final json = jsonDecode(event.encode()) as Map; + expect(json['action'], 'show_settings'); + }); + + test('decode round-trips', () { + const original = ShowSettingsEvent(); + final decoded = InboundEvent.decode(original.encode()); + expect(decoded, isA()); + }); + }); + + group('decode errors', () { + test('unknown action throws FormatException', () { + expect( + () => InboundEvent.decode('{"action": "unknown"}'), + throwsA(isA()), + ); + }); + + test('missing action throws FormatException', () { + expect( + () => InboundEvent.decode('{"url": "https://x.com"}'), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/core/test/log_service_test.dart b/packages/core/test/log_service_test.dart index f546909..775d94c 100644 --- a/packages/core/test/log_service_test.dart +++ b/packages/core/test/log_service_test.dart @@ -89,6 +89,18 @@ void main() { Logger.root.severe('trace-test', Exception('e'), StackTrace.current); expect(logFile.readAsStringSync(), contains('trace-test')); }); + + test( + 'silently swallows FileSystemException when log directory is removed mid-session', + () { + final logFile = File('${tempDir.path}/app.log'); + initLogging(logFile); + // Delete the directory after initLogging so the write will fail. + tempDir.deleteSync(recursive: true); + // Must not throw — the catch block drops the write silently. + expect(() => Logger.root.info('after-removal'), returnsNormally); + }, + ); }); group('rotation', () { diff --git a/packages/core/test/pipe_message_test.dart b/packages/core/test/pipe_message_test.dart deleted file mode 100644 index 9ed6f4f..0000000 --- a/packages/core/test/pipe_message_test.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:convert'; - -import 'package:linkunbound_core/linkunbound_core.dart'; -import 'package:test/test.dart'; - -void main() { - group('OpenUrlMessage', () { - test('encode produces correct JSON', () { - const msg = OpenUrlMessage('https://example.com'); - final json = jsonDecode(msg.encode()) as Map; - expect(json['action'], 'open_url'); - expect(json['url'], 'https://example.com'); - }); - - test('decode round-trips', () { - const original = OpenUrlMessage('https://test.com'); - final decoded = PipeMessage.decode(original.encode()); - expect(decoded, isA()); - expect((decoded as OpenUrlMessage).url, 'https://test.com'); - }); - }); - - group('ShowSettingsMessage', () { - test('encode produces correct JSON', () { - const msg = ShowSettingsMessage(); - final json = jsonDecode(msg.encode()) as Map; - expect(json['action'], 'show_settings'); - }); - - test('decode round-trips', () { - const original = ShowSettingsMessage(); - final decoded = PipeMessage.decode(original.encode()); - expect(decoded, isA()); - }); - }); - - group('PingMessage', () { - test('encode produces correct JSON', () { - const msg = PingMessage(); - final json = jsonDecode(msg.encode()) as Map; - expect(json['action'], 'ping'); - }); - - test('decode round-trips', () { - const original = PingMessage(); - final decoded = PipeMessage.decode(original.encode()); - expect(decoded, isA()); - }); - }); - - group('decode errors', () { - test('unknown action throws FormatException', () { - expect( - () => PipeMessage.decode('{"action": "unknown"}'), - throwsA(isA()), - ); - }); - - test('missing action throws FormatException', () { - expect( - () => PipeMessage.decode('{"url": "https://x.com"}'), - throwsA(isA()), - ); - }); - }); -} diff --git a/packages/core/test/url_utils_test.dart b/packages/core/test/url_utils_test.dart index 27545c1..b0ddabc 100644 --- a/packages/core/test/url_utils_test.dart +++ b/packages/core/test/url_utils_test.dart @@ -56,4 +56,58 @@ void main() { expect(stripEdgeProtocol(input), 'https://example.com/p?a=1&b=2#section'); }); }); + + group('unwrapSafeLink', () { + test('returns input unchanged when not a safe link', () { + expect( + unwrapSafeLink('https://example.com/page'), + 'https://example.com/page', + ); + }); + + test('returns input when host is unrelated', () { + expect( + unwrapSafeLink('https://other.example.com/?url=https://x.com'), + 'https://other.example.com/?url=https://x.com', + ); + }); + + test('unwraps Outlook SafeLink', () { + const inner = 'https://example.com/report?id=7'; + final wrapped = + 'https://nam12.safelinks.protection.outlook.com/' + '?url=${Uri.encodeComponent(inner)}'; + expect(unwrapSafeLink(wrapped), inner); + }); + + test('unwraps Teams CDN redirector', () { + const inner = 'https://teams.example.com/document?id=1'; + final wrapped = + 'https://statics.teams.cdn.office.net/' + '?url=${Uri.encodeComponent(inner)}'; + expect(unwrapSafeLink(wrapped), inner); + }); + + test('returns original when inner url parameter is missing', () { + const wrapped = 'https://nam12.safelinks.protection.outlook.com/?other=1'; + expect(unwrapSafeLink(wrapped), wrapped); + }); + + test('returns original when inner url is empty', () { + const wrapped = 'https://nam12.safelinks.protection.outlook.com/?url='; + expect(unwrapSafeLink(wrapped), wrapped); + }); + + test('returns original when inner scheme is not http(s)', () { + final wrapped = + 'https://nam12.safelinks.protection.outlook.com/' + '?url=${Uri.encodeComponent('javascript:alert(1)')}'; + expect(unwrapSafeLink(wrapped), wrapped); + }); + + test('returns original when input is not parseable', () { + const malformed = 'http://[::1'; + expect(unwrapSafeLink(malformed), malformed); + }); + }); } diff --git a/resources/assets/LinkUnbound_tray.ico b/resources/assets/LinkUnbound_tray.ico new file mode 100644 index 0000000..d529df4 Binary files /dev/null and b/resources/assets/LinkUnbound_tray.ico differ diff --git a/resources/assets/LinkUnbound_tray_128.png b/resources/assets/LinkUnbound_tray_128.png new file mode 100644 index 0000000..7dc8e4c Binary files /dev/null and b/resources/assets/LinkUnbound_tray_128.png differ diff --git a/resources/assets/LinkUnbound_tray_256.png b/resources/assets/LinkUnbound_tray_256.png new file mode 100644 index 0000000..14bea3e Binary files /dev/null and b/resources/assets/LinkUnbound_tray_256.png differ diff --git a/resources/assets/LinkUnbound_tray_32.png b/resources/assets/LinkUnbound_tray_32.png new file mode 100644 index 0000000..017ba04 Binary files /dev/null and b/resources/assets/LinkUnbound_tray_32.png differ diff --git a/resources/assets/LinkUnbound_tray_64.png b/resources/assets/LinkUnbound_tray_64.png new file mode 100644 index 0000000..c3d79cc Binary files /dev/null and b/resources/assets/LinkUnbound_tray_64.png differ diff --git a/scripts/build_macos_dmg.sh b/scripts/build_macos_dmg.sh new file mode 100755 index 0000000..507a643 --- /dev/null +++ b/scripts/build_macos_dmg.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Build, sign, package as DMG and notarize LinkUnbound.app for distribution. +# +# Required env vars (load from .env or your shell): +# APPLE_ID Apple ID email (e.g. you@example.com) +# APPLE_APP_PASSWORD App-specific password from appleid.apple.com +# APPLE_TEAM_ID 10-char Team ID (e.g. TFKDH6LAD4) +# Optional: +# SIGN_IDENTITY Override codesign identity. Default: first +# "Developer ID Application" in the login keychain. +# VERSION Override pubspec version (e.g. 2.0.0-beta.1) +# SKIP_NOTARIZE=1 Build + sign + DMG only, no notarization. +# +# Usage: +# ./scripts/build_macos_dmg.sh +# +# Output: apps/linkunbound/dist/LinkUnbound__universal.dmg + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_DIR="$REPO_ROOT/apps/linkunbound" +APP_NAME="LinkUnbound" +APP_PATH="$APP_DIR/build/macos/Build/Products/Release/${APP_NAME}.app" +ENTITLEMENTS="$APP_DIR/macos/Runner/Release.entitlements" +DIST_DIR="$APP_DIR/dist" + +require() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "::error:: required command not found: $1" + exit 1 + fi +} + +require flutter +require codesign +require xcrun + +if ! command -v create-dmg >/dev/null 2>&1; then + echo "create-dmg not found. Installing via Homebrew..." + brew install create-dmg +fi + +VERSION="${VERSION:-$(grep '^version:' "$APP_DIR/pubspec.yaml" | awk '{print $2}')}" +BUILD_NAME="${VERSION%-*}" +BUILD_NUMBER=$(echo "$BUILD_NAME" | awk -F. '{printf "%d%03d%03d", $1, $2, $3}') + +echo "==> Version: $VERSION Build: $BUILD_NAME ($BUILD_NUMBER)" + +SIGN_IDENTITY="${SIGN_IDENTITY:-$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}')}" +if [[ -z "$SIGN_IDENTITY" ]]; then + echo "::error:: no 'Developer ID Application' identity found in keychain." + echo "Available identities:" + security find-identity -v -p codesigning + exit 1 +fi +echo "==> Signing identity: $SIGN_IDENTITY" + +echo "==> Refreshing CocoaPods..." +rm -f "$APP_DIR/macos/Podfile.lock" +( cd "$APP_DIR/macos" && pod install --repo-update ) + +echo "==> Building release (universal)..." +( cd "$APP_DIR" && flutter build macos --release \ + --build-name="$BUILD_NAME" \ + --build-number="$BUILD_NUMBER" \ + --dart-define="APP_VERSION=$VERSION" ) + +echo "==> Verifying universal binary..." +ARCHS=$(lipo -archs "$APP_PATH/Contents/MacOS/$APP_NAME") +echo "Architectures: $ARCHS" +if [[ "$ARCHS" != *"x86_64"* ]] || [[ "$ARCHS" != *"arm64"* ]]; then + echo "::error:: expected universal binary (x86_64 + arm64), got: $ARCHS" + exit 1 +fi + +echo "==> Signing .app with Hardened Runtime..." +codesign --deep --force --options runtime \ + --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_IDENTITY" \ + "$APP_PATH" + +codesign --verify --deep --strict "$APP_PATH" +echo "==> .app signature verified." + +mkdir -p "$DIST_DIR" +DMG_NAME="${APP_NAME}_${VERSION}_universal.dmg" +DMG_PATH="$DIST_DIR/$DMG_NAME" +rm -f "$DMG_PATH" + +echo "==> Creating DMG..." +create-dmg \ + --volname "$APP_NAME" \ + --window-pos 200 120 \ + --window-size 660 400 \ + --icon-size 80 \ + --icon "${APP_NAME}.app" 180 190 \ + --app-drop-link 480 190 \ + --hide-extension "${APP_NAME}.app" \ + --no-internet-enable \ + "$DMG_PATH" \ + "$APP_PATH" \ + || true + +if [[ ! -f "$DMG_PATH" ]]; then + echo "::error:: DMG was not created" + exit 1 +fi + +echo "==> DMG created: $DMG_NAME ($(du -h "$DMG_PATH" | cut -f1))" + +echo "==> Signing DMG..." +codesign --force --sign "$SIGN_IDENTITY" "$DMG_PATH" +codesign --verify "$DMG_PATH" + +if [[ "${SKIP_NOTARIZE:-0}" == "1" ]]; then + echo "==> SKIP_NOTARIZE=1, skipping notarization." + echo "==> Done: $DMG_PATH" + exit 0 +fi + +: "${APPLE_ID:?APPLE_ID not set}" +: "${APPLE_APP_PASSWORD:?APPLE_APP_PASSWORD not set}" +: "${APPLE_TEAM_ID:?APPLE_TEAM_ID not set}" + +echo "==> Submitting for notarization (this may take several minutes)..." +xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_APP_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait \ + --timeout 1200 + +echo "==> Stapling notarization ticket..." +xcrun stapler staple "$DMG_PATH" + +echo "==> Verifying notarization..." +spctl --assess --type open --context context:primary-signature "$DMG_PATH" + +echo "==> Done: $DMG_PATH" diff --git a/scripts/dev_clean.sh b/scripts/dev_clean.sh new file mode 100755 index 0000000..17a508d --- /dev/null +++ b/scripts/dev_clean.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# LinkUnbound — Dev cleanup for macOS. +# +# Removes user-scoped traces of LinkUnbound so the next run boots from a +# clean state: Application Support, caches, preferences, saved state, +# Launch Services handler registration, login items, and build outputs. +# +# Usage: +# ./scripts/dev_clean.sh # full clean +# ./scripts/dev_clean.sh --dry-run # show what would be removed +# ./scripts/dev_clean.sh --skip-files # only LaunchServices / login items +# ./scripts/dev_clean.sh --skip-ls # only files +# +# Safe to run repeatedly. Does not require sudo. + +set -u + +DRY_RUN=0 +SKIP_FILES=0 +SKIP_LS=0 + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + --skip-files) SKIP_FILES=1 ;; + --skip-ls) SKIP_LS=1 ;; + -h|--help) + sed -n '2,15p' "$0" + exit 0 + ;; + *) echo "Unknown option: $arg" >&2; exit 2 ;; + esac +done + +BUNDLE_ID="com.rgdevment.linkunbound" +APP_NAME="LinkUnbound" +removed=0 + +c_gray() { printf '\033[90m%s\033[0m\n' "$*"; } +c_green() { printf '\033[32m%s\033[0m\n' "$*"; } +c_yellow() { printf '\033[33m%s\033[0m\n' "$*"; } +c_cyan() { printf '\n\033[36m=== %s ===\033[0m\n' "$*"; } + +step() { c_gray " [-] $*"; } +done_msg(){ c_green " [OK] $*"; } +skip_msg(){ c_yellow " [--] $*"; } + +remove_path() { + local path="$1" + if [[ -e "$path" || -L "$path" ]]; then + if (( DRY_RUN )); then + step "Would remove: $path" + else + rm -rf "$path" 2>/dev/null && { + done_msg "Removed: $path" + removed=$((removed + 1)) + } || skip_msg "Failed to remove: $path" + fi + fi +} + +remove_glob() { + local pattern="$1" + shopt -s nullglob + for p in $pattern; do + remove_path "$p" + done + shopt -u nullglob +} + +printf '\n\033[1mLinkUnbound Dev Cleanup (macOS)\033[0m\n' +(( DRY_RUN )) && c_yellow "(DRY RUN — nothing will be deleted)" + +if (( ! SKIP_FILES )); then + c_cyan "Files: Application Support / Caches / Preferences" + + remove_path "$HOME/Library/Application Support/$APP_NAME" + remove_path "$HOME/Library/Application Support/$BUNDLE_ID" + remove_path "$HOME/Library/Caches/$BUNDLE_ID" + remove_path "$HOME/Library/HTTPStorages/$BUNDLE_ID" + remove_path "$HOME/Library/HTTPStorages/$BUNDLE_ID.binarycookies" + remove_path "$HOME/Library/WebKit/$BUNDLE_ID" + remove_path "$HOME/Library/Preferences/$BUNDLE_ID.plist" + remove_path "$HOME/Library/Saved Application State/$BUNDLE_ID.savedState" + remove_path "$HOME/Library/Containers/$BUNDLE_ID" + remove_path "$HOME/Library/Group Containers/$BUNDLE_ID" + + c_cyan "Files: Logs" + remove_path "$HOME/Library/Logs/$APP_NAME" + remove_path "$HOME/Library/Logs/$BUNDLE_ID" + + c_cyan "Files: Build outputs" + project_root="$(cd "$(dirname "$0")/.." && pwd)" + remove_path "$project_root/build" + remove_path "$project_root/apps/linkunbound/build" + remove_path "$project_root/apps/linkunbound/macos/Pods" + remove_path "$project_root/apps/linkunbound/macos/Flutter/ephemeral" +fi + +if (( ! SKIP_LS )); then + c_cyan "Launch Services: handler registration" + + lsreg="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister" + if [[ -x "$lsreg" ]]; then + if (( DRY_RUN )); then + step "Would unregister bundle id: $BUNDLE_ID" + step "Would rebuild Launch Services database" + else + "$lsreg" -u -all "/Applications/$APP_NAME.app" 2>/dev/null || true + "$lsreg" -kill -r -domain local -domain system -domain user >/dev/null 2>&1 + done_msg "Launch Services database rebuilt" + removed=$((removed + 1)) + fi + else + skip_msg "lsregister not found — skipping LS reset" + fi + + c_cyan "Default browser association (LSHandlers in Global Preferences)" + + matches=$(defaults read com.apple.LaunchServices/com.apple.launchservices.secure 2>/dev/null \ + | grep -ic "$BUNDLE_ID" || true) + if [[ "$matches" -gt 0 ]]; then + if (( DRY_RUN )); then + step "Would remove $matches LSHandlers entries referencing $BUNDLE_ID" + step "(use System Settings → Desktop & Dock → Default web browser to switch)" + else + skip_msg "$matches LSHandlers entries reference $BUNDLE_ID — change default browser in System Settings → Desktop & Dock" + fi + fi + + c_cyan "Login Items (auto-launch on startup)" + + if osascript -e "tell application \"System Events\" to get the name of every login item" 2>/dev/null \ + | tr ',' '\n' | grep -qi "$APP_NAME"; then + if (( DRY_RUN )); then + step "Would remove login item: $APP_NAME" + else + osascript -e "tell application \"System Events\" to delete every login item whose name is \"$APP_NAME\"" >/dev/null 2>&1 \ + && { done_msg "Removed login item: $APP_NAME"; removed=$((removed + 1)); } \ + || skip_msg "Failed to remove login item (may need accessibility permissions)" + fi + fi +fi + +printf '\n\033[1m--- Done. %d items cleaned. ---\033[0m\n\n' "$removed"