From 17b98db33228d133e9fb5ac62c0862e046ee9910 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:50:15 +0100 Subject: [PATCH 01/13] docs: add macOS port plan Plan for adding macOS as a third supported target alongside Windows and Linux from the same codebase: split the `not(windows)` cfg gates into explicit linux/macos arms (GTK is Linux-only), stand up a native window layer, and fill in Mac feature backends. Phased checklist in docs/macos-port.md. Co-Authored-By: Claude Opus 4.8 --- docs/macos-port.md | 141 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/macos-port.md diff --git a/docs/macos-port.md b/docs/macos-port.md new file mode 100644 index 0000000..1e74463 --- /dev/null +++ b/docs/macos-port.md @@ -0,0 +1,141 @@ +# Commandeer → macOS port plan + +Status: **planning** (no code changes yet). Commandeer currently targets +**Windows** and **Linux (Wayland/COSMIC)**; this document is the plan for adding +**macOS** as a third supported target from the same codebase. + +## The core idea (how it stays "in-line" across platforms) + +**One repo, one branch, no fork.** Clone on the Mac, work there, commit + push; +pull on the Windows box. Cross-platform behavior lives entirely in +`#[cfg(target_os = "...")]` gates, exactly as Windows/Linux already coexist. +macOS becomes a third arm. + +**The one structural gotcha:** the codebase treats "not Windows" as "Linux." +`#[cfg(not(target_os = "windows"))]` is *true on macOS*, so macOS currently +inherits Linux's GTK/Wayland code — which won't compile. The mechanical heart of +the port is **splitting `not(windows)` into `linux` vs `macos`** wherever it +touches GTK, and adding Mac arms elsewhere. + +**Rule to keep all platforms healthy:** never add a bare `not(windows)` branch +again — always gate `linux` and `macos` explicitly (or `unix` only when the code +is genuinely identical on both, like the `PermissionsExt` use in `fs.rs`). + +## What's already portable (zero work) + +- **Entire frontend** (`src/`, React/TS/Vite) — verified building on macOS. +- Cross-platform Rust: file index (SQLite+FTS5), `notify` watcher, `walkdir`, + `reqwest`/rates, `claude` usage, `config`/`store`/snippets/quicklinks/themes, + `fzf`, `arboard`. +- `fs.rs` — uses `std::os::unix::fs::PermissionsExt`, already correct on macOS. +- `stats.rs` — already has a `not(any(windows, linux))` fallback arm. +- Plugins: `autostart` (already `MacosLauncher::LaunchAgent`), `single-instance`, + `deep-link`, `opener`, `global-shortcut` all support macOS. +- Many `not(windows)` command bodies are already stubs (`list_apps` → `vec![]`, + audio/system/process/paste → errors), so they compile on macOS as-is with the + same feature gaps Linux has. + +## Phase 0 — Toolchain (prerequisite) + +- [ ] Install Rust: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` + (Xcode Command Line Tools already present) +- [ ] `source ~/.cargo/env`; confirm `cargo --version` +- [ ] `bun install` (done) +- [ ] Bun/Node already installed + +## Phase 1 — Make it compile & launch (structural) + +Goal: an app that *runs* on macOS with the same feature gaps Linux has. This is +the bulk of the feasibility risk, and it's small. + +- [ ] **`src-tauri/Cargo.toml`** — regate the GTK block: + `[target.'cfg(not(windows))'.dependencies]` (gtk, gtk-layer-shell) → + `[target.'cfg(target_os = "linux")'.dependencies]`. Stops macOS from + building GTK. +- [ ] **`src-tauri/src/lib.rs`** — every `#[cfg(not(target_os = "windows"))]` + block that uses `gtk`/`gtk_layer_shell` becomes `#[cfg(target_os = "linux")]`: + - `PALETTE_TOP_MARGIN` const + - `resize_palette` body (Linux-only; macOS/Windows resize via the frontend) + - the layer-shell setup in `.setup()` + - `update_cosmic_shortcut` and its calls in `set_game_mode`/`setup` +- [ ] **`src-tauri/src/lib.rs`** — add a `#[cfg(target_os = "macos")]` setup arm. + Minimum to launch: nothing special (the palette window is already + `transparent`, `decorations:false`, `alwaysOnTop`, `center`). Rounded + corners / vibrancy come in Phase 2. +- [ ] **`src-tauri/tauri.conf.json`** — palette `windowEffects` uses `"acrylic"` + (Windows-only, ignored on mac). Later swap for a macOS material + (`"hudWindow"` / `"popover"`). +- [ ] **`src/lib/tauri.ts`** (wherever `IS_LINUX` lives) — add `IS_MAC` + (`navigator.userAgent.includes('Mac')`). macOS follows the **Windows** + sizing path (frontend `setSize`), not the Linux layer-shell path. +- [ ] `npm run tauri dev` → iterate until it launches. Expected: palette opens + via global shortcut; launcher/audio/screenshot/etc. dead (same as Linux). + This confirms macOS is a supported target. + +## Phase 2 — Core UX parity + +- [ ] **Window positioning** (`position_on_cursor_monitor`): add a macOS arm. + Cleanest option — replace both with Tauri's cross-platform + `app.cursor_position()` + `available_monitors()` and pick the containing + monitor (could unify Windows too). +- [ ] **Non-activating panel (Raycast feel):** add the `tauri-nspanel` plugin + (community, Tauri v2) so the palette is an `NSPanel` that shows over + fullscreen apps and doesn't steal focus/activate the app. Optional but this + is what makes it feel native. +- [ ] **Vibrancy + rounded corners:** `NSVisualEffectView` material via + `windowEffects`; corner radius via the panel. +- [ ] **Global hotkey:** `global-shortcut` works natively on macOS — verify + registration. Note Cmd+Space collides with Spotlight; pick a default or + keep it configurable (plumbing already exists). +- [ ] **Tray icon** (`setup_tray`, currently Windows-only): enable for macOS; + Tauri tray works. Icons must be RGBA. + +## Phase 3 — Feature backends (each independent; fill in over time) + +| Feature | File | macOS approach | +|---|---|---| +| App launcher list | `launcher.rs` | Scan `/Applications`, `/System/Applications`, `~/Applications` for `.app` bundles | +| Launch app | `launcher.rs` | `open ` / `open -a` | +| Screenshot capture | `screenshot.rs` | Built-in `screencapture -x -t png ` (replaces `cosmic-screenshot`) | +| Screenshot → clipboard | `screenshot.rs` | `arboard` image set (replaces `wl-copy`), or `osascript` | +| Paste to previous | `paste.rs` | Simulate ⌘V via `CGEvent` — **needs Accessibility permission** | +| Audio volume | `audio.rs` | `osascript -e 'set volume output volume ...'` (quick) or CoreAudio (proper) | +| System actions | `system.rs` | `osascript` / `pmset sleepnow` / lock via `pmset displaysleepnow`; empty trash via Finder AppleScript | +| Process list/kill | `process.rs` | Add cross-platform `sysinfo` crate + `libc::kill` (could unify all 3 OSes) | +| File/app icons | `icons.rs` | `NSWorkspace.icon(forFile:)` → PNG; defer (return `None` like Linux initially) | +| Everything search | `search.rs` | N/A — Windows-only indexer; macOS uses the built-in `file_index` (cross-platform) | +| Script types / `scripts_dir` | `fs.rs` / `config.rs` | Add macOS default dir + `.sh` / `.command` / executables | + +**macOS's genuinely hard part — permissions.** Paste (keystroke synthesis) needs +**Accessibility**; `screencapture` of other windows may need **Screen +Recording**; global hotkeys can need **Input Monitoring**. These require user +approval in System Settings and behave differently for unsigned dev builds. +Budget time here — it's the one area with no Windows/Linux analog. + +- [ ] **Verify `clipboard/crypto.rs`** — the clipboard-history encryption has cfg + gates; confirm the `not(windows)` key path isn't Linux-keyring-specific + (may need a macOS Keychain or portable arm). + +## Phase 4 — Packaging & sync hygiene + +- [ ] **`package.json` `release` script** — hardcodes `commandeer.exe`. Add a + macOS branch producing `.app`/`.dmg` (`tauri build` emits these; + `bundle.targets: "all"` is set). +- [ ] **`tauri.conf.json` `bundle`** — add a macOS icon (`.icns`) alongside `.ico`. +- [ ] **Deep link** — `commandeer://` on macOS needs `CFBundleURLTypes` in the + bundle Info.plist (deep-link plugin config); runtime `register()` may not + persist unbundled. +- [ ] **Update `CLAUDE.md`** — its "cross-platform: Windows and Linux" line and + Platform-split section need a macOS column so future work stays consistent. +- [ ] **Consider CI** — a GitHub Actions matrix (`windows-latest` + + `macos-latest`, later `ubuntu`) running `npm run build` + `cargo build` per + push is the real guardrail that keeps platforms in-line automatically. + +## Effort estimate + +- **Phase 1 (compiles + launches):** small — a focused session. Answers "is it + possible?" → yes. +- **Phases 2–3:** incremental, feature-by-feature; usable after Phase 2 even with + backends stubbed. +- **Riskiest:** macOS permissions (paste/screenshot) and NSPanel behavior; + everything else is conventional. From 1c074392de15fc5f694cc82ef9f4fcba2a05de10 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:58:47 +0100 Subject: [PATCH 02/13] =?UTF-8?q?feat(macos):=20Phase=201=20=E2=80=94=20co?= =?UTF-8?q?mpile=20&=20launch=20on=20macOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add macOS as a third supported target from the same codebase: - Cargo.toml: regate the gtk / gtk-layer-shell deps from cfg(not(windows)) to cfg(target_os = "linux") so they aren't pulled in on macOS (which is also "not windows"). tauri-build auto-added the macos-private-api feature. - lib.rs: move every gtk/layer-shell/COSMIC cfg(not(windows)) block to cfg(target_os = "linux"); resize_palette's no-op arm is now cfg(not(target_os = "linux")) so Windows and macOS both keep params used. - tauri.conf.json: enable app.macOSPrivateApi so the transparent palette window actually renders transparent on macOS. No frontend changes: IS_LINUX (userAgent) is false on macOS, so the palette already uses the native setSize path. cargo build is clean and the app launches without panic (file index scans, transparent window confirmed). Feature backends (launcher, screenshot, audio, system, process, paste), tray, window positioning, and packaging remain — see docs/macos-port.md. Co-Authored-By: Claude Opus 4.8 --- docs/macos-port.md | 78 ++++++++++++++++++++++++--------------- src-tauri/Cargo.toml | 6 ++- src-tauri/src/lib.rs | 16 ++++---- src-tauri/tauri.conf.json | 1 + 4 files changed, 62 insertions(+), 39 deletions(-) diff --git a/docs/macos-port.md b/docs/macos-port.md index 1e74463..b5bde0b 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -1,8 +1,9 @@ # Commandeer → macOS port plan -Status: **planning** (no code changes yet). Commandeer currently targets -**Windows** and **Linux (Wayland/COSMIC)**; this document is the plan for adding -**macOS** as a third supported target from the same codebase. +Status: **Phase 1 complete** — Commandeer compiles, links, and launches on macOS +with a transparent palette window. Phases 2–4 (UX parity, feature backends, +packaging) remain. Commandeer targets **Windows**, **Linux (Wayland/COSMIC)**, +and now **macOS** from the same codebase. ## The core idea (how it stays "in-line" across platforms) @@ -35,42 +36,59 @@ is genuinely identical on both, like the `PermissionsExt` use in `fs.rs`). audio/system/process/paste → errors), so they compile on macOS as-is with the same feature gaps Linux has. -## Phase 0 — Toolchain (prerequisite) +## Phase 0 — Toolchain (prerequisite) — DONE -- [ ] Install Rust: `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` - (Xcode Command Line Tools already present) -- [ ] `source ~/.cargo/env`; confirm `cargo --version` -- [ ] `bun install` (done) -- [ ] Bun/Node already installed +- [x] Install Rust via rustup (1.96.1). Xcode Command Line Tools already present. + Installed with `--no-modify-path`; `. "$HOME/.cargo/env"` added to `~/.zshenv`. +- [x] `cargo --version` confirmed +- [x] `bun install` +- [x] Bun/Node already installed -## Phase 1 — Make it compile & launch (structural) +## Phase 1 — Make it compile & launch (structural) — DONE -Goal: an app that *runs* on macOS with the same feature gaps Linux has. This is -the bulk of the feasibility risk, and it's small. +Goal: an app that *runs* on macOS with the same feature gaps Linux has. Achieved +in a single session; the feasibility risk was as small as expected. -- [ ] **`src-tauri/Cargo.toml`** — regate the GTK block: +- [x] **`src-tauri/Cargo.toml`** — regated the GTK block: `[target.'cfg(not(windows))'.dependencies]` (gtk, gtk-layer-shell) → `[target.'cfg(target_os = "linux")'.dependencies]`. Stops macOS from - building GTK. -- [ ] **`src-tauri/src/lib.rs`** — every `#[cfg(not(target_os = "windows"))]` - block that uses `gtk`/`gtk_layer_shell` becomes `#[cfg(target_os = "linux")]`: + building GTK. (`tauri-build` also auto-added the `macos-private-api` cargo + feature once the config below was set.) +- [x] **`src-tauri/src/lib.rs`** — every `#[cfg(not(target_os = "windows"))]` + block that used `gtk`/`gtk_layer_shell` is now `#[cfg(target_os = "linux")]`: - `PALETTE_TOP_MARGIN` const - - `resize_palette` body (Linux-only; macOS/Windows resize via the frontend) - - the layer-shell setup in `.setup()` + - `resize_palette` body (Linux-only; the no-op `let _` arm is now + `cfg(not(target_os = "linux"))` so both Windows and macOS keep params used) + - the layer-shell + WAYLAND setup in `.setup()` - `update_cosmic_shortcut` and its calls in `set_game_mode`/`setup` -- [ ] **`src-tauri/src/lib.rs`** — add a `#[cfg(target_os = "macos")]` setup arm. - Minimum to launch: nothing special (the palette window is already - `transparent`, `decorations:false`, `alwaysOnTop`, `center`). Rounded +- [x] **macOS setup arm** — none needed to launch: the palette is already + `transparent`, `decorations:false`, `alwaysOnTop`, `center`. Rounded corners / vibrancy come in Phase 2. -- [ ] **`src-tauri/tauri.conf.json`** — palette `windowEffects` uses `"acrylic"` - (Windows-only, ignored on mac). Later swap for a macOS material - (`"hudWindow"` / `"popover"`). -- [ ] **`src/lib/tauri.ts`** (wherever `IS_LINUX` lives) — add `IS_MAC` - (`navigator.userAgent.includes('Mac')`). macOS follows the **Windows** - sizing path (frontend `setSize`), not the Linux layer-shell path. -- [ ] `npm run tauri dev` → iterate until it launches. Expected: palette opens - via global shortcut; launcher/audio/screenshot/etc. dead (same as Linux). - This confirms macOS is a supported target. +- [x] **`src-tauri/tauri.conf.json`** — added `"macOSPrivateApi": true` under + `app`. **Required**: without it macOS logs "window is set to be transparent + but macos-private-api is not enabled" and the palette isn't transparent. + (Note: this uses private Apple APIs → not Mac App Store distributable, which + is fine here.) The Windows-only `"acrylic"` windowEffect is ignored on mac; + swap for a macOS material (`"hudWindow"`/`"popover"`) in Phase 2. +- [x] **Frontend — no change needed.** `IS_LINUX` keys off + `userAgent.includes('Linux')`, which is *false* on macOS, so all three + `IS_LINUX` branches (palette sizing, window transparency, screenshot-hotkey + setting) already resolve to the Windows-style path on Mac. macOS uses native + `setSize` for palette resize automatically. +- [x] `npm run tauri dev` launches: `cargo build` is clean (exit 0, 3 harmless + dead-code warnings), app runs without panic, file index scans, transparent + window confirmed. macOS is a supported target. + +### Known non-blocking gaps after Phase 1 (expected; addressed in later phases) + +- Global hotkey default is Ctrl+Space — on macOS this often collides with input- + source switching / Spotlight; verify and/or change default in Phase 2. +- `set_window_transparency` command returns an error on macOS (Windows-only + native path); the transparency *setting* is a no-op. Give macOS the Linux-style + CSS-opacity fallback or a native impl in Phase 2/3. +- Screenshot / launcher / audio / system / process / paste are stubs or Linux-only + shell-outs → Phase 3. +- No tray icon on macOS (tray is Windows-only) → Phase 2. ## Phase 2 — Core UX parity diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d60c35..1e7db1a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -11,7 +11,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["tray-icon", "protocol-asset"] } +tauri = { version = "2", features = ["macos-private-api", "tray-icon", "protocol-asset"] } tauri-plugin-global-shortcut = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" @@ -61,7 +61,9 @@ windows = { version = "0.58", features = [ "Win32_System_Variant", ] } -[target.'cfg(not(windows))'.dependencies] +# Linux-only (not merely non-Windows): GTK/layer-shell must NOT be pulled in on +# macOS, which is also "not windows". macOS uses native window APIs instead. +[target.'cfg(target_os = "linux")'.dependencies] # Render the palette as a wlr-layer-shell surface on Linux/Wayland: lets the # compositor center it and makes transparent regions truly invisible (no # toplevel border/tint), and allows in-place resize without an unmap flash. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 013e82a..2a964b2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,16 +6,17 @@ use tauri_plugin_global_shortcut::ShortcutState; /// Distance (logical px) from the top of the screen to the top of the palette on /// Wayland. The surface is anchored to the top edge, so this stays fixed while /// the height grows downward. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] const PALETTE_TOP_MARGIN: i32 = 150; /// Resize the palette to `height` logical px. On the Wayland layer-shell surface /// the size is taken from the GTK window's size request (it has no anchors), and /// changing it reconfigures the surface in place — no unmap, no flicker. On -/// Windows the frontend resizes via setSize instead, so this is a no-op there. +/// Windows and macOS the frontend resizes via setSize instead, so this is a +/// no-op there. #[tauri::command] fn resize_palette(app: tauri::AppHandle, height: i32) { - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] { use gtk::prelude::*; if let Some(win) = app.get_webview_window("palette") { @@ -24,7 +25,8 @@ fn resize_palette(app: tauri::AppHandle, height: i32) { } } } - #[cfg(target_os = "windows")] + // Windows and macOS resize via the frontend's setSize, so this is a no-op. + #[cfg(not(target_os = "linux"))] let _ = (&app, height); } @@ -171,7 +173,7 @@ fn setup_tray(app: &tauri::App) -> tauri::Result<()> { /// instance, which toggles the palette). We manage that binding here so it /// mirrors the Windows shortcut: Ctrl+Space normally, Alt+Space in game mode. /// Only our own entry is touched; any other custom shortcuts are preserved. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn update_cosmic_shortcut(game_mode: bool) { let home = match std::env::var_os("HOME") { Some(h) => h, @@ -234,7 +236,7 @@ fn set_game_mode(enabled: bool, app: tauri::AppHandle) -> Result<(), String> { // Re-register the configured hotkeys (game hotkey when enabled). commands::shortcuts::reload_shortcuts(&app, enabled)?; - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] { update_cosmic_shortcut(enabled); } @@ -344,7 +346,7 @@ pub fn run() { // later via set_game_mode) plus any per-command shortcuts. commands::shortcuts::setup_shortcuts(app.app_handle())?; - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] { // Ensure a working default COSMIC binding even before the frontend // calls set_game_mode; the frontend then refines it for game mode. diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c9ba691..b266b29 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -10,6 +10,7 @@ "beforeBuildCommand": "npm run build" }, "app": { + "macOSPrivateApi": true, "windows": [ { "label": "palette", From 5159dd9bf6dbab5327ef28e714431b71395212c1 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:05:45 +0100 Subject: [PATCH 03/13] =?UTF-8?q?feat(macos):=20Phase=202=20(pass=201)=20?= =?UTF-8?q?=E2=80=94=20hotkey,=20vibrancy,=20agent=20policy,=20tray?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core UX parity for macOS: - shortcuts.rs: default global hotkey is Cmd+Shift+Space on macOS (Ctrl+Space is the input-source switcher, Cmd+Space is Spotlight). - Cargo.toml + lib.rs: apply window-vibrancy (NSVisualEffectMaterial HudWindow + 12px radius) to the palette in a cfg(target_os = "macos") setup arm; best-effort. - lib.rs: set_activation_policy(Accessory) so the app is a background agent (no Dock icon / Cmd-Tab entry), like Raycast/Spotlight. - lib.rs: enable the tray icon on macOS (setup_tray gate widened to windows|macos) so there's an entry point + Quit without a Dock icon. Builds clean; app launches, toggles the palette via single-instance, and survives show/hide with no panic. Deferred to 2b: tauri-nspanel (non-activating panel), cursor-monitor positioning, macOS set_window_transparency fallback. See docs/macos-port.md. Co-Authored-By: Claude Opus 4.8 --- docs/macos-port.md | 57 ++++--- src-tauri/Cargo.lock | 223 +++++++++++++++++++++------- src-tauri/Cargo.toml | 5 + src-tauri/src/commands/shortcuts.rs | 6 + src-tauri/src/lib.rs | 30 +++- 5 files changed, 247 insertions(+), 74 deletions(-) diff --git a/docs/macos-port.md b/docs/macos-port.md index b5bde0b..a8c770e 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -90,23 +90,46 @@ in a single session; the feasibility risk was as small as expected. shell-outs → Phase 3. - No tray icon on macOS (tray is Windows-only) → Phase 2. -## Phase 2 — Core UX parity - -- [ ] **Window positioning** (`position_on_cursor_monitor`): add a macOS arm. - Cleanest option — replace both with Tauri's cross-platform - `app.cursor_position()` + `available_monitors()` and pick the containing - monitor (could unify Windows too). -- [ ] **Non-activating panel (Raycast feel):** add the `tauri-nspanel` plugin - (community, Tauri v2) so the palette is an `NSPanel` that shows over - fullscreen apps and doesn't steal focus/activate the app. Optional but this - is what makes it feel native. -- [ ] **Vibrancy + rounded corners:** `NSVisualEffectView` material via - `windowEffects`; corner radius via the panel. -- [ ] **Global hotkey:** `global-shortcut` works natively on macOS — verify - registration. Note Cmd+Space collides with Spotlight; pick a default or - keep it configurable (plumbing already exists). -- [ ] **Tray icon** (`setup_tray`, currently Windows-only): enable for macOS; - Tauri tray works. Icons must be RGBA. +## Phase 2 — Core UX parity — IN PROGRESS + +Done in this pass (compiles clean; app launches, toggles the palette via the +single-instance path, and survives show/hide with no panic — visual confirmation +of vibrancy/tray is left to on-device testing since screen capture needs the +Screen Recording permission): + +- [x] **Global hotkey default** — macOS now defaults to `Cmd+Shift+Space` + (Ctrl+Space = input-source switch, Cmd+Space = Spotlight). Per-platform + const in `shortcuts.rs`; still user-configurable. +- [x] **Vibrancy + rounded corners** — added the `window-vibrancy` crate + (macOS-target dep) and apply `NSVisualEffectMaterial::HudWindow` + + 12px radius to the palette in a `#[cfg(target_os = "macos")]` setup arm. + Best-effort (`let _`), so failure just falls back to the plain transparent + window. Material is easy to tweak if HudWindow reads wrong under the Light + theme. +- [x] **Background agent** — `set_activation_policy(Accessory)` on macOS: no Dock + icon, no Cmd-Tab entry (matches Raycast/Spotlight). The tray + hotkey are + the entry points. +- [x] **Tray icon** — `setup_tray` gate widened to + `any(target_os = "windows", target_os = "macos")` (Show / Start at Login / + Quit). Autostart already uses `MacosLauncher::LaunchAgent`. NOTE: it reuses + the colored app icon; a monochrome **template** image would look more native + in the macOS menu bar (follow-up). + +Deferred to Phase 2b (more involved / higher risk, and most valuable once paste +works): + +- [ ] **Non-activating panel (Raycast feel):** add the `tauri-nspanel` plugin so + the palette is an `NSPanel` that shows over fullscreen apps and doesn't + activate the app / steal the previous app's active state (important for + paste-to-previous once that lands in Phase 3). +- [ ] **Window positioning** (`position_on_cursor_monitor`): add a macOS arm so + the palette opens on the display under the cursor. Currently relies on + `center: true` (opens centered on the current display). Cleanest option — + unify Windows + macOS on Tauri's cross-platform monitor + cursor APIs. +- [ ] **`set_window_transparency`** returns an error on macOS (Windows-only native + path). Give macOS the Linux-style CSS-opacity fallback (add an `IS_MAC` + branch in `src/lib/tauri.ts`) or a native impl, so the transparency setting + isn't a no-op that rejects. ## Phase 3 — Feature backends (each independent; fill in over time) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 120bbaa..d83d4f6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -68,11 +68,11 @@ dependencies = [ "clipboard-win", "image", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", "windows-sys 0.60.2", @@ -307,13 +307,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.4", ] [[package]] @@ -565,6 +574,7 @@ dependencies = [ "tokio", "uuid", "walkdir", + "window-vibrancy 0.5.3", "windows 0.58.0", ] @@ -907,9 +917,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -1611,8 +1621,8 @@ checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7" dependencies = [ "crossbeam-channel", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "once_cell", "serde", "thiserror 2.0.18", @@ -2533,10 +2543,10 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.17.16", "serde", @@ -2642,6 +2652,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" version = "0.6.4" @@ -2652,6 +2678,22 @@ dependencies = [ "objc2-exception-helper", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + [[package]] name = "objc2-app-kit" version = "0.3.2" @@ -2659,11 +2701,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -2674,7 +2728,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -2685,11 +2739,23 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2705,6 +2771,18 @@ dependencies = [ "cc", ] +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -2712,8 +2790,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.0", - "block2", - "objc2", + "block2 0.6.2", + "objc2 0.6.4", "objc2-core-foundation", ] @@ -2724,10 +2802,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", ] +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.11.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2735,9 +2838,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -2747,9 +2850,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.11.0", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -2759,11 +2862,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ "bitflags 2.11.0", - "block2", - "objc2", - "objc2-app-kit", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -4113,11 +4216,11 @@ dependencies = [ "bytemuck", "js-sys", "ndk", - "objc2", + "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", "redox_syscall", "tracing", @@ -4305,7 +4408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", - "block2", + "block2 0.6.2", "core-foundation", "core-graphics", "crossbeam-channel", @@ -4321,9 +4424,9 @@ dependencies = [ "ndk", "ndk-context", "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "once_cell", "parking_lot", "raw-window-handle", @@ -4382,9 +4485,9 @@ dependencies = [ "log", "mime", "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "percent-encoding", @@ -4407,7 +4510,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy", + "window-vibrancy 0.6.0", "windows 0.61.3", ] @@ -4549,8 +4652,8 @@ checksum = "17e1bea14edce6b793a04e2417e3fd924b9bc4faae83cdee7d714156cceeed29" dependencies = [ "dunce", "glob", - "objc2-app-kit", - "objc2-foundation", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "open", "schemars 0.8.22", "serde", @@ -4589,7 +4692,7 @@ dependencies = [ "gtk", "http", "jni", - "objc2", + "objc2 0.6.4", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -4613,8 +4716,8 @@ dependencies = [ "http", "jni", "log", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", @@ -5088,11 +5191,11 @@ dependencies = [ "dirs 6.0.0", "libappindicator", "muda", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", - "objc2-foundation", + "objc2-foundation 0.3.2", "once_cell", "png 0.17.16", "serde", @@ -5590,16 +5693,30 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "window-vibrancy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831ad7678290beae36be6f9fad9234139c7f00f3b536347de7745621716be82d" +dependencies = [ + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + [[package]] name = "window-vibrancy" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" dependencies = [ - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "raw-window-handle", "windows-sys 0.59.0", "windows-version", @@ -6278,7 +6395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" dependencies = [ "base64 0.22.1", - "block2", + "block2 0.6.2", "cookie", "crossbeam-channel", "dirs 6.0.0", @@ -6292,10 +6409,10 @@ dependencies = [ "jni", "libc", "ndk", - "objc2", - "objc2-app-kit", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", "objc2-ui-kit", "objc2-web-kit", "once_cell", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1e7db1a..c82cd11 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -70,3 +70,8 @@ windows = { version = "0.58", features = [ gtk = "0.18" # v0_6 enables set_keyboard_mode (KeyboardMode::OnDemand); our system lib is 0.10. gtk-layer-shell = { version = "0.8", features = ["v0_6"] } + +[target.'cfg(target_os = "macos")'.dependencies] +# Translucent NSVisualEffect background + rounded corners for the palette +# (the macOS analogue of the Windows "acrylic" windowEffect). +window-vibrancy = "0.5" diff --git a/src-tauri/src/commands/shortcuts.rs b/src-tauri/src/commands/shortcuts.rs index 7cb23d7..92d3b3f 100644 --- a/src-tauri/src/commands/shortcuts.rs +++ b/src-tauri/src/commands/shortcuts.rs @@ -24,7 +24,13 @@ fn active() -> &'static Mutex { ACTIVE.get_or_init(|| Mutex::new(ActiveShortcuts::default())) } +#[cfg(not(target_os = "macos"))] const DEFAULT_HOTKEY: &str = "Ctrl+Space"; +// macOS: Ctrl+Space is the system input-source switcher and Cmd+Space is +// Spotlight, so neither is usable out of the box. Default to Cmd+Shift+Space, +// which is normally free. (User-configurable via Settings → Global Hotkey.) +#[cfg(target_os = "macos")] +const DEFAULT_HOTKEY: &str = "Cmd+Shift+Space"; const DEFAULT_GAME_HOTKEY: &str = "Alt+Space"; // Insert, not PrintScreen: RegisterHotKey(VK_SNAPSHOT) "succeeds" but never // fires because PrintScreen emits no WM_KEYDOWN, so WM_HOTKEY is never sent. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a964b2..eec6b0e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,9 +115,10 @@ fn get_autostart(app: tauri::AppHandle) -> Result { } /// Tray icon with Show / Start at Login / Quit — the only way to quit or -/// rediscover the app once the palette is hidden. Windows-only for now; the -/// Linux build would need libappindicator and is untested there. -#[cfg(target_os = "windows")] +/// rediscover the app once the palette is hidden (especially on macOS, where the +/// app is an Accessory with no Dock icon). Windows + macOS; the Linux build would +/// need libappindicator and is untested there. +#[cfg(any(target_os = "windows", target_os = "macos"))] fn setup_tray(app: &tauri::App) -> tauri::Result<()> { use tauri::menu::{CheckMenuItem, MenuBuilder, MenuItem}; use tauri::tray::TrayIconBuilder; @@ -339,7 +340,7 @@ pub fn run() { }); } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] setup_tray(app)?; // Configurable base hotkey (default Ctrl+Space; game mode applied @@ -440,6 +441,27 @@ pub fn run() { } } + #[cfg(target_os = "macos")] + { + // A command palette is a background agent: no Dock icon and no + // Cmd-Tab entry. The tray icon and global hotkey are the entry + // points (mirrors Raycast/Spotlight). + app.set_activation_policy(tauri::ActivationPolicy::Accessory); + + // Translucent NSVisualEffect background + rounded corners for the + // palette — the macOS analogue of the Windows acrylic effect. + // Best-effort: any failure leaves the plain transparent window. + use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial, NSVisualEffectState}; + if let Some(win) = app.get_webview_window("palette") { + let _ = apply_vibrancy( + &win, + NSVisualEffectMaterial::HudWindow, + Some(NSVisualEffectState::Active), + Some(12.0), + ); + } + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ From ea7316a1192b5311b6e126533f1b0e460836e4e7 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:10:08 +0100 Subject: [PATCH 04/13] =?UTF-8?q?feat(macos):=20Phase=202b=20=E2=80=94=20c?= =?UTF-8?q?ursor-monitor=20positioning=20+=20transparency=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib.rs: add a macOS position_on_cursor_monitor built on Tauri's cross-platform cursor_position/monitor_from_point/work_area APIs (the Win32 path is untouched, so no Windows regression). The palette now opens centered on the display under the cursor, top at ~20% of the work area. Both show paths (toggle/show) call it on windows|macos. - src/lib/tauri.ts: route macOS through the Linux-style CSS-opacity path in setWindowTransparency instead of the Windows-only native invoke (which errored on mac). The transparency setting now works on macOS. Builds clean (cargo + tsc); launches and toggles twice with no panic. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 28 ++++++++++++++++++++++++++-- src/lib/tauri.ts | 10 ++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eec6b0e..ccfd971 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -64,6 +64,30 @@ fn position_on_cursor_monitor(win: &tauri::WebviewWindow) { } } +/// macOS equivalent of `position_on_cursor_monitor`, built on Tauri's +/// cross-platform monitor APIs instead of Win32. Center horizontally on the +/// display under the cursor with the top at ~20% of that display's work area +/// (below the menu bar). All physical pixels, matching `outer_size` / +/// `set_position`. +#[cfg(target_os = "macos")] +fn position_on_cursor_monitor(win: &tauri::WebviewWindow) { + let Ok(cursor) = win.cursor_position() else { + return; + }; + let monitor = match win.monitor_from_point(cursor.x, cursor.y) { + Ok(Some(m)) => m, + _ => match win.primary_monitor() { + Ok(Some(m)) => m, + _ => return, + }, + }; + let area = monitor.work_area(); + let width = win.outer_size().map(|s| s.width as i32).unwrap_or(669); + let x = area.position.x + (area.size.width as i32 - width) / 2; + let y = area.position.y + (area.size.height as i32) / 5; + let _ = win.set_position(tauri::PhysicalPosition::new(x, y)); +} + /// Show the palette if hidden, hide it if visible. fn toggle_palette(app: &tauri::AppHandle) { if let Some(win) = app.get_webview_window("palette") { @@ -76,7 +100,7 @@ fn toggle_palette(app: &tauri::AppHandle) { // Snapshot the focused Explorer folder now (resolves on a worker // thread) so the frontend's Search Folder check is instant. commands::explorer::capture_location(); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] position_on_cursor_monitor(&win); let _ = win.show(); let _ = win.set_focus(); @@ -90,7 +114,7 @@ pub(crate) fn show_palette(app: &tauri::AppHandle) { if let Some(win) = app.get_webview_window("palette") { commands::paste::capture_foreground(); commands::explorer::capture_location(); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] position_on_cursor_monitor(&win); let _ = win.show(); let _ = win.set_focus(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 8b73426..37123b9 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -293,13 +293,15 @@ export const readThemes = () => invoke('read_themes') const IS_LINUX = typeof navigator !== 'undefined' && navigator.userAgent.includes('Linux') +const IS_MAC = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') export const setWindowTransparency = (transparency: number) => { - if (IS_LINUX) { + if (IS_LINUX || IS_MAC) { // Wayland/cosmic-comp has no whole-window alpha (and GTK3 toplevel opacity - // is a no-op there). The window background is already fully transparent, - // so fading the webview root is visually equivalent to the Windows - // layered-window alpha. + // is a no-op there); macOS has no native layered-window alpha command + // (the palette is a transparent window + vibrancy). In both cases the + // window background is already transparent, so fading the webview root is + // visually equivalent to the Windows layered-window alpha. const t = Math.min(1, Math.max(0, transparency)) document.documentElement.style.opacity = String(1 - t) return Promise.resolve() From 5e0d26045f8f22c505a4ccebc9cd532dd78ebf87 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:11:19 +0100 Subject: [PATCH 05/13] docs: mark Phase 2b done; defer nspanel to Phase 3 with rationale Co-Authored-By: Claude Opus 4.8 --- docs/macos-port.md | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/docs/macos-port.md b/docs/macos-port.md index a8c770e..2773ad2 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -115,21 +115,29 @@ Screen Recording permission): the colored app icon; a monochrome **template** image would look more native in the macOS menu bar (follow-up). -Deferred to Phase 2b (more involved / higher risk, and most valuable once paste -works): - -- [ ] **Non-activating panel (Raycast feel):** add the `tauri-nspanel` plugin so - the palette is an `NSPanel` that shows over fullscreen apps and doesn't - activate the app / steal the previous app's active state (important for - paste-to-previous once that lands in Phase 3). -- [ ] **Window positioning** (`position_on_cursor_monitor`): add a macOS arm so - the palette opens on the display under the cursor. Currently relies on - `center: true` (opens centered on the current display). Cleanest option — - unify Windows + macOS on Tauri's cross-platform monitor + cursor APIs. -- [ ] **`set_window_transparency`** returns an error on macOS (Windows-only native - path). Give macOS the Linux-style CSS-opacity fallback (add an `IS_MAC` - branch in `src/lib/tauri.ts`) or a native impl, so the transparency setting - isn't a no-op that rejects. +### Phase 2b — DONE (except nspanel, deferred with rationale) + +- [x] **Window positioning** — added a macOS `position_on_cursor_monitor` in + `lib.rs` on Tauri's cross-platform `cursor_position` / `monitor_from_point` + / `work_area` APIs. The palette opens centered on the display under the + cursor, top at ~20% of the work area. The Win32 path is left untouched (no + Windows regression); both show paths call it on `windows|macos`. +- [x] **`set_window_transparency`** — macOS now routes through the Linux-style + CSS-opacity fallback (added `IS_MAC` in `src/lib/tauri.ts`) instead of the + Windows-only native invoke that errored. The transparency setting works. +- [ ] **Non-activating panel (`tauri-nspanel`) — DEFERRED to Phase 3.** Rationale: + 1. The plugin is **git-only** (branch-pinned, not on crates.io) — a + dependency-stability decision worth making deliberately, not by default. + 2. Its main payoff — *not* stealing the previously-focused app's active + state — only matters for **paste-to-previous**, which is a Phase 3 + feature. Bundling nspanel with that work lets it be verified end-to-end. + 3. Its other benefit (show over fullscreen / on all spaces) is a + `collectionBehavior` tweak; both benefits are inherently interactive and + can't be verified in a headless/CI build, so they belong with hands-on + paste testing. + Until then the palette is a normal always-on-top window: it works, it just + activates the app on show and won't float over another app's fullscreen + space. ## Phase 3 — Feature backends (each independent; fill in over time) From f51136a21de1125e8da62ecf37aee12111061609 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:21:43 +0100 Subject: [PATCH 06/13] fix(macos): round palette corners + fix WKWebView list scrolling - ResultsList: replace scrollIntoView({block:'nearest'}) with deterministic scrollTop math. WKWebView (macOS) recenters on 'nearest', making the selection jump to the middle when paging past the bottom of a long list; the manual computation behaves identically on Chromium (Windows/Linux) and WebKit (macOS) and only scrolls the minimum needed. - Palette: add borderRadius:12 to the container on macOS/Linux (Windows rounds the native frame via DWM). Matches the 12px native vibrancy radius on macOS, so the corners are actually rounded. Co-Authored-By: Claude Opus 4.8 --- src/components/Palette.tsx | 5 +++++ src/components/ResultsList.tsx | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/Palette.tsx b/src/components/Palette.tsx index 1eb82cb..20f83b8 100644 --- a/src/components/Palette.tsx +++ b/src/components/Palette.tsx @@ -275,6 +275,7 @@ const PROVIDER_DEBOUNCE_MS = 150 // which can be resized in place; a plain setSize is enough. On Windows we also // lock min == max so the user can't drag-resize it. const IS_LINUX = typeof navigator !== 'undefined' && navigator.userAgent.includes('Linux') +const IS_MAC = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') interface PaletteProps { config: AppConfig @@ -1315,6 +1316,10 @@ export default function Palette({ background: 'var(--bg)', backdropFilter: 'blur(60px) saturate(180%)', WebkitBackdropFilter: 'blur(60px) saturate(180%)', + // Windows rounds the native window frame (DWM, in the Rust setup), so + // only round in CSS on platforms that don't: macOS (matches the 12px + // vibrancy radius applied natively) and Linux (layer-shell surface). + borderRadius: IS_MAC || IS_LINUX ? 12 : undefined, display: 'flex', flexDirection: 'column', fontFamily: 'var(--font)', diff --git a/src/components/ResultsList.tsx b/src/components/ResultsList.tsx index daa07d5..1f45d4d 100644 --- a/src/components/ResultsList.tsx +++ b/src/components/ResultsList.tsx @@ -13,8 +13,23 @@ export default function ResultsList({ items, selectedIndex, onSelect, onHover }: const listRef = useRef(null) const selectedRef = useRef(null) + // Manual scroll instead of Element.scrollIntoView({ block: 'nearest' }): + // WKWebView (macOS) interprets 'nearest' by recentering the element, which + // makes the selection jump to the middle when paging past the bottom of a long + // list. Computing scrollTop from the rects is deterministic across engines + // (Chromium on Windows/Linux, WebKit on macOS) and only scrolls the minimum + // needed to bring the selected row fully into view. useEffect(() => { - selectedRef.current?.scrollIntoView({ block: 'nearest' }) + const container = listRef.current + const el = selectedRef.current + if (!container || !el) return + const cRect = container.getBoundingClientRect() + const eRect = el.getBoundingClientRect() + if (eRect.top < cRect.top) { + container.scrollTop += eRect.top - cRect.top + } else if (eRect.bottom > cRect.bottom) { + container.scrollTop += eRect.bottom - cRect.bottom + } }, [selectedIndex]) return ( From 6b1e487acdcb8085395d08b91181fc954abfbba3 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:27:09 +0100 Subject: [PATCH 07/13] fix(macos): real window transparency via NSWindow alphaValue The CSS-opacity fallback faded the webview onto the opaque native vibrancy layer, so the transparency slider produced a blurred-wallpaper patch rather than genuine see-through. Implement set_window_transparency natively on macOS: set NSWindow.alphaValue (via objc2 msg_send on the main thread), the direct analogue of the Windows LWA_ALPHA path. This makes the whole window (vibrancy + content) translucent, revealing the desktop behind. Frontend: macOS now uses the native invoke path again (only Linux keeps the CSS-opacity fallback). Adds objc2 as a macOS dep, pinned to window-vibrancy's 0.5 so only one copy builds. Co-Authored-By: Claude Opus 4.8 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 +++ src-tauri/src/commands/window.rs | 26 +++++++++++++++++++++++++- src/lib/tauri.ts | 11 +++++------ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d83d4f6..884acb5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -559,6 +559,7 @@ dependencies = [ "gtk-layer-shell", "image", "notify", + "objc2 0.5.2", "rayon", "reqwest 0.12.28", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c82cd11..4ddbf93 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -75,3 +75,6 @@ gtk-layer-shell = { version = "0.8", features = ["v0_6"] } # Translucent NSVisualEffect background + rounded corners for the palette # (the macOS analogue of the Windows "acrylic" windowEffect). window-vibrancy = "0.5" +# Set NSWindow.alphaValue for the transparency slider (msg_send). Version pinned +# to match window-vibrancy's objc2 so only one copy is built. +objc2 = "0.5" diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index c7801f4..33f10e5 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -35,7 +35,31 @@ pub async fn set_window_transparency(transparency: f64, window: tauri::Window) - } } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + // Native NSWindow alpha — the macOS analogue of the Windows LWA_ALPHA + // above. Setting alphaValue makes the whole window (vibrancy + content) + // genuinely translucent, revealing the desktop behind, instead of just + // fading the webview onto the opaque vibrancy layer (which reads as a + // blurred wallpaper patch, not transparency). AppKit must be touched on + // the main thread; the raw NSWindow pointer isn't Send, so it crosses + // the closure boundary as a usize. + let alpha = 1.0 - transparency.clamp(0.0, 1.0); + let ns_window = window + .ns_window() + .map_err(|_| "Failed to get NSWindow".to_string())? as usize; + window + .run_on_main_thread(move || { + use objc2::runtime::AnyObject; + let ns_window = ns_window as *mut AnyObject; + unsafe { + let _: () = objc2::msg_send![ns_window, setAlphaValue: alpha]; + } + }) + .map_err(|e| e.to_string()) + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { // Linux never reaches here: Wayland has no whole-window alpha, so the // frontend applies CSS opacity to the webview root instead (the window diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 37123b9..56f03ac 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -293,15 +293,14 @@ export const readThemes = () => invoke('read_themes') const IS_LINUX = typeof navigator !== 'undefined' && navigator.userAgent.includes('Linux') -const IS_MAC = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') export const setWindowTransparency = (transparency: number) => { - if (IS_LINUX || IS_MAC) { + if (IS_LINUX) { // Wayland/cosmic-comp has no whole-window alpha (and GTK3 toplevel opacity - // is a no-op there); macOS has no native layered-window alpha command - // (the palette is a transparent window + vibrancy). In both cases the - // window background is already transparent, so fading the webview root is - // visually equivalent to the Windows layered-window alpha. + // is a no-op there). The window background is already fully transparent, + // so fading the webview root is visually equivalent to the Windows + // layered-window alpha. macOS instead sets the native NSWindow alphaValue + // (see set_window_transparency in commands/window.rs). const t = Math.min(1, Math.max(0, transparency)) document.documentElement.style.opacity = String(1 - t) return Promise.resolve() From 7a5b40dcec81d1b7df99465fa5dc89f8c8bf95b2 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:27:27 +0100 Subject: [PATCH 08/13] docs: record on-device Phase 2 fixes (corners, scrolling, native transparency) Co-Authored-By: Claude Opus 4.8 --- docs/macos-port.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/macos-port.md b/docs/macos-port.md index 2773ad2..9bccb59 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -122,9 +122,17 @@ Screen Recording permission): / `work_area` APIs. The palette opens centered on the display under the cursor, top at ~20% of the work area. The Win32 path is left untouched (no Windows regression); both show paths call it on `windows|macos`. -- [x] **`set_window_transparency`** — macOS now routes through the Linux-style - CSS-opacity fallback (added `IS_MAC` in `src/lib/tauri.ts`) instead of the - Windows-only native invoke that errored. The transparency setting works. +- [x] **`set_window_transparency`** — implemented natively on macOS: sets + `NSWindow.alphaValue` (objc2 `msg_send` on the main thread), the analogue of + the Windows `LWA_ALPHA` path, for genuine see-through. (An initial + CSS-opacity fallback was wrong here — it faded the webview onto the opaque + vibrancy layer, reading as a blurred-wallpaper patch rather than + transparency.) Only Linux keeps the CSS-opacity path. +- [x] **On-device fixes** (found testing Phase 2): rounded palette corners + (CSS `border-radius` on macOS/Linux; Windows rounds via DWM) and a + long-list scrolling bug (WKWebView recenters on + `scrollIntoView({block:'nearest'})` — replaced with deterministic + `scrollTop` math that behaves the same on every engine). - [ ] **Non-activating panel (`tauri-nspanel`) — DEFERRED to Phase 3.** Rationale: 1. The plugin is **git-only** (branch-pinned, not on crates.io) — a dependency-stability decision worth making deliberately, not by default. From e8fdc73cae9d4448c642973eaada54dce5588550 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:50:24 +0100 Subject: [PATCH 09/13] =?UTF-8?q?feat(macos):=20Phase=203=20=E2=80=94=20na?= =?UTF-8?q?tive=20feature=20backends?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every stubbed/Linux-only backend now has a macOS arm: - launcher: scan ~/Applications, /Applications, /System/Applications for .app bundles (no descent into bundles; user apps shadow system ones); launch via `open` (LaunchServices activates running instances) - screenshot: `screencapture -x -R` (points from Tauri monitor APIs; PNG arrives at native Retina pixels). Overlay reuses the Windows physical positioning; its NSWindow is raised to screen-saver level in setup so it covers the menu bar without constrainFrameRect clamping, and click-away cancel now applies on macOS. Clipboard copy uses the arboard arm (wl-copy stays Linux-only) - paste to previous: NSWorkspace frontmost-app pid captured on palette show; paste reactivates it (main thread) then posts ⌘V CGEvents at the HID tap, with an actionable error when Accessibility isn't granted - audio: osascript get/set volume + mute on the default output, exposed as a single "System Output" pseudo-device - system: pmset lock/sleep; graceful shutdown/restart/logout via System Events; Finder empty-trash; Hibernate errors as not-a-macOS-concept - process: sysinfo list + libc SIGKILL (new macOS-only deps, with core-graphics for CGEvent) - scripts: .command runs via `open` (Terminal); fallback opener is `open` instead of xdg-open; gio/.desktop gated to Linux Adds macOS smoke tests (app scan, volume round-trip, process list) — cargo test green. crypto.rs verified portable (plaintext passthrough, nothing Linux-specific). tauri-nspanel deliberately not adopted: explicit reactivation makes it unnecessary for paste. Co-Authored-By: Claude Fable 5 --- docs/macos-port.md | 108 +++++++++++++++++------- src-tauri/Cargo.lock | 94 ++++++++++++++++++++- src-tauri/Cargo.toml | 6 ++ src-tauri/src/commands/audio.rs | 101 ++++++++++++++++++++-- src-tauri/src/commands/fs.rs | 21 ++++- src-tauri/src/commands/launcher.rs | 102 ++++++++++++++++++++++- src-tauri/src/commands/paste.rs | 120 ++++++++++++++++++++++++++- src-tauri/src/commands/process.rs | 66 ++++++++++++++- src-tauri/src/commands/screenshot.rs | 68 +++++++++++++-- src-tauri/src/commands/system.rs | 55 +++++++++++- src-tauri/src/lib.rs | 25 +++++- 11 files changed, 706 insertions(+), 60 deletions(-) diff --git a/docs/macos-port.md b/docs/macos-port.md index 9bccb59..1c3c2e0 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -1,9 +1,11 @@ # Commandeer → macOS port plan -Status: **Phase 1 complete** — Commandeer compiles, links, and launches on macOS -with a transparent palette window. Phases 2–4 (UX parity, feature backends, -packaging) remain. Commandeer targets **Windows**, **Linux (Wayland/COSMIC)**, -and now **macOS** from the same codebase. +Status: **Phases 1–3 complete** — Commandeer runs on macOS with UX parity +(vibrancy, tray, hotkey, positioning) and native feature backends (launcher, +screenshot, paste, audio, system actions, processes). Phase 4 (packaging & +sync hygiene) remains, plus on-device permission grants to verify +screenshot/paste end-to-end. Commandeer targets **Windows**, **Linux +(Wayland/COSMIC)**, and now **macOS** from the same codebase. ## The core idea (how it stays "in-line" across platforms) @@ -90,7 +92,7 @@ in a single session; the feasibility risk was as small as expected. shell-outs → Phase 3. - No tray icon on macOS (tray is Windows-only) → Phase 2. -## Phase 2 — Core UX parity — IN PROGRESS +## Phase 2 — Core UX parity — DONE Done in this pass (compiles clean; app launches, toggles the palette via the single-instance path, and survives show/hide with no panic — visual confirmation @@ -147,31 +149,77 @@ Screen Recording permission): activates the app on show and won't float over another app's fullscreen space. -## Phase 3 — Feature backends (each independent; fill in over time) - -| Feature | File | macOS approach | -|---|---|---| -| App launcher list | `launcher.rs` | Scan `/Applications`, `/System/Applications`, `~/Applications` for `.app` bundles | -| Launch app | `launcher.rs` | `open ` / `open -a` | -| Screenshot capture | `screenshot.rs` | Built-in `screencapture -x -t png ` (replaces `cosmic-screenshot`) | -| Screenshot → clipboard | `screenshot.rs` | `arboard` image set (replaces `wl-copy`), or `osascript` | -| Paste to previous | `paste.rs` | Simulate ⌘V via `CGEvent` — **needs Accessibility permission** | -| Audio volume | `audio.rs` | `osascript -e 'set volume output volume ...'` (quick) or CoreAudio (proper) | -| System actions | `system.rs` | `osascript` / `pmset sleepnow` / lock via `pmset displaysleepnow`; empty trash via Finder AppleScript | -| Process list/kill | `process.rs` | Add cross-platform `sysinfo` crate + `libc::kill` (could unify all 3 OSes) | -| File/app icons | `icons.rs` | `NSWorkspace.icon(forFile:)` → PNG; defer (return `None` like Linux initially) | -| Everything search | `search.rs` | N/A — Windows-only indexer; macOS uses the built-in `file_index` (cross-platform) | -| Script types / `scripts_dir` | `fs.rs` / `config.rs` | Add macOS default dir + `.sh` / `.command` / executables | - -**macOS's genuinely hard part — permissions.** Paste (keystroke synthesis) needs -**Accessibility**; `screencapture` of other windows may need **Screen -Recording**; global hotkeys can need **Input Monitoring**. These require user -approval in System Settings and behave differently for unsigned dev builds. -Budget time here — it's the one area with no Windows/Linux analog. - -- [ ] **Verify `clipboard/crypto.rs`** — the clipboard-history encryption has cfg - gates; confirm the `not(windows)` key path isn't Linux-keyring-specific - (may need a macOS Keychain or portable arm). +## Phase 3 — Feature backends — DONE + +All backends implemented in one pass. `cargo build` clean; `cargo test` green, +including new macOS smoke tests (launcher scan, osascript volume round-trip, +process enumeration); app relaunched and palette toggled with the new +capture-foreground path. What could not be scripted is the permission-gated +interactive verification (see below). + +- [x] **App launcher** (`launcher.rs`) — scan `~/Applications`, + `/Applications`, `/System/Applications` (depth 3, never descending into + a bundle; user-installed apps shadow system ones by name). Launch via + `open`, which goes through LaunchServices and activates an already- + running instance instead of starting a second copy. +- [x] **Screenshot capture** (`screenshot.rs`) — `screencapture -x -t png + -R` of the cursor monitor; rect computed from Tauri's monitor APIs + (physical ÷ scale = points; verified the PNG comes back at native 2× + Retina pixels, which is what the overlay's physical sizing expects). + Overlay positioning now shared with Windows (`monitor_origin`); in + `.setup()` the overlay NSWindow is raised to the screen-saver level + (1000) so it can cover the menu-bar strip (normal-level frames get + clamped below it by constrainFrameRect, which would misalign the + region→pixel mapping) and joins all Spaces/fullscreen. Click-away + cancel enabled on macOS (was Windows-only). +- [x] **Screenshot → clipboard** — arboard image offer, same arm as Windows; + only Linux keeps the `wl-copy` shell-out. +- [x] **Paste to previous** (`paste.rs`) — frontmost app pid captured via + NSWorkspace when the palette shows; paste = clipboard → reactivate that + app (NSRunningApplication, main thread) → 150 ms beat → ⌘V posted as + CGEvents at the HID tap. Checks `AXIsProcessTrusted` first and returns a + pointed "grant Accessibility" error instead of silently doing nothing. +- [x] **Audio volume** (`audio.rs`) — osascript `set volume` / `get volume + settings`. AppleScript only addresses the default output, so macOS + exposes one pseudo-device ("System Output"); per-device control would + need CoreAudio proper (follow-up if ever wanted). +- [x] **System actions** (`system.rs`) — Lock/Sleep via `pmset` + (`displaysleepnow` / `sleepnow`); Shutdown/Restart/Logout via System + Events AppleScript (graceful — apps get to save; one-time Automation + prompt); Empty Trash via Finder AppleScript. Hibernate returns a "not a + macOS concept" error. +- [x] **Process list/kill** (`process.rs`) — `sysinfo` (macOS-only dep) for + the list, `libc::kill(SIGKILL)` to match TerminateProcess semantics. + The Windows/Linux arms were deliberately left untouched rather than + unified. +- [x] **File/app icons** (`icons.rs`) — deferred as planned; `path_icon` + returns `None` on macOS like Linux. `NSWorkspace.icon(forFile:)` is the + follow-up. +- [x] **Script types** (`fs.rs`) — `.command` files surfaced and launched via + `open` (opens Terminal, like double-clicking; checked before the + executable-bit test so they don't run invisibly). Generic fallback + opener is `open` instead of `xdg-open` on macOS; `.desktop`/`gio` gated + to Linux. `scripts_dir` default needed no change (exe walk-up + + `~/commandeer/commands` are portable). +- [x] **Verified `clipboard/crypto.rs`** — the `not(windows)` arm is a plain + passthrough placeholder, not Linux-keyring-specific; compiles and runs + on macOS as-is. macOS Keychain encryption is a possible follow-up. +- [x] **nspanel decision (deferred from Phase 2b) — not adopted.** + Paste-to-previous works without it: the previous app is explicitly + reactivated before ⌘V, so not-stealing-focus isn't required. Skipping + it avoids the git-only, branch-pinned dependency. Revisit only if + focus-return proves flaky in practice or show-over-fullscreen-apps + becomes a priority for the palette itself. + +**On-device verification still owed (permissions can't be scripted):** +- Screenshot: needs **Screen Recording** for the frozen frame to include other + apps' windows (without it macOS silently captures just the wallpaper). In + dev the permission attaches to the invoking terminal/IDE; for a bundled + build, to the app. +- Paste: needs **Accessibility**; until granted the command errors with + instructions rather than no-opping. +- Shutdown/Restart/Logout/Empty Trash: one-time **Automation** prompts on + first use (System Events / Finder). ## Phase 4 — Packaging & sync hygiene diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 884acb5..4c109fd 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -555,9 +555,11 @@ version = "0.1.0" dependencies = [ "arboard", "chrono", + "core-graphics 0.24.0", "gtk", "gtk-layer-shell", "image", + "libc", "notify", "objc2 0.5.2", "rayon", @@ -565,6 +567,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -640,6 +643,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.0", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -2616,6 +2632,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4376,6 +4401,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4411,7 +4450,7 @@ dependencies = [ "bitflags 2.11.0", "block2 0.6.2", "core-foundation", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dispatch2", "dlopen2", @@ -5723,6 +5762,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.58.0" @@ -5755,6 +5804,18 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.58.0" @@ -5805,6 +5866,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.58.0" @@ -5827,6 +5899,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.58.0" @@ -5882,6 +5965,15 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.2.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4ddbf93..aa949d1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -78,3 +78,9 @@ window-vibrancy = "0.5" # Set NSWindow.alphaValue for the transparency slider (msg_send). Version pinned # to match window-vibrancy's objc2 so only one copy is built. objc2 = "0.5" +# Synthesize the ⌘V keystroke for paste-to-previous (CGEvent). +core-graphics = "0.24" +# Process list for the process manager (the macOS analogue of EnumProcesses). +sysinfo = "0.33" +# kill(2) for the process manager's force-kill. +libc = "0.2" diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index c1312cd..1d37294 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -1,5 +1,6 @@ -//! System volume control via the Windows Core Audio API (IAudioEndpointVolume, -//! per render endpoint). Backs the per-device volume sliders and mute toggle. +//! System volume control. Windows: Core Audio API (IAudioEndpointVolume, per +//! render endpoint), backing per-device volume sliders and a mute toggle. +//! macOS: osascript `set volume`, default output only (see `mod mac`). //! //! Endpoints are re-activated per call rather than cached: devices can appear //! or vanish at any time (headphones plugged in, Bluetooth connects), @@ -27,7 +28,11 @@ pub async fn list_audio_devices() -> Result, String> { .await .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + mac::list_devices() + } + #[cfg(target_os = "linux")] { Err("volume control is only implemented on Windows".to_string()) } @@ -43,7 +48,14 @@ pub async fn get_volume(device: Option) -> Result { .await .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + let _ = device; // osascript only addresses the default output + tokio::task::spawn_blocking(mac::get_volume) + .await + .map_err(|e| e.to_string())? + } + #[cfg(target_os = "linux")] { let _ = device; Err("volume control is only implemented on Windows".to_string()) @@ -61,7 +73,15 @@ pub async fn set_volume(level: f32, device: Option) -> Result<(), String .await .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + let _ = device; // osascript only addresses the default output + let level = level.clamp(0.0, 1.0); + tokio::task::spawn_blocking(move || mac::set_volume(level)) + .await + .map_err(|e| e.to_string())? + } + #[cfg(target_os = "linux")] { let _ = (level, device); Err("volume control is only implemented on Windows".to_string()) @@ -79,13 +99,82 @@ pub async fn toggle_mute(device: Option) -> Result { .await .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + let _ = device; // osascript only addresses the default output + tokio::task::spawn_blocking(mac::toggle_mute) + .await + .map_err(|e| e.to_string())? + } + #[cfg(target_os = "linux")] { let _ = device; Err("volume control is only implemented on Windows".to_string()) } } +/// osascript-backed volume control. AppleScript's `set volume` only addresses +/// the current default output, so a single pseudo-device is exposed; per-device +/// control would need CoreAudio proper (follow-up if ever wanted). Each call +/// shells out (~50 ms), which is fine for slider/toggle interaction rates. +#[cfg(target_os = "macos")] +mod mac { + use super::AudioDevice; + + fn osascript(script: &str) -> Result { + let out = std::process::Command::new("osascript") + .args(["-e", script]) + .output() + .map_err(|e| format!("osascript failed to run: {e}"))?; + if !out.status.success() { + return Err(format!( + "osascript failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + pub fn list_devices() -> Result, String> { + Ok(vec![AudioDevice { + id: "default".to_string(), + name: "System Output".to_string(), + is_default: true, + }]) + } + + pub fn get_volume() -> Result { + let out = osascript("output volume of (get volume settings)")?; + let pct: f32 = out + .parse() + .map_err(|_| format!("unexpected volume reading: {out}"))?; + Ok((pct / 100.0).clamp(0.0, 1.0)) + } + + pub fn set_volume(level: f32) -> Result<(), String> { + let pct = (level * 100.0).round() as i32; + osascript(&format!("set volume output volume {pct}")).map(|_| ()) + } + + pub fn toggle_mute() -> Result { + let muted = osascript("output muted of (get volume settings)")? == "true"; + osascript(&format!("set volume output muted {}", !muted))?; + Ok(!muted) + } + + #[cfg(test)] + mod tests { + // Mirrors the Windows smoke test: real osascript round-trip; writing + // back the level just read changes nothing audible. + #[test] + fn smoke_volume_roundtrip() { + let level = super::get_volume().expect("get_volume"); + assert!((0.0..=1.0).contains(&level), "level {level}"); + super::set_volume(level).expect("set_volume"); + } + } +} + #[cfg(target_os = "windows")] mod win { use super::AudioDevice; diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 58f0a7b..3ee3a95 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -20,8 +20,9 @@ pub struct ScriptInfo { /// Whether a directory entry should be surfaced as a runnable command. /// /// Windows: batch/command scripts, shell shortcuts, and VS Code workspaces. -/// Unix: shell scripts, `.desktop` launchers, VS Code workspaces, AppImages, -/// or any regular file with the executable bit set. +/// Unix: shell scripts, `.desktop` launchers, VS Code workspaces, AppImages +/// (macOS: `.command` Terminal scripts), or any regular file with the +/// executable bit set. fn is_script_file(path: &Path) -> bool { let ext = path.extension().and_then(|x| x.to_str()); @@ -41,6 +42,10 @@ fn is_script_file(path: &Path) -> bool { ) { return true; } + #[cfg(target_os = "macos")] + if ext == Some("command") { + return true; + } is_executable(path) } } @@ -504,7 +509,7 @@ pub async fn run_script(path: String) -> Result<(), String> { .unwrap_or("") .to_lowercase(); - let mut cmd = if ext == "desktop" { + let mut cmd = if cfg!(target_os = "linux") && ext == "desktop" { // Launch a .desktop entry through the desktop's app launcher. let mut c = std::process::Command::new("gio"); c.arg("launch").arg(&path); @@ -513,6 +518,13 @@ pub async fn run_script(path: String) -> Result<(), String> { let mut c = std::process::Command::new("code"); c.arg(&path); c + } else if cfg!(target_os = "macos") && ext == "command" { + // Open in Terminal via LaunchServices, like double-clicking it — + // direct exec would run it invisibly. (Checked before the + // executable-bit test: .command files usually carry it.) + let mut c = std::process::Command::new("open"); + c.arg(&path); + c } else if is_executable(script_path) { // Directly runnable: scripts with a shebang, AppImages, binaries. std::process::Command::new(&path) @@ -523,7 +535,8 @@ pub async fn run_script(path: String) -> Result<(), String> { c } else { // Fall back to the desktop's default handler for the file type. - let mut c = std::process::Command::new("xdg-open"); + let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; + let mut c = std::process::Command::new(opener); c.arg(&path); c }; diff --git a/src-tauri/src/commands/launcher.rs b/src-tauri/src/commands/launcher.rs index 8e230b9..8645c0c 100644 --- a/src-tauri/src/commands/launcher.rs +++ b/src-tauri/src/commands/launcher.rs @@ -108,6 +108,70 @@ fn start_menu_entries() -> Vec { out } +/// Scan the standard application folders for `.app` bundles. Earlier roots win +/// on name collisions (a user-installed app shadows the system copy). +#[cfg(target_os = "macos")] +fn mac_app_entries() -> Vec { + use std::path::PathBuf; + + let mut roots: Vec = Vec::new(); + if let Some(home) = std::env::var_os("HOME") { + roots.push(PathBuf::from(home).join("Applications")); + } + roots.push(PathBuf::from("/Applications")); + roots.push(PathBuf::from("/System/Applications")); + + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for root in roots { + // Depth 3 covers subfolders like /Applications/Utilities without + // crawling the whole disk. + let mut walker = walkdir::WalkDir::new(&root) + .max_depth(3) + .follow_links(false) + .into_iter(); + while let Some(entry) = walker.next() { + let Ok(entry) = entry else { continue }; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("app") { + continue; + } + // An .app is one launchable unit — never descend into the bundle + // (helper apps inside would show up as bogus entries). + if entry.file_type().is_dir() { + walker.skip_current_dir(); + } + let Some(name) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + if !seen.insert(name.to_lowercase()) { + continue; + } + out.push(AppEntry { + name: name.to_string(), + path: path.to_string_lossy().into_owned(), + }); + } + } + out +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + // Exercises the real filesystem walk over the application folders. + #[test] + fn smoke_mac_app_entries() { + let apps = super::mac_app_entries(); + assert!(!apps.is_empty(), "no .app bundles found"); + assert!(apps.iter().all(|a| a.path.ends_with(".app"))); + // Ships with every macOS install, lives under /System/Applications. + assert!( + apps.iter().any(|a| a.name == "Calculator"), + "expected a stock app in the list" + ); + } +} + #[tauri::command] pub async fn list_apps() -> Result, String> { #[cfg(target_os = "windows")] @@ -123,7 +187,18 @@ pub async fn list_apps() -> Result, String> { .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + tokio::task::spawn_blocking(|| { + let mut apps = mac_app_entries(); + apps.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(apps) + }) + .await + .map_err(|e| e.to_string())? + } + + #[cfg(target_os = "linux")] { Ok(vec![]) } @@ -168,7 +243,30 @@ pub async fn run_app(path: String) -> Result<(), String> { .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + tokio::task::spawn_blocking(move || { + // `open` goes through LaunchServices: it activates a running + // instance instead of launching a second one, same as Finder. + let out = std::process::Command::new("open") + .arg(&path) + .output() + .map_err(|e| format!("open failed to run: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(format!( + "failed to launch '{}': {}", + path, + String::from_utf8_lossy(&out.stderr).trim() + )) + } + }) + .await + .map_err(|e| e.to_string())? + } + + #[cfg(target_os = "linux")] { let _ = path; Err("run_app is only implemented on Windows".to_string()) diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index e51b6ac..360d8b6 100644 --- a/src-tauri/src/commands/paste.rs +++ b/src-tauri/src/commands/paste.rs @@ -9,6 +9,11 @@ use tauri::Manager; #[cfg(target_os = "windows")] static PREV_FOREGROUND: std::sync::atomic::AtomicIsize = std::sync::atomic::AtomicIsize::new(0); +/// macOS analogue of `PREV_FOREGROUND`: pid of the app that was frontmost when +/// the palette was shown. 0 = nothing captured. +#[cfg(target_os = "macos")] +static PREV_APP_PID: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(0); + /// Called right before the palette is shown. pub fn capture_foreground() { #[cfg(target_os = "windows")] @@ -17,6 +22,77 @@ pub fn capture_foreground() { let hwnd = GetForegroundWindow(); PREV_FOREGROUND.store(hwnd.0 as isize, std::sync::atomic::Ordering::Relaxed); } + + #[cfg(target_os = "macos")] + unsafe { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + let Some(cls) = AnyClass::get("NSWorkspace") else { + return; + }; + let ws: *mut AnyObject = msg_send![cls, sharedWorkspace]; + let pid: i32 = if ws.is_null() { + 0 + } else { + let front: *mut AnyObject = msg_send![ws, frontmostApplication]; + if front.is_null() { + 0 + } else { + msg_send![front, processIdentifier] + } + }; + PREV_APP_PID.store(pid, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Bring the previously-frontmost app back to the front. AppKit call, so this +/// must run on the main thread. +#[cfg(target_os = "macos")] +fn activate_app(pid: i32) -> Result<(), String> { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + unsafe { + let cls = AnyClass::get("NSRunningApplication") + .ok_or("NSRunningApplication class not found")?; + let target: *mut AnyObject = msg_send![cls, runningApplicationWithProcessIdentifier: pid]; + if target.is_null() { + return Err(format!("previous app (pid {pid}) is no longer running")); + } + // NSApplicationActivateIgnoringOtherApps (1 << 1) + let _: bool = msg_send![target, activateWithOptions: 2usize]; + Ok(()) + } +} + +/// Post ⌘V as HID-level keyboard events. Requires the Accessibility permission +/// (System Settings → Privacy & Security → Accessibility); without it the +/// events are silently dropped, so fail loudly instead. +#[cfg(target_os = "macos")] +fn synthesize_cmd_v() -> Result<(), String> { + use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation}; + use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + } + if !unsafe { AXIsProcessTrusted() } { + return Err( + "pasting needs the Accessibility permission: System Settings → Privacy & Security → Accessibility" + .to_string(), + ); + } + + let src = CGEventSource::new(CGEventSourceStateID::HIDSystemState) + .map_err(|_| "CGEventSource creation failed".to_string())?; + const KEY_V: u16 = 9; // kVK_ANSI_V + for down in [true, false] { + let ev = CGEvent::new_keyboard_event(src.clone(), KEY_V, down) + .map_err(|_| "CGEvent creation failed".to_string())?; + ev.set_flags(CGEventFlags::CGEventFlagCommand); + ev.post(CGEventTapLocation::HID); + } + Ok(()) } #[cfg(target_os = "windows")] @@ -47,9 +123,9 @@ pub unsafe fn force_foreground(hwnd: windows::Win32::Foundation::HWND) { } } -/// Hide the palette, put `text` on the clipboard, refocus the window that was -/// active when the palette opened, and synthesize Ctrl+V. On non-Windows this -/// degrades to copy-only. +/// Hide the palette, put `text` on the clipboard, refocus the window/app that +/// was active when the palette opened, and synthesize Ctrl+V (⌘V on macOS). +/// On Linux this degrades to copy-only. #[tauri::command] pub async fn paste_to_previous(app: tauri::AppHandle, text: String) -> Result<(), String> { if let Some(win) = app.get_webview_window("palette") { @@ -106,7 +182,43 @@ pub async fn paste_to_previous(app: tauri::AppHandle, text: String) -> Result<() .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + // Clipboard first, so the target app reads the new text. + let clip_text = text; + tokio::task::spawn_blocking(move || { + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; + clipboard.set_text(clip_text).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let pid = PREV_APP_PID.load(std::sync::atomic::Ordering::Relaxed); + if pid == 0 { + return Err("no previous app to paste into".to_string()); + } + + // Explicitly reactivate the previous app rather than relying on the + // focus falling back to it when the palette hides — deterministic, and + // it also works when something else grabbed focus in between. + let (tx, rx) = tokio::sync::oneshot::channel(); + app.run_on_main_thread(move || { + let _ = tx.send(activate_app(pid)); + }) + .map_err(|e| format!("main thread dispatch failed: {e}"))?; + rx.await + .map_err(|e| format!("activation channel closed: {e}"))??; + + tokio::task::spawn_blocking(|| { + // Give the target app a beat to become key before ⌘V. + std::thread::sleep(std::time::Duration::from_millis(150)); + synthesize_cmd_v() + }) + .await + .map_err(|e| e.to_string())? + } + + #[cfg(target_os = "linux")] { std::thread::spawn(move || { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands/process.rs b/src-tauri/src/commands/process.rs index 1775d6c..f512b01 100644 --- a/src-tauri/src/commands/process.rs +++ b/src-tauri/src/commands/process.rs @@ -86,12 +86,59 @@ pub async fn list_processes() -> Result, String> { .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + tokio::task::spawn_blocking(|| { + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; + + let mut sys = System::new(); + sys.refresh_processes_specifics( + ProcessesToUpdate::All, + true, + ProcessRefreshKind::nothing() + .with_memory() + .with_exe(UpdateKind::OnlyIfNotSet), + ); + let out = sys + .processes() + .iter() + .map(|(pid, p)| ProcessInfo { + pid: pid.as_u32(), + name: p.name().to_string_lossy().into_owned(), + memory_bytes: p.memory(), + exe_path: p.exe().map(|e| e.to_string_lossy().into_owned()), + }) + .collect(); + Ok(out) + }) + .await + .map_err(|e| e.to_string())? + } + + #[cfg(target_os = "linux")] { Ok(vec![]) } } +#[cfg(all(test, target_os = "macos"))] +mod tests { + // Real sysinfo enumeration; the test process itself must be in the list. + #[test] + fn smoke_list_processes() { + let procs = tokio::runtime::Runtime::new() + .unwrap() + .block_on(super::list_processes()) + .expect("list_processes"); + assert!(!procs.is_empty(), "no processes listed"); + let me = std::process::id(); + assert!( + procs.iter().any(|p| p.pid == me), + "own pid {me} missing from the process list" + ); + } +} + #[tauri::command] pub async fn kill_process(pid: u32) -> Result<(), String> { #[cfg(target_os = "windows")] @@ -113,7 +160,22 @@ pub async fn kill_process(pid: u32) -> Result<(), String> { .map_err(|e| e.to_string())? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + // SIGKILL to match the Windows TerminateProcess semantics: forceful, + // no chance for the target to ignore it. + let ret = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + if ret == 0 { + Ok(()) + } else { + Err(format!( + "could not kill process {pid}: {}", + std::io::Error::last_os_error() + )) + } + } + + #[cfg(target_os = "linux")] { let _ = pid; Err("kill_process is only implemented on Windows".to_string()) diff --git a/src-tauri/src/commands/screenshot.rs b/src-tauri/src/commands/screenshot.rs index b246a6e..895dbfc 100644 --- a/src-tauri/src/commands/screenshot.rs +++ b/src-tauri/src/commands/screenshot.rs @@ -3,9 +3,9 @@ //! and save it under Pictures/Screenshots. //! //! Capture backends: `cosmic-screenshot` (portal CLI) on Linux, GDI BitBlt of -//! the cursor monitor on Windows. The frozen frame is written to -//! `/frame.png` and served to the overlay webview via the asset -//! protocol. +//! the cursor monitor on Windows, `screencapture -R` of the cursor monitor on +//! macOS. The frozen frame is written to `/frame.png` and served to +//! the overlay webview via the asset protocol. use std::path::PathBuf; use std::sync::Mutex; @@ -17,7 +17,7 @@ pub struct Capture { pub width: u32, pub height: u32, /// Physical origin of the captured monitor (for overlay positioning). - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] pub monitor_origin: (i32, i32), } @@ -45,7 +45,7 @@ fn frame_path(app: &AppHandle) -> Result { Ok(dir.join("frame.png")) } -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn capture_frame(app: &AppHandle) -> Result { let dest = frame_path(app)?; let dir = dest.parent().unwrap().to_path_buf(); @@ -78,6 +78,56 @@ fn capture_frame(app: &AppHandle) -> Result { }) } +/// Built-in `screencapture` of the monitor under the cursor. `-R` takes the +/// rect in global logical points (Tauri's monitor APIs report physical pixels, +/// so divide by the scale factor); the PNG comes out at native Retina pixels, +/// matching the physical sizing the overlay uses. Capturing other apps' +/// windows needs the Screen Recording permission — without it macOS silently +/// yields just the wallpaper (and prompts once for dev builds, per invoking +/// binary). +#[cfg(target_os = "macos")] +fn capture_frame(app: &AppHandle) -> Result { + let dest = frame_path(app)?; + + let cursor = app.cursor_position().map_err(|e| e.to_string())?; + let monitor = app + .monitor_from_point(cursor.x, cursor.y) + .ok() + .flatten() + .or_else(|| app.primary_monitor().ok().flatten()) + .ok_or("no monitor found")?; + let scale = monitor.scale_factor(); + let pos = monitor.position(); + let size = monitor.size(); + let rect = format!( + "-R{},{},{},{}", + (pos.x as f64 / scale).round(), + (pos.y as f64 / scale).round(), + (size.width as f64 / scale).round(), + (size.height as f64 / scale).round(), + ); + + let out = std::process::Command::new("screencapture") + .args(["-x", "-t", "png", &rect]) + .arg(&dest) + .output() + .map_err(|e| format!("screencapture failed to run: {e}"))?; + if !out.status.success() { + return Err(format!( + "screencapture failed: {}", + String::from_utf8_lossy(&out.stderr) + )); + } + + let (width, height) = image::image_dimensions(&dest).map_err(|e| e.to_string())?; + Ok(Capture { + frame_path: dest, + width, + height, + monitor_origin: (pos.x, pos.y), + }) +} + /// GDI capture of the monitor under the cursor (same monitor the overlay is /// then positioned on, so overlay pixels map 1:1 onto frame pixels). #[cfg(target_os = "windows")] @@ -291,7 +341,7 @@ pub fn show_screenshot_overlay(app: AppHandle) -> Result<(), String> { return Ok(()); } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] { let (x, y) = capture.monitor_origin; let _ = win.set_position(tauri::PhysicalPosition::new(x, y)); @@ -351,7 +401,7 @@ pub async fn finish_screenshot(app: AppHandle, region: Region) -> Result Result<(), String> { let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let status = std::process::Command::new("wl-copy") @@ -365,7 +415,9 @@ fn copy_image_to_clipboard(path: &std::path::Path, _img: &image::RgbaImage) -> R Ok(()) } -#[cfg(target_os = "windows")] +/// Windows and macOS keep serving clipboard offers after the process moves on, +/// so arboard's image offer works directly. +#[cfg(any(target_os = "windows", target_os = "macos"))] fn copy_image_to_clipboard(_path: &std::path::Path, img: &image::RgbaImage) -> Result<(), String> { let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?; cb.set_image(arboard::ImageData { diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 02f039a..08be20c 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -34,13 +34,66 @@ pub async fn system_action(action: SystemAction, app: tauri::AppHandle) -> Resul .map_err(|e| format!("system action channel closed: {e}"))? } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + let _ = app; + tokio::task::spawn_blocking(move || mac::run(action)) + .await + .map_err(|e| e.to_string())? + } + + #[cfg(target_os = "linux")] { let _ = (action, app); Err("system actions are only implemented on Windows".to_string()) } } +/// macOS actions shell out: `pmset` for power (no privileges needed) and +/// AppleScript for session actions — System Events shuts down/restarts/logs +/// out *gracefully* (apps get to save), unlike `shutdown(8)`, which also needs +/// root. The System Events and Finder scripts each trigger a one-time +/// Automation permission prompt on first use. +#[cfg(target_os = "macos")] +mod mac { + use super::SystemAction; + + fn run_cmd(program: &str, args: &[&str]) -> Result<(), String> { + let out = std::process::Command::new(program) + .args(args) + .output() + .map_err(|e| format!("{program} failed to run: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(format!( + "{program} failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + )) + } + } + + fn osascript(script: &str) -> Result<(), String> { + run_cmd("osascript", &["-e", script]) + } + + pub fn run(action: SystemAction) -> Result<(), String> { + match action { + // Sleeps the display; with the default "require password after + // sleep" setting that is the lock screen. + SystemAction::Lock => run_cmd("pmset", &["displaysleepnow"]), + SystemAction::Sleep => run_cmd("pmset", &["sleepnow"]), + SystemAction::Hibernate => { + Err("Hibernate is not a separate action on macOS (sleep already writes a safe-sleep image)".to_string()) + } + SystemAction::Shutdown => osascript("tell application \"System Events\" to shut down"), + SystemAction::Restart => osascript("tell application \"System Events\" to restart"), + SystemAction::Logout => osascript("tell application \"System Events\" to log out"), + SystemAction::EmptyTrash => osascript("tell application \"Finder\" to empty trash"), + } + } +} + #[cfg(target_os = "windows")] mod win { use super::SystemAction; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ccfd971..2d95723 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -319,10 +319,10 @@ pub fn run() { } } } - // Windows only: dismiss the screenshot overlay on click-away. On + // Windows/macOS: dismiss the screenshot overlay on click-away. On // Linux the overlay layer's focus semantics are quirky (a spurious // unfocus would make the tool unusable), so Esc is the only out. - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] if win.label() == "screenshot" { if let WindowEvent::Focused(false) = event { if std::env::var_os("COMMANDEER_NO_AUTOHIDE").is_none() @@ -484,6 +484,27 @@ pub fn run() { Some(12.0), ); } + + // The screenshot overlay must cover the *whole* display, + // including the menu-bar strip. At a normal window level AppKit + // both draws the menu bar over it and may clamp the frame below + // the bar (constrainFrameRect), which would misalign the + // region-to-pixel mapping. Raise it to the screen-saver level + // (1000) — only normal-level windows are constrained — and let + // it join every Space, fullscreen apps included. Safe to do + // once here: hide/show doesn't reset either property. + if let Some(win) = app.get_webview_window("screenshot") { + if let Ok(ns_window) = win.ns_window() { + use objc2::runtime::AnyObject; + let ns_window = ns_window as *mut AnyObject; + unsafe { + let _: () = objc2::msg_send![ns_window, setLevel: 1000isize]; + // canJoinAllSpaces (1<<0) | fullScreenAuxiliary (1<<8) + let _: () = + objc2::msg_send![ns_window, setCollectionBehavior: (1u64 | (1u64 << 8))]; + } + } + } } Ok(()) From 3d2e8eaa8f8cd9500fba5f4a5b870c951a17a584 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:01:09 +0100 Subject: [PATCH 10/13] fix(macos): stop hover from stealing selection while typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WKWebView re-fires mouseenter/mousemove when rows re-rank under a stationary cursor, so every keystroke (which resets selection to the top) let the row under the idle mouse yank the selection back — typically to the middle of the list, since the palette opens near the cursor. Chromium doesn't synthesize these events, which is why Windows behaved. Apply the movement guard ResultsGrid already had (ignore events whose coordinates match the last ones) as a container-level mousemove handler in ResultsList and ActionPanel, and remove the per-row/per-cell onMouseEnter handlers that bypassed it. ResultRow's separate hover tint went with it: real hovers now select the row instantly, so the tint only ever showed on synthetic events. Also extract the Phase 2 minimal-scroll fix into lib/scroll.ts and apply it to ActionPanel and ResultsGrid, which still used scrollIntoView({block:'nearest'}) and had the same WKWebView recentering jump when paging. Co-Authored-By: Claude Fable 5 --- src/components/ActionPanel.tsx | 28 +++++++++++++++++-- src/components/ResultRow.tsx | 15 ++++------ src/components/ResultsGrid.tsx | 6 ++-- src/components/ResultsList.tsx | 50 ++++++++++++++++++++++------------ src/lib/scroll.ts | 16 +++++++++++ 5 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 src/lib/scroll.ts diff --git a/src/components/ActionPanel.tsx b/src/components/ActionPanel.tsx index 8ed2905..be69690 100644 --- a/src/components/ActionPanel.tsx +++ b/src/components/ActionPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef } from 'react' import type { ActionItem } from '../types' +import { scrollToReveal } from '../lib/scroll' import { getIconSvg, hasIcon } from './Icon' interface ActionPanelProps { @@ -12,14 +13,37 @@ interface ActionPanelProps { export default function ActionPanel({ items, selectedIndex, onSelect, onHover }: ActionPanelProps) { const listRef = useRef(null) const selectedRef = useRef(null) + const lastMousePos = useRef<{ x: number; y: number } | null>(null) useEffect(() => { - selectedRef.current?.scrollIntoView({ block: 'nearest' }) + scrollToReveal(listRef.current, selectedRef.current) }, [selectedIndex]) + // Movement-guarded hover selection — see ResultsList for why plain + // mouseenter is wrong on WKWebView. + function handleMouseMove(e: React.MouseEvent) { + const pos = { x: e.clientX, y: e.clientY } + const last = lastMousePos.current + if (!last) { + lastMousePos.current = pos + return + } + if (last.x === pos.x && last.y === pos.y) return + lastMousePos.current = pos + + const target = e.target as HTMLElement + const row = target.closest('[data-action-index]') as HTMLElement | null + if (row) { + const index = parseInt(row.dataset.actionIndex ?? '', 10) + if (!Number.isNaN(index) && index !== selectedIndex) onHover(index) + } + } + return (
{ lastMousePos.current = null }} style={{ position: 'absolute', top: 0, @@ -51,8 +75,8 @@ export default function ActionPanel({ items, selectedIndex, onSelect, onHover }:
onSelect(item)} - onMouseEnter={() => onHover(i)} style={{ display: 'flex', alignItems: 'center', diff --git a/src/components/ResultRow.tsx b/src/components/ResultRow.tsx index 6b6dae8..3489d59 100644 --- a/src/components/ResultRow.tsx +++ b/src/components/ResultRow.tsx @@ -5,9 +5,11 @@ import { getIconSvg, hasIcon } from './Icon' interface ResultRowProps { item: PaletteItem + /** Position in the list, exposed as data-list-index for the container's + * movement-guarded hover handler (see ResultsList). */ + index: number selected: boolean onSelect: () => void - onHover: () => void } // Shell icons resolve per extension (folders share one entry), so a shared @@ -34,10 +36,8 @@ function shellIconFor(item: PaletteItem): Promise { } const ResultRow = forwardRef( - ({ item, selected, onSelect, onHover }, ref) => { - const [hovered, setHovered] = useState(false) + ({ item, index, selected, onSelect }, ref) => { const [shellIcon, setShellIcon] = useState(null) - const active = selected || hovered // Upgrade named file/folder icons to the real shell icon when available useEffect(() => { @@ -54,9 +54,7 @@ const ResultRow = forwardRef( const isDataUrl = displayIcon.startsWith('data:') const isNamedIcon = hasIcon(displayIcon) const hasIconValue = displayIcon.length > 0 - const bg = active - ? (selected ? 'var(--accent)' : 'var(--bg-select)') - : 'transparent' + const bg = selected ? 'var(--accent)' : 'transparent' const fg = selected ? '#ffffff' : 'var(--text)' const subFg = selected ? 'rgba(255,255,255,0.78)' : 'var(--text-dim)' // An explicit item color overrides the theme icon tint (e.g. the Claude @@ -66,9 +64,8 @@ const ResultRow = forwardRef( return (
{ setHovered(true); onHover() }} - onMouseLeave={() => setHovered(false)} style={{ display: 'flex', alignItems: 'center', diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx index a7b94c8..a0fcbbd 100644 --- a/src/components/ResultsGrid.tsx +++ b/src/components/ResultsGrid.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import type { PaletteItem } from '../types' import { fuzzyMatch } from '../lib/fuzzy' +import { scrollToReveal } from '../lib/scroll' import { getIconSvg, hasIcon } from './Icon' interface ResultsGridProps { @@ -32,7 +33,7 @@ export default function ResultsGrid({ items, selectedIndex, query, columns = 4, const lastMousePos = useRef<{ x: number; y: number } | null>(null) useEffect(() => { - selectedRef.current?.scrollIntoView({ block: 'nearest' }) + scrollToReveal(gridRef.current, selectedRef.current) }, [selectedIndex]) function handleMouseMove(e: React.MouseEvent) { @@ -49,7 +50,7 @@ export default function ResultsGrid({ items, selectedIndex, query, columns = 4, const cell = target.closest('[data-grid-index]') as HTMLElement | null if (cell) { const index = parseInt(cell.dataset.gridIndex ?? '', 10) - if (!Number.isNaN(index)) onHover(index) + if (!Number.isNaN(index) && index !== selectedIndex) onHover(index) } } @@ -107,7 +108,6 @@ export default function ResultsGrid({ items, selectedIndex, query, columns = 4, userSelect: 'none', minHeight: 72, }} - onMouseEnter={() => onHover(i)} > {hasIconValue && (
(null) const selectedRef = useRef(null) + const lastMousePos = useRef<{ x: number; y: number } | null>(null) - // Manual scroll instead of Element.scrollIntoView({ block: 'nearest' }): - // WKWebView (macOS) interprets 'nearest' by recentering the element, which - // makes the selection jump to the middle when paging past the bottom of a long - // list. Computing scrollTop from the rects is deterministic across engines - // (Chromium on Windows/Linux, WebKit on macOS) and only scrolls the minimum - // needed to bring the selected row fully into view. useEffect(() => { - const container = listRef.current - const el = selectedRef.current - if (!container || !el) return - const cRect = container.getBoundingClientRect() - const eRect = el.getBoundingClientRect() - if (eRect.top < cRect.top) { - container.scrollTop += eRect.top - cRect.top - } else if (eRect.bottom > cRect.bottom) { - container.scrollTop += eRect.bottom - cRect.bottom - } + scrollToReveal(listRef.current, selectedRef.current) }, [selectedIndex]) + // Hover-selection follows *physical* mouse movement only (same guard as + // ResultsGrid): WKWebView re-fires enter/move events when rows re-rank or + // scroll under a stationary cursor, which yanked the selection to whatever + // row sat under the mouse on every keystroke. Coordinates identical to the + // last event = not a real move, so it is ignored. + function handleMouseMove(e: React.MouseEvent) { + const pos = { x: e.clientX, y: e.clientY } + const last = lastMousePos.current + if (!last) { + lastMousePos.current = pos + return + } + if (last.x === pos.x && last.y === pos.y) return + lastMousePos.current = pos + + const target = e.target as HTMLElement + const row = target.closest('[data-list-index]') as HTMLElement | null + if (row) { + const index = parseInt(row.dataset.listIndex ?? '', 10) + if (!Number.isNaN(index) && index !== selectedIndex) onHover(index) + } + } + + function handleMouseLeave() { + lastMousePos.current = null + } + return (
onSelect(item)} - onHover={() => onHover(i)} /> ))}
diff --git a/src/lib/scroll.ts b/src/lib/scroll.ts new file mode 100644 index 0000000..436acb9 --- /dev/null +++ b/src/lib/scroll.ts @@ -0,0 +1,16 @@ +// Minimal-scroll replacement for Element.scrollIntoView({ block: 'nearest' }): +// WKWebView (macOS) interprets 'nearest' by recentering the element, which +// makes the selection jump to the middle of the viewport when paging through a +// long list. Computing scrollTop from the rects is deterministic across +// engines (Chromium on Windows/Linux, WebKit on macOS) and only scrolls the +// minimum needed to bring the element fully into view. +export function scrollToReveal(container: HTMLElement | null, el: HTMLElement | null) { + if (!container || !el) return + const cRect = container.getBoundingClientRect() + const eRect = el.getBoundingClientRect() + if (eRect.top < cRect.top) { + container.scrollTop += eRect.top - cRect.top + } else if (eRect.bottom > cRect.bottom) { + container.scrollTop += eRect.bottom - cRect.bottom + } +} From 103fe0cb7c62b0dd7e5312eae2f567d9500897a0 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:52:56 +0100 Subject: [PATCH 11/13] =?UTF-8?q?feat(macos):=20Phase=204=20=E2=80=94=20pa?= =?UTF-8?q?ckaging,=20sync=20hygiene,=20and=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add macOS .app bundle release via scripts/release.cjs (cross-platform). - Add src-tauri/icons/icon.icns and CFBundleURLTypes for commandeer:// deep links. - Implement macOS file-search icons via NSWorkspace.iconForFile:. - Implement macOS system stats (CPU/RAM) using sysinfo. - Enable user-configured screenshot hotkey on macOS (no default to avoid conflicts). - Clean up platform cfg gates in fs.rs (explicit linux/macos instead of not(windows)). - Add CI matrix (ubuntu/windows/macos) for frontend type-check and Rust build/tests. - Update CLAUDE.md and docs/macos-port.md with macOS status and dev notes. --- .github/workflows/ci.yml | 38 ++ .gitignore | 4 +- CLAUDE.md | 28 +- docs/macos-port.md | 55 ++- package.json | 2 +- scripts/release.cjs | 47 ++ src-tauri/icons/icon.icns | Bin 0 -> 10855 bytes src-tauri/src/commands/config.rs | 5 +- src-tauri/src/commands/fs.rs | 29 +- src-tauri/src/commands/icons.rs | 716 +++++++++++++++++----------- src-tauri/src/commands/search.rs | 14 +- src-tauri/src/commands/shortcuts.rs | 27 +- src-tauri/src/commands/stats.rs | 86 +++- src-tauri/tauri.conf.json | 3 +- src/commands/settings.ts | 7 +- src/lib/tauri.ts | 5 +- 16 files changed, 707 insertions(+), 359 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 scripts/release.cjs create mode 100644 src-tauri/icons/icon.icns diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c8a44d9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + frontend: + name: Frontend type-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - run: bun run build + + rust: + name: Rust build / test + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: bun install + - name: Build Tauri backend + run: cargo build --manifest-path src-tauri/Cargo.toml + - name: Run Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml diff --git a/.gitignore b/.gitignore index 2d40209..1032170 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ src-tauri/target/ # Tauri generated src-tauri/gen/ -# Binary (built artifact, not source) -bin/commandeer.exe +# Binary build artifacts (not source) +bin/ # Env / local config .env diff --git a/CLAUDE.md b/CLAUDE.md index 092e2c6..da6ff43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project -Commandeer is a Raycast-style command palette built with Tauri 2 (React/TypeScript frontend, Rust backend). It is **cross-platform: Windows and Linux (Wayland/COSMIC)** — originally Windows-only, later ported. It is **still in active development** with new features being added regularly; keep this file updated as the app evolves. +Commandeer is a Raycast-style command palette built with Tauri 2 (React/TypeScript frontend, Rust backend). It is **cross-platform: Windows, Linux (Wayland/COSMIC), and macOS** — originally Windows-only, then ported to Linux, then to macOS. It is **still in active development** with new features being added regularly; keep this file updated as the app evolves. There is no test suite or linter configured. `npm run build` runs `tsc` and is the type-check. @@ -18,7 +18,10 @@ npm run tauri build -- --no-bundle # release build (on Linux: source ~/.cargo/ # NEVER `cargo build --release` directly: without the tauri # CLI the binary is dev-mode and loads localhost:5173 npm run build # tsc + vite build (frontend only; use as the type-check) -npm run release # Windows-only: build + copy commandeer.exe to bin/ +npm run release # cross-platform release build + copy artifact to bin/ + # Windows: commandeer.exe + # Linux: commandeer binary + # macOS: commandeer.app bundle ``` Linux dev/test notes: @@ -28,13 +31,20 @@ Linux dev/test notes: - Screenshots: `cosmic-screenshot --interactive=false --notify=false --save-dir DIR`. - Icons in `src-tauri/icons/*.png` must be RGBA — RGB fails the `generate_context!` macro on Linux. +macOS dev/test notes: +- The app is an Accessory (no Dock icon / Cmd-Tab entry). Use the tray icon or the global hotkey to surface it. +- Default toggle hotkey is `Cmd+Shift+Space` (Spotlight owns `Cmd+Space`, input-source switching owns `Ctrl+Space`). +- Screenshot capture and paste-to-previous require permission grants: **Screen Recording** for screenshots, **Accessibility** for paste. Until granted the commands fail with instructions rather than silently no-oping. +- Shutdown/Restart/Logout/Empty Trash trigger a one-time **Automation** prompt on first use (System Events / Finder). +- `npm run release` produces a signed/unsigned `bin/commandeer.app` bundle; right-click → Open the first time if unsigned. + ## Architecture -Two always-running Tauri windows that hide/show rather than launching per use: the palette (label `palette`, transparent, undecorated) and the screenshot overlay (label `screenshot`, opaque fullscreen). A tray icon (Windows-only) and the global hotkey / single-instance toggle are the entry points. +Two always-running Tauri windows that hide/show rather than launching per use: the palette (label `palette`, transparent, undecorated) and the screenshot overlay (label `screenshot`, opaque fullscreen). A tray icon (Windows + macOS) and the global hotkey / single-instance toggle are the entry points. ### Screenshot tool -Lightshot-style region capture: trigger → Rust freezes the screen to `/frame.png` (`cosmic-screenshot` CLI on Linux, GDI BitBlt of the cursor monitor on Windows) → the `screenshot` window (same JS bundle; `main.tsx` branches on window label to `ScreenshotOverlay.tsx`) shows the frame under a dim veil → drag a region → Rust crops, saves to `~/Pictures/Screenshots`, and copies PNG to the clipboard (`wl-copy` on Linux, arboard on Windows). Esc cancels. Backend in `commands/screenshot.rs`. Triggers: `commandeer://screenshot` deep link (bound to PrtScn via a second managed COSMIC shortcut line on Linux), a configurable global shortcut on Windows (`screenshot_hotkey` config, **default `Insert`**, editable via Settings → Screenshot Hotkey), and a Tools → Take Screenshot palette command. **Do not default the Windows shortcut to PrintScreen**: `RegisterHotKey(VK_SNAPSHOT)` returns success but never fires `WM_HOTKEY` because PrtScn emits no `WM_KEYDOWN` — so it silently does nothing. Any ordinary key (Insert, Fn keys, letters+modifiers) works. The frame is encoded as fast/unfiltered PNG (transient file, reloaded once then deleted) — ~50 ms capture on a 2560×1440 release build; the unoptimized dev build is ~15× slower, so judge screenshot latency only from a release build. The overlay shows only after the frontend reports the frame `` decoded (1500 ms Rust-side fallback; `show_screenshot_overlay` is idempotent so the onload path and the fallback can't double-show and flicker), and on Linux is a 4-edge-anchored, exclusive-keyboard layer-shell surface. +Lightshot-style region capture: trigger → Rust freezes the screen to `/frame.png` (`cosmic-screenshot` CLI on Linux, GDI BitBlt of the cursor monitor on Windows, `screencapture -R` of the cursor monitor on macOS) → the `screenshot` window (same JS bundle; `main.tsx` branches on window label to `ScreenshotOverlay.tsx`) shows the frame under a dim veil → drag a region → Rust crops, saves to `~/Pictures/Screenshots`, and copies PNG to the clipboard (`wl-copy` on Linux, arboard on Windows/macOS). Esc cancels. Backend in `commands/screenshot.rs`. Triggers: `commandeer://screenshot` deep link (bound to PrtScn via a second managed COSMIC shortcut line on Linux), a configurable global shortcut on Windows/macOS (`screenshot_hotkey` config, **default `Insert` on Windows**, editable via Settings → Screenshot Hotkey), and a Tools → Take Screenshot palette command. **Do not default the Windows shortcut to PrintScreen**: `RegisterHotKey(VK_SNAPSHOT)` returns success but never fires `WM_HOTKEY` because PrtScn emits no `WM_KEYDOWN` — so it silently does nothing. Any ordinary key (Insert, Fn keys, letters+modifiers) works. macOS has no default screenshot hotkey because Mac keyboards lack PrintScreen and the common system shortcuts are `Cmd+Shift+3/4/5`. The frame is encoded as fast/unfiltered PNG (transient file, reloaded once then deleted) — ~50 ms capture on a 2560×1440 release build; the unoptimized dev build is ~15× slower, so judge screenshot latency only from a release build. The overlay shows only after the frontend reports the frame `` decoded (1500 ms Rust-side fallback; `show_screenshot_overlay` is idempotent so the onload path and the fallback can't double-show and flicker), and on Linux is a 4-edge-anchored, exclusive-keyboard layer-shell surface. ### Frontend (`src/`) @@ -46,7 +56,7 @@ Everything hangs off three types in `src/types.ts`: `App.tsx` builds the command list (grouping `folderName`-tagged commands under virtual folders) and hands it to `components/Palette.tsx` (~1500 lines), which owns the step stack, query state, fuzzy ranking (fzf + frecency in `src/lib/`), keyboard handling, and the Ctrl+K action panel. `src/lib/tauri.ts` is the single wrapper around all Rust `invoke` calls. `src/lib/appEvents.ts` is a mutable bridge so settings commands can flip App-level state without prop drilling. -User-facing "commands" also come from a scripts directory on disk (configurable `scripts_dir`; `.ps1`/`.lnk` on Windows, `.sh`/`.desktop`/`.AppImage`/executables on Linux), scanned by the Rust side. +User-facing "commands" also come from a scripts directory on disk (configurable `scripts_dir`; `.ps1`/`.lnk` on Windows, `.sh`/`.desktop`/`.AppImage`/executables on Linux, `.sh`/`.command`/executables on macOS), scanned by the Rust side. ### Backend (`src-tauri/src/`) @@ -54,10 +64,10 @@ User-facing "commands" also come from a scripts directory on disk (configurable ### Platform split -All OS-specific code is behind `#[cfg(target_os = "windows")]` / `#[cfg(not(windows))]` in Rust and an `IS_LINUX` (user-agent) check in the frontend. The two platforms differ most in: +All OS-specific code is behind `#[cfg(target_os = "windows")]` / `#[cfg(target_os = "linux")]` / `#[cfg(target_os = "macos")]` in Rust and `IS_LINUX` / `IS_MAC` (user-agent) checks in the frontend. Never use a bare `#[cfg(not(windows))]` branch for Linux/macOS-specific code — gate each OS explicitly (or `unix` only when the code is genuinely identical, like `PermissionsExt`). The three platforms differ most in: -- **Window sizing/positioning.** Windows: frontend `setSize` + min/max + cursor-monitor positioning. Linux/Wayland: cosmic-comp ignores client resizes/moves of mapped toplevels, so the palette is rendered as a **wlr-layer-shell overlay** (gtk-layer-shell, set up in `lib.rs`), anchored to the top edge with a fixed margin; the frontend measures content height and calls the `resize_palette` Rust command, which changes the GTK size request to resize in place without flicker. `html,body,#root` are content-height on purpose so this measurement works. -- **Launching & icons.** Windows uses PowerShell/shell32 (`.lnk` icon extraction); Linux parses `.desktop` files and launches via direct exec / `sh` / `gio launch` / `xdg-open`. -- **Global hotkey.** See Linux notes above; `set_game_mode` in `lib.rs` rewrites the COSMIC custom-shortcut config on Linux. +- **Window sizing/positioning.** Windows: frontend `setSize` + min/max + cursor-monitor positioning. Linux/Wayland: cosmic-comp ignores client resizes/moves of mapped toplevels, so the palette is rendered as a **wlr-layer-shell overlay** (gtk-layer-shell, set up in `lib.rs`), anchored to the top edge with a fixed margin; the frontend measures content height and calls the `resize_palette` Rust command, which changes the GTK size request to resize in place without flicker. `html,body,#root` are content-height on purpose so this measurement works. macOS: a normal always-on-top transparent window positioned via Tauri monitor APIs; vibrancy and rounded corners are applied with `window-vibrancy`. +- **Launching & icons.** Windows uses PowerShell/shell32 (`.lnk` icon extraction); Linux parses `.desktop` files and launches via direct exec / `sh` / `gio launch` / `xdg-open`; macOS scans `.app` bundles and launches via `open`. File-search icons use the same shell APIs (`SHGetFileInfoW` on Windows, `NSWorkspace.iconForFile:` on macOS). +- **Global hotkey.** See Linux/macOS notes above; `set_game_mode` in `lib.rs` rewrites the COSMIC custom-shortcut config on Linux and switches the registered base hotkey everywhere. Config is JSON read/written by the Rust side (`commands/config.rs`; `scripts_dir` defaults per-platform). Lightweight UI prefs (game mode, widget visibility, script cache) live in webview `localStorage`. diff --git a/docs/macos-port.md b/docs/macos-port.md index 1c3c2e0..7a32fae 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -1,11 +1,13 @@ # Commandeer → macOS port plan -Status: **Phases 1–3 complete** — Commandeer runs on macOS with UX parity -(vibrancy, tray, hotkey, positioning) and native feature backends (launcher, -screenshot, paste, audio, system actions, processes). Phase 4 (packaging & -sync hygiene) remains, plus on-device permission grants to verify -screenshot/paste end-to-end. Commandeer targets **Windows**, **Linux -(Wayland/COSMIC)**, and now **macOS** from the same codebase. +Status: **Phases 1–4 complete** — Commandeer runs on macOS with UX parity +(vibrancy, tray, hotkey, positioning), native feature backends (launcher, +screenshot, paste, audio, system actions, processes), packaging (`.app` +bundle, `.icns`, `CFBundleURLTypes`), sync hygiene (cross-platform `release` +script, CI matrix), and file-search / system-stats parity. On-device +permission grants remain to verify screenshot/paste end-to-end. Commandeer +targets **Windows**, **Linux (Wayland/COSMIC)**, and **macOS** from the same +codebase. ## The core idea (how it stays "in-line" across platforms) @@ -221,20 +223,33 @@ interactive verification (see below). - Shutdown/Restart/Logout/Empty Trash: one-time **Automation** prompts on first use (System Events / Finder). -## Phase 4 — Packaging & sync hygiene - -- [ ] **`package.json` `release` script** — hardcodes `commandeer.exe`. Add a - macOS branch producing `.app`/`.dmg` (`tauri build` emits these; - `bundle.targets: "all"` is set). -- [ ] **`tauri.conf.json` `bundle`** — add a macOS icon (`.icns`) alongside `.ico`. -- [ ] **Deep link** — `commandeer://` on macOS needs `CFBundleURLTypes` in the - bundle Info.plist (deep-link plugin config); runtime `register()` may not - persist unbundled. -- [ ] **Update `CLAUDE.md`** — its "cross-platform: Windows and Linux" line and - Platform-split section need a macOS column so future work stays consistent. -- [ ] **Consider CI** — a GitHub Actions matrix (`windows-latest` + - `macos-latest`, later `ubuntu`) running `npm run build` + `cargo build` per - push is the real guardrail that keeps platforms in-line automatically. +## Phase 4 — Packaging & sync hygiene — DONE + +- [x] **`package.json` `release` script** — replaced the Windows-only one-liner + with `scripts/release.js`, which builds the right artifact per platform: + `.exe` on Windows, raw binary on Linux, `.app` bundle on macOS. +- [x] **`tauri.conf.json` `bundle`** — added `icons/icon.icns` and + `bundle.macOS.info.CFBundleURLTypes` for `commandeer://` deep links. +- [x] **Deep link** — `CFBundleURLTypes` is now in the generated Info.plist; + runtime `register()` covers dev/unbundled runs. +- [x] **Update `CLAUDE.md`** — added macOS to the project description, commands, + dev notes, architecture, screenshot, and platform-split sections. +- [x] **CI** — added `.github/workflows/ci.yml` with a matrix of + `ubuntu-latest` / `windows-latest` / `macos-latest` running the frontend + type-check and Rust build + tests. +- [x] **macOS file-search icons** — implemented `NSWorkspace.iconForFile:` in + `commands/icons.rs`; `path_icon` and file-search results now resolve icons + on macOS. +- [x] **macOS system stats** — implemented CPU + memory in `commands/stats.rs` + using `sysinfo` (already a dependency for process enumeration); GPU is + intentionally absent because no reliable unprivileged cross-vendor metric + exists on macOS. +- [x] **macOS screenshot hotkey** — Tauri's global shortcut is now registered on + macOS when the user configures one (no default, to avoid conflicts with + system shortcuts); the settings UI is shown on macOS as well. +- [x] **Platform-gate cleanup** — `commands/fs.rs` now uses explicit + `target_os = "linux"` / `target_os = "macos"` gates instead of bare + `not(windows)` branches, matching the port rule. ## Effort estimate diff --git a/package.json b/package.json index 377888a..9eae513 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "release": "tauri build --no-bundle && bun -e \"await Bun.$`mkdir -p bin`; await Bun.$`cp src-tauri/target/release/commandeer.exe bin/commandeer.exe`\"" + "release": "node scripts/release.cjs" }, "dependencies": { "@tauri-apps/api": "2.10.1", diff --git a/scripts/release.cjs b/scripts/release.cjs new file mode 100644 index 0000000..4fe72f1 --- /dev/null +++ b/scripts/release.cjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Cross-platform release helper: builds Commandeer and copies the artifact +// into bin/. macOS gets the full .app bundle (so deep links and Info.plist +// are preserved); Windows/Linux get the raw binary, matching the previous +// Windows-only behaviour. + +const { execSync } = require('child_process') +const fs = require('fs') +const path = require('path') + +const platform = process.platform + +function run(cmd) { + console.log(`> ${cmd}`) + execSync(cmd, { stdio: 'inherit' }) +} + +function copy(src, dst) { + fs.mkdirSync(path.dirname(dst), { recursive: true }) + fs.copyFileSync(src, dst) + console.log(`Copied ${src} -> ${dst}`) +} + +function copyDir(src, dst) { + fs.rmSync(dst, { recursive: true, force: true }) + fs.mkdirSync(path.dirname(dst), { recursive: true }) + fs.cpSync(src, dst, { recursive: true, preserveTimestamps: true }) + console.log(`Copied ${src} -> ${dst}`) +} + +if (platform === 'darwin') { + // Build the .app bundle so it has the correct Info.plist, icon, and deep-link + // scheme. We target only the 'app' bundle because .dmg creation requires + // external tooling (create-dmg) that may not be installed; the .app is the + // usable artifact anyway. + run('tauri build --bundles app') + copyDir( + 'src-tauri/target/release/bundle/macos/commandeer.app', + 'bin/commandeer.app' + ) +} else if (platform === 'win32') { + run('tauri build --no-bundle') + copy('src-tauri/target/release/commandeer.exe', 'bin/commandeer.exe') +} else { + run('tauri build --no-bundle') + copy('src-tauri/target/release/commandeer', 'bin/commandeer') +} diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..0060df4a0d9848eeba3d96e470ae068e5454520a GIT binary patch literal 10855 zcmeHNe{2&~9RI$%Yj^E7cffu*mT|-KL!6@^WH19<=WtY@8*EC2nzbCaQJL**D~un( z*9914Isp?<6GO8E5hY{^A=!*#HzW{I83+V{NM-yqk}*Wt9HzdzF02Xqhsk1sd3U*c z-|yY~e&4%qzwf>8_tWQ=J$`@*)oyvxWB`=6rOZ~6Y|JtOK(cjC@j_Bc>c#8H{S)`= zKav9Z7nT&k{_nDH5~f(OTdU^JhY5t{0X4XRQ>&0Gi(CL~2mwn%+~oM-#me(x*k?eFluSz3UZ%ZZ|p z;v6rb9-Xi`Au?_=Ffqy~Vrd=XE~8e47lld)*1>*Y_^&b~F$X_||3$TY%K>(JbzYGob_(mTmZn-{f(_KsfIOM9iHhrPM9IF6^^r&+5*gJ;#Lve=Lc+cI%t4g zI2^vWDDC1UL#m_eRPykmqmKS{Z%@5_;7V{7tHTXtpu;1>J!>%&PLA}>kWfTldp{=x zwd}O@DP_=#vcF6i4;P{75}OJhINFVjP=LGcQL+EtBoX8|vWjgh9dq-X`fCsR-K1v# z^tT`OJ9W4r(EQHf`?yNwhG2{$81y@}1%T?Gv#g{C3*-GxVB7C4N&DrJF||g%UU=-v zvG-Q&Ix$S#n%evB-i>3Wh#?c+k;FVFbX;w;g`pQQF2oA?l>tT{P7(OTOkCU7Ji-j? zv1AX!hT|C@VsxA3IvBm^ICO(c!lOhX-3&k7nLW-4_aWGV5x;X{Az~v5{03c;)Vl5@ zX<`;_f*Oi?6df0I0(k^y+F;_gvs~LpQ#Q>|SDC@}&OD8-cXYj@fdLI~XdFlb=KoLF z|Bvh46tetfTL`=ojrK>Qa=#p{>aU9S^!E^ahP+F6KAih8M1+aues}9m|3SE+$o-hO z+)r%*AhGx0Mu4=Af!y!xf@H`Qo@?*gw>>gSx7FG8ZQ+f;tio&OulqS(5G5?V5GmcA zwgV7D_&P?9OUOgQSr9AQKRc#X3IoaX)Y{^BCvbB!-q&hpl3Y+DI9(EU-W)wb6J$xY zs1m^tMnc{z%T%?ZQ_Uw;LP#}3{qDwjnqU%{FEyf`(Mv6XZ3a~kBa$#`9V-h1zaxLL zCTJ!prAF|^OlUtfd#om?C%F{uEVV3k13JJEnY!p~K<8QN26VYqg9L0S&%s zra*HrnlRE-oZ<#SipU5QJ_70n{3GE8try1V+YDcur>m%L+<&c82|F)_JxAk?Zh}L{ zpWOtLjz7BzJ{^B{t623UqU+4VST%^`xv1?^@~>HQmoP(tRf9OVPf2S5_HA1FD^Xsc zV%2uTtn0d}3lf8s{>Unp^2?x9Oo4to7rtfULwQHpY6>HFN3<}Qt?JCoE&bFn93uPK#6I(el8WLLmdfZrSTmb&kwV4~4M))F?4`^x~K%RnQ(I6~Dt)UhI|wqymoE<#)d6#HErF#v*qCDUmOY_>HOiEx)GEdD7H%sz!c*j-0%Da Dw0fqO literal 0 HcmV?d00001 diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index e84821b..88cbdc1 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -22,8 +22,9 @@ pub struct AppConfig { /// Alternate global hotkey used in game mode (e.g. "Alt+Space") #[serde(default, skip_serializing_if = "Option::is_none")] pub global_hotkey_game: Option, - /// Global hotkey that starts the region screenshot (default "PrintScreen"; - /// registered on Windows — Linux uses a managed COSMIC binding instead) + /// Global hotkey that starts the region screenshot (default "Insert" on + /// Windows; macOS has no default because PrintScreen keys don't exist. + /// Linux uses a managed COSMIC binding instead.) #[serde(default, skip_serializing_if = "Option::is_none")] pub screenshot_hotkey: Option, } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 3ee3a95..5fd73fa 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -20,9 +20,9 @@ pub struct ScriptInfo { /// Whether a directory entry should be surfaced as a runnable command. /// /// Windows: batch/command scripts, shell shortcuts, and VS Code workspaces. -/// Unix: shell scripts, `.desktop` launchers, VS Code workspaces, AppImages -/// (macOS: `.command` Terminal scripts), or any regular file with the -/// executable bit set. +/// macOS/Linux: shell scripts, VS Code workspaces, or any regular file with the +/// executable bit set. Linux additionally surfaces `.desktop` launchers and +/// AppImages; macOS additionally surfaces `.command` Terminal scripts. fn is_script_file(path: &Path) -> bool { let ext = path.extension().and_then(|x| x.to_str()); @@ -34,8 +34,9 @@ fn is_script_file(path: &Path) -> bool { ) } - #[cfg(not(target_os = "windows"))] + #[cfg(any(target_os = "macos", target_os = "linux"))] { + #[cfg(target_os = "linux")] if matches!( ext, Some("sh") | Some("desktop") | Some("code-workspace") | Some("AppImage") @@ -43,14 +44,14 @@ fn is_script_file(path: &Path) -> bool { return true; } #[cfg(target_os = "macos")] - if ext == Some("command") { + if matches!(ext, Some("sh") | Some("command") | Some("code-workspace")) { return true; } is_executable(path) } } -#[cfg(not(target_os = "windows"))] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn is_executable(path: &Path) -> bool { use std::os::unix::fs::PermissionsExt; path.is_file() @@ -61,7 +62,7 @@ fn is_executable(path: &Path) -> bool { /// Read a single key from the `[Desktop Entry]` group of a .desktop file. /// Localized variants (e.g. `Name[de]=`) are ignored in favour of the default. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn desktop_entry_value(content: &str, key: &str) -> Option { let prefix = format!("{key}="); let mut in_entry = false; @@ -79,7 +80,7 @@ fn desktop_entry_value(content: &str, key: &str) -> Option { } /// Human-friendly name declared inside a .desktop file (`Name=`). -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn resolve_desktop_name(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; let name = desktop_entry_value(&content, "Name")?; @@ -91,7 +92,7 @@ fn resolve_desktop_name(path: &Path) -> Option { } /// Resolve the `Icon=` of a .desktop file to a base64 data URL, if findable. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn resolve_desktop_icon(path: &Path) -> Option { let content = fs::read_to_string(path).ok()?; let icon = desktop_entry_value(&content, "Icon")?; @@ -119,7 +120,7 @@ fn resolve_desktop_icon(path: &Path) -> Option { } /// Best-effort lookup of a freedesktop icon name across the common theme roots. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn find_themed_icon(name: &str) -> Option { use std::path::PathBuf; @@ -178,8 +179,8 @@ fn collect_script_files(dir: &Path, folder: Option) -> Vec { .unwrap_or_default(); let stem = path.file_stem()?.to_string_lossy().into_owned(); - // Unix: a .desktop entry's Name= is friendlier than its filename. - #[cfg(not(target_os = "windows"))] + // Linux: a .desktop entry's Name= is friendlier than its filename. + #[cfg(target_os = "linux")] let stem = if ext == "desktop" { resolve_desktop_name(&path).unwrap_or(stem) } else { @@ -203,8 +204,8 @@ fn collect_script_files(dir: &Path, folder: Option) -> Vec { None }; - // Unix: fall back to the icon declared inside a .desktop entry. - #[cfg(not(target_os = "windows"))] + // Linux: fall back to the icon declared inside a .desktop entry. + #[cfg(target_os = "linux")] let icon = icon.or_else(|| { if ext == "desktop" { resolve_desktop_icon(&path) diff --git a/src-tauri/src/commands/icons.rs b/src-tauri/src/commands/icons.rs index bcf48a9..4d3c7c4 100644 --- a/src-tauri/src/commands/icons.rs +++ b/src-tauri/src/commands/icons.rs @@ -1,246 +1,341 @@ -//! Shell icons for file-search results: SHGetFileInfoW → 32×32 PNG data URL. -//! Icons are resolved per extension (files) or once for all folders, so the -//! Mutex-guarded cache makes repeat lookups free. The PNG encoder writes -//! uncompressed deflate blocks — no image/zlib dependencies needed for these -//! tiny payloads, and entries are cached anyway. +//! Shell icons for file-search results. Icons are resolved lazily and cached +//! per extension/path so repeat lookups are free. +//! +//! Backends: +//! - Windows: SHGetFileInfoW / IShellItemImageFactory → 32×32 PNG data URL. +//! - macOS: NSWorkspace.iconForFile: → PNG data URL (via TIFF → bitmap rep). +//! - Linux: not implemented; returns None. -#![cfg(target_os = "windows")] - -use std::collections::HashMap; -use std::path::Path; -use std::sync::{Mutex, OnceLock}; - -fn cache() -> &'static Mutex>> { - static CACHE: OnceLock>>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) +/// Data-URL icon for a path, or None if the shell has nothing for it. +/// Uses file extension attributes for fast lookup; best for generic files. +#[cfg(target_os = "windows")] +pub fn icon_for_path(path: &str) -> Option { + win::icon_for_path(path) } -fn file_cache() -> &'static Mutex>> { - static CACHE: OnceLock>>> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(HashMap::new())) +#[cfg(target_os = "macos")] +pub fn icon_for_path(path: &str) -> Option { + mac::icon_for_path(path) } -/// Data-URL icon for a path, or None if the shell has nothing for it. -/// Uses file extension attributes for fast lookup; best for generic files. +#[cfg(target_os = "linux")] pub fn icon_for_path(path: &str) -> Option { - let p = Path::new(path); - let is_dir = p.is_dir(); - let key = if is_dir { - "\u{0}folder".to_string() - } else { - p.extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .unwrap_or_default() - }; - if let Some(hit) = cache().lock().ok()?.get(&key) { - return hit.clone(); - } - let icon = shell_icon(path, is_dir, true); - if let Ok(mut c) = cache().lock() { - c.insert(key, icon.clone()); - } - icon + let _ = path; + None } -/// Data-URL icon for a specific file path, resolving .lnk targets and app icons. +/// Data-URL icon for a specific file path, resolving shortcuts and app icons. /// Cached per path; slower than icon_for_path but accurate for shortcuts. +#[cfg(target_os = "windows")] pub fn icon_for_file(path: &str) -> Option { - let key = path.to_lowercase(); - if let Some(hit) = file_cache().lock().ok()?.get(&key) { - return hit.clone(); - } - // For Windows shortcuts, resolve the real icon source so we get the app - // icon instead of the generic shortcut overlay. - let resolved = if path.to_lowercase().ends_with(".lnk") { - resolve_lnk_icon_source(path).unwrap_or_else(|| path.to_string()) - } else { - path.to_string() - }; - let icon = shell_icon(&resolved, Path::new(&resolved).is_dir(), false); - if let Ok(mut c) = file_cache().lock() { - c.insert(key, icon.clone()); - } - icon + win::icon_for_file(path) } -/// Data-URL icon for a shell parsing name (e.g. "shell:AppsFolder\\"), -/// resolved via IShellItemImageFactory — works for UWP/Store apps that have -/// no filesystem icon source. Cached per path. +/// Data-URL icon for a shell parsing name (e.g. "shell:AppsFolder\\"). +#[cfg(target_os = "windows")] pub fn icon_for_shell_item(parse_path: &str) -> Option { - let key = parse_path.to_lowercase(); - if let Some(hit) = file_cache().lock().ok()?.get(&key) { - return hit.clone(); + win::icon_for_shell_item(parse_path) +} + +// ── Windows backend ────────────────────────────────────────────────────────── + +#[cfg(target_os = "windows")] +mod win { + use super::encode_png; + use std::collections::HashMap; + use std::path::Path; + use std::sync::{Mutex, OnceLock}; + + fn cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) } - let icon = shell_item_icon(parse_path); - if let Ok(mut c) = file_cache().lock() { - c.insert(key, icon.clone()); + + fn file_cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) } - icon -} -fn shell_item_icon(parse_path: &str) -> Option { - use windows::core::{Interface, PCWSTR}; - use windows::Win32::Foundation::SIZE; - use windows::Win32::Graphics::Gdi::DeleteObject; - use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; - use windows::Win32::UI::Shell::{ - IShellItem, IShellItemImageFactory, SHCreateItemFromParsingName, SIIGBF_ICONONLY, - }; - - let wide: Vec = parse_path - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - unsafe { - // COM may already be initialized on this thread; ignore that. - let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - let item: IShellItem = SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None).ok()?; - let factory: IShellItemImageFactory = item.cast().ok()?; - let hbm = factory - .GetImage(SIZE { cx: 32, cy: 32 }, SIIGBF_ICONONLY) - .ok()?; - let png = hbitmap_to_png(hbm); - let _ = DeleteObject(hbm); - png.map(|bytes| format!("data:image/png;base64,{}", super::fs::base64_encode(&bytes))) + pub fn icon_for_path(path: &str) -> Option { + let p = Path::new(path); + let is_dir = p.is_dir(); + let key = if is_dir { + "\u{0}folder".to_string() + } else { + p.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default() + }; + if let Some(hit) = cache().lock().ok()?.get(&key) { + return hit.clone(); + } + let icon = shell_icon(path, is_dir, true); + if let Ok(mut c) = cache().lock() { + c.insert(key, icon.clone()); + } + icon } -} -/// Resolve a Windows .lnk shortcut to the path that should be used for its -/// icon. This prefers an explicit icon location stored in the shortcut, then -/// falls back to the shortcut target so we avoid the shortcut overlay. -#[cfg(target_os = "windows")] -fn resolve_lnk_icon_source(path: &str) -> Option { - use windows::core::{Interface, PCWSTR}; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, STGM, - }; - use windows::Win32::UI::Shell::{IShellLinkW, ShellLink}; - - unsafe { - // COM may already be initialized on this thread; ignore that. - let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); - let shell_link: IShellLinkW = - CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).ok()?; - let persist_file: windows::Win32::System::Com::IPersistFile = shell_link.cast().ok()?; - let wide: Vec = path - .replace('/', "\\") + pub fn icon_for_file(path: &str) -> Option { + let key = path.to_lowercase(); + if let Some(hit) = file_cache().lock().ok()?.get(&key) { + return hit.clone(); + } + let resolved = if path.to_lowercase().ends_with(".lnk") { + resolve_lnk_icon_source(path).unwrap_or_else(|| path.to_string()) + } else { + path.to_string() + }; + let icon = shell_icon(&resolved, Path::new(&resolved).is_dir(), false); + if let Ok(mut c) = file_cache().lock() { + c.insert(key, icon.clone()); + } + icon + } + + pub fn icon_for_shell_item(parse_path: &str) -> Option { + let key = parse_path.to_lowercase(); + if let Some(hit) = file_cache().lock().ok()?.get(&key) { + return hit.clone(); + } + let icon = shell_item_icon(parse_path); + if let Ok(mut c) = file_cache().lock() { + c.insert(key, icon.clone()); + } + icon + } + + fn shell_item_icon(parse_path: &str) -> Option { + use windows::core::{Interface, PCWSTR}; + use windows::Win32::Foundation::SIZE; + use windows::Win32::Graphics::Gdi::DeleteObject; + use windows::Win32::System::Com::{CoInitializeEx, COINIT_APARTMENTTHREADED}; + use windows::Win32::UI::Shell::{ + IShellItem, IShellItemImageFactory, SHCreateItemFromParsingName, SIIGBF_ICONONLY, + }; + + let wide: Vec = parse_path .encode_utf16() .chain(std::iter::once(0)) .collect(); - persist_file.Load(PCWSTR(wide.as_ptr()), STGM(0)).ok()?; - - // First, honor an explicit icon location set on the shortcut itself. - let mut icon_path = [0u16; 260]; - let mut icon_index = 0i32; - if shell_link - .GetIconLocation(&mut icon_path, &mut icon_index) - .is_ok() - { - let len = icon_path + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let item: IShellItem = SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None).ok()?; + let factory: IShellItemImageFactory = item.cast().ok()?; + let hbm = factory + .GetImage(SIZE { cx: 32, cy: 32 }, SIIGBF_ICONONLY) + .ok()?; + let png = hbitmap_to_png(hbm); + let _ = DeleteObject(hbm); + png.map(|bytes| format!("data:image/png;base64,{}", super::super::fs::base64_encode(&bytes))) + } + } + + fn resolve_lnk_icon_source(path: &str) -> Option { + use windows::core::{Interface, PCWSTR}; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, STGM, + }; + use windows::Win32::UI::Shell::{IShellLinkW, ShellLink}; + + unsafe { + let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); + let shell_link: IShellLinkW = + CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER).ok()?; + let persist_file: windows::Win32::System::Com::IPersistFile = shell_link.cast().ok()?; + let wide: Vec = path + .replace('/', "\\") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + persist_file.Load(PCWSTR(wide.as_ptr()), STGM(0)).ok()?; + + let mut icon_path = [0u16; 260]; + let mut icon_index = 0i32; + if shell_link + .GetIconLocation(&mut icon_path, &mut icon_index) + .is_ok() + { + let len = icon_path + .iter() + .position(|&c| c == 0) + .unwrap_or(icon_path.len()); + let icon = String::from_utf16_lossy(&icon_path[..len]); + if !icon.is_empty() { + return Some(icon); + } + } + + let mut target = [0u16; 260]; + shell_link.GetPath(&mut target, std::ptr::null_mut(), 0).ok()?; + let len = target .iter() .position(|&c| c == 0) - .unwrap_or(icon_path.len()); - let icon = String::from_utf16_lossy(&icon_path[..len]); - if !icon.is_empty() { - return Some(icon); + .unwrap_or(target.len()); + let resolved = String::from_utf16_lossy(&target[..len]); + if resolved.is_empty() { + None + } else { + Some(resolved) } } + } - // Otherwise resolve the shortcut target. - let mut target = [0u16; 260]; - shell_link.GetPath(&mut target, std::ptr::null_mut(), 0).ok()?; - let len = target - .iter() - .position(|&c| c == 0) - .unwrap_or(target.len()); - let resolved = String::from_utf16_lossy(&target[..len]); - if resolved.is_empty() { - None + fn shell_icon(path: &str, is_dir: bool, use_file_attributes: bool) -> Option { + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, + }; + use windows::Win32::UI::Shell::{ + SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGFI_USEFILEATTRIBUTES, + }; + use windows::Win32::UI::WindowsAndMessaging::DestroyIcon; + + let wide: Vec = path + .replace('/', "\\") + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let attrs = if is_dir { + FILE_ATTRIBUTE_DIRECTORY } else { - Some(resolved) + FILE_ATTRIBUTE_NORMAL + }; + + unsafe { + let mut info = SHFILEINFOW::default(); + let mut flags = SHGFI_ICON | SHGFI_LARGEICON; + if use_file_attributes { + flags |= SHGFI_USEFILEATTRIBUTES; + } + let ok = SHGetFileInfoW( + PCWSTR(wide.as_ptr()), + attrs, + Some(&mut info), + std::mem::size_of::() as u32, + flags, + ); + if ok == 0 || info.hIcon.is_invalid() { + return None; + } + let png = hicon_to_png(info.hIcon); + let _ = DestroyIcon(info.hIcon); + png.map(|bytes| format!("data:image/png;base64,{}", super::super::fs::base64_encode(&bytes))) } } -} -#[cfg(not(target_os = "windows"))] -fn resolve_lnk_icon_source(_path: &str) -> Option { - None -} + unsafe fn hicon_to_png( + hicon: windows::Win32::UI::WindowsAndMessaging::HICON, + ) -> Option> { + use windows::Win32::Graphics::Gdi::{ + DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, + }; + use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO}; + + let mut info = ICONINFO::default(); + GetIconInfo(hicon, &mut info).ok()?; + + let mut bm = BITMAP::default(); + let has_color = !info.hbmColor.is_invalid(); + let src = if has_color { info.hbmColor } else { info.hbmMask }; + let got = GetObjectW( + src, + std::mem::size_of::() as i32, + Some(&mut bm as *mut _ as *mut _), + ); + let (width, height) = (bm.bmWidth, bm.bmHeight); + + let mut result = None; + if got != 0 && has_color && width > 0 && height > 0 && width <= 256 && height <= 256 { + let hdc = GetDC(None); + let header = BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }; + let count = (width * height) as usize; + let mut bgra = vec![0u8; count * 4]; + + let mut bmi = BITMAPINFO { + bmiHeader: header, + ..Default::default() + }; + let lines = GetDIBits( + hdc, + info.hbmColor, + 0, + height as u32, + Some(bgra.as_mut_ptr() as *mut _), + &mut bmi, + DIB_RGB_COLORS, + ); + if lines != 0 { + if bgra.chunks_exact(4).all(|px| px[3] == 0) { + let mut mask = vec![0u8; count * 4]; + let mut mask_bmi = BITMAPINFO { + bmiHeader: header, + ..Default::default() + }; + let mask_ok = !info.hbmMask.is_invalid() + && GetDIBits( + hdc, + info.hbmMask, + 0, + height as u32, + Some(mask.as_mut_ptr() as *mut _), + &mut mask_bmi, + DIB_RGB_COLORS, + ) != 0; + for (i, px) in bgra.chunks_exact_mut(4).enumerate() { + px[3] = if mask_ok && mask[i * 4] != 0 { 0 } else { 255 }; + } + } + let mut rgba = Vec::with_capacity(count * 4); + for px in bgra.chunks_exact(4) { + rgba.extend_from_slice(&[px[2], px[1], px[0], px[3]]); + } + result = Some(encode_png(width as u32, height as u32, &rgba)); + } + ReleaseDC(None, hdc); + } -fn shell_icon(path: &str, is_dir: bool, use_file_attributes: bool) -> Option { - use windows::core::PCWSTR; - use windows::Win32::Storage::FileSystem::{ - FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, - }; - use windows::Win32::UI::Shell::{ - SHGetFileInfoW, SHFILEINFOW, SHGFI_ICON, SHGFI_LARGEICON, SHGFI_USEFILEATTRIBUTES, - }; - use windows::Win32::UI::WindowsAndMessaging::DestroyIcon; - - let wide: Vec = path - .replace('/', "\\") - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); - let attrs = if is_dir { - FILE_ATTRIBUTE_DIRECTORY - } else { - FILE_ATTRIBUTE_NORMAL - }; - - unsafe { - let mut info = SHFILEINFOW::default(); - let mut flags = SHGFI_ICON | SHGFI_LARGEICON; - if use_file_attributes { - // USEFILEATTRIBUTES: resolve from the extension alone — no disk access - flags |= SHGFI_USEFILEATTRIBUTES; + if !info.hbmColor.is_invalid() { + let _ = DeleteObject(info.hbmColor); } - let ok = SHGetFileInfoW( - PCWSTR(wide.as_ptr()), - attrs, - Some(&mut info), - std::mem::size_of::() as u32, - flags, + if !info.hbmMask.is_invalid() { + let _ = DeleteObject(info.hbmMask); + } + result + } + + unsafe fn hbitmap_to_png(hbm: windows::Win32::Graphics::Gdi::HBITMAP) -> Option> { + use windows::Win32::Graphics::Gdi::{ + GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, + DIB_RGB_COLORS, + }; + + let mut bm = BITMAP::default(); + let got = GetObjectW( + hbm, + std::mem::size_of::() as i32, + Some(&mut bm as *mut _ as *mut _), ); - if ok == 0 || info.hIcon.is_invalid() { + let (width, height) = (bm.bmWidth, bm.bmHeight); + if got == 0 || width <= 0 || height <= 0 || width > 256 || height > 256 { return None; } - let png = hicon_to_png(info.hIcon); - let _ = DestroyIcon(info.hIcon); - png.map(|bytes| format!("data:image/png;base64,{}", super::fs::base64_encode(&bytes))) - } -} -unsafe fn hicon_to_png(hicon: windows::Win32::UI::WindowsAndMessaging::HICON) -> Option> { - use windows::Win32::Graphics::Gdi::{ - DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, - BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, - }; - use windows::Win32::UI::WindowsAndMessaging::{GetIconInfo, ICONINFO}; - - let mut info = ICONINFO::default(); - GetIconInfo(hicon, &mut info).ok()?; - - let mut bm = BITMAP::default(); - let has_color = !info.hbmColor.is_invalid(); - let src = if has_color { info.hbmColor } else { info.hbmMask }; - let got = GetObjectW( - src, - std::mem::size_of::() as i32, - Some(&mut bm as *mut _ as *mut _), - ); - let (width, height) = (bm.bmWidth, bm.bmHeight); - - let mut result = None; - // Monochrome (mask-only) icons are skipped: has_color is false and the - // block below only reads hbmColor. - if got != 0 && has_color && width > 0 && height > 0 && width <= 256 && height <= 256 { let hdc = GetDC(None); let header = BITMAPINFOHEADER { biSize: std::mem::size_of::() as u32, biWidth: width, - biHeight: -height, // top-down + biHeight: -height, biPlanes: 1, biBitCount: 32, biCompression: BI_RGB.0, @@ -248,116 +343,156 @@ unsafe fn hicon_to_png(hicon: windows::Win32::UI::WindowsAndMessaging::HICON) -> }; let count = (width * height) as usize; let mut bgra = vec![0u8; count * 4]; - - let mut bmi = BITMAPINFO { bmiHeader: header, ..Default::default() }; + let mut bmi = BITMAPINFO { + bmiHeader: header, + ..Default::default() + }; let lines = GetDIBits( hdc, - info.hbmColor, + hbm, 0, height as u32, Some(bgra.as_mut_ptr() as *mut _), &mut bmi, DIB_RGB_COLORS, ); - if lines != 0 { - // Mask-based icons report all-zero alpha; recover it from hbmMask - if bgra.chunks_exact(4).all(|px| px[3] == 0) { - let mut mask = vec![0u8; count * 4]; - let mut mask_bmi = BITMAPINFO { bmiHeader: header, ..Default::default() }; - let mask_ok = !info.hbmMask.is_invalid() - && GetDIBits( - hdc, - info.hbmMask, - 0, - height as u32, - Some(mask.as_mut_ptr() as *mut _), - &mut mask_bmi, - DIB_RGB_COLORS, - ) != 0; - for (i, px) in bgra.chunks_exact_mut(4).enumerate() { - // Mask white = transparent, black = opaque - px[3] = if mask_ok && mask[i * 4] != 0 { 0 } else { 255 }; - } - } - let mut rgba = Vec::with_capacity(count * 4); - for px in bgra.chunks_exact(4) { - rgba.extend_from_slice(&[px[2], px[1], px[0], px[3]]); + ReleaseDC(None, hdc); + if lines == 0 { + return None; + } + + if bgra.chunks_exact(4).all(|px| px[3] == 0) { + for px in bgra.chunks_exact_mut(4) { + px[3] = 255; } - result = Some(encode_png(width as u32, height as u32, &rgba)); } - ReleaseDC(None, hdc); + let mut rgba = Vec::with_capacity(count * 4); + for px in bgra.chunks_exact(4) { + rgba.extend_from_slice(&[px[2], px[1], px[0], px[3]]); + } + Some(encode_png(width as u32, height as u32, &rgba)) } +} - if !info.hbmColor.is_invalid() { - let _ = DeleteObject(info.hbmColor); - } - if !info.hbmMask.is_invalid() { - let _ = DeleteObject(info.hbmMask); +// ── macOS backend ──────────────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +mod mac { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + + fn cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) } - result -} -/// 32bpp HBITMAP (e.g. from IShellItemImageFactory) → PNG bytes. Shell images -/// carry a real alpha channel; all-zero alpha (some legacy sources) is -/// recovered as fully opaque. -unsafe fn hbitmap_to_png(hbm: windows::Win32::Graphics::Gdi::HBITMAP) -> Option> { - use windows::Win32::Graphics::Gdi::{ - GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, - DIB_RGB_COLORS, - }; - - let mut bm = BITMAP::default(); - let got = GetObjectW( - hbm, - std::mem::size_of::() as i32, - Some(&mut bm as *mut _ as *mut _), - ); - let (width, height) = (bm.bmWidth, bm.bmHeight); - if got == 0 || width <= 0 || height <= 0 || width > 256 || height > 256 { - return None; + pub fn icon_for_path(path: &str) -> Option { + let key = if std::path::Path::new(path).is_dir() { + "\u{0}folder".to_string() + } else { + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default() + }; + if let Some(hit) = cache().lock().ok()?.get(&key) { + return hit.clone(); + } + let icon = nsimage_icon_for_path(path); + if let Ok(mut c) = cache().lock() { + c.insert(key, icon.clone()); + } + icon } - let hdc = GetDC(None); - let header = BITMAPINFOHEADER { - biSize: std::mem::size_of::() as u32, - biWidth: width, - biHeight: -height, // top-down - biPlanes: 1, - biBitCount: 32, - biCompression: BI_RGB.0, - ..Default::default() - }; - let count = (width * height) as usize; - let mut bgra = vec![0u8; count * 4]; - let mut bmi = BITMAPINFO { bmiHeader: header, ..Default::default() }; - let lines = GetDIBits( - hdc, - hbm, - 0, - height as u32, - Some(bgra.as_mut_ptr() as *mut _), - &mut bmi, - DIB_RGB_COLORS, - ); - ReleaseDC(None, hdc); - if lines == 0 { - return None; + fn nsstring(s: &str) -> Option<*mut objc2::runtime::AnyObject> { + use objc2::msg_send; + use objc2::runtime::AnyClass; + use std::ffi::CString; + + let c = CString::new(s).ok()?; + unsafe { + let cls = AnyClass::get("NSString")?; + let ns: *mut objc2::runtime::AnyObject = msg_send![cls, stringWithUTF8String: c.as_ptr()]; + if ns.is_null() { None } else { Some(ns) } + } } - if bgra.chunks_exact(4).all(|px| px[3] == 0) { - for px in bgra.chunks_exact_mut(4) { - px[3] = 255; + fn nsimage_icon_for_path(path: &str) -> Option { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + + unsafe { + let workspace_cls = AnyClass::get("NSWorkspace")?; + let workspace: *mut AnyObject = msg_send![workspace_cls, sharedWorkspace]; + if workspace.is_null() { + return None; + } + let ns_path = nsstring(path)?; + let image: *mut AnyObject = msg_send![workspace, iconForFile: ns_path]; + if image.is_null() { + return None; + } + + // NSImage → TIFF → NSBitmapImageRep → PNG data. + let tiff: *mut AnyObject = msg_send![image, TIFFRepresentation]; + if tiff.is_null() { + return None; + } + let rep_cls = AnyClass::get("NSBitmapImageRep")?; + let rep: *mut AnyObject = msg_send![rep_cls, alloc]; + let rep: *mut AnyObject = msg_send![rep, initWithData: tiff]; + if rep.is_null() { + return None; + } + // NSBitmapImageFileTypePNG = 4 + let png_type: u64 = 4; + let empty_dict: *mut AnyObject = msg_send![AnyClass::get("NSDictionary")?, dictionary]; + let data: *mut AnyObject = + msg_send![rep, representationUsingType: png_type properties: empty_dict]; + if data.is_null() { + return None; + } + let len: usize = msg_send![data, length]; + let bytes: *const std::ffi::c_void = msg_send![data, bytes]; + let bytes = std::slice::from_raw_parts(bytes as *const u8, len); + let b64 = super::super::fs::base64_encode(bytes); + Some(format!("data:image/png;base64,{b64}")) } } - let mut rgba = Vec::with_capacity(count * 4); - for px in bgra.chunks_exact(4) { - rgba.extend_from_slice(&[px[2], px[1], px[0], px[3]]); + + #[cfg(test)] + mod tests { + // NSWorkspace icon lookup for a stock app; verifies the Objective-C + // bridge and TIFF→PNG conversion without relying on a specific icon. + #[test] + fn smoke_mac_app_icon() { + let paths = [ + "/Applications/Calculator.app", + "/System/Applications/Calculator.app", + ]; + let mut found = false; + for path in &paths { + if std::path::Path::new(path).exists() { + let icon = super::icon_for_path(path); + assert!( + icon.as_deref().unwrap_or("").starts_with("data:image/png;base64,"), + "expected PNG data URL for {path}, got {icon:?}" + ); + found = true; + break; + } + } + assert!(found, "neither Calculator.app path exists"); + } } - Some(encode_png(width as u32, height as u32, &rgba)) } // ── Minimal PNG writer (RGBA8, uncompressed deflate) ───────────────────────── +// Only the Windows backend needs this; macOS asks AppKit for PNG bytes directly. +#[cfg(target_os = "windows")] fn crc32(data: &[u8]) -> u32 { let mut crc = 0xFFFF_FFFFu32; for &byte in data { @@ -373,6 +508,7 @@ fn crc32(data: &[u8]) -> u32 { !crc } +#[cfg(target_os = "windows")] fn adler32(data: &[u8]) -> u32 { let (mut a, mut b) = (1u32, 0u32); for &byte in data { @@ -382,6 +518,7 @@ fn adler32(data: &[u8]) -> u32 { (b << 16) | a } +#[cfg(target_os = "windows")] fn png_chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); let start = out.len(); @@ -391,8 +528,8 @@ fn png_chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { out.extend_from_slice(&crc.to_be_bytes()); } +#[cfg(target_os = "windows")] fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Vec { - // Raw scanlines, filter type 0 per row let stride = width as usize * 4; let mut raw = Vec::with_capacity((stride + 1) * height as usize); for row in rgba.chunks(stride) { @@ -400,7 +537,6 @@ fn encode_png(width: u32, height: u32, rgba: &[u8]) -> Vec { raw.extend_from_slice(row); } - // zlib stream with stored (uncompressed) deflate blocks let mut z = vec![0x78, 0x01]; let blocks: Vec<&[u8]> = raw.chunks(0xFFFF).collect(); for (i, block) in blocks.iter().enumerate() { diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index 7482e19..b7765ea 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -36,7 +36,15 @@ pub async fn path_icon(path: String) -> Option { .ok() .flatten() } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + tokio::task::spawn_blocking(move || crate::commands::icons::icon_for_path(&path)) + .await + .ok() + .flatten() + } + + #[cfg(target_os = "linux")] { let _ = path; None @@ -280,7 +288,7 @@ pub async fn search_files( icon: None, }) .collect(); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] for r in &mut out { r.icon = crate::commands::icons::icon_for_path(&r.path); } @@ -384,7 +392,7 @@ pub async fn search_files( } } - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] for r in &mut results { r.icon = crate::commands::icons::icon_for_path(&r.path); } diff --git a/src-tauri/src/commands/shortcuts.rs b/src-tauri/src/commands/shortcuts.rs index 92d3b3f..293f1b5 100644 --- a/src-tauri/src/commands/shortcuts.rs +++ b/src-tauri/src/commands/shortcuts.rs @@ -193,16 +193,21 @@ pub fn register_base_hotkey(app: &AppHandle, config: &AppConfig, game_mode: bool Ok(()) } -/// (Re-)register the screenshot hotkey (Windows only — on Linux the trigger is -/// a managed COSMIC binding that relaunches us with a deep link). Registration -/// failure is non-fatal: Windows' own Snipping Tool setting can hold PrtScn, -/// and the palette command still works. -#[cfg(target_os = "windows")] +/// (Re-)register the screenshot hotkey (Windows and macOS — on Linux the +/// trigger is a managed COSMIC binding that relaunches us with a deep link). +/// Registration failure is non-fatal: the OS or other apps may already own the +/// chosen binding, and the palette command still works. +#[cfg(any(target_os = "windows", target_os = "macos"))] fn register_screenshot_hotkey(app: &AppHandle, config: &AppConfig) { - let hotkey_str = config - .screenshot_hotkey - .as_deref() - .unwrap_or(DEFAULT_SCREENSHOT_HOTKEY); + let hotkey_str = match config.screenshot_hotkey.as_deref() { + Some(s) => s, + #[cfg(target_os = "windows")] + None => DEFAULT_SCREENSHOT_HOTKEY, + // macOS has no safe default (PrintScreen keys don't exist; Cmd+Shift+3/4/5 + // are system shortcuts), so leave it unregistered until the user sets one. + #[cfg(target_os = "macos")] + None => return, + }; let shortcut = match parse_shortcut(hotkey_str) { Ok(s) => s, Err(e) => { @@ -260,7 +265,7 @@ pub fn register_command_hotkeys( pub fn setup_shortcuts(app: &AppHandle) -> Result<(), String> { let config = read_config_sync(app)?; register_base_hotkey(app, &config, false)?; - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] register_screenshot_hotkey(app, &config); let overrides = read_overrides_sync(app)?; @@ -272,7 +277,7 @@ pub fn setup_shortcuts(app: &AppHandle) -> Result<(), String> { pub fn reload_shortcuts(app: &AppHandle, game_mode: bool) -> Result<(), String> { let config = read_config_sync(app)?; register_base_hotkey(app, &config, game_mode)?; - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] register_screenshot_hotkey(app, &config); let overrides = read_overrides_sync(app)?; diff --git a/src-tauri/src/commands/stats.rs b/src-tauri/src/commands/stats.rs index 5d4ed5f..2c1ea3f 100644 --- a/src-tauri/src/commands/stats.rs +++ b/src-tauri/src/commands/stats.rs @@ -286,6 +286,90 @@ pub async fn system_stats() -> SystemStats { } } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] + #[cfg(target_os = "macos")] + { + let (mem_used, mem_total, mem_percent) = memory(); + SystemStats { + cpu: cpu_percent(), + mem_used, + mem_total, + mem_percent, + gpu: gpu_percent(), + } + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] SystemStats { cpu: 0.0, mem_used: 0, mem_total: 0, mem_percent: 0.0, gpu: None } } + +#[cfg(all(test, target_os = "macos"))] +mod tests { + #[tokio::test] + async fn smoke_system_stats() { + let stats = super::system_stats().await; + assert!((0.0..=100.0).contains(&stats.cpu), "cpu {} out of range", stats.cpu); + assert!(stats.mem_total > 0, "mem_total should be positive"); + assert!(stats.mem_used <= stats.mem_total, "mem_used > mem_total"); + assert!( + (0.0..=100.0).contains(&stats.mem_percent), + "mem_percent {} out of range", + stats.mem_percent + ); + } +} + +/// macOS system stats backed by `sysinfo` (already pulled in for process.rs). +/// A single cached `System` instance is kept across polls so CPU usage has a +/// previous sample to delta against. +#[cfg(target_os = "macos")] +static MAC_SYS: Mutex> = Mutex::new(None); + +#[cfg(target_os = "macos")] +fn cpu_percent() -> f32 { + use sysinfo::{CpuRefreshKind, MemoryRefreshKind, RefreshKind}; + + let mut guard = MAC_SYS.lock().unwrap(); + let sys = guard.get_or_insert_with(|| { + sysinfo::System::new_with_specifics( + RefreshKind::nothing() + .with_cpu(CpuRefreshKind::everything()) + .with_memory(MemoryRefreshKind::nothing()), + ) + }); + sys.refresh_cpu_usage(); + let cpus = sys.cpus(); + if cpus.is_empty() { + return 0.0; + } + let total: f32 = cpus.iter().map(|c| c.cpu_usage()).sum(); + (total / cpus.len() as f32).clamp(0.0, 100.0) +} + +#[cfg(target_os = "macos")] +fn memory() -> (u64, u64, f32) { + use sysinfo::{MemoryRefreshKind, RefreshKind}; + + let mut guard = MAC_SYS.lock().unwrap(); + let sys = guard.get_or_insert_with(|| { + sysinfo::System::new_with_specifics( + RefreshKind::nothing().with_memory(MemoryRefreshKind::nothing()), + ) + }); + sys.refresh_memory(); + let total = sys.total_memory(); + let used = sys.used_memory(); + let pct = if total > 0 { + ((used as f64 / total as f64) * 100.0) as f32 + } else { + 0.0 + }; + (used, total, pct) +} + +#[cfg(target_os = "macos")] +fn gpu_percent() -> Option { + // No reliable, unprivileged cross-vendor GPU utilization metric on macOS. + // ioreg/powermetrics exist but require root or report vendor-specific keys; + // returning None keeps the widget hidden rather than showing stale zeroes. + None +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b266b29..464dc80 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -60,7 +60,8 @@ "icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", - "icons/icon.ico" + "icons/icon.ico", + "icons/icon.icns" ] }, "plugins": { diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 24149b2..fedda3e 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -6,10 +6,11 @@ import { dataDir, getAutostart, openPath, setAutostart, setScreenshotHotkey, set import { appEvents } from '../lib/appEvents' import { applyTheme, applyThemeByName, getAllThemes, type Theme } from '../lib/themes' -// The screenshot hotkey is a Windows-only global shortcut; on Linux the -// trigger is a managed COSMIC binding, so we hide the setting there. +// The screenshot hotkey is a global shortcut on Windows and macOS; on Linux +// the trigger is a managed COSMIC binding, so we hide the setting there. const IS_LINUX = typeof navigator !== 'undefined' && navigator.userAgent.includes('Linux') -const DEFAULT_SCREENSHOT_HOTKEY = 'Insert' +const IS_MAC = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') +const DEFAULT_SCREENSHOT_HOTKEY = IS_MAC ? '' : 'Insert' function settingsStep(config: AppConfig): Step { const transparencyPercent = Math.round((config.transparency ?? 0) * 100) diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 56f03ac..b25d7dd 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -109,8 +109,9 @@ export const writeOverrides = (overrides: Record) => export const setGlobalHotkey = (hotkey: string, gameHotkey: string | null, gameMode: boolean) => invoke('set_global_hotkey', { update: { hotkey, game_hotkey: gameHotkey }, gameMode }) -// Global hotkey that starts the region screenshot (Windows only; default 'Insert'). -// Rejects (throws) if the binding string doesn't parse. +// Global hotkey that starts the region screenshot (Windows/macOS; default 'Insert' +// on Windows, none on macOS because PrintScreen keys don't exist). Rejects (throws) +// if the binding string doesn't parse. export const setScreenshotHotkey = (hotkey: string) => invoke('set_screenshot_hotkey', { hotkey }) From 2ab9bb6ad4e7af485a780ea7eaa7be6156e20505 Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:44:28 +0100 Subject: [PATCH 12/13] macOS parity pass: @search via Finder, clipboard encryption at rest, System fixes, template tray icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gaps found comparing the finished port against the Windows/Linux arms: - @search was broken on macOS (capture_location was Windows-only and the home fallback Linux-only, so it always threw). When the palette opens over Finder the front window's folder is resolved via AppleScript on a worker thread (only queried when Finder was frontmost, so the one-time Automation prompt can't fire over unrelated apps); otherwise @search falls back to the home folder like Linux. - Clipboard history was plaintext at rest on macOS while Windows (DPAPI) and Linux (ChaCha20 + Secret Service) encrypted. The ChaCha20-Poly1305 arm now covers macOS with a 0600 key file next to the db, plus the same one-time plaintext re-encryption migration (verified live: pre-existing rows re-encrypted on startup, new copies encrypted as captured). Keychain deliberately NOT used: its ACLs bind to the code signature, so an ad-hoc-signed binary re-prompts on every rebuild — and the prompt fires inside setup and blocks launch (observed via process sample stuck in SecKeychainFindGenericPassword). - System folder: "Empty Recycle Bin" is now "Empty Trash" on macOS, and Hibernate (backend rejects it as not a macOS concept) is not listed. - Tray uses a monochrome >_ template glyph on macOS (18pt @2x, alpha-only, icon_as_template) instead of the colored app icon, which macOS would render as an opaque square; Windows/Linux keep the app icon. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_bf870673 --- CLAUDE.md | 3 +- docs/macos-port.md | 37 +++++++ src-tauri/Cargo.toml | 11 +- src-tauri/icons/tray-template.png | Bin 0 -> 280 bytes src-tauri/src/commands/clipboard/crypto.rs | 118 +++++++++++++++------ src-tauri/src/commands/clipboard/db.rs | 12 +-- src-tauri/src/commands/explorer.rs | 74 ++++++++++++- src-tauri/src/lib.rs | 24 +++++ src/commands/fileSearch.ts | 12 ++- src/components/Palette.tsx | 4 +- src/providers/system.ts | 10 +- 11 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 src-tauri/icons/tray-template.png diff --git a/CLAUDE.md b/CLAUDE.md index 31b0962..513f6e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,8 @@ macOS dev/test notes: - The app is an Accessory (no Dock icon / Cmd-Tab entry). Use the tray icon or the global hotkey to surface it. - Default toggle hotkey is `Cmd+Shift+Space` (Spotlight owns `Cmd+Space`, input-source switching owns `Ctrl+Space`). - Screenshot capture and paste-to-previous require permission grants: **Screen Recording** for screenshots, **Accessibility** for paste. Until granted the commands fail with instructions rather than silently no-oping. -- Shutdown/Restart/Logout/Empty Trash trigger a one-time **Automation** prompt on first use (System Events / Finder). +- Shutdown/Restart/Logout/Empty Trash trigger a one-time **Automation** prompt on first use (System Events / Finder). `@search` over the focused Finder folder uses the same Finder Automation channel (and only queries Finder when the palette opened over it; otherwise it falls back to the home folder like Linux). +- Clipboard history is encrypted at rest on all three platforms: DPAPI on Windows; ChaCha20-Poly1305 on Linux (key in the Secret Service, 0600 key-file fallback) and macOS (0600 key file next to the db). **Do not move the macOS key to the Keychain** while the app ships ad-hoc-signed: Keychain ACLs bind to the code signature, so every rebuild re-prompts — and the prompt fires during setup and blocks launch (verified on-device). - `npm run release` produces a signed/unsigned `bin/commandeer.app` bundle; right-click → Open the first time if unsigned. ## Shipping changes diff --git a/docs/macos-port.md b/docs/macos-port.md index 7a32fae..775553c 100644 --- a/docs/macos-port.md +++ b/docs/macos-port.md @@ -222,6 +222,11 @@ interactive verification (see below). instructions rather than no-opping. - Shutdown/Restart/Logout/Empty Trash: one-time **Automation** prompts on first use (System Events / Finder). +- @search over a Finder folder: the same Finder **Automation** prompt on + first use. Verified headlessly that the palette stays responsive while the + prompt is pending (the osascript runs on a worker thread and @search falls + back to the home folder until it resolves); the actual folder pick needs + the grant. ## Phase 4 — Packaging & sync hygiene — DONE @@ -251,6 +256,38 @@ interactive verification (see below). `target_os = "linux"` / `target_os = "macos"` gates instead of bare `not(windows)` branches, matching the port rule. +## Post-port parity pass (2026-07-04) + +Gaps found comparing the finished port against the Windows/Linux arms after +the Linux-parity merge landed: + +- [x] **@search on macOS** — was broken (`capture_location` was Windows-only, + the home-folder fallback Linux-only, so macOS threw "No File Explorer + folder is focused"). Now: when the palette opens over Finder, the front + Finder window's folder is resolved via AppleScript (same Automation + channel as Empty Trash; only queried when Finder was frontmost so the + one-time prompt can't fire over unrelated apps); otherwise @search falls + back to the home folder like Linux. Wording is Finder-specific on mac. +- [x] **Clipboard history encryption at rest** — was plaintext on macOS while + Windows (DPAPI) and Linux (ChaCha20 + Secret Service) encrypted. The + Linux ChaCha20-Poly1305 arm now covers macOS, with the key in a 0600 + key file (`~/Library/Application Support/dev.commandeer.app/ + clipboard.key`) and the same one-time plaintext re-encryption migration. + Round-trip + legacy-passthrough tests added. **Keychain deliberately not + used**: its ACLs bind to the code signature, and an ad-hoc-signed binary + gets a new signature every rebuild, so each rebuild re-prompted — with + the prompt firing inside setup and blocking launch entirely (observed + on-device via a process sample stuck in SecKeychainFindGenericPassword). + Revisit only with a stable Developer ID signature. +- [x] **System folder fixes** — "Empty Recycle Bin" is now "Empty Trash" on + macOS, and Hibernate (which the backend rejects as not a macOS concept) + is no longer listed. +- [x] **Menu-bar template icon** — the tray now uses a monochrome `>_` glyph + (`icons/tray-template.png`, 18pt @2x, alpha-only) with + `icon_as_template`, so macOS tints it correctly for light/dark menu + bars; Windows/Linux keep the colored app icon. (Closes the Phase 2 + follow-up note.) + ## Effort estimate - **Phase 1 (compiles + launches):** small — a focused session. Answers "is it diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 545100e..fabfa7f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -61,15 +61,18 @@ windows = { version = "0.58", features = [ "Win32_System_Variant", ] } +# Clipboard-history encryption at rest (Windows uses DPAPI instead; Linux and +# macOS encrypt with ChaCha20-Poly1305 — key in the Secret Service via +# `keyring` on Linux, in a 0600 key file on macOS; see clipboard/crypto.rs for +# why not the Keychain) +[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] +chacha20poly1305 = "0.10" + # Linux-only (not merely non-Windows): GTK/layer-shell must NOT be pulled in on # macOS, which is also "not windows". macOS uses native window APIs instead. [target.'cfg(target_os = "linux")'.dependencies] # kill(2) for the process-list "kill" action libc = "0.2" -# Clipboard-history encryption at rest (Windows uses DPAPI; Linux stores the -# key in the Secret Service via `keyring`, falling back to a 0600 key file; -# macOS currently keeps history unencrypted — see clipboard/crypto.rs) -chacha20poly1305 = "0.10" keyring = { version = "3", features = ["sync-secret-service"] } # Render the palette as a wlr-layer-shell surface on Linux/Wayland: lets the # compositor center it and makes transparent regions truly invisible (no diff --git a/src-tauri/icons/tray-template.png b/src-tauri/icons/tray-template.png new file mode 100644 index 0000000000000000000000000000000000000000..239793f8ac8c7c385c180c6a0a3df1dead5667a4 GIT binary patch literal 280 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbB?t8j8hE&{2PLN<-oFLM}Byxb` zXfsm;^G8dKG8rC4=Askz8vl5XY-~R1sPKooL%VTCrNZJjsK aPKIqf6KwDGoNxttmBG{1&t;ucLK6U^)N7*v literal 0 HcmV?d00001 diff --git a/src-tauri/src/commands/clipboard/crypto.rs b/src-tauri/src/commands/clipboard/crypto.rs index 1d37a81..a8ce5ec 100644 --- a/src-tauri/src/commands/clipboard/crypto.rs +++ b/src-tauri/src/commands/clipboard/crypto.rs @@ -2,12 +2,18 @@ //! //! On Windows we use DPAPI (CryptProtectData / CryptUnprotectData), which ties //! the ciphertext to the current user account and requires no key management. -//! On Linux we use ChaCha20-Poly1305 with a random key held in the Secret -//! Service (login keyring) via the `keyring` crate, falling back to a +//! On Linux and macOS we use ChaCha20-Poly1305: on Linux the key lives in the +//! Secret Service (login keyring) via the `keyring` crate, falling back to a //! 0600-permission key file in the app data dir when no keyring daemon is -//! available. Rows are prefixed with a magic marker so pre-encryption rows -//! pass through `decrypt` unchanged (and get re-encrypted by a one-time -//! migration in db.rs). Other Unixes keep the plaintext passthrough. +//! available; on macOS the key file is the primary store. The Keychain was +//! tried and rejected for macOS: its ACLs bind to the code signature, and this +//! app ships ad-hoc-signed and is rebuilt constantly (see the ship-change +//! loop), so every rebuild re-prompts — and the prompt fires inside setup, +//! blocking launch until it is answered. Revisit only if the app ships with a +//! stable Developer ID signature. +//! Rows are prefixed with a magic marker so pre-encryption rows pass through +//! `decrypt` unchanged (and get re-encrypted by a one-time migration in +//! db.rs). Other Unixes keep the plaintext passthrough. #[cfg(target_os = "windows")] pub fn encrypt(plaintext: &[u8]) -> Result, String> { @@ -71,16 +77,16 @@ pub fn decrypt(ciphertext: &[u8]) -> Result, String> { /// Stored-blob marker for encrypted rows. Starts with a NUL byte, which never /// begins a legacy plaintext row (they are non-empty UTF-8 clipboard strings). -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] const MAGIC: &[u8; 6] = b"\x00CMDR1"; /// Whether a stored blob is one of ours (vs legacy plaintext). -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn is_encrypted(blob: &[u8]) -> bool { blob.starts_with(MAGIC) } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn encrypt(plaintext: &[u8]) -> Result, String> { use chacha20poly1305::aead::{Aead, AeadCore, OsRng}; use chacha20poly1305::{ChaCha20Poly1305, KeyInit}; @@ -98,14 +104,14 @@ pub fn encrypt(plaintext: &[u8]) -> Result, String> { Ok(out) } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] pub fn decrypt(ciphertext: &[u8]) -> Result, String> { use chacha20poly1305::aead::Aead; use chacha20poly1305::{ChaCha20Poly1305, KeyInit}; if !is_encrypted(ciphertext) { - // Row from before encryption landed on Linux; passed through so old - // histories keep working (db.rs re-encrypts them on startup). + // Row from before encryption landed on this platform; passed through + // so old histories keep working (db.rs re-encrypts them on startup). return Ok(ciphertext.to_vec()); } let body = &ciphertext[MAGIC.len()..]; @@ -123,16 +129,18 @@ pub fn decrypt(ciphertext: &[u8]) -> Result, String> { } } - // Only on failure, try the other key sources. A transient Secret Service - // outage can fall back to a file key (or vice versa), leaving some rows - // encrypted under keyring and others under the file; trying every + // Only on failure, try the other key sources. On Linux a transient Secret + // Service outage can fall back to a file key (or vice versa), leaving some + // rows encrypted under keyring and others under the file; trying every // available key keeps all rows decryptable. Gathered lazily because - // existing_keyring_key() is a D-Bus round trip per call. + // existing_keyring_key() is a D-Bus round trip per call. On macOS the file + // key is the only source, so the alternates just re-read it from disk. let mut tried_any = primary.is_some(); - for alt in [existing_keyring_key(), existing_file_key()] - .into_iter() - .flatten() - { + #[cfg(target_os = "linux")] + let alternates = [existing_keyring_key(), existing_file_key()]; + #[cfg(target_os = "macos")] + let alternates = [existing_file_key()]; + for alt in alternates.into_iter().flatten() { if primary.as_ref() == Some(&alt) { continue; } @@ -149,16 +157,28 @@ pub fn decrypt(ciphertext: &[u8]) -> Result, String> { }) } -/// The 32-byte data key: from the Secret Service when available (created on -/// first use), else a 0600 key file next to the database. Resolved once. -#[cfg(target_os = "linux")] +/// The 32-byte data key, resolved once. Linux: from the Secret Service when +/// available (created on first use), else a 0600 key file next to the +/// database. macOS: the key file directly (see the module docs for why not +/// the Keychain). +#[cfg(any(target_os = "linux", target_os = "macos"))] fn key() -> Result<[u8; 32], String> { use std::sync::OnceLock; static KEY: OnceLock> = OnceLock::new(); - KEY.get_or_init(|| keyring_key().or_else(|_| file_key())).clone() + KEY.get_or_init(|| { + #[cfg(target_os = "linux")] + { + keyring_key().or_else(|_| file_key()) + } + #[cfg(target_os = "macos")] + { + file_key() + } + }) + .clone() } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn generate_key() -> [u8; 32] { use chacha20poly1305::aead::rand_core::RngCore; let mut key = [0u8; 32]; @@ -192,7 +212,7 @@ fn existing_keyring_key() -> Option<[u8; 32]> { } /// Read the key file if it already exists, without creating one. -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn existing_file_key() -> Option<[u8; 32]> { std::fs::read(key_file_path().ok()?) .ok()? @@ -214,9 +234,20 @@ fn key_file_path() -> Result { .join("clipboard.key")) } -/// Fallback when no Secret Service daemon is running (headless, minimal WMs): -/// a raw key file readable only by the user, alongside the clipboard db. -#[cfg(target_os = "linux")] +/// Location of the fallback key file, alongside the clipboard db. +#[cfg(target_os = "macos")] +fn key_file_path() -> Result { + let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?; + Ok(std::path::PathBuf::from(home) + .join("Library/Application Support") + .join("dev.commandeer.app") + .join("clipboard.key")) +} + +/// A raw key file readable only by the user, alongside the clipboard db. +/// Linux: the fallback when no Secret Service daemon is running (headless, +/// minimal WMs). macOS: the primary key store. +#[cfg(any(target_os = "linux", target_os = "macos"))] fn file_key() -> Result<[u8; 32], String> { let path = key_file_path()?; if let Some(dir) = path.parent() { @@ -240,16 +271,17 @@ fn file_key() -> Result<[u8; 32], String> { .open(&path) .map_err(|e| e.to_string())?; f.write_all(&key).map_err(|e| e.to_string())?; + #[cfg(target_os = "linux")] eprintln!("clipboard: no Secret Service available, using key file at {}", path.display()); Ok(key) } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "macos"))] fn hex_decode(s: &str) -> Option<[u8; 32]> { let s = s.trim(); if s.len() != 64 { @@ -262,12 +294,34 @@ fn hex_decode(s: &str) -> Option<[u8; 32]> { Some(out) } -#[cfg(not(any(target_os = "windows", target_os = "linux")))] +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] pub fn encrypt(plaintext: &[u8]) -> Result, String> { Ok(plaintext.to_vec()) } -#[cfg(not(any(target_os = "windows", target_os = "linux")))] +#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] pub fn decrypt(ciphertext: &[u8]) -> Result, String> { Ok(ciphertext.to_vec()) } + +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] +mod tests { + // Exercises the real key path (OS secret store, or the file fallback when + // no store is reachable) — the same key the app itself would create/use. + #[test] + fn roundtrip_and_marker() { + let plain = "clipboard row \u{1F980} with unicode".as_bytes(); + let blob = super::encrypt(plain).expect("encrypt"); + assert!(super::is_encrypted(&blob), "encrypted rows carry the magic marker"); + assert_ne!(&blob[super::MAGIC.len()..], plain, "must not store plaintext"); + assert_eq!(super::decrypt(&blob).expect("decrypt"), plain); + } + + // Pre-encryption rows (no marker) must pass through unchanged so old + // histories keep working until db.rs re-encrypts them. + #[test] + fn legacy_plaintext_passthrough() { + let legacy = b"plain old clipboard text"; + assert_eq!(super::decrypt(legacy).expect("passthrough"), legacy); + } +} diff --git a/src-tauri/src/commands/clipboard/db.rs b/src-tauri/src/commands/clipboard/db.rs index 9fa94c8..e64f9b5 100644 --- a/src-tauri/src/commands/clipboard/db.rs +++ b/src-tauri/src/commands/clipboard/db.rs @@ -58,15 +58,15 @@ impl ClipboardDb { conn: Arc::new(Mutex::new(conn)), }; db.migrate_json(app)?; - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] db.migrate_plaintext()?; Ok(db) } /// One-time (idempotent) re-encryption of rows written before encryption - /// existed on Linux. `decrypt` passes unmarked rows through, so this is - /// safe to interrupt and re-run. - #[cfg(target_os = "linux")] + /// existed on Linux/macOS. `decrypt` passes unmarked rows through, so this + /// is safe to interrupt and re-run. + #[cfg(any(target_os = "linux", target_os = "macos"))] fn migrate_plaintext(&self) -> Result<(), String> { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction().map_err(|e| e.to_string())?; @@ -96,8 +96,8 @@ impl ClipboardDb { } /// Decrypt a stored row. On Windows a failure is fatal (DPAPI always - /// roundtrips for the same user); on Linux a lost key must not brick the - /// whole history, so undecryptable rows are skipped via `None`. + /// roundtrips for the same user); on Linux/macOS a lost key must not brick + /// the whole history, so undecryptable rows are skipped via `None`. fn decrypt_row(encrypted: &[u8]) -> Result, String> { let result = crypto::decrypt(encrypted).and_then(|bytes| { String::from_utf8(bytes).map_err(|e| format!("invalid UTF-8 in clipboard entry: {e}")) diff --git a/src-tauri/src/commands/explorer.rs b/src-tauri/src/commands/explorer.rs index d0ffe3f..a0276ab 100644 --- a/src-tauri/src/commands/explorer.rs +++ b/src-tauri/src/commands/explorer.rs @@ -1,7 +1,8 @@ -//! File search in the active Explorer folder: resolve which folder was open -//! in the File Explorer window that had focus before the palette was shown -//! (via the IShellWindows COM enumeration), and list a folder's contents -//! recursively for the palette to filter client-side. +//! File search in the active file-manager folder: resolve which folder was +//! open in the File Explorer (Windows, via the IShellWindows COM enumeration) +//! or Finder (macOS, via AppleScript) window that had focus before the palette +//! was shown, and list a folder's contents recursively for the palette to +//! filter client-side. use serde::Serialize; use std::sync::Mutex; @@ -100,6 +101,71 @@ pub fn capture_location() { *LAST_LOCATION.lock().unwrap() = loc; }); } + + #[cfg(target_os = "macos")] + { + // Only ask Finder when it is the app the palette opened over: the + // AppleScript below needs the one-time Automation permission, so it + // must not fire on every palette show over unrelated apps. + if !frontmost_is_finder() { + return; + } + std::thread::spawn(|| { + let loc = finder_front_window_path(); + *LAST_LOCATION.lock().unwrap() = loc; + }); + } +} + +/// Whether the frontmost application (the one the palette is about to cover) +/// is Finder. Cheap NSWorkspace lookup, no permissions involved. +#[cfg(target_os = "macos")] +fn frontmost_is_finder() -> bool { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + unsafe { + let Some(cls) = AnyClass::get("NSWorkspace") else { + return false; + }; + let ws: *mut AnyObject = msg_send![cls, sharedWorkspace]; + if ws.is_null() { + return false; + } + let front: *mut AnyObject = msg_send![ws, frontmostApplication]; + if front.is_null() { + return false; + } + let bundle_id: *mut AnyObject = msg_send![front, bundleIdentifier]; + if bundle_id.is_null() { + return false; + } + let cstr: *const std::os::raw::c_char = msg_send![bundle_id, UTF8String]; + if cstr.is_null() { + return false; + } + std::ffi::CStr::from_ptr(cstr).to_bytes() == b"com.apple.finder" + } +} + +/// Folder shown in Finder's front window, via AppleScript (the same channel +/// Empty Trash uses; first use triggers the one-time Automation prompt). +/// Virtual locations (Recents, AirDrop, Trash, a window-less Desktop focus) +/// fail the `as alias` coercion and yield None — the frontend then falls back +/// to the home folder. +#[cfg(target_os = "macos")] +fn finder_front_window_path() -> Option { + let out = std::process::Command::new("osascript") + .args([ + "-e", + "tell application \"Finder\" to POSIX path of (target of front Finder window as alias)", + ]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if path.is_empty() { None } else { Some(path) } } /// Folder open in the File Explorer window that was focused when the palette diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 15b9d3f..1fc7eaf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -236,6 +236,30 @@ fn setup_tray(app: &tauri::App) -> tauri::Result<()> { "quit" => app.exit(0), _ => {} }); + #[cfg(target_os = "macos")] + { + // Menu-bar icons are template images on macOS: alpha-only glyphs the + // system tints for light/dark menu bars and selection. The colored app + // icon would read as an opaque square, so use a dedicated monochrome + // glyph (18pt @2x) and fall back to the app icon only if it fails to + // decode. + const TRAY_TEMPLATE: &[u8] = include_bytes!("../icons/tray-template.png"); + match image::load_from_memory(TRAY_TEMPLATE) { + Ok(img) => { + let rgba = img.to_rgba8(); + let (w, h) = rgba.dimensions(); + tray = tray + .icon(tauri::image::Image::new_owned(rgba.into_raw(), w, h)) + .icon_as_template(true); + } + Err(_) => { + if let Some(icon) = app.default_window_icon() { + tray = tray.icon(icon.clone()); + } + } + } + } + #[cfg(not(target_os = "macos"))] if let Some(icon) = app.default_window_icon() { tray = tray.icon(icon.clone()); } diff --git a/src/commands/fileSearch.ts b/src/commands/fileSearch.ts index b56b946..6435ab7 100644 --- a/src/commands/fileSearch.ts +++ b/src/commands/fileSearch.ts @@ -1,8 +1,10 @@ -// "@search" prefix search over the folder open in the previously-focused File -// Explorer window. The whole tree is loaded once (parallel walk in Rust, -// capped), then every keystroke filters client-side — no IPC while typing. +// "@search" prefix search over the folder open in the previously-focused +// file-manager window (File Explorer on Windows, Finder on macOS). The whole +// tree is loaded once (parallel walk in Rust, capped), then every keystroke +// filters client-side — no IPC while typing. // On Linux there is no way to ask the compositor which file-manager folder is -// focused, so @search predictably falls back to the home folder instead. +// focused, so @search predictably falls back to the home folder instead — as +// does macOS when Finder wasn't the frontmost app. import type { PaletteItem } from '../types' import { envInfo, explorerLocation, listFilesRecursive, openPath, type FileEntry } from '../lib/tauri' @@ -30,7 +32,7 @@ export async function loadActiveFolderItems(): Promise { let folder = await explorerLocation() if (!folder) { const env = await envInfo() - if (env.os === 'linux' && env.home) folder = env.home + if ((env.os === 'linux' || env.os === 'macos') && env.home) folder = env.home } if (!folder) throw new Error('No File Explorer folder is focused') const files = await listFilesRecursive(folder, FILE_CAP) diff --git a/src/components/Palette.tsx b/src/components/Palette.tsx index f7e3315..58d0cb8 100644 --- a/src/components/Palette.tsx +++ b/src/components/Palette.tsx @@ -254,11 +254,11 @@ const LAST_CMD_KEY = 'commandeer:last' // Root-level @ prefixes. Typing '@' (or a partial token) lists these as // suggestions; a completed token followed by a space activates the mode. // @find → global file search (FTS5 index → Everything → walkdir) -// @search → file search in the focused Explorer folder +// @search → file search in the focused Explorer/Finder folder // @web → web search in the browser const AT_PREFIXES = [ { token: '@find', icon: 'folder', description: 'Find files across your computer' }, - { token: '@search', icon: 'folder', description: IS_LINUX ? 'Search your home folder' : 'Search the focused Explorer folder' }, + { token: '@search', icon: 'folder', description: IS_LINUX ? 'Search your home folder' : IS_MAC ? 'Search the focused Finder folder' : 'Search the focused Explorer folder' }, { token: '@web', icon: 'search', description: 'Search the web' }, { token: '@calc', icon: 'calculator', description: 'Calculate an expression (40+2, 100 usd to eur)' }, { token: '@time', icon: 'clock', description: 'Convert time zones (4pm bst to est)' }, diff --git a/src/providers/system.ts b/src/providers/system.ts index f077984..50b1c43 100644 --- a/src/providers/system.ts +++ b/src/providers/system.ts @@ -1,9 +1,9 @@ import type { Command, CommandProvider, Step } from '../types' -import { IS_LINUX, systemAction, type SystemActionId } from '../lib/tauri' +import { IS_LINUX, IS_MAC, systemAction, type SystemActionId } from '../lib/tauri' // Same feature, different vocabulary: Windows' "Recycle Bin" is "Trash" // everywhere else. -const TRASH_NOUN = IS_LINUX ? 'Trash' : 'Recycle Bin' +const TRASH_NOUN = IS_LINUX || IS_MAC ? 'Trash' : 'Recycle Bin' interface SystemCommand { id: SystemActionId @@ -67,5 +67,9 @@ export const systemProvider: CommandProvider = { id: 'system', name: 'System', priority: 40, - getCommands: () => SYSTEM_COMMANDS.map(actionCommand), + getCommands: () => + SYSTEM_COMMANDS + // Hibernate isn't a macOS concept (the backend rejects it); don't list it. + .filter(sc => !(IS_MAC && sc.id === 'hibernate')) + .map(actionCommand), } From ffcab3ad2647a790a18c763a9632fea84b1bea6e Mon Sep 17 00:00:00 2001 From: alexj2k0 <87905015+alexj2k0@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:47:25 +0100 Subject: [PATCH 13/13] ci: install Tauri's Linux system libraries on the ubuntu runner First real run of the Phase 4 workflow: glib-sys failed on a bare ubuntu-latest because Tauri links system GTK/WebKit. Also pull in gtk-layer-shell (palette overlay surface) and D-Bus headers (keyring/Secret Service). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_bf870673 --- .github/workflows/ci.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8a44d9..4f33bd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,17 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest + # Tauri's Linux stack links against system GTK/WebKit; the repo + # additionally needs gtk-layer-shell (palette overlay) and D-Bus + # headers (keyring/Secret Service). + - name: Install Linux system libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ + librsvg2-dev libxdo-dev libssl-dev libgtk-layer-shell-dev \ + libdbus-1-dev - run: bun install - name: Build Tauri backend run: cargo build --manifest-path src-tauri/Cargo.toml