feat(tui): Simplify clipboard handling (#9163)

Replace the external clipboard dependency with small macOS, Windows,
and X11 helpers and integrate them into coding-agent.

Run native clipboard operations on worker threads and make command
fallbacks asynchronous. Preserve incremental X11 transfers, legacy text
encodings, native image formats, and existing Wayland and platform tools.

Include native prebuilds with ARM64 page alignment, packaging updates,
and regression coverage.
This commit is contained in:
Armin Ronacher 2026-09-05 23:52:32 +02:00 • committed by GitHub
parent da840b6216
commit caf6dfe731
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2294 additions and 1569 deletions

View file

@ -72,7 +72,7 @@ cd "pi-${VERSION}"
./scripts/build-binaries.sh --offline-model-data --platform linux-x64 --out "$PWD/out"
```
The source archive includes the generated provider model data used for the release. `--offline-model-data` builds with that snapshot instead of refreshing it from live provider catalogs. The script still installs dependencies, builds the monorepo, compiles the Bun executable, and stages its runtime assets. Package maintainers who provide dependencies separately can pass `--skip-install --skip-deps`.
The archive includes release model data and native prebuilds. `--offline-model-data` uses that model data without refreshing provider catalogs. The script installs dependencies and builds the executable with its runtime assets; pass `--skip-install` if dependencies are already provided.
## Supply-chain hardening

182
package-lock.json generated
View file

@ -1294,185 +1294,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mariozechner/clipboard": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz",
"integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@mariozechner/clipboard-darwin-arm64": "0.3.9",
"@mariozechner/clipboard-darwin-universal": "0.3.9",
"@mariozechner/clipboard-darwin-x64": "0.3.9",
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-arm64-musl": "0.3.9",
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-x64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-x64-musl": "0.3.9",
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.9",
"@mariozechner/clipboard-win32-x64-msvc": "0.3.9"
}
},
"node_modules/@mariozechner/clipboard-darwin-arm64": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz",
"integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-darwin-universal": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz",
"integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==",
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-darwin-x64": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz",
"integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz",
"integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz",
"integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz",
"integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz",
"integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz",
"integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz",
"integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz",
"integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
@ -5459,9 +5280,6 @@
},
"engines": {
"node": ">=22.19.0"
},
"optionalDependencies": {
"@mariozechner/clipboard": "0.3.9"
}
},
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {

View file

@ -15,6 +15,9 @@
"clean": "npm run clean --workspaces",
"build": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build:offline": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../ai && npm run build:offline && cd ../agent && npm run build && cd ../session-backends/sqlite-node && npm run build && cd ../../protocol && npm run build && cd ../client && npm run build && cd ../server && npm run build && cd ../coding-agent && npm run build",
"build:native:darwin": "npm --prefix packages/tui run build:native:darwin",
"build:native:linux": "npm --prefix packages/tui run build:native:linux",
"build:native:win32": "npm --prefix packages/tui run build:native:win32",
"check": "biome check --write --error-on-warnings . && npm run check:pinned-deps && npm run check:runtime-deps && npm run check:ts-imports && npm run check:entry-graphs && npm run check:shrinkwrap && npm run check:install-lock:coding-agent && tsgo --noEmit && npm run check:browser-smoke",
"check:browser-smoke": "node scripts/check-browser-smoke.mjs",
"check:pinned-deps": "node scripts/check-pinned-deps.mjs",

View file

@ -96,7 +96,6 @@ termux-camera-photo out.jpg # Take photo
## Limitations
- **No image clipboard**: Termux clipboard API only supports text
- **No native binaries**: Some optional native dependencies (like the clipboard module) are unavailable on Android ARM64 and are skipped during installation
- **Storage access**: To access files in `/storage/emulated/0` (Downloads, etc.), run `termux-setup-storage` once to grant permissions
## Troubleshooting

View file

@ -526,9 +526,6 @@
"undici": "8.9.0",
"yaml": "2.9.0"
},
"optionalDependencies": {
"@mariozechner/clipboard": "0.3.9"
},
"bin": {
"pi": "dist/bundle/cli.js"
},
@ -996,185 +993,6 @@
},
"hasInstallScript": true
},
"node_modules/@mariozechner/clipboard": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz",
"integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==",
"license": "MIT",
"optionalDependencies": {
"@mariozechner/clipboard-darwin-arm64": "0.3.9",
"@mariozechner/clipboard-darwin-universal": "0.3.9",
"@mariozechner/clipboard-darwin-x64": "0.3.9",
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-arm64-musl": "0.3.9",
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-x64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-x64-musl": "0.3.9",
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.9",
"@mariozechner/clipboard-win32-x64-msvc": "0.3.9"
},
"engines": {
"node": ">= 10"
},
"optional": true
},
"node_modules/@mariozechner/clipboard-darwin-arm64": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz",
"integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-darwin-universal": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz",
"integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"darwin"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-darwin-x64": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz",
"integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"darwin"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz",
"integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz",
"integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz",
"integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"riscv64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz",
"integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz",
"integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz",
"integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"win32"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz",
"integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"win32"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",

View file

@ -29,9 +29,6 @@
"undici": "8.9.0",
"yaml": "2.9.0"
},
"optionalDependencies": {
"@mariozechner/clipboard": "0.3.9"
},
"bin": {
"pi": "dist/bundle/cli.js"
},
@ -986,185 +983,6 @@
},
"hasInstallScript": true
},
"node_modules/@mariozechner/clipboard": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz",
"integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==",
"license": "MIT",
"optionalDependencies": {
"@mariozechner/clipboard-darwin-arm64": "0.3.9",
"@mariozechner/clipboard-darwin-universal": "0.3.9",
"@mariozechner/clipboard-darwin-x64": "0.3.9",
"@mariozechner/clipboard-linux-arm64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-arm64-musl": "0.3.9",
"@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-x64-gnu": "0.3.9",
"@mariozechner/clipboard-linux-x64-musl": "0.3.9",
"@mariozechner/clipboard-win32-arm64-msvc": "0.3.9",
"@mariozechner/clipboard-win32-x64-msvc": "0.3.9"
},
"engines": {
"node": ">= 10"
},
"optional": true
},
"node_modules/@mariozechner/clipboard-darwin-arm64": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz",
"integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-darwin-universal": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz",
"integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"darwin"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-darwin-x64": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz",
"integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"darwin"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz",
"integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-arm64-musl": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz",
"integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-riscv64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz",
"integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"riscv64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-gnu": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz",
"integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-linux-x64-musl": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz",
"integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-arm64-msvc": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz",
"integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"win32"
],
"cpu": [
"arm64"
],
"optional": true
},
"node_modules/@mariozechner/clipboard-win32-x64-msvc": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz",
"integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"os": [
"win32"
],
"cpu": [
"x64"
],
"optional": true
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",

View file

@ -76,9 +76,6 @@
"rimraf": "6.1.2"
}
},
"optionalDependencies": {
"@mariozechner/clipboard": "0.3.9"
},
"devDependencies": {
"@earendil-works/pi-client": "^0.85.1",
"@earendil-works/pi-protocol": "^0.85.1",

View file

@ -0,0 +1,44 @@
import { spawn } from "node:child_process";
/** Undefined means the command failed; an empty buffer is a successful result. */
export function runClipboardCommand(
command: string,
args: readonly string[],
options?: { input?: string; timeoutMs?: number; maxBufferBytes?: number },
): Promise<Buffer | undefined> {
return new Promise((resolve) => {
// Clipboard writers can daemonize. Do not give them output pipes to retain.
const child = spawn(command, args, {
stdio: ["pipe", options?.input === undefined ? "pipe" : "ignore", "ignore"],
windowsHide: true,
});
const chunks: Buffer[] = [];
let length = 0;
let settled = false;
const finish = (result: Buffer | undefined): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
const abort = (): void => {
child.kill("SIGKILL");
child.stdout?.destroy();
child.stdin?.destroy();
finish(undefined);
};
const timer = setTimeout(abort, options?.timeoutMs ?? 3000);
child.on("error", () => finish(undefined));
child.on("close", (code) => {
if (!settled) finish(code === 0 ? Buffer.concat(chunks, length) : undefined);
});
child.stdout?.on("data", (chunk: Buffer) => {
if (settled) return;
length += chunk.length;
if (length > (options?.maxBufferBytes ?? 50 * 1024 * 1024)) abort();
else chunks.push(chunk);
});
child.stdin?.on("error", () => {}); // A writer may exit before consuming all input.
child.stdin?.end(options?.input);
});
}

View file

@ -1,10 +1,11 @@
import { spawnSync } from "child_process";
import { getNativeClipboard } from "@earendil-works/pi-tui";
import { randomUUID } from "crypto";
import { readFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { clipboard } from "./clipboard-native.ts";
import { runClipboardCommand } from "./clipboard-command.ts";
import { detectSupportedImageMimeType } from "./mime.ts";
import { loadPhoton } from "./photon.ts";
export type ClipboardImage = {
@ -15,9 +16,7 @@ export type ClipboardImage = {
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
const DEFAULT_LIST_TIMEOUT_MS = 1000;
const DEFAULT_READ_TIMEOUT_MS = 3000;
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
@ -86,42 +85,13 @@ async function convertToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
}
}
function runCommand(
command: string,
args: string[],
options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv },
): { stdout: Buffer; ok: boolean } {
const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
// Undefined means the backend failed; null means it has no image. An empty
// Wayland clipboard must not fall through to stale X11 clipboard contents.
async function readClipboardImageViaWlPaste(): Promise<ClipboardImage | null | undefined> {
const list = await runClipboardCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (list === undefined) return undefined;
const result = spawnSync(command, args, {
timeout: timeoutMs,
maxBuffer: maxBufferBytes,
env: options?.env,
});
if (result.error) {
return { ok: false, stdout: Buffer.alloc(0) };
}
if (result.status !== 0) {
return { ok: false, stdout: Buffer.alloc(0) };
}
const stdout = Buffer.isBuffer(result.stdout)
? result.stdout
: Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined);
return { ok: true, stdout };
}
function readClipboardImageViaWlPaste(): ClipboardImage | null {
const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!list.ok) {
return null;
}
const types = list.stdout
const types = list
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
@ -132,12 +102,11 @@ function readClipboardImageViaWlPaste(): ClipboardImage | null {
return null;
}
const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
if (!data.ok || data.stdout.length === 0) {
return null;
}
const data = await runClipboardCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
if (data === undefined) return undefined;
if (data.length === 0) return null;
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
return { bytes: data, mimeType: baseMimeType(selectedType) };
}
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
@ -158,16 +127,18 @@ function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
* directly, so we use it as a fallback.
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
async function readClipboardImageViaPowerShell(): Promise<ClipboardImage | null> {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
try {
const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!winPathResult.ok) {
const winPathResult = await runClipboardCommand("wslpath", ["-w", tmpFile], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
if (winPathResult === undefined) {
return null;
}
const winPath = winPathResult.stdout.toString("utf-8").trim();
const winPath = winPathResult.toString("utf-8").trim();
if (!winPath) {
return null;
}
@ -181,14 +152,14 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null {
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
].join("; ");
const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
const result = await runClipboardCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
});
if (!result.ok) {
if (result === undefined) {
return null;
}
const output = result.stdout.toString("utf-8").trim();
const output = result.toString("utf-8").trim();
if (output !== "ok") {
return null;
}
@ -210,45 +181,39 @@ function readClipboardImageViaPowerShell(): ClipboardImage | null {
}
}
function readClipboardImageViaXclip(): ClipboardImage | null {
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
async function readClipboardImageViaXclip(): Promise<ClipboardImage | null | undefined> {
const targets = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
});
let candidateTypes: string[] = [];
if (targets.ok) {
candidateTypes = targets.stdout
if (targets !== undefined) {
candidateTypes = targets
.toString("utf-8")
.split(/\r?\n/)
.map((t) => t.trim())
.filter(Boolean);
}
const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null;
const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES];
const preferred = selectPreferredImageMimeType(candidateTypes);
if (targets !== undefined && !preferred) return null;
const tryTypes = new Set(preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : SUPPORTED_IMAGE_MIME_TYPES);
for (const mimeType of tryTypes) {
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
if (data.ok && data.stdout.length > 0) {
return { bytes: data.stdout, mimeType: baseMimeType(mimeType) };
const data = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
if (data !== undefined && data.length > 0) {
return { bytes: data, mimeType: baseMimeType(mimeType) };
}
}
return null;
return undefined;
}
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null> {
if (!clipboard || !clipboard.hasImage()) {
return null;
}
const imageData = await clipboard.getImageBinary();
if (!imageData || imageData.length === 0) {
return null;
}
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
return { bytes, mimeType: "image/png" };
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null | undefined> {
const bytes = await getNativeClipboard()?.getImage();
if (bytes === undefined) return undefined;
if (!bytes?.length) return null;
return { bytes, mimeType: detectSupportedImageMimeType(bytes) ?? "application/octet-stream" };
}
export async function readClipboardImage(options?: {
@ -262,23 +227,17 @@ export async function readClipboardImage(options?: {
return null;
}
let image: ClipboardImage | null = null;
let image: ClipboardImage | null | undefined;
if (platform === "linux") {
const wsl = isWSL(env);
const wayland = isWaylandSession(env);
if (wayland || wsl) {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
}
if (!image && wsl) {
image = readClipboardImageViaPowerShell();
}
if (!image && !wayland) {
image = (await readClipboardImageViaNativeClipboard()) ?? readClipboardImageViaXclip();
if (isWaylandSession(env) || wsl) {
image = await readClipboardImageViaWlPaste();
}
if (image === undefined) image = await readClipboardImageViaXclip();
// Preserve Linux's empty/unavailable distinction if Windows has no image.
if (!image && wsl) image = (await readClipboardImageViaPowerShell()) ?? image;
if (image === undefined) image = await readClipboardImageViaNativeClipboard();
} else {
image = await readClipboardImageViaNativeClipboard();
}
@ -287,7 +246,7 @@ export async function readClipboardImage(options?: {
return null;
}
// Convert unsupported formats (e.g., BMP from WSLg) to PNG
// Convert unsupported formats (e.g., Windows DIB data wrapped as BMP) to PNG
if (!isSupportedImageMimeType(image.mimeType)) {
const pngBytes = await convertToPng(image.bytes);
if (!pngBytes) {

View file

@ -1,33 +0,0 @@
import { createRequire } from "module";
import { dirname, join } from "path";
import { pathToFileURL } from "url";
export type ClipboardModule = {
getText: () => Promise<string>;
setText: (text: string) => Promise<void>;
hasImage: () => boolean;
getImageBinary: () => Promise<Array<number>>;
};
type ClipboardRequire = (id: string) => unknown;
const moduleRequire = createRequire(import.meta.url);
const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href);
const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
export function loadClipboardNative(
requires: readonly ClipboardRequire[] = [moduleRequire, executableDirRequire],
): ClipboardModule | null {
for (const requireClipboard of requires) {
try {
return requireClipboard("@mariozechner/clipboard") as ClipboardModule;
} catch {
// Try the next resolution root.
}
}
return null;
}
const clipboard = !process.env.TERMUX_VERSION && hasDisplay ? loadClipboardNative() : null;
export { clipboard };

View file

@ -1,21 +1,6 @@
import { type ExecFileSyncOptionsWithStringEncoding, execFileSync, execSync, spawn } from "child_process";
import { platform } from "os";
import { isWaylandSession } from "./clipboard-image.ts";
import { clipboard } from "./clipboard-native.ts";
type NativeClipboardExecOptions = {
input: string;
timeout: number;
stdio: ["pipe", "ignore", "ignore"];
};
function copyToX11Clipboard(options: NativeClipboardExecOptions): void {
try {
execSync("xclip -selection clipboard", options);
} catch {
execSync("xsel --clipboard --input", options);
}
}
import { platform } from "node:os";
import { getNativeClipboard } from "@earendil-works/pi-tui";
import { runClipboardCommand } from "./clipboard-command.ts";
const MAX_OSC52_ENCODED_LENGTH = 100_000;
@ -32,144 +17,61 @@ function emitOsc52(text: string): boolean {
return true;
}
type ClipboardReadResult = { ok: true; text: string | null } | { ok: false };
const READ_CLIPBOARD_OPTIONS: ExecFileSyncOptionsWithStringEncoding = {
encoding: "utf8",
maxBuffer: 50 * 1024 * 1024,
timeout: 5000,
};
function readWaylandClipboardText(): ClipboardReadResult {
try {
const text = execFileSync("wl-paste", ["--no-newline", "--type", "text"], READ_CLIPBOARD_OPTIONS);
return { ok: true, text: text || null };
} catch {
return { ok: false };
}
}
/** Read plain text from the system clipboard. */
export async function readClipboardText(): Promise<string | null> {
if (platform() === "linux" && isWaylandSession() && process.env.WAYLAND_DISPLAY) {
const result = readWaylandClipboardText();
if (result.ok) {
return result.text;
if (platform() === "linux") {
const commands: [string, string[]][] = [];
if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]);
if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
if (process.env.DISPLAY) {
commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
}
for (const [command, args] of commands) {
const bytes = await runClipboardCommand(command, args, { timeoutMs: 5000 });
if (bytes !== undefined) return bytes.toString("utf8") || null;
}
}
if (!clipboard) {
return null;
}
try {
const text = await clipboard.getText();
return text || null;
return (await getNativeClipboard()?.getText()) || null;
} catch {
return null;
}
}
export async function copyToClipboard(text: string): Promise<void> {
let copied = false;
const p = platform();
// Prefer direct clipboard writes. Emitting OSC 52 first can make terminals
// write the same native clipboard concurrently with the addon, and very large
// OSC 52 payloads can desynchronize terminal rendering.
//
// On Linux, skip the native addon. The underlying `clipboard-rs` crate is
// X11-only and does not retain selection ownership after `set_text`
// resolves, so on Wayland-only compositors (Hyprland, Niri, ...) and even
// some X11 sessions the call resolves successfully without populating the
// clipboard. The platform tools below (wl-copy, xclip, xsel) properly
// daemonize and keep ownership.
try {
if (clipboard && p !== "linux") {
await clipboard.setText(text);
copied = true;
}
} catch {
// Fall through to platform-specific clipboard tools.
}
const remote = isRemoteSession();
if (copied && !remote) {
return;
}
const options: NativeClipboardExecOptions = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
if (!copied) {
let copied = false;
// Direct writes precede OSC 52 so the terminal cannot race the native writer.
// Linux tools retain clipboard selection ownership after this call returns.
if (p !== "linux") {
try {
if (p === "darwin") {
execSync("pbcopy", options);
const clipboard = getNativeClipboard();
if (clipboard?.setText) {
await clipboard.setText(text);
copied = true;
} else if (p === "win32") {
execSync("clip", options);
copied = true;
} else {
// Linux. Try Termux, Wayland, or X11 clipboard tools.
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", options);
copied = true;
} catch {
// Fall back to Wayland or X11 tools.
}
}
if (!copied) {
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
const isWayland = isWaylandSession();
if (isWayland && hasWaylandDisplay) {
try {
// Verify wl-copy exists (spawn errors are async and won't be caught)
execSync("which wl-copy", { stdio: "ignore" });
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
// Await the exit code and only claim success on a clean exit, so a
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
const wlCopyExit = await new Promise<number>((resolve) => {
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
proc.on("error", () => resolve(1));
proc.on("close", (code) => resolve(code ?? 1));
proc.stdin.on("error", () => {
// Ignore EPIPE errors if wl-copy exits early
});
proc.stdin.write(text);
proc.stdin.end();
});
if (wlCopyExit === 0) {
copied = true;
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
} catch {
if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
} else if (hasX11Display) {
copyToX11Clipboard(options);
copied = true;
}
}
}
} catch {
// Fall through to OSC 52 fallback.
// Try platform commands next.
}
}
if (remote || !copied) {
const osc52Copied = emitOsc52(text);
copied = copied || osc52Copied;
}
if (!copied) {
throw new Error("Failed to copy to clipboard");
const commands: [string, string[]][] = [];
if (p === "darwin") commands.push(["pbcopy", []]);
else if (p === "win32") commands.push(["clip", []]);
else {
if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-set", []]);
if (process.env.WAYLAND_DISPLAY) commands.push(["wl-copy", []]);
if (process.env.DISPLAY) {
commands.push(["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]);
}
}
for (const [command, args] of commands) {
if ((await runClipboardCommand(command, args, { input: text, timeoutMs: 5000 })) !== undefined) {
copied = true;
break;
}
}
}
if (isRemoteSession() || !copied) copied = emitOsc52(text) || copied;
if (!copied) throw new Error("Failed to copy to clipboard");
}

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { runClipboardCommand } from "../src/utils/clipboard-command.ts";
describe("clipboard commands", () => {
test("preserves binary output and distinguishes empty success from failure", async () => {
expect(
await runClipboardCommand(process.execPath, ["-e", "process.stdout.write(Buffer.from([0, 255, 10]))"]),
).toEqual(Buffer.from([0, 255, 10]));
expect(await runClipboardCommand(process.execPath, ["-e", ""])).toEqual(Buffer.alloc(0));
expect(await runClipboardCommand(process.execPath, ["-e", "process.exit(1)"])).toBeUndefined();
expect(await runClipboardCommand("pi-clipboard-command-does-not-exist", [])).toBeUndefined();
});
test("sends Unicode input to clipboard writers", async () => {
const script =
"let text = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', c => text += c); process.stdin.on('end', () => process.exit(text === 'café 日本語' ? 0 : 1));";
expect(await runClipboardCommand(process.execPath, ["-e", script], { input: "café 日本語" })).toEqual(
Buffer.alloc(0),
);
});
test("times out without blocking the event loop", async () => {
let ticks = 0;
const timer = setInterval(() => ticks++, 10);
try {
expect(
await runClipboardCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 200 }),
).toBeUndefined();
expect(ticks).toBeGreaterThan(5);
} finally {
clearInterval(timer);
}
});
test("rejects output above the buffer limit", async () => {
expect(
await runClipboardCommand(process.execPath, ["-e", "process.stdout.write(Buffer.alloc(1024))"], {
maxBufferBytes: 16,
}),
).toBeUndefined();
});
});

View file

@ -6,6 +6,7 @@
* instead of image/png.
*/
import { describe, expect, test, vi } from "vitest";
import { readClipboardImage } from "../src/utils/clipboard-image.ts";
function createTinyBmp1x1Red24bpp(): Uint8Array {
// Minimal 1x1 24bpp BMP (BGR + row padding to 4 bytes)
@ -42,47 +43,24 @@ function createTinyBmp1x1Red24bpp(): Uint8Array {
}
// Mock wl-paste to return BMP
vi.mock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
spawnSync: vi.fn((command: string, args: string[]) => {
if (command === "wl-paste" && args.includes("--list-types")) {
return { status: 0, stdout: Buffer.from("image/bmp\n"), error: null };
}
if (command === "wl-paste" && args.includes("image/bmp")) {
return { status: 0, stdout: Buffer.from(createTinyBmp1x1Red24bpp()), error: null };
}
return { status: 1, stdout: Buffer.alloc(0), error: null };
}),
};
});
vi.mock("../src/utils/clipboard-command.ts", () => ({
runClipboardCommand: vi.fn(async (command: string, args: string[]) => {
if (command === "wl-paste" && args.includes("--list-types")) return Buffer.from("image/bmp\n");
if (command === "wl-paste" && args.includes("image/bmp")) return Buffer.from(createTinyBmp1x1Red24bpp());
return undefined;
}),
}));
// Mock the native clipboard (not used in Wayland path, but needs to be mocked)
vi.mock("@mariozechner/clipboard", () => ({
default: {
hasImage: vi.fn(() => false),
getImageBinary: vi.fn(() => Promise.resolve(null)),
},
vi.mock("@earendil-works/pi-tui", () => ({
getNativeClipboard: () => ({ getImage: async () => createTinyBmp1x1Red24bpp() }),
}));
describe("readClipboardImage BMP conversion", () => {
test("converts BMP to PNG on Wayland/WSLg", async () => {
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
// Simulate Wayland session (WSLg)
const image = await readClipboardImage({
env: { WAYLAND_DISPLAY: "wayland-0" },
platform: "linux",
});
test.each(["linux", "win32"] as const)("%s: converts command/native BMP to PNG", async (platform) => {
const image = await readClipboardImage({ env: { WAYLAND_DISPLAY: "wayland-0" }, platform });
expect(image).not.toBeNull();
expect(image!.mimeType).toBe("image/png");
// Verify PNG magic bytes
expect(image!.bytes[0]).toBe(0x89);
expect(image!.bytes[1]).toBe(0x50); // P
expect(image!.bytes[2]).toBe(0x4e); // N
expect(image!.bytes[3]).toBe(0x47); // G
expect(Array.from(image!.bytes.slice(0, 4))).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
});

View file

@ -0,0 +1,31 @@
import type * as Tui from "@earendil-works/pi-tui";
import { afterEach, expect, test, vi } from "vitest";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
const readClipboardText = vi.hoisted(() => vi.fn<() => Promise<string | null>>());
vi.mock("@earendil-works/pi-tui", async (importOriginal) => ({
...(await importOriginal<typeof Tui>()),
getNativeClipboard: () => ({
async getImage() {
throw new Error("Native clipboard operation failed");
},
}),
}));
vi.mock("../src/utils/clipboard.ts", () => ({ copyToClipboard: vi.fn(), readClipboardText }));
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: vi.fn(async () => undefined) }));
afterEach(() => vi.unstubAllEnvs());
test("native image errors abort paste without reading text or changing the editor", async () => {
vi.stubEnv("TERMUX_VERSION", "");
const context = {
editor: { insertTextAtCursor: vi.fn() },
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
handleClipboardPaste(this: typeof context): Promise<void>;
};
await prototype.handleClipboardPaste.call(context);
expect(readClipboardText).not.toHaveBeenCalled();
expect(context.editor.insertTextAtCursor).not.toHaveBeenCalled();
expect(context.ui.requestRender).not.toHaveBeenCalled();
});

View file

@ -1,184 +1,134 @@
import type { SpawnSyncReturns } from "child_process";
import type { NativeClipboard } from "@earendil-works/pi-tui";
import { writeFileSync } from "fs";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { readClipboardImage } from "../src/utils/clipboard-image.ts";
const mocks = vi.hoisted(() => {
return {
spawnSync: vi.fn<(command: string, args: string[], options: unknown) => SpawnSyncReturns<Buffer>>(),
clipboard: {
hasImage: vi.fn<() => boolean>(),
getImageBinary: vi.fn<() => Promise<Uint8Array | null>>(),
},
};
});
const mocks = vi.hoisted(() => ({
command: vi.fn<(command: string, args: string[], options?: unknown) => Promise<Buffer | undefined>>(),
getImage: vi.fn<NativeClipboard["getImage"]>(),
getNativeClipboard: vi.fn<() => NativeClipboard | undefined>(),
}));
vi.mock("child_process", () => {
return {
spawnSync: mocks.spawnSync,
};
});
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: mocks.command }));
vi.mock("@earendil-works/pi-tui", () => ({ getNativeClipboard: mocks.getNativeClipboard }));
vi.mock("../src/utils/clipboard-native.js", () => {
return {
clipboard: mocks.clipboard,
};
});
function spawnOk(stdout: Buffer): SpawnSyncReturns<Buffer> {
return {
pid: 123,
output: [Buffer.alloc(0), stdout, Buffer.alloc(0)],
stdout,
stderr: Buffer.alloc(0),
status: 0,
signal: null,
};
function commandResult(stdout: Buffer, status = 0): Buffer | undefined {
return status === 0 ? stdout : undefined;
}
function spawnError(error: Error): SpawnSyncReturns<Buffer> {
return {
pid: 123,
output: [Buffer.alloc(0), Buffer.alloc(0), Buffer.alloc(0)],
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
status: null,
signal: null,
error,
};
}
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
describe("readClipboardImage", () => {
beforeEach(() => {
vi.resetModules();
mocks.spawnSync.mockReset();
mocks.clipboard.hasImage.mockReset();
mocks.clipboard.getImageBinary.mockReset();
vi.resetAllMocks();
mocks.command.mockResolvedValue(commandResult(Buffer.alloc(0), 1));
mocks.getImage.mockResolvedValue(png);
mocks.getNativeClipboard.mockReturnValue({ getText: async () => null, getImage: mocks.getImage });
});
test("Wayland: uses wl-paste and never calls clipboard", async () => {
mocks.clipboard.hasImage.mockImplementation(() => {
throw new Error("clipboard.hasImage should not be called on Wayland");
for (const [backend, command, env] of [
["wayland", "wl-paste", { WAYLAND_DISPLAY: "1", DISPLAY: ":0" }],
["x11", "xclip", { DISPLAY: ":0" }],
] as const) {
test.each([true, false])(`${backend}: command image present=%s stops fallback`, async (present) => {
mocks.command.mockImplementation(async (name, args) => {
expect(name).toBe(command);
const listing = args.includes("--list-types") || args.includes("TARGETS");
return commandResult(listing ? Buffer.from(present ? "text/plain\nimage/png\n" : "text/plain\n") : png);
});
expect(await readClipboardImage({ platform: "linux", env })).toEqual(
present ? { bytes: png, mimeType: "image/png" } : null,
);
expect(mocks.command).toHaveBeenCalledTimes(present ? 2 : 1);
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
});
}
mocks.spawnSync.mockImplementation((command, args, _options) => {
if (command === "wl-paste" && args[0] === "--list-types") {
return spawnOk(Buffer.from("text/plain\nimage/png\n", "utf-8"));
}
if (command === "wl-paste" && args[0] === "--type") {
return spawnOk(Buffer.from([1, 2, 3]));
}
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
test.each([png, null, new Uint8Array()])("native X11 result %j stops fallback", async (bytes) => {
mocks.getImage.mockResolvedValue(bytes);
expect(await readClipboardImage({ platform: "linux", env: { DISPLAY: ":0" } })).toEqual(
bytes?.length ? { bytes, mimeType: "image/png" } : null,
);
expect(mocks.getNativeClipboard).toHaveBeenCalledExactlyOnceWith();
expect(mocks.getImage).toHaveBeenCalledOnce();
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(Array<string>(5).fill("xclip"));
});
test.each(["missing module", "unavailable display"])("Wayland: falls back to X11 after %s", async (failure) => {
if (failure === "missing module") mocks.getNativeClipboard.mockReturnValue(undefined);
else mocks.getImage.mockResolvedValue(undefined);
mocks.command.mockImplementation(async (command, args) => {
if (command === "wl-paste") return commandResult(Buffer.alloc(0), 1);
return commandResult(args.includes("TARGETS") ? Buffer.from("image/png\n") : Buffer.from(png));
});
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
const result = await readClipboardImage({ platform: "linux", env: { WAYLAND_DISPLAY: "1" } });
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
expect(Array.from(result?.bytes ?? [])).toEqual([1, 2, 3]);
expect(await readClipboardImage({ platform: "linux", env: { WAYLAND_DISPLAY: "1" } })).toEqual({
bytes: png,
mimeType: "image/png",
});
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
});
test("Wayland: falls back to xclip when wl-paste is missing", async () => {
mocks.clipboard.hasImage.mockImplementation(() => {
throw new Error("clipboard.hasImage should not be called on Wayland");
});
const enoent = new Error("spawn ENOENT");
(enoent as { code?: string }).code = "ENOENT";
mocks.spawnSync.mockImplementation((command, args, _options) => {
if (command === "wl-paste") {
return spawnError(enoent);
}
if (command === "xclip" && args.includes("TARGETS")) {
return spawnOk(Buffer.from("image/png\n", "utf-8"));
}
if (command === "xclip" && args.includes("image/png")) {
return spawnOk(Buffer.from([9, 8]));
}
return spawnOk(Buffer.alloc(0));
});
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
const result = await readClipboardImage({ platform: "linux", env: { XDG_SESSION_TYPE: "wayland" } });
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
expect(Array.from(result?.bytes ?? [])).toEqual([9, 8]);
});
test("WSL: passes PowerShell path directly instead of through a custom env var", async () => {
mocks.clipboard.hasImage.mockImplementation(() => {
throw new Error("clipboard.hasImage should not be called before PowerShell on WSL");
});
test("WSL: tries PowerShell before a broken native X11 bridge", async () => {
mocks.getImage.mockRejectedValue(new Error("Broken X11 bridge"));
let tmpFile: string | undefined;
mocks.spawnSync.mockImplementation((command, args, options) => {
if (command === "wl-paste" || command === "xclip") {
return spawnOk(Buffer.alloc(0));
}
mocks.command.mockImplementation(async (command, args, options) => {
if (command === "wl-paste" || command === "xclip") return undefined;
if (command === "wslpath") {
tmpFile = args[1];
return spawnOk(Buffer.from("C:\\Users\\O'Hare\\clip.png\n", "utf-8"));
return commandResult(Buffer.from("C:\\Users\\O'Hare\\clip.png\n"));
}
if (command === "powershell.exe") {
const spawnOptions = options as { env?: NodeJS.ProcessEnv };
expect(spawnOptions.env?.PI_WSL_CLIPBOARD_IMAGE_PATH).toBeUndefined();
expect(args[2]).toContain("$path = 'C:\\Users\\O''Hare\\clip.png'");
if (!tmpFile) {
throw new Error("wslpath should be called before powershell.exe");
}
writeFileSync(tmpFile, Buffer.from([4, 5, 6]));
return spawnOk(Buffer.from("ok\n", "utf-8"));
if (!tmpFile) throw new Error("wslpath should be called before powershell.exe");
writeFileSync(tmpFile, png);
return commandResult(Buffer.from("ok\n"));
}
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
throw new Error(`Unexpected command: ${command}`);
});
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
const result = await readClipboardImage({ platform: "linux", env: { WSL_DISTRO_NAME: "Ubuntu" } });
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
expect(Array.from(result?.bytes ?? [])).toEqual([4, 5, 6]);
expect(await readClipboardImage({ platform: "linux", env: { WSL_DISTRO_NAME: "Ubuntu" } })).toEqual({
bytes: new Uint8Array(png),
mimeType: "image/png",
});
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
});
test("Non-Wayland: uses clipboard", async () => {
mocks.spawnSync.mockImplementation(() => {
throw new Error(
"spawnSync should not be called for non-Wayland sessions when native clipboard returns an image",
for (const platform of ["darwin", "win32"] as const) {
test.each([png, null, new Uint8Array(), undefined])(`${platform}: reads native image %j once`, async (bytes) => {
mocks.getImage.mockResolvedValue(bytes);
expect(await readClipboardImage({ platform, env: {} })).toEqual(
bytes?.length ? { bytes, mimeType: "image/png" } : null,
);
expect(mocks.getImage).toHaveBeenCalledOnce();
expect(mocks.command).not.toHaveBeenCalled();
});
}
mocks.clipboard.hasImage.mockReturnValue(true);
mocks.clipboard.getImageBinary.mockResolvedValue(new Uint8Array([7]));
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
const result = await readClipboardImage({ platform: "linux", env: {} });
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
expect(Array.from(result?.bytes ?? [])).toEqual([7]);
test("returns null without a native helper", async () => {
mocks.getNativeClipboard.mockReturnValue(undefined);
expect(await readClipboardImage({ platform: "win32", env: {} })).toBeNull();
expect(mocks.getImage).not.toHaveBeenCalled();
});
test("Non-Wayland: falls back to xclip when clipboard has no image", async () => {
mocks.spawnSync.mockImplementation((command, args, _options) => {
if (command === "xclip" && args.includes("TARGETS")) {
return spawnOk(Buffer.from("image/png\n", "utf-8"));
}
if (command === "xclip" && args.includes("image/png")) {
return spawnOk(Buffer.from([8, 9]));
}
throw new Error(`Unexpected spawnSync call: ${command} ${args.join(" ")}`);
});
test.each(["linux", "win32"] as const)(
"%s: propagates native transfer errors without fallback",
async (platform) => {
const error = new Error("Native clipboard operation failed");
mocks.getImage.mockImplementation(async () => {
throw error;
});
await expect(readClipboardImage({ platform, env: { WAYLAND_DISPLAY: "1", DISPLAY: ":0" } })).rejects.toBe(
error,
);
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(
platform === "linux" ? ["wl-paste", ...Array<string>(5).fill("xclip")] : [],
);
},
);
mocks.clipboard.hasImage.mockReturnValue(false);
const { readClipboardImage } = await import("../src/utils/clipboard-image.ts");
const result = await readClipboardImage({ platform: "linux", env: {} });
expect(result).not.toBeNull();
expect(result?.mimeType).toBe("image/png");
expect(Array.from(result?.bytes ?? [])).toEqual([8, 9]);
test("Termux does not read image clipboards", async () => {
expect(await readClipboardImage({ platform: "linux", env: { TERMUX_VERSION: "0.119" } })).toBeNull();
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
expect(mocks.command).not.toHaveBeenCalled();
});
});

View file

@ -1,32 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { type ClipboardModule, loadClipboardNative } from "../src/utils/clipboard-native.ts";
type ClipboardRequire = (id: string) => unknown;
const fakeClipboard: ClipboardModule = {
getText: async () => "",
setText: async () => {},
hasImage: () => true,
getImageBinary: async () => [1, 2, 3],
};
describe("loadClipboardNative", () => {
test("falls back to the next require root", () => {
const primary = vi.fn<ClipboardRequire>(() => {
throw new Error("missing from bundled root");
});
const fallback = vi.fn<ClipboardRequire>(() => fakeClipboard);
expect(loadClipboardNative([primary, fallback])).toBe(fakeClipboard);
expect(primary).toHaveBeenCalledWith("@mariozechner/clipboard");
expect(fallback).toHaveBeenCalledWith("@mariozechner/clipboard");
});
test("returns null when no require root can load clipboard", () => {
const missing = vi.fn<ClipboardRequire>(() => {
throw new Error("missing");
});
expect(loadClipboardNative([missing])).toBeNull();
});
});

View file

@ -1,210 +1,172 @@
import { execFileSync, execSync, spawn } from "child_process";
import { platform } from "os";
import type { NativeClipboard } from "@earendil-works/pi-tui";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts";
const mocks = vi.hoisted(() => {
return {
clipboard: {
getText: vi.fn<() => Promise<string>>(),
setText: vi.fn<(text: string) => Promise<void>>(),
},
execFileSync: vi.fn(),
execSync: vi.fn(),
spawn: vi.fn(),
platform: vi.fn<() => NodeJS.Platform>(),
isWaylandSession: vi.fn<() => boolean>(),
};
});
vi.mock("../src/utils/clipboard-native.js", () => {
return {
clipboard: mocks.clipboard,
};
});
vi.mock("child_process", () => {
return {
execFileSync: mocks.execFileSync,
execSync: mocks.execSync,
spawn: mocks.spawn,
};
});
vi.mock("os", () => {
return {
platform: mocks.platform,
};
});
vi.mock("../src/utils/clipboard-image.js", () => {
return {
isWaylandSession: mocks.isWaylandSession,
};
});
const mockedExecFileSync = vi.mocked(execFileSync);
const mockedExecSync = vi.mocked(execSync);
const mockedSpawn = vi.mocked(spawn);
const mockedPlatform = vi.mocked(platform);
const mocks = vi.hoisted(() => ({
clipboard: {
getText: vi.fn<NativeClipboard["getText"]>(),
getImage: vi.fn<NativeClipboard["getImage"]>(),
setText: vi.fn<(text: string) => Promise<void>>(),
},
getNativeClipboard: vi.fn<() => NativeClipboard | undefined>(),
command:
vi.fn<
(
command: string,
args: readonly string[],
options?: { input?: string; timeoutMs?: number },
) => Promise<Buffer | undefined>
>(),
platform: vi.fn<() => NodeJS.Platform>(),
}));
vi.mock("@earendil-works/pi-tui", () => ({ getNativeClipboard: mocks.getNativeClipboard }));
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: mocks.command }));
vi.mock("node:os", () => ({ platform: mocks.platform }));
let originalWrite: typeof process.stdout.write;
let stdoutWrites: string[];
let nativeResolved = false;
function osc52Writes(): string[] {
return stdoutWrites.filter((write) => write.startsWith("\x1b]52;c;"));
}
let osc52Writes: string[];
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("SSH_CONNECTION", "");
vi.stubEnv("SSH_CLIENT", "");
vi.stubEnv("MOSH_CONNECTION", "");
stdoutWrites = [];
nativeResolved = false;
mocks.clipboard.getText.mockReset();
mocks.clipboard.setText.mockReset();
mocks.execFileSync.mockReset();
mocks.execSync.mockReset();
mocks.spawn.mockReset();
mocks.platform.mockReset();
mocks.isWaylandSession.mockReset();
mockedPlatform.mockReturnValue("darwin");
mocks.isWaylandSession.mockReturnValue(false);
mocks.clipboard.getText.mockResolvedValue("");
mocks.clipboard.setText.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
nativeResolved = true;
});
vi.resetAllMocks();
for (const name of [
"SSH_CONNECTION",
"SSH_CLIENT",
"MOSH_CONNECTION",
"WAYLAND_DISPLAY",
"DISPLAY",
"TERMUX_VERSION",
])
vi.stubEnv(name, "");
mocks.platform.mockReturnValue("darwin");
mocks.getNativeClipboard.mockReturnValue(mocks.clipboard);
mocks.clipboard.getText.mockResolvedValue(null);
mocks.clipboard.setText.mockResolvedValue();
mocks.command.mockResolvedValue(Buffer.alloc(0));
osc52Writes = [];
originalWrite = process.stdout.write.bind(process.stdout);
process.stdout.write = ((...args: Parameters<typeof process.stdout.write>) => {
const [chunk] = args;
if (typeof chunk === "string" && chunk.startsWith("\x1b]52;c;")) {
stdoutWrites.push(chunk);
osc52Writes.push(chunk);
return true;
}
return originalWrite(...args);
}) as typeof process.stdout.write;
});
afterEach(() => {
process.stdout.write = originalWrite;
vi.unstubAllEnvs();
});
describe("readClipboardText", () => {
test("returns native clipboard text", async () => {
test("awaits native clipboard text and catches rejected reads", async () => {
mocks.clipboard.getText.mockResolvedValue("clipboard text");
await expect(readClipboardText()).resolves.toBe("clipboard text");
});
test("reads the Wayland clipboard before the stale native X11 clipboard", async () => {
// Regression test for #7248.
mockedPlatform.mockReturnValue("linux");
mocks.isWaylandSession.mockReturnValue(true);
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
mockedExecFileSync.mockReturnValue("Wayland text");
mocks.clipboard.getText.mockResolvedValue("stale X11 text");
await expect(readClipboardText()).resolves.toBe("Wayland text");
expect(mockedExecFileSync).toHaveBeenCalledWith("wl-paste", ["--no-newline", "--type", "text"], {
encoding: "utf8",
maxBuffer: 50 * 1024 * 1024,
timeout: 5000,
});
expect(mocks.clipboard.getText).not.toHaveBeenCalled();
});
test("does not fall back to stale X11 text when the Wayland clipboard is empty", async () => {
mockedPlatform.mockReturnValue("linux");
mocks.isWaylandSession.mockReturnValue(true);
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
mockedExecFileSync.mockReturnValue("");
mocks.clipboard.getText.mockResolvedValue("stale X11 text");
await expect(readClipboardText()).resolves.toBeNull();
expect(mocks.clipboard.getText).not.toHaveBeenCalled();
});
test("falls back to the native clipboard when wl-paste is unavailable", async () => {
mockedPlatform.mockReturnValue("linux");
mocks.isWaylandSession.mockReturnValue(true);
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
mockedExecFileSync.mockImplementation(() => {
throw new Error("wl-paste unavailable");
});
mocks.clipboard.getText.mockResolvedValue("X11 fallback text");
await expect(readClipboardText()).resolves.toBe("X11 fallback text");
});
test("returns null for empty or unavailable clipboard text", async () => {
await expect(readClipboardText()).resolves.toBeNull();
mocks.clipboard.getText.mockRejectedValue(new Error("clipboard unavailable"));
await expect(readClipboardText()).resolves.toBeNull();
});
for (const [env, command, args, calls] of [
["WAYLAND_DISPLAY", "wl-paste", ["--no-newline", "--type", "text"], ["wl-paste"]],
["DISPLAY", "xclip", ["-selection", "clipboard", "-out"], ["xclip"]],
["DISPLAY", "xsel", ["--clipboard", "--output"], ["xclip", "xsel"]],
["TERMUX_VERSION", "termux-clipboard-get", [], ["termux-clipboard-get"]],
] as const) {
test.each(["clipboard text", ""])(`${command} result %j stops fallback`, async (text) => {
// Regression test for #7248: empty Wayland content must not fall through to stale X11.
mocks.platform.mockReturnValue("linux");
vi.stubEnv("DISPLAY", ":0");
vi.stubEnv(env, "1");
mocks.command.mockImplementation(async (name) => (name === command ? Buffer.from(text) : undefined));
await expect(readClipboardText()).resolves.toBe(text || null);
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(calls);
expect(mocks.command).toHaveBeenLastCalledWith(command, args, { timeoutMs: 5000 });
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
});
}
test.each(["native text", "", null, undefined])("uses native X11 after command failures: %j", async (text) => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("DISPLAY", ":0");
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
mocks.command.mockResolvedValue(undefined);
mocks.clipboard.getText.mockResolvedValue(text);
await expect(readClipboardText()).resolves.toBe(text || null);
expect(mocks.getNativeClipboard).toHaveBeenCalledExactlyOnceWith();
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wl-paste", "xclip", "xsel"]);
});
test("falls back to X11 tools when wl-paste is unavailable", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
vi.stubEnv("DISPLAY", ":0");
mocks.command.mockImplementation(async (name) => (name === "wl-paste" ? undefined : Buffer.from("X11 text")));
await expect(readClipboardText()).resolves.toBe("X11 text");
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
});
});
describe("copyToClipboard", () => {
test("local native success skips OSC 52 and shell fallbacks", async () => {
test("local native success skips OSC 52 and commands", async () => {
await copyToClipboard("hello");
expect(mocks.clipboard.setText).toHaveBeenCalledWith("hello");
expect(osc52Writes()).toHaveLength(0);
expect(mockedExecSync).not.toHaveBeenCalled();
expect(mockedSpawn).not.toHaveBeenCalled();
expect(osc52Writes).toHaveLength(0);
expect(mocks.command).not.toHaveBeenCalled();
});
test("remote native success emits OSC 52 after native write", async () => {
vi.stubEnv("SSH_CONNECTION", "client server");
mocks.clipboard.setText.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
expect(osc52Writes()).toHaveLength(0);
nativeResolved = true;
});
test("Linux skips the native writer", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("DISPLAY", ":0");
await copyToClipboard("hello");
expect(nativeResolved).toBe(true);
expect(osc52Writes()).toHaveLength(1);
expect(mockedExecSync).not.toHaveBeenCalled();
});
test("local shell fallback success skips OSC 52", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mockedExecSync.mockReturnValue(Buffer.alloc(0));
await copyToClipboard("hello");
expect(mockedExecSync).toHaveBeenCalledWith("pbcopy", {
expect(mocks.getNativeClipboard).not.toHaveBeenCalled();
expect(mocks.command).toHaveBeenCalledWith("xclip", ["-selection", "clipboard"], {
input: "hello",
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
timeoutMs: 5000,
});
expect(osc52Writes()).toHaveLength(0);
});
test("uses OSC 52 fallback when native and shell tools fail", async () => {
test("waits for the native write before emitting remote OSC 52", async () => {
vi.stubEnv("SSH_CONNECTION", "client server");
let complete = () => {};
mocks.clipboard.setText.mockReturnValue(
new Promise<void>((resolve) => {
complete = resolve;
}),
);
const copy = copyToClipboard("hello");
expect(osc52Writes).toHaveLength(0);
complete();
await copy;
expect(osc52Writes).toHaveLength(1);
expect(mocks.command).not.toHaveBeenCalled();
});
test("a rejected native write falls back to pbcopy", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mockedExecSync.mockImplementation(() => {
throw new Error("pbcopy failed");
});
await copyToClipboard("hello");
expect(osc52Writes()).toHaveLength(1);
expect(mocks.command).toHaveBeenCalledWith("pbcopy", [], { input: "hello", timeoutMs: 5000 });
expect(osc52Writes).toHaveLength(0);
});
test("a read-only native clipboard uses the command writer", async () => {
mocks.getNativeClipboard.mockReturnValue({
getText: mocks.clipboard.getText,
getImage: mocks.clipboard.getImage,
});
await copyToClipboard("hello");
expect(mocks.command).toHaveBeenCalledOnce();
});
test("tries xclip and xsel after wl-copy fails", async () => {
mocks.platform.mockReturnValue("linux");
vi.stubEnv("WAYLAND_DISPLAY", "wayland-0");
vi.stubEnv("DISPLAY", ":0");
mocks.command.mockImplementation(async (name) => (name === "xsel" ? Buffer.alloc(0) : undefined));
await copyToClipboard("hello");
expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wl-copy", "xclip", "xsel"]);
expect(osc52Writes).toHaveLength(0);
});
test("uses OSC 52 when native and command writes fail", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mocks.command.mockResolvedValue(undefined);
await copyToClipboard("hello");
expect(osc52Writes).toHaveLength(1);
});
test("does not emit oversized OSC 52 payloads", async () => {
mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));
mockedExecSync.mockImplementation(() => {
throw new Error("pbcopy failed");
});
mocks.command.mockResolvedValue(undefined);
await expect(copyToClipboard("x".repeat(80_000))).rejects.toThrow("Failed to copy to clipboard");
expect(osc52Writes()).toHaveLength(0);
expect(osc52Writes).toHaveLength(0);
});
});

View file

@ -0,0 +1,170 @@
#ifndef PI_CLIPBOARD_H
#define PI_CLIPBOARD_H
#include "napi.h"
#ifndef _WIN32
#include <stdlib.h>
#include <string.h>
#endif
typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE } clipboard_operation;
typedef enum { CLIPBOARD_UNAVAILABLE, CLIPBOARD_EMPTY, CLIPBOARD_UTF8, CLIPBOARD_LATIN1, CLIPBOARD_UTF16, CLIPBOARD_BUFFER } clipboard_format;
typedef struct {
napi_async_work work;
napi_deferred deferred;
clipboard_operation operation;
clipboard_format format;
void* data;
size_t length;
const char* error;
} clipboard_job;
// Only this function runs on the worker thread. It must not call N-API.
static void clipboard_execute(clipboard_job* job);
static void* clipboard_alloc(size_t size) {
#ifdef _WIN32
return GlobalAlloc(GPTR, size);
#else
return calloc(1, size);
#endif
}
static void clipboard_free(void* data) {
#ifdef _WIN32
if (data) GlobalFree(data);
#else
free(data);
#endif
}
#ifdef PI_CLIPBOARD_WRITE
static void clipboard_copy(clipboard_job* job, const void* data, size_t length, clipboard_format format) {
job->data = clipboard_alloc(length ? length : 1);
if (!job->data) {
job->error = "Out of memory";
return;
}
#ifdef _WIN32
volatile uint8_t* target = job->data;
const volatile uint8_t* source = data;
for (size_t index = 0; index < length; index++) target[index] = source[index];
#else
memcpy(job->data, data, length);
#endif
job->length = format == CLIPBOARD_UTF16 ? length / sizeof(uint16_t) : length;
job->format = format;
}
#endif
static void PI_NAPI_CALL execute_clipboard_work(napi_env env, void* data) {
(void)env;
clipboard_execute(data);
}
static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void* data) {
clipboard_job* job = data;
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
napi_value result = undefined_value(env);
if (status != 0) job->error = "Clipboard operation cancelled";
if (!job->error && job->operation != CLIPBOARD_WRITE) {
if (job->format == CLIPBOARD_EMPTY) {
result = null_value(env);
} else if (job->format == CLIPBOARD_BUFFER) {
napi_create_buffer_copy_fn create_buffer = (napi_create_buffer_copy_fn)node_symbol("napi_create_buffer_copy");
status = create_buffer(env, job->length, job->data, 0, &result);
} else if (job->format == CLIPBOARD_UTF16) {
napi_create_string_utf16_fn create_utf16 = (napi_create_string_utf16_fn)node_symbol("napi_create_string_utf16");
status = create_utf16(env, job->data, job->length, &result);
} else if (job->format == CLIPBOARD_UTF8 || job->format == CLIPBOARD_LATIN1) {
napi_create_string_utf8_fn create_text = job->format == CLIPBOARD_LATIN1
? (napi_create_string_utf8_fn)node_symbol("napi_create_string_latin1") : create_string;
status = create_text(env, job->data, job->length, &result);
}
if (status != 0) job->error = "Could not create clipboard value";
}
if (job->error) {
napi_create_error_fn create_error = (napi_create_error_fn)node_symbol("napi_create_error");
napi_value message;
create_string(env, job->error, NAPI_AUTO_LENGTH, &message);
create_error(env, 0, message, &result);
}
napi_settle_deferred_fn settle = (napi_settle_deferred_fn)node_symbol(
job->error ? "napi_reject_deferred" : "napi_resolve_deferred"
);
settle(env, job->deferred, result);
napi_async_work_fn delete_work = (napi_async_work_fn)node_symbol("napi_delete_async_work");
delete_work(env, job->work);
clipboard_free(job->data);
clipboard_free(job);
}
static napi_value queue_clipboard(napi_env env, napi_callback_info info, clipboard_operation operation) {
napi_create_async_work_fn create_work = (napi_create_async_work_fn)node_symbol("napi_create_async_work");
napi_async_work_fn queue_work = (napi_async_work_fn)node_symbol("napi_queue_async_work");
napi_async_work_fn delete_work = (napi_async_work_fn)node_symbol("napi_delete_async_work");
napi_create_promise_fn create_promise = (napi_create_promise_fn)node_symbol("napi_create_promise");
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
if (!create_work || !queue_work || !delete_work || !create_promise || !create_string) {
return fail(env, "Async clipboard API unavailable");
}
clipboard_job* job = clipboard_alloc(sizeof(*job));
if (!job) return fail(env, "Out of memory");
job->operation = operation;
#ifdef PI_CLIPBOARD_WRITE
if (operation == CLIPBOARD_WRITE) {
napi_get_cb_info_fn get_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
#ifdef _WIN32
napi_get_value_string_utf16_fn get_string = (napi_get_value_string_utf16_fn)node_symbol("napi_get_value_string_utf16");
size_t unit_size = sizeof(uint16_t);
#else
napi_get_value_string_utf8_fn get_string = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
size_t unit_size = 1;
#endif
size_t argc = 1;
napi_value argument = 0;
if (!get_info || !get_string || get_info(env, info, &argc, &argument, 0, 0) != 0 || argc == 0 ||
get_string(env, argument, 0, 0, &job->length) != 0) {
clipboard_free(job);
return fail(env, "setText requires a string");
}
job->data = job->length < SIZE_MAX / unit_size
? clipboard_alloc((job->length + 1) * unit_size) : 0;
if (!job->data || get_string(env, argument, job->data, job->length + 1, &job->length) != 0) {
clipboard_free(job->data);
clipboard_free(job);
return fail(env, "Could not read clipboard text");
}
}
#else
(void)info;
#endif
napi_value name = 0;
napi_value promise = 0;
if (create_string(env, "pi.clipboard", NAPI_AUTO_LENGTH, &name) != 0 ||
create_work(env, 0, name, execute_clipboard_work, complete_clipboard_work, job, &job->work) != 0 ||
create_promise(env, &job->deferred, &promise) != 0 || queue_work(env, job->work) != 0) {
if (job->work) delete_work(env, job->work);
clipboard_free(job->data);
clipboard_free(job);
return fail(env, "Could not queue clipboard operation");
}
return promise;
}
static napi_value PI_NAPI_CALL get_clipboard_text(napi_env env, napi_callback_info info) {
return queue_clipboard(env, info, CLIPBOARD_TEXT);
}
static napi_value PI_NAPI_CALL get_clipboard_image(napi_env env, napi_callback_info info) {
return queue_clipboard(env, info, CLIPBOARD_IMAGE);
}
#ifdef PI_CLIPBOARD_WRITE
static napi_value PI_NAPI_CALL set_clipboard_text(napi_env env, napi_callback_info info) {
return queue_clipboard(env, info, CLIPBOARD_WRITE);
}
#endif
#endif

View file

@ -1,20 +1,22 @@
# Darwin native prebuilds
Build both macOS architectures from the repository root:
Provides modifier-key state and asynchronous text/image clipboard access using AppKit.
## Building
On macOS, run from the repository root:
```sh
npm --prefix packages/tui run build:native:darwin
```
The build uses macOS 11.0 as the arm64 deployment target and macOS 10.15 as the x86_64 deployment target. On macOS, `build.sh` finds Apple clang and the active macOS SDK through `xcrun`. Either an Intel or Apple Silicon host can build both outputs.
The build uses Apple clang and the macOS SDK found through `xcrun`. Either Intel or Apple Silicon can build both targets: arm64 (macOS 11+) and x64 (macOS 10.15+).
A non-macOS host needs a complete Darwin cross-toolchain, including a macOS SDK and a Mach-O linker. For example, an osxcross installation can be selected with `CC` and `SDKROOT`:
Cross-building requires a Darwin toolchain with a macOS SDK and Mach-O linker, such as osxcross:
```sh
CC=/path/to/osxcross/clang SDKROOT=/path/to/MacOSX.sdk \
npm --prefix packages/tui run build:native:darwin
```
The SDK must be obtained and used in accordance with Apple's license. Plain Linux or Windows clang is not enough because the addon includes and links CoreGraphics.
Zig is not used here because it does not provide the Apple SDK or CoreGraphics framework stubs. It therefore does not make this build SDK-independent, and its clang driver does not currently handle this Mach-O bundle recipe as a drop-in replacement for Apple clang.
The SDK must be obtained and used under Apple's license. Plain clang or Zig alone is not sufficient.

View file

@ -3,7 +3,7 @@
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_file="$script_dir/src/darwin-modifiers.c"
source_file="$script_dir/src/darwin-platform.m"
if [[ -n "${CC:-}" ]]; then
compiler="$CC"
@ -37,25 +37,32 @@ build() {
local arch="$1"
local target="$2"
local output_dir="$script_dir/prebuilds/darwin-$arch"
local temporary_output="$temporary_dir/darwin-$arch/darwin-modifiers.node"
local temporary_output="$temporary_dir/darwin-$arch/darwin-platform.node"
mkdir -p "$(dirname "$temporary_output")"
"$compiler" \
-fobjc-arc \
-std=c11 \
-Wall \
-Wextra \
-O2 \
-Oz \
-flto \
-fvisibility=hidden \
"--target=$target" \
"${sdk_flags[@]}" \
-bundle \
-undefined dynamic_lookup \
-Wl,-dead_strip \
-Wl,-x \
-framework AppKit \
-framework CoreGraphics \
-framework Foundation \
"$source_file" \
-o "$temporary_output"
mkdir -p "$output_dir"
install -m 755 "$temporary_output" "$output_dir/darwin-modifiers.node"
echo "Built $output_dir/darwin-modifiers.node"
install -m 755 "$temporary_output" "$output_dir/darwin-platform.node"
echo "Built $output_dir/darwin-platform.node"
}
build arm64 arm64-apple-macos11.0

View file

@ -1,70 +0,0 @@
#include <CoreGraphics/CoreGraphics.h>
#include <dlfcn.h>
#include <stdbool.h>
#include <stddef.h>
#include <string.h>
#define NAPI_AUTO_LENGTH ((size_t)-1)
typedef void* napi_env;
typedef void* napi_value;
typedef void* napi_callback_info;
typedef napi_value (*napi_callback)(napi_env, napi_callback_info);
typedef int (*napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*);
typedef int (*napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
typedef int (*napi_get_boolean_fn)(napi_env, bool, napi_value*);
typedef int (*napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**);
typedef int (*napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*);
static void* node_symbol(const char* name) {
return dlsym(RTLD_DEFAULT, name);
}
static CGEventFlags modifier_mask_for_name(const char* name) {
if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift;
if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand;
if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl;
if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate;
return 0;
}
static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) {
napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
bool pressed = false;
if (napi_get_cb_info && napi_get_value_string_utf8) {
size_t argc = 1;
napi_value args[1] = {0};
if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
char name[16] = {0};
size_t copied = 0;
if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
CGEventFlags mask = modifier_mask_for_name(name);
if (mask != 0) {
CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState);
pressed = (flags & mask) != 0;
}
}
}
}
napi_value result = 0;
if (napi_get_boolean) napi_get_boolean(env, pressed, &result);
return result;
}
__attribute__((visibility("default"))) napi_value napi_register_module_v1(napi_env env, napi_value exports) {
napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property");
napi_value fn = 0;
if (napi_create_function &&
napi_set_named_property &&
napi_create_function(env, "isModifierPressed", NAPI_AUTO_LENGTH, is_modifier_pressed, 0, &fn) == 0) {
napi_set_named_property(env, exports, "isModifierPressed", fn);
}
return exports;
}

View file

@ -0,0 +1,93 @@
#import <AppKit/AppKit.h>
#import <CoreGraphics/CoreGraphics.h>
#import <Foundation/Foundation.h>
#include <stdlib.h>
#include <string.h>
#define PI_CLIPBOARD_WRITE
#include "../../clipboard.h"
static CGEventFlags modifier_mask_for_name(const char* name) {
if (strcmp(name, "shift") == 0) return kCGEventFlagMaskShift;
if (strcmp(name, "command") == 0) return kCGEventFlagMaskCommand;
if (strcmp(name, "control") == 0) return kCGEventFlagMaskControl;
if (strcmp(name, "option") == 0) return kCGEventFlagMaskAlternate;
return 0;
}
static napi_value is_modifier_pressed(napi_env env, napi_callback_info info) {
napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
napi_get_value_string_utf8_fn napi_get_value_string_utf8 =
(napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
bool pressed = false;
if (napi_get_cb_info && napi_get_value_string_utf8) {
size_t argc = 1;
napi_value args[1] = {0};
if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
char name[16] = {0};
size_t copied = 0;
if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
CGEventFlags mask = modifier_mask_for_name(name);
if (mask != 0) {
CGEventFlags flags = CGEventSourceFlagsState(kCGEventSourceStateCombinedSessionState);
pressed = (flags & mask) != 0;
}
}
}
}
napi_value result = 0;
if (!napi_get_boolean || napi_get_boolean(env, pressed, &result) != 0) {
return fail(env, "Could not inspect modifier state");
}
return result;
}
static void clipboard_execute(clipboard_job* job) {
@autoreleasepool {
NSPasteboard* pasteboard = [NSPasteboard generalPasteboard];
if (!pasteboard) {
job->error = "Could not open clipboard";
} else if (job->operation == CLIPBOARD_WRITE) {
NSString* text = [[NSString alloc] initWithBytes:job->data length:job->length encoding:NSUTF8StringEncoding];
if (!text) {
job->error = "Clipboard text is not valid UTF-8";
return;
}
[pasteboard clearContents];
if (![pasteboard setString:text forType:NSPasteboardTypeString]) job->error = "Could not set clipboard text";
} else if (job->operation == CLIPBOARD_TEXT) {
NSString* text = [pasteboard stringForType:NSPasteboardTypeString];
if (!text) {
job->format = CLIPBOARD_EMPTY;
} else if (!text.UTF8String) {
job->error = "Could not encode clipboard text";
} else {
clipboard_copy(job, text.UTF8String, [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding], CLIPBOARD_UTF8);
}
} else {
if (![pasteboard availableTypeFromArray:@[ NSPasteboardTypePNG, NSPasteboardTypeTIFF ]]) {
job->format = CLIPBOARD_EMPTY;
return;
}
NSData* png = [pasteboard dataForType:NSPasteboardTypePNG];
if (!png) {
NSImage* image = [[NSImage alloc] initWithPasteboard:pasteboard];
NSData* tiff = image.TIFFRepresentation;
NSBitmapImageRep* bitmap = tiff ? [NSBitmapImageRep imageRepWithData:tiff] : nil;
png = bitmap ? [bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}] : nil;
}
if (png) clipboard_copy(job, png.bytes, png.length, CLIPBOARD_BUFFER);
else job->error = "Clipboard does not contain an image";
}
}
}
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
set_function_export(env, exports, "getText", get_clipboard_text);
set_function_export(env, exports, "setText", set_clipboard_text);
set_function_export(env, exports, "getImage", get_clipboard_image);
return exports;
}

View file

@ -0,0 +1,23 @@
# Linux clipboard helper
Provides asynchronous X11 text and image reads using `libxcb.so.1`. Prebuilds support x64 and arm64 on glibc and musl. Reads have bounded waits; if a native operation stalls, the helper remains unavailable until it finishes.
Coding-agent falls back to command-line tools when native reads are unavailable. Wayland reads use `wl-paste`; all Linux writes use the existing command-line or terminal clipboard paths.
## Building
Install a C compiler and XCB development headers, then run from the repository root on each supported Linux architecture:
```sh
npm --prefix packages/tui run build:native:linux
```
## Testing
Install the build dependencies plus `pkg-config`, `Xvfb`, and `xclip`, then run from `packages/tui`:
```sh
node --test test/native-clipboard-linux.test.ts
```
Tests use isolated X11 servers, not the desktop clipboard. They skip when dependencies are missing.

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
compiler="${CC:-cc}"
if [[ "$(uname -s)" != "Linux" ]]; then
echo "Linux native helpers must be built on Linux" >&2
exit 1
fi
case "$(uname -m)" in
x86_64)
arch="x64"
;;
aarch64|arm64)
arch="arm64"
;;
*)
echo "Unsupported Linux architecture: $(uname -m)" >&2
exit 1
;;
esac
if ! command -v "$compiler" >/dev/null 2>&1; then
echo "Linux C compiler not found: $compiler" >&2
exit 1
fi
build_dir="$(mktemp -d "${TMPDIR:-/tmp}/pi-tui-linux.XXXXXX")"
trap 'rm -rf "$build_dir"' EXIT
common_flags=(
-std=c11
-D_POSIX_C_SOURCE=200809L
-Wall
-Wextra
-Werror
-Os
-flto
-fPIC
-pthread
-fvisibility=hidden
-fno-stack-protector
-shared
-nostdlib
-Wl,--unresolved-symbols=ignore-all
-Wl,--no-as-needed
-Wl,-O2
-Wl,--gc-sections
# A timed-out private thread may outlive the Node environment that loaded us.
-Wl,-z,nodelete
-Wl,-s
)
x11_output="$build_dir/linux-platform-x11.node"
"$compiler" "${common_flags[@]}" \
"$script_dir/src/linux-platform-x11.c" \
-lxcb \
-o "$x11_output"
output_dir="$script_dir/prebuilds/linux-$arch"
mkdir -p "$output_dir"
install -m 755 "$x11_output" "$output_dir/linux-platform-x11.node"
printf 'Built %s\n' "$output_dir/linux-platform-x11.node"

View file

@ -0,0 +1,70 @@
#include <pthread.h>
#include <time.h>
#include "../../clipboard.h"
static void read_clipboard(clipboard_job* job);
static pthread_mutex_t worker_mutex = PTHREAD_MUTEX_INITIALIZER;
static pthread_once_t worker_once = PTHREAD_ONCE_INIT;
static pthread_cond_t worker_changed;
static bool worker_ready, worker_busy, worker_waiting, worker_finished;
static clipboard_job worker_result;
static int (*signal_worker)(pthread_cond_t*);
static int (*wait_for_worker)(pthread_cond_t*, pthread_mutex_t*, const struct timespec*);
static void initialize_worker_condition(void) {
// Unversioned ELF imports otherwise bind obsolete condition variables on glibc x64.
int (*initialize)(pthread_cond_t*, const pthread_condattr_t*) = node_symbol("pthread_cond_init");
signal_worker = node_symbol("pthread_cond_signal");
wait_for_worker = node_symbol("pthread_cond_timedwait");
if (!initialize || !signal_worker || !wait_for_worker) return;
pthread_condattr_t attributes;
if (pthread_condattr_init(&attributes) != 0) return;
worker_ready = pthread_condattr_setclock(&attributes, CLOCK_MONOTONIC) == 0 &&
initialize(&worker_changed, &attributes) == 0;
pthread_condattr_destroy(&attributes);
}
static void* read_on_private_thread(void* unused) {
(void)unused;
read_clipboard(&worker_result);
pthread_mutex_lock(&worker_mutex);
worker_finished = true;
if (!worker_waiting) {
free(worker_result.data); // The caller timed out; discard the late result.
worker_busy = false;
}
signal_worker(&worker_changed);
pthread_mutex_unlock(&worker_mutex);
return 0;
}
static void clipboard_execute(clipboard_job* job) {
pthread_once(&worker_once, initialize_worker_condition);
if (!worker_ready) return;
pthread_mutex_lock(&worker_mutex);
if (!worker_busy) {
worker_busy = worker_waiting = true;
worker_finished = false;
worker_result = (clipboard_job){.operation = job->operation};
struct timespec deadline;
clock_gettime(CLOCK_MONOTONIC, &deadline);
deadline.tv_sec += 3; // Allow the two-second transfer deadline to report its own error first.
pthread_t thread;
if (pthread_create(&thread, 0, read_on_private_thread, 0) == 0) {
pthread_detach(thread);
// Only this bounded wait occupies libuv. Other callers return unavailable.
while (!worker_finished && wait_for_worker(&worker_changed, &worker_mutex, &deadline) == 0) {}
if (worker_finished) {
job->data = worker_result.data;
job->length = worker_result.length;
job->format = worker_result.format;
job->error = worker_result.error;
worker_busy = false;
}
} else {
worker_busy = false;
}
worker_waiting = false;
}
pthread_mutex_unlock(&worker_mutex);
}

View file

@ -0,0 +1,274 @@
#include <xcb/xcb.h>
#include <string.h>
#include <xcb/xcbext.h>
#include <errno.h>
#include <poll.h>
#include <time.h>
#include "clipboard-worker.h"
#define MAX_CLIPBOARD_BYTES (50u * 1024u * 1024u)
#define CLIPBOARD_TIMEOUT_MS 2000
static const char* const text_types[] = {
"text/plain;charset=utf-8", "text/plain;charset=UTF-8", "UTF8_STRING", "text/plain", "STRING",
};
static const char* const image_types[] = {
"image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff",
};
static int64_t monotonic_ms(void) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
}
typedef struct {
unsigned char* data;
size_t length;
uint32_t items;
uint8_t format;
xcb_atom_t type;
} property_data;
typedef struct {
xcb_connection_t* connection;
xcb_window_t window;
xcb_atom_t clipboard;
xcb_atom_t property;
xcb_atom_t targets;
xcb_atom_t incr;
int64_t deadline;
} x11_clipboard;
static bool append_bytes(property_data* result, const unsigned char* bytes, size_t length) {
if (length > MAX_CLIPBOARD_BYTES - result->length) return false;
unsigned char* data = realloc(result->data, result->length + length + 1);
if (!data) return false;
memcpy(data + result->length, bytes, length);
result->length += length;
data[result->length] = 0;
result->data = data;
return true;
}
// Share one deadline across discovery, replies, and incremental chunks.
// Connection setup and flushing are libxcb calls on the private thread.
static bool wait_for_input(x11_clipboard* clipboard) {
for (;;) {
int64_t remaining = clipboard->deadline - monotonic_ms();
if (remaining <= 0 || xcb_connection_has_error(clipboard->connection)) return false;
struct pollfd descriptor = {xcb_get_file_descriptor(clipboard->connection), POLLIN, 0};
int ready = poll(&descriptor, 1, (int)remaining);
if (ready >= 0) return ready && (descriptor.revents & POLLIN);
if (errno != EINTR) return false;
}
}
static void* wait_for_reply(x11_clipboard* clipboard, unsigned sequence) {
if (xcb_flush(clipboard->connection) <= 0) return 0;
while (monotonic_ms() < clipboard->deadline) {
void* reply = 0;
xcb_generic_error_t* error = 0;
if (xcb_poll_for_reply(clipboard->connection, sequence, &reply, &error)) {
free(error);
return reply;
}
if (!wait_for_input(clipboard)) break;
}
xcb_discard_reply(clipboard->connection, sequence);
return 0;
}
static xcb_generic_event_t* wait_for_event(x11_clipboard* clipboard, uint8_t type) {
if (xcb_flush(clipboard->connection) <= 0) return 0;
while (monotonic_ms() < clipboard->deadline) {
xcb_generic_event_t* event = xcb_poll_for_event(clipboard->connection);
if (event) {
if ((event->response_type & 0x7f) == type) return event;
free(event);
} else if (!wait_for_input(clipboard)) {
break;
}
}
return 0;
}
static bool read_property(x11_clipboard* clipboard, bool remove, property_data* result) {
xcb_get_property_cookie_t cookie = xcb_get_property(
clipboard->connection, remove, clipboard->window, clipboard->property,
XCB_GET_PROPERTY_TYPE_ANY, 0, MAX_CLIPBOARD_BYTES / 4
);
xcb_get_property_reply_t* reply = wait_for_reply(clipboard, cookie.sequence);
if (!reply) return false;
bool copied = false;
bool valid_format = reply->format == 8 || reply->format == 16 || reply->format == 32;
uint64_t length = (uint64_t)reply->value_len * (reply->format / 8);
if (reply->bytes_after == 0 && reply->type != XCB_NONE && valid_format &&
length <= MAX_CLIPBOARD_BYTES && length <= (uint64_t)reply->length * 4) {
result->type = reply->type;
result->format = reply->format;
result->items = reply->value_len;
copied = append_bytes(result, xcb_get_property_value(reply), (size_t)length);
}
free(reply);
return copied;
}
static bool append_property(property_data* result, const property_data* chunk) {
if ((chunk->format != 8 && chunk->format != 16 && chunk->format != 32) ||
chunk->type == XCB_NONE ||
(uint64_t)chunk->items * (chunk->format / 8) != chunk->length ||
(result->type != XCB_NONE && (result->type != chunk->type || result->format != chunk->format)) ||
chunk->items > UINT32_MAX - result->items) return false;
if (!append_bytes(result, chunk->data, chunk->length)) return false;
result->type = chunk->type;
result->format = chunk->format;
result->items += chunk->items;
return true;
}
static bool request_selection(x11_clipboard* clipboard, xcb_atom_t target, property_data* result) {
memset(result, 0, sizeof(*result));
xcb_delete_property(clipboard->connection, clipboard->window, clipboard->property);
xcb_convert_selection(
clipboard->connection, clipboard->window, clipboard->clipboard,
target, clipboard->property, XCB_CURRENT_TIME
);
while (true) {
xcb_selection_notify_event_t* event = (xcb_selection_notify_event_t*)wait_for_event(clipboard, XCB_SELECTION_NOTIFY);
if (!event) return false;
bool matches = event->selection == clipboard->clipboard && event->target == target;
bool received = event->property != XCB_NONE;
free(event);
if (!matches) continue;
if (!received) return true; // No owner, or the owner does not offer this target.
if (!read_property(clipboard, false, result)) return false;
break;
}
if (result->type != clipboard->incr) return true;
free(result->data);
memset(result, 0, sizeof(*result));
xcb_delete_property(clipboard->connection, clipboard->window, clipboard->property);
while (true) {
xcb_property_notify_event_t* event = (xcb_property_notify_event_t*)wait_for_event(clipboard, XCB_PROPERTY_NOTIFY);
if (!event) return false;
bool matches = event->atom == clipboard->property && event->state == XCB_PROPERTY_NEW_VALUE;
free(event);
if (!matches) continue;
property_data chunk = {0};
if (!read_property(clipboard, true, &chunk)) return false;
bool appended = append_property(result, &chunk);
bool finished = chunk.items == 0;
free(chunk.data);
if (!appended) return false;
if (finished) return true;
}
}
static xcb_atom_t intern_atom(x11_clipboard* clipboard, const char* name) {
xcb_intern_atom_cookie_t cookie = xcb_intern_atom(clipboard->connection, false, strlen(name), name);
xcb_intern_atom_reply_t* reply = wait_for_reply(clipboard, cookie.sequence);
if (!reply) return XCB_NONE;
xcb_atom_t atom = reply->atom;
free(reply);
return atom;
}
static bool open_clipboard(x11_clipboard* clipboard) {
memset(clipboard, 0, sizeof(*clipboard));
clipboard->deadline = monotonic_ms() + CLIPBOARD_TIMEOUT_MS;
int screen_number = 0;
clipboard->connection = xcb_connect(0, &screen_number);
if (xcb_connection_has_error(clipboard->connection)) return false;
xcb_screen_iterator_t screens = xcb_setup_roots_iterator(xcb_get_setup(clipboard->connection));
while (screen_number-- > 0 && screens.rem) xcb_screen_next(&screens);
if (!screens.rem) return false;
clipboard->window = xcb_generate_id(clipboard->connection);
uint32_t mask = XCB_EVENT_MASK_PROPERTY_CHANGE;
xcb_create_window(
clipboard->connection, XCB_COPY_FROM_PARENT, clipboard->window,
screens.data->root, 0, 0, 1, 1, 0, XCB_WINDOW_CLASS_INPUT_OUTPUT,
XCB_COPY_FROM_PARENT, XCB_CW_EVENT_MASK, &mask
);
clipboard->clipboard = intern_atom(clipboard, "CLIPBOARD");
clipboard->property = intern_atom(clipboard, "PI_CLIPBOARD");
clipboard->targets = intern_atom(clipboard, "TARGETS");
clipboard->incr = intern_atom(clipboard, "INCR");
return clipboard->clipboard && clipboard->property && clipboard->targets && clipboard->incr;
}
static void close_clipboard(x11_clipboard* clipboard) {
if (clipboard->connection) xcb_disconnect(clipboard->connection);
}
static bool preferred_target(x11_clipboard* clipboard, bool image, xcb_atom_t* target) {
const char* const* types = image ? image_types : text_types;
size_t type_count = image ? 6 : 5;
xcb_atom_t wanted[6];
for (size_t index = 0; index < type_count; index++) {
wanted[index] = intern_atom(clipboard, types[index]);
if (wanted[index] == XCB_NONE) return false;
}
property_data targets = {0};
bool received = request_selection(clipboard, clipboard->targets, &targets);
bool valid = targets.type == XCB_ATOM_ATOM && targets.format == 32 &&
targets.items == targets.length / sizeof(xcb_atom_t) && targets.length % sizeof(xcb_atom_t) == 0;
if (received && valid) {
xcb_atom_t* offered = (xcb_atom_t*)targets.data;
for (size_t wanted_index = 0; wanted_index < type_count; wanted_index++) {
for (uint32_t offered_index = 0; offered_index < targets.items; offered_index++) {
if (offered[offered_index] == wanted[wanted_index]) {
free(targets.data);
*target = wanted[wanted_index];
return true;
}
}
}
}
free(targets.data);
// Some owners refuse TARGETS but still serve text directly.
if (received && !image && targets.type == XCB_NONE) {
*target = intern_atom(clipboard, "UTF8_STRING");
return *target != XCB_NONE;
}
return received && (valid || targets.type == XCB_NONE);
}
static bool read_selection(x11_clipboard* clipboard, bool image, property_data* result) {
xcb_atom_t target = XCB_NONE;
bool received = preferred_target(clipboard, image, &target) &&
(target == XCB_NONE || request_selection(clipboard, target, result));
if (!received) {
free(result->data);
memset(result, 0, sizeof(*result));
}
return received;
}
static void read_clipboard(clipboard_job* job) {
x11_clipboard clipboard;
if (open_clipboard(&clipboard)) {
property_data contents = {0};
if (!read_selection(&clipboard, job->operation == CLIPBOARD_IMAGE, &contents)) {
job->error = "Could not read X11 clipboard";
} else {
job->data = contents.data;
job->length = contents.length;
job->format = !contents.data ? CLIPBOARD_EMPTY : job->operation == CLIPBOARD_IMAGE
? CLIPBOARD_BUFFER : contents.type == XCB_ATOM_STRING ? CLIPBOARD_LATIN1 : CLIPBOARD_UTF8;
}
}
close_clipboard(&clipboard);
}
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "getText", get_clipboard_text);
set_function_export(env, exports, "getImage", get_clipboard_image);
return exports;
}

View file

@ -0,0 +1,89 @@
#ifndef PI_NAPI_H
#define PI_NAPI_H
// Resolve the small N-API surface we use from Node or Bun, without Node headers
// or a link-time dependency on a particular runtime.
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#ifdef _WIN32
#include <windows.h>
#define PI_NAPI_CALL __cdecl
#define PI_NAPI_EXPORT __declspec(dllexport)
#else
#include <dlfcn.h>
#define PI_NAPI_CALL
#define PI_NAPI_EXPORT __attribute__((visibility("default")))
#endif
#define NAPI_AUTO_LENGTH ((size_t)-1)
typedef void* napi_env;
typedef void* napi_value;
typedef void* napi_callback_info;
typedef void* napi_async_work;
typedef void* napi_deferred;
typedef void (PI_NAPI_CALL *napi_async_execute_callback)(napi_env, void*);
typedef void (PI_NAPI_CALL *napi_async_complete_callback)(napi_env, int, void*);
typedef int (PI_NAPI_CALL *napi_create_async_work_fn)(napi_env, napi_value, napi_value, napi_async_execute_callback, napi_async_complete_callback, void*, napi_async_work*);
typedef int (PI_NAPI_CALL *napi_async_work_fn)(napi_env, napi_async_work);
typedef int (PI_NAPI_CALL *napi_create_promise_fn)(napi_env, napi_deferred*, napi_value*);
typedef int (PI_NAPI_CALL *napi_settle_deferred_fn)(napi_env, napi_deferred, napi_value);
typedef int (PI_NAPI_CALL *napi_create_error_fn)(napi_env, napi_value, napi_value, napi_value*);
typedef napi_value (PI_NAPI_CALL *napi_callback)(napi_env, napi_callback_info);
typedef int (PI_NAPI_CALL *napi_create_buffer_copy_fn)(napi_env, size_t, const void*, void**, napi_value*);
typedef int (PI_NAPI_CALL *napi_create_function_fn)(napi_env, const char*, size_t, napi_callback, void*, napi_value*);
typedef int (PI_NAPI_CALL *napi_create_string_utf8_fn)(napi_env, const char*, size_t, napi_value*);
typedef int (PI_NAPI_CALL *napi_create_string_utf16_fn)(napi_env, const uint16_t*, size_t, napi_value*);
typedef int (PI_NAPI_CALL *napi_get_boolean_fn)(napi_env, bool, napi_value*);
typedef int (PI_NAPI_CALL *napi_get_cb_info_fn)(napi_env, napi_callback_info, size_t*, napi_value*, napi_value*, void**);
typedef int (PI_NAPI_CALL *napi_get_value_fn)(napi_env, napi_value*);
typedef int (PI_NAPI_CALL *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*);
typedef int (PI_NAPI_CALL *napi_get_value_string_utf16_fn)(napi_env, napi_value, uint16_t*, size_t, size_t*);
typedef int (PI_NAPI_CALL *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
typedef int (PI_NAPI_CALL *napi_throw_error_fn)(napi_env, const char*, const char*);
static void* node_symbol(const char* name) {
#ifdef _WIN32
HMODULE module = GetModuleHandleA(0);
void* proc = module ? (void*)GetProcAddress(module, name) : 0;
if (proc) return proc;
module = GetModuleHandleA("node.dll");
return module ? (void*)GetProcAddress(module, name) : 0;
#else
return dlsym(RTLD_DEFAULT, name);
#endif
}
static napi_value undefined_value(napi_env env) {
napi_get_value_fn get_value = (napi_get_value_fn)node_symbol("napi_get_undefined");
napi_value result = 0;
if (get_value) get_value(env, &result);
return result;
}
static napi_value null_value(napi_env env) {
napi_get_value_fn get_value = (napi_get_value_fn)node_symbol("napi_get_null");
napi_value result = 0;
if (get_value) get_value(env, &result);
return result;
}
static napi_value fail(napi_env env, const char* message) {
napi_throw_error_fn napi_throw_error = (napi_throw_error_fn)node_symbol("napi_throw_error");
if (napi_throw_error) napi_throw_error(env, 0, message);
return undefined_value(env);
}
static void set_function_export(napi_env env, napi_value exports, const char* name, napi_callback callback) {
napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
napi_set_named_property_fn napi_set_named_property =
(napi_set_named_property_fn)node_symbol("napi_set_named_property");
napi_value fn = 0;
if (napi_create_function && napi_set_named_property &&
napi_create_function(env, name, NAPI_AUTO_LENGTH, callback, 0, &fn) == 0) {
napi_set_named_property(env, exports, name, fn);
}
}
#endif

View file

@ -1,16 +1,16 @@
# Windows native prebuilds
Build both Windows architectures from the repository root:
Provides console input setup, modifier-key state, and asynchronous text/image clipboard access. Links to `kernel32` and `user32`; no Node headers are required.
## Building
On Windows, install Visual Studio's "Desktop development with C++" workload, then run from the repository root to build x64 and arm64:
```sh
npm --prefix packages/tui run build:native:win32
```
On Windows, the build uses the Microsoft C++ Build Tools from Visual Studio. `build.mjs` locates `VsDevCmd.bat`, initializes a developer environment for `amd64` and `arm64`, and builds the addon with `cl.exe`/`link.exe`.
Install the "Desktop development with C++" workload, or at minimum the MSVC toolset and Windows SDK components. No Node headers are required; the addon resolves N-API symbols from the host process.
For non-Windows cross-builds, or for custom Windows toolchains, set `PI_TUI_WIN32_TOOLCHAIN=mingw` and provide MinGW-compatible compilers:
For cross-builds or custom toolchains, provide MinGW-compatible compilers:
```sh
PI_TUI_WIN32_TOOLCHAIN=mingw \
@ -19,4 +19,13 @@ CC_ARM64=/path/to/aarch64-w64-mingw32-gcc \
npm --prefix packages/tui run build:native:win32
```
The addon intentionally avoids the C runtime and links only against `kernel32`.
## Testing clipboard writes
On a Windows test desktop, run from `packages/tui` in PowerShell:
```powershell
$env:PI_TEST_NATIVE_CLIPBOARD = "1"
node --test test/native-platform.test.ts
```
This opt-in test checks native text writes and reads. **It replaces the system clipboard contents.**

View file

@ -5,7 +5,7 @@ import * as path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const sourceFile = path.join(scriptDir, "src", "win32-console-mode.c");
const sourceFile = path.join(scriptDir, "src", "win32-platform.c");
const temporaryDir = mkdtempSync(path.join(tmpdir(), "pi-tui-win32-"));
const targets = [
@ -121,7 +121,7 @@ function resolveMingwCompiler(target) {
function installOutput(temporaryOutput, target) {
const outputDir = path.join(scriptDir, "prebuilds", `win32-${target.arch}`);
mkdirSync(outputDir, { recursive: true });
const output = path.join(outputDir, "win32-console-mode.node");
const output = path.join(outputDir, "win32-platform.node");
copyFileSync(temporaryOutput, output);
chmodSync(output, 0o755);
console.log(`Built ${path.relative(process.cwd(), output)}`);
@ -129,7 +129,7 @@ function installOutput(temporaryOutput, target) {
function buildWithMsvc(target, vsDevCmd) {
const buildDir = path.join(temporaryDir, `msvc-${target.arch}`);
const temporaryOutput = path.join(buildDir, "win32-console-mode.node");
const temporaryOutput = path.join(buildDir, "win32-platform.node");
mkdirSync(buildDir, { recursive: true });
const batchFile = path.join(buildDir, "build.cmd");
@ -154,6 +154,7 @@ function buildWithMsvc(target, vsDevCmd) {
"/NOENTRY",
"/NODEFAULTLIB",
"kernel32.lib",
"user32.lib",
"/OPT:REF",
"/OPT:ICF",
].join(" "),
@ -174,7 +175,7 @@ function buildWithMingw(target) {
}
const buildDir = path.join(temporaryDir, `mingw-${target.arch}`);
const temporaryOutput = path.join(buildDir, "win32-console-mode.node");
const temporaryOutput = path.join(buildDir, "win32-platform.node");
mkdirSync(buildDir, { recursive: true });
const args = [
@ -183,12 +184,14 @@ function buildWithMingw(target) {
"-Wall",
"-Wextra",
"-Oz",
"-fno-builtin",
"-shared",
"-nostdlib",
"-Wl,--no-entry",
"-Wl,--strip-all",
sourceFile,
"-lkernel32",
"-luser32",
"-o",
temporaryOutput,
];

View file

@ -1,119 +0,0 @@
#include <windows.h>
#ifndef ENABLE_VIRTUAL_TERMINAL_INPUT
#define ENABLE_VIRTUAL_TERMINAL_INPUT 0x0200
#endif
#define NAPI_AUTO_LENGTH ((unsigned long long)-1)
#define KEY_PRESSED_MASK 0x8000
typedef void* napi_env;
typedef void* napi_value;
typedef void* napi_callback_info;
typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info);
typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*);
typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*);
typedef int (__cdecl *napi_get_cb_info_fn)(napi_env, napi_callback_info, unsigned long long*, napi_value*, napi_value*, void**);
typedef int (__cdecl *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, unsigned long long, unsigned long long*);
typedef SHORT (WINAPI *get_async_key_state_fn)(int);
static void* node_symbol(const char* name) {
HMODULE module = GetModuleHandleA(0);
void* proc = module ? (void*)GetProcAddress(module, name) : 0;
if (proc) return proc;
module = GetModuleHandleA("node.dll");
return module ? (void*)GetProcAddress(module, name) : 0;
}
static int string_equals(const char* left, const char* right) {
while (*left && *right && *left == *right) {
left++;
right++;
}
return *left == 0 && *right == 0;
}
static get_async_key_state_fn get_async_key_state_symbol(void) {
static int loaded = 0;
static get_async_key_state_fn get_async_key_state = 0;
if (!loaded) {
HMODULE module = GetModuleHandleA("user32.dll");
if (!module) module = LoadLibraryA("user32.dll");
get_async_key_state = module ? (get_async_key_state_fn)GetProcAddress(module, "GetAsyncKeyState") : 0;
loaded = 1;
}
return get_async_key_state;
}
static int is_key_pressed(int virtual_key) {
get_async_key_state_fn get_async_key_state = get_async_key_state_symbol();
return get_async_key_state && (((unsigned short)get_async_key_state(virtual_key)) & KEY_PRESSED_MASK) != 0;
}
static int is_modifier_name_pressed(const char* name) {
if (string_equals(name, "shift")) return is_key_pressed(VK_SHIFT) || is_key_pressed(VK_LSHIFT) || is_key_pressed(VK_RSHIFT);
if (string_equals(name, "control")) return is_key_pressed(VK_CONTROL) || is_key_pressed(VK_LCONTROL) || is_key_pressed(VK_RCONTROL);
if (string_equals(name, "option") || string_equals(name, "alt")) return is_key_pressed(VK_MENU) || is_key_pressed(VK_LMENU) || is_key_pressed(VK_RMENU);
if (string_equals(name, "command") || string_equals(name, "super") || string_equals(name, "win")) return is_key_pressed(VK_LWIN) || is_key_pressed(VK_RWIN);
return 0;
}
static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) {
(void)info;
HANDLE handle = GetStdHandle(STD_INPUT_HANDLE);
DWORD mode = 0;
int enabled = handle != INVALID_HANDLE_VALUE &&
GetConsoleMode(handle, &mode) &&
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT);
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
napi_value result = 0;
if (napi_get_boolean) napi_get_boolean(env, enabled, &result);
return result;
}
static napi_value __cdecl is_modifier_pressed(napi_env env, napi_callback_info info) {
napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
int pressed = 0;
if (napi_get_cb_info && napi_get_value_string_utf8) {
unsigned long long argc = 1;
napi_value args[1] = {0};
if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
char name[16] = {0};
unsigned long long copied = 0;
if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
pressed = is_modifier_name_pressed(name);
}
}
}
napi_value result = 0;
if (napi_get_boolean) napi_get_boolean(env, pressed, &result);
return result;
}
static void set_function_export(napi_env env, napi_value exports, const char* name, napi_callback callback) {
napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function");
napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property");
napi_value fn = 0;
if (napi_create_function &&
napi_set_named_property &&
napi_create_function(env, name, NAPI_AUTO_LENGTH, callback, 0, &fn) == 0) {
napi_set_named_property(env, exports, name, fn);
}
}
__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "enableVirtualTerminalInput", enable_virtual_terminal_input);
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
return exports;
}

View file

@ -0,0 +1,241 @@
#define PI_CLIPBOARD_WRITE
#include "../../clipboard.h"
#ifndef BI_ALPHABITFIELDS
#define BI_ALPHABITFIELDS 6
#endif
#define KEY_PRESSED_MASK 0x8000
#define OPEN_CLIPBOARD_ATTEMPTS 10
#define OPEN_CLIPBOARD_RETRY_MS 5
#define BITMAP_FILE_HEADER_SIZE 14
static int string_equals(const char* left, const char* right) {
while (*left && *right && *left == *right) {
left++;
right++;
}
return *left == 0 && *right == 0;
}
static int is_key_pressed(int virtual_key) {
return ((unsigned short)GetAsyncKeyState(virtual_key) & KEY_PRESSED_MASK) != 0;
}
static int is_modifier_name_pressed(const char* name) {
if (string_equals(name, "shift")) return is_key_pressed(VK_SHIFT) || is_key_pressed(VK_LSHIFT) || is_key_pressed(VK_RSHIFT);
if (string_equals(name, "control")) return is_key_pressed(VK_CONTROL) || is_key_pressed(VK_LCONTROL) || is_key_pressed(VK_RCONTROL);
if (string_equals(name, "option") || string_equals(name, "alt")) return is_key_pressed(VK_MENU) || is_key_pressed(VK_LMENU) || is_key_pressed(VK_RMENU);
if (string_equals(name, "command") || string_equals(name, "super") || string_equals(name, "win")) return is_key_pressed(VK_LWIN) || is_key_pressed(VK_RWIN);
return 0;
}
static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) {
(void)info;
HANDLE handle = GetStdHandle(STD_INPUT_HANDLE);
DWORD mode = 0;
bool enabled = handle != INVALID_HANDLE_VALUE &&
GetConsoleMode(handle, &mode) &&
SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_INPUT);
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
napi_value result = 0;
if (!napi_get_boolean || napi_get_boolean(env, enabled, &result) != 0) {
return fail(env, "Could not configure console input");
}
return result;
}
static napi_value __cdecl is_modifier_pressed(napi_env env, napi_callback_info info) {
napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info");
napi_get_value_string_utf8_fn napi_get_value_string_utf8 =
(napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8");
napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean");
bool pressed = false;
if (napi_get_cb_info && napi_get_value_string_utf8) {
size_t argc = 1;
napi_value args[1] = {0};
if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 1 && args[0]) {
char name[16] = {0};
size_t copied = 0;
if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) {
pressed = is_modifier_name_pressed(name);
}
}
}
napi_value result = 0;
if (!napi_get_boolean || napi_get_boolean(env, pressed, &result) != 0) {
return fail(env, "Could not inspect modifier state");
}
return result;
}
static bool open_clipboard(HWND owner) {
for (int attempt = 0; attempt < OPEN_CLIPBOARD_ATTEMPTS; attempt++) {
if (OpenClipboard(owner)) return true;
Sleep(OPEN_CLIPBOARD_RETRY_MS);
}
return false;
}
static uint16_t read_u16(const uint8_t* data) {
return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}
static uint32_t read_u32(const uint8_t* data) {
return (uint32_t)data[0] |
((uint32_t)data[1] << 8) |
((uint32_t)data[2] << 16) |
((uint32_t)data[3] << 24);
}
static void write_u32(uint8_t* data, uint32_t value) {
data[0] = (uint8_t)value;
data[1] = (uint8_t)(value >> 8);
data[2] = (uint8_t)(value >> 16);
data[3] = (uint8_t)(value >> 24);
}
static void copy_bytes(uint8_t* destination, const uint8_t* source, size_t length) {
volatile uint8_t* target = destination;
const volatile uint8_t* input = source;
for (size_t index = 0; index < length; index++) target[index] = input[index];
}
static size_t dib_pixel_offset(const uint8_t* dib, size_t size) {
if (size < 12) return 0;
uint32_t header_size = read_u32(dib);
if (header_size == 12) {
uint16_t bits_per_pixel = read_u16(dib + 10);
uint32_t color_count = bits_per_pixel <= 8 ? 1u << bits_per_pixel : 0;
size_t offset = header_size + (size_t)color_count * 3;
return offset <= size ? offset : 0;
}
if (header_size < 40 || header_size > size) return 0;
uint16_t bits_per_pixel = read_u16(dib + 14);
uint32_t compression = read_u32(dib + 16);
uint32_t color_count = read_u32(dib + 32);
if (!color_count && bits_per_pixel <= 8) color_count = 1u << bits_per_pixel;
size_t offset = header_size + (size_t)color_count * 4;
if (header_size == 40 && compression == BI_BITFIELDS) offset += 3 * sizeof(uint32_t);
if (header_size == 40 && compression == BI_ALPHABITFIELDS) offset += 4 * sizeof(uint32_t);
return offset <= size ? offset : 0;
}
static void read_clipboard_image(clipboard_job* job) {
UINT png_format = RegisterClipboardFormatW(L"PNG");
if (!(png_format && IsClipboardFormatAvailable(png_format)) &&
!IsClipboardFormatAvailable(CF_DIBV5) && !IsClipboardFormatAvailable(CF_DIB)) {
job->format = CLIPBOARD_EMPTY;
return;
}
HGLOBAL handle = png_format && IsClipboardFormatAvailable(png_format)
? (HGLOBAL)GetClipboardData(png_format) : 0;
bool is_png = handle != 0;
if (!handle && IsClipboardFormatAvailable(CF_DIBV5)) handle = (HGLOBAL)GetClipboardData(CF_DIBV5);
if (!handle && IsClipboardFormatAvailable(CF_DIB)) handle = (HGLOBAL)GetClipboardData(CF_DIB);
const uint8_t* source = handle ? (const uint8_t*)GlobalLock(handle) : 0;
size_t size = handle ? GlobalSize(handle) : 0;
if (!source || !size) {
if (source) GlobalUnlock(handle);
job->error = "Clipboard does not contain an image";
return;
}
if (is_png) {
clipboard_copy(job, source, size, CLIPBOARD_BUFFER);
} else {
size_t offset = dib_pixel_offset(source, size);
uint8_t* bitmap = offset && size <= UINT32_MAX - BITMAP_FILE_HEADER_SIZE
? clipboard_alloc(size + BITMAP_FILE_HEADER_SIZE) : 0;
if (bitmap) {
bitmap[0] = 'B';
bitmap[1] = 'M';
write_u32(bitmap + 2, (uint32_t)(size + BITMAP_FILE_HEADER_SIZE));
write_u32(bitmap + 10, (uint32_t)(BITMAP_FILE_HEADER_SIZE + offset));
copy_bytes(bitmap + BITMAP_FILE_HEADER_SIZE, source, size);
job->data = bitmap;
job->length = size + BITMAP_FILE_HEADER_SIZE;
job->format = CLIPBOARD_BUFFER;
} else {
job->error = "Could not create clipboard image buffer";
}
}
GlobalUnlock(handle);
}
static void clipboard_execute(clipboard_job* job) {
// A real clipboard owner is required for text writes, including under Node
// without a console. Create and destroy it on the same worker thread.
HWND owner = 0;
if (job->operation == CLIPBOARD_WRITE) {
owner = CreateWindowExW(0, L"STATIC", 0, 0, 0, 0, 0, 0,
HWND_MESSAGE, 0, GetModuleHandleA(0), 0);
if (!owner) {
job->error = "Could not create clipboard owner window";
return;
}
}
if (!open_clipboard(owner)) {
if (owner) DestroyWindow(owner);
job->error = "Could not open clipboard";
return;
}
if (job->operation == CLIPBOARD_WRITE) {
size_t size = (job->length + 1) * sizeof(uint16_t);
HGLOBAL handle = GlobalAlloc(GMEM_MOVEABLE, size);
void* text = handle ? GlobalLock(handle) : 0;
bool success = false;
if (text) {
copy_bytes(text, job->data, size);
GlobalUnlock(handle);
success = EmptyClipboard() && SetClipboardData(CF_UNICODETEXT, handle) != 0;
}
if (!success) {
if (handle) GlobalFree(handle);
job->error = "Could not set clipboard text";
}
} else if (job->operation == CLIPBOARD_TEXT) {
if (!IsClipboardFormatAvailable(CF_UNICODETEXT)) {
job->format = CLIPBOARD_EMPTY;
} else {
HGLOBAL handle = (HGLOBAL)GetClipboardData(CF_UNICODETEXT);
const uint16_t* text = handle ? GlobalLock(handle) : 0;
if (text) {
size_t capacity = GlobalSize(handle) / sizeof(uint16_t);
size_t length = 0;
while (length < capacity && text[length]) length++;
clipboard_copy(job, text, length * sizeof(uint16_t), CLIPBOARD_UTF16);
GlobalUnlock(handle);
} else {
job->error = "Clipboard does not contain text";
}
}
} else {
read_clipboard_image(job);
}
CloseClipboard();
if (owner) DestroyWindow(owner);
}
BOOL WINAPI _DllMainCRTStartup(HINSTANCE instance, DWORD reason, LPVOID reserved) {
(void)instance;
(void)reason;
(void)reserved;
return TRUE;
}
PI_NAPI_EXPORT napi_value PI_NAPI_CALL napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "enableVirtualTerminalInput", enable_virtual_terminal_input);
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
set_function_export(env, exports, "getText", get_clipboard_text);
set_function_export(env, exports, "setText", set_clipboard_text);
set_function_export(env, exports, "getImage", get_clipboard_image);
return exports;
}

View file

@ -8,20 +8,27 @@
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"build:native:darwin": "native/darwin/build.sh",
"build:native:linux": "native/linux/build.sh",
"build:native:win32": "node native/win32/build.mjs",
"test": "node --test --test-reporter=dot --test-reporter-destination=stdout test/*.test.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"files": [
"dist/**/*",
"native/napi.h",
"native/clipboard.h",
"native/win32/prebuilds/**/*.node",
"native/win32/src/**/*.c",
"native/win32/build.mjs",
"native/win32/README.md",
"native/darwin/prebuilds/**/*.node",
"native/darwin/src/**/*.c",
"native/darwin/src/**/*.m",
"native/darwin/build.sh",
"native/darwin/README.md",
"native/linux/prebuilds/**/*.node",
"native/linux/src/**/*.{c,h}",
"native/linux/build.sh",
"native/linux/README.md",
"README.md"
],
"keywords": [

View file

@ -75,6 +75,8 @@ export {
} from "./keys.ts";
// LaTeX rendering
export { type RenderLatexOptions, renderLatex } from "./latex.ts";
// Native platform integration
export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts";
// Input buffering for batch splitting
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
// Terminal interface and implementations

View file

@ -1,56 +1,10 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { getNativeModuleCandidates } from "./native-module-path.ts";
import { getNativePlatformHelper, type ModifierKey } from "./native-platform.ts";
const cjsRequire = createRequire(import.meta.url);
export type ModifierKey = "shift" | "command" | "control" | "option";
type NativeModifiersHelper = {
isModifierPressed: (name: ModifierKey) => boolean;
};
let nativeModifiersHelper: NativeModifiersHelper | null | undefined;
function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper {
if (typeof value !== "object" || value === null) return false;
const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed;
return typeof candidate === "function";
}
function loadNativeModifiersHelper(): NativeModifiersHelper | undefined {
if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined;
nativeModifiersHelper = null;
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") return undefined;
let nativePath: string;
if (process.platform === "darwin") {
nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node");
} else if (process.platform === "win32") {
nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
} else {
return undefined;
}
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as unknown;
if (isNativeModifiersHelper(helper)) {
nativeModifiersHelper = helper;
return helper;
}
} catch {
// Try the next possible packaging location.
}
}
return undefined;
}
export type { ModifierKey } from "./native-platform.ts";
export function isNativeModifierPressed(key: ModifierKey): boolean {
const helper = loadNativeModifiersHelper();
if (!helper) return false;
const helper = getNativePlatformHelper();
if (!helper?.isModifierPressed) return false;
try {
return helper.isModifierPressed(key) === true;
} catch {

View file

@ -0,0 +1,63 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { getNativeModuleCandidates } from "./native-module-path.ts";
const cjsRequire = createRequire(import.meta.url);
export type ModifierKey = "shift" | "command" | "control" | "option";
export interface NativeClipboard {
/** Undefined means unavailable, null means no text; transfer failures reject. */
getText(): Promise<string | null | undefined>;
/** Undefined means unavailable, null means no image; transfer failures reject. */
getImage(): Promise<Uint8Array | null | undefined>;
/** Linux uses command-line tools to retain clipboard ownership instead. */
setText?(text: string): Promise<void>;
}
type NativePlatformHelper = NativeClipboard & {
enableVirtualTerminalInput?: () => boolean;
isModifierPressed?: (name: ModifierKey) => boolean;
};
// Cache module loading, not display availability: a disconnected display can recover.
const helpers = new Map<string, NativePlatformHelper | undefined>();
function loadNativePlatformHelper(platform: string, suffix = ""): NativePlatformHelper | undefined {
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") return undefined;
const nativePath = path.join(
"native",
platform,
"prebuilds",
`${platform}-${arch}`,
`${platform}-platform${suffix}.node`,
);
if (helpers.has(nativePath)) return helpers.get(nativePath);
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as Partial<NativePlatformHelper> | null;
if (typeof helper?.getText === "function" && typeof helper.getImage === "function") {
helpers.set(nativePath, helper as NativePlatformHelper);
return helper as NativePlatformHelper;
}
} catch {
// Try the next possible packaging location.
}
}
helpers.set(nativePath, undefined);
return undefined;
}
export function getNativePlatformHelper(): NativePlatformHelper | undefined {
if (process.platform !== "darwin" && process.platform !== "win32") return undefined;
return loadNativePlatformHelper(process.platform);
}
/** Load a clipboard helper without opening the display until a read is requested. */
export function getNativeClipboard(): NativeClipboard | undefined {
if (process.platform !== "linux") return getNativePlatformHelper();
if (!process.env.DISPLAY) return undefined;
return loadNativePlatformHelper("linux", "-x11");
}

View file

@ -1,13 +1,10 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { setKittyProtocolActive } from "./keys.ts";
import { isNativeModifierPressed } from "./native-modifiers.ts";
import { getNativeModuleCandidates } from "./native-module-path.ts";
import { getNativePlatformHelper } from "./native-platform.ts";
import { StdinBuffer } from "./stdin-buffer.ts";
const cjsRequire = createRequire(import.meta.url);
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07";
@ -378,22 +375,7 @@ export class ProcessTerminal implements Terminal {
private enableWindowsVTInput(): void {
if (process.platform !== "win32") return;
try {
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") return;
// Dynamic require so non-Windows and bundled/browser paths never load the
// native helper. Installed packages resolve it from pi-tui; standalone
// binaries resolve the copy next to the executable.
const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean };
helper.enableVirtualTerminalInput?.();
return;
} catch {
// Try the next possible packaging location.
}
}
getNativePlatformHelper()?.enableVirtualTerminalInput?.();
} catch {
// Native helper not available — Shift+Tab won't be distinguishable from Tab.
}

View file

@ -0,0 +1,31 @@
const { createHash } = require("node:crypto");
const assert = require("node:assert/strict");
const { readFileSync } = require("node:fs");
(async () => {
let ticks = 0;
const timer = setInterval(() => ticks++, 10);
const started = performance.now();
try {
const helper = require(process.argv[2]);
const pending = helper[process.argv[3]]();
assert.ok(pending instanceof Promise, "Native reads must return promises");
const value = await pending;
const bytes = typeof value === "string" || Buffer.isBuffer(value) ? Buffer.from(value) : undefined;
console.log(JSON.stringify({
ok: true,
value: value === null || (typeof value === "string" && value.length <= 100) ? value : undefined,
unavailable: value === undefined || undefined,
length: bytes?.length,
hash: bytes && createHash("sha256").update(bytes).digest("hex"),
}));
} catch (error) {
console.log(JSON.stringify({ ok: false, error: String(error) }));
} finally {
clearInterval(timer);
if (performance.now() - started > 200) assert.ok(ticks > 5, "Clipboard I/O must not block the JS event loop");
// Check before Node exits: process exit must not hide a leaked child or zombie.
assert.equal(readFileSync(`/proc/self/task/${process.pid}/children`, "utf8").trim(), "");
}
})().catch((error) => { console.error(error); process.exitCode = 1; });

View file

@ -0,0 +1,64 @@
#include <assert.h>
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
static atomic_int allocations, reads;
static atomic_bool released;
static void* tracked_calloc(size_t count, size_t size) {
void* pointer = calloc(count, size);
if (pointer) atomic_fetch_add(&allocations, 1);
return pointer;
}
static void tracked_free(void* pointer) {
if (pointer) atomic_fetch_sub(&allocations, 1);
free(pointer);
}
#define calloc tracked_calloc
#define free tracked_free
#include "../../native/linux/src/clipboard-worker.h"
#undef calloc
#undef free
// Exercise the production executor with a controlled blocking callback, without X11.
static void read_clipboard(clipboard_job* job) {
job->data = tracked_calloc(5, 1);
assert(job->data);
memcpy(job->data, "text", 4);
job->length = 4;
job->format = job->operation == CLIPBOARD_IMAGE ? CLIPBOARD_BUFFER : CLIPBOARD_UTF8;
atomic_fetch_add(&reads, 1);
while (!atomic_load(&released)) {
struct timespec delay = {.tv_nsec = 1000000};
nanosleep(&delay, 0);
}
}
static napi_value release_reader(napi_env env, napi_callback_info info) {
(void)info;
atomic_store(&released, true);
return undefined_value(env);
}
static napi_value get_state(napi_env env, napi_callback_info info) {
(void)info;
char state[80];
pthread_mutex_lock(&worker_mutex);
snprintf(state, sizeof(state), "%d,%d,%d", atomic_load(&reads), atomic_load(&allocations), worker_busy);
pthread_mutex_unlock(&worker_mutex);
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
napi_value result = 0;
create_string(env, state, NAPI_AUTO_LENGTH, &result);
return result;
}
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "getText", get_clipboard_text);
set_function_export(env, exports, "getImage", get_clipboard_image);
set_function_export(env, exports, "release", release_reader);
set_function_export(env, exports, "state", get_state);
return exports;
}

View file

@ -0,0 +1,66 @@
const assert = require("node:assert/strict");
const { once } = require("node:events");
const { readFile } = require("node:fs/promises");
const { setTimeout: delay } = require("node:timers/promises");
const { isMainThread, parentPort, Worker, workerData } = require("node:worker_threads");
async function waitFor(predicate) {
const deadline = performance.now() + 5000;
while (!predicate()) {
assert.ok(performance.now() < deadline, "Timed out waiting for the private reader");
await delay(10);
}
}
(async () => {
if (!isMainThread) {
const helper = require(workerData);
void helper.getText();
await waitFor(() => helper.state().startsWith("1,"));
parentPort.postMessage("reading");
return;
}
const [addon, mode] = process.argv.slice(2);
if (mode === "worker-exit") {
// The originating environment unloads while its private callback still waits.
const worker = new Worker(__filename, { workerData: addon });
await once(worker, "message");
await worker.terminate();
const helper = require(addon);
assert.equal(helper.state(), "1,1,1", "The detached operation survives environment teardown");
helper.release();
await waitFor(() => helper.state() === "1,0,0");
assert.equal(await helper.getText(), "text");
console.log("passed");
return;
}
const helper = require(addon);
const started = performance.now();
const pending = helper.getText();
await waitFor(() => helper.state().startsWith("1,"));
if (mode === "process-exit") {
assert.equal(await pending, undefined);
console.log("passed");
return; // The private callback never finishes; Node must still exit naturally.
}
const concurrentStarted = performance.now();
const concurrent = Array.from({ length: 20 }, (_, index) => helper[index % 2 ? "getText" : "getImage"]());
// With UV_THREADPOOL_SIZE=2, a second blocked native task would delay this read.
await readFile(__filename);
assert.deepEqual(await Promise.all(concurrent), Array(20).fill(undefined));
assert.ok(performance.now() - concurrentStarted < 1500, "Concurrent reads and filesystem I/O must remain available");
assert.equal(await pending, undefined);
assert.ok(performance.now() - started < 4500, "The shared worker must leave its bounded wait");
assert.equal(helper.state(), "1,1,1", "Only one private reader may remain blocked");
assert.equal(await helper.getImage(), undefined);
assert.equal(helper.state(), "1,1,1", "Another request must not start another private reader");
helper.release();
await waitFor(() => helper.state() === "1,0,0");
assert.equal(await helper.getText(), "text");
assert.deepEqual(await helper.getImage(), Buffer.from("text"));
assert.equal(helper.state(), "3,0,0", "Late and successful results must all be freed");
console.log("passed");
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View file

@ -0,0 +1,177 @@
#include <xcb/xcb.h>
#include <assert.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Track only allocations made by the clipboard implementation, not libxcb.
// This makes the partial-transfer leak regression deterministic, unlike RSS.
static void* allocations[64];
static size_t sizes[64];
static size_t allocated_bytes;
static size_t peak_bytes;
static void* tracked_realloc(void* pointer, size_t size) {
size_t index = 0;
while (index < 64 && allocations[index] != pointer) index++;
assert(index < 64);
void* result = realloc(pointer, size);
if (result) {
allocated_bytes = allocated_bytes - sizes[index] + size;
sizes[index] = size;
allocations[index] = result;
if (allocated_bytes > peak_bytes) peak_bytes = allocated_bytes;
}
return result;
}
static void tracked_free(void* pointer) {
for (size_t index = 0; pointer && index < 64; index++) {
if (allocations[index] == pointer) {
allocated_bytes -= sizes[index];
sizes[index] = 0;
allocations[index] = 0;
break;
}
}
free(pointer);
}
#define realloc tracked_realloc
#define free tracked_free
#include "../../native/linux/src/linux-platform-x11.c"
#undef realloc
#undef free
int main(int argc, char** argv) {
if (argc != 2) return 1;
if (strcmp(argv[1], "metadata") == 0) {
xcb_atom_t atom = XCB_ATOM_STRING;
property_data chunk = {
.data = (unsigned char*)&atom, .length = sizeof(atom),
.items = 1, .format = 32, .type = XCB_ATOM_ATOM,
};
property_data result = {0};
assert(append_property(&result, &chunk));
assert(append_property(&result, &chunk));
assert(result.length == 2 * sizeof(atom) && result.items == 2);
chunk.format = 8;
chunk.items = sizeof(atom);
assert(!append_property(&result, &chunk));
chunk.format = 32;
chunk.items = 1;
chunk.type = XCB_ATOM_STRING;
assert(!append_property(&result, &chunk));
chunk.type = XCB_ATOM_ATOM;
chunk.length--;
assert(!append_property(&result, &chunk));
assert(result.length == 2 * sizeof(atom) && result.items == 2);
// The terminating chunk must agree with the preceding metadata too.
chunk.length = chunk.items = 0;
chunk.format = 8;
assert(!append_property(&result, &chunk));
chunk.format = 32;
assert(append_property(&result, &chunk));
tracked_free(result.data);
assert(allocated_bytes == 0);
puts("validated");
return 0;
}
if (strcmp(argv[1], "text") == 0 || strcmp(argv[1], "image") == 0) {
x11_clipboard clipboard;
assert(open_clipboard(&clipboard));
property_data result = {0};
bool received = read_selection(&clipboard, strcmp(argv[1], "image") == 0, &result);
close_clipboard(&clipboard);
assert(!received);
assert(!result.data && !result.length);
assert(peak_bytes >= 8192); // Both the received chunk and accumulated data were allocated.
assert(allocated_bytes == 0);
puts("clean");
return 0;
}
x11_clipboard clipboard;
assert(open_clipboard(&clipboard));
xcb_connection_t* connection = clipboard.connection;
xcb_atom_t text = intern_atom(&clipboard, "UTF8_STRING");
xcb_atom_t image = intern_atom(&clipboard, "image/png");
xcb_set_selection_owner(connection, clipboard.window, clipboard.clipboard, XCB_CURRENT_TIME);
// Wait for the server to process selection ownership before reporting ready.
assert(intern_atom(&clipboard, "READY") != XCB_NONE);
puts("ready");
fflush(stdout);
xcb_window_t requestor = XCB_NONE;
xcb_atom_t property = XCB_NONE;
xcb_atom_t target = XCB_NONE;
bool sent_chunk = false;
xcb_generic_event_t* event;
while ((event = xcb_wait_for_event(connection))) {
uint8_t type = event->response_type & 0x7f;
if (type == XCB_SELECTION_REQUEST) {
xcb_selection_request_event_t* request = (xcb_selection_request_event_t*)event;
puts("requested");
fflush(stdout);
if (strcmp(argv[1], "idle") == 0) {
free(event);
continue;
}
xcb_atom_t response_property = request->property;
if (strcmp(argv[1], "direct-text") == 0) {
if (request->target == text) {
const char value[] = "café 日本語";
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, request->requestor,
request->property, text, 8, sizeof(value) - 1, value);
} else {
response_property = XCB_NONE;
}
} else if (request->target == clipboard.targets) {
xcb_atom_t targets[] = {text, image};
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, request->requestor,
request->property, XCB_ATOM_ATOM, 32, 2, targets);
} else {
requestor = request->requestor;
property = request->property;
target = request->target;
uint32_t mask = XCB_EVENT_MASK_PROPERTY_CHANGE;
xcb_change_window_attributes(connection, requestor, XCB_CW_EVENT_MASK, &mask);
uint32_t length = 8192;
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, requestor,
property, clipboard.incr, 32, 1, &length);
}
union {
xcb_selection_notify_event_t selection;
char bytes[32];
} notification = { .selection = {
.response_type = XCB_SELECTION_NOTIFY,
.time = request->time,
.requestor = request->requestor,
.selection = request->selection,
.target = request->target,
.property = response_property,
} };
xcb_send_event(connection, false, request->requestor, XCB_EVENT_MASK_NO_EVENT, notification.bytes);
xcb_flush(connection);
} else if (type == XCB_PROPERTY_NOTIFY) {
xcb_property_notify_event_t* notification = (xcb_property_notify_event_t*)event;
if (notification->window == requestor && notification->atom == property &&
notification->state == XCB_PROPERTY_DELETE) {
if (!sent_chunk) {
unsigned char chunk[4096] = {0};
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, requestor, property, target, 8, sizeof(chunk), chunk);
sent_chunk = true;
} else if (strcmp(argv[1], "invalid") == 0) {
// Reject the terminating chunk after allocating the preceding data.
xcb_change_property(connection, XCB_PROP_MODE_REPLACE, requestor, property, XCB_ATOM_ATOM, 32, 0, 0);
} // Otherwise never finish the transfer; the reader must time out.
xcb_flush(connection);
}
}
free(event);
}
close_clipboard(&clipboard);
}

View file

@ -0,0 +1,247 @@
import assert from "node:assert/strict";
import { type ChildProcessWithoutNullStreams, execFile, execFileSync, spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { createServer, type Socket } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const exec = promisify(execFile);
const fixtures = fileURLToPath(new URL("./fixtures/", import.meta.url));
const native = fileURLToPath(new URL("../native/linux/", import.meta.url));
const supported = process.platform === "linux" && ["arm64", "x64"].includes(process.arch);
const dependenciesAvailable =
supported &&
spawnSync("sh", ["-c", "command -v cc && command -v Xvfb && command -v xclip && pkg-config --exists xcb"], {
stdio: "ignore",
}).status === 0;
interface ReaderResult {
ok: boolean;
value?: string | null;
unavailable?: boolean;
length?: number;
hash?: string;
error?: string;
}
async function startServer(
t: TestContext,
command: string,
args: string[],
env: NodeJS.ProcessEnv,
): Promise<{ child: ChildProcessWithoutNullStreams; ready: string }> {
const child = spawn(command, args, { env, stdio: "pipe" });
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill("SIGKILL");
await exited;
}
});
let stderr = "";
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
const ready = await new Promise<string>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code) => reject(new Error(`${command} exited with ${code}: ${stderr}`)));
child.stdout.once("data", (data: Buffer) => resolve(data.toString().trim()));
});
return { child, ready };
}
async function readClipboard(method: string, env: NodeJS.ProcessEnv): Promise<ReaderResult> {
const { stdout } = await exec(
process.execPath,
[
join(fixtures, "clipboard-reader.cjs"),
join(native, "prebuilds", `linux-${process.arch}`, "linux-platform-x11.node"),
method,
],
{ env, timeout: 6000 },
);
return JSON.parse(stdout) as ReaderResult;
}
describe("native Linux clipboard", { skip: !dependenciesAvailable, timeout: 60000 }, () => {
let directory: string;
async function startX11(t: TestContext) {
const { child, ready } = await startServer(
t,
"Xvfb",
["-displayfd", "1", "-screen", "0", "640x480x24", "-nolisten", "tcp"],
process.env,
);
return { child, env: { ...process.env, DISPLAY: `:${ready}` } };
}
before(() => {
directory = mkdtempSync(join(tmpdir(), "pi-clipboard-test-"));
const flags = ["-std=c11", "-D_POSIX_C_SOURCE=200809L", "-Wall", "-Wextra", "-Werror", "-pthread"];
execFileSync("cc", [
...flags,
join(fixtures, "clipboard-x11-test.c"),
"-lxcb",
"-ldl",
"-o",
join(directory, "x11-test"),
]);
execFileSync("cc", [
...flags,
"-shared",
"-fPIC",
"-nostdlib", // Match the prebuild's unversioned system-library imports.
...(process.arch === "arm64" ? ["-mno-outline-atomics"] : []), // Inline the fixture's allocation counters.
"-Wl,-z,nodelete",
join(fixtures, "clipboard-worker-test.c"),
"-ldl",
"-o",
join(directory, "worker-test.node"),
]);
});
after(() => {
if (directory) rmSync(directory, { recursive: true, force: true });
});
it("validates X11 incremental property metadata before accumulating bytes", () => {
assert.equal(execFileSync(join(directory, "x11-test"), ["metadata"], { encoding: "utf8" }).trim(), "validated");
});
for (const mode of ["concurrent", "process-exit", "worker-exit"]) {
it(`bounds private clipboard work and preserves cleanup: ${mode}`, async () => {
const { stdout } = await exec(
process.execPath,
[join(fixtures, "clipboard-worker-test.cjs"), join(directory, "worker-test.node"), mode],
{ env: { ...process.env, UV_THREADPOOL_SIZE: "2" }, timeout: 10000 },
);
assert.equal(stdout.trim(), "passed");
});
}
for (const method of ["getText", "getImage"]) {
it(`keeps JS responsive during stalled X11 connection setup: ${method}`, async (t) => {
const sockets = new Set<Socket>();
let connections = 0;
const server = createServer((socket) => {
connections++;
sockets.add(socket);
socket.on("data", () => {}); // Accept setup bytes without replying.
// libxcb owns setup blocking; release it after checking the JS timer.
const timer = setTimeout(() => socket.destroy(), 300);
t.after(() => clearTimeout(timer));
socket.on("close", () => sockets.delete(socket));
});
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string" && address.port > 6000);
const env = { ...process.env, DISPLAY: `127.0.0.1:${address.port - 6000}` };
const started = performance.now();
const result = await readClipboard(method, env);
assert.ok(performance.now() - started < 3500, "The worker must return after the connection closes");
assert.deepEqual(result, { ok: true, unavailable: true });
assert.ok(connections > 0, "The helper must reach the stalled X11 server");
assert.equal(sockets.size, 0, "The failed connection must be closed");
});
}
it("decodes X11 STRING as Latin-1", async (t) => {
const { env } = await startX11(t);
execFileSync("xclip", ["-selection", "clipboard", "-in", "-t", "STRING"], {
env,
input: Buffer.from("café £ÿ", "latin1"),
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
const result = await readClipboard("getText", env);
assert.equal(result.ok, true, result.error);
assert.equal(result.value, "café £ÿ");
});
it("reads UTF8_STRING when X11 TARGETS is refused", async (t) => {
const { env } = await startX11(t);
await startServer(t, join(directory, "x11-test"), ["direct-text"], env);
const result = await readClipboard("getText", env);
assert.equal(result.ok, true, result.error);
assert.equal(result.value, "café 日本語");
});
it("returns null for empty X11 clipboards and text-only selections", async (t) => {
const { env } = await startX11(t);
assert.deepEqual(await readClipboard("getText", env), { ok: true, value: null });
assert.deepEqual(await readClipboard("getImage", env), { ok: true, value: null });
execFileSync("xclip", ["-selection", "clipboard", "-in", "-t", "UTF8_STRING"], {
env,
input: "text only",
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
assert.deepEqual(await readClipboard("getImage", env), { ok: true, value: null });
});
it("preserves X11 Unicode and incremental text/image transfers", async (t) => {
const { env } = await startX11(t);
for (const [target, method, bytes] of [
["UTF8_STRING", "getText", Buffer.from("café 日本語")],
["UTF8_STRING", "getText", Buffer.alloc(4 * 1024 * 1024, 120)],
["image/png", "getImage", Buffer.alloc(4 * 1024 * 1024, 123)],
] as const) {
execFileSync("xclip", ["-selection", "clipboard", "-in", "-t", target], {
env,
input: bytes,
stdio: ["pipe", "ignore", "ignore"],
timeout: 5000,
});
const result = await readClipboard(method, env);
assert.equal(result.ok, true, result.error);
assert.equal(result.length, bytes.length);
assert.equal(result.hash, createHash("sha256").update(bytes).digest("hex"));
}
});
for (const mode of ["disconnect", "timeout"]) {
it(`reports X11 transfer ${mode} as an exception, not an unavailable display`, async (t) => {
const { child, env } = await startX11(t);
const owner = await startServer(t, join(directory, "x11-test"), ["idle"], env);
const requested = once(owner.child.stdout, "data");
const started = performance.now();
const result = readClipboard("getText", env);
await requested;
if (mode === "disconnect") child.kill("SIGKILL");
const failure = await result;
assert.equal(failure.ok, false);
assert.match(failure.error ?? "", /X11 clipboard/);
assert.ok(performance.now() - started < 3500, "The transfer must share the 2-second deadline");
});
}
for (const content of ["text", "image"]) {
it(`frees partially received X11 ${content} after invalid metadata`, async (t) => {
const { env } = await startX11(t);
await startServer(t, join(directory, "x11-test"), ["invalid"], env);
const { stdout } = await exec(join(directory, "x11-test"), [content], { env, timeout: 6000 });
assert.equal(stdout.trim(), "clean");
});
it(`times out stalled X11 ${content} after a partial transfer without blocking JS`, async (t) => {
const { env } = await startX11(t);
await startServer(t, join(directory, "x11-test"), ["partial"], env);
const started = performance.now();
const result = await readClipboard(content === "text" ? "getText" : "getImage", env);
assert.equal(result.ok, false);
assert.match(result.error ?? "", /X11 clipboard/);
assert.ok(performance.now() - started < 3500);
});
}
});

View file

@ -8,7 +8,7 @@ describe("getNativeModuleCandidates", () => {
it("resolves native helpers from the installed TUI package when the module is bundled elsewhere", () => {
const packageRoot = resolve("virtual", "node_modules", "@earendil-works", "pi-tui");
const bundledModule = resolve("virtual", "pi-coding-agent", "dist", "bundle", "chunks", "chunk.js");
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-console-mode.node");
const nativePath = join("native", "win32", "prebuilds", "win32-arm64", "win32-platform.node");
const candidates = getNativeModuleCandidates(nativePath, {
moduleUrl: pathToFileURL(bundledModule).href,
@ -26,7 +26,7 @@ describe("getNativeModuleCandidates", () => {
it("keeps standalone binary fallbacks when the TUI package is unavailable", () => {
const bundledModule = resolve("virtual", "pi", "bundle", "chunks", "chunk.js");
const execPath = resolve("virtual", "pi", "pi.exe");
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-modifiers.node");
const nativePath = join("native", "darwin", "prebuilds", "darwin-arm64", "darwin-platform.node");
const candidates = getNativeModuleCandidates(nativePath, {
moduleUrl: pathToFileURL(bundledModule).href,

View file

@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire, Module } from "node:module";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import { getNativeClipboard, getNativePlatformHelper } from "../src/native-platform.ts";
test("Linux ARM64 prebuild supports 64 KB system pages", () => {
const binary = readFileSync(
new URL("../native/linux/prebuilds/linux-arm64/linux-platform-x11.node", import.meta.url),
);
assert.equal(binary.subarray(0, 4).toString(), "\x7fELF");
const offset = Number(binary.readBigUInt64LE(32));
const entrySize = binary.readUInt16LE(54);
const count = binary.readUInt16LE(56);
let loadSegments = 0;
for (let index = 0; index < count; index++) {
const entry = offset + index * entrySize;
if (binary.readUInt32LE(entry) !== 1) continue;
loadSegments++;
const fileOffset = binary.readBigUInt64LE(entry + 8);
const address = binary.readBigUInt64LE(entry + 16);
assert.equal((address - fileOffset) % 65536n, 0n);
assert.ok(binary.readBigUInt64LE(entry + 48) >= 65536n);
}
assert.ok(loadSegments > 0);
});
// Opt in on a Windows test desktop: this replaces the system clipboard contents.
test(
"writes Windows clipboard text through the native helper without command fallbacks",
{ skip: process.platform !== "win32" || process.env.PI_TEST_NATIVE_CLIPBOARD !== "1" },
async () => {
const clipboard = getNativeClipboard();
assert.ok(clipboard?.setText);
for (const text of ["clipboard café 日本語", "", "second write"]) {
await clipboard.setText(text);
assert.equal(await clipboard.getText(), text);
assert.equal(await clipboard.getImage(), null);
}
},
);
test(
"uses the native platform helper directly as the clipboard API",
{ skip: !["darwin", "win32"].includes(process.platform) || !["arm64", "x64"].includes(process.arch) },
() => {
const clipboard = getNativeClipboard();
assert.ok(clipboard);
assert.equal(typeof clipboard.getText, "function");
assert.equal(typeof clipboard.getImage, "function");
assert.equal(typeof clipboard.setText, "function");
assert.equal(clipboard, getNativePlatformHelper());
assert.equal(clipboard, getNativeClipboard());
},
);
test("Linux loads X11 lazily and rechecks DISPLAY", { skip: !["arm64", "x64"].includes(process.arch) }, async (t) => {
const require = createRequire(import.meta.url);
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
const display = process.env.DISPLAY;
const waylandDisplay = process.env.WAYLAND_DISPLAY;
const modulePath = fileURLToPath(
new URL(`../native/linux/prebuilds/linux-${process.arch}/linux-platform-x11.node`, import.meta.url),
);
const previous = require.cache[modulePath];
t.after(() => {
Object.defineProperty(process, "platform", platform);
if (display === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = display;
if (waylandDisplay === undefined) delete process.env.WAYLAND_DISPLAY;
else process.env.WAYLAND_DISPLAY = waylandDisplay;
if (previous) require.cache[modulePath] = previous;
else delete require.cache[modulePath];
});
Object.defineProperty(process, "platform", { value: "linux" });
delete process.env.DISPLAY;
process.env.WAYLAND_DISPLAY = "wayland-0";
assert.equal(getNativeClipboard(), undefined); // Wayland paste uses wl-paste.
let available = false;
const helper = {
getText: t.mock.fn(async () => (available ? "X11" : undefined)),
getImage: t.mock.fn(async () => null),
};
const module = new Module(modulePath);
module.exports = helper;
require.cache[modulePath] = module;
process.env.DISPLAY = ":0";
const clipboard = getNativeClipboard()!;
assert.equal(clipboard, helper);
assert.equal(helper.getText.mock.callCount(), 0);
assert.equal(await clipboard.getText(), undefined);
available = true;
assert.equal(await clipboard.getText(), "X11");
assert.equal(await clipboard.getImage(), null);
delete process.env.DISPLAY;
assert.equal(getNativeClipboard(), undefined);
process.env.DISPLAY = ":1";
assert.equal(getNativeClipboard(), clipboard);
});

View file

@ -4,11 +4,10 @@
# Mirrors .github/workflows/build-binaries.yml
#
# Usage:
# ./scripts/build-binaries.sh [--skip-install] [--skip-deps] [--skip-build] [--offline-model-data] [--platform <platform>] [--out <dir>]
# ./scripts/build-binaries.sh [--skip-install] [--skip-build] [--offline-model-data] [--platform <platform>] [--out <dir>]
#
# Options:
# --skip-install Skip npm ci
# --skip-deps Skip installing cross-platform dependencies
# --skip-build Skip the package build
# --offline-model-data Build with bundled model data instead of refreshing it
# --platform <name> Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64)
@ -28,7 +27,6 @@ set -euo pipefail
cd "$(dirname "$0")/.."
SKIP_INSTALL=false
SKIP_DEPS=false
SKIP_BUILD=false
OFFLINE_MODEL_DATA=false
PLATFORM=""
@ -40,10 +38,6 @@ while [[ $# -gt 0 ]]; do
SKIP_INSTALL=true
shift
;;
--skip-deps)
SKIP_DEPS=true
shift
;;
--skip-build)
SKIP_BUILD=true
shift
@ -94,45 +88,6 @@ else
echo "==> Skipping npm ci (--skip-install)"
fi
if [[ "$SKIP_DEPS" == "false" ]]; then
echo "==> Installing cross-platform native bindings..."
CLIPBOARD_VERSION=$(node -p "require('./packages/coding-agent/package.json').optionalDependencies['@mariozechner/clipboard']")
# npm ci only installs optional deps for the current platform. Install the
# cross-platform packages in isolation so npm does not re-resolve and mutate
# the workspace dependency graph, which can trigger npm/arborist failures.
NATIVE_DEPS_DIR=$(mktemp -d)
cleanup_native_deps() {
rm -rf "$NATIVE_DEPS_DIR"
}
trap cleanup_native_deps EXIT
printf '%s\n' '{"private":true}' > "$NATIVE_DEPS_DIR/package.json"
# Use --force to bypass platform checks (os/cpu restrictions in package.json).
npm install --prefix "$NATIVE_DEPS_DIR" --include=optional --no-save --package-lock=false --force --ignore-scripts \
@mariozechner/clipboard@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-darwin-arm64@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-darwin-x64@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-linux-x64-gnu@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-linux-arm64-gnu@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-win32-x64-msvc@"$CLIPBOARD_VERSION" \
@mariozechner/clipboard-win32-arm64-msvc@"$CLIPBOARD_VERSION"
mkdir -p node_modules/@mariozechner
for package in \
clipboard \
clipboard-darwin-arm64 \
clipboard-darwin-x64 \
clipboard-linux-x64-gnu \
clipboard-linux-arm64-gnu \
clipboard-win32-x64-msvc \
clipboard-win32-arm64-msvc; do
rm -rf "node_modules/@mariozechner/$package"
cp -R "$NATIVE_DEPS_DIR/node_modules/@mariozechner/$package" node_modules/@mariozechner/
done
cleanup_native_deps
trap - EXIT
else
echo "==> Skipping cross-platform native bindings (--skip-deps)"
fi
if [[ "$SKIP_BUILD" == "false" ]]; then
if [[ "$OFFLINE_MODEL_DATA" == "true" ]]; then
echo "==> Building all packages with bundled model data..."
@ -159,35 +114,6 @@ else
PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64 windows-arm64)
fi
set_clipboard_target() {
case "$1" in
darwin-arm64)
clipboard_native_package="clipboard-darwin-arm64"
clipboard_native_file="clipboard.darwin-arm64.node"
;;
darwin-x64)
clipboard_native_package="clipboard-darwin-x64"
clipboard_native_file="clipboard.darwin-x64.node"
;;
linux-x64)
clipboard_native_package="clipboard-linux-x64-gnu"
clipboard_native_file="clipboard.linux-x64-gnu.node"
;;
linux-arm64)
clipboard_native_package="clipboard-linux-arm64-gnu"
clipboard_native_file="clipboard.linux-arm64-gnu.node"
;;
windows-x64)
clipboard_native_package="clipboard-win32-x64-msvc"
clipboard_native_file="clipboard.win32-x64-msvc.node"
;;
windows-arm64)
clipboard_native_package="clipboard-win32-arm64-msvc"
clipboard_native_file="clipboard.win32-arm64-msvc.node"
;;
esac
}
for platform in "${PLATFORMS[@]}"; do
echo "Building for $platform..."
bun_target="bun-$platform"
@ -224,26 +150,11 @@ for platform in "${PLATFORMS[@]}"; do
cp -r docs "$OUTPUT_DIR/$platform/"
cp -r examples "$OUTPUT_DIR/$platform/"
set_clipboard_target "$platform"
mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner"
cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/"
cp "../../node_modules/@mariozechner/$clipboard_native_package/$clipboard_native_file" \
"$OUTPUT_DIR/$platform/node_modules/@mariozechner/clipboard/"
# Copy terminal input native helpers next to compiled binaries.
if [[ "$platform" == darwin-* ]]; then
mkdir -p "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform"
cp ../tui/native/darwin/prebuilds/$platform/darwin-modifiers.node "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform/"
fi
if [[ "$platform" == windows-* ]]; then
if [[ "$platform" == "windows-arm64" ]]; then
win32_arch_dir="win32-arm64"
else
win32_arch_dir="win32-x64"
fi
mkdir -p "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir"
cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir/"
fi
# Copy the selected architecture's native platform helpers next to the executable.
native_platform="${platform/windows-/win32-}"
native_path="native/${native_platform%-*}/prebuilds"
mkdir -p "$OUTPUT_DIR/$platform/$native_path"
cp -R "../tui/$native_path/$native_platform" "$OUTPUT_DIR/$platform/$native_path/"
done
# Create archives

View file

@ -120,6 +120,18 @@ required_paths=(
"packages/ai/src/models.generated.ts"
"packages/ai/src/image-models.generated.ts"
"packages/ai/src/providers/data/.manifest.json"
"packages/tui/native/napi.h"
"packages/tui/native/clipboard.h"
"packages/tui/native/darwin/src/darwin-platform.m"
"packages/tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node"
"packages/tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node"
"packages/tui/native/linux/build.sh"
"packages/tui/native/linux/src/linux-platform-x11.c"
"packages/tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node"
"packages/tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node"
"packages/tui/native/win32/src/win32-platform.c"
"packages/tui/native/win32/prebuilds/win32-arm64/win32-platform.node"
"packages/tui/native/win32/prebuilds/win32-x64/win32-platform.node"
"packages/coding-agent/package.json"
"packages/coding-agent/src/utils/image-resize-worker.ts"
"packages/coding-agent/src/core/export-html/template.css"

View file

@ -153,7 +153,6 @@ function buildBunBinaryRelease(targetDirectory, archiveDirectory) {
const binaryBuildDirectory = join(archiveDirectory, "binary-build");
run("./scripts/build-binaries.sh", [
"--skip-install",
"--skip-deps",
"--skip-build",
"--platform",
platform,