Skip to content

Commit b1ddcec

Browse files
Josh Guiceclaude
andcommitted
feat: add custom pasteboard type for clipboard manager compatibility (macOS)
OpenWhispr briefly writes transcribed text to the system clipboard before simulating a paste. Clipboard managers (e.g. Maccy, Paste, CopyClip) capture these transient writes, cluttering their history. Because the paste is simulated via Cmd+V in the foreground app, clipboard managers attribute the entry to that app rather than OpenWhispr, making app-based filtering impossible. This adds a compiled Swift helper (macos-clipboard-marker) that writes both the plain text and a custom pasteboard type (com.openwhispr.transcription) to the macOS pasteboard simultaneously. Users can add this type to their clipboard manager's ignore list to filter out dictation entries. Also appends a trailing space after pasted transcriptions so the cursor is positioned ready for the next word, matching the behaviour of other dictation tools like WhisperFlow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fc4b5a6 commit b1ddcec

File tree

7 files changed

+258
-5
lines changed

7 files changed

+258
-5
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,7 @@ OpenWhispr is designed with privacy and security in mind:
702702
- Linux Wayland: Install `wtype` or `ydotool` for paste simulation (ensure `ydotoold` daemon is running)
703703
- All platforms: Text is always copied to clipboard - use Ctrl+V (Cmd+V on macOS) to paste manually
704704
7. **Panel position**: If the panel appears off-screen, restart the app to reset position
705+
8. **Clipboard manager capturing dictations**: OpenWhispr briefly writes transcribed text to the system clipboard before pasting it into your active app. Clipboard managers (e.g. [Maccy](https://maccy.app), Paste, CopyClip) may capture these transient writes. On macOS, OpenWhispr includes a custom pasteboard type (`com.openwhispr.transcription`) alongside the text. To prevent your clipboard manager from storing dictations, add `com.openwhispr.transcription` to its ignored pasteboard types. In Maccy: Settings → Ignore → add `com.openwhispr.transcription` under pasteboard types.
705706

706707
### Getting Help
707708

electron-builder.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"resources/bin/macos-globe-listener",
8989
"resources/bin/macos-fast-paste",
9090
"resources/bin/macos-text-monitor",
91+
"resources/bin/macos-clipboard-marker",
9192
"resources/bin/linux-fast-paste",
9293
"resources/bin/linux-text-monitor",
9394
{

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"compile:text-monitor": "node scripts/build-text-monitor.js",
1212
"compile:winpaste": "node scripts/build-windows-fast-paste.js",
1313
"compile:linux-paste": "node scripts/build-linux-fast-paste.js",
14-
"compile:native": "npm run compile:globe && npm run compile:fast-paste && npm run compile:winkeys && npm run compile:winpaste && npm run compile:linux-paste && npm run compile:text-monitor",
14+
"compile:clipboard-marker": "node scripts/build-macos-clipboard-marker.js",
15+
"compile:native": "npm run compile:globe && npm run compile:fast-paste && npm run compile:clipboard-marker && npm run compile:winkeys && npm run compile:winpaste && npm run compile:linux-paste && npm run compile:text-monitor",
1516
"prestart": "npm run compile:native",
1617
"start": "electron .",
1718
"predev": "npm run compile:native",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import AppKit
2+
3+
// Writes text to the macOS pasteboard along with a custom marker type so that
4+
// clipboard managers (e.g. Maccy) can identify and optionally ignore OpenWhispr's
5+
// transient clipboard writes during paste operations.
6+
//
7+
// Usage: macos-clipboard-marker "text to write" ["custom.type.name"]
8+
9+
let text = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : ""
10+
let markerType = CommandLine.arguments.count > 2 ? CommandLine.arguments[2] : "com.openwhispr.transcription"
11+
12+
let pb = NSPasteboard.general
13+
pb.clearContents()
14+
pb.declareTypes([.string, NSPasteboard.PasteboardType(markerType)], owner: nil)
15+
pb.setString(text, forType: .string)
16+
pb.setString("1", forType: NSPasteboard.PasteboardType(markerType))
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env node
2+
3+
const { spawnSync } = require("child_process");
4+
const crypto = require("crypto");
5+
const fs = require("fs");
6+
const path = require("path");
7+
8+
const isMac = process.platform === "darwin";
9+
if (!isMac) {
10+
process.exit(0);
11+
}
12+
13+
// Support cross-compilation via --arch flag or TARGET_ARCH env var
14+
const archIndex = process.argv.indexOf("--arch");
15+
const targetArch =
16+
(archIndex !== -1 && process.argv[archIndex + 1]) || process.env.TARGET_ARCH || process.arch;
17+
18+
const ARCH_TO_TARGET = {
19+
arm64: "arm64-apple-macosx11.0",
20+
x64: "x86_64-apple-macosx10.15",
21+
};
22+
const swiftTarget = ARCH_TO_TARGET[targetArch];
23+
if (!swiftTarget) {
24+
console.error(`[clipboard-marker] Unsupported architecture: ${targetArch}`);
25+
process.exit(1);
26+
}
27+
28+
const projectRoot = path.resolve(__dirname, "..");
29+
const swiftSource = path.join(projectRoot, "resources", "macos-clipboard-marker.swift");
30+
const outputDir = path.join(projectRoot, "resources", "bin");
31+
const outputBinary = path.join(outputDir, "macos-clipboard-marker");
32+
const hashFile = path.join(outputDir, `.macos-clipboard-marker.${targetArch}.hash`);
33+
const moduleCacheDir = path.join(outputDir, ".swift-module-cache");
34+
35+
const ARCH_CPU_TYPE = {
36+
arm64: 0x0100000c, // CPU_TYPE_ARM64
37+
x64: 0x01000007, // CPU_TYPE_X86_64
38+
};
39+
40+
function log(message) {
41+
console.log(`[clipboard-marker] ${message}`);
42+
}
43+
44+
function ensureDir(dirPath) {
45+
if (!fs.existsSync(dirPath)) {
46+
fs.mkdirSync(dirPath, { recursive: true });
47+
}
48+
}
49+
50+
function verifyBinaryArch(binaryPath, expectedArch) {
51+
try {
52+
const fd = fs.openSync(binaryPath, "r");
53+
const header = Buffer.alloc(8);
54+
fs.readSync(fd, header, 0, 8, 0);
55+
fs.closeSync(fd);
56+
57+
const magic = header.readUInt32LE(0);
58+
if (magic !== 0xfeedfacf) {
59+
return false;
60+
}
61+
const cpuType = header.readInt32LE(4);
62+
return cpuType === ARCH_CPU_TYPE[expectedArch];
63+
} catch {
64+
return false;
65+
}
66+
}
67+
68+
if (!fs.existsSync(swiftSource)) {
69+
console.error(`[clipboard-marker] Swift source not found at ${swiftSource}`);
70+
process.exit(1);
71+
}
72+
73+
ensureDir(outputDir);
74+
ensureDir(moduleCacheDir);
75+
76+
let needsBuild = true;
77+
if (fs.existsSync(outputBinary)) {
78+
if (!verifyBinaryArch(outputBinary, targetArch)) {
79+
log(`Existing binary is wrong architecture (expected ${targetArch}), rebuild needed`);
80+
needsBuild = true;
81+
} else {
82+
try {
83+
const binaryStat = fs.statSync(outputBinary);
84+
const sourceStat = fs.statSync(swiftSource);
85+
if (binaryStat.mtimeMs >= sourceStat.mtimeMs) {
86+
needsBuild = false;
87+
}
88+
} catch {
89+
needsBuild = true;
90+
}
91+
}
92+
}
93+
94+
if (!needsBuild && fs.existsSync(outputBinary)) {
95+
try {
96+
const sourceContent = fs.readFileSync(swiftSource, "utf8");
97+
const currentHash = crypto.createHash("sha256").update(sourceContent).digest("hex");
98+
99+
if (fs.existsSync(hashFile)) {
100+
const savedHash = fs.readFileSync(hashFile, "utf8").trim();
101+
if (savedHash !== currentHash) {
102+
log("Source hash changed, rebuild needed");
103+
needsBuild = true;
104+
}
105+
} else {
106+
log(`No hash file for ${targetArch}, rebuild needed`);
107+
needsBuild = true;
108+
}
109+
} catch (err) {
110+
log(`Hash check failed: ${err.message}, forcing rebuild`);
111+
needsBuild = true;
112+
}
113+
}
114+
115+
if (!needsBuild) {
116+
process.exit(0);
117+
}
118+
119+
function attemptCompile(command, args) {
120+
log(`Compiling with ${[command, ...args].join(" ")}`);
121+
return spawnSync(command, args, {
122+
stdio: "inherit",
123+
env: {
124+
...process.env,
125+
SWIFT_MODULE_CACHE_PATH: moduleCacheDir,
126+
},
127+
});
128+
}
129+
130+
const compileArgs = [
131+
swiftSource,
132+
"-O",
133+
"-target",
134+
swiftTarget,
135+
"-module-cache-path",
136+
moduleCacheDir,
137+
"-o",
138+
outputBinary,
139+
];
140+
141+
let result = attemptCompile("xcrun", ["swiftc", ...compileArgs]);
142+
143+
if (result.status !== 0) {
144+
result = attemptCompile("swiftc", compileArgs);
145+
}
146+
147+
if (result.status !== 0) {
148+
console.error("[clipboard-marker] Failed to compile macOS clipboard-marker binary.");
149+
process.exit(result.status ?? 1);
150+
}
151+
152+
try {
153+
fs.chmodSync(outputBinary, 0o755);
154+
} catch (error) {
155+
console.warn(`[clipboard-marker] Unable to set executable permissions: ${error.message}`);
156+
}
157+
158+
if (!verifyBinaryArch(outputBinary, targetArch)) {
159+
console.error(
160+
`[clipboard-marker] FATAL: Compiled binary architecture does not match target (${targetArch}). ` +
161+
`This can happen when cross-compiling without setting TARGET_ARCH env var.`
162+
);
163+
process.exit(1);
164+
}
165+
166+
try {
167+
const sourceContent = fs.readFileSync(swiftSource, "utf8");
168+
const hash = crypto.createHash("sha256").update(sourceContent).digest("hex");
169+
fs.writeFileSync(hashFile, hash);
170+
} catch (err) {
171+
log(`Warning: Could not save source hash: ${err.message}`);
172+
}
173+
174+
log(`Successfully built macOS clipboard-marker binary (${targetArch}).`);

src/helpers/audioManager.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1796,7 +1796,10 @@ registerProcessor("pcm-streaming-processor", PCMStreamingProcessor);
17961796

17971797
async safePaste(text, options = {}) {
17981798
try {
1799-
await window.electronAPI.pasteText(text, options);
1799+
// Append a trailing space so the cursor is ready for the next word or
1800+
// dictation, matching the behaviour of other dictation tools.
1801+
const textToInsert = text && text.length > 0 ? text + " " : text;
1802+
await window.electronAPI.pasteText(textToInsert, options);
18001803
return true;
18011804
} catch (error) {
18021805
const message =

src/helpers/clipboard.js

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,63 @@ const RESTORE_DELAYS = {
5959
linux: 200,
6060
};
6161

62+
// Custom pasteboard type so clipboard managers (e.g. Maccy) can identify and
63+
// ignore transient clipboard writes made by OpenWhispr during paste operations.
64+
// Users can add this type to their clipboard manager's ignore list.
65+
const OPENWHISPR_PASTEBOARD_TYPE = "com.openwhispr.transcription";
66+
67+
/**
68+
* Resolve the path to the compiled macos-clipboard-marker binary, which writes
69+
* text to the pasteboard alongside a custom marker type.
70+
*/
71+
function resolveClipboardMarkerBinary() {
72+
const candidates = [
73+
// Development: resources/bin/
74+
path.join(__dirname, "..", "..", "resources", "bin", "macos-clipboard-marker"),
75+
// Packaged app: resources/bin/ (asar unpacked)
76+
path.join(
77+
(require("electron").app || { getAppPath: () => "" }).getAppPath(),
78+
"..",
79+
"resources",
80+
"bin",
81+
"macos-clipboard-marker"
82+
),
83+
];
84+
for (const p of candidates) {
85+
try {
86+
if (fs.existsSync(p)) {
87+
fs.accessSync(p, fs.constants.X_OK);
88+
return p;
89+
}
90+
} catch {}
91+
}
92+
return null;
93+
}
94+
95+
/**
96+
* Write text to the clipboard with a custom marker pasteboard type so that
97+
* clipboard managers can identify (and optionally ignore) OpenWhispr's
98+
* transient writes. Falls back to plain clipboard.writeText on failure or
99+
* on non-macOS platforms.
100+
*/
101+
function writeClipboardWithMarker(text) {
102+
if (process.platform === "darwin") {
103+
const binary = resolveClipboardMarkerBinary();
104+
if (binary) {
105+
try {
106+
const result = spawnSync(binary, [text, OPENWHISPR_PASTEBOARD_TYPE], { timeout: 3000 });
107+
if (result.status === 0) {
108+
return;
109+
}
110+
debugLogger.log("⚠️ clipboard-marker binary failed, falling back to writeText");
111+
} catch (e) {
112+
debugLogger.log("⚠️ clipboard-marker binary error, falling back to writeText:", e?.message);
113+
}
114+
}
115+
}
116+
clipboard.writeText(text);
117+
}
118+
62119
function writeClipboardInRenderer(webContents, text) {
63120
if (!webContents || !webContents.executeJavaScript) {
64121
return Promise.reject(new Error("Invalid webContents for clipboard write"));
@@ -460,7 +517,7 @@ class ClipboardManager {
460517
if (platform === "linux" && this._isWayland()) {
461518
this._writeClipboardWayland(text, webContents);
462519
} else {
463-
clipboard.writeText(text);
520+
writeClipboardWithMarker(text);
464521
}
465522
this.safeLog("📋 Text copied to clipboard:", text.substring(0, 50) + "...");
466523

@@ -481,7 +538,7 @@ class ClipboardManager {
481538
await this.pasteMacOS(originalClipboard, options);
482539
} catch (firstError) {
483540
this.safeLog("⚠️ First paste attempt failed, retrying...", firstError?.message);
484-
clipboard.writeText(text);
541+
writeClipboardWithMarker(text);
485542
await new Promise((r) => setTimeout(r, 200));
486543
await this.pasteMacOS(originalClipboard, options);
487544
}
@@ -1546,7 +1603,7 @@ Would you like to open System Settings now?`;
15461603
if (process.platform === "linux" && this._isWayland()) {
15471604
this._writeClipboardWayland(text, webContents);
15481605
} else {
1549-
clipboard.writeText(text);
1606+
writeClipboardWithMarker(text);
15501607
}
15511608
return { success: true };
15521609
}

0 commit comments

Comments
 (0)