diff --git a/package.json b/package.json index 77914aa48..a2d1b5b8f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@tanstack/react-virtual": "^3.14.2", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-autostart": "~2.5.1", + "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-log": "~2.8.0", "@tauri-apps/plugin-notification": "^2.3.3", "@tauri-apps/plugin-opener": "^2.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61c97832a..7130d28c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@tauri-apps/plugin-autostart': specifier: ~2.5.1 version: 2.5.1 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2.3.2 + version: 2.3.2 '@tauri-apps/plugin-log': specifier: ~2.8.0 version: 2.8.0 @@ -2650,6 +2653,9 @@ packages: '@tauri-apps/plugin-autostart@2.5.1': resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==} + '@tauri-apps/plugin-clipboard-manager@2.3.2': + resolution: {integrity: sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==} + '@tauri-apps/plugin-log@2.8.0': resolution: {integrity: sha512-a+7rOq3MJwpTOLLKbL8d0qGZ85hgHw5pNOWusA9o3cf7cEgtYHiGY/+O8fj8MvywQIGqFv0da2bYQDlrqLE7rw==} @@ -7778,6 +7784,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-clipboard-manager@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-log@2.8.0': dependencies: '@tauri-apps/api': 2.11.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 22193028c..c128756a1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -84,6 +84,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 = "arrayvec" version = "0.7.6" @@ -460,6 +481,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" @@ -603,6 +630,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[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 = "combine" version = "4.6.7" @@ -740,6 +776,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" @@ -1177,6 +1219,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "event-listener" version = "5.4.1" @@ -1204,6 +1252,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" @@ -1260,6 +1314,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" @@ -1810,6 +1870,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" @@ -2149,6 +2220,20 @@ dependencies = [ "winapi-util", ] +[[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" @@ -2627,6 +2712,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" @@ -2708,6 +2803,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[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 = "8.2.0" @@ -2825,6 +2929,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3159,6 +3264,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.8.0" @@ -3582,12 +3698,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[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.37.5" @@ -3606,6 +3734,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4166,7 +4303,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -5061,6 +5198,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-log" version = "2.8.0" @@ -5392,6 +5544,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", @@ -5445,6 +5598,20 @@ dependencies = [ "syn 2.0.117", ] +[[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 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -5755,6 +5922,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" @@ -6121,6 +6299,76 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "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.95" @@ -6251,6 +6499,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 = "winapi" version = "0.3.9" @@ -6825,6 +7079,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", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.3" @@ -6905,6 +7177,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", + "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" @@ -7097,6 +7386,21 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[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.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c8deebfaf..a091f9829 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -47,6 +47,7 @@ futures-util = "0.3" tokio = { version = "1", default-features = false, features = ["rt"] } tempfile = "3" notify = "8.2.0" +tauri-plugin-clipboard-manager = "2" [dev-dependencies] proptest = "1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4d541ca28..be7824d47 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -23,6 +23,8 @@ "log:default", "os:default", "notification:default", + "clipboard-manager:allow-read-text", + "clipboard-manager:allow-write-text", "store:default", "autostart:allow-enable", "autostart:allow-disable", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b901b89a..8446dabf2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -130,6 +130,7 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_notification::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .plugin( tauri_plugin_log::Builder::new() .level(tauri_plugin_log::log::LevelFilter::Info) diff --git a/src/modules/terminal/lib/rendererPool.ts b/src/modules/terminal/lib/rendererPool.ts index 1a7a5d7da..ba74b5f0a 100644 --- a/src/modules/terminal/lib/rendererPool.ts +++ b/src/modules/terminal/lib/rendererPool.ts @@ -14,6 +14,10 @@ import { terminalLineNavigationSequence, terminalWordNavigationSequence, } from "./keymap"; +import { + readTerminalClipboard, + writeTerminalClipboard, +} from "./terminalClipboard"; export const POOL_MAX_SIZE = 5; const FIT_DEBOUNCE_MS = 8; @@ -276,19 +280,18 @@ function createSlot(): Slot { if (isTerminalCopy(event)) { if (event.type === "keydown" && slot.term.hasSelection()) { const sel = slot.term.getSelection(); - if (sel) void navigator.clipboard.writeText(sel).catch(() => {}); + if (sel) void writeTerminalClipboard(sel); } event.preventDefault(); return false; } if (isTerminalPaste(event)) { if (event.type === "keydown") { - void navigator.clipboard - .readText() - .then((text) => { - if (text) slot.term.paste(text); - }) - .catch(() => {}); + const targetLeafId = slot.currentLeafId; + void readTerminalClipboard().then((text) => { + if (!text || slot.currentLeafId !== targetLeafId) return; + slot.term.paste(text); + }); } event.preventDefault(); return false; diff --git a/src/modules/terminal/lib/terminalClipboard.test.ts b/src/modules/terminal/lib/terminalClipboard.test.ts new file mode 100644 index 000000000..bbf732d2a --- /dev/null +++ b/src/modules/terminal/lib/terminalClipboard.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const nativeClipboard = vi.hoisted(() => ({ + readText: vi.fn<() => Promise>(), + writeText: vi.fn<(text: string) => Promise>(), +})); + +vi.mock("@tauri-apps/plugin-clipboard-manager", () => nativeClipboard); + +import { + readTerminalClipboard, + writeTerminalClipboard, +} from "./terminalClipboard"; + +const originalNavigator = globalThis.navigator; + +const webClipboard = { + readText: vi.fn<() => Promise>(), + writeText: vi.fn<(text: string) => Promise>(), +}; + +describe("terminalClipboard", () => { + beforeEach(() => { + nativeClipboard.readText.mockReset(); + nativeClipboard.writeText.mockReset(); + webClipboard.readText.mockReset(); + webClipboard.writeText.mockReset(); + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { clipboard: webClipboard }, + }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: originalNavigator, + }); + }); + + it("reads from the native clipboard before falling back to the web clipboard", async () => { + nativeClipboard.readText.mockResolvedValue("from native"); + webClipboard.readText.mockResolvedValue("from web"); + + await expect(readTerminalClipboard()).resolves.toBe("from native"); + + expect(nativeClipboard.readText).toHaveBeenCalledOnce(); + expect(webClipboard.readText).not.toHaveBeenCalled(); + }); + + it("falls back to the web clipboard when native clipboard read fails", async () => { + nativeClipboard.readText.mockRejectedValue(new Error("not available")); + webClipboard.readText.mockResolvedValue("from web"); + + await expect(readTerminalClipboard()).resolves.toBe("from web"); + + expect(nativeClipboard.readText).toHaveBeenCalledOnce(); + expect(webClipboard.readText).toHaveBeenCalledOnce(); + }); + + it("writes to the native clipboard before falling back to the web clipboard", async () => { + nativeClipboard.writeText.mockResolvedValue(); + + await writeTerminalClipboard("copy me"); + + expect(nativeClipboard.writeText).toHaveBeenCalledWith("copy me"); + expect(webClipboard.writeText).not.toHaveBeenCalled(); + }); + + it("falls back to the web clipboard when native clipboard write fails", async () => { + nativeClipboard.writeText.mockRejectedValue(new Error("not available")); + webClipboard.writeText.mockResolvedValue(); + + await writeTerminalClipboard("copy me"); + + expect(nativeClipboard.writeText).toHaveBeenCalledWith("copy me"); + expect(webClipboard.writeText).toHaveBeenCalledWith("copy me"); + }); +}); diff --git a/src/modules/terminal/lib/terminalClipboard.ts b/src/modules/terminal/lib/terminalClipboard.ts new file mode 100644 index 000000000..1abe9a2cb --- /dev/null +++ b/src/modules/terminal/lib/terminalClipboard.ts @@ -0,0 +1,39 @@ +import { + readText as readNativeText, + writeText as writeNativeText, +} from "@tauri-apps/plugin-clipboard-manager"; + +function getWebClipboard(): Clipboard | null { + if (typeof navigator === "undefined") return null; + return navigator.clipboard ?? null; +} + +export async function readTerminalClipboard(): Promise { + try { + return await readNativeText(); + } catch { + // WebKit on Linux can fail to read external clipboard content through the + // web clipboard API, but it remains a useful fallback in browser contexts. + } + + try { + return (await getWebClipboard()?.readText()) ?? ""; + } catch { + return ""; + } +} + +export async function writeTerminalClipboard(text: string): Promise { + try { + await writeNativeText(text); + return; + } catch { + // Keep the existing browser clipboard behavior as a fallback for dev/web. + } + + try { + await getWebClipboard()?.writeText(text); + } catch { + // Best-effort copy path. + } +}