Skip to content

chore(deps): bump softprops/action-gh-release from 2 to 3#1

Closed
dependabot[bot] wants to merge 51 commits into
mainfrom
dependabot/github_actions/softprops/action-gh-release-3
Closed

chore(deps): bump softprops/action-gh-release from 2 to 3#1
dependabot[bot] wants to merge 51 commits into
mainfrom
dependabot/github_actions/softprops/action-gh-release-3

Conversation

@dependabot
Copy link
Copy Markdown

@dependabot dependabot Bot commented on behalf of github May 15, 2026

Bumps softprops/action-gh-release from 2 to 3.

Release notes

Sourced from softprops/action-gh-release's releases.

v3.0.0

3.0.0 is a major release that moves the action runtime from Node 20 to Node 24. Use v3 on GitHub-hosted runners and self-hosted fleets that already support the Node 24 Actions runtime. If you still need the last Node 20-compatible line, stay on v2.6.2.

What's Changed

Other Changes 🔄

  • Move the action runtime and bundle target to Node 24
  • Update @types/node to the Node 24 line and allow future Dependabot updates
  • Keep the floating major tag on v3; v2 remains pinned to the latest 2.x release

v2.6.2

What's Changed

Other Changes 🔄

Full Changelog: softprops/action-gh-release@v2...v2.6.2

v2.6.1

2.6.1 is a patch release focused on restoring linked discussion thread creation when discussion_category_name is set. It fixes [#764](https://github.com/softprops/action-gh-release/issues/764), where the draft-first publish flow stopped carrying the discussion category through the final publish step.

If you still hit an issue after upgrading, please open a report with the bug template and include a minimal repro or sanitized workflow snippet where possible.

What's Changed

Bug fixes 🐛

v2.6.0

2.6.0 is a minor release centered on previous_tag support for generate_release_notes, which lets workflows pin GitHub's comparison base explicitly instead of relying on the default range. It also includes the recent concurrent asset upload recovery fix, a working_directory docs sync, a checked-bundle freshness guard for maintainers, and clearer immutable-prerelease guidance where GitHub platform behavior imposes constraints on how prerelease asset uploads can be published.

If you still hit an issue after upgrading, please open a report with the bug template and include a minimal repro or sanitized workflow snippet where possible.

What's Changed

... (truncated)

Changelog

Sourced from softprops/action-gh-release's changelog.

0.1.13

  • fix issue with multiple runs concatenating release bodies #145
Commits

Dependabot compatibility score

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 rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will 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 version will 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 dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

claude and others added 30 commits May 11, 2026 14:32
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).
sundeqi and others added 18 commits May 13, 2026 13:50
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>
@dependabot dependabot Bot added dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code labels May 15, 2026
@jackerjay jackerjay closed this May 15, 2026
@dependabot @github
Copy link
Copy Markdown
Author

dependabot Bot commented on behalf of github May 15, 2026

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 @dependabot ignore this major version or @dependabot ignore this minor version. You can also ignore all major, minor, or patch releases for a dependency by adding an ignore condition with the desired update_types to your config file.

If you change your mind, just re-open this PR and I'll resolve any conflicts on it.

@dependabot dependabot Bot deleted the dependabot/github_actions/softprops/action-gh-release-3 branch May 15, 2026 14:49
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.
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants