chore(deps): bump softprops/action-gh-release from 2 to 3#1
Closed
dependabot[bot] wants to merge 51 commits into
Closed
chore(deps): bump softprops/action-gh-release from 2 to 3#1dependabot[bot] wants to merge 51 commits into
dependabot[bot] wants to merge 51 commits into
Conversation
Initial product requirements document for LinkPilot, a macOS-first link / browser / profile / workspace router. Covers personas, system architecture (URL handler + daemon + browser extension + native messaging host), routing context/decision model, rule DSL, plugin permission strategy, OAuth/loop edge cases, MVP scoping, and Arc Space caveats.
Stand up the v0.1 step-1 skeleton agreed in the implementation plan: - Cargo workspace (9 crates) with platform-trait split - linkpilot-core: routing engine + rule model + config schema + protocol; 3 router unit tests pass - platform-mac with real crate layout and stub trait impls; win/linux re-export StubProvider so the workspace compiles everywhere - linkpilot-ipc: endpoint resolver and length-prefixed JSON framing - lp CLI, native-host bridge, headless-daemon placeholder - apps/desktop Tauri 2 shell with tray config, capabilities, Info.plist template for http/https scheme registration, and placeholder frontend - README and v0.2+ directory READMEs for extension and config DSL cargo check --workspace --exclude linkpilot-desktop passes on Linux; linkpilot-desktop needs GTK/WebKit dev libs (Linux) or Xcode CLT (macOS), documented in README.
Slice steps 4–5 of the v0.1 plan: skip the IPC server for now and let the CLI talk straight to the on-disk config and the macOS launcher. This is the smallest demoable path before the Tauri daemon's socket layer exists. Highlights: - core::config restructured into a module; ConfigStore handles atomic write, default-path resolution per OS, and seeds first-run with the PRD §22 demo (github/notion → Chrome Default, figma/youtube → Arc) - platform-mac::MacInventory probes /Applications and ~/Applications for Chrome / Edge / Brave / Arc / Firefox / Safari; profiles parsed via linkpilot-core's shared Chromium / Firefox parsers - platform-mac::MacUrlLauncher dispatches by BrowserKind: launches Chromium binaries with --profile-directory directly, Firefox with -P, and shells Safari through `open -a` - lp open / doctor / rules list wired against ConfigStore + PlatformProvider with no IPC; lp open --dry-run prints the decision only - README documents the macOS smoke test and Linux cross-check (rustup target add x86_64-apple-darwin) Verified: - cargo check --workspace --exclude linkpilot-desktop (Linux) - cargo check --target x86_64-apple-darwin -p linkpilot-platform-mac -p linkpilot-cli - cargo test -p linkpilot-core (5/5 incl. new ConfigStore tests)
Push everything that v0.1 needs end-to-end so it can be exercised on macOS: Rust: - core::history: 1000-entry RouteHistory ring buffer with newest-first reads - core::config::store: ConfigStore is now Clone-able (Arc<Mutex<State>>), with `watch()` returning a notify::RecommendedWatcher that distinguishes echoes (same writer token) from real external edits and only re-publishes the latter - core: notify added as a workspace dep - 3 new ConfigStore + RouteHistory unit tests (8/8 pass) Tauri daemon (apps/desktop/src-tauri): - AppState owns ConfigStore + RouteHistory + Arc<dyn PlatformProvider> + the watcher handle so the watcher lives as long as the app - Deep-link plugin registered for http/https; on_open_url builds a RoutingContext, runs the router, fires the launcher, and emits `route-logged` - fsnotify watcher emits `config-changed` (external origin only) - Commands: config_get/replace, rule_upsert/delete, list_browsers/profiles, route_evaluate/open/history, is_default_browser, request_set_default, doctor, import_config, export_config - Tray icon with Show / Quit menu items keeps daemon alive after window close Frontend (apps/desktop/src): - Vite + React + TypeScript + @tauri-apps/api wired through package.json, tsconfig.json, vite.config.ts - Typed IPC wrappers in lib/ipc.ts mirror every Rust command - Five pages: Overview (status + recent routes + URL evaluator), Rules (priority list + JSON editor), Inspector (live decision stream), Browsers (cards with profiles), Settings (set-as-default, autostart, import/export) - macOS-styled CSS with dark-mode variants CLI: `lp` updated for the new `document() -> ConfigDocument` API. Verified: - cargo check --workspace --exclude linkpilot-desktop (Linux native) - cargo check --target x86_64-apple-darwin -p linkpilot-platform-mac -p linkpilot-cli - cargo test -p linkpilot-core (8/8 incl. history + config tests) - (cd apps/desktop && npm install && npm run build) → tsc + vite OK Outstanding for the user to verify on macOS: - `cargo tauri dev` boots the GUI with deep-link registration - Setting LinkPilot as default browser via System Settings makes it appear - icons need replacing via `cargo tauri icon <png>`
macOS Cocoa wiring: - platform-mac::MacDefaultBrowser now calls LSSetDefaultHandlerForURLScheme for http + https via the ApplicationServices framework (core-foundation for CFString interop). is_linkpilot_default reads back the current handler and compares to LinkPilot's bundle id. - platform-mac::MacAutostart writes / removes a LaunchAgent plist at ~/Library/LaunchAgents/<bundle-id>.plist that points at current_exe(). Works without code signing; SMAppService migration is a v0.2 concern. - MacProvider is now parameterised by bundle_id. The Tauri app reads app.config().identifier; the CLI uses MacProvider::default() which falls back to "app.linkpilot.desktop". IPC server: - linkpilot-ipc grows server.rs (tokio multi-thread runtime, Unix-socket listener, per-connection task) and client.rs (blocking send() that builds a current_thread runtime per call, 5s timeout). - Daemon-side handler lives in apps/desktop/src-tauri/src/ipc_host.rs and reuses the same router / launcher / config code the Tauri commands call. AppState parks the ServerHandle alongside the fsnotify watcher so both outlive setup. - transport.rs gains async read_frame/write_frame helpers on the length-prefixed JSON wire format. CLI: - `lp open|doctor|rules list` prefer the running daemon and gracefully fall back to local execution when the socket is missing. `--local` forces the fallback path. Verified: - cargo check --workspace --exclude linkpilot-desktop (Linux) - cargo check --target x86_64-apple-darwin -p linkpilot-platform-mac -p linkpilot-cli - cargo test -p linkpilot-core (8/8 still pass)
- New RuleEditor component: recursive MatcherTree builder (all/any/not + 5 leaf ops) and Action picker with cascading browser/profile dropdowns from the live inventory. - Inline validation: empty patterns and missing target browsers disable Save with a human-readable issue list. - Rules page now lists rules with an "Edit" affordance per row, an "Add rule" button at the top, and the JSON editor preserved under an "Advanced: raw JSON" collapsible card for power users. - Frontend builds cleanly (tsc --noEmit + vite); Rust core tests still 8/8. Addresses the v0.1 polish item called out in README.md (replacing the JSON-textarea rule editor with a structured form) — Persona A/B/C can now create their first rule without touching JSON.
- Source artwork in docs/brand/: full-color brand mark (icon.png, 1254×1254 "P-compass") + a simplified single-color SVG for the menu-bar (tray-template.svg). - Regenerated the full bundle matrix via `npx tauri icon` — replaces the 1×1 transparent placeholders for macOS / iOS / Android / Windows / Linux. - Rendered the menu-bar template at 22 / 44 / 66 pt with rsvg-convert so iconAsTemplate tinting (active / inactive, light / dark) works correctly; using the full-color icon there would render as a solid blob. - apps/desktop/src-tauri/icons/README.md documents how to regenerate both matrices; top-level README updated to reflect "v0.1 feature-complete". - `cargo check -p linkpilot-desktop` passes against the new assets.
Closes the v0.2 "rule explain" item from the PRD §18 KPIs ("user can
explain route reason"). Single source of truth lives in linkpilot-core;
the frontend renders the structured trace.
Rust core:
- New `MatcherEval` mirrors `MatcherTree` with `matched: bool` on every
node (serde-tagged "op").
- `Router::evaluate_explained(ctx) -> Explained { decision, explanation }`
evaluates each enabled rule's full tree and returns the eval tree of
the rule that won. `Router::evaluate` is preserved as a thin wrapper
that drops the explanation, so existing callers (CLI, RouteEvaluate
IPC) compile unchanged.
- `RouteRecord::with_explanation` attaches the eval tree to history;
`RouteRecord::new` still works for the 2-arg case. `explanation`
is `#[serde(default)]` so older serialized records keep loading.
- Two new tests cover an AND with both children matching and the
no-rule-fired default-target fallback.
Tauri callsites (`route_open`, `url_handler::dispatch_system_url`, IPC
`Request::RouteOpen`) now call `evaluate_explained` and propagate the
trace into the emitted `route-logged` event.
Frontend:
- TypeScript `MatcherEval` mirrors the Rust enum; `RouteRecord` grows
an optional `explanation` field.
- Inspector now renders a structured "Why this decision" tree per
selected route: ✓ / ✗ tag on every node, indented children, matched
rule's priority + note on top. Raw JSON moved behind a "Show" toggle.
Verification:
- `cargo test -p linkpilot-core` — 10/10 (was 8/8).
- `cargo check --workspace --exclude linkpilot-desktop` clean.
- `cargo check -p linkpilot-desktop` clean.
- `npm run build` clean (tsc --noEmit + vite).
The Tauri CLI is a npm devDependency (`@tauri-apps/cli` in apps/desktop/package.json). The previous instructions said `cargo tauri dev`, which fails with "no such command: `tauri`" unless the user also runs `cargo install tauri-cli`. Drop the unnecessary cargo path and the global `npm install -g`; use `npx tauri` (or `npm run tauri -- …`) consistently.
…efix Tauri 2 executes beforeDevCommand from the directory containing tauri.conf.json's parent (i.e. apps/desktop/), not from src-tauri/. So `npm --prefix .. run dev` resolves to apps/ — which has no package.json — and fails with ENOENT. Use `npm run dev` / `npm run build` directly; they find apps/desktop/package.json correctly.
…click Three overlapping bugs prevented the GUI from ever appearing: 1. Both tauri.conf.json (\`trayIcon\`) and tray.rs (\`TrayIconBuilder\`) spawned a tray. The config tray had the real artwork but no click handler; the code tray had the handler but no icon (used the default app icon, looked broken). Clicking the visible icon did nothing. Fix: drop \`trayIcon\` from the config, load icons/tray.png in code via \`Image::from_bytes(include_bytes!(…))\` (needs tauri's \`image-png\` feature), bind \`.icon_as_template(true)\` so macOS tints it correctly. 2. The main window was created with \`visible: false\` and there was no code to show it on launch — first-time users had no GUI at all. Fix: \`tray::show_main_window\` is called once during setup so the window appears immediately. 3. Closing the window destroyed it, so the tray click handler was show()-ing a dead window. Fix: intercept \`WindowEvent::CloseRequested\` and call \`window.hide()\` instead. Also wire \`RunEvent::Reopen\` on macOS so clicking the Dock icon while the window is hidden brings it back — Tauri does not do this for you. \`show_main_window\` now also unminimizes, in case the user minimized before closing.
The Apple Event delivered to tauri-plugin-deep-link drops the sender, so RoutingContext.source.app_name was always None — meaning source-app rules (PRD §22 demo: \`github.com from Slack → Chrome Work\`) could never match. This wires up real opener detection. How it works: - MacOpenerDetector::start spawns a background thread that polls \`NSWorkspace.frontmostApplication\` every 750ms via objc2-app-kit and pushes (name, bundle_id) onto a small ring (capacity 8), skipping LinkPilot's own bundle id. Duplicate consecutive entries just refresh the timestamp so a long-frontmost app stays "recent" without bloat. - url_handler::dispatch_system_url calls detect() before building the RoutingContext. By the time the URL handler runs, LinkPilot is usually already frontmost (macOS activated it to deliver the Apple Event), so we deliberately look at the most-recent non-LinkPilot entry within the last 30 seconds — that's the actual opener. - Polling at 750ms misses the absolute worst case (user switches to Slack and clicks a link in the same ~750ms window) but covers the typical "I'm in Slack, click a link" flow reliably. The Inspector already renders \`source.app_name\` in RouteSummary, so real opener info now appears with no frontend changes. Deps: objc2 0.5, objc2-foundation 0.2 (NSString), objc2-app-kit 0.2 (NSWorkspace + NSRunningApplication). Target-specific (macOS only). processIdentifier is left at None for now — bundle_id is what rules match on.
A new "Test URL" sidebar tab that runs the live router over a URL + simulated source without launching a browser. Edit a rule in Rules, switch back, and the decision + ✓/✗ explanation tree update in 250ms (debounced). Closes the "make rule authoring fully GUI-driven" gap that sat next to the structured rule editor — no more reaching for \`lp open --dry-run\` to verify a regex. Backing changes: - \`Explained\` in linkpilot-core gains Serialize/Deserialize so the Tauri \`route_evaluate\` command can return decision + explanation in one round-trip (was just RoutingDecision). - \`RouteRequest\` (Tauri side) grows \`from_browser\` and \`from_profile\` fields. \`build_context\` picks SourceKind::BrowserExtension when a source browser is supplied, otherwise SourceKind::System — so source-browser / source-profile matchers can be exercised from the panel. - The explanation renderer is hoisted into a shared \`components/Explanation.tsx\` and reused by both Inspector and Test URL pages — single source of truth for the ✓/✗ tree shape. - The mini "Test a URL" widget on Overview is removed (the dedicated tab supersedes it). Overview now points to the Test URL tab. Verification: - \`tsc --noEmit + vite build\` clean (43 modules → 173 kB). - \`cargo check -p linkpilot-desktop\` clean. - \`cargo test -p linkpilot-core\` 10/10.
The default_target was the last config field that still required JSON hand-editing. Now it has the same browser + profile + incognito cascade used by rule \`open\` actions — change a dropdown and the config file is rewritten immediately (atomic write + anti-echo token in the existing config store handles concurrency). - Extracted the cascading TargetEditor out of RuleEditor into a shared \`components/TargetEditor.tsx\` so the rule editor and the new Settings card are the same widget. No behavior change for rule editing. - Added a "Default target" card to Settings.tsx (between "Default browser" and "General"). Loads installed browsers once on mount and on configEpoch bumps. - Rules.tsx default-target card now points users to Settings instead of telling them to JSON-edit. Verification: - \`npm run build\` clean (44 modules → 173 kB).
Holding any rule row and dragging it over another shows a blue drop indicator (top half = drop before, bottom half = drop after). On drop the priorities are restamped: N*10 for the new top, (N-1)*10 for the next, etc. — step of 10 leaves room to nudge a single rule by hand through the editor if needed. Whole-row draggable (with an explicit ⋮⋮ handle on the left as the discovery affordance); inner Edit/Delete buttons still receive their own clicks because button clicks don't initiate a drag. Implemented with native HTML5 DnD — no extra dependency. The drop indicator is a 2px pseudo-element (\`.drop-before::before\` / \`.drop-after::after\`), so the visual lives in CSS, not in the React tree. Edge cases handled: - dragging onto self → no-op - releasing outside any row → onDragEnd clears state, no commit - onDragOver only previews drop slot; commit happens only in onDrop on the actual target. Verification: - \`npm run build\` clean (44 modules → 175 kB, +1.6 kB).
Two bugs were stacking: 1. React stale-closure: onDragStart set draggedId via setState, but the very next onDragOver fired before the re-render committed and read the old null draggedId — so its preventDefault() guard short-circuited and the browser flagged the row as "not droppable". The fix is to stop using React state as the source of truth for the dragged id. We now stash the id on dataTransfer with a custom MIME (application/x-linkpilot-rule-id) and read it back in onDrop. State is kept only for the visual indicator (where a one-frame lag is harmless). 2. WKWebView (Tauri's webview on macOS) treats click+drag on a row as a text selection by default, swallowing the dragstart event before it can fire. Adding \`user-select: none\` (+ -webkit-) to .rule-row tells the engine "no text selection here", which is exactly what makes HTML5 DnD work in Safari-family engines. onDragOver now always allows drop as long as the dataTransfer carries our MIME — filters out unrelated drags (URL drops, text selections) without depending on React state. onDrop recomputes the before/after slot from the cursor at drop time, so the commit is correct even if the indicator state lagged.
Previous attempt used a custom MIME on dataTransfer to identify the
dragged rule, but WKWebView (Tauri on macOS) does NOT expose custom
MIME types in dataTransfer.types during dragover — only in onDrop via
getData(). So the dragover guard
\`types.includes("application/x-linkpilot-rule-id")\` always returned
false, preventDefault() never ran, and the row was flagged as not
droppable: no blue indicator, no drop event.
Fix: stash the dragged id in a useRef. The ref is synchronous (no
React stale-closure) and survives across renders, so onDragOver and
onDrop both see the current value with no WebKit dance. State is kept
only to drive the visual indicator (a one-frame lag there is harmless).
Also set \`text/plain\` on dataTransfer purely to satisfy Firefox's
"drag must carry a payload" requirement; we never read it.
Reports of drag visuals working (green + cursor, no text selection) but the drop never reordering suggest WKWebView is firing dragend before drop — clearing the ref the drop handler needs. This commit: - Splits clearDrag into clearVisuals (state only) and clearDrag (state + ref). onDragEnd now calls clearVisuals — the ref is cleared only inside onDrop, after the commit (or its bailouts). dragend never steals the ref. - Adds console.log at start / drop / end / commit so the actual event order and source/target ids are visible in the dev console. Will be removed once the bug is confirmed fixed.
Console output from a user test showed:
[dnd] start <id>
[dnd] end (ref still: <id>)
— with no [dnd] drop in between, even though the ref survived to
dragend. That's WKWebView's tell: dragover preventDefault alone isn't
enough; WebKit requires dragenter to also be canceled before it marks
a row as a valid drop target. The W3C spec is more permissive ("if the
most recent dragenter OR dragover was canceled" → drop fires) but in
practice WebKit wants both.
Adds onDragEnter to RuleRow that mirrors the dragover gate and calls
preventDefault. Also widens the diagnostic logging so we can see the
event sequence on the next test: enter / over (only on target change) /
drop / end. Logs will be stripped once the user confirms reordering
works.
Root cause of the never-firing drop events: Tauri's window \`dragDropEnabled\` defaults to true, which makes Tauri intercept the OS-level drag-drop pipeline (for handling file drops INTO the app). That interception swallows dragenter / dragover / drop events *before they reach the WebView*, so HTML5 DnD inside the page silently breaks. dragstart still works because it's a renderer-side event; dragend still works because it's the OS's "cancel" path when no target accepted the drop. Setting \`dragDropEnabled: false\` on the main window lets the OS pass drag events through to the WebView normally. We don't accept any file-drops in LinkPilot, so disabling this loses nothing. The dragenter handler, ref-based source tracking, and dragend/drop race defense from the previous attempts all stay — they were correct guards for separate WebKit quirks we'd have hit later. Removed the diagnostic console.log calls now that the real cause is identified.
…ggle
Slice 1 of the UI overhaul. Doesn't touch the CSS framework (that's
Slice 2); focuses on quick wins that are independent of shadcn/Tailwind.
1. New brand artwork
- docs/brand/icon.png replaced with the cleaner "P + paper plane"
(1254×1254). Full bundle matrix regenerated via \`npx tauri icon\`.
- apps/desktop/src/assets/brand.png is a 128×128 downscale used as
the in-app sidebar logo (saves shipping the 1MB master in the JS
bundle for a 22pt render).
2. Sidebar brand mark
- The sidebar now opens with the LinkPilot logo + name in a flex
row, rendered above the nav items.
3. Nav icons via lucide-react (+7 KB gz)
- Overview / Rules / Test URL / Inspector / Browsers / Settings
each get an icon (LayoutDashboard / Workflow / FlaskConical /
ScrollText / Compass / Settings).
4. Disabled horizontal rubber-band scroll
- html, body, #root: overflow hidden + overscroll-behavior none.
- .content keeps vertical scroll only with overscroll-behavior:
contain so child scrolls don't bubble into the window.
5. Title-bar fusion
- tauri.conf.json: \`titleBarStyle: "Overlay"\`, \`hiddenTitle: true\`,
and \`trafficLightPosition: {x:16,y:18}\` slot the macOS traffic
lights into the sidebar's top-left corner with no title text.
- .sidebar gets padding-top: 56 to clear the lights and is now a
-webkit-app-region: drag handle, with no-drag opt-outs on the
nav buttons.
6. Light / Dark / System theme
- New lib/theme.ts: useTheme() hook + bootstrapTheme() called from
main.tsx before render to avoid a light-flash on dark-mode boot.
- Persisted in localStorage (theme is a per-machine preference, not
part of the synced config).
- CSS variables now select on [data-theme] instead of
prefers-color-scheme — the hook flips the attribute and the system
media query is subscribed when mode === "system".
- Settings → new "Appearance" card with the System / Light / Dark
dropdown. The current resolved theme is shown inline when System.
Verification: \`tsc --noEmit + vite build\` clean (1760 modules → 182 KB
JS, 16 KB brand image, 4.4 KB CSS). \`cargo check -p linkpilot-desktop\`
clean against the new tauri.conf.json fields.
Replaces docs/brand/icon.png with the latest revision (compass ring + triangular nav points around the P-plane mark). Rebuilds the full Tauri bundle matrix and the 128px in-app sidebar brand via the documented commands. No code changes; assets only.
Migrates every page off the hand-written CSS in styles/app.css onto a proper component system. The visual language is now consistent (cards, buttons, inputs, selects, tooltips, badges all share design tokens) and the Rules row Edit/Delete buttons are icon-only with tooltips. Stack: - Tailwind v4 via @tailwindcss/vite (no postcss.config or tailwind.config needed; CSS-first @theme block). - shadcn/ui-style hand-written primitives in components/ui/ (Button, Card, Input, Textarea, Label, Select, Checkbox, Badge, Tooltip). Built on @radix-ui/react-{slot,label,select,checkbox,tooltip,dialog} + class-variance-authority + clsx + tailwind-merge. - @ → ./src path alias in tsconfig and vite.config; all imports updated. - Design tokens: macOS-flavored palette as HSL CSS vars on :root and .dark, exposed to Tailwind via @theme inline. Theme.ts toggles a .dark class on <html> (in addition to the legacy data-theme attribute) so shadcn's dark: variant works out of the box. - Custom variant @custom-variant dark (&:where(.dark, .dark *)) to map Tailwind's dark: prefix to our .dark class. Pages migrated: - App.tsx — sidebar uses Tailwind utilities, brand-mark stays, nav buttons carry the lucide icon + label. TooltipProvider wraps the whole app. - Settings — split into Card sections; Theme dropdown uses Radix Select via shadcn primitive; Launch-at-login uses Radix Checkbox. - Rules — list rows use a divide-y Card; drag-to-reorder kept (ref + dragenter preventDefault from before), GripVertical replaces ⋮⋮ ASCII; Edit/Delete are icon-only Buttons with Radix Tooltip. Advanced JSON uses shadcn Textarea. - Test URL — three-column "from app / from browser / from profile" row uses Selects from shadcn; result region reuses the shared ExplanationView. - Inspector — list of routes is a clickable Card with hover + selected states; selected detail uses SummaryRow layout. - Browsers — each entry is a Card with Profile sub-list. - Menu Bar (Overview) — Status / Recent routes as Cards. DecisionLine re-exported here using Badge variants (default/accent/destructive/ secondary) instead of ad-hoc tag classes. Component changes: - TargetEditor — same cascade, now uses shadcn Select. Uses sentinel "__any" string because Radix Select disallows empty value. - RuleEditor — every form element is a shadcn primitive; the matcher tree indents via Tailwind border-l-2 ml-3 pl-3. "+ Add child" uses outline Button with a Plus icon; "Remove" is a ghost icon Button. - Explanation — ✓/✗ are now lucide Check/X inside colored circle badges instead of pill tags. CSS: - styles/app.css fully rewritten: @import "tailwindcss" + @theme tokens + base layer. Old .row/.card/.matcher etc. classes are gone. vite-env.d.ts: declare module "react" now sits below `export {}` so it augments rather than replaces React's type exports — without that, every useState/useCallback import broke type-checking in tsx files. Bundle: - JS 314 KB (was 175 KB) — Radix UI primitives. CSS 27 KB. - Both well within budget; tsc --noEmit + vite build clean; cargo check and core tests still green.
Slice 3 of the UI overhaul. The Browsers, Inspector, and TargetEditor
surfaces now show each app's actual icon next to its name instead of a
text-only label, finally closing PRD §16.2 ("Route Inspector shows
source app").
How it works:
1. crates/platform-mac/src/app_icon.rs resolves bundle id → .app via
\`mdfind kMDItemCFBundleIdentifier == "…"\` (Spotlight, ~10ms cached),
reads CFBundleIconFile from Contents/Info.plist via \`plutil\`, then
converts the .icns to a sized PNG with \`sips -s format png -Z N\`.
PNGs are cached to ~/Library/Application Support/LinkPilot/icons/
keyed by bundle id (or sanitized .app path when only a path is known).
2. A new \`app_icon\` Tauri command reads the cached PNG and returns it as
a base64 data URL. Non-macOS targets return null so the cross-platform
handler list still compiles.
3. <AppIcon bundleId / appPath /> in components/AppIcon.tsx wraps a
module-level Map cache + an in-flight Promise dedupe so a Inspector
page rendering 200 history rows only fans out one IPC per distinct
app. Falls back to lucide's AppWindow icon while loading or on
failure — UI stays usable even when extraction fails.
Wired into:
- Browsers page: each browser card gets a 32pt icon next to the name.
- Inspector: source app gets its icon both in the list rows (14pt) and
the selected-route detail (18pt).
- TargetEditor: the browser dropdown items render a 16pt icon alongside
the display name, so picking the target for a rule's "open → X" or
the default_target is now visual.
For browsers the icon is loaded via the .app path derived from the
executable (\`/Applications/Foo.app/Contents/MacOS/…\` → \`/Applications/Foo.app\`),
which works even when InstalledBrowser.platform_app_id isn't populated.
Deps: base64 0.22 on the Tauri crate (only place that encodes).
Verification:
- \`cargo check -p linkpilot-desktop\` clean.
- \`cargo test -p linkpilot-core\` 10/10.
- \`tsc --noEmit + vite build\` clean (JS 317 KB, +2 KB for AppIcon).
With \`titleBarStyle: "Overlay"\` macOS hides the title bar but does not auto-mark anything in the window as a drag region. The sidebar already had \`-webkit-app-region: drag\` on its body, so dragging the LEFT side of the title-bar zone worked. The right side — the empty 48px gap above the page content — was a dead zone the user couldn't grab. Split <main> into a 32px drag strip on top + the existing scrollable content below it (pt reduced from 12 → 4 to keep total top padding unchanged). Now any empty pixel in the top 32px of the window moves the window, just like a native title bar.
… area Last fix made a 32px drag strip at the top of <main>, but the visible "title bar area" the user expects to drag from extends further down (the empty space above the page heading is ~64px tall — drag strip + scroll container padding combined). Below y=32 the cursor was already over the scroll container's pt-4 padding, which has no drag region. Bump the strip to h-16 (64px) and zero out the scroll container's top padding. Same total top space; whole thing is now one continuous drag region matching what the user sees as "the title bar".
The CSS \`-webkit-app-region: drag\` inline style turned out to be unreliable in Tauri 2's WKWebView — repeated user reports of "the title bar area doesn't drag the window" even after the strip was correctly sized and positioned. Tauri 2 ships an injected mousedown handler that listens for the \`data-tauri-drag-region\` HTML attribute on event.target and calls window.startDragging() directly via the IPC bridge. Switch to that attribute as the canonical drag mechanism. Layout change: - One absolutely-positioned drag region overlays both columns at the top of the window (h-14 = 56px, z-40). Covers the traffic-light row plus the empty space to their right and the equivalent strip above main's content. macOS renders the traffic lights in its compositor above the webview, so the overlay does NOT interfere with their clicks. - Remove the in-aside CSS-drag rule and the per-button no-drag opt-out — \`data-tauri-drag-region\` is exact-match on event.target, so buttons (children of aside, not having the attribute themselves) receive their clicks normally without any opt-out boilerplate. - Drop the split-main flex layout from the previous attempt — main is back to a single scrollable container with pt-12 (48px top padding). The drag overlay above provides the title-bar surface, and the padding gives the page heading its breathing room. This should finally make "drag the window by clicking the top strip" behave like every other macOS app.
Tauri 2's drag-region script (triggered by data-tauri-drag-region clicks)
calls window.startDragging() on single-click and toggleMaximize() on
double-click. Both go through Tauri's command IPC and require explicit
permission in capabilities/default.json. The base \`core:window:default\`
does NOT include them — clicking the drag strip threw:
Unhandled Promise Rejection: window.start_dragging not allowed.
Permissions associated with this command:
core:window:allow-start-dragging
Add the two missing permissions. cargo check confirms the schema is
still valid (tauri-build re-validates capabilities on every build).
…lly runs
App icons never appeared in the UI because every extraction failed at
the plist step. \`plutil -extract X json\` requires the extracted value
to be a JSON-serializable top-level object/array, but CFBundleIconFile
is a bare string (e.g. "AppIcon" for Safari, "app.icns" for Chrome),
which trips:
/Applications/Safari.app/Contents/Info.plist:
Invalid object in plist for JSON format
Switch to \`-extract … raw\` which prints the string content with no
quoting — exactly what locate_icns() then resolves against
Contents/Resources/.
Also lock the window size while we're here: maxWidth/maxHeight = the
initial 980×680, resizable: false, maximizable: false. Drops the
implicit "drag resize" affordance from the bottom-right corner. The
allow-toggle-maximize ACL permission is kept so the drag-region
double-click is a quiet no-op (vs. throwing a permission rejection)
when the user double-clicks the title strip.
End-to-end verified manually:
sips -s format png -Z 64 /Applications/Safari.app/.../AppIcon.icns
→ /tmp/x.png: PNG image data, 64 x 64, 8-bit/color RGBA
Same flow works for Chrome (\`app.icns\`) and Arc (\`AppIcon\`).
Closes the long tail of "raw lowercase browser id" surfaces. Anywhere
the UI used to print "open → arc/Personal" or "chrome" verbatim, it now
shows the OS-extracted icon + the inventory's display_name + an optional
profile suffix. If a rule references a browser that's not currently
installed (e.g. user has Chrome in config but uninstalled it), the id
is shown with its first letter capitalised as a graceful fallback.
New shared building blocks:
- lib/browsers.ts:
- useBrowsers(): subscribes to a module-level cache populated by a
single ipc.listBrowsers() call. All <BrowserBadge> instances re-use
the same inventory list and re-render together when it lands.
- appPathFromExecutable(): /Applications/X.app/Contents/MacOS/X →
/Applications/X.app. Was duplicated across browsers.tsx,
TargetEditor.tsx, test-url.tsx; now imported from one source.
- browserDisplayName(id, inventory): inventory-aware lookup with a
capitalize-first-letter fallback.
- components/BrowserBadge.tsx: icon + display name (+ optional profile).
The single component every "show a browser" surface now uses.
Wired into:
- pages/rules.tsx — RuleRow's action column is now <ActionDisplay/>
rendering JSX (was the describeAction text fn); shows BrowserBadge
for open actions. The Default-target header card uses BrowserBadge too.
- pages/menu-bar.tsx — DecisionLine's "open → X" branch swaps the raw
font-mono span for BrowserBadge. This propagates to Inspector list
rows, Inspector selected detail, Test URL result row, and Overview
recent-routes — all of which call DecisionLine.
- pages/test-url.tsx — the "From browser" Select dropdown items now
show the icon next to the display name (mirrors TargetEditor's
existing pattern).
- components/RuleEditor.tsx — the source-browser matcher leaf's
dropdown also gets icons (previously plain "Display name (id)" text).
Bundle delta: +1.3 KB JS (BrowserBadge + useBrowsers hook).
Default WebKit chunky scrollbar ran from the top of <main> (right up under the title-bar drag strip) to the very bottom edge of the window — visually loud and overlapped the drag handle hit zone. Move the overflow off <main> onto an inner div, give <main> its own top/bottom padding instead of stuffing it inside the scroller, and style the scrollbar: - 8px wide, transparent track - thumb appears only when the scroll region is hovered (mimics macOS overlay scrollbars) - thumb-on-thumb hover bumps to muted-foreground/50 Effect: the scrollbar lives strictly inside the content area, doesn't touch the title strip, and visually fades away when not in use.
Default WebView behavior is "everything is selectable text", which makes the app feel like a web page: I-beam cursor on every label, blue highlight when you drag across a card title, etc. Real macOS apps don't let you select labels — only fields and code blocks accept selection. Globally disable user-select on html/body/#root + force the default cursor, then re-enable on: - <input> / <textarea> / [contenteditable=true] — typing surfaces - <pre> / <code> — JSON viewers - .select-text (and descendants) — opt-in escape hatch Opt-in `select-text` added on the few values users genuinely want to copy: - Settings / Overview → config file path + daemon version - Inspector list rows + selected detail → URL - Overview recent-routes URL - Browsers → executable path Everything else (page headings, descriptions, rule labels, route metadata, button labels) is now uncopyable — and the cursor stays as a regular arrow over them. Matches the native macOS UX.
…e dialog Routing a URL to Arc while Arc was already running tripped Arc's own single-instance check: Arc is already open. Only one instance of Arc can be opened at a time. That dialog is from Arc itself (not macOS), thrown whenever its binary is invoked a second time. The Chromium siblings (Chrome / Edge / Brave) all detect a running instance via shared lock and route the URL internally, so direct-exec works for them — Arc is the odd one out. Split BrowserKind::Arc off from BrowserKind::Chromium in the URL launcher: Arc now uses \`/usr/bin/open -a Arc <url>\`, which delivers the URL to the running instance via Apple Events (the path \`open\` uses for every other native app). We don't try to inject --profile-directory — Arc doesn't honour it from CLI anyway; per PRD §23 Space / profile routing inside Arc is owned by Air Traffic Control. The BrowserTarget.profile field is silently ignored for Arc; a future Arc adapter could surface this with strategy: "air-traffic-control" if we want to be explicit. Chrome / Edge / Brave path is unchanged — direct binary call still delivers URLs to a running instance AND respects --profile-directory, which is the only way to externally target a specific Chrome profile.
A rule whose action is \`ask\` was being evaluated correctly (Inspector
showed the Ask decision), but the URL launcher path only handled \`Open\`
and silently dropped Ask / Allow / Block. So routing "Lark → ask" did
nothing at all in practice.
Implements the Ask UX with macOS native AppleScript \`choose from list\`:
- crates/platform-mac/src/prompt.rs::pick_browser builds an applescript
list literal from the candidate labels and runs it under osascript,
returning Some(picked_label) or None on cancel. Same chooser macOS
uses for "Open With…" — appears centered on the active screen even
when LinkPilot isn't frontmost, which is the realistic case because
Ask fires when the user just clicked a link in Lark / Slack / Mail.
- new apps/desktop/src-tauri/src/dispatch.rs::execute is the one place
every URL-launch entry point (url_handler, route_open command, IPC
RouteOpen) delegates to. It matches on the decision:
* Open → launch the rule's target (unchanged behaviour)
* Ask → resolve candidates (fall back to every installed
browser when the rule didn't pre-populate any),
prompt, launch the picked one
* Allow/Block → no-op
Returns a LaunchOutcome enum so callers can distinguish a real
failure (bad URL, launcher error) from user-cancelled / intentional
skip.
The Ask branch maps the picked label back to the BrowserTarget via a
linear lookup; with ≤ a dozen browsers per machine that's free.
Cross-platform: prompt.rs lives in linkpilot-platform-mac, and the
dispatch resolver is cfg-gated to macOS — Windows / Linux fall through
to None and the Ask outcome becomes Cancelled. They'll wire a native
prompt when those platforms come online.
Verification: cargo check -p linkpilot-desktop, cargo test -p
linkpilot-core (10/10).
…ons)
Each rule row's matcher description used to be a plain text string
("host github.com", "from app Slack"). Replace with <WhenDisplay/> JSX
that picks a context-appropriate icon per leaf:
- url-host → lucide Globe + pattern
- url-path → lucide Link2 + pattern
- source-app → real macOS app icon via Spotlight + name
- source-browser → BrowserBadge (existing component) — browser logo
- source-profile → lucide User + profile id
- always → lucide Asterisk
- AND (all) → lucide Layers, recursive children with "AND" pill
- OR (any) → lucide Split, recursive children with "OR" pill
- NOT → lucide Ban (destructive tint) + nested matcher
For source-app we don't have a bundle id on MatcherTree::SourceApp
(matching is by name) so the AppIcon component grows a `name` prop
that resolves a .app path via Spotlight at the daemon, then runs the
existing icns → png pipeline. Lookup is cached at module level so a
Rules list with N rules referencing the same app fires one IPC.
Rust:
- crates/platform-mac/src/app_icon.rs:
* ensure_png() now takes an extra `name: Option<&str>` parameter
* new resolve_app_path_by_name() runs mdfind on
(kMDItemContentType == 'com.apple.application-bundle') &&
((kMDItemDisplayName == "X") || (kMDItemCFBundleName == "X"))
and prefers /Applications hits over user-scoped ones.
- commands/mod.rs::AppIconRequest gains a `name` field.
Frontend:
- AppIcon accepts {name} alongside {bundleId, appPath}; cache key
includes name so identical lookups dedupe.
- New WhenDisplay component does the JSX rendering. The old
describeWhen() text helper in rules.tsx is deleted.
Verification: cargo check -p linkpilot-desktop, vite build clean
(JS 324 KB, +5 KB for the new component + the lucide icons it pulls).
… Ask dialog
Two bugs the user hit on the install:
1. Rules row source-app icons stayed blank — the previous mdfind query
used kMDItemDisplayName / kMDItemCFBundleName, which Spotlight does
not reliably populate for Electron apps (Lark, Slack, Discord, …).
Empirically /Applications/Lark.app indexes neither; the query
returned nothing.
New resolve_app_path_by_name tries strategies in order of robustness:
a. /Applications/{name}.app (the 95% case)
b. ~/Applications/{name}.app (per-user installs)
c. mdfind kMDItemFSName == "{name}.app" (always indexed)
d. mdfind by display/bundle name (last resort, the old path)
Most apps resolve at step (a) — sub-millisecond and no subprocess.
2. The Ask chooser appeared to never show. Root cause: when LinkPilot
catches a URL event, the user's focus is still on Lark / Slack /
wherever they clicked, so osascript renders the choose-from-list
dialog BEHIND that app. Add `tell me to activate` at the top of the
AppleScript — that forces osascript's own process to the foreground
before the chooser appears.
Also added tracing::debug / info calls in prompt.rs and dispatch.rs
so the next time something looks like it didn't fire, the user can
run \`Console.app\` filtered to \"LinkPilot\" and see exactly which
decision branch ran, what candidates / target were resolved, and the
osascript exit / output.
Default muted-foreground gray made the per-matcher icons technically present but visually invisible — they read as monochrome decorations. Apply Tailwind accent colors keyed to each matcher's semantic so the type is recognizable at a glance: url-host → sky-500 (web) url-path → emerald-500 (route) source-app → real app icon (already colored) source-browser → real browser icon (already colored) source-profile → violet-500 (identity) always → amber-500 (wildcard) AND (all) → indigo-500 (composition) OR (any) → cyan-500 (alternation) NOT → rose-500 + rose-500 "NOT" text (destructive)
The picker handed back "Lark" (CFBundleDisplayName) but Lark's
NSRunningApplication.localizedName returns "Feishu" (CFBundleName) —
so a rule authored as \`from app Lark\` never fired when the user
actually clicked a link in Lark; the opener detector reported "Feishu"
and case-insensitive name comparison missed.
Fix:
- MatcherTree::SourceApp gains an optional bundle_id field
(serde(default), so older configs without it deserialize fine).
- Match logic prefers bundle_id when populated — case-insensitive
compare against ctx.source.bundle_id (opener detector already
populates this from NSRunningApplication.bundleIdentifier, which is
the stable per-app identifier and immune to display-name vs
CFBundleName quirks). Only when bundle_id is empty does it fall
back to name matching, preserving behaviour for hand-authored
rules that never went through the picker.
Frontend:
- TS MatcherTree gets the same optional field.
- AppPickerButton onPicked already returned {name, bundleId}; the
source-app leaf now stores both into the rule. Typing into the
text input clears bundle_id (the user is intentionally overriding
the picker's capture, so matching should fall back to name).
- WhenDisplay uses bundle_id for icon lookup when available (most
reliable), name as fallback.
After this, the user's existing "from app Lark" rule needs to be
re-saved via the picker to pick up the bundle id; or replaced with a
fresh rule. We don't migrate old rules because we can't guarantee
the typed name resolves to a single .app.
Verification: cargo test -p linkpilot-core (10/10), vite build clean.
The selected-route URL field rendered a multi-hundred-char URL on one
line (font-mono) past the right edge of the card and ran into the rows
below. Two flexbox traps stacked:
1. <flex-1> default min-width is auto (== content width), so the
value column expanded to fit the URL instead of shrinking. Adding
`min-w-0` lets it shrink to its parent's width.
2. Even then, an unbroken URL won't wrap without an explicit
`break-all` because there are no break opportunities in a query
string. Apply break-all to the URL span.
Also switch SummaryRow / ResultRow from items-center to items-start so
the "URL" label aligns to the top of the multi-line value instead of
floating to its visual center. Bumps the label's top padding by 2px
so it lines up with the first line of mono text.
Same `min-w-0` is added to the truncated URL spans in the Inspector
list and Overview recent-routes for symmetry — they already had
`truncate` which masked the bug, but the flex container could still
report wrong widths without it.
… window The osascript "choose from list" chooser worked but looked ugly: a generic AppKit alert with the full URL spilling onto 20 lines of mono text and plain text browser names. Replace with a custom Tauri secondary window styled like macOS's Cmd-Tab switcher: dark panel, browser icons in a row, arrow-key navigation. Backend (src-tauri/src/picker.rs): - PickerState (Tauri-managed): the session data + a oneshot sync mpsc sender. Sync mpsc fits our call-pattern — we're already on a worker thread (deep-link callback / IPC handler / Tauri command worker), so blocking for the user's decision doesn't tie up the main loop. - show_picker(app, url, choices): stash the session, open a decoration- less always-on-top 560×280 window labelled "picker", block on the channel for ≤60s, close the window, return the picked id (or None on cancel / timeout). - picker_session / picker_resolve Tauri commands wire the renderer to the session and the channel respectively. dispatch::execute now takes &AppHandle alongside &AppState and routes Ask → show_picker. The three callsites (url_handler::dispatch_system_url, commands::route_open, ipc_host::Request::RouteOpen) all already have the handle. resolve_ask builds PickerChoice entries with bundle_id / app_path / display_name harvested from the browser inventory so the renderer can re-use AppIcon's existing resolution to show real macOS icons next to each option. Frontend: - main.tsx switches on a `view=picker` query param to render <PickerWindow/> instead of <App/>. Tauri loads the same dist/ in every window, so this is the standard pattern. - src/picker/PickerWindow.tsx: ← / → / Tab navigation (wraps), Enter to confirm, Esc to cancel. Click on a tile to pick directly. The URL preview is truncated to 80 chars + ellipsis with the full URL on hover. -webkit-app-region: drag on the panel so the user can move the window if it lands inconveniently; buttons opt out via no-drag. capabilities/default.json: the picker window joins "main" in the default-capability windows list so it can invoke picker_session / picker_resolve / app_icon (for the per-tile icons). The picker window is opaque dark (bg-neutral-900) with no rounded corners — without macOS-transparent windows the rounded corners would show the underlying window background. A future polish pass can enable transparent + vibrancy after we add the macos-private-api Tauri feature.
After replacing the osascript dialog with a real Tauri secondary
window, clicking a link that triggered Ask froze the whole app for 60s
(picker timeout). Root cause: a chain of main-thread blockers.
- Sync tauri::commands run on the main thread.
- The deep-link plugin's on_open_url callback also runs on the main
thread.
- dispatch::execute called show_picker on the caller's thread, and
show_picker blocked the caller on mpsc::recv_timeout waiting for
picker_resolve.
- picker_resolve is itself a sync Tauri command → main thread →
can't run while main is parked in recv_timeout. Hard deadlock.
Fix: in dispatch::execute, when the decision is Ask, detach the entire
picker + launch flow onto std::thread::spawn. The caller (deep-link
callback / route_open / IPC RouteOpen) returns LaunchOutcome::Pending
immediately, freeing the main thread. The worker thread:
1. Calls show_picker which builds the window (Tauri dispatches that
to the main thread internally — safe from any thread).
2. Blocks on the mpsc channel (worker thread is fine to block).
3. picker_resolve runs on the now-idle main thread and signals.
4. Worker wakes, closes the window, launches the picked browser.
LaunchOutcome's Cancelled variant is removed — cancellation is now a
side-effect of the worker thread and the caller never sees it.
Verified cargo check; runtime requires a `npx tauri build` + reinstall
to land in /Applications/LinkPilot.app.
Three upgrades to the browser picker:
1. macOS frosted-glass background via window-vibrancy 0.6.
tauri.conf.json sets \`app.macOSPrivateApi: true\` and the tauri
crate gains the matching \`macos-private-api\` feature so
WebviewWindowBuilder::transparent() actually transmits transparency
through the AppKit layer. apply_glass() installs
NSVisualEffectView with HudWindow material + 16px corner radius
(matches the inner rounded-2xl), Active state so the blur stays
even when the user moves focus away briefly.
2. Position the window on whichever monitor currently contains the
cursor instead of always centering on the primary display.
center_position_on_cursor_monitor() reads app.cursor_position()
(physical px), scans available_monitors() for the one whose
bounds contain the cursor, then converts back to logical pixels
via the monitor's scale_factor and computes the top-left for a
centered 560×280 panel. Falls back to .center() if either lookup
fails (e.g. unusual multi-monitor setups).
3. Inner panel restored to \`rounded-2xl\` with a translucent
\`bg-black/30\` overlay + 1px white/10 outline — works now that the
window itself is transparent, so the rounded corners reveal the
desktop instead of the window's own background.
Punted: raising the NSWindow level above NSFloatingWindowLevel.
objc2-app-kit 0.2's NSWindow access needs a tangle of features beyond
what's exposed by default; \`always_on_top: true\` already pins above
normal windows, and most reports of "picker behind another app" come
from apps that use floating themselves (rare). Will revisit with a
proper NSWindow.setLevel hop via raw msg_send! if it actually bites.
… outline
Three follow-ups after the first picker landed looking like a flat
grey panel:
1. NSVisualEffectView was actually installed by apply_glass(), but
the webview's html / body / #root were left at their app.css base
background (hsl(--background)) — fully opaque, masking the
vibrancy. Add an .picker-root class on <html> from main.tsx when
the window's view=picker, and a global rule that forces
html/body/#root to background: transparent !important.
Now the webview is actually see-through and the vibrancy material
shows the desktop / window-behind blur.
2. Lark in full-screen Space had the picker invisible — full-screen
apps live on their own Space and normal floating windows don't
follow. New elevate_above_fullscreen() sends two msg_send! calls
to the picker's NSWindow:
- setCollectionBehavior:
NSWindowCollectionBehaviorCanJoinAllSpaces (1)
| NSWindowCollectionBehaviorFullScreenAuxiliary (256)
→ joins every Space, including full-screen ones, without
becoming full-screen itself.
- setLevel: NSStatusWindowLevel (25)
→ above NSFloatingWindowLevel where Slack / Lark floats land.
Done via raw objc2::msg_send to avoid the objc2-app-kit
NSWindow feature dance.
3. Drop the white/10 outline that read as an unintentional border
and tighten the overlay tint from /30 to /25. With the actual
blur showing now, the panel needs less artificial darkening.
deps: objc2 0.5 (target macOS only).
The previous picker landed apply_glass() and elevate_above_fullscreen() straight on the worker thread that called show_picker, so the NSVisualEffectView install + setCollectionBehavior: / setLevel: ran off-main and crashed the process the first time a real ask fired. Wrap both helpers — plus the set_focus that follows — in \`app.run_on_main_thread(...)\`. WebviewWindow is Clone (an Arc inside) so we just hand a clone to the closure. The blocking recv_timeout + the launcher.open() that follows remain on the worker thread where they belong. WebviewWindowBuilder::build() itself dispatches internally, so the window creation was already fine; only the bare-msg_send! NSWindow elevation and the third-party window-vibrancy install were unsafe off-main.
…overlay
Three pieces:
1. Picker container has tabIndex=0 (so it can receive keyboard
events). WebKit gives those a default focus ring that looks
exactly like an unintentional border — the user kept asking
"this border is what for?". Add outline-none + focus:outline-none
so the only visible edge is the rounded vibrancy panel itself.
2. HudWindow material reads as a flat grey tint. Sidebar gives the
pronounced frosted-glass look macOS uses in Notification Center /
control widgets, much closer to the Cmd-Tab feel we were after.
Also drop the bg-black/25 overlay on the inner div — the overlay
was muddying the vibrancy. Let the material carry the entire
background and rely on the existing white text for contrast.
3. The msg_send! collection-behavior change was being attempted but
by itself didn't reliably land on full-screen Spaces. Layer
Tauri's safe set_visible_on_all_workspaces(true) (sets the
CanJoinAllSpaces bit through the framework) on top of the raw
FullScreenAuxiliary + StatusWindowLevel pair. Also switch the
setCollectionBehavior arg type from u64 to usize to match
NSUInteger on 64-bit macOS exactly, which is what objc2's
msg_send signature wants.
Both apply_glass and elevate_above_fullscreen now log their result
through tracing — if a future build still misbehaves the Console can
tell us where it failed instead of "feels broken".
…y popover + custom browsers
Comprehensive UI refresh that pulls every desktop surface onto a single
design system, adds two new windows (Onboarding, Tray popover), and
extends the data model with Workspaces and user-added Custom Browsers.
Design system (apps/desktop/src/styles/app.css)
- macOS Sequoia tokens: Pilot indigo (#5057e8) accent, off-white
System-Settings content (#ececee / #1f1f22), 0.5px hairlines,
22pt page titles, 13pt body
- Component layer: .mac-card / .mac-row / .mac-tag / .mac-switch /
.mac-seg / .mac-stat / .mac-toolbar / .mac-sidebar / .mac-popover
- shadcn tokens remapped onto the same palette so legacy components
inherit the look automatically
- Fixed accent-tinted radial wash on the main window (no per-page
switching) inspired by Onboarding Step 1
Desktop shell (apps/desktop/src/App.tsx)
- Pilot wordmark + 22pt brand mark; sidebar padding clears macOS
traffic lights
- Workspaces section in sidebar with real on/off dot, hit-count
chip, and click-to-open workspace detail
- Footer daemon status dot
- Tab + workspace view union: tab tabs and workspace detail are
mutually exclusive in the right pane
- Listens for tray:navigate to deep-link from the tray popover
Page redesigns
- Overview (menu-bar): stat grid (Routes today / Active rules /
Browsers) + Status card with bolt/window/globe/doc rows + Recent
routes; LinkPilot brand icon next to the "default browser" value
- Browsers: System-Settings-shaped card with profile children;
"Rescan" + "Add manually" action bar (see Custom browsers below)
- Settings: row-shaped Default browser, Default target, Appearance,
General, Import/Export sections
- Rules / Inspector / Test URL: header restyled to mac-h2/mac-subtitle;
bodies inherit tokens via shadcn
New: Workspace detail page (apps/desktop/src/pages/workspace.tsx)
- Click a workspace in the sidebar to open a dedicated view
- Inline rename, on/off switch, delete
- 3 stat cards (Status / Rules / Recent hits)
- Rules list with priority + note + matcher description + per-rule
hit counter
- Recent activity list filtered to this workspace's rule hits
New: Onboarding flow (apps/desktop/src/onboarding/OnboardingFlow.tsx)
- 4-step first-run wizard with per-step radial-gradient backgrounds
(indigo→purple→cyan / blue / green-cyan / orange-pink), Light+Dark
- Welcome → Set as default (real requestSetDefaultBrowser) → Browser
picker (real listBrowsers + listProfiles) → Rule templates (4
presets persisted via configReplace)
- Gated on localStorage flag; Finish always exits even if template
save fails (template-save errors only surface a warning)
- Rule IDs generated via crypto.randomUUID (matches Rust's Uuid
newtype — earlier string IDs failed serde deserialization)
New: Tray popover (apps/desktop/src-tauri/src/tray.rs +
apps/desktop/src/tray/TrayPopover.tsx)
- Left-click tray icon → 360×500 vibrancy popover anchored under the
icon (HudWindow material, level 101, FullScreenAuxiliary so it
shows over fullscreen apps)
- Click outside → focus-lost auto-hide with 250ms re-show suppression
so clicking the icon again toggles correctly
- Workspace pills toggle real enabled state
- Recent route list (live via onRouteLogged + onConfigChanged)
- Footer Rules/History/Settings deep-link via tray:navigate event
- tray_open_main(tab?) Tauri command shows main + hides popover + emits
Workspaces data model (crates/core/src/rules.rs,
crates/core/src/config/mod.rs,
crates/core/src/routing.rs)
- Rule.workspace_id: Option<String>
- Workspace { id, display_name, description?, enabled }
- Router skips rules in disabled workspaces (defaults to enabled when
workspace_id refers to a missing workspace)
- WorkspacesCard in Rules page (rename / delete / batch toggle /
filter chips) preserved through redesign
- RuleEditor workspace dropdown
- workspace_upsert / workspace_delete / workspace_set_enabled IPC
Custom browsers (crates/core/src/config/mod.rs,
crates/platform-mac/src/app_picker.rs,
apps/desktop/src-tauri/src/commands/mod.rs,
apps/desktop/src/pages/browsers.tsx)
- ConfigDocument.custom_browsers: Vec<InstalledBrowser> with
#[serde(default)] for forward-compat
- "Add manually" uses AppleScript choose-file (filtered to
com.apple.application-bundle) — Finder-style picker that exposes
apps LaunchServices wouldn't surface, then reads Info.plist for
CFBundleIdentifier + CFBundleDisplayName
- list_browsers merges auto-detected + custom (custom wins on id
collision); add_custom_browser / remove_custom_browser commands
- doctor uses the same merged_browsers helper so the Overview
"Browsers" stat tile reflects custom additions
Settings (crates/core/src/config/mod.rs +
apps/desktop/src-tauri/src/commands/mod.rs +
crates/core/src/routing.rs)
- Settings.smart_routing_enabled (default true) — master kill-switch
for rule evaluation
- Router short-circuits to default_target when off, with a
"smart routing disabled" reason that surfaces in the Inspector
- set_smart_routing Tauri command (UI deferred — backend stays
registered for future re-enable)
Echo broadcast (apps/desktop/src-tauri/src/lib.rs)
- GUI-induced config changes now emit config-changed (label "echo")
in addition to external edits — sidebar workspace dots, Overview
stats, tray popover, etc. all refresh live without needing
per-handler refresh plumbing
Picker carry-over (apps/desktop/src-tauri/src/picker.rs +
apps/desktop/src/picker/PickerWindow.tsx +
apps/desktop/src-tauri/src/dispatch.rs)
- Vibrancy material switched to HudWindow (Spotlight/Raycast feel)
- Belt-and-suspenders transparency override on the React side so the
NSVisualEffectView shows through even if CSS loads late
- Pre-rendered icon data URLs in the ask-flow dispatch (Rust→base64
PNG) so the picker paints icons without an IPC round-trip
macOS launcher (crates/platform-mac/src/launcher.rs)
- activate_by_bundle_id post-spawn boost via `open -b` so
Chromium/Firefox direct-exec doesn't lose focus to LinkPilot
Info.plist patch script (apps/desktop/scripts/patch-info-plist.sh)
- Post-bundle patches LSHandlerRank, CFBundleDocumentTypes,
NSUserActivityTypes, LSApplicationCategoryType so macOS Settings'
"Default web browser" picker can find LinkPilot
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](softprops/action-gh-release@v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com>
Author
|
OK, I won't notify you again about this release, but will get in touch when a new version is available. If you'd rather skip all updates until the next major or minor version, let me know by commenting If you change your mind, just re-open this PR and I'll resolve any conflicts on it. |
jackerjay
added a commit
that referenced
this pull request
May 19, 2026
The IPC server's bind path always did 'let _ = remove_file(&path);'
before UnixListener::bind — server.rs:99. That silently handles a
stale socket file (e.g. from a previously-SIGKILLed daemon whose
ServerHandle Drop never ran) by replacing it with a fresh listener.
The fix here doesn't change that behaviour; it makes it observable.
The headless-daemon binary now does its own remove_file before
calling serve() and logs the outcome ('cleaned up stale socket file'
on success, warn on permission errors). The pattern matches the
M2.1 PID cleanup log a few lines above, so a future debugger reading
the startup log can tell at a glance whether a stale socket was
around. server.rs keeps its own remove_file as defence in depth —
covers embeddings (the Tauri shell, headless-daemon, future NMH)
that don't replicate this prelude.
Adds crates/ipc/tests/stale_socket_cleanup.rs:
- Pre-create a regular file at the endpoint path (simulates leftover
junk that bind would reject as EADDRINUSE without cleanup).
- Call serve() and wait specifically for the path's file type to
flip to socket (a naive .exists() poll would return true on the
seeded regular file, hiding the race).
- Connect + send a real StatePing + verify the Pong comes back.
Test count: 47 → 48.
This was referenced May 19, 2026
jackerjay
added a commit
that referenced
this pull request
May 19, 2026
## Why LinkPilot was carrying two competing notions of "priority": 1. A numeric `Rule.priority: i32` field. 2. The position of each rule inside `config.rules`. They drifted apart in practice — the GUI editor stamped `100` on every new rule, the CLI defaulted to `10`, the demo config used `10`, and drag-to-reorder restamped `(N - idx) * 10`. So a user with a `source-app = Lark → Ask` rule and a `url-host = github.com → Open Chrome` rule could easily end up with both at the same numeric priority. The stable sort then silently favored whichever rule was added first; the picker for `Ask` did pop but the user, expecting `github.com → Chrome` to dispatch directly, read it as "nothing happened — priority isn't working." User report (translated): *"当我的 rules 存在冲突的时候,例如我配置了 lark 打开使用 ask,同时配置了 github.com 默认走 Chrome,此时我在 lark 中点击 github 相关的域名不会有任何的浏览器跳转反应... 而且不能出现重复的优先级,应该按照列表排序的方式来规定优先级"*. ## What Collapse the two sources into one: **list order in `config.rules` IS priority.** Top of list wins. Duplicates are now structurally impossible. ### Core (`crates/core/`) - **Drop `Rule.priority`** from the schema. - `Router::evaluate_explained` walks `config.rules` top-to-bottom and returns the first matching enabled rule — no sort. - One-shot migration in `ConfigStore::load_or_init`: if a pre-v0.2 config still has `priority` fields on rules, stable-sort by `priority` descending (preserving user intent), strip the field, and persist atomically. The fsnotify external-edit path runs the same migration so hand-edited legacy configs still work. ### CLI (`crates/cli/`) - `rules add` drops `--priority`; new rules append at the bottom (safe default — won't silently override existing ones). - `set-priority` removed; replaced by `rules move <id> <top|up|down|bottom>`. - `rules list` renders 1-based slot numbers instead of a priority column. ### Frontend (`apps/desktop/src/`) - `RuleEditor` removes the Priority input and explains that priority is now set by list order on the Rules page. - `pages/rules.tsx` drops the sort + restamp; drag-reorder is a pure list rewrite. The leading `#N` badge is now the rule's 1-based global slot. - `pages/inspector.tsx`, `pages/test-url.tsx`, `pages/workspace.tsx`: same — show the slot, not a numeric priority. - `OnboardingFlow` prepends toggled templates in their declared order so the first template lands at slot #1. ### DSL (`packages/config-dsl/`) - `.priority(p)` builder removed; `RuleJson.priority` removed from the wire shape. - `compile()` documents that `cfg.rules` order is authoritative. ## Tests - New regression `routing::tests::first_match_in_list_order_wins` — reproduces the user's exact scenario (Lark/Ask + github/Chrome) and asserts that swapping the list positions flips the winning decision. - New migration test `config::store::tests::migrates_legacy_priority_to_list_order` — writes a legacy JSON with `"priority": 10` and `"priority": 100` rules in the wrong order, loads it, verifies the prio-100 rule moves to slot 1 and that the `priority` key is gone from disk after persistence. - New DSL test `rule array order is preserved (list-order priority)` plus an updated `RouteBuilder modifiers stick` that asserts no `priority` field is emitted. ### Local verification - `cargo test -p linkpilot-core` — 32 passed (incl. the two new tests above) - `cargo test -p linkpilot-cli` — 7 passed - `cargo check --workspace` incl. `linkpilot-desktop` — clean - `cargo clippy --workspace` — no new warnings (only pre-existing `tray.rs` `needless_borrow`s) - `npm run build` in `apps/desktop` (tsc + vite) — clean - `bun test` in `packages/config-dsl` — 13 passed - `npm run bundle:mac` — produces `LinkPilot.app` + `LinkPilot_0.2.0_aarch64.dmg`; Info.plist patch applies cleanly ## Backwards compatibility The Rule struct doesn't use `serde(deny_unknown_fields)`, so existing configs that still carry `priority` deserialize without error. The migration runs on the next load — sorts by `priority` desc (stable), drops the field, persists once. Idempotent: subsequent loads see no `priority` and skip migration. User-authored priority intent is preserved. ## Test plan - [ ] First launch on an existing install: confirm `linkpilot.config.json` no longer contains `"priority":` after the daemon boots once. - [ ] Repro user's case: configure `source-app = Lark → Ask` and `url-host = github.com → Open Chrome`; drag the github rule to the top in the Rules page; click a github link inside Lark → opens directly in Chrome with no picker. - [ ] Drag the lark rule above github → same click → picker appears. - [ ] `lpt rules list` prints rules in list order with `#N` slots. - [ ] `lpt rules move <github-id> top` raises that rule to slot 1. - [ ] Onboarding: toggle two templates → both land at the top in declared order. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps softprops/action-gh-release from 2 to 3.
Release notes
Sourced from softprops/action-gh-release's releases.
... (truncated)
Changelog
Sourced from softprops/action-gh-release's changelog.
Commits
b430933release: cut v3.0.0 for Node 24 upgrade (#670)c2e35e0chore(deps): bump the npm group across 1 directory with 7 updates (#783)Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
@dependabot rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore this major versionwill close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this minor versionwill close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this dependencywill close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)