diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4f33bd1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +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 + # 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 + - name: Run Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml diff --git a/.gitignore b/.gitignore index be2edf2..74a2743 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 a1f2601..513f6e6 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,6 +31,14 @@ 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). `@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 After **every** completed task, bug fix, or feature — once the work is done and verified — ship it: **(1) commit and push, (2) rebuild the release binary, (3) restart the running process** on the new binary. The running app should always reflect committed code. Use the `ship-change` skill (`.claude/skills/ship-change/SKILL.md`), which encodes the exact per-OS steps (Windows/macOS/Linux): @@ -46,7 +57,7 @@ Two always-running Tauri windows that hide/show rather than launching per use: t ### Screenshot tool -Lightshot-style region capture: trigger → Rust freezes the screen to `/frame.png` (on Linux a four-CLI fallback chain — `cosmic-screenshot` → `gnome-screenshot` → `spectacle` → `grim`, first one present wins; on Windows a GDI BitBlt of the full virtual screen — all monitors — with the overlay spanning the same bounds) → 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. On Windows the overlay appears via a cloak-then-reveal handshake: at capture time the window is positioned/sized and shown **DWM-cloaked** (composited but not displayed — WebView2 only renders while visible), the frame `` loads and rasterizes off-screen, and `reveal_screenshot_overlay` uncloaks only when the webview reports the image was actually **presented**, via an **Element Timing** observer (`elementtiming="shot-frame"`). onLoad/double-rAF are NOT sufficient signals — they race the GPU raster of the multi-monitor-sized texture and flashed black. Fallbacks: a 500 ms frontend timer post-onload and a 1500 ms Rust-side force-show+uncloak; all commands are idempotent. On Linux the overlay is a 4-edge-anchored, exclusive-keyboard layer-shell surface; the frontend's `show_screenshot_overlay` call on img onload is the show path there and the cloak machinery is Windows-only. Linux has its own stale-frame defense (there is no DWM cloak, and GTK3 toplevel opacity is a no-op on Wayland): WebKitGTK replays its **last composite** as the first frame when a hidden window is re-mapped, so the overlay window is **transparent on Linux** (`tauri.linux.conf.json` — platform configs replace the whole `app.windows` array, keep it in sync with `tauri.conf.json`) and the frontend always paints a cleared, fully transparent state (no frame, no veil) and waits a double-rAF (`afterClearPaint`) **before** any hide — finish, Esc-cancel, and the re-trigger path (Rust emits `screenshot-clear`; the webview clears then calls `hide_screenshot_overlay`; Rust force-hides after its pre-capture delay as fallback). The replayed composite is then invisible, the live desktop underneath is pixel-identical to the frozen frame, and the overlay's appearance reads as a single smooth veil dim. On Windows, both windows set `additionalBrowserArgs` with `CalculateNativeWinOcclusion` disabled (WebView2 browser args are process-wide — keep the two windows' args identical): without it, Chromium suspends rendering of hidden windows, the new frame never paints before `show()`, and the window flashes its stale surface (the previous capture) for a frame. +Lightshot-style region capture: trigger → Rust freezes the screen to `/frame.png` (on Linux a four-CLI fallback chain — `cosmic-screenshot` → `gnome-screenshot` → `spectacle` → `grim`, first one present wins; on Windows a GDI BitBlt of the full virtual screen — all monitors — with the overlay spanning the same bounds; on macOS `screencapture -R` of the cursor monitor) → 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. On Windows the overlay appears via a cloak-then-reveal handshake: at capture time the window is positioned/sized and shown **DWM-cloaked** (composited but not displayed — WebView2 only renders while visible), the frame `` loads and rasterizes off-screen, and `reveal_screenshot_overlay` uncloaks only when the webview reports the image was actually **presented**, via an **Element Timing** observer (`elementtiming="shot-frame"`). onLoad/double-rAF are NOT sufficient signals — they race the GPU raster of the multi-monitor-sized texture and flashed black. Fallbacks: a 500 ms frontend timer post-onload and a 1500 ms Rust-side force-show+uncloak; all commands are idempotent. On Linux the overlay is a 4-edge-anchored, exclusive-keyboard layer-shell surface; the frontend's `show_screenshot_overlay` call on img onload is the show path there (and on macOS) and the cloak machinery is Windows-only. Linux has its own stale-frame defense (there is no DWM cloak, and GTK3 toplevel opacity is a no-op on Wayland): WebKitGTK replays its **last composite** as the first frame when a hidden window is re-mapped, so the overlay window is **transparent on Linux** (`tauri.linux.conf.json` — platform configs replace the whole `app.windows` array, keep it in sync with `tauri.conf.json`) and the frontend always paints a cleared, fully transparent state (no frame, no veil) and waits a double-rAF (`afterClearPaint`) **before** any hide — finish, Esc-cancel, and the re-trigger path (Rust emits `screenshot-clear`; the webview clears then calls `hide_screenshot_overlay`; Rust force-hides after its pre-capture delay as fallback). The replayed composite is then invisible, the live desktop underneath is pixel-identical to the frozen frame, and the overlay's appearance reads as a single smooth veil dim. On Windows, both windows set `additionalBrowserArgs` with `CalculateNativeWinOcclusion` disabled (WebView2 browser args are process-wide — keep the two windows' args identical): without it, Chromium suspends rendering of hidden windows, the new frame never paints before `show()`, and the window flashes its stale surface (the previous capture) for a frame. ### Frontend (`src/`) @@ -58,7 +69,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/`) @@ -66,10 +77,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 new file mode 100644 index 0000000..775553c --- /dev/null +++ b/docs/macos-port.md @@ -0,0 +1,298 @@ +# Commandeer → macOS port plan + +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) + +**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) — DONE + +- [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) — DONE + +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. + +- [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. (`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; 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` +- [x] **macOS setup arm** — none needed to launch: the palette is already + `transparent`, `decorations:false`, `alwaysOnTop`, `center`. Rounded + corners / vibrancy come in Phase 2. +- [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 — 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 +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). + +### 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`** — 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. + 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 — 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). +- @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 + +- [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. + +## 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 + 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. diff --git a/package.json b/package.json index 5cc13b0..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 \"const b = process.platform === 'win32' ? 'commandeer.exe' : 'commandeer'; await Bun.$`mkdir -p bin`; await Bun.$`cp src-tauri/target/release/${b} bin/${b}`\"" + "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/Cargo.lock b/src-tauri/Cargo.lock index 8df81e8..e25a671 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -78,11 +78,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", @@ -317,13 +317,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]] @@ -592,17 +601,20 @@ dependencies = [ "arboard", "chacha20poly1305", "chrono", + "core-graphics 0.24.0", "gtk", "gtk-layer-shell", "image", "keyring", "libc", "notify", + "objc2 0.5.2", "rayon", "reqwest 0.12.28", "rusqlite", "serde", "serde_json", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-autostart", @@ -613,6 +625,7 @@ dependencies = [ "tokio", "uuid", "walkdir", + "window-vibrancy 0.5.3", "windows 0.58.0", ] @@ -677,6 +690,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" @@ -977,9 +1003,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]] @@ -1681,8 +1707,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", @@ -2632,10 +2658,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", @@ -2704,6 +2730,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" @@ -2741,6 +2776,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" @@ -2751,6 +2802,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" @@ -2758,11 +2825,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]] @@ -2773,7 +2852,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", "dispatch2", - "objc2", + "objc2 0.6.4", ] [[package]] @@ -2784,11 +2863,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" @@ -2804,6 +2895,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" @@ -2811,8 +2914,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", ] @@ -2823,10 +2926,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" @@ -2834,9 +2962,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]] @@ -2846,9 +2974,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]] @@ -2858,11 +2986,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]] @@ -4229,11 +4357,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", @@ -4388,6 +4516,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" @@ -4421,9 +4563,9 @@ 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", + "core-graphics 0.25.0", "crossbeam-channel", "dispatch2", "dlopen2", @@ -4437,9 +4579,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", @@ -4498,9 +4640,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", @@ -4523,7 +4665,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "window-vibrancy", + "window-vibrancy 0.6.0", "windows 0.61.3", ] @@ -4665,8 +4807,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", @@ -4705,7 +4847,7 @@ dependencies = [ "gtk", "http", "jni", - "objc2", + "objc2 0.6.4", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", @@ -4729,8 +4871,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", @@ -5204,11 +5346,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", @@ -5716,21 +5858,45 @@ 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", ] +[[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" @@ -5763,6 +5929,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" @@ -5813,6 +5991,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" @@ -5835,6 +6024,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" @@ -5890,6 +6090,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" @@ -6404,7 +6613,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", @@ -6418,10 +6627,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 86953d2..fabfa7f 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,18 +61,36 @@ 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) -chacha20poly1305 = "0.10" keyring = { version = "3", features = ["sync-secret-service"] } - -[target.'cfg(not(windows))'.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. 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" +# 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/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..0060df4 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/tray-template.png b/src-tauri/icons/tray-template.png new file mode 100644 index 0000000..239793f Binary files /dev/null and b/src-tauri/icons/tray-template.png differ diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index af9e3ea..c091714 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")] { tokio::task::spawn_blocking(linux::list_devices) .await @@ -45,7 +50,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")] { tokio::task::spawn_blocking(move || linux::get_volume(device.as_deref())) .await @@ -64,7 +76,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 = level.clamp(0.0, 1.0); tokio::task::spawn_blocking(move || linux::set_volume(level, device.as_deref())) @@ -84,7 +104,14 @@ 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")] { tokio::task::spawn_blocking(move || linux::toggle_mute(device.as_deref())) .await @@ -96,7 +123,7 @@ pub async fn toggle_mute(device: Option) -> Result { /// the Fedora default) with `pactl` (PulseAudio) as the fallback. The backend /// is probed once and cached; device ids stay opaque strings for the frontend /// (wpctl object ids / pactl sink names). -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] mod linux { use super::AudioDevice; use std::sync::Mutex; @@ -301,6 +328,68 @@ mod linux { } } +/// 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/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/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/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/commands/fs.rs b/src-tauri/src/commands/fs.rs index e551b28..cf68bf1 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. +/// 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()); @@ -33,19 +34,24 @@ 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") ) { return true; } + #[cfg(target_os = "macos")] + 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() @@ -55,7 +61,7 @@ fn is_executable(path: &Path) -> bool { } // .desktop parsing/icon helpers live in the shared desktop module. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] use super::desktop::{resolve_desktop_icon, resolve_desktop_name}; fn collect_script_files(dir: &Path, folder: Option) -> Vec { @@ -75,8 +81,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 { @@ -100,8 +106,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) @@ -406,6 +412,7 @@ pub async fn run_script(path: String) -> Result<(), String> { .unwrap_or("") .to_lowercase(); + #[cfg(target_os = "linux")] if ext == "desktop" { // Launch through gio when present, else parse Exec= ourselves. return super::desktop::launch_desktop_file(script_path); @@ -415,6 +422,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) @@ -425,7 +439,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/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/launcher.rs b/src-tauri/src/commands/launcher.rs index b98bc01..ba49596 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")] { tokio::task::spawn_blocking(|| { let mut apps = desktop_dir_entries(); @@ -138,7 +213,7 @@ pub async fn list_apps() -> Result, String> { /// Enumerate installed apps from the XDG applications dirs (`~/.local/share` /// first so user entries shadow system ones, then each `XDG_DATA_DIRS` entry — /// which on Fedora includes the Flatpak exports). Deduped by desktop-file ID. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn desktop_dir_entries() -> Vec { use std::collections::HashSet; use std::path::PathBuf; @@ -243,7 +318,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")] { tokio::task::spawn_blocking(move || { super::desktop::launch_desktop_file(std::path::Path::new(&path)) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 682325f..c8c9ba9 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -3,7 +3,8 @@ pub mod claude; pub mod clipboard; pub mod config; pub mod deeplink; -#[cfg(not(target_os = "windows"))] +// Linux-only (not merely non-Windows): pulls in gtk/gio, which macOS lacks. +#[cfg(target_os = "linux")] pub mod desktop; pub mod explorer; pub mod file_index; diff --git a/src-tauri/src/commands/paste.rs b/src-tauri/src/commands/paste.rs index 78ad341..d1b611d 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,12 +123,13 @@ 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. Returns whether the -/// paste keystroke was actually delivered: on Linux that needs an input -/// synthesizer (wtype / ydotool on Wayland, xdotool on X11) — without one this -/// degrades to copy-only and returns `false` so the frontend can tell the user -/// to press Ctrl+V themselves. +/// 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). +/// Returns whether the paste keystroke was actually delivered: on Linux that +/// needs an input synthesizer (wtype / ydotool on Wayland, xdotool on X11) — +/// without one this degrades to copy-only and returns `false` so the frontend +/// can tell the user to press Ctrl+V themselves. On macOS delivery requires +/// the Accessibility permission and fails loudly without it. #[tauri::command] pub async fn paste_to_previous(app: tauri::AppHandle, text: String) -> Result { if let Some(win) = app.get_webview_window("palette") { @@ -109,6 +186,42 @@ pub async fn paste_to_previous(app: tauri::AppHandle, text: String) -> Result Result 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")] { tokio::task::spawn_blocking(linux_processes) .await @@ -94,9 +123,27 @@ pub async fn list_processes() -> Result, String> { } } +#[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" + ); + } +} + /// Walk /proc: numeric dirs are pids. Kernel threads (empty cmdline) are /// skipped so the list matches the "real apps" the Windows enumeration shows. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn linux_processes() -> Result, String> { let entries = std::fs::read_dir("/proc").map_err(|e| e.to_string())?; let mut out = Vec::new(); @@ -183,6 +230,21 @@ pub async fn kill_process(pid: u32) -> Result<(), String> { .map_err(|e| e.to_string())? } + #[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")] { tokio::task::spawn_blocking(move || { @@ -209,7 +271,7 @@ pub async fn kill_process(pid: u32) -> Result<(), String> { .map_err(|e| e.to_string())? } - #[cfg(not(any(target_os = "windows", target_os = "linux")))] + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] { let _ = pid; Err("kill_process is not implemented on this platform".to_string()) diff --git a/src-tauri/src/commands/screenshot.rs b/src-tauri/src/commands/screenshot.rs index 49c5469..ed430a1 100644 --- a/src-tauri/src/commands/screenshot.rs +++ b/src-tauri/src/commands/screenshot.rs @@ -5,8 +5,9 @@ //! Capture backends: on Linux a four-tool fallback chain of external CLIs //! (`cosmic-screenshot` → `gnome-screenshot` → `spectacle` → `grim`), the //! first one present wins; on Windows a GDI BitBlt of the full virtual screen -//! (all monitors). The frozen frame is written to `/frame.png` and -//! served to the overlay webview via the asset protocol. +//! (all monitors); on macOS `screencapture -R` of the cursor monitor. 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,8 +18,9 @@ pub struct Capture { pub frame_path: PathBuf, pub width: u32, pub height: u32, - /// Physical origin of the captured virtual screen (for overlay positioning). - #[cfg(target_os = "windows")] + /// Physical origin of the captured area — the virtual screen on Windows, + /// the cursor monitor on macOS (for overlay positioning). + #[cfg(any(target_os = "windows", target_os = "macos"))] pub monitor_origin: (i32, i32), } @@ -46,7 +48,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(); @@ -61,13 +63,63 @@ 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), + }) +} + /// Grab the screen into `dest` with the first available backend: /// cosmic-screenshot (COSMIC portal CLI), then the DE/compositor natives — /// gnome-screenshot, spectacle (KDE), grim (wlroots). A tool that isn't /// installed just advances the chain; a tool that runs and fails aborts with /// its stderr. (The XDG Screenshot portal is deliberately not shelled to: /// its reply arrives as a D-Bus signal that `gdbus call` can't wait for.) -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] fn capture_screen_to(dest: &std::path::Path, dir: &std::path::Path) -> Result<(), String> { let mut missing: Vec<&str> = Vec::new(); @@ -421,12 +473,21 @@ pub fn show_screenshot_overlay(app: AppHandle) -> Result<(), String> { // sizes it to the output — nothing to position. On X11 there is no layer // shell: cover the captured area from the origin (the fallback capture // tools grab the whole screen). - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] if std::env::var_os("WAYLAND_DISPLAY").is_none() { let _ = win.set_position(tauri::PhysicalPosition::new(0, 0)); let _ = win.set_size(tauri::PhysicalSize::new(capture.width, capture.height)); } + // macOS: cover the captured monitor (WKWebView repaints without the + // WebView2 black-flash problem, so positioning here is fine). + #[cfg(target_os = "macos")] + { + let (x, y) = capture.monitor_origin; + let _ = win.set_position(tauri::PhysicalPosition::new(x, y)); + let _ = win.set_size(tauri::PhysicalSize::new(capture.width, capture.height)); + } + #[cfg(target_os = "windows")] set_cloak(&win, true); @@ -508,7 +569,7 @@ pub async fn finish_screenshot(app: AppHandle, region: Region) -> Result Result<(), String> { let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some(); @@ -569,7 +630,9 @@ fn copy_image_to_clipboard(path: &std::path::Path, img: &image::RgbaImage) -> Re } } -#[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/search.rs b/src-tauri/src/commands/search.rs index ec67272..9c3d014 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")] { tokio::task::spawn_blocking(move || linux_icons::cached_icon_for_path(&path)) .await @@ -48,7 +56,7 @@ pub async fn path_icon(path: String) -> Option { /// Server-side icon cache mirroring the frontend's: keyed per extension for /// ordinary files (one theme lookup covers every .rs file), per path for /// .desktop entries and extensionless files, whose icons are individual. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] mod linux_icons { use std::collections::HashMap; use std::sync::Mutex; @@ -315,11 +323,11 @@ 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); } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] for r in &mut out { r.icon = linux_icons::cached_icon_for_path(&r.path); } @@ -423,11 +431,11 @@ 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); } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "linux")] for r in &mut results { r.icon = linux_icons::cached_icon_for_path(&r.path); } diff --git a/src-tauri/src/commands/shortcuts.rs b/src-tauri/src/commands/shortcuts.rs index 7cb23d7..293f1b5 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. @@ -187,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) => { @@ -254,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)?; @@ -266,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/src/commands/system.rs b/src-tauri/src/commands/system.rs index 2eac248..d8aba5b 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -34,7 +34,15 @@ 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 _ = app; tokio::task::spawn_blocking(move || linux::run(action)) @@ -46,7 +54,7 @@ pub async fn system_action(action: SystemAction, app: tauri::AppHandle) -> Resul /// Linux implementations shell out to the standard session/systemd tools so /// they work across desktops; polkit allows suspend/poweroff/reboot for local /// active sessions without prompting. Failures surface the tool's stderr. -#[cfg(not(target_os = "windows"))] +#[cfg(target_os = "linux")] mod linux { use super::SystemAction; @@ -160,6 +168,51 @@ mod linux { } } +/// 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/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-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b61438..1fc7eaf 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); } @@ -84,6 +86,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)); +} + /// Linux/X11 sessions get no layer-shell surface, so nothing positions the /// palette at map time; mirror the Windows behavior with portable Tauri APIs /// (centered on the cursor's monitor, top at ~20% height). No-op on Wayland. @@ -119,7 +145,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); #[cfg(target_os = "linux")] position_palette_x11(app, &win); @@ -135,7 +161,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); #[cfg(target_os = "linux")] position_palette_x11(app, &win); @@ -162,7 +188,8 @@ 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. On Linux this goes through +/// rediscover the app once the palette is hidden (especially on macOS, where +/// the app is an Accessory with no Dock icon). On Linux this goes through /// StatusNotifier/libappindicator (runtime dep: libappindicator-gtk3; COSMIC /// shows it in the Status Area applet), and failure is non-fatal — the /// palette still works via the global shortcut / relaunch toggle. @@ -209,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()); } @@ -279,10 +330,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() @@ -335,7 +386,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 desktop keybinding (COSMIC/GNOME) // even before the frontend calls set_game_mode; the frontend @@ -431,6 +482,48 @@ 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), + ); + } + + // 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(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 94c63e3..93e7c9a 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", @@ -61,7 +62,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/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/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/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/Palette.tsx b/src/components/Palette.tsx index f51ddea..58d0cb8 100644 --- a/src/components/Palette.tsx +++ b/src/components/Palette.tsx @@ -12,7 +12,7 @@ import { loadGlobalFileResults } from '../commands/globalFileSearch' import { searchAllProviders } from '../providers' import { evaluateCalcQuery } from '../providers/calculator' import { tryTimeConversion } from '../lib/timezones' -import { IS_LINUX, envInfo, openPath, openUrl, pasteToPrevious, readSnippets, setCommandHotkey, writeClipboardText, writeSnippets, type ClipboardItem, type CommandOverride, type Snippet } from '../lib/tauri' +import { IS_LINUX, IS_MAC, envInfo, openPath, openUrl, pasteToPrevious, readSnippets, setCommandHotkey, writeClipboardText, writeSnippets, type ClipboardItem, type CommandOverride, type Snippet } from '../lib/tauri' import type { ActionItem, AppConfig, Command, PaletteAction, PaletteItem, PaletteState } from '../types' import SearchInput, { SliderInput } from './SearchInput' import ResultsList from './ResultsList' @@ -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)' }, @@ -1310,13 +1310,15 @@ export default function Palette({ background: 'var(--bg)', backdropFilter: 'blur(60px) saturate(180%)', WebkitBackdropFilter: 'blur(60px) saturate(180%)', + // Windows rounds the OS window itself via DWM (DWMWCP_ROUND), so only + // round in CSS on platforms that don't: macOS 12px (matches the + // vibrancy radius applied natively), Linux 8px (layer-shell surface + // has no compositor rounding). + borderRadius: IS_MAC ? 12 : IS_LINUX ? 8 : undefined, display: 'flex', flexDirection: 'column', fontFamily: 'var(--font)', overflow: 'hidden', - // Windows rounds the OS window itself via DWM (DWMWCP_ROUND); the Linux - // layer-shell surface has no compositor rounding, so round it here. - borderRadius: IS_LINUX ? 8 : undefined, color: 'var(--text)', }} onKeyDown={handleKeyDown} diff --git a/src/components/ResultRow.tsx b/src/components/ResultRow.tsx index 9dbcf18..c54742a 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) useEffect(() => { - selectedRef.current?.scrollIntoView({ block: 'nearest' }) + 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 + } +} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 98f0dfa..10f4dcd 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 }) @@ -310,6 +311,7 @@ export const readThemes = () => invoke('read_themes') export const IS_LINUX = typeof navigator !== 'undefined' && navigator.userAgent.includes('Linux') +export const IS_MAC = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') // Runtime environment facts from the backend (Wayland vs X11 can't be sniffed // from the user agent). Fetched once and cached for the session. @@ -329,7 +331,8 @@ export const setWindowTransparency = (transparency: number) => { // 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. + // 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() 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), }