From daca368ba2b14d1d12754bfc21130dad5608886a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Fri, 13 Mar 2026 12:48:27 -0600 Subject: [PATCH] feat: add native clipboard module with arboard backend (#228) Cross-platform clipboard access (text read/write, image read) via the arboard Rust crate. No external tools (pbcopy, xclip, etc.) required. Ported from Oh My Pi's clipboard module with adaptations for GSD's architecture (direct AsyncTask instead of task::blocking wrapper). Co-authored-by: Claude Opus 4.6 (1M context) --- native/Cargo.lock | 566 +++++++++++++++++- native/crates/engine/Cargo.toml | 2 + native/crates/engine/src/clipboard.rs | 110 ++++ native/crates/engine/src/lib.rs | 1 + packages/native/package.json | 8 +- .../native/src/__tests__/clipboard.test.mjs | 79 +++ packages/native/src/clipboard/index.ts | 40 ++ packages/native/src/clipboard/types.ts | 7 + packages/native/src/index.ts | 8 + packages/native/src/native.ts | 3 + 10 files changed, 821 insertions(+), 3 deletions(-) create mode 100644 native/crates/engine/src/clipboard.rs create mode 100644 packages/native/src/__tests__/clipboard.test.mjs create mode 100644 packages/native/src/clipboard/index.ts create mode 100644 packages/native/src/clipboard/types.ts diff --git a/native/Cargo.lock b/native/Cargo.lock index a832afca6..3c0803f89 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +17,56 @@ dependencies = [ "memchr", ] +[[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", + "x11rb", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -28,12 +84,33 @@ dependencies = [ "serde", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[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 = "convert_case" version = "0.6.0" @@ -43,6 +120,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -68,6 +154,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 = "ctor" version = "0.2.9" @@ -92,6 +184,16 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "either" version = "1.15.0" @@ -116,6 +218,88 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "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 = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + [[package]] name = "globset" version = "0.4.18" @@ -170,14 +354,17 @@ dependencies = [ name = "gsd-engine" version = "0.1.0" dependencies = [ + "arboard", "dashmap", "globset", "gsd-grep", "ignore", + "image", "libc", "napi", "napi-build", "napi-derive", + "syntect", ] [[package]] @@ -191,6 +378,17 @@ dependencies = [ "rayon", ] +[[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.14.5" @@ -213,6 +411,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", + "tiff", +] + [[package]] name = "libc" version = "0.2.183" @@ -229,6 +441,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -259,6 +477,26 @@ dependencies = [ "libc", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[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 = "napi" version = "2.16.17" @@ -316,12 +554,104 @@ dependencies = [ "libloading", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -335,6 +665,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -344,6 +693,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.45" @@ -411,6 +772,19 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -461,6 +835,12 @@ dependencies = [ "syn", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "smallvec" version = "1.15.1" @@ -478,6 +858,58 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror", + "walkdir", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "unicode-ident" version = "1.0.24" @@ -500,13 +932,19 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -515,6 +953,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -523,3 +970,120 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[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 = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core", +] diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml index 2babd9848..992a5811e 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -12,9 +12,11 @@ crate-type = ["cdylib"] [dependencies] gsd-grep = { path = "../grep" } +arboard = "3" dashmap = "6" globset = "0.4" ignore = "0.4" +image = { version = "0.25", default-features = false, features = ["png"] } napi = { version = "2", features = ["napi8"] } napi-derive = "2" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } diff --git a/native/crates/engine/src/clipboard.rs b/native/crates/engine/src/clipboard.rs new file mode 100644 index 000000000..cc376c024 --- /dev/null +++ b/native/crates/engine/src/clipboard.rs @@ -0,0 +1,110 @@ +//! Clipboard utilities backed by arboard. +//! +//! Provides text copy/read and image read support across Linux, macOS, and Windows. +//! Text copy runs synchronously so macOS writes execute on the caller thread, +//! avoiding worker-thread `AppKit` pasteboard warnings in CLI contexts. + +use std::io::Cursor; + +use arboard::{Clipboard, Error as ClipboardError, ImageData}; +use image::{DynamicImage, ImageFormat, RgbaImage}; +use napi::bindgen_prelude::*; +use napi::{Env, Error, Result, Task}; +use napi_derive::napi; + +/// Clipboard image payload encoded as PNG bytes. +#[napi(object)] +pub struct ClipboardImage { + /// PNG-encoded image bytes. + pub data: Uint8Array, + #[napi(js_name = "mimeType")] + /// MIME type for the encoded image payload. + pub mime_type: String, +} + +fn encode_png(image: ImageData<'_>) -> Result> { + let width = u32::try_from(image.width) + .map_err(|_| Error::from_reason("Clipboard image width overflow"))?; + let height = u32::try_from(image.height) + .map_err(|_| Error::from_reason("Clipboard image height overflow"))?; + let bytes = image.bytes.into_owned(); + let buffer = RgbaImage::from_raw(width, height, bytes) + .ok_or_else(|| Error::from_reason("Clipboard image buffer size mismatch"))?; + let capacity = width.saturating_mul(height).saturating_mul(4) as usize; + let mut output = Vec::with_capacity(capacity); + DynamicImage::ImageRgba8(buffer) + .write_to(&mut Cursor::new(&mut output), ImageFormat::Png) + .map_err(|err| Error::from_reason(format!("Failed to encode clipboard image: {err}")))?; + Ok(output) +} + +/// Copy plain text to the system clipboard. +/// +/// Runs synchronously to avoid macOS AppKit pasteboard warnings +/// when writing from worker threads. +#[napi(js_name = "copyToClipboard")] +pub fn copy_to_clipboard(text: String) -> Result<()> { + let mut clipboard = Clipboard::new() + .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; + clipboard + .set_text(text) + .map_err(|err| Error::from_reason(format!("Failed to copy to clipboard: {err}")))?; + Ok(()) +} + +/// Read plain text from the system clipboard. +/// +/// Returns `None` when no text data is available. +#[napi(js_name = "readTextFromClipboard")] +pub fn read_text_from_clipboard() -> Result> { + let mut clipboard = Clipboard::new() + .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; + match clipboard.get_text() { + Ok(text) => Ok(Some(text)), + Err(ClipboardError::ContentNotAvailable) => Ok(None), + Err(err) => Err(Error::from_reason(format!( + "Failed to read clipboard text: {err}" + ))), + } +} + +// ── Async image read task ──────────────────────────────────────────── + +pub(crate) struct ReadImageTask; + +impl Task for ReadImageTask { + type JsValue = Option; + type Output = Option; + + fn compute(&mut self) -> Result { + let mut clipboard = Clipboard::new() + .map_err(|err| Error::from_reason(format!("Failed to access clipboard: {err}")))?; + match clipboard.get_image() { + Ok(image) => { + let bytes = encode_png(image)?; + Ok(Some(ClipboardImage { + data: Uint8Array::from(bytes), + mime_type: "image/png".to_string(), + })) + } + Err(ClipboardError::ContentNotAvailable) => Ok(None), + Err(err) => Err(Error::from_reason(format!( + "Failed to read clipboard image: {err}" + ))), + } + } + + fn resolve(&mut self, _env: Env, output: Self::Output) -> Result { + Ok(output) + } +} + +/// Read an image from the system clipboard. +/// +/// Returns a Promise that resolves to a `ClipboardImage` (PNG-encoded bytes) +/// or `null` when no image data is available. Runs on libuv's thread pool +/// to avoid blocking the main JS thread during PNG encoding. +#[napi(js_name = "readImageFromClipboard")] +pub fn read_image_from_clipboard() -> AsyncTask { + AsyncTask::new(ReadImageTask) +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index c27156def..c37ad583b 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -8,6 +8,7 @@ #![allow(clippy::needless_pass_by_value)] +mod clipboard; mod fs_cache; mod glob; mod glob_util; diff --git a/packages/native/package.json b/packages/native/package.json index 404fe52ec..2542389c1 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -1,14 +1,14 @@ { "name": "@gsd/native", "version": "0.1.0", - "description": "Native Rust bindings for GSD — high-performance grep, glob, and process management via N-API", + "description": "Native Rust bindings for GSD — high-performance native modules via N-API", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "scripts": { "build:native": "node ../../native/scripts/build.js", "build:native:dev": "node ../../native/scripts/build.js --dev", - "test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs" + "test": "node --test src/__tests__/grep.test.mjs src/__tests__/ps.test.mjs src/__tests__/glob.test.mjs src/__tests__/clipboard.test.mjs" }, "exports": { ".": { @@ -26,6 +26,10 @@ "./glob": { "types": "./src/glob/index.ts", "import": "./src/glob/index.ts" + }, + "./clipboard": { + "types": "./src/clipboard/index.ts", + "import": "./src/clipboard/index.ts" } }, "files": [ diff --git a/packages/native/src/__tests__/clipboard.test.mjs b/packages/native/src/__tests__/clipboard.test.mjs new file mode 100644 index 000000000..407b6bdc9 --- /dev/null +++ b/packages/native/src/__tests__/clipboard.test.mjs @@ -0,0 +1,79 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon"); +const platformTag = `${process.platform}-${process.arch}`; +const candidates = [ + path.join(addonDir, `gsd_engine.${platformTag}.node`), + path.join(addonDir, "gsd_engine.dev.node"), +]; + +let native; +for (const candidate of candidates) { + try { + native = require(candidate); + break; + } catch { + // try next + } +} + +if (!native) { + console.error("Native addon not found. Run build:native first."); + process.exit(1); +} + +describe("native clipboard: copyToClipboard()", () => { + test("copies text without throwing", () => { + assert.doesNotThrow(() => { + native.copyToClipboard("GSD clipboard test"); + }); + }); + + test("accepts empty string", () => { + assert.doesNotThrow(() => { + native.copyToClipboard(""); + }); + }); + + test("accepts unicode text", () => { + assert.doesNotThrow(() => { + native.copyToClipboard("Hello 世界"); + }); + }); +}); + +describe("native clipboard: readTextFromClipboard()", () => { + test("reads back text that was copied", () => { + const testText = `GSD clipboard roundtrip ${Date.now()}`; + native.copyToClipboard(testText); + const result = native.readTextFromClipboard(); + assert.equal(result, testText); + }); + + test("returns a string or null", () => { + const result = native.readTextFromClipboard(); + assert.ok(result === null || typeof result === "string"); + }); +}); + +describe("native clipboard: readImageFromClipboard()", () => { + test("returns a promise", () => { + const result = native.readImageFromClipboard(); + assert.ok(result instanceof Promise); + }); + + test("resolves to ClipboardImage or null", async () => { + const result = await native.readImageFromClipboard(); + if (result !== null) { + assert.ok(result.data instanceof Uint8Array, "data should be Uint8Array"); + assert.equal(result.mimeType, "image/png"); + } + }); +}); diff --git a/packages/native/src/clipboard/index.ts b/packages/native/src/clipboard/index.ts new file mode 100644 index 000000000..a363942af --- /dev/null +++ b/packages/native/src/clipboard/index.ts @@ -0,0 +1,40 @@ +/** + * Native clipboard access using N-API. + * + * Cross-platform clipboard read/write backed by the `arboard` Rust crate. + * No external tools (pbcopy, xclip, etc.) required. + */ + +import { native } from "../native.js"; +import type { ClipboardImage } from "./types.js"; + +export type { ClipboardImage }; + +/** + * Copy plain text to the system clipboard. + * + * Runs synchronously to avoid macOS AppKit pasteboard warnings + * when writing from worker threads. + */ +export function copyToClipboard(text: string): void { + native.copyToClipboard(text); +} + +/** + * Read plain text from the system clipboard. + * + * Returns `null` when no text data is available. + */ +export function readTextFromClipboard(): string | null { + return native.readTextFromClipboard() as string | null; +} + +/** + * Read an image from the system clipboard. + * + * Returns a Promise that resolves to a `ClipboardImage` (PNG-encoded bytes) + * or `null` when no image data is available. + */ +export function readImageFromClipboard(): Promise { + return native.readImageFromClipboard() as Promise; +} diff --git a/packages/native/src/clipboard/types.ts b/packages/native/src/clipboard/types.ts new file mode 100644 index 000000000..0ca3f508e --- /dev/null +++ b/packages/native/src/clipboard/types.ts @@ -0,0 +1,7 @@ +/** Clipboard image payload encoded as PNG bytes. */ +export interface ClipboardImage { + /** PNG-encoded image bytes. */ + data: Uint8Array; + /** MIME type for the encoded image payload (always "image/png"). */ + mimeType: string; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index e823b12a0..ed92e3a92 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -2,12 +2,20 @@ * @gsd/native — High-performance Rust modules exposed via N-API. * * Modules: + * - clipboard: native clipboard access (text + image) * - grep: ripgrep-backed regex search (content + filesystem) * - ps: cross-platform process tree management * - glob: gitignore-respecting filesystem discovery with scan caching * - highlight: syntect-based syntax highlighting */ +export { + copyToClipboard, + readTextFromClipboard, + readImageFromClipboard, +} from "./clipboard/index.js"; +export type { ClipboardImage } from "./clipboard/index.js"; + export { highlightCode, supportsLanguage, diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 5195070da..6d6958927 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -55,4 +55,7 @@ export const native = loadNative() as { highlightCode: (code: string, lang: string | null, colors: unknown) => unknown; supportsLanguage: (lang: string) => unknown; getSupportedLanguages: () => unknown; + copyToClipboard: (text: string) => void; + readTextFromClipboard: () => string | null; + readImageFromClipboard: () => Promise; };