From ea2f0b5465d16c0c2d0c74a3ecbd1dc4607b494a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 10:13:25 +0000 Subject: [PATCH 1/4] fix(desktop): attribute clipboard permission prompts to Strand Route programmatic clipboard read/write through tauri-plugin-clipboard-manager so macOS TCC names Strand instead of the webview origin. Keep navigator.clipboard as the web/demo fallback, and leave Edit-menu Copy/Paste and DAN-71 Ctrl+C/V routing unchanged. Linear: DAN-74 Co-authored-by: Daniels --- Cargo.lock | 306 +++++++++++++++++- ROADMAP.md | 9 + TASKS.md | 4 + crates/strand-tauri/Cargo.toml | 1 + crates/strand-tauri/capabilities/default.json | 2 + crates/strand-tauri/src/main.rs | 1 + docs/learnings.md | 12 + pnpm-lock.yaml | 10 + scripts/check-release-security.mjs | 2 + ui/package.json | 1 + .../PierreTree.searchAction.test.ts | 7 +- ui/src/components/PierreTree.tsx | 9 +- ui/src/lib/clipboard.test.ts | 134 ++++++++ ui/src/lib/clipboard.ts | 37 +++ ui/src/lib/terminalClipboard.test.ts | 10 + ui/src/lib/terminalClipboard.ts | 9 +- ui/src/views/HostedReviewTools.tsx | 3 +- website/docs/work.md | 4 +- 18 files changed, 543 insertions(+), 18 deletions(-) create mode 100644 ui/src/lib/clipboard.test.ts create mode 100644 ui/src/lib/clipboard.ts diff --git a/Cargo.lock b/Cargo.lock index 57177233..cf5fb64b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -157,6 +157,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -587,6 +608,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -808,6 +835,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "clru" version = "0.6.3" @@ -973,6 +1009,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1369,6 +1411,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "etcetera" version = "0.8.0" @@ -1413,6 +1461,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1459,6 +1513,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2702,6 +2762,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3071,6 +3142,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3663,6 +3748,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.2" @@ -3738,6 +3833,15 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "6.1.1" @@ -3918,6 +4022,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -4277,6 +4382,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phf" version = "0.13.1" @@ -4388,7 +4504,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -4597,6 +4713,18 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -4606,6 +4734,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -6088,6 +6225,7 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-notification", "tauri-plugin-os", @@ -6446,6 +6584,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -6824,6 +6977,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -7204,6 +7371,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7591,6 +7769,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.11.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.99" @@ -7722,6 +7970,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -8391,6 +8645,24 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -8471,6 +8743,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -8703,6 +8992,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.13.0" diff --git a/ROADMAP.md b/ROADMAP.md index 75a9cfae..3f6dc59b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2360,6 +2360,15 @@ Ctrl+Shift+C/V, right-click Edit menu Copy/Paste, and macOS ⌘C/⌘V stay on their existing paths. App-level shortcuts still do not reclaim Ctrl+C/Ctrl+V inside `.work-terminal-host`. +**DAN-74 clipboard permission identity shipped (2026-09-21):** Programmatic +clipboard read/write goes through `tauri-plugin-clipboard-manager` +(`writeClipboardText` / `readClipboardText` in `ui/src/lib/clipboard.ts`) so +macOS TCC names Strand instead of the webview origin (`tauri.dev local` / +localhost). The web demo still uses `navigator.clipboard`. Edit-menu +Predefined Copy/Paste and DAN-71 Ctrl+C/V routing are unchanged. Least-privilege +`clipboard-manager:allow-read-text` / `allow-write-text` are on the main +window allowlist. + **Terminal repository overrides and Windows PowerShell follow-up (2026-07-20):** Settings → Terminal now pairs a repository selector with a shell selector so every open repository family remains configurable without a long override diff --git a/TASKS.md b/TASKS.md index bf14dbb7..81d7b0d5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1338,6 +1338,10 @@ community plugins, performance and platform certification from Git feature gaps. shell-control ownership, exited transcript, and Relaunch (`Work.tsx`). Windows/Linux Ctrl+C copies the xterm selection else SIGINT; Ctrl+V pastes (`attachCustomKeyEventHandler`, `terminalClipboardAction`; DAN-71). + Programmatic clipboard read/write uses `tauri-plugin-clipboard-manager` so OS + prompts name Strand (`writeClipboardText` / `readClipboardText` / + `copyToClipboard` in `ui/src/lib/clipboard.ts`; DAN-74). Edit-menu Predefined + Copy/Paste is unchanged. Fixed-width tabs wheel-scroll, auto-reveal, expose an overflow selector, and render `TreeFileIcon` symbols. Renderers survive view/repository/workspace switches. Configurable terminal font/10–32px sizing with a live Settings diff --git a/crates/strand-tauri/Cargo.toml b/crates/strand-tauri/Cargo.toml index 0c7ac2e5..d318c440 100644 --- a/crates/strand-tauri/Cargo.toml +++ b/crates/strand-tauri/Cargo.toml @@ -38,6 +38,7 @@ zeroize = "1" tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-dialog = "2" +tauri-plugin-clipboard-manager = "2" tauri-plugin-shell = "2" tauri-plugin-os = "2" tauri-plugin-notification = "2" diff --git a/crates/strand-tauri/capabilities/default.json b/crates/strand-tauri/capabilities/default.json index f9c5f238..71c5580b 100644 --- a/crates/strand-tauri/capabilities/default.json +++ b/crates/strand-tauri/capabilities/default.json @@ -18,6 +18,8 @@ "core:webview:allow-set-webview-zoom", "dialog:allow-open", "dialog:allow-save", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", "notification:allow-is-permission-granted", "notification:allow-request-permission", "notification:allow-notify", diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index a8e980e9..12c41dbc 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -148,6 +148,7 @@ fn main() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::default().build()) .plugin(tauri_plugin_process::init()) .plugin( diff --git a/docs/learnings.md b/docs/learnings.md index f7b99c73..f00bef7b 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -1,5 +1,17 @@ # Learnings +## Programmatic clipboard is native so the OS names Strand (2026-09-21) + +`navigator.clipboard` in the Tauri webview is attributed to the web origin +(`tauri.dev local` / localhost), not `productName`. All programmatic text +read/write must go through `ui/src/lib/clipboard.ts`, which uses +`tauri-plugin-clipboard-manager` inside the desktop shell and falls back to +`navigator.clipboard` in the web demo. Do not call the browser Clipboard API +from UI call sites. Native Edit-menu Predefined Copy/Paste stays on AppKit / +Win32. Grant exact `clipboard-manager:allow-read-text` and +`allow-write-text` — the plugin default is empty — and keep them in +`scripts/check-release-security.mjs`. + ## SSH reads must stay isolated and bounded (2026-09-06) Remote identities never enter local filesystem commands. The first SSH surface diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd7210e8..c4460d68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@tauri-apps/api': specifier: ^2 version: 2.11.0 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2 + version: 2.3.3 '@tauri-apps/plugin-dialog': specifier: ^2 version: 2.7.1 @@ -651,6 +654,9 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-clipboard-manager@2.3.3': + resolution: {integrity: sha512-KnyoTs9gj1yEgDkSPUNjOIOHjJTr5wk8IWcYMOWxYTIJCip6QwlyPW8u2X+6bd6kHM4fAdZNpxoal0gy/TwJbg==} + '@tauri-apps/plugin-dialog@2.7.1': resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} @@ -2280,6 +2286,10 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2 + '@tauri-apps/plugin-clipboard-manager@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-dialog@2.7.1': dependencies: '@tauri-apps/api': 2.11.0 diff --git a/scripts/check-release-security.mjs b/scripts/check-release-security.mjs index 92ece412..afd0042e 100644 --- a/scripts/check-release-security.mjs +++ b/scripts/check-release-security.mjs @@ -41,6 +41,8 @@ const expectedPermissions = [ 'core:webview:allow-set-webview-zoom', 'dialog:allow-open', 'dialog:allow-save', + 'clipboard-manager:allow-read-text', + 'clipboard-manager:allow-write-text', 'notification:allow-is-permission-granted', 'notification:allow-request-permission', 'notification:allow-notify', diff --git a/ui/package.json b/ui/package.json index b56ad615..a3771133 100644 --- a/ui/package.json +++ b/ui/package.json @@ -21,6 +21,7 @@ "@pierre/theme": "1.1.0", "@pierre/trees": "1.0.0-beta.5", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-os": "^2", diff --git a/ui/src/components/PierreTree.searchAction.test.ts b/ui/src/components/PierreTree.searchAction.test.ts index 2e8be327..fbe37fd7 100644 --- a/ui/src/components/PierreTree.searchAction.test.ts +++ b/ui/src/components/PierreTree.searchAction.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ + writeText: vi.fn(), + readText: vi.fn(), +})); import { SEARCH_ACTION_CSS, SEARCH_ACTION_SPACE } from './PierreTree'; diff --git a/ui/src/components/PierreTree.tsx b/ui/src/components/PierreTree.tsx index a67928db..d17d7d46 100644 --- a/ui/src/components/PierreTree.tsx +++ b/ui/src/components/PierreTree.tsx @@ -20,6 +20,8 @@ import { TREE_ICONS } from '../lib/treeIcons'; import { expandTreeSelection, resolveTreeActionTargets } from '../lib/treeSelection'; import type { DiffStatus } from '../lib/types'; +export { copyToClipboard } from '../lib/clipboard'; + // ─── status mapping ─────────────────────────────────────────────────────── export function diffStatusToGit(s: DiffStatus): GitStatus { switch (s) { @@ -32,13 +34,6 @@ export function diffStatusToGit(s: DiffStatus): GitStatus { } } -/** Write `text` to the clipboard, swallowing the rejection clipboard APIs throw - * when the webview denies access (so a copy never surfaces an unhandled - * rejection). */ -export function copyToClipboard(text: string): void { - void navigator.clipboard?.writeText(text)?.catch((e) => console.warn('clipboard write failed', e)); -} - // ─── public types ───────────────────────────────────────────────────────── /** Right-click menu item — the same shape the app's ContextMenu consumes. */ diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts new file mode 100644 index 00000000..1f8e9ea2 --- /dev/null +++ b/ui/src/lib/clipboard.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + writeText: vi.fn(), + readText: vi.fn(), +})); + +vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ + writeText: mocks.writeText, + readText: mocks.readText, +})); + +import { copyToClipboard, readClipboardText, writeClipboardText } from './clipboard'; + +const webWrite = vi.fn(); +const webRead = vi.fn(); + +function host(): typeof globalThis & { __TAURI_INTERNALS__?: unknown; window?: { __TAURI_INTERNALS__?: unknown } } { + return globalThis as typeof globalThis & { __TAURI_INTERNALS__?: unknown; window?: { __TAURI_INTERNALS__?: unknown } }; +} + +function stubShell(desktop: boolean, clipboard: { writeText: typeof webWrite; readText: typeof webRead } | undefined = { + writeText: webWrite, + readText: webRead, +}) { + const g = host(); + if (desktop) { + g.__TAURI_INTERNALS__ = {}; + if (g.window) g.window.__TAURI_INTERNALS__ = {}; + } else { + delete g.__TAURI_INTERNALS__; + if (g.window) delete g.window.__TAURI_INTERNALS__; + } + vi.stubGlobal('window', desktop ? { __TAURI_INTERNALS__: {} } : {}); + vi.stubGlobal('navigator', clipboard ? { clipboard } : {}); +} + +describe('clipboard helpers', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + beforeEach(() => { + mocks.writeText.mockReset(); + mocks.readText.mockReset(); + webWrite.mockReset(); + webRead.mockReset(); + warn.mockClear(); + stubShell(false); + }); + + afterEach(() => { + stubShell(false); + vi.unstubAllGlobals(); + }); + + it('copyToClipboard writes through the native plugin in the desktop shell', async () => { + stubShell(true); + mocks.writeText.mockResolvedValue(undefined); + + copyToClipboard('sha'); + await vi.waitFor(() => expect(mocks.writeText).toHaveBeenCalledWith('sha')); + expect(webWrite).not.toHaveBeenCalled(); + }); + + it('writes through the native plugin in the desktop shell', async () => { + stubShell(true); + mocks.writeText.mockResolvedValue(undefined); + + await writeClipboardText('sha'); + + expect(mocks.writeText).toHaveBeenCalledWith('sha'); + expect(webWrite).not.toHaveBeenCalled(); + }); + + it('reads through the native plugin in the desktop shell', async () => { + stubShell(true); + mocks.readText.mockResolvedValue('pasted'); + + await expect(readClipboardText()).resolves.toBe('pasted'); + expect(mocks.readText).toHaveBeenCalled(); + expect(webRead).not.toHaveBeenCalled(); + }); + + it('falls back to navigator.clipboard outside Tauri', async () => { + webWrite.mockResolvedValue(undefined); + webRead.mockResolvedValue('demo'); + + await writeClipboardText('branch'); + await expect(readClipboardText()).resolves.toBe('demo'); + + expect(webWrite).toHaveBeenCalledWith('branch'); + expect(webRead).toHaveBeenCalled(); + expect(mocks.writeText).not.toHaveBeenCalled(); + expect(mocks.readText).not.toHaveBeenCalled(); + }); + + it('copyToClipboard swallows write denials', async () => { + webWrite.mockRejectedValue(new Error('denied')); + + copyToClipboard('secret'); + await vi.waitFor(() => expect(warn).toHaveBeenCalled()); + }); + + it('readClipboardText returns empty on denial instead of throwing', async () => { + webRead.mockRejectedValue(new Error('denied')); + + await expect(readClipboardText()).resolves.toBe(''); + expect(warn).toHaveBeenCalled(); + }); + + it('readClipboardText swallows desktop read denials', async () => { + stubShell(true); + mocks.readText.mockRejectedValue(new Error('denied')); + + await expect(readClipboardText()).resolves.toBe(''); + expect(warn).toHaveBeenCalled(); + expect(webRead).not.toHaveBeenCalled(); + }); + + it('readClipboardText returns empty when the web clipboard API is missing', async () => { + stubShell(false, undefined); + Object.defineProperty(globalThis.navigator, 'clipboard', { configurable: true, value: {} }); + + await expect(readClipboardText()).resolves.toBe(''); + expect(warn).not.toHaveBeenCalled(); + expect(mocks.readText).not.toHaveBeenCalled(); + }); + + it('propagates desktop write denials from writeClipboardText', async () => { + stubShell(true); + mocks.writeText.mockRejectedValue(new Error('denied')); + + await expect(writeClipboardText('x')).rejects.toThrow('denied'); + }); +}); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts new file mode 100644 index 00000000..7a6ed172 --- /dev/null +++ b/ui/src/lib/clipboard.ts @@ -0,0 +1,37 @@ +import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager'; + +/** True inside the Tauri webview. Duplicates `isTauri` so this helper does not + * load the IPC command map on every tree/diff import. */ +function isDesktopShell(): boolean { + return typeof window !== 'undefined' && Boolean( + (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__, + ); +} + +/** Write `text` to the system clipboard. Rejects when the OS or webview + * denies access — callers that must not surface a rejection should use + * {@link copyToClipboard}. */ +export async function writeClipboardText(text: string): Promise { + if (isDesktopShell()) { + await writeText(text); + return; + } + await navigator.clipboard.writeText(text); +} + +/** Write `text` to the clipboard, swallowing the rejection clipboard APIs throw + * when access is denied (so a copy never surfaces an unhandled rejection). */ +export function copyToClipboard(text: string): void { + void writeClipboardText(text).catch((e) => console.warn('clipboard write failed', e)); +} + +export async function readClipboardText(): Promise { + try { + if (isDesktopShell()) return await readText(); + if (!navigator.clipboard?.readText) return ''; + return await navigator.clipboard.readText(); + } catch (error) { + console.warn('clipboard read failed', error); + return ''; + } +} diff --git a/ui/src/lib/terminalClipboard.test.ts b/ui/src/lib/terminalClipboard.test.ts index e7912c48..230b48e3 100644 --- a/ui/src/lib/terminalClipboard.test.ts +++ b/ui/src/lib/terminalClipboard.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from 'vitest'; +vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({ + writeText: vi.fn(), + readText: vi.fn(), +})); + import { consumeTerminalClipboardKey, + readClipboardText, terminalClipboardAction, type TerminalClipboardKeyEvent, type TerminalClipboardTerminal, @@ -100,4 +106,8 @@ describe('consumeTerminalClipboardKey', () => { await Promise.resolve(); expect(host.paste).not.toHaveBeenCalled(); }); + + it('still exports readClipboardText for Work terminal paste', () => { + expect(typeof readClipboardText).toBe('function'); + }); }); diff --git a/ui/src/lib/terminalClipboard.ts b/ui/src/lib/terminalClipboard.ts index dc305158..41a0e784 100644 --- a/ui/src/lib/terminalClipboard.ts +++ b/ui/src/lib/terminalClipboard.ts @@ -1,5 +1,7 @@ import type { OsType } from './integrations'; +export { readClipboardText } from './clipboard'; + export type TerminalClipboardAction = 'copy' | 'paste' | 'forward'; export interface TerminalClipboardKeyEvent { @@ -70,10 +72,3 @@ export function consumeTerminalClipboardKey( } } -export function readClipboardText(): Promise { - if (!navigator.clipboard?.readText) return Promise.resolve(''); - return navigator.clipboard.readText().catch((error) => { - console.warn('clipboard read failed', error); - return ''; - }); -} diff --git a/ui/src/views/HostedReviewTools.tsx b/ui/src/views/HostedReviewTools.tsx index cc588cc9..d60ef184 100644 --- a/ui/src/views/HostedReviewTools.tsx +++ b/ui/src/views/HostedReviewTools.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Dialog } from '../components/Dialog'; import { Diff } from '../components/Diff'; import { Select } from '../components/Select'; +import { writeClipboardText } from '../lib/clipboard'; import { pullRequestReview } from '../lib/db'; import { exportHostedFeedback, feedbackSuggestions, reviewBoundaries } from '../lib/hostedReview'; import { errMessage, tauri } from '../lib/tauri'; @@ -118,7 +119,7 @@ function HostedReviewDialog({ path, provider, pr, initialMode, openVersion, init :
{feedback && {feedback.threads.length} unresolved {feedback.threads.length === 1 ? 'thread' : 'threads'} loaded · {feedback.source_commit.slice(0, 8)}{staleFeedback ? ' · Head changed; reload before using suggestions or copying' : ''}} - {mode === 'feedback' && } + {mode === 'feedback' && }
}
{busy && {busy} {!applying && }} diff --git a/website/docs/work.md b/website/docs/work.md index 5832515b..50701213 100644 --- a/website/docs/work.md +++ b/website/docs/work.md @@ -129,7 +129,9 @@ While a terminal is focused, shell controls such as Ctrl+C, Ctrl+R, and Ctrl+P remain shell-owned. On Windows and Linux, Ctrl+C copies the xterm selection when one exists (otherwise it still interrupts the foreground process) and Ctrl+V pastes from the clipboard; Ctrl+Shift+C / Ctrl+Shift+V and the native -Edit menu Copy/Paste continue to work. On macOS, Command shortcuts remain +Edit menu Copy/Paste continue to work. Strand copies and pastes through the +desktop clipboard, so the operating system names Strand rather than the +embedded webview. On macOS, Command shortcuts remain app-owned (`⌘C` / `⌘V` copy and paste). On Windows and Linux, numbered view navigation and the fixed Work-tab `Ctrl+PageUp`/`Ctrl+PageDown` shortcuts remain app-owned. Press `F6` to return focus to the Work tab strip. From c3e59bdd40c6644b0defa8bed9af48c00baca4b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 10:46:52 +0000 Subject: [PATCH 2/4] test(native-review): intercept Tauri clipboard writes in feedback probe The Windows native review gate stubs navigator.clipboard.writeText to capture Copy feedback. Desktop copy now uses plugin:clipboard-manager|write_text, so also wrap __TAURI_INTERNALS__.invoke for that command and leave other IPC untouched. Linear: DAN-74 Co-authored-by: Daniels --- scripts/test-review-native.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/test-review-native.mjs b/scripts/test-review-native.mjs index 94b33a7a..58e5a3fe 100644 --- a/scripts/test-review-native.mjs +++ b/scripts/test-review-native.mjs @@ -197,8 +197,21 @@ async function launch() { await cdp.send('Page.reload'); } await waitFor('React stores and workspace persistence', () => evaluate('return !!repo && workspaces.getState().loaded && document.querySelectorAll("#root > *").length > 0;')); - // Inspect real feedback rendering without writing the user's system clipboard. - await evaluate('Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text) => { window.__reviewFeedback = text; } } });'); + // Capture Copy feedback without writing the OS clipboard. Desktop writes go + // through plugin:clipboard-manager|write_text; keep navigator.clipboard as + // a web/demo fallback. + await evaluate(` + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text) => { window.__reviewFeedback = text; } } }); + const internals = window.__TAURI_INTERNALS__; + const invoke = internals.invoke.bind(internals); + internals.invoke = (cmd, args, options) => { + if (cmd === 'plugin:clipboard-manager|write_text') { + window.__reviewFeedback = args.text; + return Promise.resolve(); + } + return invoke(cmd, args, options); + }; + `); } async function initRepo(path, committed = true) { await mkdir(path, { recursive: true }); From 5965544d3025dbfc5c217e4233268701d28ef895 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 11:01:41 +0000 Subject: [PATCH 3/4] test(native-review): intercept frozen Tauri IPC clipboard writes Tauri 2 defines __TAURI_INTERNALS__.invoke/ipc as non-writable, so the previous assignment never captured plugin:clipboard-manager|write_text. Keep the navigator stub and also intercept the Windows IPC fetch path. Co-authored-by: Daniels --- scripts/test-review-native.mjs | 67 ++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 8 deletions(-) diff --git a/scripts/test-review-native.mjs b/scripts/test-review-native.mjs index 58e5a3fe..cc504efb 100644 --- a/scripts/test-review-native.mjs +++ b/scripts/test-review-native.mjs @@ -198,19 +198,70 @@ async function launch() { } await waitFor('React stores and workspace persistence', () => evaluate('return !!repo && workspaces.getState().loaded && document.querySelectorAll("#root > *").length > 0;')); // Capture Copy feedback without writing the OS clipboard. Desktop writes go - // through plugin:clipboard-manager|write_text; keep navigator.clipboard as - // a web/demo fallback. + // through plugin:clipboard-manager|write_text. Tauri 2 defines invoke/ipc + // as non-writable, so assignment is ignored or throws in this async + // evaluate; intercept the Windows IPC fetch (and postMessage fallback). + // Keep navigator.clipboard as a web/demo fallback. await evaluate(` Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text) => { window.__reviewFeedback = text; } } }); + const capture = (cmd, args) => { + if (cmd !== 'plugin:clipboard-manager|write_text') return false; + if (args && typeof args.text === 'string') window.__reviewFeedback = args.text; + return true; + }; const internals = window.__TAURI_INTERNALS__; - const invoke = internals.invoke.bind(internals); - internals.invoke = (cmd, args, options) => { - if (cmd === 'plugin:clipboard-manager|write_text') { - window.__reviewFeedback = args.text; - return Promise.resolve(); + const wrap = (obj, key, make) => { + try { + const original = obj[key]; + if (typeof original !== 'function') return; + Object.defineProperty(obj, key, { configurable: true, writable: true, value: make(original) }); + } catch (_) {} + }; + if (internals) { + wrap(internals, 'invoke', (original) => function (cmd, args, options) { + if (capture(cmd, args)) return Promise.resolve(); + return original.call(this, cmd, args, options); + }); + wrap(internals, 'ipc', (original) => function (message) { + if (message && capture(message.cmd, message.payload)) { + internals.runCallback(message.callback); + return; + } + return original.call(this, message); + }); + wrap(internals, 'postMessage', (original) => function (message) { + if (message && capture(message.cmd, message.payload)) { + internals.runCallback(message.callback); + return; + } + return original.call(this, message); + }); + } + const origFetch = window.fetch.bind(window); + window.fetch = (input, init) => { + const url = String((typeof input === 'string' ? input : input && input.url) || ''); + if (url.includes('clipboard-manager') && url.includes('write_text')) { + const body = (init && init.body) || ''; + if (typeof body === 'string') { + try { capture('plugin:clipboard-manager|write_text', JSON.parse(body)); } catch (_) {} + } + return Promise.resolve(new Response('null', { headers: { 'Tauri-Response': 'ok', 'content-type': 'application/json' } })); } - return invoke(cmd, args, options); + return origFetch(input, init); }; + const webview = window.chrome && window.chrome.webview; + if (webview && typeof webview.postMessage === 'function') { + const post = webview.postMessage.bind(webview); + webview.postMessage = (data) => { + let message = data; + if (typeof data === 'string') { try { message = JSON.parse(data); } catch (_) {} } + if (message && capture(message.cmd, message.payload)) { + if (internals && typeof internals.runCallback === 'function') internals.runCallback(message.callback); + return; + } + return post(data); + }; + } `); } async function initRepo(path, committed = true) { From d5b8477c004743f2620fb2050ba8d7788d5a2e45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 11:05:11 +0000 Subject: [PATCH 4/4] fix(ci): capture native-review clipboard writes via test seam writeClipboardText honors window.__strandCaptureClipboardWrite so the Windows native review gate can record Copy feedback without stubbing frozen Tauri invoke or touching the OS clipboard. Co-authored-by: Daniels --- docs/learnings.md | 5 ++- scripts/test-review-native.mjs | 71 +++++----------------------------- ui/src/lib/clipboard.test.ts | 15 +++++++ ui/src/lib/clipboard.ts | 16 ++++++++ 4 files changed, 44 insertions(+), 63 deletions(-) diff --git a/docs/learnings.md b/docs/learnings.md index f00bef7b..b026e4f6 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -10,7 +10,10 @@ read/write must go through `ui/src/lib/clipboard.ts`, which uses from UI call sites. Native Edit-menu Predefined Copy/Paste stays on AppKit / Win32. Grant exact `clipboard-manager:allow-read-text` and `allow-write-text` — the plugin default is empty — and keep them in -`scripts/check-release-security.mjs`. +`scripts/check-release-security.mjs`. The native review harness cannot stub +Tauri `invoke` (those properties are frozen); `writeClipboardText` honors +`window.__strandCaptureClipboardWrite` so Copy feedback can be captured +without touching the OS clipboard. Do not remove that seam. ## SSH reads must stay isolated and bounded (2026-09-06) diff --git a/scripts/test-review-native.mjs b/scripts/test-review-native.mjs index cc504efb..bb7927bc 100644 --- a/scripts/test-review-native.mjs +++ b/scripts/test-review-native.mjs @@ -198,70 +198,17 @@ async function launch() { } await waitFor('React stores and workspace persistence', () => evaluate('return !!repo && workspaces.getState().loaded && document.querySelectorAll("#root > *").length > 0;')); // Capture Copy feedback without writing the OS clipboard. Desktop writes go - // through plugin:clipboard-manager|write_text. Tauri 2 defines invoke/ipc - // as non-writable, so assignment is ignored or throws in this async - // evaluate; intercept the Windows IPC fetch (and postMessage fallback). - // Keep navigator.clipboard as a web/demo fallback. + // through writeClipboardText → the clipboard plugin, which never hits + // navigator.clipboard. The native-review gate installs + // window.__strandCaptureClipboardWrite; keep navigator as a web fallback. await evaluate(` - Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: async (text) => { window.__reviewFeedback = text; } } }); - const capture = (cmd, args) => { - if (cmd !== 'plugin:clipboard-manager|write_text') return false; - if (args && typeof args.text === 'string') window.__reviewFeedback = args.text; - return true; + window.__strandCaptureClipboardWrite = async (text) => { + window.__reviewFeedback = text; }; - const internals = window.__TAURI_INTERNALS__; - const wrap = (obj, key, make) => { - try { - const original = obj[key]; - if (typeof original !== 'function') return; - Object.defineProperty(obj, key, { configurable: true, writable: true, value: make(original) }); - } catch (_) {} - }; - if (internals) { - wrap(internals, 'invoke', (original) => function (cmd, args, options) { - if (capture(cmd, args)) return Promise.resolve(); - return original.call(this, cmd, args, options); - }); - wrap(internals, 'ipc', (original) => function (message) { - if (message && capture(message.cmd, message.payload)) { - internals.runCallback(message.callback); - return; - } - return original.call(this, message); - }); - wrap(internals, 'postMessage', (original) => function (message) { - if (message && capture(message.cmd, message.payload)) { - internals.runCallback(message.callback); - return; - } - return original.call(this, message); - }); - } - const origFetch = window.fetch.bind(window); - window.fetch = (input, init) => { - const url = String((typeof input === 'string' ? input : input && input.url) || ''); - if (url.includes('clipboard-manager') && url.includes('write_text')) { - const body = (init && init.body) || ''; - if (typeof body === 'string') { - try { capture('plugin:clipboard-manager|write_text', JSON.parse(body)); } catch (_) {} - } - return Promise.resolve(new Response('null', { headers: { 'Tauri-Response': 'ok', 'content-type': 'application/json' } })); - } - return origFetch(input, init); - }; - const webview = window.chrome && window.chrome.webview; - if (webview && typeof webview.postMessage === 'function') { - const post = webview.postMessage.bind(webview); - webview.postMessage = (data) => { - let message = data; - if (typeof data === 'string') { try { message = JSON.parse(data); } catch (_) {} } - if (message && capture(message.cmd, message.payload)) { - if (internals && typeof internals.runCallback === 'function') internals.runCallback(message.callback); - return; - } - return post(data); - }; - } + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async (text) => { window.__reviewFeedback = text; } }, + }); `); } async function initRepo(path, committed = true) { diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts index 1f8e9ea2..823f8bcb 100644 --- a/ui/src/lib/clipboard.test.ts +++ b/ui/src/lib/clipboard.test.ts @@ -48,6 +48,7 @@ describe('clipboard helpers', () => { }); afterEach(() => { + delete window.__strandCaptureClipboardWrite; stubShell(false); vi.unstubAllGlobals(); }); @@ -61,6 +62,20 @@ describe('clipboard helpers', () => { expect(webWrite).not.toHaveBeenCalled(); }); + it('short-circuits writes through the native-review capture seam', async () => { + stubShell(true); + const capture = vi.fn().mockResolvedValue(undefined); + window.__strandCaptureClipboardWrite = capture; + + await writeClipboardText('feedback markdown'); + copyToClipboard('copied notes'); + await vi.waitFor(() => expect(capture).toHaveBeenCalledWith('copied notes')); + + expect(capture).toHaveBeenCalledWith('feedback markdown'); + expect(mocks.writeText).not.toHaveBeenCalled(); + expect(webWrite).not.toHaveBeenCalled(); + }); + it('writes through the native plugin in the desktop shell', async () => { stubShell(true); mocks.writeText.mockResolvedValue(undefined); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts index 7a6ed172..be5e28a7 100644 --- a/ui/src/lib/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -1,5 +1,14 @@ import { readText, writeText } from '@tauri-apps/plugin-clipboard-manager'; +type ClipboardCapture = (text: string) => void | Promise; + +declare global { + interface Window { + /** Native-review / test harness only: short-circuit clipboard writes. */ + __strandCaptureClipboardWrite?: ClipboardCapture; + } +} + /** True inside the Tauri webview. Duplicates `isTauri` so this helper does not * load the IPC command map on every tree/diff import. */ function isDesktopShell(): boolean { @@ -12,6 +21,13 @@ function isDesktopShell(): boolean { * denies access — callers that must not surface a rejection should use * {@link copyToClipboard}. */ export async function writeClipboardText(text: string): Promise { + const capture = typeof window !== 'undefined' + ? window.__strandCaptureClipboardWrite + : undefined; + if (capture) { + await capture(text); + return; + } if (isDesktopShell()) { await writeText(text); return;