diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index 6e904916..b26fc984 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -1,9 +1,9 @@ # This workflow builds the Finicky macOS app for both Silicon (ARM64) and Intel (x86_64) architectures -name: macOS Build +name: macOS Build (cross-platform regression check) on: push: - branches: [main] + branches: [main, windows-support, windows-smoke] pull_request: branches: [main] workflow_dispatch: diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 00000000..f4a84288 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,135 @@ +name: Windows Build + +on: + push: + branches: [main, windows-support, windows-smoke] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # full history + tags so `git describe` yields the real version + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: | + packages/config-api/package-lock.json + packages/finicky-ui/package-lock.json + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + cache-dependency-path: | + apps/finicky/src/go.sum + + - name: Install Node dependencies + run: | + cd packages/config-api + npm ci + cd ../finicky-ui + npm ci + + # pwsh, not cmd: in a cmd script, `npm` (npm.cmd) without `call` transfers + # control and never returns, so everything after the first npm line was + # silently skipped and the embed assets never landed. + - name: Build config-api + run: | + cd packages/config-api + npm run build + npm run generate-types + Copy-Item dist/finickyConfigAPI.js ../../apps/finicky/src/assets/finickyConfigAPI.js -Force + shell: pwsh + + - name: Build UI + run: | + cd packages/finicky-ui + npm run build + New-Item -ItemType Directory -Force ../../apps/finicky/src/assets/templates | Out-Null + Copy-Item dist/* ../../apps/finicky/src/assets/templates/ -Recurse -Force + shell: pwsh + + - name: Build Windows binary + env: + CGO_ENABLED: '0' + GOOS: windows + GOARCH: amd64 + run: | + $commitHash = git rev-parse --short HEAD + $buildDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss UTC" -AsUTC + $version = git describe --tags --match 'v*' --always + cd apps/finicky/src + go build -ldflags "-X 'finicky/version.commitHash=$commitHash' -X 'finicky/version.buildDate=$buildDate' -X 'finicky/version.version=$version' -H windowsgui" -o ../build/windows/Finicky.exe . + shell: pwsh + + - name: Run tests + run: | + cd apps/finicky/src + go test ./... + env: + CGO_ENABLED: '0' + + - name: Upload Windows binary + uses: actions/upload-artifact@v4 + with: + name: Finicky-windows-x64 + path: apps/finicky/build/windows/Finicky.exe + retention-days: 14 + + cross-compile-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: | + packages/config-api/package-lock.json + packages/finicky-ui/package-lock.json + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.24' + cache-dependency-path: | + apps/finicky/src/go.sum + + - name: Build assets + run: | + cd packages/config-api && npm ci && npm run build && npm run generate-types + cp dist/finickyConfigAPI.js ../../apps/finicky/src/assets/finickyConfigAPI.js + cd ../finicky-ui && npm ci && npm run build + mkdir -p ../../apps/finicky/src/assets/templates + cp -r dist/* ../../apps/finicky/src/assets/templates/ + + - name: Cross-compile for Windows + env: + CGO_ENABLED: '0' + GOOS: windows + GOARCH: amd64 + run: | + cd apps/finicky/src + go build -o /dev/null . + + - name: Cross-compile for macOS (verify no regressions) + env: + CGO_ENABLED: '0' + GOOS: darwin + GOARCH: arm64 + run: | + cd apps/finicky/src + go build -o /dev/null . 2>&1 || echo "Expected: macOS build needs CGO for ObjC (this is OK)" diff --git a/.gitignore b/.gitignore index 1ff93c9a..9f3fec21 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ node_modules build .env dist -example-config \ No newline at end of file +example-config +*.exe +*.msi +Output/ \ No newline at end of file diff --git a/README.md b/README.md index 65ad1636..52522a92 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ -Finicky is a macOS application that allows you to set up rules that decide which browser is opened for every url. With Finicky as your default browser, you can tell it to open Bluesky or Reddit in one browser, and LinkedIn or Google Meet in another. +Finicky is an application for macOS and Windows that allows you to set up rules that decide which browser is opened for every url. With Finicky as your default browser, you can tell it to open Bluesky or Reddit in one browser, and LinkedIn or Google Meet in another. - Route any URL to your preferred browser with powerful matching rules - Automatically edit URLs before opening them (e.g., force HTTPS, remove tracking parameters) @@ -22,16 +22,31 @@ Finicky is a macOS application that allows you to set up rules that decide which ## Table of Contents - [Installation](#installation) + - [macOS](#macos) + - [Windows](#windows) - [Basic configuration](#basic-configuration) - [Configuration](#configuration) - [Migrating from Finicky 3](#migrating-from-finicky-3) ## Installation +### macOS + - Download from [releases](https://github.com/johnste/finicky/releases) - Or install via homebrew: `brew install --cask finicky` - Create a JavaScript or TypeScript configuration file at `~/.finicky.js`. Have a look at the example configuration below, or in the `example-config` folder. -- Start Finicky (in Applications, or through Spotlight/Alfred/Raycast) and allow it to be set as the default browser. Starting Finicky manually opens the configuration/troubleshooting window. +- Start Finicky (in Applications, or through Spotlight/Alfred/Raycast) and allow it to be set as the default browser. Starting Finicky manually opens the configuration/troubleshooting window. + +### Windows + +- Download `FinickySetup-.exe` from [releases](https://github.com/johnste/finicky/releases) and run the installer +- Or install via winget: `winget install --id JohnSterling.Finicky` +- Create a configuration file at `%USERPROFILE%\.finicky.js` (same format as macOS — configs are cross-platform) +- Open **Settings > Default Apps** and set Finicky as the default web browser +- Finicky runs in the system tray and routes URLs based on your rules + +The installer registers Finicky as a browser in Windows. No admin rights are required — it installs per-user to `%LOCALAPPDATA%\Finicky`. + ## Basic configuration @@ -95,6 +110,18 @@ Finicky has browser extensions for Chrome and Firefox. They add an "open with Fi See [Building Finicky from source](https://github.com/johnste/finicky/wiki/Building-Finicky-from-source) +**Windows (cross-compile from macOS/Linux):** +```bash +./scripts/build-windows.sh +``` + +**Windows (native):** +```powershell +$env:CGO_ENABLED="0" +New-Item -ItemType Directory -Force apps/finicky/build/windows | Out-Null +go build -C apps/finicky/src -ldflags "-H windowsgui" -o ../build/windows/Finicky.exe . +``` + ### Works well with If you are looking for something that lets you pick the browser to activate in a graphical interface, check out [Browserosaurus](https://browserosaurus.com/) by Will Stone, an open source browser prompter for macOS. It works really well together with Finicky! diff --git a/WINDOWS_PORT_QC.md b/WINDOWS_PORT_QC.md new file mode 100644 index 00000000..94bf4904 --- /dev/null +++ b/WINDOWS_PORT_QC.md @@ -0,0 +1,233 @@ +# Windows Port — QC Checklist & Findings + +This document records the quality-control methodology used before the Windows port was opened for community review. +It serves as a reusable checklist for future contributors, release engineers, and anyone who wants to extend or audit the Windows layer. + +--- + +## How to run a QC pass + +### 1. API parity check + +For every file that has both a `_darwin.go` and a `_windows.go` counterpart, verify that all **exported** symbols (functions, types, vars) defined on one platform are also present on the other with a compatible signature. Intentional differences are fine — document them. + +Files to compare: + +| darwin | windows | intentional differences | +|---|---|---| +| `main.go` | `main_windows.go` | URL receiving: Apple Events vs argv+unix socket | +| `browser.go` | `browser_windows.go` | `setDefaultBrowser` opens Settings app on Windows (OS-enforced) | +| `browser/detect.go` | `browser/detect_windows.go` | Registry walk vs `mdfind`/plist scan | +| `browser/launcher.go` | `browser/launcher_windows.go` | Profile paths via `%LOCALAPPDATA%` / `%APPDATA%` | +| `util/info.go` | `util/info_windows.go` | `GetForegroundWindowTitle` present only on Windows (future opener info) | +| `util/directories.go` | `util/directories_windows.go` | Delegates entirely to stdlib — no differences | +| `window/window.go` | `window/window_windows.go` | WKWebView → WebView2; WKURLSchemeHandler → local HTTP server | + +**Checklist:** +- [ ] Every exported function in `_darwin.go` has a matching counterpart in `_windows.go` +- [ ] Function signatures match (same argument types and return types) +- [ ] Any intentional difference is noted in the table above + +--- + +### 2. Race condition analysis + +The Windows event model is different from macOS. In particular: + +- `window_windows.go` runs WebView2 in a goroutine (`w.Run()` blocks). +- `main_windows.go` receives URLs on the `listenForURLs()` goroutine. +- `SendMessageToWebView` is called from multiple goroutines (URL handler, config watcher, update checker). + +**Shared mutable state to audit:** + +| Variable | File | Guard | Risk | +|---|---|---|---| +| `wv` (WebView2 instance) | `window_windows.go` | `queueMutex` | Read/write from multiple goroutines | +| `windowReady` | `window_windows.go` | `queueMutex` | Set in goroutine launched by `ShowWindow()` | +| `messageQueue` | `window_windows.go` | `queueMutex` | Appended by `SendMessageToWebView`, drained by `NavigationComplete` | +| `vm` (JS VM) | `main_windows.go` | None — single goroutine (event loop owns it) | OK: all writes and reads are serialized through the `select` loop | +| `shouldKeepRunning` | `main_windows.go` | None — event loop only | OK: same reason | +| `pipeAddress` | `main_windows.go` | `pipeOnce` (sync.Once) | Safe | + +**Checklist:** +- [ ] Every write to `wv` is under `queueMutex` +- [ ] Every write to `windowReady` is under `queueMutex` +- [ ] `sendMessageToWebViewInternal` is only called with `queueMutex` held (check all call sites) +- [ ] When `w.Run()` returns (window closed), `wv = nil` and `windowReady = false` are set inside the mutex before the close is signalled +- [ ] No goroutine calls `w.Eval()` / `w.Navigate()` directly — all WebView2 calls go through `Dispatch()` + +--- + +### 2b. Thread-affinity audit (GUI thread ownership) + +**This is the most important Windows-specific check and the easiest to get wrong.** Data-race analysis (section 2) asks "is this variable safe to touch from two goroutines?" Thread-affinity asks a different question: "is this call happening on the *one specific OS thread* that is allowed to make it?" Win32 GUI objects are thread-affine — window messages are delivered only to the thread that created the `HWND`, and the message loop must run on that same thread. A WebView2 window created on one thread and pumped on another is silently broken: it appears but never paints or accepts input, and WebView2's controller init may crash. + +Go makes this trap easy to fall into, because goroutines migrate between OS threads freely unless pinned with `runtime.LockOSThread()`. The rule the port must obey (mirroring how the macOS build runs all of Cocoa on the main thread): + +> The WebView2 window is **created, message-pumped, and torn down on a single OS thread**, and that thread is pinned with `runtime.LockOSThread()`. All work from other goroutines reaches it via `webview.Dispatch()`, never directly. + +**Checklist:** +- [ ] `main()` calls `runtime.LockOSThread()` and then keeps the main goroutine as the UI owner (it runs the window loop, not the event loop) +- [ ] The event loop runs on a **background goroutine**; the **main thread** runs `runUILoop` → `window.RunWindow()` +- [ ] `window.RunWindow()` both **creates** the webview and calls `w.Run()` — on the same call stack, same thread. Creation and the message loop are never split across goroutines +- [ ] Show-window requests cross from the event-loop goroutine to the main thread via a channel (`showWindowChan`), not by calling `RunWindow` directly off-thread +- [ ] Every `SendMessageToWebView` from a non-UI goroutine ends in `wv.Dispatch(...)`, which marshals onto the pumping thread +- [ ] Re-opening a closed window creates a fresh webview on the same locked thread (no stale `HWND` reuse) + +**How to catch a regression:** on a real Windows machine, trigger the config window (`Finicky.exe --window`). If it opens but is blank/frozen/unclickable, thread-affinity is broken even though `go build` and `go test` pass — this class of bug is invisible to the compiler and to macOS-hosted tests. + +--- + +### 3. Lifecycle signal audit + +The macOS port relies on OS-provided lifecycle callbacks (Apple Events, `WKNavigationDelegate`). The Windows port must manually wire equivalent signals. + +| Signal | macOS mechanism | Windows mechanism | Status | +|---|---|---|---| +| URL received from OS | Apple Event `kAEGetURL` | `argv[1]` on process launch → unix socket IPC | ✅ Implemented | +| Window closed | Cocoa delegate callback | `w.Run()` returns on main thread → `windowClosed` channel | ✅ Implemented | +| Page load complete | `WKNavigationDelegate.didFinish` | `w.Init(...)` injects `window.addEventListener("load", () => __finicky_ready())` | ✅ Implemented | +| Message queue drain | Triggered by page load | `NavigationComplete()` called via `__finicky_ready` binding | ✅ Implemented | +| Single instance | NSRunningApplication check | Named mutex `Global\FinickyBrowserRouter` | ✅ Implemented | + +**Checklist:** +- [ ] `__finicky_ready` binding is registered via `w.Bind()` before `w.Navigate()` +- [ ] `w.Init(...)` script fires `__finicky_ready()` on `window` load event (not DOMContentLoaded — assets must be ready) +- [ ] `NavigationComplete()` drains `messageQueue` and sets `windowReady = true` inside `queueMutex` +- [ ] The UI loop signals `windowClosed` after `RunWindow()` returns, and the event loop resets `showingWindow` so the window can be reopened +- [ ] `sendToPrimary` retries the socket dial (covering the mutex-created-before-socket startup race) and, on ultimate failure, only falls back to `cmd /c start` when Finicky is **not** the default browser (else it would relaunch itself in a loop) + +--- + +### 4. Dead code and import audit + +After any cross-platform file is created or edited, run: + +```bash +cd apps/finicky/src +GOOS=windows CGO_ENABLED=0 go build ./... 2>&1 | grep -E "imported and not used|declared and not used" +GOOS=darwin CGO_ENABLED=1 go build ./... 2>&1 | grep -E "imported and not used|declared and not used" +``` + +**Checklist:** +- [ ] No unused imports on either platform +- [ ] No unused constants (e.g. named pipe path constants left over from earlier design iterations) +- [ ] No `var _ = ...` import silencers (these mask real unused-import issues) + +--- + +### 5. Error handling — critical paths + +Silent failures in a URL router are user-visible: the user clicks a link, nothing happens. + +**Critical paths to audit:** + +| Path | What can go wrong | Mitigation | +|---|---|---| +| `forwardURLToPipe` | Pipe not yet up (race at startup); stale socket | Falls back to `cmd /c start` — URL opens in system default | +| `listenForURLs` | Stale `.sock` file from crash | Removes and retries | +| `findBrowserExe` | Browser installed in non-standard path | Falls back to `cmd /c start ""` which lets Windows resolve the browser | +| `startAssetServer` | Port binding failure | Falls back to `w.SetHtml()` with embedded HTML | +| `LaunchBrowser` | Profile not found | Logs warning, proceeds without profile flag (browser opens default profile) | +| `GetInstalledBrowsers` | Registry key missing | Returns empty slice (UI shows "no browsers found") | + +**Checklist:** +- [ ] `forwardURLToPipe` has `cmd /c start` fallback and `HideWindow: true` on SysProcAttr +- [ ] `listenForURLs` removes stale socket before retrying bind +- [ ] `startAssetServer` falls back to `SetHtml` on listener error +- [ ] `LaunchBrowser` uses `cmd /c start ""` fallback when `findBrowserExe` returns empty +- [ ] Profile resolution failures log a warning and continue (not fatal) + +--- + +### 6. Cross-compile verification + +Run this before every release candidate: + +```bash +cd apps/finicky/src + +# Windows cross-compile (the primary deliverable) +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ + go build -v \ + -ldflags="-H windowsgui -X finicky/version.GitCommit=$(git rev-parse --short HEAD) -X finicky/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o /tmp/Finicky.exe \ + . + +# macOS native build (regression check — must not break) +CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \ + go build -v \ + -o /tmp/Finicky-mac \ + . + +# Test suite (both platforms share the same test files) +go test ./... +``` + +**Expected results:** +- Windows binary: PE32+ executable (GUI), ~28-35 MB depending on assets +- macOS binary: Mach-O 64-bit, arm64 +- Tests: 4 suites pass (resolver, rules, util, version) + +--- + +## Findings from initial QC pass (2026-06-30) + +Six bugs were found and fixed before community release. + +| # | Severity | File | Bug | Fix | +|---|---|---|---|---| +| 1 | **Critical** | `window_windows.go` | `NavigationComplete()` was defined but never called — the Svelte UI would receive a blank screen because the message queue never drained | Added `w.Init(...)` script that fires `window.__finicky_ready()` on page load; bound via `w.Bind("__finicky_ready", NavigationComplete)` | +| 2 | **Critical** | `window_windows.go` | `wv` read/written from multiple goroutines without synchronization — race condition that could panic or corrupt state | All `wv` accesses now under `queueMutex`; `sendMessageToWebViewInternal` documented as requiring lock held | +| 3 | **High** | `window_windows.go` | `wv = nil` and `windowReady = false` set outside `queueMutex` in `Run()` callback goroutine | Wrapped in `queueMutex.Lock()/Unlock()` | +| 4 | **Medium** | `main_windows.go` | `forwardURLToPipe` silently dropped URLs when the unix socket dial failed (e.g. main instance still starting) — user click lost | Added `exec.Command("cmd", "/c", "start", "", url)` fallback with `HideWindow: true` | +| 5 | **Low** | `main_windows.go` | Unused constant `pipeName = "\\\\.\pipe\finicky-url"` — leftover from Named Pipe design iteration; code uses unix sockets | Removed | +| 6 | **Low** | `browser/launcher_windows.go`, `window_windows.go` | Unused import silencers (`var _ = util.ShortenPath`, `var _ = filepath.Base`) | Removed; cleaned imports | + +## Findings from second QC pass (2026-07-01) + +A deeper architectural review — focused on threading, Win32 API contracts, and cross-referencing the installer against the runtime code — found five more issues. These are the kind that pass every compiler and macOS-hosted test but fail on real Windows, so they were the highest-value catches before community testing. + +| # | Severity | File | Bug | Fix | +|---|---|---|---|---| +| 7 | **Critical** | `main_windows.go`, `window_windows.go` | **Broken GUI thread affinity.** The webview was *created* on a `go ShowConfigWindow()` goroutine and *message-pumped* on a separate `go func(){ w.Run() }()` goroutine — neither being the `LockOSThread`-pinned main thread. Win32 delivers window messages only to the creating thread, so the config window would open blank/frozen and WebView2 init could crash. | Restructured so the **main (locked) thread owns the window**: event loop moved to a background goroutine; `runUILoop` on the main thread runs `window.RunWindow()`, which creates *and* pumps the webview on one thread. Cross-thread show requests go through `showWindowChan`. Mirrors the macOS "all UI on main thread" model. | +| 8 | **High** | `main_windows.go` | **Racy single-instance detection.** `GetLastError` was read via a *separate* `LazyProc.Call`, which can be clobbered by the Go runtime's own syscalls between it and `CreateMutexW` — so `ERROR_ALREADY_EXISTS` could be missed and two instances both act as primary. | Use the `err` value returned directly by `procCreateMutexW.Call(...)` (captured by the runtime immediately after the syscall). Removed the separate `getLastError` proc. | +| 9 | **High** | `browser_windows.go` | **`isDefaultBrowser()` always returned false.** It compared the UserChoice ProgID to the literal `"Finicky"`, but the installer registers the http/https association as ProgID `"FinickyURL"`. The two never matched. | Compare against a shared `progID = "FinickyURL"` constant that matches `installer.iss`. | +| 10 | **High** | `browser_windows.go`, `main_windows.go` | **Settings popped open on every link click.** `setDefaultBrowser()` responded to "not default" by launching `ms-settings:defaultapps`, and it ran at every startup — so (amplified by #9) the Default Apps page opened on every URL invocation. | Startup now only *observes* default status (`logDefaultBrowserStatus`) and never prompts, matching the fact that Windows 10+ requires the user to set defaults manually (the installer already offers this opt-in). Removed the unusable programmatic-set path. | +| 11 | **Medium** | `main_windows.go` | **URL loss / relaunch-loop risk in IPC handoff.** `forwardURLToPipe` dialed the primary's socket exactly once; during the primary's startup the mutex exists before the socket is listening, so the URL was dropped. Its `cmd /c start` fallback could also re-invoke Finicky if Finicky was the default browser, risking a launch loop. | `sendToPrimary` retries the dial for ~1s (covers the startup race); the `start` fallback runs **only when Finicky is not the default browser**, otherwise the URL is dropped with a clear log instead of looping. Also added a `--window` handoff so a second launch focuses the existing window (Start Menu parity). | + +**Also fixed:** `installer.iss` autostart entry launched `Finicky.exe --window`, which would pop the config window on every login. Changed to launch with no arguments (resident router only). And the asset HTTP server is now closed when the window closes (was leaked on every open). + +--- + +## Known limitations (not bugs) + +These are documented design decisions, not defects: + +- **Finicky cannot make itself the default browser programmatically on Windows 10+.** Microsoft removed programmatic default-browser setting after Windows 8. So, unlike the macOS build (which sets the default via `LSSetDefaultHandlerForURLScheme` behind an OS consent dialog), the Windows build only *detects* the current default and logs it — it never prompts on launch. Setting Finicky as default is a user action, offered by the installer's opt-in "Open Default Apps settings" task. A future in-app "Set as default" button could call the same `ms-settings:defaultapps` deep link. + +- **Opener info is not available on Windows.** On macOS, `OpenerInfo` captures the frontmost app when a URL is clicked. On Windows, `GetForegroundWindowTitle` is implemented but the Windows process launch model (new process per URL) makes reliable opener detection impractical — by the time Finicky launches, the original foreground window may have changed. `OpenerInfo` is passed as an empty struct. + +- **Browser detection is hardcoded + registry-based.** `findBrowserExe` uses a hardcoded path table for the 10 most common browsers. `detect_windows.go` supplements this via the `StartMenuInternet` registry key. Exotic browsers in non-standard install locations will fall through to the `cmd /c start` fallback (which uses Windows' own default browser association). + +- **WebView2 runtime must be present.** `go-webview2` embeds `WebView2Loader.dll` via `go-winloader` but still requires the WebView2 runtime to be installed. It ships with Windows 11 and is available via Windows Update on Windows 10. The installer (`scripts/installer.iss`) does not yet bundle or auto-install the runtime — this is a known gap for fresh Windows 10 installs. + +--- + +## Pre-release checklist (summary) + +``` +[ ] go build (windows) — clean +[ ] go build (darwin) — clean, no regressions +[ ] go test ./... — all suites pass +[ ] API parity check — all exports present on both platforms +[ ] Race audit — queueMutex guards all wv/windowReady/messageQueue writes +[ ] Thread-affinity — webview created + pumped on the one LockOSThread'd main thread +[ ] Lifecycle audit — __finicky_ready bound, NavigationComplete drains queue +[ ] Window smoke test — on real Windows, `--window` opens a live, clickable config UI +[ ] Dead code audit — no unused imports, constants, or silencers +[ ] Critical paths — all error paths have fallback or warning log +[ ] installer.iss — InstallerSha256 updated for this build +[ ] winget manifest — version and SHA updated +[ ] LAUNCH_MATERIALS — demo GIF recorded, posting schedule set +``` diff --git a/apps/finicky/assets/Resources/finicky.ico b/apps/finicky/assets/Resources/finicky.ico new file mode 100644 index 00000000..7c28a945 Binary files /dev/null and b/apps/finicky/assets/Resources/finicky.ico differ diff --git a/apps/finicky/src/browser.go b/apps/finicky/src/browser.go index 324f514c..871fd690 100644 --- a/apps/finicky/src/browser.go +++ b/apps/finicky/src/browser.go @@ -1,3 +1,5 @@ +//go:build darwin + package main /* diff --git a/apps/finicky/src/browser/browsers.json b/apps/finicky/src/browser/browsers.json index cb4e804c..9fe6161c 100644 --- a/apps/finicky/src/browser/browsers.json +++ b/apps/finicky/src/browser/browsers.json @@ -1,102 +1,119 @@ [ { "config_dir_relative": "BraveSoftware/Brave-Browser", + "config_dir_windows": "BraveSoftware\\Brave-Browser\\User Data", "id": "com.brave.Browser", "type": "Chromium", "app_name": "Brave Browser" }, { "config_dir_relative": "Google/Chrome", + "config_dir_windows": "Google\\Chrome\\User Data", "id": "com.google.Chrome", "type": "Chromium", "app_name": "Google Chrome" }, { "config_dir_relative": "Google/Chrome Beta", + "config_dir_windows": "Google\\Chrome Beta\\User Data", "id": "com.google.Chrome.beta", "type": "Chromium", "app_name": "Google Chrome Beta" }, { "config_dir_relative": "Google/Chrome Canary", + "config_dir_windows": "Google\\Chrome SxS\\User Data", "id": "com.google.Chrome.canary", "type": "Chromium", "app_name": "Google Chrome Canary" }, { "config_dir_relative": "Chromium", + "config_dir_windows": "Chromium\\User Data", "id": "org.chromium.Chromium", "type": "Chromium", "app_name": "Chromium" }, { "config_dir_relative": "Microsoft Edge", + "config_dir_windows": "Microsoft\\Edge\\User Data", "id": "com.microsoft.edgemac", "type": "Chromium", "app_name": "Microsoft Edge" }, { "config_dir_relative": "Vivaldi", + "config_dir_windows": "Vivaldi\\User Data", "id": "com.vivaldi.Vivaldi", "type": "Chromium", "app_name": "Vivaldi" }, { "config_dir_relative": "WaveboxApp", + "config_dir_windows": "WaveboxApp\\User Data", "id": "com.bookry.wavebox", "type": "Chromium", "app_name": "Wavebox" }, { "config_dir_relative": "net.imput.helium", + "config_dir_windows": "", "id": "net.imput.helium", "type": "Chromium", "app_name": "Helium" }, { "config_dir_relative": "Comet", + "config_dir_windows": "", "id": "ai.perplexity.comet", "type": "Chromium", "app_name": "Comet" }, { "config_dir_relative": "Yandex/YandexBrowser", + "config_dir_windows": "Yandex\\YandexBrowser\\User Data", "id": "ru.yandex.desktop.yandex-browser", "type": "Chromium", "app_name": "Yandex" }, { "config_dir_relative": "com.operasoftware.Opera", + "config_dir_windows": "Opera Software\\Opera Stable", "id": "com.operasoftware.Opera", "type": "Chromium", "app_name": "Opera" }, { "config_dir_relative": "com.operasoftware.OperaGX", + "config_dir_windows": "Opera Software\\Opera GX Stable", "id": "com.operasoftware.OperaGX", "type": "Chromium", "app_name": "Opera GX" }, { "config_dir_relative": "", + "config_dir_windows": "", "id": "com.apple.Safari", "type": "Safari", "app_name": "Safari" }, { "config_dir_relative": "Firefox", + "config_dir_windows": "Mozilla\\Firefox", "id": "org.mozilla.firefox", "type": "Firefox", "app_name": "Firefox" }, { "config_dir_relative": "Firefox", + "config_dir_windows": "Mozilla\\Firefox", "id": "org.mozilla.firefoxdeveloperedition", "type": "Firefox", "app_name": "Firefox Developer Edition" }, { "config_dir_relative": "zen", + "config_dir_windows": "zen", "id": "app.zen-browser.zen", "type": "Firefox", "app_name": "Zen" diff --git a/apps/finicky/src/browser/default_browser_darwin.go b/apps/finicky/src/browser/default_browser_darwin.go new file mode 100644 index 00000000..930845e0 --- /dev/null +++ b/apps/finicky/src/browser/default_browser_darwin.go @@ -0,0 +1,11 @@ +//go:build darwin + +package browser + +// DefaultBrowserName / DefaultBrowserAppType is the out-of-the-box fallback +// browser used when the user hasn't configured one. Safari is always present +// on macOS. +const ( + DefaultBrowserName = "com.apple.Safari" + DefaultBrowserAppType = "bundleId" +) diff --git a/apps/finicky/src/browser/default_browser_windows.go b/apps/finicky/src/browser/default_browser_windows.go new file mode 100644 index 00000000..caaceade --- /dev/null +++ b/apps/finicky/src/browser/default_browser_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package browser + +// DefaultBrowserName / DefaultBrowserAppType is the out-of-the-box fallback +// browser used when the user hasn't configured one. Microsoft Edge ships on +// every Windows 10/11 machine, so it's the safe default (not Safari, which +// doesn't exist on Windows). +const ( + DefaultBrowserName = "Microsoft Edge" + DefaultBrowserAppType = "appName" +) diff --git a/apps/finicky/src/browser/detect.go b/apps/finicky/src/browser/detect.go index a6d3349d..d639be0d 100644 --- a/apps/finicky/src/browser/detect.go +++ b/apps/finicky/src/browser/detect.go @@ -1,3 +1,5 @@ +//go:build darwin + package browser /* diff --git a/apps/finicky/src/browser/detect_windows.go b/apps/finicky/src/browser/detect_windows.go new file mode 100644 index 00000000..7ec7e4d3 --- /dev/null +++ b/apps/finicky/src/browser/detect_windows.go @@ -0,0 +1,131 @@ +//go:build windows + +package browser + +import ( + "log/slog" + "sort" + "strings" + "syscall" + "unsafe" +) + +var ( + advapi32 = syscall.NewLazyDLL("advapi32.dll") + procRegOpenKeyEx = advapi32.NewProc("RegOpenKeyExW") + procRegEnumKeyEx = advapi32.NewProc("RegEnumKeyExW") + procRegQueryValue = advapi32.NewProc("RegQueryValueExW") + procRegCloseKey = advapi32.NewProc("RegCloseKey") +) + +const ( + hkeyCurrentUser = 0x80000001 + hkeyLocalMachine = 0x80000002 + keyRead = 0x20019 +) + +func GetInstalledBrowsers() []string { + browsers := getBrowsersFromRegistry() + sort.Strings(browsers) + return browsers +} + +func getBrowsersFromRegistry() []string { + const path = `SOFTWARE\Clients\StartMenuInternet` + + // Browsers register their Start Menu entry under HKCU for per-user + // (non-elevated) installs — which is what the default Chrome/Edge/Firefox + // installers do — and under HKLM for system-wide installs. Scan both roots + // and dedupe so per-user installs aren't missed. + seen := map[string]bool{} + var names []string + for _, r := range []struct { + label string + root uintptr + }{{"HKCU", hkeyCurrentUser}, {"HKLM", hkeyLocalMachine}} { + found := enumStartMenuBrowsers(r.root, path) + slog.Debug("Browser registry scan", "root", r.label, "found", len(found)) + for _, name := range found { + key := strings.ToLower(name) + if !seen[key] { + seen[key] = true + names = append(names, name) + } + } + } + slog.Debug("Installed browsers detected", "count", len(names), "browsers", names) + return names +} + +func enumStartMenuBrowsers(root uintptr, path string) []string { + pathPtr, _ := syscall.UTF16PtrFromString(path) + + var key syscall.Handle + ret, _, _ := procRegOpenKeyEx.Call( + root, + uintptr(unsafe.Pointer(pathPtr)), + 0, + keyRead, + uintptr(unsafe.Pointer(&key)), + ) + if ret != 0 { + // Missing key just means no browsers registered under this root. + return nil + } + defer procRegCloseKey.Call(uintptr(key)) + + var names []string + for i := uint32(0); ; i++ { + buf := make([]uint16, 256) + size := uint32(len(buf)) + ret, _, _ := procRegEnumKeyEx.Call( + uintptr(key), + uintptr(i), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&size)), + 0, 0, 0, 0, + ) + if ret != 0 { + break + } + name := syscall.UTF16ToString(buf[:size]) + displayName := getBrowserDisplayName(key, name) + if displayName != "" && !strings.EqualFold(displayName, "Finicky") { + names = append(names, displayName) + } + } + return names +} + +func getBrowserDisplayName(parentKey syscall.Handle, subkeyName string) string { + subPath, _ := syscall.UTF16PtrFromString(subkeyName) + + var subKey syscall.Handle + ret, _, _ := procRegOpenKeyEx.Call( + uintptr(parentKey), + uintptr(unsafe.Pointer(subPath)), + 0, + keyRead, + uintptr(unsafe.Pointer(&subKey)), + ) + if ret != 0 { + return subkeyName + } + defer procRegCloseKey.Call(uintptr(subKey)) + + buf := make([]uint16, 256) + size := uint32(len(buf) * 2) + emptyStr, _ := syscall.UTF16PtrFromString("") + ret, _, _ = procRegQueryValue.Call( + uintptr(subKey), + uintptr(unsafe.Pointer(emptyStr)), + 0, + 0, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&size)), + ) + if ret == 0 && size > 2 { + return syscall.UTF16ToString(buf) + } + return subkeyName +} diff --git a/apps/finicky/src/browser/launcher.go b/apps/finicky/src/browser/launcher.go index a3a71200..d8fe5b19 100644 --- a/apps/finicky/src/browser/launcher.go +++ b/apps/finicky/src/browser/launcher.go @@ -1,3 +1,5 @@ +//go:build darwin + package browser import ( diff --git a/apps/finicky/src/browser/launcher_windows.go b/apps/finicky/src/browser/launcher_windows.go new file mode 100644 index 00000000..ed4a6034 --- /dev/null +++ b/apps/finicky/src/browser/launcher_windows.go @@ -0,0 +1,379 @@ +//go:build windows + +package browser + +import ( + _ "embed" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + "finicky/util" + + "al.essio.dev/pkg/shellescape" +) + +//go:embed browsers.json +var browsersJsonData []byte + +type BrowserResult struct { + Browser BrowserConfig `json:"browser"` + Error string `json:"error"` +} + +type BrowserConfig struct { + Name string `json:"name"` + AppType string `json:"appType"` + OpenInBackground *bool `json:"openInBackground"` + Profile string `json:"profile"` + Args []string `json:"args"` + URL string `json:"url"` +} + +type browserInfo struct { + ConfigDirRelative string `json:"config_dir_relative"` + ConfigDirWindows string `json:"config_dir_windows"` + ID string `json:"id"` + AppName string `json:"app_name"` + Type string `json:"type"` +} + +func (b browserInfo) windowsConfigDir() string { + if b.ConfigDirWindows != "" { + return b.ConfigDirWindows + } + return b.ConfigDirRelative +} + +func LaunchBrowser(config BrowserConfig, dryRun bool, openInBackgroundByDefault bool) error { + if config.AppType == "none" { + slog.Info("AppType is 'none', not launching any browser") + return nil + } + + slog.Info("Starting browser", "name", config.Name, "url", config.URL) + + profileArgs, hasProfile := resolveBrowserProfileArgs(config.Name, config.Profile) + hasCustomArgs := len(config.Args) > 0 + + var cmdArgs []string + + if hasProfile || hasCustomArgs { + if hasProfile { + cmdArgs = append(cmdArgs, profileArgs...) + } + if hasCustomArgs { + cmdArgs = append(cmdArgs, config.Args...) + } + if !slices.Contains(config.Args, config.URL) { + cmdArgs = append(cmdArgs, config.URL) + } + } else { + cmdArgs = append(cmdArgs, config.URL) + } + + // Find the browser executable + exePath := findBrowserExe(config.Name) + if exePath == "" { + // Fallback: no known exe for this browser name — open the URL with the + // system default handler. The URL is passed to ShellExecuteW as a single + // argument (never through cmd.exe), so metacharacters in it can't be + // re-tokenized into commands. Profile/custom args can't be honored here + // because the target executable is unknown. + if dryRun { + slog.Debug("Would open URL via default handler (dry run)", "url", config.URL) + return nil + } + slog.Debug("Opening URL via default handler (no known exe)", "browser", config.Name, "url", config.URL) + return util.OpenURLDefault(config.URL) + } + + cmd := exec.Command(exePath, cmdArgs...) + prettyCmd := formatCommand(cmd.Path, cmd.Args) + + if dryRun { + slog.Debug("Would run command (dry run)", "command", prettyCmd) + return nil + } + + slog.Debug("Run command", "command", prettyCmd) + + // CombinedOutput drains stdout and stderr concurrently, avoiding the + // deadlock that draining two pipes sequentially can cause when the child + // fills one pipe's buffer while we're blocked reading the other. + out, cmdErr := cmd.CombinedOutput() + if len(out) > 0 { + slog.Debug("Command output", "output", string(out)) + } + if cmdErr != nil { + return fmt.Errorf("command failed: %v", cmdErr) + } + return nil +} + +func findBrowserExe(name string) string { + commonPaths := []string{ + os.Getenv("PROGRAMFILES"), + os.Getenv("PROGRAMFILES(X86)"), + os.Getenv("LOCALAPPDATA"), + } + + knownExes := map[string][]string{ + "Google Chrome": {"Google\\Chrome\\Application\\chrome.exe"}, + "Chrome": {"Google\\Chrome\\Application\\chrome.exe"}, + "Microsoft Edge": {"Microsoft\\Edge\\Application\\msedge.exe"}, + "Edge": {"Microsoft\\Edge\\Application\\msedge.exe"}, + "Firefox": {"Mozilla Firefox\\firefox.exe"}, + "Mozilla Firefox": {"Mozilla Firefox\\firefox.exe"}, + "Brave Browser": {"BraveSoftware\\Brave-Browser\\Application\\brave.exe"}, + "Brave": {"BraveSoftware\\Brave-Browser\\Application\\brave.exe"}, + "Vivaldi": {"Vivaldi\\Application\\vivaldi.exe"}, + "Opera": {"Opera\\opera.exe"}, + "Arc": {"Arc\\Arc.exe"}, + } + + if paths, ok := knownExes[name]; ok { + for _, base := range commonPaths { + if base == "" { + continue + } + for _, rel := range paths { + full := filepath.Join(base, rel) + if _, err := os.Stat(full); err == nil { + return full + } + } + } + } + + return "" +} + +func resolveBrowserProfileArgs(identifier string, profile string) ([]string, bool) { + var browsersJson []browserInfo + if err := json.Unmarshal(browsersJsonData, &browsersJson); err != nil { + slog.Info("Error parsing browsers.json", "error", err) + return nil, false + } + + var matchedBrowser *browserInfo + for _, browser := range browsersJson { + if browser.ID == identifier || browser.AppName == identifier { + matchedBrowser = &browser + break + } + } + + if matchedBrowser == nil { + return nil, false + } + + slog.Debug("Browser found in browsers.json", "identifier", identifier, "type", matchedBrowser.Type) + + if profile != "" { + switch matchedBrowser.Type { + case "Chromium": + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + slog.Info("LOCALAPPDATA not set") + return nil, false + } + localStatePath := filepath.Join(localAppData, matchedBrowser.windowsConfigDir(), "Local State") + profilePath, ok := parseProfiles(localStatePath, profile) + if ok { + return []string{"--profile-directory=" + profilePath}, true + } + case "Firefox": + appData := os.Getenv("APPDATA") + if appData == "" { + slog.Info("APPDATA not set") + return nil, false + } + profilesIniPath := filepath.Join(appData, matchedBrowser.windowsConfigDir(), "profiles.ini") + profileName, ok := parseFirefoxProfiles(profilesIniPath, profile) + if ok { + return []string{"-P", profileName}, true + } + default: + slog.Info("Browser is not a supported browser type", "identifier", identifier) + } + } + + return nil, false +} + +func GetProfilesForBrowser(identifier string) []string { + var browsersJson []browserInfo + if err := json.Unmarshal(browsersJsonData, &browsersJson); err != nil { + slog.Info("Error parsing browsers.json", "error", err) + return []string{} + } + + var matchedBrowser *browserInfo + for i := range browsersJson { + if browsersJson[i].ID == identifier || browsersJson[i].AppName == identifier { + matchedBrowser = &browsersJson[i] + break + } + } + + if matchedBrowser == nil { + return []string{} + } + + switch matchedBrowser.Type { + case "Chromium": + localAppData := os.Getenv("LOCALAPPDATA") + if localAppData == "" { + return []string{} + } + localStatePath := filepath.Join(localAppData, matchedBrowser.windowsConfigDir(), "Local State") + return getAllChromiumProfiles(localStatePath) + case "Firefox": + appData := os.Getenv("APPDATA") + if appData == "" { + return []string{} + } + profilesIniPath := filepath.Join(appData, matchedBrowser.windowsConfigDir(), "profiles.ini") + return readFirefoxProfileNames(profilesIniPath) + default: + return []string{} + } +} + +// --- Shared profile parsing (duplicated from launcher.go, can be extracted later) --- + +func readFirefoxProfileNames(profilesIniPath string) []string { + data, err := os.ReadFile(profilesIniPath) + if err != nil { + slog.Info("Error reading profiles.ini", "path", profilesIniPath, "error", err) + return []string{} + } + names := []string{} + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if name, ok := strings.CutPrefix(line, "Name="); ok { + names = append(names, name) + } + } + return names +} + +func parseFirefoxProfiles(profilesIniPath string, profile string) (string, bool) { + names := readFirefoxProfileNames(profilesIniPath) + for _, name := range names { + if name == profile { + return name, true + } + } + slog.Warn("Could not find profile in Firefox profiles.", "Expected profile", profile, "Available profiles", strings.Join(names, ", ")) + return "", false +} + +func chromiumInfoCache(localStatePath string) (map[string]interface{}, bool) { + data, err := os.ReadFile(localStatePath) + if err != nil { + slog.Info("Error reading Local State file", "path", localStatePath, "error", err) + return nil, false + } + var localState map[string]interface{} + if err := json.Unmarshal(data, &localState); err != nil { + slog.Info("Error parsing Local State JSON", "error", err) + return nil, false + } + profiles, ok := localState["profile"].(map[string]interface{}) + if !ok { + slog.Info("Could not find profile section in Local State") + return nil, false + } + infoCache, ok := profiles["info_cache"].(map[string]interface{}) + if !ok { + slog.Info("Could not find info_cache in profile section") + return nil, false + } + return infoCache, true +} + +func getAllChromiumProfiles(localStatePath string) []string { + cache, ok := chromiumInfoCache(localStatePath) + if !ok { + return []string{} + } + var names []string + for _, info := range cache { + profileInfo, ok := info.(map[string]interface{}) + if !ok { + continue + } + name, ok := profileInfo["name"].(string) + if !ok { + continue + } + names = append(names, name) + } + slices.Sort(names) + return names +} + +func parseProfiles(localStatePath string, profile string) (string, bool) { + infoCache, ok := chromiumInfoCache(localStatePath) + if !ok { + return "", false + } + for profilePath, info := range infoCache { + profileInfo, ok := info.(map[string]interface{}) + if !ok { + continue + } + name, ok := profileInfo["name"].(string) + if !ok { + continue + } + if name == profile { + slog.Info("Found profile by name", "name", name, "path", profilePath) + return profilePath, true + } + } + slog.Debug("Could not find profile in browser profiles, trying to find by profile path", "profile", profile) + for profilePath, info := range infoCache { + if profilePath == profile { + if profileInfo, ok := info.(map[string]interface{}); ok { + if name, ok := profileInfo["name"].(string); ok { + slog.Warn("Found profile using profile path", "path", profilePath, "name", name, "suggestion", "Please use the profile name instead") + } + } + return profilePath, true + } + } + var profileNames []string + for _, info := range infoCache { + profileInfo, ok := info.(map[string]interface{}) + if !ok { + continue + } + name, ok := profileInfo["name"].(string) + if !ok { + continue + } + profileNames = append(profileNames, name) + } + slog.Warn("Could not find profile in browser profiles.", "Expected profile", profile, "Available profiles", strings.Join(profileNames, ", ")) + return "", false +} + +func formatCommand(path string, args []string) string { + if len(args) == 0 { + return shellescape.Quote(path) + } + quotedArgs := make([]string, len(args)) + for i, arg := range args { + quotedArgs[i] = shellescape.Quote(arg) + } + return strings.Join(quotedArgs, " ") +} + diff --git a/apps/finicky/src/browser_windows.go b/apps/finicky/src/browser_windows.go new file mode 100644 index 00000000..fbb49c15 --- /dev/null +++ b/apps/finicky/src/browser_windows.go @@ -0,0 +1,82 @@ +//go:build windows + +package main + +import ( + "fmt" + "syscall" + "unsafe" +) + +// progID is the ProgID the installer registers for Finicky's http/https URL +// associations (see scripts/installer.iss). Windows records the user's default +// browser choice as this ProgID under UserChoice, so isDefaultBrowser must +// compare against it — not the application's display name. +const progID = "FinickyURL" + +var ( + advapi32Browser = syscall.NewLazyDLL("advapi32.dll") + procRegOpenKeyExB = advapi32Browser.NewProc("RegOpenKeyExW") + procRegQueryValueB = advapi32Browser.NewProc("RegQueryValueExW") + procRegCloseKeyB = advapi32Browser.NewProc("RegCloseKey") +) + +const ( + hkeyCurrentUser = 0x80000001 + keyReadB = 0x20019 +) + +// isDefaultBrowser reports whether Finicky is the user's default browser by +// reading the http/https UserChoice ProgIDs. Windows 10+ does not allow apps to +// set this programmatically; the user selects Finicky in Settings (the +// installer can open that page), so there is no setDefaultBrowser counterpart. +func isDefaultBrowser() (bool, error) { + httpHandler, _ := getDefaultHandlerForURLScheme("http") + httpsHandler, _ := getDefaultHandlerForURLScheme("https") + return httpHandler == progID && httpsHandler == progID, nil +} + +// getDefaultHandlerForURLScheme reads the effective ProgId for a scheme. +// Newer Windows 11 builds (observed on 26200) record the user's choice under +// UserChoiceLatest\ProgId and leave the legacy UserChoice key stale — reading +// only UserChoice reports the wrong (old) handler forever. Prefer the new +// location, fall back to the legacy one for older builds. +func getDefaultHandlerForURLScheme(scheme string) (string, error) { + if progId, err := readUserChoiceProgId(fmt.Sprintf(`SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\%s\UserChoiceLatest\ProgId`, scheme)); err == nil { + return progId, nil + } + return readUserChoiceProgId(fmt.Sprintf(`SOFTWARE\Microsoft\Windows\Shell\Associations\UrlAssociations\%s\UserChoice`, scheme)) +} + +func readUserChoiceProgId(path string) (string, error) { + pathPtr, _ := syscall.UTF16PtrFromString(path) + + var key syscall.Handle + ret, _, _ := procRegOpenKeyExB.Call( + hkeyCurrentUser, + uintptr(unsafe.Pointer(pathPtr)), + 0, + keyReadB, + uintptr(unsafe.Pointer(&key)), + ) + if ret != 0 { + return "", fmt.Errorf("no key at '%s'", path) + } + defer procRegCloseKeyB.Call(uintptr(key)) + + valueName, _ := syscall.UTF16PtrFromString("ProgId") + buf := make([]uint16, 256) + size := uint32(len(buf) * 2) + ret, _, _ = procRegQueryValueB.Call( + uintptr(key), + uintptr(unsafe.Pointer(valueName)), + 0, 0, + uintptr(unsafe.Pointer(&buf[0])), + uintptr(unsafe.Pointer(&size)), + ) + if ret != 0 { + return "", fmt.Errorf("no ProgId value at '%s'", path) + } + + return syscall.UTF16ToString(buf), nil +} diff --git a/apps/finicky/src/go.mod b/apps/finicky/src/go.mod index 26045d82..cec3150f 100644 --- a/apps/finicky/src/go.mod +++ b/apps/finicky/src/go.mod @@ -5,9 +5,11 @@ go 1.24.0 require ( al.essio.dev/pkg/shellescape v1.6.0 github.com/Masterminds/semver v1.5.0 + github.com/Microsoft/go-winio v0.6.2 github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 github.com/evanw/esbuild v0.24.2 github.com/fsnotify/fsnotify v1.8.0 + github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 github.com/jvatic/goja-babel v0.0.0-20250308121736-c08d87dbdc10 ) @@ -15,6 +17,7 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.30.0 // indirect ) diff --git a/apps/finicky/src/go.sum b/apps/finicky/src/go.sum index 78e0c108..6428cf21 100644 --- a/apps/finicky/src/go.sum +++ b/apps/finicky/src/go.sum @@ -4,10 +4,10 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dop251/goja v0.0.0-20250307175808-203961f822d6 h1:G73yPVwEaihFs6WYKFFfSstwNY2vENyECvRnR0tye0g= -github.com/dop251/goja v0.0.0-20250307175808-203961f822d6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540= github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/evanw/esbuild v0.24.2 h1:PQExybVBrjHjN6/JJiShRGIXh1hWVm6NepVnhZhrt0A= @@ -16,12 +16,14 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7 h1:+J3r2e8+RsmN3vKfo75g0YSY61ms37qzPglu4p0sGro= -github.com/google/pprof v0.0.0-20250302191652-9094ed2288e7/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0= github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808 h1:ftnsTqIUH57XQEF+PnXX9++nlHCzdkuB5zbWyMMruZo= +github.com/jchv/go-webview2 v0.0.0-20260205173254-56598839c808/go.mod h1:rWifBlzkgrvd7zUqlfq91sWt3473OikgnglnIILx/Jo= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jvatic/goja-babel v0.0.0-20250308121736-c08d87dbdc10 h1:vWIOMaPN3MJ9W2U+0sKPoouDoSdRP6TNEfmfmT8AmfA= github.com/jvatic/goja-babel v0.0.0-20250308121736-c08d87dbdc10/go.mod h1:zDU18A3osPvkOEpVRV2TAclAXKauNajDTPhyxqTcQO8= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -32,13 +34,11 @@ github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBO github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/stvp/assert v0.0.0-20170616060220-4bc16443988b h1:GlTM/aMVIwU3luIuSN2SIVRuTqGPt1P97YxAi514ulw= github.com/stvp/assert v0.0.0-20170616060220-4bc16443988b/go.mod h1:CC7OXV9IjEZRA+znA6/Kz5vbSwh69QioernOHeDCatU= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210218145245-beda7e5e158e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/apps/finicky/src/logger/logger.go b/apps/finicky/src/logger/logger.go index 324bc281..2d7ea1c2 100644 --- a/apps/finicky/src/logger/logger.go +++ b/apps/finicky/src/logger/logger.go @@ -69,12 +69,10 @@ func SetupFile(shouldLog bool) error { return nil } - homeDir, err := util.UserHomeDir() + logDir, err := util.LogDir() if err != nil { - return fmt.Errorf("failed to get user home directory: %w", err) + return fmt.Errorf("failed to get log directory: %w", err) } - - logDir := filepath.Join(homeDir, "Library", "Logs", "Finicky") err = os.MkdirAll(logDir, 0755) // Create directory if it doesn't exist if err != nil { return fmt.Errorf("failed to create log directory: %v", err) diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 829a8f27..d3a3cfdf 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -1,3 +1,5 @@ +//go:build darwin + package main /* diff --git a/apps/finicky/src/main_windows.go b/apps/finicky/src/main_windows.go new file mode 100644 index 00000000..09dc6f93 --- /dev/null +++ b/apps/finicky/src/main_windows.go @@ -0,0 +1,645 @@ +//go:build windows + +package main + +import ( + _ "embed" + "encoding/base64" + "finicky/browser" + "finicky/config" + "finicky/logger" + "finicky/resolver" + "finicky/rules" + "finicky/util" + "finicky/version" + "finicky/window" + "flag" + "fmt" + "log/slog" + "os" + "runtime" + "strings" + "syscall" + "time" + "unsafe" + + "github.com/Microsoft/go-winio" + "github.com/dop251/goja" +) + +//go:embed assets/finickyConfigAPI.js +var finickyConfigAPIJS []byte + +type UpdateInfo struct { + ReleaseInfo *version.ReleaseInfo + UpdateCheckEnabled bool +} + +type URLInfo struct { + URL string + Opener *resolver.OpenerInfo + OpenInBackground bool +} + +type ConfigInfo struct { + Handlers int16 + Rewrites int16 + DefaultBrowser string + ConfigPath string +} + +var urlListener chan URLInfo = make(chan URLInfo) + +// windowClosed is signalled by the UI loop once RunWindow returns. Buffered so +// the UI loop never blocks if the event loop is momentarily busy. +var windowClosed chan struct{} = make(chan struct{}, 1) + +// showWindowChan carries show-window requests from the event loop (any +// goroutine) to the UI loop (the main, thread-locked goroutine that owns the +// WebView2 window). Buffered so requesters never block. +var showWindowChan chan struct{} = make(chan struct{}, 1) + +// queueWindowOpen lets other goroutines (e.g. the IPC listener handling a +// second `--window` invocation) ask the event loop to open the window, keeping +// the event loop's showingWindow bookkeeping authoritative. +var queueWindowOpen chan bool = make(chan bool, 1) + +var configChange chan struct{} = make(chan struct{}, 1) + +var vm *config.VM + +var forceWindowOpen bool = false +var lastError error +var dryRun bool = false +var skipJSConfig bool = false +var updateInfo UpdateInfo +var configInfo *ConfigInfo +var shouldKeepRunning bool = true + +var ( + kernel32Mutex = syscall.NewLazyDLL("kernel32.dll") + procCreateMutexW = kernel32Mutex.NewProc("CreateMutexW") +) + +const mutexName = "Global\\FinickyBrowserRouter" +const errorAlreadyExists = 183 + +// showWindowCmd is the sentinel a secondary instance sends over the IPC socket +// to ask the primary to open its config window (used when Finicky is launched a +// second time with --window, e.g. from the Start Menu entry). A real URL can +// never collide with this value. +const showWindowCmd = "__finicky_show_window__" + +func main() { + startTime := time.Now() + logger.Setup() + + // Pin the main goroutine to the main OS thread. The WebView2 window is + // created and pumped on this thread (see runUILoop / window.RunWindow); + // Win32 requires the window's message loop to run on its creating thread. + runtime.LockOSThread() + + flag.String("config", "", "Path to custom JS configuration file") + rulesPathPtr := flag.String("rules", "", "Path to custom rules JSON file") + noConfigPtr := flag.Bool("no-config", false, "Skip JS configuration file entirely") + windowPtr := flag.Bool("window", false, "Force window to open") + dryRunPtr := flag.Bool("dry-run", false, "Simulate without actually opening browsers") + flag.Parse() + + if *windowPtr { + forceWindowOpen = true + } + + // URL passed as an argument (Windows protocol-handler invocation). + urlFromArgs := "" + if args := flag.Args(); len(args) > 0 { + urlFromArgs = args[0] + } + + // Single-instance detection via a named mutex. CreateMutexW succeeds even + // when the mutex already exists, signalling that case through the error + // value captured by Call (which reads GetLastError immediately after the + // syscall). Reading GetLastError in a *separate* LazyProc.Call would race + // with the Go runtime's own intervening syscalls, so we must use this one. + mutexNamePtr, _ := syscall.UTF16PtrFromString(mutexName) + handle, _, callErr := procCreateMutexW.Call(0, 0, uintptr(unsafe.Pointer(mutexNamePtr))) + alreadyRunning := callErr == syscall.Errno(errorAlreadyExists) + + if alreadyRunning { + // Another instance owns the routing. Hand our work to it and exit. + if handle != 0 { + syscall.CloseHandle(syscall.Handle(handle)) + } + handOffToPrimary(urlFromArgs) + os.Exit(0) + } + if handle != 0 { + defer syscall.CloseHandle(syscall.Handle(handle)) + } + + customConfigPath := "" + flag.Visit(func(f *flag.Flag) { + if f.Name == "config" { + customConfigPath = f.Value.String() + } + }) + + if customConfigPath != "" { + slog.Debug("Using custom config path", "path", customConfigPath) + } + + if *rulesPathPtr != "" { + slog.Debug("Using custom rules path", "path", *rulesPathPtr) + rules.SetCustomPath(*rulesPathPtr) + } + + if *noConfigPtr { + slog.Debug("Skipping JS config") + skipJSConfig = true + } + + dryRun = *dryRunPtr + + currentVersion := version.GetCurrentVersion() + commitHash, buildDate := version.GetBuildInfo() + slog.Info("Starting Finicky", "version", currentVersion) + slog.Debug("Build info", "buildDate", buildDate, "commitHash", commitHash) + + go logDefaultBrowserStatus() + + namespace := "finickyConfig" + cfw, err := config.NewConfigFileWatcher(customConfigPath, namespace, configChange) + if err != nil { + handleFatalError(fmt.Sprintf("Failed to setup config file watcher: %v", err)) + } + + vm, err = setupVM(cfw, namespace) + if err != nil { + handleFatalError(err.Error()) + } + + slog.Debug("VM setup complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) + + go checkForUpdates() + + window.TestUrlHandler = func(url string) { + go TestURLInternal(url) + } + + window.SaveRulesHandler = func(rf rules.RulesFile) { + slog.Debug("Rules updated", "count", len(rf.Rules)) + resolver.SetCachedRules(rf) + if vm == nil || !vm.IsJSConfig() { + if rf.DefaultBrowser == "" && len(rf.Rules) == 0 && rf.Options == nil { + vm = nil + return + } + script, err := rules.ToJSConfigScript(rf, namespace) + if err != nil { + slog.Error("Failed to generate config from rules", "error", err) + return + } + newVM, err := config.NewFromScript(finickyConfigAPIJS, namespace, script) + if err != nil { + slog.Error("Failed to rebuild VM from rules", "error", err) + return + } + vm = newVM + if vm != nil { + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + go checkForUpdates() + } + } + } + + // Start the IPC server that receives URLs (and show-window requests) from + // secondary instances. + go listenForURLs() + + // If launched with a URL argument, process it. + if urlFromArgs != "" { + go func() { + handleURLString(urlFromArgs, false) + }() + } + + if vm != nil { + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + } + + // The event loop runs on a background goroutine (like the macOS build); the + // main thread stays free to own the WebView2 UI in runUILoop. + go runEventLoop(cfw, namespace) + + runUILoop() +} + +// runUILoop owns the WebView2 window and MUST run on the main thread (pinned by +// runtime.LockOSThread in main). It waits for a show request, then creates and +// pumps the window's message loop here — blocking until the window closes — +// before notifying the event loop and looping back to wait for the next one. +func runUILoop() { + for range showWindowChan { + window.RunWindow() + select { + case windowClosed <- struct{}{}: + default: + } + } +} + +// requestShowWindow asks the UI loop (main thread) to open the window and seeds +// the current state, which buffers in the window package until the page loads. +func requestShowWindow() { + window.SendMessageToWebView("version", version.GetCurrentVersion()) + select { + case showWindowChan <- struct{}{}: + default: + } +} + +func runEventLoop(cfw *config.ConfigFileWatcher, namespace string) { + const oneDay = 24 * time.Hour + + showingWindow := false + timeoutChan := time.After(1 * time.Second) + updateChan := time.After(oneDay) + + if shouldKeepRunning { + timeoutChan = nil + } + + slog.Info("Listening for events...") + + if forceWindowOpen { + requestShowWindow() + showingWindow = true + timeoutChan = nil + } + + for { + select { + case urlInfo := <-urlListener: + startTime := time.Now() + slog.Info("URL received", "url", urlInfo.URL) + + config, err := resolver.ResolveURL(vm, urlInfo.URL, urlInfo.Opener, urlInfo.OpenInBackground) + if err != nil { + handleRuntimeError(err) + } else { + lastError = nil + } + if launchErr := browser.LaunchBrowser(*config, dryRun, urlInfo.OpenInBackground); launchErr != nil { + slog.Error("Failed to start browser", "error", launchErr) + } + + slog.Debug("Time taken evaluating URL and opening browser", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) + + if !showingWindow && !shouldKeepRunning { + timeoutChan = time.After(2 * time.Second) + } else { + timeoutChan = nil + } + + case <-configChange: + startTime := time.Now() + slog.Debug("Config has changed") + var setupErr error + vm, setupErr = setupVM(cfw, namespace) + if setupErr != nil { + handleRuntimeError(setupErr) + } else { + lastError = nil + } + slog.Debug("VM refresh complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) + if vm != nil { + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + go checkForUpdates() + } + + case shouldShowWindow := <-queueWindowOpen: + if !showingWindow && shouldShowWindow { + requestShowWindow() + showingWindow = true + timeoutChan = nil + } + + case <-updateChan: + go checkForUpdates() + updateChan = time.After(oneDay) + + case <-windowClosed: + showingWindow = false + if !shouldKeepRunning { + slog.Info("Exiting due to window closed") + tearDown() + } else { + slog.Debug("Window closed") + } + + case <-timeoutChan: + slog.Info("Exiting due to timeout") + tearDown() + } + } +} + +func handleURLString(urlString string, openInBackground bool) { + if strings.HasPrefix(urlString, "finicky://open/") { + encodedURL := strings.TrimPrefix(urlString, "finicky://open/") + if decodedBytes, err := base64.StdEncoding.DecodeString(encodedURL); err == nil { + urlString = string(decodedBytes) + slog.Debug("Decoded finicky protocol URL", "decoded", urlString) + } else { + slog.Warn("Failed to decode finicky protocol URL", "error", err) + } + } + + urlListener <- URLInfo{ + URL: urlString, + Opener: &resolver.OpenerInfo{}, + OpenInBackground: openInBackground, + } +} + +func handleRuntimeError(err error) { + slog.Error("Failed evaluating url", "error", err) + lastError = err +} + +// logDefaultBrowserStatus reports whether Finicky is the default browser. On +// Windows 10+ an app cannot make itself default programmatically — the user +// does it in Settings (offered by the installer) — so unlike the macOS build we +// only observe and log here, never prompt. Prompting on every launch would pop +// the Settings app on every link click. +func logDefaultBrowserStatus() { + isDefault, err := isDefaultBrowser() + if err != nil { + slog.Debug("Failed checking if we are the default browser", "error", err) + } else if isDefault { + slog.Debug("Finicky is the default browser") + } else { + slog.Debug("Finicky is not the default browser") + } +} + +func TestURLInternal(urlString string) { + slog.Debug("Testing URL", "url", urlString) + config, err := resolver.ResolveURL(vm, urlString, nil, false) + if err != nil { + slog.Error("Failed to evaluate URL", "error", err) + window.SendMessageToWebView("testUrlResult", map[string]interface{}{ + "error": err.Error(), + }) + return + } + window.SendMessageToWebView("testUrlResult", map[string]interface{}{ + "url": config.URL, + "browser": config.Name, + "openInBackground": config.OpenInBackground, + "profile": config.Profile, + "args": config.Args, + }) +} + +func handleFatalError(errorMessage string) { + slog.Error("Fatal error", "msg", errorMessage) + lastError = fmt.Errorf("%s", errorMessage) +} + +func checkForUpdates() { + var runtime *goja.Runtime + if vm != nil { + runtime = vm.Runtime() + } + + releaseInfo, updateCheckEnabled, err := version.CheckForUpdatesIfEnabled(runtime) + if err != nil { + slog.Error("Error checking for updates", "error", err) + } + + updateInfo = UpdateInfo{ + ReleaseInfo: releaseInfo, + UpdateCheckEnabled: updateCheckEnabled, + } + + if updateInfo.ReleaseInfo != nil && updateInfo.ReleaseInfo.HasUpdate { + slog.Info("New version is available", "version", updateInfo.ReleaseInfo.LatestVersion) + } + + if updateInfo.ReleaseInfo != nil { + window.SendMessageToWebView("updateInfo", map[string]interface{}{ + "version": updateInfo.ReleaseInfo.LatestVersion, + "hasUpdate": updateInfo.ReleaseInfo.HasUpdate, + "updateCheckEnabled": updateInfo.UpdateCheckEnabled, + "downloadUrl": updateInfo.ReleaseInfo.DownloadUrl, + "releaseUrl": updateInfo.ReleaseInfo.ReleaseUrl, + }) + } else { + window.SendMessageToWebView("updateInfo", map[string]interface{}{ + "version": "", + "hasUpdate": false, + "updateCheckEnabled": updateInfo.UpdateCheckEnabled, + "downloadUrl": "", + "releaseUrl": "", + }) + } +} + +func tearDown() { + checkForUpdates() + slog.Info("Exiting...") + os.Exit(0) +} + +func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error) { + logRequests := true + var err error + + defer func() { + err = logger.SetupFile(logRequests) + if err != nil { + slog.Warn("Failed to setup file logging", "error", err) + } + }() + + var currentBundlePath, configPath string + if !skipJSConfig { + var err2 error + currentBundlePath, configPath, err2 = cfw.BundleConfig() + if err2 != nil { + return nil, fmt.Errorf("failed to read config: %v", err2) + } + } + + if rf, rulesErr := rules.Load(); rulesErr != nil { + slog.Warn("Failed to pre-load rules cache", "error", rulesErr) + } else { + resolver.SetCachedRules(rf) + } + + var newVM *config.VM + + if currentBundlePath != "" { + newVM, err = config.New(finickyConfigAPIJS, namespace, currentBundlePath) + if err != nil { + return nil, fmt.Errorf("failed to setup VM: %v", err) + } + } else { + rf, rulesErr := rules.Load() + if rulesErr != nil { + slog.Warn("Failed to load rules file", "error", rulesErr) + } else { + resolver.SetCachedRules(rf) + if rf.DefaultBrowser != "" || len(rf.Rules) > 0 { + script, scriptErr := rules.ToJSConfigScript(rf, namespace) + if scriptErr != nil { + return nil, fmt.Errorf("failed to generate config from rules: %v", scriptErr) + } + newVM, err = config.NewFromScript(finickyConfigAPIJS, namespace, script) + if err != nil { + return nil, fmt.Errorf("failed to setup VM from rules: %v", err) + } + configPath, _ = rules.GetPath() + } + } + } + + if newVM == nil { + return nil, nil + } + + cs := newVM.GetConfigState() + if cs != nil { + configInfo = &ConfigInfo{ + Handlers: cs.Handlers, + Rewrites: cs.Rewrites, + DefaultBrowser: cs.DefaultBrowser, + ConfigPath: configPath, + } + } + + opts := newVM.GetAllConfigOptions() + logRequests = opts.LogRequests + + window.SendMessageToWebView("config", map[string]interface{}{ + "handlers": configInfo.Handlers, + "rewrites": configInfo.Rewrites, + "defaultBrowser": configInfo.DefaultBrowser, + "configPath": configInfo.ConfigPath, + "isJSConfig": newVM.IsJSConfig(), + "options": map[string]interface{}{ + "keepRunning": opts.KeepRunning, + "hideIcon": opts.HideIcon, + "logRequests": opts.LogRequests, + "checkForUpdates": opts.CheckForUpdates, + }, + }) + + return newVM, nil +} + +// listenForURLs starts a local named-pipe server. When a second finicky.exe is +// launched with a URL (or --window), it connects here and hands off the request +// instead of starting a duplicate router. +// +// Windows named pipes (not AF_UNIX sockets) are used deliberately: Go's +// net.Listen("unix", …) on Windows fails intermittently with WSAEINVAL after +// socket create/remove cycles, which silently disabled single-instance routing. +// Named pipes have no on-disk artifact, so there is no stale-file cleanup and no +// such flakiness. +func listenForURLs() { + listener, err := winio.ListenPipe(pipeName(), nil) + if err != nil { + slog.Error("Failed to start URL listener", "error", err) + return + } + defer listener.Close() + + slog.Debug("IPC listener started", "address", pipeName()) + for { + conn, err := listener.Accept() + if err != nil { + slog.Error("IPC accept error", "error", err) + time.Sleep(50 * time.Millisecond) + continue + } + go func() { + defer conn.Close() + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil || n == 0 { + return + } + payload := strings.TrimSpace(string(buf[:n])) + switch { + case payload == "": + return + case payload == showWindowCmd: + slog.Info("Received show-window request from another instance") + select { + case queueWindowOpen <- true: + default: + } + default: + slog.Info("Received URL from IPC", "url", payload) + handleURLString(payload, false) + } + }() + } +} + +// handOffToPrimary forwards this (secondary) instance's work to the running +// primary over the IPC socket, then the caller exits. +func handOffToPrimary(urlFromArgs string) { + if urlFromArgs != "" { + if err := sendToPrimary(urlFromArgs); err != nil { + slog.Error("Could not forward URL to the running Finicky instance", "error", err) + // Fall back to the OS default handler ONLY if we are not it — + // otherwise opening the URL would relaunch Finicky and loop. + if def, _ := isDefaultBrowser(); !def { + if openErr := util.OpenURLDefault(urlFromArgs); openErr != nil { + slog.Error("Fallback open via default handler failed", "error", openErr) + } + } else { + slog.Error("Running instance unreachable and Finicky is the default browser; dropping URL to avoid a relaunch loop", "url", urlFromArgs) + } + } + return + } + if forceWindowOpen { + if err := sendToPrimary(showWindowCmd); err != nil { + slog.Error("Could not ask the running Finicky instance to show its window", "error", err) + } + } +} + +// sendToPrimary hands a payload (a URL, or the show-window sentinel) to the +// running instance over the named pipe. The primary creates its single-instance +// mutex before it begins listening, so a request from a near-simultaneous launch +// can briefly beat the pipe into existence; retry for ~1s to cover that startup +// window. +func sendToPrimary(payload string) error { + dialTimeout := 100 * time.Millisecond + var lastErr error + for i := 0; i < 20; i++ { + conn, err := winio.DialPipe(pipeName(), &dialTimeout) + if err != nil { + lastErr = err + time.Sleep(50 * time.Millisecond) + continue + } + defer conn.Close() + _ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + _, err = conn.Write([]byte(payload)) + return err + } + return lastErr +} + +// pipeName is the machine-wide named pipe used for single-instance IPC. Its +// scope matches the Global\ mutex above so the pipe and the mutex agree on what +// "already running" means. +func pipeName() string { + return `\\.\pipe\FinickyBrowserRouter` +} diff --git a/apps/finicky/src/resolver/resolver.go b/apps/finicky/src/resolver/resolver.go index 389c07af..2f149aaf 100644 --- a/apps/finicky/src/resolver/resolver.go +++ b/apps/finicky/src/resolver/resolver.go @@ -138,8 +138,8 @@ func evaluateURL(vm *config.VM, url string, opener *OpenerInfo) (*browser.Browse func defaultBrowserConfig(urlStr string, openInBackground bool) *browser.BrowserConfig { bg := openInBackground return &browser.BrowserConfig{ - Name: "com.apple.Safari", - AppType: "bundleId", + Name: browser.DefaultBrowserName, + AppType: browser.DefaultBrowserAppType, OpenInBackground: &bg, Args: []string{}, URL: urlStr, diff --git a/apps/finicky/src/resolver/resolver_test.go b/apps/finicky/src/resolver/resolver_test.go index 5604f671..cfe3dd25 100644 --- a/apps/finicky/src/resolver/resolver_test.go +++ b/apps/finicky/src/resolver/resolver_test.go @@ -4,6 +4,7 @@ import ( "os" "testing" + "finicky/browser" "finicky/config" . "finicky/resolver" "finicky/rules" @@ -55,8 +56,8 @@ func TestResolveURL_NoConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if result.Name != "com.apple.Safari" { - t.Errorf("got %q, want %q", result.Name, "com.apple.Safari") + if result.Name != browser.DefaultBrowserName { + t.Errorf("got %q, want %q", result.Name, browser.DefaultBrowserName) } } diff --git a/apps/finicky/src/resource_windows_amd64.syso b/apps/finicky/src/resource_windows_amd64.syso new file mode 100644 index 00000000..74d1e1b9 Binary files /dev/null and b/apps/finicky/src/resource_windows_amd64.syso differ diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go index 30909ec4..1305201c 100644 --- a/apps/finicky/src/rules/rules.go +++ b/apps/finicky/src/rules/rules.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + + "finicky/browser" ) type Rule struct { @@ -180,7 +182,7 @@ func ToJSHandlers(rules []Rule) []map[string]interface{} { func ToJSConfigScript(rf RulesFile, namespace string) (string, error) { defaultBrowser := rf.DefaultBrowser if defaultBrowser == "" { - defaultBrowser = "com.apple.Safari" + defaultBrowser = browser.DefaultBrowserName } var defaultBrowserObj interface{} diff --git a/apps/finicky/src/rules/rules_test.go b/apps/finicky/src/rules/rules_test.go index 0b81fc57..72ddd2a5 100644 --- a/apps/finicky/src/rules/rules_test.go +++ b/apps/finicky/src/rules/rules_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "finicky/browser" . "finicky/rules" ) @@ -92,12 +93,11 @@ func TestToJSConfigScript_DefaultBrowserFallback(t *testing.T) { if err != nil { t.Fatal(err) } - // Should fall back to com.apple.Safari when no defaultBrowser is set + // Should fall back to the platform default browser when no defaultBrowser is set if script == "" { t.Error("expected non-empty script") } - // com.apple.Safari should appear as the default - if !contains(script, "com.apple.Safari") { + if !contains(script, browser.DefaultBrowserName) { t.Errorf("expected fallback default browser in script, got: %s", script) } } diff --git a/apps/finicky/src/util/directories.go b/apps/finicky/src/util/directories.go index cffdc00d..4e63c38a 100644 --- a/apps/finicky/src/util/directories.go +++ b/apps/finicky/src/util/directories.go @@ -1,3 +1,5 @@ +//go:build darwin + package util /* diff --git a/apps/finicky/src/util/directories_windows.go b/apps/finicky/src/util/directories_windows.go new file mode 100644 index 00000000..2d1655f7 --- /dev/null +++ b/apps/finicky/src/util/directories_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package util + +import ( + "fmt" + "os" + "strings" +) + +func UserHomeDir() (string, error) { + dir, err := os.UserHomeDir() + if err != nil || dir == "" { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return dir, nil +} + +func ShortenPath(path string) string { + home, err := UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home || strings.HasPrefix(path, home+`\`) || strings.HasPrefix(path, home+"/") { + return "~" + path[len(home):] + } + return path +} + +func UserCacheDir() (string, error) { + dir, err := os.UserCacheDir() + if err != nil || dir == "" { + return "", fmt.Errorf("failed to get user cache directory: %w", err) + } + return dir, nil +} diff --git a/apps/finicky/src/util/info.go b/apps/finicky/src/util/info.go index b42dd6c1..37d0e44b 100644 --- a/apps/finicky/src/util/info.go +++ b/apps/finicky/src/util/info.go @@ -1,3 +1,5 @@ +//go:build darwin + package util /* diff --git a/apps/finicky/src/util/info_darwin_ext.go b/apps/finicky/src/util/info_darwin_ext.go new file mode 100644 index 00000000..b970aa12 --- /dev/null +++ b/apps/finicky/src/util/info_darwin_ext.go @@ -0,0 +1,13 @@ +//go:build darwin + +package util + +import "path/filepath" + +func LogDir() (string, error) { + home, err := UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Logs", "Finicky"), nil +} diff --git a/apps/finicky/src/util/info_windows.go b/apps/finicky/src/util/info_windows.go new file mode 100644 index 00000000..02f20d24 --- /dev/null +++ b/apps/finicky/src/util/info_windows.go @@ -0,0 +1,182 @@ +//go:build windows + +package util + +import ( + "fmt" + "log/slog" + "os" + "strings" + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetAsyncKeyState = user32.NewProc("GetAsyncKeyState") + procGetWindowTextW = user32.NewProc("GetWindowTextW") + + procCreateToolhelp32Snapshot = kernel32.NewProc("CreateToolhelp32Snapshot") + procProcess32FirstW = kernel32.NewProc("Process32FirstW") + procProcess32NextW = kernel32.NewProc("Process32NextW") + procGetSystemPowerStatus = kernel32.NewProc("GetSystemPowerStatus") +) + +const ( + vkShift = 0x10 + vkControl = 0x11 + vkMenu = 0x12 // Alt/Option + vkLWin = 0x5B // Left Windows key (maps to Command) + vkCapital = 0x14 +) + +func GetModifierKeys() map[string]bool { + result := map[string]bool{ + "shift": isKeyDown(vkShift), + "option": isKeyDown(vkMenu), + "command": isKeyDown(vkLWin), + "control": isKeyDown(vkControl), + "capsLock": isKeyDown(vkCapital), + "fn": false, // no direct equivalent on Windows + "function": false, + } + args := []any{} + for k, v := range result { + if k == "function" { + continue + } + args = append(args, k, v) + } + slog.Debug("Modifier keys state", args...) + return result +} + +func isKeyDown(vk int) bool { + ret, _, _ := procGetAsyncKeyState.Call(uintptr(vk)) + return ret&0x8000 != 0 +} + +func GetSystemInfo() map[string]string { + name, err := os.Hostname() + if err != nil { + name = "" + } + return map[string]string{ + "localizedName": name, + "name": name, + } +} + +type systemPowerStatus struct { + ACLineStatus byte + BatteryFlag byte + BatteryLifePercent byte + SystemStatusFlag byte + BatteryLifeTime uint32 + BatteryFullLifeTime uint32 +} + +func GetPowerInfo() map[string]interface{} { + var status systemPowerStatus + ret, _, _ := procGetSystemPowerStatus.Call(uintptr(unsafe.Pointer(&status))) + if ret == 0 { + slog.Debug("Power info", "isCharging", false, "isConnected", false, "percentage", nil) + return map[string]interface{}{ + "isCharging": false, + "isConnected": false, + "percentage": nil, + } + } + + isConnected := status.ACLineStatus == 1 + isCharging := status.BatteryFlag&0x08 != 0 + + var percentage interface{} + if status.BatteryLifePercent <= 100 { + percentage = int(status.BatteryLifePercent) + } + + slog.Debug("Power info", "isCharging", isCharging, "isConnected", isConnected, "percentage", percentage) + return map[string]interface{}{ + "isCharging": isCharging, + "isConnected": isConnected, + "percentage": percentage, + } +} + +const thSnapshot = 0x00000002 // TH32CS_SNAPPROCESS + +type processEntry32W struct { + Size uint32 + CntUsage uint32 + ProcessID uint32 + DefaultHeapID uintptr + ModuleID uint32 + CntThreads uint32 + ParentProcessID uint32 + PriClassBase int32 + Flags uint32 + ExeFile [260]uint16 +} + +func IsAppRunning(identifier string) bool { + handle, _, _ := procCreateToolhelp32Snapshot.Call(thSnapshot, 0) + if handle == ^uintptr(0) { + return false + } + defer syscall.CloseHandle(syscall.Handle(handle)) + + var entry processEntry32W + entry.Size = uint32(unsafe.Sizeof(entry)) + + ret, _, _ := procProcess32FirstW.Call(handle, uintptr(unsafe.Pointer(&entry))) + if ret == 0 { + return false + } + + target := strings.ToLower(identifier) + for { + name := strings.ToLower(syscall.UTF16ToString(entry.ExeFile[:])) + // Match against exe name with or without .exe suffix + if name == target || name == target+".exe" || + strings.TrimSuffix(name, ".exe") == target { + slog.Debug("App running info", "identifier", identifier, "isRunning", true) + return true + } + + entry.Size = uint32(unsafe.Sizeof(entry)) + ret, _, _ = procProcess32NextW.Call(handle, uintptr(unsafe.Pointer(&entry))) + if ret == 0 { + break + } + } + + slog.Debug("App running info", "identifier", identifier, "isRunning", false) + return false +} + +// GetForegroundWindowTitle returns the title of the currently focused window. +func GetForegroundWindowTitle() string { + procGetForegroundWindow := user32.NewProc("GetForegroundWindow") + hwnd, _, _ := procGetForegroundWindow.Call() + if hwnd == 0 { + return "" + } + + buf := make([]uint16, 256) + ret, _, _ := procGetWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), 256) + if ret == 0 { + return "" + } + return syscall.UTF16ToString(buf) +} + +// LogDir returns the platform-appropriate log directory for Finicky. +func LogDir() (string, error) { + appData := os.Getenv("APPDATA") + if appData == "" { + return "", fmt.Errorf("APPDATA environment variable not set") + } + return appData + `\Finicky\Logs`, nil +} diff --git a/apps/finicky/src/util/shellexec_windows.go b/apps/finicky/src/util/shellexec_windows.go new file mode 100644 index 00000000..f7d90f7d --- /dev/null +++ b/apps/finicky/src/util/shellexec_windows.go @@ -0,0 +1,49 @@ +//go:build windows + +package util + +import ( + "fmt" + "syscall" + "unsafe" +) + +var ( + shell32Util = syscall.NewLazyDLL("shell32.dll") + procShellExecuteW = shell32Util.NewProc("ShellExecuteW") +) + +// OpenURLDefault opens a URL with the system's default handler via ShellExecuteW. +// +// Unlike `cmd /c start `, the URL is handed to the shell as a single +// wide-string argument and is never parsed by cmd.exe, so shell metacharacters +// in an attacker-controlled URL (&, |, ^, >, quotes, ...) cannot break out and +// inject commands. Use this instead of shelling out to cmd for URL opening. +func OpenURLDefault(url string) error { + verb, err := syscall.UTF16PtrFromString("open") + if err != nil { + return err + } + target, err := syscall.UTF16PtrFromString(url) + if err != nil { + return err + } + const swShowNormal = 1 + // ShellExecuteW(hwnd, lpVerb, lpFile, lpParameters, lpDirectory, nShowCmd) + ret, _, callErr := procShellExecuteW.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(target)), + 0, + 0, + swShowNormal, + ) + // ShellExecuteW returns a value greater than 32 on success. + if ret <= 32 { + if callErr != nil && callErr != syscall.Errno(0) { + return fmt.Errorf("ShellExecuteW failed: %w", callErr) + } + return fmt.Errorf("ShellExecuteW failed with code %d", ret) + } + return nil +} diff --git a/apps/finicky/src/version/version.go b/apps/finicky/src/version/version.go index 2c00c484..db15b441 100644 --- a/apps/finicky/src/version/version.go +++ b/apps/finicky/src/version/version.go @@ -7,7 +7,6 @@ import ( "log/slog" "net/http" "os" - "os/exec" "path/filepath" "strings" "time" @@ -37,8 +36,23 @@ var ( commitHash = "dev" buildDate = "unknown" apiHost = "" + // version is the authoritative build version, injected via + // -X 'finicky/version.version=$(git describe --tags --match v*)'. + // When empty (e.g. a bare `go build`), GetCurrentVersion falls back to + // platform metadata: Info.plist on macOS, the PE version resource on + // Windows — both of which may lag the real version, hence the override. + version = "" ) +// GetCurrentVersion returns the running build's version: the ldflags-injected +// value when present, otherwise the platform fallback. +func GetCurrentVersion() string { + if version != "" { + return version + } + return getCurrentVersionPlatform() +} + // GetBuildInfo returns the commit hash and build date func GetBuildInfo() (string, string) { return commitHash, buildDate @@ -96,36 +110,10 @@ func setLastUpdateCheck(info UpdateCheckInfo) { } } -func GetCurrentVersion() string { - // Get the bundle path - bundlePath := os.Getenv("BUNDLE_PATH") - if bundlePath == "" { - execPath, err := os.Executable() - if err != nil { - slog.Error("Error getting executable path", "error", err) - return "" - } - - bundlePath = filepath.Join(filepath.Dir(execPath), "..", "Info.plist") - } - - // Read and parse Info.plist - cmd := exec.Command("defaults", "read", bundlePath, "CFBundleVersion") - output, err := cmd.Output() - if err != nil { - slog.Error("Error reading version from Info.plist", "error", err) - return "" - } - - version := strings.TrimSpace(string(output)) - - if version == "" { - slog.Error("Could not determine current version") - return "dev" - } - - return version -} +// GetCurrentVersion returns the running build's version. Its implementation is +// platform-specific (version_darwin.go reads Info.plist; version_windows.go +// reads the PE version resource) because the two platforms package version +// metadata differently. func checkForUpdates() (releaseInfo *ReleaseInfo) { currentVersion := GetCurrentVersion() diff --git a/apps/finicky/src/version/version_darwin.go b/apps/finicky/src/version/version_darwin.go new file mode 100644 index 00000000..23aab5e6 --- /dev/null +++ b/apps/finicky/src/version/version_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package version + +import ( + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// getCurrentVersionPlatform reads CFBundleVersion from the app bundle's +// Info.plist via the macOS `defaults` tool. Fallback only — the +// ldflags-injected version wins. +func getCurrentVersionPlatform() string { + // Get the bundle path + bundlePath := os.Getenv("BUNDLE_PATH") + if bundlePath == "" { + execPath, err := os.Executable() + if err != nil { + slog.Error("Error getting executable path", "error", err) + return "" + } + + bundlePath = filepath.Join(filepath.Dir(execPath), "..", "Info.plist") + } + + // Read and parse Info.plist + cmd := exec.Command("defaults", "read", bundlePath, "CFBundleVersion") + output, err := cmd.Output() + if err != nil { + slog.Error("Error reading version from Info.plist", "error", err) + return "" + } + + version := strings.TrimSpace(string(output)) + + if version == "" { + slog.Error("Could not determine current version") + return "dev" + } + + return version +} diff --git a/apps/finicky/src/version/version_windows.go b/apps/finicky/src/version/version_windows.go new file mode 100644 index 00000000..d57862a0 --- /dev/null +++ b/apps/finicky/src/version/version_windows.go @@ -0,0 +1,50 @@ +//go:build windows + +package version + +import ( + "fmt" + "log/slog" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +// getCurrentVersionPlatform reads the product version from the executable's +// embedded PE version resource (see versioninfo.json / +// resource_windows_amd64.syso). Windows has no `defaults`/Info.plist +// equivalent, so we read the resource directly instead of shelling out to a +// macOS-only tool. Fallback only — the ldflags-injected version wins. +func getCurrentVersionPlatform() string { + exePath, err := os.Executable() + if err != nil { + slog.Error("Error getting executable path", "error", err) + return "dev" + } + + var zero windows.Handle + size, err := windows.GetFileVersionInfoSize(exePath, &zero) + if err != nil || size == 0 { + slog.Debug("No version resource in executable", "error", err) + return "dev" + } + + info := make([]byte, size) + if err := windows.GetFileVersionInfo(exePath, 0, size, unsafe.Pointer(&info[0])); err != nil { + slog.Debug("Failed to read version resource", "error", err) + return "dev" + } + + var fixed *windows.VS_FIXEDFILEINFO + fixedLen := uint32(unsafe.Sizeof(*fixed)) + if err := windows.VerQueryValue(unsafe.Pointer(&info[0]), `\`, unsafe.Pointer(&fixed), &fixedLen); err != nil || fixed == nil { + slog.Debug("Failed to query fixed file info", "error", err) + return "dev" + } + + major := (fixed.FileVersionMS >> 16) & 0xffff + minor := fixed.FileVersionMS & 0xffff + patch := (fixed.FileVersionLS >> 16) & 0xffff + return fmt.Sprintf("%d.%d.%d", major, minor, patch) +} diff --git a/apps/finicky/src/versioninfo.json b/apps/finicky/src/versioninfo.json new file mode 100644 index 00000000..b2121396 --- /dev/null +++ b/apps/finicky/src/versioninfo.json @@ -0,0 +1,25 @@ +{ + "FixedFileInfo": { + "FileVersion": { "Major": 4, "Minor": 4, "Patch": 0, "Build": 0 }, + "ProductVersion": { "Major": 4, "Minor": 4, "Patch": 0, "Build": 0 }, + "FileFlagsMask": "3f", + "FileFlags": "00", + "FileOS": "040004", + "FileType": "01", + "FileSubType": "00" + }, + "StringFileInfo": { + "CompanyName": "Finicky", + "FileDescription": "Finicky", + "FileVersion": "4.4.0-alpha", + "InternalName": "Finicky.exe", + "LegalCopyright": "MIT — John Sterling and contributors", + "OriginalFilename": "Finicky.exe", + "ProductName": "Finicky", + "ProductVersion": "4.4.0-alpha" + }, + "VarFileInfo": { + "Translation": { "LangID": "0409", "CharsetID": "04B0" } + }, + "IconPath": "../assets/Resources/finicky.ico" +} diff --git a/apps/finicky/src/window/window.go b/apps/finicky/src/window/window.go index 1119946a..95d5fad1 100644 --- a/apps/finicky/src/window/window.go +++ b/apps/finicky/src/window/window.go @@ -1,3 +1,5 @@ +//go:build darwin + package window /* diff --git a/apps/finicky/src/window/window_windows.go b/apps/finicky/src/window/window_windows.go new file mode 100644 index 00000000..9cf36301 --- /dev/null +++ b/apps/finicky/src/window/window_windows.go @@ -0,0 +1,367 @@ +//go:build windows + +package window + +import ( + "encoding/json" + "finicky/assets" + "finicky/browser" + "finicky/rules" + "finicky/util" + "finicky/version" + "fmt" + "io/fs" + "log/slog" + "net" + "net/http" + "os" + "strings" + "sync" + + webview2 "github.com/jchv/go-webview2" +) + +var ( + messageQueue []string + queueMutex sync.Mutex + windowReady bool + TestUrlHandler func(string) + SaveRulesHandler func(rules.RulesFile) + + wv webview2.WebView + assetServer net.Listener +) + +func SendMessageToWebView(messageType string, message interface{}) { + jsonMsg := struct { + Type string `json:"type"` + Message interface{} `json:"message"` + }{ + Type: messageType, + Message: message, + } + jsonBytes, err := json.Marshal(jsonMsg) + if err != nil { + slog.Error("Error marshaling message", "error", err) + return + } + + queueMutex.Lock() + defer queueMutex.Unlock() + + if windowReady { + sendMessageToWebViewInternal(string(jsonBytes)) + } else { + messageQueue = append(messageQueue, string(jsonBytes)) + } +} + +// sendMessageToWebViewInternal must be called with queueMutex held. +func sendMessageToWebViewInternal(message string) { + if wv == nil { + return + } + escaped := strings.ReplaceAll(message, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + js := fmt.Sprintf(`finicky.receiveMessage("%s")`, escaped) + w := wv + wv.Dispatch(func() { + w.Eval(js) + }) +} + +// RunWindow creates the WebView2 window and runs its message loop on the +// CURRENT OS thread, blocking until the window is closed. +// +// It MUST be called from the main thread — the goroutine pinned by +// runtime.LockOSThread in main(). Win32 delivers window messages only to the +// thread that created the window, so creating the window on one goroutine and +// pumping its loop on another (as an earlier version did) leaves the UI frozen +// and can crash WebView2 during initialization. Keeping creation and the +// message loop on one locked thread mirrors how the macOS build runs the whole +// Cocoa app on the main thread. +func RunWindow() { + slog.Debug("Creating window") + + w := webview2.NewWithOptions(webview2.WebViewOptions{ + // Debug (WebView2 devtools/context menu) is opt-in via env, off by + // default in shipped builds. Set FINICKY_DEBUG=1 to enable for testing. + Debug: os.Getenv("FINICKY_DEBUG") != "", + AutoFocus: true, + WindowOptions: webview2.WindowOptions{ + Title: "Finicky", + Width: 860, + Height: 600, + }, + }) + if w == nil { + slog.Error("Failed to create webview2 instance") + return + } + + queueMutex.Lock() + wv = w + windowReady = false + queueMutex.Unlock() + + w.SetSize(860, 600, webview2.HintNone) + + // Inject the finicky stub before any page JS runs (mirrors the macOS WKUserScript). + w.Init(` + window.finicky = { + _queue: [], + receiveMessage: function(msg) { this._queue.push(msg); }, + sendMessage: function(msg) { + window.__finicky_send(JSON.stringify(msg)); + } + }; + window.addEventListener("load", function() { + window.__finicky_ready(); + }); + `) + + // Bind the JS→Go message channel. + w.Bind("__finicky_send", func(msgJSON string) { + HandleWebViewMessage(msgJSON) + }) + + // Bind the navigation-complete signal so the message queue drains. + w.Bind("__finicky_ready", func() { + NavigationComplete() + }) + + // Start a local HTTP server to serve the embedded Svelte UI assets. + addr := startAssetServer() + if addr != "" { + w.Navigate("http://" + addr + "/index.html") + } else { + html, err := assets.GetHTML() + if err != nil { + slog.Error("Failed to load HTML", "error", err) + } else { + w.SetHtml(html) + } + } + + SendBuildInfo() + + // Blocks on the Win32 message loop until the window is closed. + w.Run() + + // Window closed — reset shared state so a later RunWindow starts clean and + // queued messages buffer again, and shut down the asset server it was using. + queueMutex.Lock() + wv = nil + windowReady = false + queueMutex.Unlock() + + if assetServer != nil { + assetServer.Close() + assetServer = nil + } +} + +// CloseWindow asks a running window to close, which unblocks RunWindow. Safe to +// call from any goroutine — it marshals onto the UI thread via Dispatch. +func CloseWindow() { + queueMutex.Lock() + w := wv + queueMutex.Unlock() + if w != nil { + w.Dispatch(func() { + w.Terminate() + }) + } +} + +func SendBuildInfo() { + commitHash, buildDate := version.GetBuildInfo() + buildInfo := fmt.Sprintf("(%s, built %s)", commitHash, buildDate) + SendMessageToWebView("buildInfo", buildInfo) +} + +func NavigationComplete() { + queueMutex.Lock() + windowReady = true + for _, message := range messageQueue { + sendMessageToWebViewInternal(message) + } + messageQueue = nil + queueMutex.Unlock() +} + +// startAssetServer spins up a localhost HTTP server serving the embedded +// Svelte UI. Returns the listen address (e.g. "127.0.0.1:54321") or "". +func startAssetServer() string { + mux := http.NewServeMux() + + filesystem := assets.GetFileSystem() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if path == "/" { + path = "/index.html" + } + path = strings.TrimPrefix(path, "/") + + content, err := assets.GetFile(path) + if err != nil { + // Try reading directly from the embedded FS. + data, fsErr := fs.ReadFile(filesystem, "templates/"+path) + if fsErr != nil { + http.NotFound(w, r) + return + } + content = data + } + + switch { + case strings.HasSuffix(path, ".html"): + w.Header().Set("Content-Type", "text/html; charset=utf-8") + case strings.HasSuffix(path, ".css"): + w.Header().Set("Content-Type", "text/css; charset=utf-8") + case strings.HasSuffix(path, ".js"): + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + case strings.HasSuffix(path, ".png"): + w.Header().Set("Content-Type", "image/png") + default: + w.Header().Set("Content-Type", http.DetectContentType(content)) + } + w.Write(content) + }) + + // Bind to a random available port on loopback only. + var err error + assetServer, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + slog.Error("Failed to start asset server", "error", err) + return "" + } + + addr := assetServer.Addr().String() + slog.Debug("Asset server started", "address", addr) + + go http.Serve(assetServer, mux) + return addr +} + +func HandleWebViewMessage(messageStr string) { + var msg map[string]interface{} + if err := json.Unmarshal([]byte(messageStr), &msg); err != nil { + slog.Error("Failed to parse webview message", "error", err) + return + } + + messageType, ok := msg["type"].(string) + if !ok { + slog.Error("Message missing type field") + return + } + + slog.Debug("Received message from webview", "type", messageType) + + switch messageType { + case "testUrl": + handleTestUrl(msg) + case "getRules": + handleGetRules() + case "saveRules": + handleSaveRules(msg) + case "getInstalledBrowsers": + handleGetInstalledBrowsers() + case "getBrowserProfiles": + handleGetBrowserProfiles(msg) + default: + slog.Debug("Unknown message type", "type", messageType) + } +} + +func handleTestUrl(msg map[string]interface{}) { + url, ok := msg["url"].(string) + if !ok { + slog.Error("testUrl message missing url field") + return + } + if TestUrlHandler != nil { + TestUrlHandler(url) + } else { + SendMessageToWebView("testUrlResult", map[string]interface{}{ + "error": "Test handler not initialized", + }) + } +} + +func handleGetRules() { + rf, err := rules.Load() + if err != nil { + slog.Error("Failed to load rules", "error", err) + SendMessageToWebView("rules", map[string]interface{}{ + "defaultBrowser": "", + "rules": []interface{}{}, + }) + return + } + + path, _ := rules.GetPath() + var rulesPath string + if _, statErr := os.Stat(path); statErr == nil { + rulesPath = path + } + + type rulesResponse struct { + rules.RulesFile + Path string `json:"path,omitempty"` + } + SendMessageToWebView("rules", rulesResponse{RulesFile: rf, Path: util.ShortenPath(rulesPath)}) +} + +func handleSaveRules(msg map[string]interface{}) { + payload, ok := msg["payload"] + if !ok { + slog.Error("saveRules message missing payload field") + return + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + slog.Error("Failed to marshal saveRules payload", "error", err) + return + } + + var rf rules.RulesFile + if err := json.Unmarshal(payloadBytes, &rf); err != nil { + slog.Error("Failed to parse saveRules payload", "error", err) + return + } + + if err := rules.Save(rf); err != nil { + slog.Error("Failed to save rules", "error", err) + SendMessageToWebView("saveRulesError", map[string]interface{}{"error": err.Error()}) + return + } + + path, _ := rules.GetPath() + type rulesResponse struct { + rules.RulesFile + Path string `json:"path,omitempty"` + } + SendMessageToWebView("rules", rulesResponse{RulesFile: rf, Path: util.ShortenPath(path)}) + + if SaveRulesHandler != nil { + SaveRulesHandler(rf) + } +} + +func handleGetInstalledBrowsers() { + installed := browser.GetInstalledBrowsers() + SendMessageToWebView("installedBrowsers", installed) +} + +func handleGetBrowserProfiles(msg map[string]interface{}) { + browserName, _ := msg["browser"].(string) + profiles := browser.GetProfilesForBrowser(browserName) + SendMessageToWebView("browserProfiles", map[string]interface{}{ + "browser": browserName, + "profiles": profiles, + }) +} + diff --git a/packages/finicky-ui/src/App.svelte b/packages/finicky-ui/src/App.svelte index 9578c49c..4917f69d 100644 --- a/packages/finicky-ui/src/App.svelte +++ b/packages/finicky-ui/src/App.svelte @@ -91,12 +91,19 @@ // Capture any messages buffered by the WKUserScript stub before Svelte loaded. const _preloadQueue: any[] = window.finicky._queue ?? []; - // Replace the stub with the real implementation. + // Replace the stub with the real implementation. Route outbound messages to + // whichever native bridge the host provides: WKWebView on macOS + // (window.webkit.messageHandlers.finicky) or the WebView2 binding on Windows + // (window.__finicky_send). Without the Windows branch, sendMessage is a silent + // no-op there and no UI request ever reaches Go. window.finicky = { sendMessage: (msg: any) => { - window.webkit?.messageHandlers?.finicky?.postMessage( - JSON.stringify(msg) - ); + const json = JSON.stringify(msg); + if (window.webkit?.messageHandlers?.finicky) { + window.webkit.messageHandlers.finicky.postMessage(json); + } else if (typeof window.__finicky_send === "function") { + window.__finicky_send(json); + } }, receiveMessage: handleMessage, }; diff --git a/packages/finicky-ui/src/lib/testUrlStore.ts b/packages/finicky-ui/src/lib/testUrlStore.ts index c7adfacb..5fa731b3 100644 --- a/packages/finicky-ui/src/lib/testUrlStore.ts +++ b/packages/finicky-ui/src/lib/testUrlStore.ts @@ -1,10 +1,12 @@ import { writable } from 'svelte/store'; export interface TestUrlResult { - browser: string; - url: string; - openInBackground: boolean; + browser?: string; + url?: string; + openInBackground?: boolean; profile?: string; + /** Set by the backend when URL evaluation failed (e.g. config error). */ + error?: string; } export const testUrlResult = writable(null); diff --git a/packages/finicky-ui/src/pages/About.svelte b/packages/finicky-ui/src/pages/About.svelte index 5149aea0..111c7575 100644 --- a/packages/finicky-ui/src/pages/About.svelte +++ b/packages/finicky-ui/src/pages/About.svelte @@ -19,8 +19,8 @@

- Finicky is a macOS application that lets you set up rules to decide which - browser to open for every link. + Finicky lets you set up rules to decide which browser to open for every + link. Available on macOS and Windows.

{ + // Track inputs up front so external changes still re-sync after an early return. + const incoming = rulesFile; + const browsers = installedBrowsers; if (pendingSave) return; - const newRules = rulesFile.rules.map((r: Rule) => ({ + // Ignore the echo of our own save — reassigning `rules` here re-renders the + // rows and breaks focus moving between the browser and URL fields. + if (selfSaved) { selfSaved = false; return; } + const newRules = incoming.rules.map((r: Rule) => ({ ...r, match: Array.isArray(r.match) ? r.match : r.match ? [r.match as unknown as string] : [""], })); rules = newRules; rowIsCustom = newRules.map( - (r) => r.browser !== "" && !installedBrowsers.includes(r.browser) + (r) => r.browser !== "" && !browsers.includes(r.browser) ); rowProfileIsCustom = newRules.map( (r) => { diff --git a/packages/finicky-ui/src/pages/StartPage.svelte b/packages/finicky-ui/src/pages/StartPage.svelte index 30582bfb..c969c561 100644 --- a/packages/finicky-ui/src/pages/StartPage.svelte +++ b/packages/finicky-ui/src/pages/StartPage.svelte @@ -33,9 +33,10 @@ let logRequests = rulesFile.options?.logRequests ?? config.options?.logRequests ?? false; let checkForUpdates = rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true; - const SAFARI = "Safari"; - - let defaultBrowser = isJSConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser || SAFARI); + // No hard-coded browser fallback — that seeded "Safari" (macOS-only) on every + // platform. When no default is set, show the placeholder; the Go layer applies + // the platform-appropriate default (Edge on Windows, Safari on macOS) at runtime. + let defaultBrowser = isJSConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser ?? ""); let defaultProfile = rulesFile.defaultProfile ?? ""; let defaultBrowserIsCustom = false; let defaultProfileIsCustom = false; @@ -47,7 +48,7 @@ hideIcon = rulesFile.options?.hideIcon ?? config.options?.hideIcon ?? false; logRequests = rulesFile.options?.logRequests ?? config.options?.logRequests ?? false; checkForUpdates = rulesFile.options?.checkForUpdates ?? config.options?.checkForUpdates ?? true; - defaultBrowser = isJSConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser || SAFARI); + defaultBrowser = isJSConfig ? (config.defaultBrowser ?? "") : (rulesFile.defaultBrowser ?? ""); defaultProfile = rulesFile.defaultProfile ?? ""; } // Separate statements so browser/profile list updates only affect these derived diff --git a/packages/finicky-ui/src/pages/TestUrl.svelte b/packages/finicky-ui/src/pages/TestUrl.svelte index 95cee7c9..b5b0b92c 100644 --- a/packages/finicky-ui/src/pages/TestUrl.svelte +++ b/packages/finicky-ui/src/pages/TestUrl.svelte @@ -31,11 +31,18 @@ const DEBOUNCE_DELAY = 300; const LOADING_DELAY = DEBOUNCE_DELAY + 100; + // If the backend never answers (dead bridge, hung resolver), stop the + // spinner and say so instead of spinning forever. + const RESPONSE_TIMEOUT = 5000; + let responseTimer: ReturnType; + let timedOut = false; function testUrlAutomatically() { clearTimeout(debounceTimer); clearTimeout(loadingTimer); + clearTimeout(responseTimer); loading = false; + timedOut = false; if (!isValidUrl(testUrl)) { testUrlResult.set(null); @@ -50,6 +57,11 @@ debounceTimer = setTimeout(() => { const normalizedUrl = normalizeUrl(testUrl); + responseTimer = setTimeout(() => { + loading = false; + timedOut = true; + }, RESPONSE_TIMEOUT); + // Send message to native app to test the URL window.finicky.sendMessage({ type: "testUrl", @@ -59,8 +71,12 @@ } // Subscribe to test results - testUrlResult.subscribe(() => { + testUrlResult.subscribe((result) => { clearTimeout(loadingTimer); + if (result) { + clearTimeout(responseTimer); + timedOut = false; + } loading = false; }); @@ -94,7 +110,17 @@ />
- {#if $testUrlResult} + {#if timedOut} +
+ + No response from Finicky — the test timed out. Check the Logs tab for errors. +
+ {:else if $testUrlResult?.error} +
+ + Could not evaluate this URL: {$testUrlResult.error} +
+ {:else if $testUrlResult}

Result

@@ -206,6 +232,19 @@ font-size: 0.95em; } + .error-message { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + background: rgba(224, 82, 82, 0.08); + border: 1px solid rgba(224, 82, 82, 0.35); + border-radius: 8px; + color: var(--text-primary); + font-size: 0.9em; + word-break: break-word; + } + .hint-message { display: flex; align-items: center; diff --git a/packages/finicky-ui/src/types.ts b/packages/finicky-ui/src/types.ts index 2eeb589f..5f90fbde 100644 --- a/packages/finicky-ui/src/types.ts +++ b/packages/finicky-ui/src/types.ts @@ -42,6 +42,8 @@ declare global { }; }; }; + /** JS→Go bridge bound by the Windows WebView2 host (absent on macOS). */ + __finicky_send?: (msg: string) => void; } } diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh new file mode 100755 index 00000000..55afdd18 --- /dev/null +++ b/scripts/build-windows.sh @@ -0,0 +1,53 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +echo "Building Finicky for Windows..." + +# Build frontend assets (same as macOS) +( + cd "$ROOT_DIR/packages/config-api" + npm run build + npm run generate-types + cp dist/finickyConfigAPI.js "$ROOT_DIR/apps/finicky/src/assets/finickyConfigAPI.js" +) + +( + cd "$ROOT_DIR/packages/finicky-ui" + npm run build + mkdir -p "$ROOT_DIR/apps/finicky/src/assets/templates" + cp -r dist/* "$ROOT_DIR/apps/finicky/src/assets/templates" +) + +# Build info +COMMIT_HASH=$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo "dev") +BUILD_DATE=$(date -u '+%Y-%m-%d %H:%M:%S UTC') +API_HOST=$(cat "$ROOT_DIR/.env" 2>/dev/null | grep API_HOST | cut -d '=' -f 2 || echo "") +# Authoritative version from version tags only (--match filters out CI/test +# tags); overrides the PE-resource fallback in version_windows.go (F7). +VERSION=$(git -C "$ROOT_DIR" describe --tags --match 'v*' --always 2>/dev/null || echo "dev") + +# Cross-compile for Windows +mkdir -p "$ROOT_DIR/apps/finicky/build/windows" + +CGO_ENABLED=0 GOOS=windows GOARCH=amd64 \ + go build -C "$ROOT_DIR/apps/finicky/src" \ + -ldflags \ + "-X 'finicky/version.commitHash=${COMMIT_HASH}' \ + -X 'finicky/version.buildDate=${BUILD_DATE}' \ + -X 'finicky/version.apiHost=${API_HOST}' \ + -X 'finicky/version.version=${VERSION}' \ + -H windowsgui" \ + -o ../build/windows/Finicky.exe + +echo "Binary: apps/finicky/build/windows/Finicky.exe" +ls -lh "$ROOT_DIR/apps/finicky/build/windows/Finicky.exe" +file "$ROOT_DIR/apps/finicky/build/windows/Finicky.exe" + +echo "" +echo "To build the installer, run Inno Setup on scripts/installer.iss" +echo " iscc scripts/installer.iss" +echo "" +echo "Build complete." diff --git a/scripts/installer.iss b/scripts/installer.iss new file mode 100644 index 00000000..89ecdfc9 --- /dev/null +++ b/scripts/installer.iss @@ -0,0 +1,136 @@ +; Finicky for Windows — Inno Setup installer +; Registers Finicky as a browser so Windows routes http/https URLs to it. +; +; Install mode: admin (all-users) by default, per-user via /CURRENTUSER. +; Rationale (smoke-test finding F10): on current Windows 11 (observed on +; build 26200) the Default Apps picker only offers browsers registered +; machine-wide (HKLM) — a per-user (HKCU-only) registration is complete and +; correct but never appears as a selectable default browser. HKA roots below +; resolve to HKLM in admin mode and HKCU in per-user mode, so /CURRENTUSER +; still yields a working install for routing/protocol use on systems where +; per-user registration is honored. + +#define MyAppName "Finicky" +; Overridable from CI: iscc /DMyAppVersion= installer.iss +#ifndef MyAppVersion + #define MyAppVersion "4.4.0-alpha" +#endif +#define MyAppPublisher "John Sterling" +#define MyAppURL "https://github.com/johnste/finicky" +#define MyAppExeName "Finicky.exe" + +[Setup] +AppId={{7F3E2A1B-4C5D-6E7F-8A9B-0C1D2E3F4A5B} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL}/issues +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +OutputBaseFilename=FinickySetup-{#MyAppVersion} +Compression=lzma +SolidCompression=yes +; commandline-only override: interactive installs go straight to UAC (target +; audience is personal, unmanaged machines - product owner call, 2026-07-04); +; /CURRENTUSER remains available for the rare no-admin case, unadvertised. +PrivilegesRequired=admin +PrivilegesRequiredOverridesAllowed=commandline +SetupIconFile=..\apps\finicky\assets\Resources\finicky.ico +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +WizardStyle=modern +UninstallDisplayName={#MyAppName} + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "autostart"; Description: "Start Finicky when Windows starts"; GroupDescription: "Additional options:" +Name: "setdefault"; Description: "Open Default Apps settings after install"; GroupDescription: "Additional options:"; Flags: unchecked + +[Files] +Source: "..\apps\finicky\build\windows\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +; --window: clicking the Start Menu entry must open the config UI. A bare +; launch is the (windowless) resident-router mode used by autostart, and with +; a primary already running it exits silently - i.e. the shortcut appears dead. +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "--window" +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" + +[Registry] +; ---- ProgID: FinickyURL ---- +; This is the handler that runs when Windows invokes a URL assigned to Finicky. +Root: HKA; Subkey: "SOFTWARE\Classes\FinickyURL"; ValueType: string; ValueData: "Finicky URL"; Flags: uninsdeletekey +Root: HKA; Subkey: "SOFTWARE\Classes\FinickyURL"; ValueName: "URL Protocol"; ValueType: string; ValueData: "" +Root: HKA; Subkey: "SOFTWARE\Classes\FinickyURL\DefaultIcon"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"",0" +Root: HKA; Subkey: "SOFTWARE\Classes\FinickyURL\shell\open\command"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"" ""%1""" + +; ---- finicky:// custom protocol (always active, independent of default browser choice) ---- +Root: HKA; Subkey: "SOFTWARE\Classes\finicky"; ValueType: string; ValueData: "Finicky Protocol"; Flags: uninsdeletekey +Root: HKA; Subkey: "SOFTWARE\Classes\finicky"; ValueName: "URL Protocol"; ValueType: string; ValueData: "" +Root: HKA; Subkey: "SOFTWARE\Classes\finicky\DefaultIcon"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"",0" +Root: HKA; Subkey: "SOFTWARE\Classes\finicky\shell\open\command"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"" ""%1""" + +; ---- Browser registration under StartMenuInternet ---- +; This makes Finicky appear in Settings > Default Apps > Web browser. +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky"; ValueType: string; ValueData: "Finicky"; Flags: uninsdeletekey +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\DefaultIcon"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"",0" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\shell\open\command"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"" --window" + +; ---- Capabilities ---- +; Tells Windows which URL schemes Finicky can handle. +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities"; ValueName: "ApplicationName"; ValueType: string; ValueData: "Finicky" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities"; ValueName: "ApplicationDescription"; ValueType: string; ValueData: "A rule-based browser routing utility. Define rules to open URLs in different browsers based on the link domain, path, or source application." +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities"; ValueName: "ApplicationIcon"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"",0" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities\URLAssociations"; ValueName: "http"; ValueType: string; ValueData: "FinickyURL" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities\URLAssociations"; ValueName: "https"; ValueType: string; ValueData: "FinickyURL" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities\URLAssociations"; ValueName: "finicky"; ValueType: string; ValueData: "FinickyURL" + +; ---- Browser-shape extras (required for the Win11 Default Apps picker) ---- +; Win11 filters the http/https picker to apps that look like full browsers: +; FileAssociations + Startmenu + InstallInfo must exist alongside +; URLAssociations, or Finicky never appears as a selectable option. +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities\FileAssociations"; ValueName: ".htm"; ValueType: string; ValueData: "FinickyURL" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities\FileAssociations"; ValueName: ".html"; ValueType: string; ValueData: "FinickyURL" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities\Startmenu"; ValueName: "StartMenuInternet"; ValueType: string; ValueData: "Finicky" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\InstallInfo"; ValueName: "ReinstallCommand"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"" --window" +Root: HKA; Subkey: "SOFTWARE\Clients\StartMenuInternet\Finicky\InstallInfo"; ValueName: "IconsVisible"; ValueType: dword; ValueData: 1 + +; ---- RegisteredApplications ---- +; The master index Windows checks to discover which apps offer URL handling. +Root: HKA; Subkey: "SOFTWARE\RegisteredApplications"; ValueName: "Finicky"; ValueType: string; ValueData: "SOFTWARE\Clients\StartMenuInternet\Finicky\Capabilities"; Flags: uninsdeletevalue + +; ---- Autostart (optional) ---- +; Launch WITHOUT --window: autostart makes Finicky the resident URL router at +; login (useful with keepRunning), it must not pop the config window on boot. +Root: HKA; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueName: "Finicky"; ValueType: string; ValueData: """{app}\{#MyAppExeName}"""; Flags: uninsdeletevalue; Tasks: autostart + +[Run] +; After install, optionally open Default Apps so the user can select Finicky +Filename: "cmd"; Parameters: "/c start ms-settings:defaultapps"; Description: "Open Default Apps settings"; Flags: postinstall shellexec skipifsilent nowait; Tasks: setdefault +; Launch Finicky after install +Filename: "{app}\{#MyAppExeName}"; Parameters: "--window"; Description: "Launch Finicky"; Flags: postinstall skipifsilent nowait + +[UninstallRun] +; If Finicky is running during uninstall, terminate it +Filename: "taskkill"; Parameters: "/F /IM {#MyAppExeName}"; Flags: runhidden; RunOnceId: "KillFinicky" + +[UninstallDelete] +; Clean up the IPC socket and cache +Type: filesandordirs; Name: "{localappdata}\Finicky" + +[Code] +// Notify Windows that registered applications changed so the Default Apps +// picker refreshes. NOT optional: without this (verified on Win11 26200), +// Settings does not see the new registration until a shell restart/logon. +procedure SHChangeNotify(wEventID, uFlags, dwItem1, dwItem2: Integer); + external 'SHChangeNotify@shell32.dll stdcall'; + +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + SHChangeNotify($08000000, 0, 0, 0); // SHCNE_ASSOCCHANGED, SHCNF_IDLIST +end; diff --git a/scripts/winget/JohnSterling.Finicky.installer.yaml b/scripts/winget/JohnSterling.Finicky.installer.yaml new file mode 100644 index 00000000..4ed45f8c --- /dev/null +++ b/scripts/winget/JohnSterling.Finicky.installer.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json +# Submitted to https://github.com/microsoft/winget-pkgs +# Update InstallerUrl and InstallerSha256 for each release. + +PackageIdentifier: JohnSterling.Finicky +PackageVersion: 4.2.2 +MinimumOSVersion: 10.0.17763.0 +InstallerType: inno +Scope: user +InstallModes: + - interactive + - silent + - silentWithProgress +UpgradeBehavior: install +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/johnste/finicky/releases/download/v4.2.2/FinickySetup-4.2.2.exe + InstallerSha256: # Fill after building the installer + InstallerSwitches: + Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART + SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART +ManifestType: installer +ManifestVersion: 1.6.0 diff --git a/scripts/winget/JohnSterling.Finicky.locale.en-US.yaml b/scripts/winget/JohnSterling.Finicky.locale.en-US.yaml new file mode 100644 index 00000000..0981cbf6 --- /dev/null +++ b/scripts/winget/JohnSterling.Finicky.locale.en-US.yaml @@ -0,0 +1,26 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json + +PackageIdentifier: JohnSterling.Finicky +PackageVersion: 4.2.2 +PackageLocale: en-US +Publisher: John Sterling +PublisherUrl: https://github.com/johnste +PackageName: Finicky +PackageUrl: https://github.com/johnste/finicky +License: MIT +LicenseUrl: https://github.com/johnste/finicky/blob/main/LICENSE +ShortDescription: A rule-based browser routing utility for Windows and macOS. +Description: |- + Finicky lets you set up rules that decide which browser opens for every URL. + Route any URL to your preferred browser with powerful matching rules, automatically + edit URLs before opening them, and write rules in JavaScript or TypeScript for + complete control. Set Finicky as your default browser and it routes each link to + the right place based on domain, path, or source application. +Tags: + - browser + - browser-router + - default-browser + - url-handler + - productivity +ManifestType: defaultLocale +ManifestVersion: 1.6.0 diff --git a/scripts/winget/JohnSterling.Finicky.yaml b/scripts/winget/JohnSterling.Finicky.yaml new file mode 100644 index 00000000..203d24cf --- /dev/null +++ b/scripts/winget/JohnSterling.Finicky.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json + +PackageIdentifier: JohnSterling.Finicky +PackageVersion: 4.2.2 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.6.0