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
97 changes: 74 additions & 23 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down Expand Up @@ -635,35 +649,49 @@ 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

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* |

`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.

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.
**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:
Expand All @@ -686,6 +714,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+<letter>`
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
Expand Down Expand Up @@ -1179,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
Expand Down
63 changes: 63 additions & 0 deletions STORE.md
Original file line number Diff line number Diff line change
@@ -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 <version>.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.
Binary file added build/appx/Square150x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Square310x310Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Square44x44Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Square71x71Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/StoreLogo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added build/appx/Wide310x150Logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions native/clipboard/src/clipboard_unsupported.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

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

Expand Down
80 changes: 73 additions & 7 deletions src/main/accelerators.test.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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', () => {
Expand All @@ -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')
})
})
Loading
Loading