Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions native/clipboard/src/clipboard_unsupported.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
69 changes: 56 additions & 13 deletions native/clipboard/src/clipboard_win.cc
Original file line number Diff line number Diff line change
Expand Up @@ -325,31 +325,74 @@ 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<INPUT> 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<UINT>(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) {
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;
}

Expand Down
11 changes: 11 additions & 0 deletions src/main/clipboard/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
36 changes: 30 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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, {
Expand All @@ -82,7 +106,7 @@ if (!app.requestSingleInstanceLock()) {
consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(),
privacyAcknowledged: true,
hotkeys: hotkeyOverrides(),
pasteOnServe: spoolSession.getPasteOnServe()
autoPaste: spoolSession.getAutoPaste()
})
},

Expand Down Expand Up @@ -126,7 +150,7 @@ if (!app.requestSingleInstanceLock()) {
consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(),
privacyAcknowledged: !spoolSession.isFirstRun(),
hotkeys: hotkeyOverrides(),
pasteOnServe: spoolSession.getPasteOnServe()
autoPaste: spoolSession.getAutoPaste()
})
}
})
Expand All @@ -140,7 +164,7 @@ if (!app.requestSingleInstanceLock()) {
consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(),
privacyAcknowledged: !spoolSession.isFirstRun(),
hotkeys: hotkeyOverrides(),
pasteOnServe: spoolSession.getPasteOnServe()
autoPaste: spoolSession.getAutoPaste()
})
)

Expand Down
6 changes: 2 additions & 4 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down Expand Up @@ -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)
Expand Down
36 changes: 28 additions & 8 deletions src/main/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fakeWatcher>
written: string[]
Expand Down Expand Up @@ -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'))

Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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'))
Expand Down
Loading
Loading