From 65c3fdcfad2b87c6584743ddd7922bcad8e87235 Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Thu, 3 Sep 2026 22:23:50 -0500 Subject: [PATCH 1/3] Author the Store tiles rather than shipping Electron's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit electron-builder substitutes stock placeholder tiles when build/appx is empty, and those placeholders are the Electron logo. The package built cleanly and would have reached the Store carrying someone else's mark, which is the kind of defect that only shows up if you open the artifact and look at it. The six assets are drawn from the same geometry and palette as build/icon.ico — cream flanges and core, three orange thread bands, on the #171614 ground — so the tiles, the installer icon and the tray icon are one design. Two sizes the defaults omitted, 71x71 and 310x310, are included. STORE.md records what Partner Center has to supply, why the Store build keeps the zero-network guarantee where an auto-updater would not, that arm64 needs the ARM64 MSVC toolset which is not installed here, and the two runtime questions that cannot be answered without an elevated machine. Co-Authored-By: Claude Opus 5 --- STORE.md | 63 +++++++++++++++++++++++++++++++ build/appx/Square150x150Logo.png | Bin 0 -> 2245 bytes build/appx/Square310x310Logo.png | Bin 0 -> 5246 bytes build/appx/Square44x44Logo.png | Bin 0 -> 630 bytes build/appx/Square71x71Logo.png | Bin 0 -> 893 bytes build/appx/StoreLogo.png | Bin 0 -> 683 bytes build/appx/Wide310x150Logo.png | Bin 0 -> 2884 bytes 7 files changed, 63 insertions(+) create mode 100644 STORE.md create mode 100644 build/appx/Square150x150Logo.png create mode 100644 build/appx/Square310x310Logo.png create mode 100644 build/appx/Square44x44Logo.png create mode 100644 build/appx/Square71x71Logo.png create mode 100644 build/appx/StoreLogo.png create mode 100644 build/appx/Wide310x150Logo.png diff --git a/STORE.md b/STORE.md new file mode 100644 index 0000000..e036f68 --- /dev/null +++ b/STORE.md @@ -0,0 +1,63 @@ +# Submitting Spool to the Microsoft Store + +Everything here is about delivery. The application inside the MSIX is byte-for-byte the one in the +NSIS installer; only packaging, identity, and update mechanism differ. + +## Why the Store build keeps the zero-network guarantee + +Spool ships no updater, because an updater is network code and would void the claim the whole app is +built on (see `PLAN.md` §5e). The Store does not change that: **Windows** performs the update, the +app still contains no code that reaches the network, and `npm run check:network` gates every build +either way. This is the one distribution channel that gives users automatic updates without Spool +having to break its own promise. + +## What has to come from Partner Center + +Reserve the app name first; the reservation produces the identity values. Fill these into the `appx` +block of `electron-builder.yml` — they cannot be guessed, and a mismatch makes the package +unsubmittable: + +| Field | Where it comes from | +|---|---| +| `identityName` | Partner Center → Product identity → **Package/Identity/Name** | +| `publisher` | Partner Center → Product identity → **Package/Identity/Publisher** (the `CN=…` string) | +| `publisherDisplayName` | Partner Center → Product identity → **Package/Properties/PublisherDisplayName** | + +Then `npm run package:store` produces `release/Spool .appx` for upload. + +**The Store signs the package**, which is why electron-builder reports "AppX is not signed — Windows +Store only build" and why the unsigned NSIS installer's SmartScreen problem does not apply here. The +Azure Trusted Signing account is needed only for the direct-download installer. + +## Findings from building it + +**Tile assets had to be authored.** electron-builder ships stock placeholder tiles when +`build/appx/` is empty, and those placeholders are the **Electron logo** — the package built cleanly +and would have gone to the Store carrying someone else's mark. The six assets in `build/appx/` are +generated from the same geometry and palette as `build/icon.ico` so the tiles, the installer icon and +the tray icon are one design. Anything dropped in that directory overrides them. + +**arm64 does not build on a stock x64 toolchain.** `electron-builder --win appx --arm64` fails in +`node-gyp` with `MSB8020: The build tools for v143 (Platform Toolset = 'v143') cannot be found`, +because both native modules must be compiled for arm64 and the ARM64 compilers are a separate +component: install **MSVC v143 — VS 2022 C++ ARM64/ARM64EC build tools** in the Visual Studio Build +Tools installer. This is optional — Windows on ARM runs x64 packages under emulation — so x64-only +is a legitimate first submission, at some cost in performance and battery on those machines. + +**The manifest is right for a desktop app with native code.** It declares the `runFullTrust` +restricted capability and `EntryPoint="Windows.FullTrustApplication"`, and both `.node` binaries — +the clipboard addon and SQLCipher — are present under `app.asar.unpacked`. Without full trust the +clipboard listener could not run at all. + +## Still to verify, and why it needs an elevated machine + +Installing any MSIX requires either Developer Mode (to register a loose layout) or a signing +certificate trusted in **LocalMachine\TrustedPeople** (to install a signed package). Both need +administrator rights, so these two questions are open until that is available: + +1. **The clipboard listener under package identity.** `AddClipboardFormatListener` should be + unaffected by full-trust packaging, but it has never been observed running from inside an MSIX. +2. **`safeStorage` and the user-data path.** Packaged apps can have `%APPDATA%` redirected into the + package's own writable store. Spool's key is sealed by DPAPI through a key kept in `Local State` + *inside the user-data directory*, so if that directory moves, the existing key must still open the + existing database — the same invariant the NSIS upgrade test proved for v1 → v4. diff --git a/build/appx/Square150x150Logo.png b/build/appx/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..bdca4c1006133b9258e8295aef1157f23ba56242 GIT binary patch literal 2245 zcmeAS@N?(olHy`uVBq!ia0y~yVAKI&4mO}jWo=(6kYX$ja(7}_cTVOdki(Mh=>ZPKqrA`a8eSoYo^ETDX;v$x4fY# zIse>llcO&ZYX4khpZ@>vcYB9}8_l1`&T`uDfKfTZmxcQk%j(Vq$GHinf;uaN*QhnI zmNs2=*|5NG!z>Q5E1Y4%2b-)8W+_Dks1I_*PhKvczw6Jt*5~ts*>h^@ELTeJ5t~zfh;tVr5 z_)0NiCyVxsgs%c?VwyyKHdJwh=^f-!i?C(Uo}R!eyhg5Rs`rK_&M^Lir&I@t_NMFg z)!#pzojf$B{$LG8SQEUp>-Lw4fRebyO_kP?nNFw(;9y2$+cQ!Bu z^Z)qzp&@>-r&(BFH@+9z)cjs(9X(PgdZ<45xVv2MyzR#qSNHtBwfA)Y{o0dLWuMpW z>z>=U)9SpGx$v3Dym({rK9Ck^u$K!dd6R mL8XWT5>2c%M?GRHc_8ybxVPt9~qVP46!a{t54f8HGWdjI>=&+48&eZ+ zLt0br{86FNkQi=h;=|GB^JDk@e;3X7@qPW@&!1jRpZ_p!_qzvW(tk>0@2|YQy*sjr z_s~3>x-ZN32k*#VXT9St+lPNF(k+~30yz`ZZYcO{XiR7}ImnjS#LFVRgwu>eI7i`z zg6{^$841h>*)oBeS)@74JcM%u)NVLzaGaIUY;cgRiTBc|5d$6;J(b^1x=+8q|Bu82 ziR-(|s$W0Q<1jnMVfMhJsaLb9*RYA#qBkMAD5;-P`5`>to9ccJn^?J6iG&SS^@5ulCc$r{DGM*({EI zU3d4M)ei2_(wnGy>hp(#?e#L-FYm4R_44W0)6XHPxmP&${jIlan|A{nGH*^kcAs9J zfByiagm3t+KKzq=2mtud!`f@aI%#gv;)z4*}Q$iB} DuLXjr literal 0 HcmV?d00001 diff --git a/build/appx/Square44x44Logo.png b/build/appx/Square44x44Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..498940de7c55a76d0e5e24e52fe6b6a61d67b22a GIT binary patch literal 630 zcmeAS@N?(olHy`uVBq!ia0vp^5g^RL1|$oo8khhn#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=DjSL74G){)!X^1}1J#7srr_xVN_#dOHV7w0-<5xJi+@b*ZTA z;UyeOlNFhD#l)|qTu@;%>~u8gU=y6}%Chlv*RB-F86g6bV||)_o+?Rw9&_hm(Y@;O zhn)L28SHzX_;7df=_i|*8B2_waewGOki;UUyg{PzD2I+)f+174V8j{A4=+y^Uf7ep z;PJ0lhq6SDSr+{E-M`=N;>T~lQcSs=A9TQ#_Mqy1-?)6aTJz7Hv&8$?wja^azq+<^ z(Tug{B6<&5uzx%G$K*)nnwU+`0+mp#1DX-XeErnV2Q^D?Tt0oXC`m8e`cv zjSh)p-p-z#J2fbOany#g?yduhAged-&{%(^jm3nIcBE!@lGT^PJ4C0CgTQ&OcO*5q`qC6In1BaKjhu}&*S$X q4?W@nXZJ}+RPmVb-$jyY23SS^Y)+5PhP+L3d+{9#6+@AH`2J}$P-&$~P&b^y4`?p;`dRcyc&cCYPX;ShIlK`#0_|Z+EnCpHtAm(cVFb@DV@Ps$XvY{au~^#?#aF^xmCKjL?- Z{n9>jgVzYW)CFc422WQ%mvv4FO#mwBcY6Q; literal 0 HcmV?d00001 diff --git a/build/appx/StoreLogo.png b/build/appx/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..15333a906119cc63b8b10825150348c4e41bd4e8 GIT binary patch literal 683 zcmeAS@N?(olHy`uVBq!ia0vp^DImKUpC5- zuX7K*!gp!PTlJ&$^S7DZ{Q1w%Qb0wq!+Ao70;i))lM9O{$0Gq1C6A)@O>g+C63z6& z=AC_*o-O65r2uj3jIErXR$S-(dz@dq>aPBww?{oeMo&&J{&92GHLnRq72n)oW;hA7 zD+oGHsun#mujgA^{FAqopzq%P)o(8!=TH1qJqhHJEj-}B+}ix%vGM0k|9|i}J`=k44ofy`glX(f`u%tWs zIx;Y9?C1WI$O`0h7I;J!GcfQS24TkI`72Tw7`SeFx;TbZ#J#<-k$Y~s=z)j-1e4kt zoYkhQ%?>E!PSJQFBUpNBf`g%g%ZipL0e5%yl_I~?J4!a{dc-ce`|4es5vx)B&ui=1 z4_BY4K5zL@a^BOv_m=IyA6LIVR=&TOkzv95?dR+n7%p@tFf(NMZD3?DQNO{!FiRNd z^0S;~3=Pj%q!}EFn|K)(SRQ0!xG-mEDpUFUC;hvA{g1!z8(5Fuos-X5VS13wbAHXA zi%*|+KR?j)IPctcmOZl)npF<6y=IYq!eQnil(WHagJa*Q5LH8>>iJyxn7U7o%kM|{ z*Z-bkpLH46tKDe0u(+Ln-`CQXzkTKQ=b0QfFe)AlbKbxx zc#y4yC7Smjn+uDyfKZMpkQB^u@YrxwAjiRFI4E+q*;oGh)0>mB6>IzUTD_OL(4Ww( z$RcgcVK&2Y!$E*Idhnm0aXJPP-0xg?`yrM@7^|F|LpMhck%mv{wiMgrsDf& zoieL$ul8g+*f;TV*jD{`@afZ0s070hjwhbN@88azeq6nM-Lv!itG<7_zyI$k~ zZMx?o;-=63db`%G7hk1&qr^g&GpLj@9&w# ze}8xV&u6dC@7(nG?)l9(Ufx*!c^%_l4l{)>FCI3ZJ}nNFU;q|sbSc_YYCnAWds=*c z?XP3MKFQbb|MNROz9{wh?)m8-)hypVtMNXtn$yhV*OSNnz#^ID$N#?FmiL7|WHs?l zVv$}WoHIfBhJxpYgH{LGEJuaN4~dt}%iqWE`|&0?;b5Wp^Ju0x-{IO6gSIAj{%73w X@YdVBmH#t Date: Sun, 6 Sep 2026 06:34:06 -0500 Subject: [PATCH 2/3] Give hotkeys that other apps had taken back to the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the four default hotkeys never worked. Probing all twenty-six Win+Alt+ combinations on Windows 11 found thirteen already owned: N is OneNote's Quick Note, and M, R, G, B, T and W belong to the Xbox Game Bar, which ships with Windows. Win+Alt+M was not unlucky, it was dead for nearly every Windows 11 user. Registration had been failing honestly all along and the tray had been saying so, but nobody opens the tray to find out why a key they just pressed did nothing — which is the exact outcome PLAN.md 8 warned about. The keys are reallocated to say what they do: C for clipboard summons, U unspools the next clip, V pastes the whole spool the way Ctrl+V pastes one thing. Unspooling owns the repeat gesture, so pasting everything is not a double-press of anything — a repeated press means "give me the next one", and spending it on "give me everything" would hand that gesture to the action that makes FIFO and LIFO moot. Toggling the mode loses its hotkey entirely and becomes the mode pill: it is something you do while looking at the spool, not while typing elsewhere. But no default can be right on every machine, so the real fix is the rebinding UI PLAN.md 8 asked for and never got. The ? button opens it, carries a count of dead keys, and the footer strikes through any binding the OS refused. Rebinding is two dropdowns and an attempt rather than "press what you want", because the Windows shell eats Win-key presses before a renderer sees them. Ctrl+Alt is offered but is not the default: it measured completely free here and is AltGr abroad, where Ctrl+Alt+C types ć. Also fixes Clear spools, which counted the active spool and then skipped it, so "Clear 1 spool" did nothing when the only unstarred spool was the active one. It now clears the active spool and falls back to the default, because the button states what it spares and must not spare something it does not name. Co-Authored-By: Claude Opus 5 --- PLAN.md | 43 +++++- src/main/accelerators.test.ts | 80 ++++++++++- src/main/accelerators.ts | 90 +++++++++--- src/main/hotkeys.ts | 123 +++++++++++++--- src/main/index.ts | 49 +++++-- src/main/ipc/index.ts | 15 ++ src/main/session.test.ts | 25 ++++ src/main/session.ts | 27 +++- src/main/settings.test.ts | 9 +- src/main/settings.ts | 31 +++- src/preload/index.ts | 18 ++- src/renderer/components/App.tsx | 62 ++++++-- src/renderer/components/HotkeysPanel.tsx | 135 ++++++++++++++++++ src/renderer/env.d.ts | 7 +- .../helpers/HotkeysPanelHelper.test.ts | 45 ++++++ src/renderer/helpers/HotkeysPanelHelper.ts | 27 ++++ src/renderer/state/useAppState.ts | 1 + src/shared/hotkeys.ts | 40 ++++++ src/shared/ipc.ts | 26 ++++ 19 files changed, 763 insertions(+), 90 deletions(-) create mode 100644 src/renderer/components/HotkeysPanel.tsx create mode 100644 src/renderer/helpers/HotkeysPanelHelper.test.ts create mode 100644 src/renderer/helpers/HotkeysPanelHelper.ts create mode 100644 src/shared/hotkeys.ts diff --git a/PLAN.md b/PLAN.md index 97b5d45..dfb1813 100644 --- a/PLAN.md +++ b/PLAN.md @@ -656,14 +656,20 @@ All rebindable. The defaults deliberately avoid paste-adjacent combinations: | Action | Windows | macOS | |---|---|---| -| Summon / dismiss | `Win + Alt + V` or `Win + Alt + C` | `Ctrl + Option + V` or `Ctrl + Option + C` | -| Serve next clip | `Win + Alt + N` | `Ctrl + Option + N` | -| Paste the whole spool (§3) | `Win + Alt + A` | `Ctrl + Option + A` | -| Toggle FIFO / LIFO | `Win + Alt + M` | `Ctrl + Option + M` | +| Summon / dismiss | `Win + Alt + C` | `Ctrl + Option + C` | +| Unspool the next clip | `Win + Alt + U` | `Ctrl + Option + U` | +| Paste the whole spool (§3) | `Win + Alt + V` | `Ctrl + Option + V` | +| Toggle FIFO / LIFO | *no hotkey — the mode pill* | *no hotkey — the mode pill* | -Summon carries two bindings on each platform, because both are things a hand reaches for: `V` for the -paste-adjacent muscle memory, `C` for "clipboard". Either summons; neither is primary. The other three -actions take one binding each. +`C` is for clipboard and opens the window; `V` pastes, the way `Ctrl+V` pastes, except that it pastes +the whole spool; `U` unspools the next clip. **Unspooling owns the repeat gesture** — press `U` again +and again and clips come off in the mode's order — which is why pasting the whole spool is not a +double-press of anything. A repeated press means "give me the next one"; spending it on "give me +everything at once" would hand the unspool gesture to the one action that makes the ordering moot. + +**Toggling the mode has no hotkey, deliberately.** It is something you do while looking at the spool, +not while typing in another application, so it lives on the mode pill in the window and spends no +global combination — which are scarce, as the measurement below shows. A global hotkey **shadows the foreground application**, so the defaults matter more than they look. Two hazards worth stating outright: @@ -686,6 +692,29 @@ concludes the app is broken. On failure, say which combination was refused and o An action with two bindings is live as long as one of them is claimed, and the refused one is still named — a half-working hotkey the user cannot see the shape of is its own kind of broken. +**Measured, and the original defaults were wrong.** Probing all twenty-six `Win+Alt+` +combinations on Windows 11 found **thirteen already owned**: `A B D F G K M N R S T W Y`. `N` is +OneNote's Quick Note. `M`, `R`, `G`, `B`, `T` and `W` are the Xbox Game Bar, **which ships with +Windows** — so `Win+Alt+M` was not unlucky, it was dead for nearly every Windows 11 user, and +`Win+Alt+N` for anyone with Office. Two of the four original defaults never worked. The user found +this the way the paragraph above predicts: by pressing keys that did nothing and concluding the app +was broken, while the tray quietly held the explanation nobody opens the tray to read. + +The lesson is not that better letters exist. It is that **no default can be right on every machine**, +because which combinations are free depends on what else is installed — so the rebinding UI is not a +nicety, it is the only correct answer, and the refusal has to appear in the window rather than the +tray. The `?` button carries a count of dead keys for exactly this reason: it is the only way a +refusal reaches someone who has not gone looking for one. + +`Ctrl+Alt` was measured completely free on the same machine and is still **not** the Windows default, +because free-on-a-US-layout is not free: it is `AltGr` abroad, where `Ctrl+Alt+C` types `ć`. It is +offered as a rebinding choice carrying that warning. + +Rebinding is two dropdowns and a *try*, not "press the combination you want", because the Windows +shell eats `Win`-key presses before a renderer sees them — a capture box could not hear the family the +defaults live in. Picking and then attempting is also the honest shape: the operating system decides +who gets a combination, not this app. + --- ## 9. The capacity advisor diff --git a/src/main/accelerators.test.ts b/src/main/accelerators.test.ts index f5fd980..a73f3a5 100644 --- a/src/main/accelerators.test.ts +++ b/src/main/accelerators.test.ts @@ -1,20 +1,41 @@ import { describe, expect, it } from 'vitest' import { + ACTIONS, defaultAccelerators, describeAccelerator, describeAction, + resolveAccelerators, + splitAccelerator, type Platform } from './accelerators' const PLATFORMS: Platform[] = ['win32', 'darwin', 'linux'] describe('defaultAccelerators', () => { - it('summons with Win+Alt+V or Win+Alt+C on Windows', () => { - expect(defaultAccelerators('summon', 'win32')).toEqual(['Super+Alt+V', 'Super+Alt+C']) + it('summons with Win+Alt+C on Windows — C for clipboard', () => { + expect(defaultAccelerators('summon', 'win32')).toEqual(['Super+Alt+C']) }) - it('summons with Ctrl+Option+V or Ctrl+Option+C on macOS', () => { - expect(defaultAccelerators('summon', 'darwin')).toEqual(['Control+Alt+V', 'Control+Alt+C']) + it('unspools with Win+Alt+U and pastes the whole spool with Win+Alt+V', () => { + expect(defaultAccelerators('serve', 'win32')).toEqual(['Super+Alt+U']) + expect(defaultAccelerators('pasteAll', 'win32')).toEqual(['Super+Alt+V']) + }) + + it('summons with Ctrl+Option+C on macOS', () => { + expect(defaultAccelerators('summon', 'darwin')).toEqual(['Control+Alt+C']) + }) + + // Measured on Windows 11: OneNote owns Win+Alt+N and the Xbox Game Bar owns Win+Alt+M, and the + // Game Bar ships with Windows. Both were defaults once, and both were dead for most users. + it.each(['N', 'M', 'A'])('does not default to Win+Alt+%s, which is commonly taken', (key) => { + for (const action of ACTIONS) { + expect(defaultAccelerators(action, 'win32')).not.toContain(`Super+Alt+${key}`) + } + }) + + it('gives every action a distinct key, so one press cannot mean two things', () => { + const all = ACTIONS.flatMap((action) => defaultAccelerators(action, 'win32')) + expect(new Set(all).size).toBe(all.length) }) it.each(PLATFORMS)('never claims Ctrl+Shift+V on %s', (platform) => { @@ -32,6 +53,51 @@ describe('defaultAccelerators', () => { const accelerators = defaultAccelerators('summon', platform) expect(new Set(accelerators).size).toBe(accelerators.length) }) + + // Ctrl+Alt is AltGr on international layouts, where Ctrl+Alt+C types 'ć'. It is offered as a + // rebinding choice, but it must never be what Windows users get by default. + it('never defaults to Ctrl+Alt on Windows, which is AltGr abroad', () => { + for (const action of ACTIONS) { + for (const accelerator of defaultAccelerators(action, 'win32')) { + expect(accelerator.startsWith('Control+Alt')).toBe(false) + } + } + }) +}) + +describe('resolveAccelerators', () => { + it('uses the default when the user has chosen nothing', () => { + expect(resolveAccelerators('serve', 'win32', {})).toEqual(['Super+Alt+U']) + }) + + it('replaces the default rather than joining it, so a refusal is not kept alive', () => { + expect(resolveAccelerators('serve', 'win32', { serve: 'Control+Shift+Alt+J' })).toEqual([ + 'Control+Shift+Alt+J' + ]) + }) + + it('ignores an empty choice', () => { + expect(resolveAccelerators('serve', 'win32', { serve: '' })).toEqual(['Super+Alt+U']) + }) +}) + +describe('splitAccelerator', () => { + it('separates the modifier family from the key', () => { + expect(splitAccelerator('Super+Alt+U')).toEqual({ modifier: 'Super+Alt', key: 'U' }) + expect(splitAccelerator('Control+Shift+Alt+J')).toEqual({ + modifier: 'Control+Shift+Alt', + key: 'J' + }) + }) + + it('round-trips whatever defaultAccelerators produces', () => { + for (const action of ACTIONS) { + for (const accelerator of defaultAccelerators(action, 'win32')) { + const { modifier, key } = splitAccelerator(accelerator) + expect(`${modifier}+${key}`).toBe(accelerator) + } + } + }) }) describe('describeAccelerator', () => { @@ -45,8 +111,8 @@ describe('describeAccelerator', () => { }) describe('describeAction', () => { - it('reads both summon bindings as one phrase', () => { - expect(describeAction('summon', 'win32')).toBe('Win+Alt+V or Win+Alt+C') - expect(describeAction('summon', 'darwin')).toBe('Ctrl+Option+V or Ctrl+Option+C') + it('names the summon binding on each platform', () => { + expect(describeAction('summon', 'win32')).toBe('Win+Alt+C') + expect(describeAction('summon', 'darwin')).toBe('Ctrl+Option+C') }) }) diff --git a/src/main/accelerators.ts b/src/main/accelerators.ts index b016bd7..5822bb3 100644 --- a/src/main/accelerators.ts +++ b/src/main/accelerators.ts @@ -1,34 +1,67 @@ /** - * Default global hotkeys (PLAN.md 8, "Hotkeys"). Pure: platform in, accelerators out. + * Global hotkeys (PLAN.md 8, "Hotkeys"). Pure: platform and overrides in, accelerators out. * - * The defaults deliberately avoid paste-adjacent combinations, and on Windows they avoid the - * `Win+Shift+*` family the shell reserves for itself. Summon carries two bindings — `V` for the - * paste-adjacent muscle memory, `C` for "clipboard" — because either is a reasonable thing to - * reach for. The rest take one binding each. + * **The defaults are a guess and the app must not pretend otherwise.** A global accelerator is + * granted first-come-first-served by the operating system, so which ones are available depends on + * what else is installed. Measured on Windows 11: of the twenty-six `Win+Alt+` combinations, + * thirteen were already owned — `N` by OneNote, and `M`, `R`, `G`, `B`, `T`, `W` by the Xbox Game + * Bar, which ships with Windows. Two of the three original defaults were therefore dead on arrival + * for most users rather than for unlucky ones. The answer is not a cleverer guess; it is that every + * binding is rebindable and every refusal is said out loud. + * + * `Ctrl+Alt` is deliberately not the Windows default: it is `AltGr` on international layouts, where + * `Ctrl+Alt+N`, `+A` and `+C` type `ń`, `ą` and `ć`. It is offered as a rebinding choice, carrying + * that warning, because on a US layout it is the emptiest space available. */ export type Platform = 'win32' | 'darwin' | 'linux' -export type Action = 'summon' | 'serve' | 'pasteAll' | 'toggleMode' +export { + buildAccelerator, + KEY_CHOICES, + MODIFIER_CHOICES, + splitAccelerator +} from '../shared/hotkeys' + +/** + * Toggling FIFO/LIFO is deliberately absent. It is something you do while looking at the spool, not + * while typing in another application, so it lives on the mode pill in the window and spends no + * global accelerator (PLAN.md 8). + */ +export type Action = 'summon' | 'serve' | 'pasteAll' const WINDOWS_MODIFIER = 'Super+Alt' const MAC_MODIFIER = 'Control+Alt' +/** + * One binding each, and the letters say what they do: `C` for clipboard opens the window, `U` + * unspools the next clip, `V` pastes — the whole spool, the way `Ctrl+V` pastes one thing. + * + * Serving carries the repeat gesture: press `U` again and again and clips come off in whatever + * order the mode says. That is why pasting the whole spool is not a double-press of anything — + * a repeated press means "give me the next one", and spending it on "give me everything at once" + * would take the unspool gesture and hand it to the action that makes the ordering moot. + */ const KEYS: Record = { - summon: ['V', 'C'], - serve: ['N'], - pasteAll: ['A'], - toggleMode: ['M'] + summon: ['C'], + serve: ['U'], + pasteAll: ['V'] } -/** Every action, in the order the tray lists them. */ -export const ACTIONS: readonly Action[] = ['summon', 'serve', 'pasteAll', 'toggleMode'] +/** Every action, in the order the tray and the hotkey panel list them. */ +export const ACTIONS: readonly Action[] = ['summon', 'serve', 'pasteAll'] /** What each action is called where a person reads it. */ export const ACTION_LABELS: Record = { - summon: 'Summon', - serve: 'Serve next clip', - pasteAll: 'Paste the whole spool', - toggleMode: 'Toggle FIFO / LIFO' + summon: 'Summon or dismiss the window', + serve: 'Unspool the next clip', + pasteAll: 'Paste the whole spool' +} + +/** A line of help under each action, so the panel doubles as the reference (PLAN.md 8). */ +export const ACTION_HINTS: Record = { + summon: 'Press it again to send the window away.', + serve: 'Press it repeatedly to unspool clip after clip, in the current order.', + pasteAll: 'Puts every clip on the clipboard at once, joined by your separator.' } /** Every accelerator a platform binds to an action. Non-Windows platforms follow the macOS shape. */ @@ -37,6 +70,23 @@ export function defaultAccelerators(action: Action, platform: Platform): string[ return KEYS[action].map((key) => `${modifier}+${key}`) } +/** + * What this install should actually try to claim: the user's choice where they have made one, the + * default otherwise. An override replaces the defaults rather than joining them — a rebinding that + * left the refused combination in place would keep reporting a refusal the user has already dealt + * with. + */ +export function resolveAccelerators( + action: Action, + platform: Platform, + overrides: Partial> = {} +): string[] { + const chosen = overrides[action] + return chosen === undefined || chosen.length === 0 + ? defaultAccelerators(action, platform) + : [chosen] +} + /** How one accelerator should read to a user on this platform. */ export function describeAccelerator(accelerator: string, platform: Platform): string { return platform === 'darwin' @@ -45,8 +95,12 @@ export function describeAccelerator(accelerator: string, platform: Platform): st } /** How an action's whole set of bindings should read — "Win+Alt+V or Win+Alt+C". */ -export function describeAction(action: Action, platform: Platform): string { - const described = defaultAccelerators(action, platform).map((accelerator) => +export function describeAction( + action: Action, + platform: Platform, + overrides: Partial> = {} +): string { + const described = resolveAccelerators(action, platform, overrides).map((accelerator) => describeAccelerator(accelerator, platform) ) return described.join(' or ') diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index bfb16cc..e4c48fe 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -1,8 +1,12 @@ import { globalShortcut } from 'electron' +import type { HotkeyView } from '../shared/ipc' import { + ACTION_HINTS, + ACTION_LABELS, ACTIONS, defaultAccelerators, describeAccelerator, + resolveAccelerators, type Action, type Platform } from './accelerators' @@ -10,6 +14,26 @@ import { reportHotkeyStatus, type HotkeyStatus } from './tray' export type HotkeyHandlers = Record void> +/** One action's binding and whether the operating system actually granted it. */ +export interface HotkeyBinding { + readonly action: Action + /** The Electron accelerator, which is what gets stored and re-registered. */ + readonly accelerator: string + /** The same thing as a person reads it: `Win+Alt+C`. */ + readonly described: string + readonly claimed: boolean +} + +/** + * Kept so a rebinding can re-register everything without the caller having to hand the handlers + * back. There is one set of global hotkeys in a process, so one module-level record is honest about + * what is going on rather than hiding it behind an object nobody owns twice. + */ +let handlers: HotkeyHandlers | null = null +let overrides: Partial> = {} +let platform: Platform = process.platform as Platform +let bindings: HotkeyBinding[] = [] + function claim(accelerator: string, handler: () => void): boolean { try { return globalShortcut.register(accelerator, handler) @@ -18,31 +42,90 @@ function claim(accelerator: string, handler: () => void): boolean { } } +function apply(): HotkeyBinding[] { + globalShortcut.unregisterAll() + if (handlers === null) return [] + + bindings = ACTIONS.flatMap((action) => + resolveAccelerators(action, platform, overrides).map((accelerator): HotkeyBinding => ({ + action, + accelerator, + described: describeAccelerator(accelerator, platform), + claimed: claim(accelerator, handlers![action]) + })) + ) + + reportHotkeyStatus(toStatuses(bindings)) + return bindings +} + +/** The tray wants the per-action shape; the panel wants the per-binding one. */ +function toStatuses(all: readonly HotkeyBinding[]): HotkeyStatus[] { + return ACTIONS.map((action) => { + const mine = all.filter((binding) => binding.action === action) + return { + action, + claimed: mine.filter((b) => b.claimed).map((b) => b.described), + refused: mine.filter((b) => !b.claimed).map((b) => b.described) + } + }) +} + /** - * Claim the global hotkeys. Registration can fail when another application — or the shell itself — - * already owns a combination, and that failure is reported rather than swallowed (PLAN.md 8). An - * action with several bindings works as long as one of them is claimed; the refused ones are still - * named, so the user is never left guessing which key is live. + * Claim the global hotkeys. Registration fails when another application — or the shell itself — + * already owns a combination, and **that failure is surfaced rather than swallowed** (PLAN.md 8): + * a silently dead hotkey is the worst outcome, because the user concludes the app is broken. The + * result goes to the tray and, through the session, to the hotkey panel where it can be fixed. */ export function registerHotkeys( - handlers: HotkeyHandlers, - platform: Platform = process.platform as Platform -): HotkeyStatus[] { - const statuses = ACTIONS.map((action): HotkeyStatus => { - const claimed: string[] = [] - const refused: string[] = [] - - for (const accelerator of defaultAccelerators(action, platform)) { - const described = describeAccelerator(accelerator, platform) - if (claim(accelerator, handlers[action])) claimed.push(described) - else refused.push(described) - } + nextHandlers: HotkeyHandlers, + nextOverrides: Partial> = {}, + nextPlatform: Platform = process.platform as Platform +): HotkeyBinding[] { + handlers = nextHandlers + overrides = nextOverrides + platform = nextPlatform + return apply() +} - return { action, claimed, refused } - }) +/** + * Take a new combination for one action and try it. Everything is re-registered rather than only + * the changed action, because releasing one accelerator can free another that was refused for + * colliding with it. + */ +export function rebindHotkey(action: Action, accelerator: string): HotkeyBinding[] { + overrides = { ...overrides, [action]: accelerator } + return apply() +} + +/** Give an action its default binding back. */ +export function resetHotkey(action: Action): HotkeyBinding[] { + const next = { ...overrides } + delete next[action] + overrides = next + return apply() +} + +/** What the user has chosen, for persisting to settings. */ +export function hotkeyOverrides(): Partial> { + return { ...overrides } +} + +export function currentBindings(): readonly HotkeyBinding[] { + return bindings +} - reportHotkeyStatus(statuses) - return statuses +/** What the window shows: every binding, whether it is live, and whether the user chose it. */ +export function hotkeyViews(): HotkeyView[] { + return bindings.map((binding) => ({ + action: binding.action, + label: ACTION_LABELS[binding.action], + hint: ACTION_HINTS[binding.action], + accelerator: binding.accelerator, + described: binding.described, + claimed: binding.claimed, + isDefault: defaultAccelerators(binding.action, platform).includes(binding.accelerator) + })) } export function unregisterHotkeys(): void { diff --git a/src/main/index.ts b/src/main/index.ts index ccf1e97..a3caf03 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,14 @@ import { blockSessionRequests, installNetworkGuard } from './guard' installNetworkGuard() import { app, BrowserWindow, safeStorage, session } from 'electron' -import { registerHotkeys, unregisterHotkeys } from './hotkeys' +import { + hotkeyOverrides, + hotkeyViews, + rebindHotkey, + registerHotkeys, + resetHotkey, + unregisterHotkeys +} from './hotkeys' import { registerIpc } from './ipc' import { Session } from './session' import { explainStorageFailure, openStore, resetEverything, startFresh, storePaths } from './store' @@ -72,10 +79,25 @@ if (!app.requestSingleInstanceLock()) { window: settings.window, activeSpoolId: spoolSession.getActiveSpoolId(), consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), - privacyAcknowledged: true + privacyAcknowledged: true, + hotkeys: hotkeyOverrides() }) }, + /** + * Take a new combination, try it, and say what happened. The answer reaches the window + * through the state, so a refusal is visible where the user is already looking (PLAN.md 8). + */ + setHotkey: (action, accelerator) => { + rebindHotkey(action, accelerator) + spoolSession.setHotkeys(hotkeyViews()) + }, + + resetHotkey: (action) => { + resetHotkey(action) + spoolSession.setHotkeys(hotkeyViews()) + }, + /** * The failsafe (PLAN.md 11, M9). The handle is closed first because Windows will not delete * a file that is still open; everything after that is blind deletion, so a corrupt store @@ -100,7 +122,8 @@ if (!app.requestSingleInstanceLock()) { window: state, activeSpoolId: spoolSession.getActiveSpoolId(), consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), - privacyAcknowledged: !spoolSession.isFirstRun() + privacyAcknowledged: !spoolSession.isFirstRun(), + hotkeys: hotkeyOverrides() }) } }) @@ -112,18 +135,24 @@ if (!app.requestSingleInstanceLock()) { window: settings.window, activeSpoolId: spoolSession.getActiveSpoolId(), consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), - privacyAcknowledged: !spoolSession.isFirstRun() + privacyAcknowledged: !spoolSession.isFirstRun(), + hotkeys: hotkeyOverrides() }) ) createCompactWindow() createTray() - registerHotkeys({ - summon: () => toggleCompactWindow(), - serve: () => spoolSession.serveNext(), - pasteAll: () => spoolSession.pasteWholeSpool(), - toggleMode: () => spoolSession.toggleMode() - }) + registerHotkeys( + { + summon: () => toggleCompactWindow(), + serve: () => spoolSession.serveNext(), + pasteAll: () => spoolSession.pasteWholeSpool() + }, + settings.hotkeys + ) + // A refused hotkey has to reach the window, not only the tray: a silently dead key is the + // worst outcome, because the user concludes the app is broken (PLAN.md 8). + spoolSession.setHotkeys(hotkeyViews()) // Watching starts once there is a window to report to, so a failure to load the addon is // visible rather than lost to a console nobody is reading (PLAN.md 8). diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 180a5f1..9e6148a 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -6,6 +6,7 @@ import { type SeparatorKind, type WindowStateName } from '../../shared/ipc' +import type { HotkeyAction } from '../../shared/ipc' import type { Session } from '../session' /** @@ -18,6 +19,10 @@ export interface IpcActions { startFreshStore: () => void /** Record that the privacy statement was read, and let capture begin (PLAN.md 11, M13). */ acknowledgePrivacy: () => void + /** Try a new global hotkey for one action, and remember it if the OS grants it (PLAN.md 8). */ + setHotkey: (action: HotkeyAction, accelerator: string) => void + /** Put one action back to its shipped default. */ + resetHotkey: (action: HotkeyAction) => void setWindowState: (state: WindowStateName) => void /** The failsafe of PLAN.md 11, M9. Returns what it could not remove, if anything. */ resetEverything: () => { failed: Array<{ path: string; reason: string }> } @@ -78,6 +83,13 @@ export function registerIpc( ipcMain.handle(CHANNELS.dismissCapacityAdvice, () => session.dismissCapacityAdvice()) ipcMain.handle(CHANNELS.pauseCapture, () => session.pauseCapture()) ipcMain.handle(CHANNELS.acknowledgePrivacy, () => actions.acknowledgePrivacy()) + ipcMain.handle(CHANNELS.toggleMode, () => session.toggleMode()) + ipcMain.handle(CHANNELS.setHotkey, (_event, action: HotkeyAction, accelerator: string) => + actions.setHotkey(action, accelerator) + ) + ipcMain.handle(CHANNELS.resetHotkey, (_event, action: HotkeyAction) => + actions.resetHotkey(action) + ) ipcMain.handle(CHANNELS.resumeCapture, () => session.resumeCapture()) ipcMain.handle(CHANNELS.setStarred, (_event, spoolId: string, starred: boolean) => session.setStarred(spoolId, starred) @@ -117,6 +129,9 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.dismissCapacityAdvice) ipcMain.removeHandler(CHANNELS.pauseCapture) ipcMain.removeHandler(CHANNELS.acknowledgePrivacy) + ipcMain.removeHandler(CHANNELS.toggleMode) + ipcMain.removeHandler(CHANNELS.setHotkey) + ipcMain.removeHandler(CHANNELS.resetHotkey) ipcMain.removeHandler(CHANNELS.resumeCapture) ipcMain.removeHandler(CHANNELS.setStarred) ipcMain.removeHandler(CHANNELS.clearSpools) diff --git a/src/main/session.test.ts b/src/main/session.test.ts index d7162e3..337ce39 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -1215,6 +1215,31 @@ describe('starred spools (PLAN.md 10)', () => { expect(saved.deletedBatches).toEqual([['go1', 'go2']]) }) + // The bug this exists for: the button counted every unstarred spool, including the active one, + // while the action skipped the active one. With a single unstarred spool that happened to be + // active, "Clear 1 spool" did nothing at all. + it('clears the active spool too, because the button counts it', () => { + const { session, saved } = withSpools([defaultSpool, sized('only', 'Only one', MIB)]) + session.setActiveSpool('only') + expect(session.getState().spool.name).toBe('Only one') + + session.clearSpools() + + expect(names(session)).toEqual(['Default spool']) + expect(saved.deletedBatches).toEqual([['only']]) + }) + + it('falls back to the default spool when clearing takes the active one away', () => { + const { session } = withSpools([defaultSpool, sized('a', 'Alpha', MIB), sized('b', 'Beta', MIB)]) + session.setActiveSpool('a') + + session.clearSpools() + + // Something always has to be catching a copy (PLAN.md 2). + expect(session.getState().spool.name).toBe('Default spool') + expect(names(session)).toEqual(['Default spool']) + }) + it('never offers a starred spool to the capacity advisor', () => { const { session } = withSpools( [ diff --git a/src/main/session.ts b/src/main/session.ts index f4dba7a..46b24c8 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import type { AppState, ConsentChoice, + HotkeyView, Notice, PendingPrompt, StorageStatus @@ -116,6 +117,12 @@ export class Session { */ private firstRun = true + /** + * What the operating system granted, so the window can say which keys are live (PLAN.md 8). The + * session does not own hotkeys; it carries their status because it is what assembles AppState. + */ + private hotkeys: readonly HotkeyView[] = [] + /** Where state is written through to. Null means this session keeps nothing (PLAN.md 11, M6). */ private store: Store | null = null private storage: StorageStatus = { @@ -212,6 +219,12 @@ export class Session { } /** Exposed for the IPC layer and for tests, which drive it with snapshots directly. */ + /** Report what the global hotkeys came out as, so a refused one can be seen and fixed. */ + setHotkeys(hotkeys: readonly HotkeyView[]): void { + this.hotkeys = hotkeys + this.publish() + } + /** The statement has been read. Capture may begin. */ acknowledgePrivacy(): void { if (!this.firstRun) return @@ -704,11 +717,18 @@ export class Session { })) ) - const removable = clearing - .map((spool) => spool.id) - .filter((id) => id !== this.state.spool.id) + const removable = clearing.map((spool) => spool.id) if (removable.length === 0) return + // The active spool is cleared like any other. It used to be skipped, while the button went on + // counting it — so a user whose only unstarred spool was the active one pressed "Clear 1 spool" + // and watched nothing happen. The button states what it spares (PLAN.md 9); sparing something + // it does not name is the one thing it must not do. + if (removable.includes(this.state.spool.id)) { + const fallback = this.otherSpools.find((spool) => spool.kind === 'default') + if (fallback !== undefined) this.activate(fallback, { keepLeaving: false }) + } + this.otherSpools = this.otherSpools.filter((spool) => !removable.includes(spool.id)) this.store?.deleteSpools(removable) this.refreshCapacity() @@ -863,6 +883,7 @@ export class Session { : { byteLength: this.pendingJoin.byteLength, clips: this.pendingJoin.clips }, capacity: this.capacityView(), firstRun: this.firstRun, + hotkeys: this.hotkeys, prompt: this.promptView(), privacy: { heuristics: HEURISTIC_RULES, diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index 64bf68e..decf137 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -26,7 +26,8 @@ describe('settings (PLAN.md 3, 8)', () => { window: 'expanded', activeSpoolId: 'abc', consentTimeoutSeconds: 45, - privacyAcknowledged: true + privacyAcknowledged: true, + hotkeys: {} }) expect(loadSettings(path())).toEqual({ @@ -34,7 +35,8 @@ describe('settings (PLAN.md 3, 8)', () => { window: 'expanded', activeSpoolId: 'abc', consentTimeoutSeconds: 45, - privacyAcknowledged: true + privacyAcknowledged: true, + hotkeys: {} }) }) @@ -65,7 +67,8 @@ describe('settings (PLAN.md 3, 8)', () => { window: 'compact', activeSpoolId: null, consentTimeoutSeconds: 30, - privacyAcknowledged: false + privacyAcknowledged: false, + hotkeys: {} }) // 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 8263ac4..92c5cf5 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,5 +1,6 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import type { Action } from './accelerators' import { DEFAULT_SEPARATOR, type SeparatorKind } from './core/join' /** @@ -30,6 +31,11 @@ export interface Settings { * start until it has: the promise is made before anything is collected, not after (M13). */ readonly privacyAcknowledged: boolean + /** + * Hotkeys the user has rebound, by action. Absent means "use the default" — which is not the same + * as an empty string, and is why this is a sparse map rather than a full record (PLAN.md 8). + */ + readonly hotkeys: Partial> } export const DEFAULT_SETTINGS: Settings = { @@ -37,7 +43,8 @@ export const DEFAULT_SETTINGS: Settings = { window: 'compact', activeSpoolId: null, consentTimeoutSeconds: 30, - privacyAcknowledged: false + privacyAcknowledged: false, + hotkeys: {} } export function settingsPath(userDataDirectory: string): string { @@ -53,6 +60,25 @@ const SEPARATORS: readonly SeparatorKind[] = [ 'none' ] +const HOTKEY_ACTIONS: readonly Action[] = ['summon', 'serve', 'pasteAll'] + +/** + * Take only bindings for actions that exist, and only strings. A hand-edited file should cost the + * user a hotkey, not their app — and an unrecognised action silently becoming a binding would be a + * shortcut nothing can ever release. + */ +function readHotkeys(raw: unknown): Partial> { + if (typeof raw !== 'object' || raw === null) return {} + + const source = raw as Record + const chosen: Partial> = {} + for (const action of HOTKEY_ACTIONS) { + const value = source[action] + if (typeof value === 'string' && value.length > 0) chosen[action] = value + } + return chosen +} + /** * Read the file, taking only what is recognised. A settings file that has been hand-edited into * nonsense should cost the user their preferences, not their app. @@ -80,7 +106,8 @@ export function loadSettings(path: string): Settings { raw.consentTimeoutSeconds <= 600 ? Math.round(raw.consentTimeoutSeconds) : DEFAULT_SETTINGS.consentTimeoutSeconds, - privacyAcknowledged: raw.privacyAcknowledged === true + privacyAcknowledged: raw.privacyAcknowledged === true, + hotkeys: readHotkeys(raw.hotkeys) } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6fd721c..52c418d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,9 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron' -import { describeAction, type Platform } from '../main/accelerators' +import type { Platform } from '../main/accelerators' import { CHANNELS, type AppState, type ConsentChoice, + type HotkeyAction, type SeparatorKind, type WindowStateName } from '../shared/ipc' @@ -16,10 +17,6 @@ import { */ const api = { platform: process.platform as Platform, - summonHotkey: describeAction('summon', process.platform as Platform), - serveHotkey: describeAction('serve', process.platform as Platform), - pasteAllHotkey: describeAction('pasteAll', process.platform as Platform), - modeHotkey: describeAction('toggleMode', process.platform as Platform), /** The state as it stands right now, for a renderer that has just mounted. */ getState: (): Promise => ipcRenderer.invoke(CHANNELS.getState), @@ -73,6 +70,17 @@ const api = { /** The capacity advisor (PLAN.md 9): it recommends, the user decides. */ dismissCapacityAdvice: (): Promise => ipcRenderer.invoke(CHANNELS.dismissCapacityAdvice), + /** Change direction. On the mode pill rather than a hotkey (PLAN.md 8). */ + toggleMode: (): Promise => ipcRenderer.invoke(CHANNELS.toggleMode), + + /** Try a new combination for one action; the answer says whether the OS granted it. */ + setHotkey: (action: HotkeyAction, accelerator: string): Promise => + ipcRenderer.invoke(CHANNELS.setHotkey, action, accelerator), + + /** Put one action back to its shipped default. */ + resetHotkey: (action: HotkeyAction): Promise => + ipcRenderer.invoke(CHANNELS.resetHotkey, action), + /** The privacy statement has been read; capture may begin (PLAN.md 11, M13). */ acknowledgePrivacy: (): Promise => ipcRenderer.invoke(CHANNELS.acknowledgePrivacy), diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 4cc917d..ebf5f34 100644 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -1,11 +1,13 @@ import { useState, type JSX } from 'react' import { capacityLabel } from '../helpers/ClipListHelper' +import { refusedCount } from '../helpers/HotkeysPanelHelper' import { useAppState } from '../state/useAppState' import { CapacityAdvisor } from './CapacityAdvisor' import { ClipList } from './ClipList' import { FirstRun } from './FirstRun' import { ExpandedView } from './ExpandedView' import { ConsentPrompt } from './ConsentPrompt' +import { HotkeysPanel } from './HotkeysPanel' import { PrivacyPanel } from './PrivacyPanel' import { SettingsPanel } from './SettingsPanel' @@ -13,11 +15,21 @@ import { SettingsPanel } from './SettingsPanel' * The compact window (PLAN.md 8): the active spool's name, its mode pill, the clip that serves * next, the clips behind it, and the privacy affordance. This is the state the app lives in. */ -function Hint({ keys, children }: { keys: string; children: string }): JSX.Element { +function Hint({ + keys, + live, + children +}: { + keys: string + live: boolean + children: string +}): JSX.Element { return (
-
{keys}
-
{children}
+
+ {keys} +
+
{children}
) } @@ -26,9 +38,11 @@ export function App(): JSX.Element { const [showPrivacy, setShowPrivacy] = useState(false) const [expanded, setExpanded] = useState(false) const [showSettings, setShowSettings] = useState(false) - const { summonHotkey, serveHotkey, pasteAllHotkey, modeHotkey, platform } = window.spool + const [showHotkeys, setShowHotkeys] = useState(false) + const { platform } = window.spool const state = useAppState() - const { spool, notice, capture, prompt, privacy, storage, capacity } = state + const { spool, notice, capture, prompt, privacy, storage, capacity, hotkeys } = state + const dead = refusedCount(hotkeys) // Before anything else: the statement, and nothing captured until it is acknowledged. if (state.firstRun) { @@ -61,6 +75,10 @@ export function App(): JSX.Element { return setShowSettings(false)} /> } + if (showHotkeys) { + return setShowHotkeys(false)} /> + } + if (showPrivacy) { return ( setShowPrivacy(false)} /> @@ -73,9 +91,18 @@ export function App(): JSX.Element {

{spool.name}

{capacityLabel(spool)} - + + + + {summary !== null && ( +

+ {summary} +

+ )} + +
+ {hotkeys.map((hotkey) => ( + + ))} + +

+ Switching FIFO and LIFO has no hotkey on purpose — it is the mode pill at the top of the + window. It is something you change while looking at the spool, not while typing somewhere + else, so it spends no global combination. +

+
+ + ) +} + +function HotkeyRow({ hotkey }: { hotkey: HotkeyView }): JSX.Element { + const { modifier, key } = splitAccelerator(hotkey.accelerator) + const chosen = MODIFIER_CHOICES.find((choice) => choice.value === modifier) + + const change = (nextModifier: string, nextKey: string): void => { + void window.spool.setHotkey(hotkey.action, `${nextModifier}+${nextKey}`) + } + + return ( +
+
+

{hotkey.label}

+ + {hotkey.described} + +
+ +

{hotkey.hint}

+ + {!hotkey.claimed && ( +

+ Another application already owns {hotkey.described}, so this key does nothing. Pick a + different one below. +

+ )} + +
+ + + + + {!hotkey.isDefault && ( + + )} +
+ + {chosen?.hazard != null && ( +

{chosen.hazard}

+ )} +
+ ) +} diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 2afb2ae..a025314 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -5,10 +5,6 @@ import type { AppState, ConsentChoice, SeparatorKind, WindowStateName } from '.. declare global { interface SpoolApi { readonly platform: 'win32' | 'darwin' | 'linux' - readonly summonHotkey: string - readonly serveHotkey: string - readonly pasteAllHotkey: string - readonly modeHotkey: string getState(): Promise answerConsent(choice: ConsentChoice): Promise startFreshStore(): Promise @@ -30,6 +26,9 @@ declare global { dismissCapacityAdvice(): Promise pauseCapture(): Promise acknowledgePrivacy(): Promise + toggleMode(): Promise + setHotkey(action: HotkeyAction, accelerator: string): Promise + resetHotkey(action: HotkeyAction): Promise resumeCapture(): Promise setStarred(spoolId: string, starred: boolean): Promise clearSpools(): Promise diff --git a/src/renderer/helpers/HotkeysPanelHelper.test.ts b/src/renderer/helpers/HotkeysPanelHelper.test.ts new file mode 100644 index 0000000..c70aff7 --- /dev/null +++ b/src/renderer/helpers/HotkeysPanelHelper.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { HotkeyView } from '../../shared/ipc' +import { hotkeySummary, refusedCount } from './HotkeysPanelHelper' + +function view(overrides: Partial = {}): HotkeyView { + return { + action: 'serve', + label: 'Unspool the next clip', + hint: 'Press it repeatedly.', + accelerator: 'Super+Alt+U', + described: 'Win+Alt+U', + claimed: true, + isDefault: true, + ...overrides + } +} + +describe('hotkeySummary', () => { + it('says nothing when every key is live', () => { + expect(hotkeySummary([view(), view({ action: 'summon' })])).toBeNull() + }) + + it('names the one refused key and what to do about it', () => { + const summary = hotkeySummary([view({ claimed: false, described: 'Win+Alt+N' })]) + expect(summary).toContain('Win+Alt+N') + expect(summary).toContain('refused') + }) + + it('counts them when more than one is dead', () => { + const summary = hotkeySummary([ + view({ claimed: false, described: 'Win+Alt+N' }), + view({ action: 'pasteAll', claimed: false, described: 'Win+Alt+A' }) + ]) + expect(summary).toContain('2 hotkeys') + expect(summary).toContain('Win+Alt+N') + expect(summary).toContain('Win+Alt+A') + }) +}) + +describe('refusedCount', () => { + it('is the badge on the ? button, which is how a refusal reaches someone not looking for it', () => { + expect(refusedCount([view(), view({ claimed: false })])).toBe(1) + expect(refusedCount([view(), view()])).toBe(0) + }) +}) diff --git a/src/renderer/helpers/HotkeysPanelHelper.ts b/src/renderer/helpers/HotkeysPanelHelper.ts new file mode 100644 index 0000000..5f330c5 --- /dev/null +++ b/src/renderer/helpers/HotkeysPanelHelper.ts @@ -0,0 +1,27 @@ +import type { HotkeyView } from '../../shared/ipc' + +/** Pure helpers for the hotkey panel (PLAN.md 8). No I/O, no React. */ + +/** + * The line at the top of the panel, or null when every key is live. + * + * It names the count rather than the keys, because the rows underneath already name the keys and + * the point of this line is to be readable at a glance from the button that opened it. + */ +export function hotkeySummary(hotkeys: readonly HotkeyView[]): string | null { + const refused = hotkeys.filter((hotkey) => !hotkey.claimed) + if (refused.length === 0) return null + + const which = refused.map((hotkey) => hotkey.described).join(' and ') + return refused.length === 1 + ? `${which} was refused by another application and does nothing. Pick another below.` + : `${refused.length} hotkeys were refused by other applications and do nothing: ${which}.` +} + +/** + * What the "?" button shows without opening anything: a count of dead keys, or null when all is + * well. A badge is the only way a refusal reaches someone who has not gone looking for it. + */ +export function refusedCount(hotkeys: readonly HotkeyView[]): number { + return hotkeys.filter((hotkey) => !hotkey.claimed).length +} diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts index 9c43573..9375c0b 100644 --- a/src/renderer/state/useAppState.ts +++ b/src/renderer/state/useAppState.ts @@ -30,6 +30,7 @@ const initialState: AppState = { spools: [], pendingJoin: null, firstRun: false, + hotkeys: [], capacity: { measure: 'bytes', used: 0, diff --git a/src/shared/hotkeys.ts b/src/shared/hotkeys.ts new file mode 100644 index 0000000..b907072 --- /dev/null +++ b/src/shared/hotkeys.ts @@ -0,0 +1,40 @@ +/** + * The vocabulary of a hotkey, shared by both sides (PLAN.md 6, 8). + * + * It lives here rather than in `main/accelerators.ts` because the rebinding UI has to offer these + * choices and the renderer never reaches into main. Pure data and two string functions — no + * behaviour that could differ between the process that registers a combination and the process that + * asks for one. + */ + +/** The modifier families offered when rebinding, with the hazard each one carries. */ +export const MODIFIER_CHOICES: readonly { + readonly value: string + readonly label: string + readonly hazard: string | null +}[] = [ + { value: 'Super+Alt', label: 'Win + Alt', hazard: null }, + { + value: 'Control+Alt', + label: 'Ctrl + Alt', + hazard: 'This is AltGr on international keyboards, where it types accented characters.' + }, + { value: 'Control+Shift+Alt', label: 'Ctrl + Shift + Alt', hazard: null }, + { value: 'Super+Control+Alt', label: 'Win + Ctrl + Alt', hazard: null } +] + +/** The keys offered when rebinding. Letters only: digits and punctuation move between layouts. */ +export const KEY_CHOICES: readonly string[] = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +/** Put a modifier family and a key back together into an Electron accelerator. */ +export function buildAccelerator(modifier: string, key: string): string { + return `${modifier}+${key}` +} + +/** Split an accelerator into the parts the rebinding UI edits. */ +export function splitAccelerator(accelerator: string): { modifier: string; key: string } { + const cut = accelerator.lastIndexOf('+') + return cut === -1 + ? { modifier: MODIFIER_CHOICES[0].value, key: accelerator } + : { modifier: accelerator.slice(0, cut), key: accelerator.slice(cut + 1) } +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index b45f458..ef8ca76 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -9,6 +9,27 @@ export type Mode = 'fifo' | 'lifo' +/** The actions that carry a global hotkey. Toggling the mode is done in the window (PLAN.md 8). */ +export type HotkeyAction = 'summon' | 'serve' | 'pasteAll' + +/** + * One binding, and whether the operating system granted it. `claimed: false` is the case that + * matters: another application owns the combination, the key is dead, and the panel has to say so + * rather than leave the user pressing it (PLAN.md 8). + */ +export interface HotkeyView { + readonly action: HotkeyAction + readonly label: string + readonly hint: string + /** The Electron accelerator, which is what a rebinding sends back. */ + readonly accelerator: string + /** The same combination as a person reads it. */ + readonly described: string + readonly claimed: boolean + /** Whether this is the shipped default or something the user chose. */ + readonly isDefault: boolean +} + /** * `nothing_to_paste` is the odd one out: the decline categories are said once per session, but a * serve on an empty spool has to answer every time it is asked (PLAN.md 3). @@ -177,6 +198,8 @@ export interface AppState { * (PLAN.md 11, M13). */ 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[] } /** The channel names, in one place so the two sides cannot drift apart. */ @@ -202,6 +225,9 @@ export const CHANNELS = { dismissCapacityAdvice: 'spool:dismiss-capacity-advice', pauseCapture: 'spool:pause-capture', acknowledgePrivacy: 'spool:acknowledge-privacy', + toggleMode: 'spool:toggle-mode', + setHotkey: 'spool:set-hotkey', + resetHotkey: 'spool:reset-hotkey', resumeCapture: 'spool:resume-capture', deleteSpools: 'spool:delete-spools', setStarred: 'spool:set-starred', From 771fb1f683ce5f8a1e20e6d7c06acdeab66d561a Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Sun, 6 Sep 2026 07:15:18 -0500 Subject: [PATCH 3/3] Unspool into the window you were typing in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serving put the clip on the clipboard and stopped, leaving the user to press Ctrl+V themselves. That is two keystrokes for one intention, and unspooling into a form is the thing this app exists to do. PLAN.md 8 had rejected serve-and-paste, but the reason was entirely a macOS one: synthesizing input there needs Accessibility permission, which is permission to read every keystroke, and an app claiming it cannot spy on you must not ask for it. Windows SendInput is output rather than input and needs no permission, no elevation and no keyboard hook, so the objection does not reach this platform. The three secondary arguments survived the change instead of opposing it: no hook is needed, the clip stays on the clipboard so serve-once-paste-many still works, and the cursor still advances because the user pressed a key. The addon gains sendPaste, which refuses when Spool's own window is in front — pasting into ourselves is never what anyone meant. It is a setting rather than a law, because a synthesized Ctrl+V does nothing in terminals that paste with Ctrl+Shift+V. No new dependency: extending the addon we already own keeps the surface the zero-network gate has to check exactly as narrow as it was. Also records the scope this rests on. macOS is dropped: it costs an Apple membership, a Mac, and a second native clipboard implementation to reach a platform this was never built for, and the Microsoft Store asks nothing about it. Co-Authored-By: Claude Opus 5 --- PLAN.md | 54 +++++++++++++------ native/clipboard/src/clipboard_unsupported.cc | 7 +++ native/clipboard/src/clipboard_win.cc | 42 +++++++++++++++ src/main/clipboard/watcher.ts | 2 + src/main/clipboard/writer.ts | 22 ++++++++ src/main/index.ts | 14 +++-- src/main/ipc/index.ts | 4 ++ src/main/session.test.ts | 49 +++++++++++++++-- src/main/session.ts | 30 ++++++++++- src/main/settings.test.ts | 9 ++-- src/main/settings.ts | 9 +++- src/preload/index.ts | 4 ++ src/renderer/components/SettingsPanel.tsx | 19 +++++++ src/renderer/env.d.ts | 1 + src/renderer/state/useAppState.ts | 1 + src/shared/ipc.ts | 3 ++ 16 files changed, 240 insertions(+), 30 deletions(-) diff --git a/PLAN.md b/PLAN.md index dfb1813..81fe071 100644 --- a/PLAN.md +++ b/PLAN.md @@ -5,7 +5,21 @@ on by copying and play off one at a time — or all at once, in the order you ar or backwards, rearranging by dragging. Nothing it captures can leave the machine, and that property is enforced by the build, not by good intentions. -Electron + React. Windows first, macOS once an Apple Developer account exists (M14). No cloud, no account, no telemetry, no model. +Electron + React. **Windows.** No cloud, no account, no telemetry, no model. + +### Scope: this is a Windows application + +Written first as "Windows first, macOS once an Apple Developer account exists", and narrowed on +purpose. macOS is **out of scope**: it costs an Apple Developer membership, a Mac to notarize from, +and a second native clipboard implementation, in exchange for reaching a platform this app was never +being built for. The Microsoft Store asks nothing about other platforms — an MSIX declares +`TargetDeviceFamily Name="Windows.Desktop"` and that is the end of it — so nothing downstream wants +macOS either. Linux is welcome if it happens to work; it is not a goal, and nothing is owed to it. + +This is not a small edit. Being Windows-only is what lets serving paste (§8): the argument against +synthesizing input was a macOS permission argument, and on Windows there is no permission to ask for. +A spec that keeps a platform it will not ship to also keeps that platform's constraints, and pays for +them in features it declines to build. --- @@ -635,20 +649,28 @@ Paste is the interesting half. The model is two distinct keystrokes: 2. **Paste** — the user presses Ctrl+V, entirely natively. The app is not involved. The obvious alternative is a single serve-and-paste hotkey that writes the clip and then synthesizes a -paste into the foreground window, which is what Raycast and Alfred do. **It is rejected for v1, and the -reason is specific to this app:** synthesizing input requires Accessibility permission on macOS, which -is effectively permission to read every keystroke on the machine. A tool whose entire claim is that it -*cannot* spy on you should not be asking for the one permission that would let it. The system dialog -says as much, and it would be right to. - -Three smaller reasons the two-step is the better default anyway: - -- It needs no keyboard hook and no permission prompt on either platform. -- **The served clip stays pasteable repeatedly.** Serve once, paste into four places. A combined hotkey - hides that. -- Advancing the cursor is a deliberate act rather than a side effect of pasting, which is what - invariant 6 asks for. A paste that silently moved the cursor would leave the user unsure where they - are in the spool. +paste into the foreground window, which is what Raycast and Alfred do. It was rejected for v1 because +synthesizing input requires Accessibility permission on macOS, which is effectively permission to read +every keystroke on the machine. A tool whose entire claim is that it *cannot* spy on you should not be +asking for the one permission that would let it. + +**Amended: serving pastes on Windows.** The objection above is entirely a macOS objection, and this is +a Windows application (see the scope note in §1). Windows `SendInput` is *output* — it asks the OS to +deliver a keystroke — and needs no permission, no elevation, and no keyboard hook. There is nothing to +ask the user for and nothing to be trusted with. The three secondary arguments turned out to survive +the change rather than oppose it: + +- *No keyboard hook* — still none. Sending is not listening. +- *The served clip stays pasteable repeatedly* — still true. The clip remains on the clipboard, so + serve-once-paste-into-four-places still works; this adds the first paste rather than removing the + others. +- *Advancing the cursor is a deliberate act* — still true. The user pressed the unspool key. That is + 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. ### Hotkeys @@ -1208,7 +1230,7 @@ attributed to the addon. --- -### M14 — macOS packaging +### M14 — macOS packaging *(dropped; see the scope note above)* **Gated on an Apple Developer Program membership**, which is required for a Developer ID certificate and for notarization; software distributed outside the Mac App Store will not launch without both. No diff --git a/native/clipboard/src/clipboard_unsupported.cc b/native/clipboard/src/clipboard_unsupported.cc index cd13c39..c51b490 100644 --- a/native/clipboard/src/clipboard_unsupported.cc +++ b/native/clipboard/src/clipboard_unsupported.cc @@ -21,10 +21,17 @@ Napi::Value IsSupported(const Napi::CallbackInfo& info) { return Napi::Boolean::New(info.Env(), false); } +// Always false off Windows. Synthesizing a paste on macOS would need Accessibility permission — +// permission to read every keystroke — which this app will not ask for (PLAN.md 8). +Napi::Value SendPaste(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)); return exports; } diff --git a/native/clipboard/src/clipboard_win.cc b/native/clipboard/src/clipboard_win.cc index f4d1261..5539d72 100644 --- a/native/clipboard/src/clipboard_win.cc +++ b/native/clipboard/src/clipboard_win.cc @@ -304,10 +304,52 @@ Napi::Value IsSupported(const Napi::CallbackInfo& info) { return Napi::Boolean::New(info.Env(), true); } +// Synthesize Ctrl+V into whatever window has focus (PLAN.md 8). +// +// This is *output*, not input: SendInput asks Windows to deliver a keystroke, and needs no +// permission, no elevation, and no keyboard hook. That distinction is the whole reason it is +// acceptable here. The macOS equivalent would require Accessibility permission, which is permission +// to read every keystroke on the machine, and an app whose claim is that it cannot spy on you must +// not ask for it — so this stays a Windows-only capability rather than a cross-platform one. +// +// It refuses when Spool itself is in front. Serving is meant to put a clip into the document you +// were already working in; pasting into our own window would type the clip into the app that just +// produced it, which is never what anyone meant. +Napi::Value SendPaste(const Napi::CallbackInfo& info) { + Napi::Env env = info.Env(); + + HWND foreground = GetForegroundWindow(); + if (foreground == nullptr) return Napi::Boolean::New(env, false); + + DWORD foreground_pid = 0; + GetWindowThreadProcessId(foreground, &foreground_pid); + if (foreground_pid == GetCurrentProcessId()) return Napi::Boolean::New(env, false); + + INPUT inputs[4] = {}; + + inputs[0].type = INPUT_KEYBOARD; + inputs[0].ki.wVk = VK_CONTROL; + + inputs[1].type = INPUT_KEYBOARD; + inputs[1].ki.wVk = 'V'; + + inputs[2].type = INPUT_KEYBOARD; + inputs[2].ki.wVk = 'V'; + inputs[2].ki.dwFlags = KEYEVENTF_KEYUP; + + inputs[3].type = INPUT_KEYBOARD; + inputs[3].ki.wVk = VK_CONTROL; + inputs[3].ki.dwFlags = KEYEVENTF_KEYUP; + + const UINT sent = SendInput(4, inputs, sizeof(INPUT)); + return Napi::Boolean::New(env, sent == 4); +} + 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)); return exports; } diff --git a/src/main/clipboard/watcher.ts b/src/main/clipboard/watcher.ts index dafacc1..43868d0 100644 --- a/src/main/clipboard/watcher.ts +++ b/src/main/clipboard/watcher.ts @@ -26,6 +26,8 @@ interface NativeAddon { start(callback: (snapshot: NativeSnapshot) => void): void stop(): void isSupported(): boolean + /** Synthesize Ctrl+V into the foreground window. False when it refused (PLAN.md 8). */ + sendPaste(): boolean } export type WatcherLoad = diff --git a/src/main/clipboard/writer.ts b/src/main/clipboard/writer.ts index 80cfe07..693379a 100644 --- a/src/main/clipboard/writer.ts +++ b/src/main/clipboard/writer.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module' import { clipboard } from 'electron' /** @@ -10,3 +11,24 @@ import { clipboard } from 'electron' export function writeClipboardText(text: string): void { clipboard.writeText(text) } + +/** + * Ask the addon to synthesize Ctrl+V into whatever window has focus (PLAN.md 8). + * + * **Windows only, and that is a decision rather than a gap.** SendInput is output: it needs no + * permission and no keyboard hook. The macOS equivalent needs Accessibility permission, which is + * permission to read every keystroke on the machine, and an app whose whole claim is that it cannot + * spy on you must not ask for it. So serving pastes here and would not there. + * + * Returns false when nothing was sent — no foreground window, or Spool's own window is in front, + * which the addon refuses because pasting into ourselves is never what anyone meant. + */ +export function sendPaste(): boolean { + try { + const require = createRequire(__filename) + const addon = require('spool-clipboard') as { sendPaste?: () => boolean } + return addon.sendPaste?.() ?? false + } catch { + return false + } +} diff --git a/src/main/index.ts b/src/main/index.ts index a3caf03..0f00311 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,7 +15,7 @@ import { import { registerIpc } from './ipc' import { Session } from './session' import { explainStorageFailure, openStore, resetEverything, startFresh, storePaths } from './store' -import { writeClipboardText } from './clipboard/writer' +import { sendPaste, writeClipboardText } from './clipboard/writer' import { createTray, reportCaptureState } from './tray' import { loadSettings, saveSettings, settingsPath, type WindowState } from './settings' import { @@ -43,7 +43,7 @@ if (!app.requestSingleInstanceLock()) { app.isPackaged ) - const spoolSession = new Session(writeClipboardText) + const spoolSession = new Session(writeClipboardText, sendPaste) /** * Open the encrypted store and restore what it holds (PLAN.md 11, M6). A failure is reported @@ -63,6 +63,7 @@ if (!app.requestSingleInstanceLock()) { spoolSession.setSeparator(settings.separator) spoolSession.setPrivacyAcknowledged(settings.privacyAcknowledged) + spoolSession.setPasteOnServe(settings.pasteOnServe) spoolSession.setConsentTimeout(settings.consentTimeoutSeconds) registerIpc(spoolSession, getCompactWindow, { @@ -80,7 +81,8 @@ if (!app.requestSingleInstanceLock()) { activeSpoolId: spoolSession.getActiveSpoolId(), consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), privacyAcknowledged: true, - hotkeys: hotkeyOverrides() + hotkeys: hotkeyOverrides(), + pasteOnServe: spoolSession.getPasteOnServe() }) }, @@ -123,7 +125,8 @@ if (!app.requestSingleInstanceLock()) { activeSpoolId: spoolSession.getActiveSpoolId(), consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), privacyAcknowledged: !spoolSession.isFirstRun(), - hotkeys: hotkeyOverrides() + hotkeys: hotkeyOverrides(), + pasteOnServe: spoolSession.getPasteOnServe() }) } }) @@ -136,7 +139,8 @@ if (!app.requestSingleInstanceLock()) { activeSpoolId: spoolSession.getActiveSpoolId(), consentTimeoutSeconds: spoolSession.getConsentTimeoutSeconds(), privacyAcknowledged: !spoolSession.isFirstRun(), - hotkeys: hotkeyOverrides() + hotkeys: hotkeyOverrides(), + pasteOnServe: spoolSession.getPasteOnServe() }) ) diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 9e6148a..ff61947 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -84,6 +84,9 @@ 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.setHotkey, (_event, action: HotkeyAction, accelerator: string) => actions.setHotkey(action, accelerator) ) @@ -130,6 +133,7 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.pauseCapture) ipcMain.removeHandler(CHANNELS.acknowledgePrivacy) ipcMain.removeHandler(CHANNELS.toggleMode) + ipcMain.removeHandler(CHANNELS.setPasteOnServe) 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 337ce39..3e027cc 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -32,14 +32,14 @@ const text = (value: string, sourceApp: string | null = null): ClipboardSnapshot sourceApp }) -function started(): { +function started(paste: () => boolean = () => false): { session: Session watcher: ReturnType written: string[] } { const watcher = fakeWatcher() const written: string[] = [] - const session = new Session((text) => written.push(text)) + const session = new Session((text) => written.push(text), paste) // A session that has already been through its first run, which is what every test below is // about. The first-run behaviour itself is tested by setting this back to false. session.setPrivacyAcknowledged(true) @@ -276,13 +276,56 @@ describe('serving (PLAN.md 11, M4)', () => { expect(nextPreview()).toBe('A') }) + it('pastes the clip it serves into the window in front', () => { + let pastes = 0 + const { session, watcher, written } = started(() => { + pastes += 1 + return true + }) + watcher.change(text('into the form')) + + session.serveNext() + + expect(written).toEqual(['into the form']) + expect(pastes).toBe(1) + }) + + it('serves without pasting when the user has turned that off', () => { + let pastes = 0 + const { session, watcher, written } = started(() => { + pastes += 1 + return true + }) + session.setPasteOnServe(false) + watcher.change(text('placed, not typed')) + + session.serveNext() + + expect(written).toEqual(['placed, not typed']) + expect(pastes).toBe(0) + }) + + it('does not paste when there was nothing to serve', () => { + let pastes = 0 + const { session } = started(() => { + pastes += 1 + return true + }) + + session.serveNext() + + expect(pastes).toBe(0) + expect(session.getState().notice?.category).toBe('nothing_to_paste') + }) + 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')) session.serveNext() - // Pasting is the user pressing Ctrl+V; the app is not involved and writes nothing further. + // Serving now pastes once as well (PLAN.md 8), but it writes to the clipboard exactly once — + // so the clip is still there to be pasted by hand, as often as the user likes. expect(written).toEqual(['once served']) expect(session.getState().spool.count).toBe(1) }) diff --git a/src/main/session.ts b/src/main/session.ts index 46b24c8..8ff413e 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -139,7 +139,28 @@ export class Session { * `writeText` is injected rather than imported so that this class never reaches for Electron — * which is also what lets every rule below be tested without launching the app. */ - constructor(private readonly writeText: (text: string) => void) {} + /** + * 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. + */ + private pasteOnServe = 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 + ) {} + + setPasteOnServe(enabled: boolean): void { + this.pasteOnServe = enabled + this.publish() + } + + getPasteOnServe(): boolean { + return this.pasteOnServe + } /** * Attach a store and restore what it holds (PLAN.md 11, M6). Everything the user had — clips, @@ -356,6 +377,12 @@ export class Session { pendingSelfWrite: result.clip.content } this.notice = null + + // 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() + this.publish() } @@ -884,6 +911,7 @@ export class Session { capacity: this.capacityView(), firstRun: this.firstRun, hotkeys: this.hotkeys, + pasteOnServe: this.pasteOnServe, prompt: this.promptView(), privacy: { heuristics: HEURISTIC_RULES, diff --git a/src/main/settings.test.ts b/src/main/settings.test.ts index decf137..afb2c3b 100644 --- a/src/main/settings.test.ts +++ b/src/main/settings.test.ts @@ -27,7 +27,8 @@ describe('settings (PLAN.md 3, 8)', () => { activeSpoolId: 'abc', consentTimeoutSeconds: 45, privacyAcknowledged: true, - hotkeys: {} + hotkeys: {}, + pasteOnServe: true }) expect(loadSettings(path())).toEqual({ @@ -36,7 +37,8 @@ describe('settings (PLAN.md 3, 8)', () => { activeSpoolId: 'abc', consentTimeoutSeconds: 45, privacyAcknowledged: true, - hotkeys: {} + hotkeys: {}, + pasteOnServe: true }) }) @@ -68,7 +70,8 @@ describe('settings (PLAN.md 3, 8)', () => { activeSpoolId: null, consentTimeoutSeconds: 30, privacyAcknowledged: false, - hotkeys: {} + hotkeys: {}, + pasteOnServe: 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 92c5cf5..e7295a1 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -36,6 +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 } export const DEFAULT_SETTINGS: Settings = { @@ -44,7 +46,8 @@ export const DEFAULT_SETTINGS: Settings = { activeSpoolId: null, consentTimeoutSeconds: 30, privacyAcknowledged: false, - hotkeys: {} + hotkeys: {}, + pasteOnServe: true } export function settingsPath(userDataDirectory: string): string { @@ -107,7 +110,9 @@ export function loadSettings(path: string): Settings { ? Math.round(raw.consentTimeoutSeconds) : DEFAULT_SETTINGS.consentTimeoutSeconds, privacyAcknowledged: raw.privacyAcknowledged === true, - hotkeys: readHotkeys(raw.hotkeys) + hotkeys: readHotkeys(raw.hotkeys), + // Absent means on: the default is the behaviour, and only an explicit false turns it off. + pasteOnServe: raw.pasteOnServe !== false } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 52c418d..41c09bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -70,6 +70,10 @@ 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), + /** 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 57a2741..d6829a7 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -117,6 +117,25 @@ 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. +

+
+