From 09695a530bf69426e258db04110a06a2cf02ec2e Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Fri, 11 Sep 2026 07:21:03 -0500 Subject: [PATCH 1/2] Choose which clips are in play MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spool is not always wanted whole. Ticking clips narrows what the next serve delivers and what the whole-spool paste joins, using one working set for both, so Win+Alt+U, Win+Alt+V and the button cannot come to mean different things. An empty selection means every clip. Selecting nothing and meaning nothing is not a state worth having: it would make both hotkeys dead and the button a no-op with nothing to distinguish that from a bug. Clearing the selection and selecting everything are therefore the same act, and unticking the last box returns to all rather than to none. The selection is not stored. It describes what you are doing now, the way a text selection does, and one that survived a restart would be a rule the user does not remember making. It ends when the active spool changes or the spool is cleared, and drops any deleted clip, because a set still naming a clip that is gone makes the button promise more than it can deliver. Serving steps over what is not in play and wraps among the chosen clips. A cursor left on an excluded clip is not an error — it was put there before the choice was made — so the next serve walks forward to the first clip in play instead of refusing. The button names what it will take: "Put all 15 on the clipboard" becomes "Put 3 of 15", keeping the total visible so a selection reads as a narrowing rather than as the whole truth. Co-Authored-By: Claude Opus 5 --- PLAN.md | 20 ++++ src/main/core/join.ts | 13 ++- src/main/core/selection.test.ts | 62 ++++++++++ src/main/core/selection.ts | 51 ++++++++ src/main/core/spool.ts | 34 +++++- src/main/ipc/index.ts | 6 + src/main/ipc/view.ts | 17 ++- src/main/session.test.ts | 109 ++++++++++++++++++ src/main/session.ts | 41 ++++++- src/preload/index.ts | 7 ++ src/renderer/components/ClipList.tsx | 14 ++- src/renderer/components/ExpandedView.tsx | 17 ++- src/renderer/env.d.ts | 2 + .../helpers/ArrangeListHelper.test.ts | 4 +- src/renderer/helpers/ClipListHelper.test.ts | 5 +- src/renderer/helpers/ExpandedViewHelper.ts | 15 ++- src/renderer/state/useAppState.ts | 4 +- src/shared/ipc.ts | 8 ++ 18 files changed, 406 insertions(+), 23 deletions(-) create mode 100644 src/main/core/selection.test.ts create mode 100644 src/main/core/selection.ts diff --git a/PLAN.md b/PLAN.md index c8e60ad..1de8fb4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -128,6 +128,26 @@ correctly instead of needing special cases. The other half of the product, and the reason reordering exists: collect a scattered set of values, arrange them, and put them back into one document in the right order. +### Choosing which clips are in play + +A spool is not always wanted whole. Ticking clips narrows what the next serve delivers and what the +whole-spool paste joins — the same working set for both, so `Win+Alt+U`, `Win+Alt+V` and the button +can never mean different things. + +**An empty selection means every clip.** Selecting nothing and meaning nothing is not a state worth +having: it would make both hotkeys dead and the button a no-op, with nothing to distinguish that +from a bug. So clearing the selection and selecting everything are the same act, and the app offers +one command rather than two that disagree at the edges. Unticking the last box returns to all. + +The selection is **not stored**. It describes what you are doing now, the way a text selection does, +and one that survived a restart would be a rule the user does not remember making. It ends when the +active spool changes, when the spool is cleared, and it drops any clip that is deleted — a set still +naming a clip that is gone would make the button promise more than it can deliver. + +Serving steps over what is not in play, in both directions, and wraps among the chosen clips. A +cursor sitting on an excluded clip is not an error: it was put there before the choice was made, so +the next serve walks forward to the first clip that is in play rather than refusing. + **It joins and writes once.** Every clip in the spool is concatenated with a separator and written to the system clipboard as a single item. The user then pastes normally, once. The alternative — synthesising one paste per clip — is rejected for the same reason serve-and-paste is (§8): it needs diff --git a/src/main/core/join.ts b/src/main/core/join.ts index a52c597..4931bef 100644 --- a/src/main/core/join.ts +++ b/src/main/core/join.ts @@ -1,3 +1,4 @@ +import { selectedClips } from './selection' import type { Spool } from './types' /** @@ -66,10 +67,16 @@ export type JoinResult = * would silently drop the clips behind it. The cursor does not move: this is a bulk read, not a * traversal. */ -export function joinSpool(spool: Spool, separator: SeparatorKind): JoinResult { - if (spool.clips.length === 0) return { ok: false, reason: 'empty' } +export function joinSpool( + spool: Spool, + separator: SeparatorKind, + selection: ReadonlySet = new Set() +): JoinResult { + // A selection narrows what is joined; empty means every clip, so the ordinary case is unchanged. + const chosen = selectedClips(spool.clips, selection) + if (chosen.length === 0) return { ok: false, reason: 'empty' } - const ordered = spool.mode === 'fifo' ? spool.clips : [...spool.clips].reverse() + const ordered = spool.mode === 'fifo' ? chosen : [...chosen].reverse() const text = ordered.map((clip) => clip.content).join(separatorText(separator)) return { diff --git a/src/main/core/selection.test.ts b/src/main/core/selection.test.ts new file mode 100644 index 0000000..f89f3a4 --- /dev/null +++ b/src/main/core/selection.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { isSelected, prune, selectedClips, selectedCount, toggle } from './selection' +import type { Clip } from './types' + +const clip = (id: string): Clip => ({ + id, + content: id, + preview: id, + byteLength: id.length, + sourceApp: null, + wasFlagged: false, + capturedAt: '2026-09-11T00:00:00.000Z' +}) + +const clips = [clip('a'), clip('b'), clip('c')] + +describe('an empty selection means every clip (PLAN.md 3)', () => { + // The rule the whole feature rests on: selecting nothing and meaning nothing is not a state worth + // having, because it would make the hotkeys dead with no way to tell that apart from a bug. + it('treats every clip as in play', () => { + const none = new Set() + expect(clips.every((c) => isSelected(c.id, none))).toBe(true) + expect(selectedClips(clips, none)).toEqual(clips) + expect(selectedCount(clips, none)).toBe(3) + }) + + it('narrows to exactly what was chosen once something is', () => { + const some = new Set(['a', 'c']) + expect(selectedClips(clips, some).map((c) => c.id)).toEqual(['a', 'c']) + expect(selectedCount(clips, some)).toBe(2) + expect(isSelected('b', some)).toBe(false) + }) + + it('keeps spool order rather than the order things were ticked', () => { + expect(selectedClips(clips, new Set(['c', 'a'])).map((c) => c.id)).toEqual(['a', 'c']) + }) +}) + +describe('toggle', () => { + it('adds and removes one clip', () => { + expect([...toggle(new Set(), 'b')]).toEqual(['b']) + expect([...toggle(new Set(['b']), 'b')]).toEqual([]) + }) + + // Unticking the last box returns to all, which is the same state as never having ticked one. + // Clearing the selection and selecting everything are one act, so they cannot disagree. + it('emptying the selection is the same as selecting all', () => { + const emptied = toggle(new Set(['b']), 'b') + expect(selectedCount(clips, emptied)).toBe(3) + }) +}) + +describe('prune', () => { + it('drops ids for clips that are gone', () => { + expect([...prune([clip('a')], new Set(['a', 'b']))]).toEqual(['a']) + }) + + // A selection still holding a deleted clip would make the button promise more than it can give. + it('a selection emptied by deletion means all again, not none', () => { + expect(selectedCount([clip('a')], prune([clip('a')], new Set(['gone'])))).toBe(1) + }) +}) diff --git a/src/main/core/selection.ts b/src/main/core/selection.ts new file mode 100644 index 0000000..4bdb41d --- /dev/null +++ b/src/main/core/selection.ts @@ -0,0 +1,51 @@ +import type { Clip } from './types' + +/** + * Which clips are in play (PLAN.md 3). + * + * A selection is a working set, not a property of the clips: it says which of them the next serve, + * the next whole-spool paste, and the button that offers it should consider. It is deliberately not + * stored — it is about what you are doing right now, the way a text selection is, and a selection + * that survived a restart would be a rule the user does not remember making. + * + * **An empty selection means every clip.** Selecting nothing and meaning nothing is not a state + * worth having: it would make the hotkeys dead and the button a no-op, with no way to tell that + * apart from a bug. So clearing the selection and selecting everything are the same act, and the + * app says so rather than offering both. + */ +export function isSelected(clipId: string, selection: ReadonlySet): boolean { + return selection.size === 0 || selection.has(clipId) +} + +/** The clips a serve or a join should consider, in spool order. */ +export function selectedClips( + clips: readonly Clip[], + selection: ReadonlySet +): readonly Clip[] { + return selection.size === 0 ? clips : clips.filter((clip) => selection.has(clip.id)) +} + +/** How many clips are in play, which is what the button has to name. */ +export function selectedCount(clips: readonly Clip[], selection: ReadonlySet): number { + return selectedClips(clips, selection).length +} + +/** + * Drop ids that are no longer in the spool. + * + * A selection holding a deleted clip would keep counting it, so the button would promise more than + * it could deliver — and an id that came back on a later clip would silently select something the + * user never chose. + */ +export function prune(clips: readonly Clip[], selection: ReadonlySet): Set { + const present = new Set(clips.map((clip) => clip.id)) + return new Set([...selection].filter((id) => present.has(id))) +} + +/** Add or remove one clip. Removing the last one empties the selection, which means all again. */ +export function toggle(selection: ReadonlySet, clipId: string): Set { + const next = new Set(selection) + if (next.has(clipId)) next.delete(clipId) + else next.add(clipId) + return next +} diff --git a/src/main/core/spool.ts b/src/main/core/spool.ts index 4cbbd6d..eb07b07 100644 --- a/src/main/core/spool.ts +++ b/src/main/core/spool.ts @@ -1,5 +1,6 @@ import { byteLength } from './clip' import { CLIP_BYTE_CAP, DEFAULT_SPOOL_CLIP_CAP, SAVED_SPOOL_CLIP_CAP } from './limits' +import { isSelected } from './selection' import type { CaptureResult, Clip, Mode, ServeResult, Spool, SpoolKind } from './types' /** The clip cap this spool is bound by (PLAN.md 3, Limits). */ @@ -100,14 +101,37 @@ export function capture(spool: Spool, clip: Clip): CaptureResult { * Write the cursor's clip out and advance (PLAN.md 3). **Serving pastes; it does not pop** — the * clip stays exactly where it was, and the cursor moves one step in the mode's direction, wrapping * at the end. + * + * A selection narrows what is in play: unselected clips are stepped over, so unspooling walks the + * chosen ones in the mode's order and wraps among them. An empty selection means every clip, so + * the ordinary case costs nothing. */ -export function serve(spool: Spool): ServeResult { - const index = cursorIndex(spool) - if (index === -1) return { ok: false, reason: 'empty', spool } +export function serve(spool: Spool, selection: ReadonlySet = new Set()): ServeResult { + const count = spool.clips.length + if (count === 0) return { ok: false, reason: 'empty', spool } + + const eligible = (clip: Clip): boolean => isSelected(clip.id, selection) + if (!spool.clips.some(eligible)) return { ok: false, reason: 'empty', spool } + + const start = cursorIndex(spool) + if (start === -1) return { ok: false, reason: 'empty', spool } + const direction = step(spool.mode) + + // The cursor may be sitting on a clip the selection excludes — it was put there before the + // selection was made. Walk to the first one that is in play rather than refusing to serve. + const nextEligible = (from: number): number => { + let at = from + for (let taken = 0; taken < count; taken++) { + if (eligible(spool.clips[at])) return at + at = (at + direction + count) % count + } + return from + } + + const index = nextEligible(start) const clip = spool.clips[index] - const count = spool.clips.length - const next = (index + step(spool.mode) + count) % count + const next = nextEligible((index + direction + count) % count) return { ok: true, clip, spool: { ...spool, cursorClipId: spool.clips[next].id } } } diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 6058829..e62fbb0 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -85,6 +85,10 @@ export function registerIpc( ipcMain.handle(CHANNELS.acknowledgePrivacy, () => actions.acknowledgePrivacy()) ipcMain.handle(CHANNELS.toggleMode, () => session.toggleMode()) ipcMain.handle(CHANNELS.setAutoPaste, (_event, enabled: boolean) => session.setAutoPaste(enabled)) + ipcMain.handle(CHANNELS.toggleClipSelected, (_event, clipId: string) => + session.toggleClipSelected(clipId) + ) + ipcMain.handle(CHANNELS.selectAllClips, () => session.selectAllClips()) ipcMain.handle(CHANNELS.setHotkey, (_event, action: HotkeyAction, accelerator: string) => actions.setHotkey(action, accelerator) ) @@ -129,6 +133,8 @@ export function registerIpc( ipcMain.removeHandler(CHANNELS.acknowledgePrivacy) ipcMain.removeHandler(CHANNELS.toggleMode) ipcMain.removeHandler(CHANNELS.setAutoPaste) + ipcMain.removeHandler(CHANNELS.toggleClipSelected) + ipcMain.removeHandler(CHANNELS.selectAllClips) ipcMain.removeHandler(CHANNELS.setHotkey) ipcMain.removeHandler(CHANNELS.resetHotkey) ipcMain.removeHandler(CHANNELS.resumeCapture) diff --git a/src/main/ipc/view.ts b/src/main/ipc/view.ts index 3a5751c..6d25f1b 100644 --- a/src/main/ipc/view.ts +++ b/src/main/ipc/view.ts @@ -1,3 +1,4 @@ +import { isSelected, selectedCount } from '../core/selection' import { clipCap } from '../core/spool' import type { Spool } from '../core/types' import type { SpoolView } from '../../shared/ipc' @@ -8,7 +9,10 @@ import type { SpoolView } from '../../shared/ipc' * Clip **content** deliberately does not cross: the compact window shows previews, and the full * text has no business in a renderer that only displays it. Pure, so it is tested without a window. */ -export function toSpoolView(spool: Spool): SpoolView { +export function toSpoolView( + spool: Spool, + selection: ReadonlySet = new Set() +): SpoolView { return { name: spool.name, mode: spool.mode, @@ -16,10 +20,17 @@ export function toSpoolView(spool: Spool): SpoolView { id: clip.id, preview: clip.preview, capturedAt: clip.capturedAt, - sourceApp: clip.sourceApp + sourceApp: clip.sourceApp, + isSelected: isSelected(clip.id, selection) })), cursorClipId: spool.cursorClipId, count: spool.clips.length, - cap: clipCap(spool.kind) + cap: clipCap(spool.kind), + // What a serve or a whole-spool paste would act on. Equal to `count` when nothing is chosen, + // because an empty selection means every clip. + inPlay: selectedCount(spool.clips, selection), + // Whether the user has actually chosen a subset, which is what the UI needs to know to offer + // a way back to all of them. + hasSelection: selection.size > 0 } } diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 465cbdb..436a968 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -780,6 +780,115 @@ describe('pasting the whole spool (PLAN.md 3)', () => { }) }) +describe('choosing which clips are in play (PLAN.md 3)', () => { + function withClips(...contents: string[]) { + const { session, watcher, written } = started((report) => report(true)) + for (const content of contents) watcher.change(text(content)) + return { session, watcher, written } + } + + const ids = (session: Session): string[] => session.getState().spool.clips.map((c) => c.id) + + it('unspools every clip when nothing has been chosen', () => { + const { session, written } = withClips('one', 'two', 'three') + + session.serveNext() + session.serveNext() + + expect(written).toEqual(['one', 'two']) + expect(session.getState().spool.inPlay).toBe(3) + expect(session.getState().spool.hasSelection).toBe(false) + }) + + it('steps over the clips that were not chosen', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.toggleClipSelected(first) + session.toggleClipSelected(third) + + session.serveNext() + session.serveNext() + + expect(written).toEqual(['one', 'three']) + }) + + it('wraps among the chosen clips rather than through the others', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.toggleClipSelected(first) + session.toggleClipSelected(third) + + session.serveNext() + session.serveNext() + session.serveNext() + + expect(written).toEqual(['one', 'three', 'one']) + }) + + it('joins only the chosen clips, in spool order', () => { + const { session, written } = withClips('one', 'two', 'three') + const [first, , third] = ids(session) + session.toggleClipSelected(third) + session.toggleClipSelected(first) + + session.pasteWholeSpool() + + expect(written).toEqual(['one\nthree']) + expect(session.getState().spool.inPlay).toBe(2) + expect(session.getState().spool.hasSelection).toBe(true) + }) + + it('serves a chosen clip even when the cursor sat on one that was not', () => { + const { session, written } = withClips('one', 'two', 'three') + // The cursor is on 'one'; choosing only the third must not leave serving stuck. + session.toggleClipSelected(ids(session)[2]) + + session.serveNext() + + expect(written).toEqual(['three']) + }) + + it('unticking the last clip returns to all, rather than to none', () => { + const { session, written } = withClips('one', 'two') + const [first] = ids(session) + session.toggleClipSelected(first) + expect(session.getState().spool.inPlay).toBe(1) + + session.toggleClipSelected(first) + + expect(session.getState().spool.hasSelection).toBe(false) + session.pasteWholeSpool() + expect(written).toEqual(['one\ntwo']) + }) + + it('selecting all again is the way back, and costs nothing when already there', () => { + const { session } = withClips('one', 'two') + session.toggleClipSelected(ids(session)[0]) + + session.selectAllClips() + + expect(session.getState().spool.hasSelection).toBe(false) + expect(session.getState().spool.inPlay).toBe(2) + }) + + it('forgets the choice when the clips it named are deleted', () => { + const { session } = withClips('one', 'two') + const [first] = ids(session) + session.toggleClipSelected(first) + + session.deleteClip(first) + + // The chosen clip is gone, so the set is empty — which means all of what is left. + expect(session.getState().spool.hasSelection).toBe(false) + expect(session.getState().spool.inPlay).toBe(1) + }) + + it('marks every clip as in play in the view when nothing is chosen', () => { + const { session } = withClips('one', 'two') + expect(session.getState().spool.clips.every((c) => c.isSelected)).toBe(true) + }) +}) + describe('arranging (PLAN.md 11, M7)', () => { it('applies an arrangement to the active spool', () => { const { session, watcher } = started() diff --git a/src/main/session.ts b/src/main/session.ts index e34883a..1496e54 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -35,6 +35,7 @@ import { type MeasureName } from './core/capacity' import { expireClips, isRetentionHours } from './core/retention' +import { prune, toggle } from './core/selection' import { arrange, clear, createSpool, deleteClip, serve, setMode } from './core/spool' import type { Clip, Mode, Spool } from './core/types' import type { ClipboardSnapshot } from './detect/admit' @@ -147,6 +148,16 @@ export class Session { */ private autoPaste = true + /** + * Which clips are in play (PLAN.md 3). Empty means every clip, and is the state the app starts + * in — a selection nobody has made is not a selection of nothing. + * + * Not stored, and cleared when the active spool changes. It describes what you are doing now, the + * way a text selection does; one that survived a restart would be a rule the user does not + * remember making. + */ + private selection: ReadonlySet = new Set() + constructor( private readonly writeText: (text: string) => void, /** @@ -176,6 +187,22 @@ export class Session { this.publish() } + /** Put one clip in or out of the working set. */ + toggleClipSelected(clipId: string): void { + this.selection = toggle(this.selection, clipId) + this.publish() + } + + /** + * Back to every clip. Clearing the selection and selecting everything are the same act, so there + * is one command rather than two that quietly disagree at the edges. + */ + selectAllClips(): void { + if (this.selection.size === 0) return + this.selection = new Set() + this.publish() + } + setAutoPaste(enabled: boolean): void { this.autoPaste = enabled this.publish() @@ -370,7 +397,7 @@ export class Session { * pasted as many times as the user likes. */ serveNext(): void { - const result = serve(this.state.spool) + const result = serve(this.state.spool, this.selection) if (!result.ok) { this.notice = NOTHING_TO_PASTE @@ -406,7 +433,7 @@ export class Session { * application on the machine. */ pasteWholeSpool(confirmed = false): void { - const joined = joinSpool(this.state.spool, this.settings.separator) + const joined = joinSpool(this.state.spool, this.settings.separator, this.selection) if (!joined.ok) { this.notice = NOTHING_TO_PASTE @@ -592,6 +619,9 @@ export class Session { */ deleteClip(clipId: string): void { this.state = { ...this.state, spool: deleteClip(this.state.spool, clipId) } + // A selection holding a clip that is gone would keep counting it, so the button would promise + // more than it can deliver. + this.selection = prune(this.state.spool.clips, this.selection) this.publish() } @@ -601,6 +631,8 @@ export class Session { this.state = { ...this.state, spool: clear(this.state.spool) } // The next copy is not a duplicate of something that is no longer there. this.state = { ...this.state, lastCapturedText: null } + // Every clip the working set named has gone with them. + this.selection = new Set() } else { this.otherSpools = this.otherSpools.map((spool) => spool.id === spoolId ? clear(spool) : spool @@ -834,6 +866,9 @@ export class Session { const remaining = this.otherSpools.filter((spool) => spool.id !== next.id) this.otherSpools = keepLeaving ? [...remaining, leaving] : remaining + // The working set named clips in the spool being left, so it ends with it. Carrying it across + // would leave a selection the user cannot see and did not make here. + this.selection = new Set() // Duplicate suppression compares against the last capture *in this spool*, so it resets. this.state = { ...this.state, spool: this.touch(next), lastCapturedText: null } this.savedSpool = null @@ -841,7 +876,7 @@ export class Session { getState(): AppState { return { - spool: toSpoolView(this.state.spool), + spool: toSpoolView(this.state.spool, this.selection), notice: this.notice, capture: this.capture, storage: this.storage, diff --git a/src/preload/index.ts b/src/preload/index.ts index 9c31d69..09d5dd1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -71,6 +71,13 @@ const api = { setAutoPaste: (enabled: boolean): Promise => ipcRenderer.invoke(CHANNELS.setAutoPaste, enabled), + /** Put one clip in or out of the working set (PLAN.md 3). */ + toggleClipSelected: (clipId: string): Promise => + ipcRenderer.invoke(CHANNELS.toggleClipSelected, clipId), + + /** Back to every clip. Clearing a selection and selecting all are the same act. */ + selectAllClips: (): Promise => ipcRenderer.invoke(CHANNELS.selectAllClips), + /** 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/ClipList.tsx b/src/renderer/components/ClipList.tsx index ff2300a..fba77d5 100644 --- a/src/renderer/components/ClipList.tsx +++ b/src/renderer/components/ClipList.tsx @@ -38,6 +38,18 @@ export function ClipList({ spool }: { spool: SpoolView }): JSX.Element { } >
+ {/* + Checked means in play. With nothing chosen every box is checked, because an empty + selection means every clip — so the list never shows a state the hotkeys disagree + with (PLAN.md 3). + */} + void window.spool.toggleClipSelected(clip.id)} + aria-label={`Include ${clip.preview}`} + className="mt-0.5 shrink-0" + />
{source !== null && ( - {source} + {source} )} ) diff --git a/src/renderer/components/ExpandedView.tsx b/src/renderer/components/ExpandedView.tsx index a31770d..424f850 100644 --- a/src/renderer/components/ExpandedView.tsx +++ b/src/renderer/components/ExpandedView.tsx @@ -1,7 +1,7 @@ import { useState, type JSX } from 'react' import type { AppState, SeparatorKind } from '../../shared/ipc' import { hasChanged, sameClips } from '../helpers/ArrangeListHelper' -import { formatBytes, separatorOptions } from '../helpers/ExpandedViewHelper' +import { formatBytes, pasteAllLabel, separatorOptions } from '../helpers/ExpandedViewHelper' import { ArrangeList } from './ArrangeList' import { SpoolSidebar } from './SpoolSidebar' @@ -134,10 +134,21 @@ export function ExpandedView({ onClick={() => void window.spool.pasteWholeSpool()} className="w-full rounded border border-spool-thread/50 px-2 py-1.5 text-spool-thread hover:bg-spool-thread/10 disabled:border-spool-paper/10 disabled:text-spool-paper/25" > - Put all {spool.count} on the clipboard + {pasteAllLabel(spool)} + {spool.hasSelection && ( + + )}

- Then paste once, with Ctrl+V. The cursor does not move. + {spool.hasSelection + ? 'Ticked clips are the ones in play, for this button and for unspooling.' + : 'Then paste once, with Ctrl+V. The cursor does not move.'}

diff --git a/src/renderer/env.d.ts b/src/renderer/env.d.ts index 4757508..3a639a1 100644 --- a/src/renderer/env.d.ts +++ b/src/renderer/env.d.ts @@ -27,6 +27,8 @@ declare global { pauseCapture(): Promise acknowledgePrivacy(): Promise toggleMode(): Promise + toggleClipSelected(clipId: string): Promise + selectAllClips(): Promise setAutoPaste(enabled: boolean): Promise setHotkey(action: HotkeyAction, accelerator: string): Promise resetHotkey(action: HotkeyAction): Promise diff --git a/src/renderer/helpers/ArrangeListHelper.test.ts b/src/renderer/helpers/ArrangeListHelper.test.ts index 1acb52d..d9b4f98 100644 --- a/src/renderer/helpers/ArrangeListHelper.test.ts +++ b/src/renderer/helpers/ArrangeListHelper.test.ts @@ -40,10 +40,10 @@ describe('hasChanged', () => { describe('sourceLabel', () => { it('drops the extension a Windows process name carries', () => { - expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: 'EXCEL.EXE' })).toBe( + expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: 'EXCEL.EXE', isSelected: true })).toBe( 'EXCEL' ) - expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: null })).toBeNull() + expect(sourceLabel({ id: 'a', preview: 'p', capturedAt: 'x', sourceApp: null, isSelected: true })).toBeNull() }) }) diff --git a/src/renderer/helpers/ClipListHelper.test.ts b/src/renderer/helpers/ClipListHelper.test.ts index 5a07e9e..a283870 100644 --- a/src/renderer/helpers/ClipListHelper.test.ts +++ b/src/renderer/helpers/ClipListHelper.test.ts @@ -6,7 +6,8 @@ const clip = (id: string, sourceApp: string | null = null): ClipView => ({ id, preview: `preview ${id}`, capturedAt: '2026-08-22T07:00:00.000Z', - sourceApp + sourceApp, + isSelected: true }) const spool = (ids: string[], cursorClipId: string | null): SpoolView => ({ @@ -14,6 +15,8 @@ const spool = (ids: string[], cursorClipId: string | null): SpoolView => ({ mode: 'fifo', clips: ids.map((id) => clip(id)), cursorClipId, + inPlay: ids.length, + hasSelection: false, count: ids.length, cap: 50 }) diff --git a/src/renderer/helpers/ExpandedViewHelper.ts b/src/renderer/helpers/ExpandedViewHelper.ts index 2a5558f..bee09a5 100644 --- a/src/renderer/helpers/ExpandedViewHelper.ts +++ b/src/renderer/helpers/ExpandedViewHelper.ts @@ -1,4 +1,4 @@ -import type { SeparatorKind } from '../../shared/ipc' +import type { SeparatorKind, SpoolView } from '../../shared/ipc' /** Pure helpers for the expanded window. No React, no I/O (PLAN.md 6). */ @@ -26,3 +26,16 @@ export function formatBytes(bytes: number): string { if (kib >= 1) return `${Math.round(kib * 10) / 10} KB` return `${bytes} bytes` } + +/** + * What the whole-spool button says it will take (PLAN.md 3). + * + * It names the number in play rather than the number in the spool, because the button and the + * hotkey must agree: `Win+Alt+V` pastes exactly what this says. "All 15" when nothing is chosen, + * "3 of 15" when a subset is — the total stays visible so the selection is legible as a narrowing + * rather than as the whole truth. + */ +export function pasteAllLabel(spool: SpoolView): string { + if (!spool.hasSelection) return `Put all ${spool.count} on the clipboard` + return `Put ${spool.inPlay} of ${spool.count} on the clipboard` +} diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts index 9e07e4e..21d5040 100644 --- a/src/renderer/state/useAppState.ts +++ b/src/renderer/state/useAppState.ts @@ -8,7 +8,9 @@ import type { AppState } from '../../shared/ipc' type Action = { type: 'state'; state: AppState } const initialState: AppState = { - spool: { name: 'Default spool', mode: 'fifo', clips: [], cursorClipId: null, count: 0, cap: 50 }, + spool: { name: 'Default spool', mode: 'fifo', clips: [], cursorClipId: null, + inPlay: 0, + hasSelection: false, count: 0, cap: 50 }, notice: null, capture: { available: false, reason: null }, prompt: null, diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 1321425..6689c8e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -53,6 +53,8 @@ export interface ClipView { readonly preview: string readonly capturedAt: string readonly sourceApp: string | null + /** Whether this clip is in play. True for every clip when nothing has been chosen (PLAN.md 3). */ + readonly isSelected: boolean } export interface SpoolView { @@ -63,6 +65,10 @@ export interface SpoolView { readonly cursorClipId: string | null readonly count: number readonly cap: number + /** How many clips a serve or a whole-spool paste would act on — `count` unless a subset is chosen. */ + readonly inPlay: number + /** Whether the user has chosen a subset, as distinct from the empty selection that means all. */ + readonly hasSelection: boolean } /** The four choices offered by the consent prompt (PLAN.md 4). */ @@ -228,6 +234,8 @@ export const CHANNELS = { toggleMode: 'spool:toggle-mode', setHotkey: 'spool:set-hotkey', setAutoPaste: 'spool:set-auto-paste', + toggleClipSelected: 'spool:toggle-clip-selected', + selectAllClips: 'spool:select-all-clips', resetHotkey: 'spool:reset-hotkey', resumeCapture: 'spool:resume-capture', deleteSpools: 'spool:delete-spools', From b25886e875c100e47760dbe69031fc2148166024 Mon Sep 17 00:00:00 2001 From: wkotheimer Date: Fri, 11 Sep 2026 07:37:55 -0500 Subject: [PATCH 2/2] Stop guessing whether a clip is a secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The heuristics scanned every copy for PEM blocks, JWTs, key prefixes, connection strings and high-entropy text, and prompted on a match. They went for three reasons. They interrupted an ordinary workflow to report something the user already knew. Copying a credential is a normal thing to do, and the prompt arrived every time, asking permission for the thing the person had just deliberately done. The premise was weaker than it looked. The guessing defended against exposure, but nothing Spool holds leaves the machine. What Spool does change is persistence: a clipboard entry that would have lived until the next copy instead lives in an encrypted file with a visible preview. That is a real difference and it is the honest case for asking — it is not a strong enough one to justify asking about every API key a developer copies. And it was the entire cost of capture: 147ms per MiB, because each needle walked the whole buffer separately. Classification is now 0.003ms, because it no longer reads the content at all — classify does not take the bytes any more, which is the strongest form that claim can take. Two tests that failed intermittently at a five-second timeout stopped being flaky as a side effect. What is kept is not a guess. CanIncludeInClipboardHistory = 0 is an explicit statement from the application that owns the secret, and Windows' own Clipboard History obeys it. Spool makes a transient thing durable, so ignoring it would persist exactly what a password manager asked it not to, and behave worse than the OS feature beside it. It costs a flag check. detect/bytes.ts loses nine functions that existed only to feed the heuristics. wipe stays: a declined clip must not be left in memory. Co-Authored-By: Claude Opus 5 --- PLAN.md | 32 ++++- src/main/clipboard/capture.ts | 11 +- src/main/detect/bytes.ts | 116 +-------------- src/main/detect/consent.ts | 22 ++- src/main/detect/sensitivity.test.ts | 117 ++++------------ src/main/detect/sensitivity.ts | 163 +++------------------- src/main/session.test.ts | 19 ++- src/main/session.ts | 3 - src/renderer/components/ConsentPrompt.tsx | 10 +- src/renderer/components/FirstRun.tsx | 5 +- src/renderer/components/PrivacyPanel.tsx | 19 +-- src/renderer/state/useAppState.ts | 1 - src/shared/ipc.ts | 3 - 13 files changed, 117 insertions(+), 404 deletions(-) diff --git a/PLAN.md b/PLAN.md index 1de8fb4..fd84044 100644 --- a/PLAN.md +++ b/PLAN.md @@ -210,9 +210,31 @@ on a device with far less room. See §10 for the arithmetic. ## 4. Sensitive clips -Two tiers, with different confidence and different wording. +One signal: what the source application declared. -### Tier 1 — Declared (authoritative) +**Tier 2 was removed.** The app used to also guess from content — PEM blocks, JWTs, key prefixes +like `sk-` and `AKIA`, connection-string keywords, high-entropy strings — and prompt on a match. It +went for three reasons, in order of weight. + +It interrupted an ordinary workflow to say something the user already knew. **Copying a credential +is a normal thing to do**, and the prompt arrived every time, asking permission for the thing the +person had just deliberately done. + +The premise was weaker than it looked. The heuristics existed against a risk of *exposure*, but +nothing Spool holds leaves the machine — the guarantee of §5 is the whole product. What Spool does +change is **persistence**: a clipboard entry that would have lived until the next copy instead lives +in an encrypted file with a visible preview. That is a real difference, and it is the honest case +for asking. It is not a strong enough one to justify asking about every API key a developer copies. + +And it was the entire cost of capture: **147ms per MiB**, because each needle walked the whole +buffer separately. Removing it took classification from 147ms to 0.003ms, because it no longer reads +the content at all — `classify` does not take the bytes any more, which is the strongest form that +claim can take. Two tests that failed intermittently at a five-second timeout stopped being flaky as +a side effect. + +What is kept is **not a guess**, and that distinction is the whole of the decision. + +### What the application declared (authoritative) The source application marked the clipboard content as secret. Password managers do this. @@ -222,6 +244,12 @@ The source application marked the clipboard content as secret. Password managers Prompt names the source: *"1Password marked this as concealed. Keep it in this spool?"* +`CanIncludeInClipboardHistory = 0` is an explicit statement from the application that owns the +secret, saying *do not persist this*, and **Windows' own Clipboard History obeys it**. Spool makes a +transient thing durable, so ignoring that request would persist exactly what a password manager asked +it not to, and leave Spool behaving worse than the operating-system feature beside it. It costs a +flag check and no scanning, which is why it survives the argument that removed the other tier. + ### Tier 2 — Heuristic (advisory) Pattern or entropy match. Lower confidence, softer wording: *"This looks like a secret."* diff --git a/src/main/clipboard/capture.ts b/src/main/clipboard/capture.ts index 0572d4c..4c2fd81 100644 --- a/src/main/clipboard/capture.ts +++ b/src/main/clipboard/capture.ts @@ -117,13 +117,10 @@ export function captureSnapshot( } } - const sensitivity = classify( - { - formats: snapshot.formats, - canIncludeInClipboardHistory: snapshot.canIncludeInClipboardHistory ?? null - }, - bytes - ) + const sensitivity = classify({ + formats: snapshot.formats, + canIncludeInClipboardHistory: snapshot.canIncludeInClipboardHistory ?? null + }) const decision = decideConsent(sensitivity, snapshot.sourceApp ?? null, cleared.sourceRules) if (decision.kind === 'skip') { diff --git a/src/main/detect/bytes.ts b/src/main/detect/bytes.ts index 63f3fb1..a858b61 100644 --- a/src/main/detect/bytes.ts +++ b/src/main/detect/bytes.ts @@ -1,117 +1,15 @@ /** - * Byte-level helpers for the sensitivity detectors (PLAN.md 4). + * Working on clipboard bytes rather than strings (PLAN.md 4). * - * These work on `Uint8Array` and never build a string, which is the whole point: a JavaScript - * string is immutable and garbage-collected, so a secret that becomes one cannot be wiped and may - * outlive the user's decision — possibly into a swap file. Detection therefore happens on the bytes - * the addon handed over, and the bytes are what gets zeroed on Skip. + * This module was once a small byte-searching library — `ascii`, `startsWith`, `includes`, + * `indexOf`, `trim`, `isDigit`, `hasWhitespace`, `characterClasses`, `shannonEntropy` — built so the + * secret heuristics could scan a copy without ever turning it into a string. The heuristics were + * removed, and every one of those went with them: there is nothing left that reads the content. * - * ASCII-only comparisons are enough for every pattern in §4: PEM headers, base64url, key prefixes, - * and connection-string keywords are all ASCII, and UTF-8 encodes ASCII as itself, so a multi-byte - * character can never be mistaken for one of them. + * `wipe` stays, and it is the one that mattered. A clip the user declines must not be left in + * memory, and zeroing the buffer is the only thing this file does now. */ -const encoder = new TextEncoder() - -/** The ASCII bytes of a literal, for comparing against clipboard content. */ -export function ascii(literal: string): Uint8Array { - return encoder.encode(literal) -} - -const isUpper = (byte: number): boolean => byte >= 0x41 && byte <= 0x5a -const isLower = (byte: number): boolean => byte >= 0x61 && byte <= 0x7a - -/** Lowercase one ASCII byte, leaving everything else alone. */ -const foldCase = (byte: number): number => (isUpper(byte) ? byte + 0x20 : byte) - -export function isWhitespace(byte: number): boolean { - return byte === 0x20 || byte === 0x09 || byte === 0x0a || byte === 0x0d || byte === 0x0b -} - -export function isDigit(byte: number): boolean { - return byte >= 0x30 && byte <= 0x39 -} - -/** Does `haystack` begin with `needle`, ignoring leading whitespace? */ -export function startsWith(haystack: Uint8Array, needle: Uint8Array): boolean { - let start = 0 - while (start < haystack.length && isWhitespace(haystack[start])) start += 1 - if (haystack.length - start < needle.length) return false - - for (let i = 0; i < needle.length; i += 1) { - if (haystack[start + i] !== needle[i]) return false - } - return true -} - -/** Does `haystack` contain `needle` anywhere? `fold` compares case-insensitively. */ -export function includes(haystack: Uint8Array, needle: Uint8Array, fold = false): boolean { - return indexOf(haystack, needle, fold) !== -1 -} - -export function indexOf(haystack: Uint8Array, needle: Uint8Array, fold = false, from = 0): number { - if (needle.length === 0 || haystack.length < needle.length) return -1 - - outer: for (let i = from; i <= haystack.length - needle.length; i += 1) { - for (let j = 0; j < needle.length; j += 1) { - const a = fold ? foldCase(haystack[i + j]) : haystack[i + j] - const b = fold ? foldCase(needle[j]) : needle[j] - if (a !== b) continue outer - } - return i - } - return -1 -} - -/** The content with leading and trailing whitespace removed — a view, not a copy. */ -export function trim(bytes: Uint8Array): Uint8Array { - let start = 0 - let end = bytes.length - while (start < end && isWhitespace(bytes[start])) start += 1 - while (end > start && isWhitespace(bytes[end - 1])) end -= 1 - return bytes.subarray(start, end) -} - -export function hasWhitespace(bytes: Uint8Array): boolean { - for (const byte of bytes) if (isWhitespace(byte)) return true - return false -} - -/** How many of the four character classes appear: lower, upper, digit, and everything else. */ -export function characterClasses(bytes: Uint8Array): number { - let lower = false - let upper = false - let digit = false - let symbol = false - - for (const byte of bytes) { - if (isLower(byte)) lower = true - else if (isUpper(byte)) upper = true - else if (isDigit(byte)) digit = true - else symbol = true - } - - return [lower, upper, digit, symbol].filter(Boolean).length -} - -/** - * Shannon entropy in bits per byte. Random-looking material scores high; English prose and - * repetitive identifiers score low. - */ -export function shannonEntropy(bytes: Uint8Array): number { - if (bytes.length === 0) return 0 - - const counts = new Map() - for (const byte of bytes) counts.set(byte, (counts.get(byte) ?? 0) + 1) - - let entropy = 0 - for (const count of counts.values()) { - const probability = count / bytes.length - entropy -= probability * Math.log2(probability) - } - return entropy -} - /** * Zero the bytes and drop them. Best-effort, and worth describing as exactly that: it is defeated * by a process dump or a swapped page, and it is still far better than letting a declined password diff --git a/src/main/detect/consent.ts b/src/main/detect/consent.ts index 21d9051..2f46553 100644 --- a/src/main/detect/consent.ts +++ b/src/main/detect/consent.ts @@ -61,25 +61,21 @@ export function keepsTheClip(choice: ConsentChoice): boolean { */ export const CONSENT_TIMEOUT_MS = 30_000 -/** How the prompt reads. Tier 1 names the source; Tier 2 is softer, because it is a guess. */ +/** + * How the prompt reads. It always names the source, because the app itself is what raised this — + * there is no longer a softer wording for a guess, because there are no guesses. + */ export function promptWording( sensitivity: Sensitivity, sourceApp: string | null ): { headline: string; detail: string } { const application = sourceApp === null ? null : sourceApp.replace(/\.exe$/i, '') - if (sensitivity.tier === 1) { - return { - headline: - application === null - ? 'That copy was marked as concealed. Keep it in this spool?' - : `${application} marked this as concealed. Keep it in this spool?`, - detail: sensitivity.rule - } - } - return { - headline: 'This looks like a secret. Keep it in this spool?', - detail: `It looks like ${sensitivity.rule}.` + headline: + application === null + ? 'That copy was marked as concealed. Keep it in this spool?' + : `${application} marked this as concealed. Keep it in this spool?`, + detail: sensitivity.rule } } diff --git a/src/main/detect/sensitivity.test.ts b/src/main/detect/sensitivity.test.ts index 1409d21..3ae2540 100644 --- a/src/main/detect/sensitivity.test.ts +++ b/src/main/detect/sensitivity.test.ts @@ -1,23 +1,20 @@ import { describe, expect, it } from 'vitest' -import { wipe } from './bytes' -import { classify, declaredConcealed, looksLikeSecret } from './sensitivity' +import { classify, declaredConcealed } from './sensitivity' -const bytes = (text: string): Uint8Array => new TextEncoder().encode(text) - -describe('Tier 1 — declared (PLAN.md 4)', () => { +describe('what the application declared (PLAN.md 4)', () => { it('trusts the Windows exclusion format', () => { const result = declaredConcealed({ formats: ['CF_UNICODETEXT', 'ExcludeClipboardContentFromMonitorProcessing'], canIncludeInClipboardHistory: null }) - expect(result?.tier).toBe(1) + expect(result?.rule).toMatch(/concealed/) }) it('trusts CanIncludeInClipboardHistory when it says no', () => { expect( - declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 })?.tier - ).toBe(1) + declaredConcealed({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 })?.rule + ).toMatch(/clipboard history/) }) it('does not fire when that format says yes', () => { @@ -31,8 +28,8 @@ describe('Tier 1 — declared (PLAN.md 4)', () => { declaredConcealed({ formats: ['public.utf8-plain-text', 'org.nspasteboard.ConcealedType'], canIncludeInClipboardHistory: null - })?.tier - ).toBe(1) + })?.rule + ).toMatch(/concealed/) }) it('says nothing about an ordinary copy', () => { @@ -40,92 +37,30 @@ describe('Tier 1 — declared (PLAN.md 4)', () => { .toBeNull() }) - it('beats a Tier 2 guess, because one is a statement and the other is a shape', () => { - const result = classify( - { formats: ['ExcludeClipboardContentFromMonitorProcessing'], canIncludeInClipboardHistory: 0 }, - bytes('just some ordinary text') - ) - - expect(result?.tier).toBe(1) - }) -}) - -describe('Tier 2 — heuristics (PLAN.md 4)', () => { - it.each([ - ['a PEM block', '-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END'], - ['a JWT', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSJ9.dBjftJeZ4CVPmB92K'], - ['an OpenAI key', 'sk-proj-abc123def456ghi789jkl012mno345pqr'], - ['an AWS access key', 'AKIAIOSFODNN7EXAMPLE'], - ['a GitHub token', 'ghp_16C7e42F292c6912E7710c838347Ae178B4a'], - ['a GitHub fine-grained token', 'github_pat_11ABCDEFG0abcdefghijkl_mnopqrstuvwxyz'], - ['a Slack token', 'xoxb-123456789012-1234567890123-abcdefgh'], - ['a Google API key', 'AIzaSyD-abc123DEF456ghi789JKL012mno345PQ'], - ['a SQL Server connection string', 'Server=tcp:db.example.com;Database=app;Password=hunter2;'], - ['a lowercase pwd= connection string', 'host=db;user=app;pwd=s3cret;'], - ['a random-looking secret', 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'] - ])('flags %s', (_name, content) => { - expect(looksLikeSecret(bytes(content))?.tier).toBe(2) - }) - - it('names which rule matched, so the prompt can say why', () => { - expect(looksLikeSecret(bytes('AKIAIOSFODNN7EXAMPLE'))?.rule).toMatch(/AWS/) - expect(looksLikeSecret(bytes('-----BEGIN CERTIFICATE-----'))?.rule).toMatch(/PEM/) - }) -}) - -describe('Tier 2 negatives — what must not trip (PLAN.md 11, M5)', () => { - it.each([ - ['ordinary prose', 'The quick brown fox jumps over the lazy dog, and then does it again.'], - ['a single sentence', 'Remember to call the plumber about the leak on Tuesday morning.'], - ['a URL', 'https://github.com/willkotheimer/Spool/blob/main/PLAN.md#milestones'], - ['a long URL with a query', 'https://example.com/search?q=clipboard+manager&page=2&sort=recent'], - ['a bare domain', 'www.example.com/some/deep/path/to/a/document'], - ['a code snippet', 'const spool = createSpool({ id: "default", mode: "fifo" })'], - ['an import line', "import { captureSnapshot } from './clipboard/capture'"], - ['a camelCase identifier', 'getUserAccountSettingsFromDatabase'], - ['a file path', 'C:/Users/wkoth/source/repos/Spool/src/main/detect'], - ['a short word', 'password'], - ['a phone number', '+1 (555) 010-9999'], - ['an email address', 'someone@example.com'], - ['a hex colour', '#3b82f6'], - ['a date', '2026-08-22T15:00:00.000Z'] - ])('leaves %s alone', (_name, content) => { - expect(looksLikeSecret(bytes(content))).toBeNull() - }) - - it('leaves an empty or blank clipboard alone', () => { - expect(looksLikeSecret(bytes(''))).toBeNull() - expect(looksLikeSecret(bytes(' \n '))).toBeNull() - }) -}) - -describe('wiping (PLAN.md 4)', () => { - it('zeroes the bytes in place, so the buffer that held a secret no longer does', () => { - const secret = bytes('AKIAIOSFODNN7EXAMPLE') - expect(looksLikeSecret(secret)).not.toBeNull() - - wipe(secret) - - expect(secret.every((byte) => byte === 0)).toBe(true) - }) + it('is the only thing that raises a prompt', () => { + const result = classify({ + formats: ['ExcludeClipboardContentFromMonitorProcessing'], + canIncludeInClipboardHistory: 0 + }) - it('does not mind being handed nothing', () => { - expect(() => wipe(null)).not.toThrow() + expect(result?.rule).toMatch(/concealed/) }) }) -describe('the path exclusion stays narrow', () => { - it('still flags a secret that merely contains slashes', () => { - // The AWS secret key shape: slashes throughout, but not a path. - expect(looksLikeSecret(bytes('wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY'))?.tier).toBe(2) +// The heuristics that used to live here are gone. They guessed from content — PEM blocks, JWTs, +// key prefixes, connection-string keywords, high-entropy strings — and prompted on a match. They +// interrupted an ordinary workflow to report something the user already knew, and cost 147ms per +// MiB because every needle walked the whole buffer. Nothing Spool holds leaves the machine, so the +// guessing bought nothing it was worth paying for. +describe('nothing is guessed from content (PLAN.md 4)', () => { + it('keeps a credential without asking, because copying one is an ordinary thing to do', () => { + // Content is not even passed in any more, which is the strongest form this claim can take. + expect(classify({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: null })).toBeNull() + expect(classify({ formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 1 })).toBeNull() }) - it.each([ - ['a Windows path', 'C:/Users/wkoth/source/repos/Spool/src/main/detect'], - ['a backslash path', 'C:\\Users\\wkoth\\AppData\\Local\\Programs\\Spool'], - ['a POSIX path', '/usr/local/share/SpoolThings/Config'], - ['a UNC path', '\\\\fileserver\\Shared\\Reports\\Q3Summary'] - ])('leaves %s alone', (_name, content) => { - expect(looksLikeSecret(bytes(content))).toBeNull() + it('still asks when the application itself declared the copy concealed', () => { + const declared = { formats: ['CF_UNICODETEXT'], canIncludeInClipboardHistory: 0 } + expect(classify(declared)?.rule).toMatch(/clipboard history/) }) }) diff --git a/src/main/detect/sensitivity.ts b/src/main/detect/sensitivity.ts index a8b386c..0f8b937 100644 --- a/src/main/detect/sensitivity.ts +++ b/src/main/detect/sensitivity.ts @@ -1,27 +1,21 @@ -import { - ascii, - characterClasses, - hasWhitespace, - includes, - indexOf, - isDigit, - shannonEntropy, - startsWith, - trim -} from './bytes' - /** - * The two tiers of PLAN.md 4, over bytes rather than strings. + * What the source application declared about a copy (PLAN.md 4), read from the clipboard formats + * rather than from the content. + * + * **Guessing from content was removed.** Spool used to also scan for PEM blocks, JWTs, key + * prefixes, connection-string keywords and high-entropy strings, and prompt when one matched. It + * interrupted an ordinary workflow — copying a credential is a normal thing to do, and nothing here + * leaves the machine — to say something the user already knew. It was also the entire cost of + * capture: 147ms per MiB, because each needle walked the whole buffer separately. * - * Different confidence, different wording: Tier 1 is what the source application *declared*, and is - * authoritative. Tier 2 is a guess from shape, and says so. False positives are acceptable here; - * silent capture of a secret is not. + * What is kept is not a guess. `CanIncludeInClipboardHistory = 0` is an explicit statement from the + * application that owns the secret, saying *do not persist this*; Windows' own Clipboard History + * obeys it. Spool makes a transient thing durable, so ignoring that request would persist exactly + * what a password manager asked it not to, and behave worse than the OS feature beside it. It costs + * a flag check. */ -export type Tier = 1 | 2 - export interface Sensitivity { - readonly tier: Tier /** Which rule matched, for the privacy panel and for the prompt's second line. */ readonly rule: string } @@ -44,139 +38,22 @@ const DECLARED_FORMATS = new Set([ 'org.nspasteboard.ConcealedType' ]) -/** Tier 1 — the source application marked this as secret. Password managers do this. */ +/** The source application marked this as secret. Password managers do this. */ export function declaredConcealed(signals: ConcealmentSignals): Sensitivity | null { if (signals.formats.some((format) => DECLARED_FORMATS.has(format))) { - return { tier: 1, rule: 'the application marked it as concealed' } + return { rule: 'the application marked it as concealed' } } if (signals.canIncludeInClipboardHistory === 0) { - return { tier: 1, rule: 'the application asked to be kept out of clipboard history' } + return { rule: 'the application asked to be kept out of clipboard history' } } return null } -const PEM = ascii('-----BEGIN') -const JWT = ascii('eyJ') -const DOT = ascii('.') - -/** Key prefixes worth recognising by name (PLAN.md 4). */ -const KEY_PREFIXES: ReadonlyArray<[label: string, prefix: Uint8Array]> = [ - ['an OpenAI-style key (sk-)', ascii('sk-')], - ['an AWS access key (AKIA)', ascii('AKIA')], - ['a GitHub token (ghp_)', ascii('ghp_')], - ['a GitHub token (github_pat_)', ascii('github_pat_')], - ['a Slack token (xoxb-)', ascii('xoxb-')], - ['a Google API key (AIza)', ascii('AIza')] -] - -const CONNECTION_KEYWORDS: ReadonlyArray<[label: string, needle: Uint8Array]> = [ - ['a connection string (Password=)', ascii('Password=')], - ['a connection string (pwd=)', ascii('pwd=')], - ['a connection string (Server=)', ascii('Server=')] -] - -/** Entropy high enough to look generated rather than written. */ -const ENTROPY_THRESHOLD = 4.0 -const ENTROPY_MIN_LENGTH = 16 -const ENTROPY_MAX_LENGTH = 200 -const ENTROPY_MIN_CLASSES = 3 - -/** A URL is not a secret, and its punctuation would otherwise score like one. */ -const URL_MARKERS = [ascii('://'), ascii('www.')] - /** - * Nor is an absolute file path, which a developer copies many times a day — and a prompt that fires - * on every one of those teaches the user to dismiss prompts, which costs more than it saves. + * The whole classification. One question now: did the application say so? * - * Deliberately narrow: it recognises only what a path *starts* with. Checking for slashes anywhere - * would be a hole, because an AWS secret key is full of them. + * It no longer takes the content, which is the point. Nothing here reads what you copied. */ -function looksLikeAbsolutePath(content: Uint8Array): boolean { - const [first, second, third] = content - const isSlash = (byte: number | undefined): boolean => byte === 0x2f || byte === 0x5c - const isLetter = - first !== undefined && - ((first >= 0x41 && first <= 0x5a) || (first >= 0x61 && first <= 0x7a)) - - // C:/… or C:\… - if (isLetter && second === 0x3a && isSlash(third)) return true - // /usr/… or \\server\… - return isSlash(first) +export function classify(signals: ConcealmentSignals): Sensitivity | null { + return declaredConcealed(signals) } - -/** Tier 2 — pattern or entropy match. Lower confidence, softer wording. */ -export function looksLikeSecret(bytes: Uint8Array): Sensitivity | null { - const content = trim(bytes) - if (content.length === 0) return null - - if (startsWith(content, PEM)) return { tier: 2, rule: 'a PEM block' } - if (isJwt(content)) return { tier: 2, rule: 'a JWT' } - - for (const [label, prefix] of KEY_PREFIXES) { - if (includes(content, prefix)) return { tier: 2, rule: label } - } - - for (const [label, needle] of CONNECTION_KEYWORDS) { - if (includes(content, needle, true)) return { tier: 2, rule: label } - } - - if (isHighEntropy(content)) return { tier: 2, rule: 'a long random-looking string' } - - return null -} - -/** `eyJ` followed by two dot-separated base64url segments. */ -function isJwt(content: Uint8Array): boolean { - if (!startsWith(content, JWT)) return false - - const firstDot = indexOf(content, DOT) - if (firstDot <= 0) return false - const secondDot = indexOf(content, DOT, false, firstDot + 1) - if (secondDot <= firstDot + 1) return false - - // Three segments, all base64url. The third may be empty for an unsigned token. - return ( - isBase64Url(content.subarray(0, firstDot)) && - isBase64Url(content.subarray(firstDot + 1, secondDot)) && - isBase64Url(content.subarray(secondDot + 1)) - ) -} - -function isBase64Url(segment: Uint8Array): boolean { - for (const byte of segment) { - const alphanumeric = - isDigit(byte) || (byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a) - if (!alphanumeric && byte !== 0x2d && byte !== 0x5f && byte !== 0x3d) return false - } - return true -} - -function isHighEntropy(content: Uint8Array): boolean { - if (content.length < ENTROPY_MIN_LENGTH || content.length > ENTROPY_MAX_LENGTH) return false - if (hasWhitespace(content)) return false - if (URL_MARKERS.some((marker) => includes(content, marker, true))) return false - if (looksLikeAbsolutePath(content)) return false - if (characterClasses(content) < ENTROPY_MIN_CLASSES) return false - - return shannonEntropy(content) >= ENTROPY_THRESHOLD -} - -/** - * The whole classification, in the order of PLAN.md 4: what the application declared beats what the - * content looks like, because one is a statement and the other is a guess. - */ -export function classify(signals: ConcealmentSignals, bytes: Uint8Array): Sensitivity | null { - return declaredConcealed(signals) ?? looksLikeSecret(bytes) -} - -/** Every Tier 2 rule, for the privacy panel — the user is owed the list of what trips a prompt. */ -export const HEURISTIC_RULES: ReadonlyArray<{ label: string; detail: string }> = [ - { label: 'PEM blocks', detail: 'text beginning -----BEGIN' }, - { label: 'JWTs', detail: 'eyJ followed by two dot-separated base64url segments' }, - { label: 'Known key prefixes', detail: 'sk-, AKIA, ghp_, github_pat_, xoxb-, AIza' }, - { label: 'Connection strings', detail: 'Password=, pwd=, Server=' }, - { - label: 'High-entropy strings', - detail: `${ENTROPY_MIN_LENGTH}–${ENTROPY_MAX_LENGTH} characters, no spaces, at least ${ENTROPY_MIN_CLASSES} character classes, and random-looking` - } -] diff --git a/src/main/session.test.ts b/src/main/session.test.ts index 436a968..1ef67e0 100644 --- a/src/main/session.test.ts +++ b/src/main/session.test.ts @@ -402,24 +402,23 @@ describe('consent (PLAN.md 4)', () => { sourceApp: 'Code.exe' }) - it('raises a Tier 1 prompt naming the application, and files nothing yet', () => { + it('raises a prompt naming the application, and files nothing yet', () => { const { session, watcher } = started() watcher.change(secret('hunter2')) const { prompt, spool } = session.getState() - expect(prompt?.tier).toBe(1) expect(prompt?.headline).toBe('1Password marked this as concealed. Keep it in this spool?') expect(spool.count).toBe(0) }) - it('raises a softer Tier 2 prompt for something that merely looks like a secret', () => { + // The heuristics are gone: copying a credential is an ordinary thing to do, and nothing Spool + // holds leaves the machine, so guessing at content bought nothing worth its interruption. + it('does not ask about something that merely looks like a secret', () => { const { session, watcher } = started() watcher.change(heuristic('AKIAIOSFODNN7EXAMPLE')) - const { prompt } = session.getState() - expect(prompt?.tier).toBe(2) - expect(prompt?.headline).toBe('This looks like a secret. Keep it in this spool?') - expect(prompt?.detail).toMatch(/AWS/) + expect(session.getState().prompt).toBeNull() + expect(session.getState().spool.count).toBe(1) }) it('never shows the content of the clip it is asking about', () => { @@ -499,10 +498,10 @@ describe('consent (PLAN.md 4)', () => { watcher.change(secret('from the manager')) session.answerConsent('always_skip') - watcher.change(heuristic('AKIAIOSFODNN7EXAMPLE')) + // A different application that also declares its copy concealed is still asked about. + watcher.change(secret('from somewhere else', 'Bitwarden.exe')) - // A different application still gets asked about. - expect(session.getState().prompt?.tier).toBe(2) + expect(session.getState().prompt?.headline).toMatch(/Bitwarden/) }) it('an ordinary copy is never asked about', () => { diff --git a/src/main/session.ts b/src/main/session.ts index 1496e54..1f0080d 100644 --- a/src/main/session.ts +++ b/src/main/session.ts @@ -47,7 +47,6 @@ import { ruleFromChoice } from './detect/consent' import { emptyLedger, NOTHING_TO_PASTE } from './detect/notices' -import { HEURISTIC_RULES } from './detect/sensitivity' import { toSpoolView } from './ipc/view' import type { Store } from './store' @@ -901,7 +900,6 @@ export class Session { autoPaste: this.autoPaste, prompt: this.promptView(), privacy: { - heuristics: HEURISTIC_RULES, consentTimeoutSeconds: Math.round(this.settings.consentTimeoutMs / 1000), sourceRules: [...this.state.sourceRules].map(([sourceApp, action]) => ({ sourceApp, @@ -953,7 +951,6 @@ export class Session { const { headline, detail } = promptWording(this.pending.sensitivity, this.pending.sourceApp) return { - tier: this.pending.sensitivity.tier, headline, detail, sourceApp: this.pending.sourceApp, diff --git a/src/renderer/components/ConsentPrompt.tsx b/src/renderer/components/ConsentPrompt.tsx index bcbf371..ca6a81c 100644 --- a/src/renderer/components/ConsentPrompt.tsx +++ b/src/renderer/components/ConsentPrompt.tsx @@ -16,16 +16,12 @@ export function ConsentPrompt({ prompt: PendingPrompt onAnswer: (choice: ConsentChoice) => void }): JSX.Element { + // One voice now: the only thing that raises this is the application saying so, so there is no + // softer styling for a guess. const application = sourceName(prompt.sourceApp) return ( -
+

{prompt.headline}

{prompt.detail}

diff --git a/src/renderer/components/FirstRun.tsx b/src/renderer/components/FirstRun.tsx index 26c65f4..5860284 100644 --- a/src/renderer/components/FirstRun.tsx +++ b/src/renderer/components/FirstRun.tsx @@ -39,9 +39,8 @@ export function FirstRun({

- Before anything that looks like a secret is stored, Spool asks. It looks for{' '} - {privacy.heuristics.map((rule) => rule.label.toLowerCase()).join(', ')}, and it treats an - application marking a copy as concealed — as password managers do — as authoritative. A + Spool does not inspect what you copy or guess whether it is a secret. When an application + marks a copy as concealed — as password managers do — Spool asks before keeping it, and a prompt left unanswered for {privacy.consentTimeoutSeconds} seconds is treated as Skip.

diff --git a/src/renderer/components/PrivacyPanel.tsx b/src/renderer/components/PrivacyPanel.tsx index 6690a6a..7259d14 100644 --- a/src/renderer/components/PrivacyPanel.tsx +++ b/src/renderer/components/PrivacyPanel.tsx @@ -49,21 +49,16 @@ export function PrivacyPanel({

-
+

- Spool asks before keeping anything that matches one of these. It never decides for you, - and it never drops a clip on its own. + Spool does not read your clips looking for secrets, and does not guess. It once scanned + for key prefixes, connection strings and random-looking text; that was removed. Copying a + credential is an ordinary thing to do, and nothing here leaves this machine.

-
    - {privacy.heuristics.map(({ label, detail }) => ( -
  • - {label} — {detail} -
  • - ))} -

- An application can also mark a copy as concealed — password managers do — and that - marking is treated as authoritative. + What remains is not a guess. An application can mark a copy as concealed — password + managers do, and Windows' own clipboard history obeys it — and that marking is treated as + authoritative.

diff --git a/src/renderer/state/useAppState.ts b/src/renderer/state/useAppState.ts index 21d5040..50b471b 100644 --- a/src/renderer/state/useAppState.ts +++ b/src/renderer/state/useAppState.ts @@ -15,7 +15,6 @@ const initialState: AppState = { capture: { available: false, reason: null }, prompt: null, privacy: { - heuristics: [], consentTimeoutSeconds: 30, dataFilePath: null, sourceRules: [], diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 6689c8e..341a9ae 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -76,8 +76,6 @@ export type ConsentChoice = 'keep_once' | 'skip' | 'always_keep' | 'always_skip' /** A clip held in memory, unwritten, while the user decides (PLAN.md 4). */ export interface PendingPrompt { - /** 1 is what the application declared and is authoritative; 2 is a guess from shape. */ - readonly tier: 1 | 2 readonly headline: string readonly detail: string /** Named so the standing-answer choices can say which application they apply to. */ @@ -88,7 +86,6 @@ export interface PendingPrompt { /** What the privacy panel says Spool looks for, taken from the detectors themselves. */ export interface PrivacyFacts { - readonly heuristics: ReadonlyArray<{ readonly label: string; readonly detail: string }> readonly consentTimeoutSeconds: number /** Where the encrypted store lives, or null while there is not one yet (M6). */ readonly dataFilePath: string | null