From e702b1df364f5be26baa9c6842653d4c82bf8774 Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Thu, 10 Sep 2026 23:08:11 -0500 Subject: [PATCH] Release held modifiers before synthesizing the paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unspooling advanced the spool and never pasted. The cause is that a hotkey fires on the key down, so when the handler runs the user is still holding Win+Alt. Synthesizing Ctrl+V into that state delivers Win+Alt+Ctrl+V, which is a paste in no application, and nothing happens. It looked intermittent because a handler that happened to run after the keys came up worked fine, which is how it got through a round of testing with a false pass. The addon now lifts every modifier that is currently down before pressing Ctrl+V. They are not restored: the user's keys are still physically held, their release is harmless, and re-pressing Win would open the Start menu. Verified by holding the modifiers 700ms past the hotkey, which is what a person actually does — three unspools in a row now deliver three clips in order. Two things follow from the report rather than from the code. Pasting the whole spool now pastes as well, because one key that pastes and one that silently changes the clipboard is an inconsistency rather than a design; the setting is renamed autoPaste to cover both, still reading the old pasteOnServe key. And a paste that does not land now says so and names Ctrl+V as the way out — Windows refuses synthesized input to elevated windows and some terminals want Ctrl+Shift+V, and in those cases silence is what makes a user conclude the app is broken. Unspooling while Spool's own window is in front now dismisses it and pastes into whatever was behind, rather than refusing in silence. Co-Authored-By: Claude Opus 5 --- PLAN.md | 21 +++++- native/clipboard/src/clipboard_unsupported.cc | 5 ++ native/clipboard/src/clipboard_win.cc | 69 +++++++++++++++---- src/main/clipboard/writer.ts | 11 +++ src/main/index.ts | 36 ++++++++-- src/main/ipc/index.ts | 6 +- src/main/session.test.ts | 36 +++++++--- src/main/session.ts | 65 +++++++++++++---- src/main/settings.test.ts | 6 +- src/main/settings.ts | 10 +-- src/main/window.ts | 16 +++++ src/preload/index.ts | 6 +- src/renderer/components/SettingsPanel.tsx | 15 ++-- src/renderer/env.d.ts | 2 +- src/renderer/state/useAppState.ts | 2 +- src/shared/ipc.ts | 6 +- 16 files changed, 242 insertions(+), 70 deletions(-) diff --git a/PLAN.md b/PLAN.md index 81fe071..149a8c1 100644 --- a/PLAN.md +++ b/PLAN.md @@ -668,9 +668,24 @@ the change rather than oppose it: the deliberate act; the paste is its effect, not a hidden side effect of some other action. What remains true is that a synthesized `Ctrl+V` does nothing in terminals that paste with -`Ctrl+Shift+V`, so it is a setting rather than a law, and the addon refuses to paste when Spool's own -window is in front — unspooling is meant to put a clip into the document you were already working in. -**macOS, if it ever ships, serves without pasting**, and the reasoning above is why. +`Ctrl+Shift+V`, and Windows refuses synthesized input to a window running as administrator, so it is +a setting rather than a law. **macOS, if it ever ships, serves without pasting**, and the reasoning +above is why. + +**Measured, and the first implementation did not work at all.** A hotkey fires on the key *down*, so +at the instant the handler runs the user is still holding `Win+Alt`. Synthesizing `Ctrl+V` into that +state delivers `Win+Alt+Ctrl+V`, which is a paste in no application on earth, and nothing happens. +It presented as "unspooling advances the spool but never pastes", and it looked intermittent because +a handler that happened to run after the keys came up worked perfectly — which is how it survived a +round of testing. **So the addon lifts every modifier that is currently down before pressing Ctrl+V**, +and does not restore them: the user's own keys are still physically held, their release is harmless, +and re-pressing `Win` would open the Start menu. + +Two consequences follow from taking the user's account seriously rather than the code's. Pasting the +whole spool pastes too — one key that pastes and one that silently changes the clipboard is an +inconsistency, not a design. And **a paste that does not land says so**, naming `Ctrl+V` as the way +out, because the alternative is what happened here: a key that appears to do nothing, and a user who +reasonably concludes the app is broken. ### Hotkeys diff --git a/native/clipboard/src/clipboard_unsupported.cc b/native/clipboard/src/clipboard_unsupported.cc index c51b490..4d6a785 100644 --- a/native/clipboard/src/clipboard_unsupported.cc +++ b/native/clipboard/src/clipboard_unsupported.cc @@ -27,11 +27,16 @@ Napi::Value SendPaste(const Napi::CallbackInfo& info) { return Napi::Boolean::New(info.Env(), false); } +Napi::Value ForegroundIsSelf(const Napi::CallbackInfo& info) { + return Napi::Boolean::New(info.Env(), false); +} + Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("start", Napi::Function::New(env, Start)); exports.Set("stop", Napi::Function::New(env, Stop)); exports.Set("isSupported", Napi::Function::New(env, IsSupported)); exports.Set("sendPaste", Napi::Function::New(env, SendPaste)); + exports.Set("foregroundIsSelf", Napi::Function::New(env, ForegroundIsSelf)); return exports; } diff --git a/native/clipboard/src/clipboard_win.cc b/native/clipboard/src/clipboard_win.cc index 5539d72..305ff8a 100644 --- a/native/clipboard/src/clipboard_win.cc +++ b/native/clipboard/src/clipboard_win.cc @@ -325,24 +325,66 @@ Napi::Value SendPaste(const Napi::CallbackInfo& info) { GetWindowThreadProcessId(foreground, &foreground_pid); if (foreground_pid == GetCurrentProcessId()) return Napi::Boolean::New(env, false); - INPUT inputs[4] = {}; + // **Release whatever the user is still holding first.** + // + // The hotkey that asked for this paste fires on the key *down*, so at this instant Win and Alt + // are almost certainly still held — the user has not let go of `Win+Alt+U` yet. Synthesizing + // Ctrl+V into that state delivers `Win+Alt+Ctrl+V`, which is not a paste in any application, and + // nothing happens. It cost a user their trust in the feature before it was understood, and it + // looked intermittent because a handler that happened to run after the keys came up worked fine. + // + // So: lift every modifier that is currently down, then press Ctrl+V cleanly. They are not + // restored afterwards. The user's own keys are still physically held and their next release is + // harmless, whereas re-pressing Win here would open the Start menu. + const WORD kModifiers[] = {VK_LWIN, VK_RWIN, VK_LMENU, VK_RMENU, + VK_LSHIFT, VK_RSHIFT, VK_LCONTROL, VK_RCONTROL}; + + std::vector inputs; + for (WORD vk : kModifiers) { + if ((GetAsyncKeyState(vk) & 0x8000) == 0) continue; + INPUT up = {}; + up.type = INPUT_KEYBOARD; + up.ki.wVk = vk; + up.ki.dwFlags = KEYEVENTF_KEYUP; + inputs.push_back(up); + } + + const size_t released = inputs.size(); - inputs[0].type = INPUT_KEYBOARD; - inputs[0].ki.wVk = VK_CONTROL; + INPUT press = {}; + press.type = INPUT_KEYBOARD; + press.ki.wVk = VK_CONTROL; + inputs.push_back(press); - inputs[1].type = INPUT_KEYBOARD; - inputs[1].ki.wVk = 'V'; + press.ki.wVk = 'V'; + inputs.push_back(press); - inputs[2].type = INPUT_KEYBOARD; - inputs[2].ki.wVk = 'V'; - inputs[2].ki.dwFlags = KEYEVENTF_KEYUP; + INPUT release = {}; + release.type = INPUT_KEYBOARD; + release.ki.dwFlags = KEYEVENTF_KEYUP; + release.ki.wVk = 'V'; + inputs.push_back(release); - inputs[3].type = INPUT_KEYBOARD; - inputs[3].ki.wVk = VK_CONTROL; - inputs[3].ki.dwFlags = KEYEVENTF_KEYUP; + release.ki.wVk = VK_CONTROL; + inputs.push_back(release); - const UINT sent = SendInput(4, inputs, sizeof(INPUT)); - return Napi::Boolean::New(env, sent == 4); + const UINT expected = static_cast(released + 4); + const UINT sent = SendInput(expected, inputs.data(), sizeof(INPUT)); + return Napi::Boolean::New(env, sent == expected); +} + +// Whether Spool's own window is the one in front. +// +// Asked before serving, because it decides where the clip is meant to go. If we are in front, the +// window has to get out of the way first: the user is looking at Spool, but the clip is for +// whatever they were working in before they opened it. +Napi::Value ForegroundIsSelf(const Napi::CallbackInfo& info) { + HWND foreground = GetForegroundWindow(); + if (foreground == nullptr) return Napi::Boolean::New(info.Env(), false); + + DWORD foreground_pid = 0; + GetWindowThreadProcessId(foreground, &foreground_pid); + return Napi::Boolean::New(info.Env(), foreground_pid == GetCurrentProcessId()); } Napi::Object Init(Napi::Env env, Napi::Object exports) { @@ -350,6 +392,7 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("stop", Napi::Function::New(env, Stop)); exports.Set("isSupported", Napi::Function::New(env, IsSupported)); exports.Set("sendPaste", Napi::Function::New(env, SendPaste)); + exports.Set("foregroundIsSelf", Napi::Function::New(env, ForegroundIsSelf)); return exports; } diff --git a/src/main/clipboard/writer.ts b/src/main/clipboard/writer.ts index 693379a..2076e42 100644 --- a/src/main/clipboard/writer.ts +++ b/src/main/clipboard/writer.ts @@ -32,3 +32,14 @@ export function sendPaste(): boolean { return false } } + +/** Whether Spool's own window is in front, which decides where a clip is meant to go. */ +export function foregroundIsSelf(): boolean { + try { + const require = createRequire(__filename) + const addon = require('spool-clipboard') as { foregroundIsSelf?: () => boolean } + return addon.foregroundIsSelf?.() ?? false + } catch { + return false + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 0f00311..730f9bd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,11 +15,12 @@ import { import { registerIpc } from './ipc' import { Session } from './session' import { explainStorageFailure, openStore, resetEverything, startFresh, storePaths } from './store' -import { sendPaste, writeClipboardText } from './clipboard/writer' +import { foregroundIsSelf, sendPaste, writeClipboardText } from './clipboard/writer' import { createTray, reportCaptureState } from './tray' import { loadSettings, saveSettings, settingsPath, type WindowState } from './settings' import { createCompactWindow, + dismissCompactWindow, getCompactWindow, restoreWindowState, setWindowState, @@ -28,6 +29,29 @@ import { } from './window' // One instance owns the tray icon and the hotkeys; a second launch summons the first. +/** + * Paste into the window the user was actually working in (PLAN.md 8). + * + * A global hotkey does not steal focus, so normally the window they were typing in still has it and + * the paste simply lands. The exception is when Spool itself is in front — they summoned it and have + * not clicked away — and then the only sensible target is whatever they were in *before* they opened + * it. So the window gets out of the way first, and the paste follows once focus has moved back. + * + * The delay is the cost of that. Windows moves focus asynchronously after a window hides, and + * synthesizing a keystroke into the gap would deliver it nowhere. + */ +function pasteWhereTheUserWas(report: (pasted: boolean) => void): void { + if (foregroundIsSelf() && dismissCompactWindow()) { + setTimeout(() => report(sendPaste()), FOCUS_SETTLE_MS) + return + } + + report(sendPaste()) +} + +/** Long enough for focus to land on the window behind ours, short enough not to be felt. */ +const FOCUS_SETTLE_MS = 120 + if (!app.requestSingleInstanceLock()) { app.quit() } else { @@ -43,7 +67,7 @@ if (!app.requestSingleInstanceLock()) { app.isPackaged ) - const spoolSession = new Session(writeClipboardText, sendPaste) + const spoolSession = new Session(writeClipboardText, pasteWhereTheUserWas) /** * Open the encrypted store and restore what it holds (PLAN.md 11, M6). A failure is reported @@ -63,7 +87,7 @@ if (!app.requestSingleInstanceLock()) { spoolSession.setSeparator(settings.separator) spoolSession.setPrivacyAcknowledged(settings.privacyAcknowledged) - spoolSession.setPasteOnServe(settings.pasteOnServe) + spoolSession.setAutoPaste(settings.autoPaste) spoolSession.setConsentTimeout(settings.consentTimeoutSeconds) registerIpc(spoolSession, getCompactWindow, { @@ -82,7 +106,7 @@ if (!app.requestSingleInstanceLock()) { consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), privacyAcknowledged: true, hotkeys: hotkeyOverrides(), - pasteOnServe: spoolSession.getPasteOnServe() + autoPaste: spoolSession.getAutoPaste() }) }, @@ -126,7 +150,7 @@ if (!app.requestSingleInstanceLock()) { consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), privacyAcknowledged: !spoolSession.isFirstRun(), hotkeys: hotkeyOverrides(), - pasteOnServe: spoolSession.getPasteOnServe() + autoPaste: spoolSession.getAutoPaste() }) } }) @@ -140,7 +164,7 @@ if (!app.requestSingleInstanceLock()) { consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), privacyAcknowledged: !spoolSession.isFirstRun(), hotkeys: hotkeyOverrides(), - pasteOnServe: spoolSession.getPasteOnServe() + autoPaste: spoolSession.getAutoPaste() }) ) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index ff61947..f6b66b9 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -84,9 +84,7 @@ export function registerIpc( ipcMain.handle(CHANNELS.pauseCapture, () => session.pauseCapture()) ipcMain.handle(CHANNELS.acknowledgePrivacy, () => actions.acknowledgePrivacy()) ipcMain.handle(CHANNELS.toggleMode, () => session.toggleMode()) - ipcMain.handle(CHANNELS.setPasteOnServe, (_event, enabled: boolean) => - session.setPasteOnServe(enabled) - ) + ipcMain.handle(CHANNELS.setAutoPaste, (_event, enabled: boolean) => session.setAutoPaste(enabled)) ipcMain.handle(CHANNELS.setHotkey, (_event, action: HotkeyAction, accelerator: string) => actions.setHotkey(action, accelerator) ) @@ -133,7 +131,7 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.pauseCapture) ipcMain.removeHandler(CHANNELS.acknowledgePrivacy) ipcMain.removeHandler(CHANNELS.toggleMode) - ipcMain.removeHandler(CHANNELS.setPasteOnServe) + ipcMain.removeHandler(CHANNELS.setAutoPaste) ipcMain.removeHandler(CHANNELS.setHotkey) ipcMain.removeHandler(CHANNELS.resetHotkey) ipcMain.removeHandler(CHANNELS.resumeCapture) diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 3e027cc..ef6a966 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -32,7 +32,7 @@ const text = (value: string, sourceApp: string | null = null): ClipboardSnapshot sourceApp }) -function started(paste: () => boolean = () => false): { +function started(paste: (report: (pasted: boolean) => void) => void = (r) => r(false)): { session: Session watcher: ReturnType written: string[] @@ -278,9 +278,9 @@ describe('serving (PLAN.md 11, M4)', () => { it('pastes the clip it serves into the window in front', () => { let pastes = 0 - const { session, watcher, written } = started(() => { + const { session, watcher, written } = started((report) => { pastes += 1 - return true + report(true) }) watcher.change(text('into the form')) @@ -292,11 +292,11 @@ describe('serving (PLAN.md 11, M4)', () => { it('serves without pasting when the user has turned that off', () => { let pastes = 0 - const { session, watcher, written } = started(() => { + const { session, watcher, written } = started((report) => { pastes += 1 - return true + report(true) }) - session.setPasteOnServe(false) + session.setAutoPaste(false) watcher.change(text('placed, not typed')) session.serveNext() @@ -307,9 +307,9 @@ describe('serving (PLAN.md 11, M4)', () => { it('does not paste when there was nothing to serve', () => { let pastes = 0 - const { session } = started(() => { + const { session } = started((report) => { pastes += 1 - return true + report(true) }) session.serveNext() @@ -318,6 +318,26 @@ describe('serving (PLAN.md 11, M4)', () => { expect(session.getState().notice?.category).toBe('nothing_to_paste') }) + // A synthesized Ctrl+V can fail for reasons the app cannot control. Failing silently is what + // made the user conclude the app was broken rather than that the clip was on their clipboard. + it('says so when the paste did not land, and says what to do instead', () => { + const { session, watcher } = started((report) => report(false)) + watcher.change(text('somewhere that refused it')) + + session.serveNext() + + expect(session.getState().notice?.message).toMatch(/Ctrl\+V/) + }) + + it('says nothing when the paste landed', () => { + const { session, watcher } = started((report) => report(true)) + watcher.change(text('somewhere that took it')) + + session.serveNext() + + expect(session.getState().notice).toBeNull() + }) + it('leaves the served clip on the clipboard to be pasted as often as the user likes', () => { const { session, watcher, written } = started() watcher.change(text('once served')) diff --git a/src/main/session.ts b/src/main/session.ts index 8ff413e..43cce6c 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -140,26 +140,56 @@ export class Session { * which is also what lets every rule below be tested without launching the app. */ /** - * Whether serving also pastes (PLAN.md 8). On by default: unspooling into the document you are - * already typing in is the thing this app is for, and making it two keystrokes made the second - * one feel like a tax. It stays a setting because a synthesized Ctrl+V does nothing in terminals - * that paste with Ctrl+Shift+V, and because some people would rather place than place-and-type. + * Whether putting something on the clipboard also pastes it (PLAN.md 8). On by default: + * unspooling into the document you are already typing in is the thing this app is for, and making + * it two keystrokes made the second one feel like a tax. + * + * It governs **both** placements. Unspooling pasted and pasting the whole spool did not, which + * made `V` a key that silently changed the clipboard and showed nothing — the inconsistency was + * the bug, not either half of it. + * + * It stays a setting because a synthesized Ctrl+V does nothing in terminals that paste with + * Ctrl+Shift+V, and because Windows refuses synthesized input to an elevated window, which no + * amount of care here can change. */ - private pasteOnServe = true + private autoPaste = true constructor( private readonly writeText: (text: string) => void, - /** Synthesize the paste. Returns false when it declined — our own window was in front. */ - private readonly paste: () => boolean = () => false + /** + * Put the clipboard where the user was working. It may have to dismiss our own window first and + * wait for focus to return, so the answer comes back through the callback rather than as a + * return value — and it always comes back, because a paste that did not land has to be said. + */ + private readonly paste: (report: (pasted: boolean) => void) => void = (report) => report(false) ) {} - setPasteOnServe(enabled: boolean): void { - this.pasteOnServe = enabled + /** + * What happened to the last paste (PLAN.md 8). + * + * A synthesized Ctrl+V can fail for reasons this app cannot control — a terminal that pastes with + * Ctrl+Shift+V, a window running as administrator, which Windows refuses synthesized input to — + * and **failing silently is the worst of the options**. The user presses a key, nothing appears, + * and they conclude the app is broken rather than that the clip is sitting on their clipboard + * waiting for Ctrl+V. So it is reported, in the window, in the words of what to do next. + */ + reportPasteResult(pasted: boolean): void { + if (pasted) return + + this.notice = { + category: 'unsupported', + message: 'It is on your clipboard — press Ctrl+V. Spool could not paste into that window.' + } + this.publish() + } + + setAutoPaste(enabled: boolean): void { + this.autoPaste = enabled this.publish() } - getPasteOnServe(): boolean { - return this.pasteOnServe + getAutoPaste(): boolean { + return this.autoPaste } /** @@ -381,7 +411,7 @@ export class Session { // Then put it where the user was typing. The clip stays on the clipboard afterwards, so the // plan's reason for keeping these separate — serve once, paste into four places — still holds: // this adds the first paste rather than taking the others away (PLAN.md 8). - if (this.pasteOnServe) this.paste() + if (this.autoPaste) this.paste((pasted) => this.reportPasteResult(pasted)) this.publish() } @@ -418,8 +448,15 @@ export class Session { this.state = { ...this.state, pendingSelfWrite: joined.text } this.notice = { category: 'pasted_spool', - message: `${joined.clips} clips are on the clipboard, ready to paste` + message: this.autoPaste + ? `${joined.clips} clips pasted, and still on the clipboard` + : `${joined.clips} clips are on the clipboard, ready to paste` } + + // The whole spool lands the same way a single clip does. Pasting one but not the other made + // this key look broken: it changed the clipboard and showed nothing (PLAN.md 8). + if (this.autoPaste) this.paste((pasted) => this.reportPasteResult(pasted)) + this.publish() } @@ -911,7 +948,7 @@ export class Session { capacity: this.capacityView(), firstRun: this.firstRun, hotkeys: this.hotkeys, - pasteOnServe: this.pasteOnServe, + autoPaste: this.autoPaste, prompt: this.promptView(), privacy: { heuristics: HEURISTIC_RULES, diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index afb2c3b..84a80d9 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -28,7 +28,7 @@ describe('settings (PLAN.md 3, 8)', () => { consentTimeoutSeconds: 45, privacyAcknowledged: true, hotkeys: {}, - pasteOnServe: true + autoPaste: true }) expect(loadSettings(path())).toEqual({ @@ -38,7 +38,7 @@ describe('settings (PLAN.md 3, 8)', () => { consentTimeoutSeconds: 45, privacyAcknowledged: true, hotkeys: {}, - pasteOnServe: true + autoPaste: true }) }) @@ -71,7 +71,7 @@ describe('settings (PLAN.md 3, 8)', () => { consentTimeoutSeconds: 30, privacyAcknowledged: false, hotkeys: {}, - pasteOnServe: true + autoPaste: true }) // The cautious default: a settings file that says nothing about it has not agreed to anything. diff --git a/src/main/settings.ts b/src/main/settings.ts index e7295a1..65b1171 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -36,8 +36,8 @@ export interface Settings { * as an empty string, and is why this is a sparse map rather than a full record (PLAN.md 8). */ readonly hotkeys: Partial> - /** Whether serving a clip also pastes it into the foreground window (PLAN.md 8). */ - readonly pasteOnServe: boolean + /** Whether placing something on the clipboard also pastes it where you were (PLAN.md 8). */ + readonly autoPaste: boolean } export const DEFAULT_SETTINGS: Settings = { @@ -47,7 +47,7 @@ export const DEFAULT_SETTINGS: Settings = { consentTimeoutSeconds: 30, privacyAcknowledged: false, hotkeys: {}, - pasteOnServe: true + autoPaste: true } export function settingsPath(userDataDirectory: string): string { @@ -112,7 +112,9 @@ export function loadSettings(path: string): Settings { privacyAcknowledged: raw.privacyAcknowledged === true, hotkeys: readHotkeys(raw.hotkeys), // Absent means on: the default is the behaviour, and only an explicit false turns it off. - pasteOnServe: raw.pasteOnServe !== false + // `pasteOnServe` is the name this shipped under before it governed the whole spool as well; + // a settings file written by that build still says what its author meant. + autoPaste: raw.autoPaste !== false && (raw as { pasteOnServe?: unknown }).pasteOnServe !== false } } diff --git a/src/main/window.ts b/src/main/window.ts index aee1f5f..01b9ec1 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -107,6 +107,22 @@ export function showCompactWindow(): void { window.focus() } +/** + * Get out of the way so a paste can land where the user was working (PLAN.md 8). + * + * Hiding returns focus to whatever had it before Spool was summoned, which is the window the clip + * is actually for. Returns whether anything was hidden, so the caller knows to wait for focus to + * settle before synthesizing anything. + */ +export function dismissCompactWindow(): boolean { + const window = compactWindow + if (window && window.isVisible()) { + window.hide() + return true + } + return false +} + /** The summon hotkey is a toggle: visible and focused means dismiss (PLAN.md 8). */ export function toggleCompactWindow(): void { const window = compactWindow diff --git a/src/preload/index.ts b/src/preload/index.ts index 41c09bd..ba2f295 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -70,9 +70,9 @@ const api = { /** The capacity advisor (PLAN.md 9): it recommends, the user decides. */ dismissCapacityAdvice: (): Promise => ipcRenderer.invoke(CHANNELS.dismissCapacityAdvice), - /** Whether unspooling also pastes into the window in front (PLAN.md 8). */ - setPasteOnServe: (enabled: boolean): Promise => - ipcRenderer.invoke(CHANNELS.setPasteOnServe, enabled), + /** Whether placing something on the clipboard also pastes it where you were (PLAN.md 8). */ + setAutoPaste: (enabled: boolean): Promise => + ipcRenderer.invoke(CHANNELS.setAutoPaste, enabled), /** Change direction. On the mode pill rather than a hotkey (PLAN.md 8). */ toggleMode: (): Promise => ipcRenderer.invoke(CHANNELS.toggleMode), diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index d6829a7..b5e9e74 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -117,22 +117,23 @@ export function SettingsPanel({ state, onBack }: { state: AppState; onBack: () =

-
+

- The clip stays on the clipboard either way, so you can paste it again elsewhere. Turn - this off if you work in a terminal that pastes with Ctrl+Shift+V, where a synthesized - Ctrl+V does nothing. + Applies to unspooling a clip and to pasting the whole spool. It stays on the clipboard + either way, so you can paste it again elsewhere. Two cases where it will not arrive: + terminals that paste with Ctrl+Shift+V, and windows running as administrator, which + Windows refuses synthesized input to.

diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 31ece53..4279c87 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -27,7 +27,7 @@ declare global { pauseCapture(): Promise acknowledgePrivacy(): Promise toggleMode(): Promise - setPasteOnServe(enabled: boolean): Promise + setAutoPaste(enabled: boolean): Promise setHotkey(action: HotkeyAction, accelerator: string): Promise resetHotkey(action: HotkeyAction): Promise resumeCapture(): Promise diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts index ff6208e..9e07e4e 100644 --- a/src/renderer/state/useAppState.ts +++ b/src/renderer/state/useAppState.ts @@ -31,7 +31,7 @@ const initialState: AppState = { pendingJoin: null, firstRun: false, hotkeys: [], - pasteOnServe: true, + autoPaste: true, capacity: { measure: 'bytes', used: 0, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 497c5a2..01ae58e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -200,8 +200,8 @@ export interface AppState { readonly firstRun: boolean /** Every hotkey and whether it is live, for the panel that doubles as the reference (PLAN.md 8). */ readonly hotkeys: readonly HotkeyView[] - /** Whether unspooling also pastes into the window in front (PLAN.md 8). */ - readonly pasteOnServe: boolean + /** Whether placing something on the clipboard also pastes it where you were (PLAN.md 8). */ + readonly autoPaste: boolean } /** The channel names, in one place so the two sides cannot drift apart. */ @@ -229,7 +229,7 @@ export const CHANNELS = { acknowledgePrivacy: 'spool:acknowledge-privacy', toggleMode: 'spool:toggle-mode', setHotkey: 'spool:set-hotkey', - setPasteOnServe: 'spool:set-paste-on-serve', + setAutoPaste: 'spool:set-auto-paste', resetHotkey: 'spool:reset-hotkey', resumeCapture: 'spool:resume-capture', deleteSpools: 'spool:delete-spools',