diff --git a/PLAN.md b/PLAN.md index c8e60ad..fd84044 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 @@ -190,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 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. -### Tier 1 — Declared (authoritative) +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. @@ -202,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/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/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/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..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', () => { @@ -780,6 +779,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..1f0080d 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' @@ -46,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' @@ -147,6 +147,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 +186,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 +396,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 +432,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 +618,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 +630,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 +865,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 +875,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, @@ -866,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, @@ -918,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/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/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/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/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/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..50b471b 100644 --- a/src/renderer/state/useAppState.ts +++ b/src/renderer/state/useAppState.ts @@ -8,12 +8,13 @@ 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, privacy: { - heuristics: [], consentTimeoutSeconds: 30, dataFilePath: null, sourceRules: [], diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 1321425..341a9ae 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). */ @@ -70,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. */ @@ -82,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 @@ -228,6 +231,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',