From 28110282535a52592bd0e139f15d866a31a56df1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 11:10:57 -0400 Subject: [PATCH 1/7] feat(dictation): warn when macOS Secure Input blocks paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secure Event Input (password fields, Secure Keyboard Entry) silently drops the synthetic Cmd+V, so dictation reported "Pasted" while nothing arrived. Probe the CoreGraphics session for kCGSSessionSecureInputPID via JXA before pasting. When active, leave the transcript on the clipboard, skip the keystroke, and show a warning in the pill ("Secure Input blocked paste — press Cmd+V") that stays up longer than a normal result. Probe failures fall through to the existing paste path. Co-Authored-By: Claude Opus 5.5 --- .papercuts/troubleshooting.md | 4 ++ docs/plans/README.md | 1 + docs/plans/dictation-secure-input-plan.md | 52 ++++++++++++++++ main/services/dictation-coordinator.test.ts | 34 ++++++++++ main/services/dictation-coordinator.ts | 9 ++- main/services/dictation-paste.test.ts | 68 ++++++++++++++++++++ main/services/dictation-paste.ts | 69 ++++++++++++++++++++- main/services/dictation.ts | 10 ++- package.json | 2 +- renderer/pill/pill-app.tsx | 17 ++--- renderer/pill/pill-copied-notice.test.tsx | 47 ++++++++++++++ renderer/pill/pill-copied-notice.tsx | 44 +++++++++++++ renderer/shared/dictation.ts | 9 ++- 13 files changed, 346 insertions(+), 20 deletions(-) create mode 100644 docs/plans/dictation-secure-input-plan.md create mode 100644 renderer/pill/pill-copied-notice.test.tsx create mode 100644 renderer/pill/pill-copied-notice.tsx diff --git a/.papercuts/troubleshooting.md b/.papercuts/troubleshooting.md index ceeb89458..37911688b 100644 --- a/.papercuts/troubleshooting.md +++ b/.papercuts/troubleshooting.md @@ -1404,3 +1404,7 @@ because their native file-mutator test binary had not been built. Run ## 2026-09-26 PR #121 merge of #251 (Remote contract revision 14) - A PR that adds to the Remote contract has to renumber when main bumps `contractRevision`. The conflicts show up in 7 files: both fixtures, the TS/iOS/Android fixture assertions and the iOS fixture CodingKeys. After resolving, `cmp` the Android copy against the shared fixture. Plan docs that name the revision also go stale. + +## 2026-09-27 Dictation Secure Input detection +- Carbon `IsSecureEventInput` is not usable from Aiden. JXA `ObjC.bindFunction` reports "function not found", and on the macOS 27 SDK both linking and `dlsym` fail, although `EnableSecureEventInput` still resolves. Read `kCGSSessionSecureInputPID` from `CGSessionCopyCurrentDictionary()` through JXA instead. +- The worktree-isolation guard rejects compound shell commands and heredocs that touch scratch paths. Put multi-step probes into a script file under the scratchpad and run that file. diff --git a/docs/plans/README.md b/docs/plans/README.md index 054d3b166..f409d0f8a 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -16,6 +16,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Aiden CLI](aiden-cli-plan.md) | Active | Phases 0–5 implementation complete; macOS/Linux CLI (57 tests each), Linux native helpers, complete shared subagent suites, root TypeScript/lint, and Android client checks pass. Physical iPhone acceptance is pending an unlocked device. Phase 6 adds QR remote pairing, scheduled-run notifications (mobile push + desktop), shared desktop memory, daemon autostart, and prebuilt binaries; see the [checklist](aiden-cli-parity-checklist.md). | | [Rich link previews for Chats and Bots](rich-link-previews-plan.md) | Implemented for review | Shared regular Chat and Bot transcripts now show provider-aware inline icons and bounded hover/focus cards. User HTTP(S) text is autolinked, assistant streaming and persisted Markdown share the opt-in renderer, and URL-derived previews perform no network requests. | | [Simulator Devices](simulator-devices-plan.md) | Partial | Environment **Simulator** tab plus `device_*` agent tools, ported from T3 Code (MIT, `1c127066`). iOS only; consent-gated pinned `expo-device-hub@0.12.0` + `agent-device@0.21.12`; token-authenticated loopback proxy. Phases 0–3 done (spike, flagged tab shell, main-process toolchain/host/proxy/service/IPC, live stream viewer with controls and screenshot-to-chat; `test:devices` 96 pass; fake-hub Electron E2E; real-Mac acceptance passed). Phase 4 (agent `device_*` tools), Phase 5 (simulators on paired Macs over Aiden Remote; [plan](simulator-devices-phase-5-peers.md)) and Phase 6 (procedural 3D device frames; [plan](simulator-devices-phase-6-3d.md)) done. Phase 7 done: Settings → **Simulator** (consent switches, pinned/installed helper versions, prune, remove installed tools) and [`docs/devices.md`](../devices.md); onboarding skipped while the flag is off. Remaining: real-Mac acceptance for Phases 4–6 before the flag defaults on; SSH hosts are a later follow-up. | +| [Dictation Secure Input warning](dictation-secure-input-plan.md) | Implemented for review | Before pasting, dictation probes macOS Secure Event Input through the CoreGraphics session; when active it keeps the transcript on the clipboard and the pill explains why with a ⌘V hint. PR CI pending. | | [Scoped MCP resources](mcp-scoped-resources-plan.md) | Implemented | Per-server resource inventory/templates/read; MCP93/Bots448/scheduled151/onboarding56 and both independent reviews pass. PR CI pending. | | [Chronological Chat Motion](chronological-chat-motion-plan.md) | In review | Readable Thinking stretches, tool rows, and prose project in sequence across desktop and regular native chats. [PR #224](https://github.com/sambitcreate/aiden-agent/pull/224) and visual acceptance remain. | | [Composer Context Meter](composer-context-meter-plan.md) | In review | Composer gauge and popover driven by `projectNextContextUsage` (the runtime compaction projection) with live `chat:context-pressure` pushes; PR #187 kept only this part after PR #224 shipped ordered thinking traces. Visual acceptance pending. | diff --git a/docs/plans/dictation-secure-input-plan.md b/docs/plans/dictation-secure-input-plan.md new file mode 100644 index 000000000..4082a8c51 --- /dev/null +++ b/docs/plans/dictation-secure-input-plan.md @@ -0,0 +1,52 @@ +# Dictation Secure Input Warning + +Status: Implemented for review (`feature/dictation-secure-input`). + +Source: Handy parity tracker, P0 — "dictation paste silently fails while macOS +Secure Event Input is active". + +## Problem + +macOS Secure Event Input (password fields, a terminal's Secure Keyboard Entry, +some password managers) drops synthetic keystrokes from other processes. The +atomic dictation paste (`main/services/dictation-paste.ts`) still reported +`pasted`, so the pill said "Pasted" while nothing arrived. + +## Detection + +- Carbon `IsSecureEventInput` is neither bridged to JXA nor exported by the + current macOS SDK (the symbol is absent at link and `dlsym` time on macOS 27). +- WindowServer publishes `kCGSSessionSecureInputPID` in the current CoreGraphics + session dictionary only while some process holds Secure Input. Aiden reads it + with a one-line JXA probe (`CGSessionCopyCurrentDictionary`), about 50 ms, with + no native helper or new entitlement. The same key appears in + `ioreg -n Root -d1` `IOConsoleUsers`, but that route needs multi-session + matching and old-style plist parsing. +- The owning PID is not used to name an app: in testing it pointed at the + frontmost app rather than the process that enabled Secure Input. + +## Behavior + +1. Accessibility missing → existing "allow Accessibility" copy (probe skipped). +2. Secure Input active → transcript written to the clipboard, no keystroke, and a + `copied` result with reason `secure-input`. +3. Probe failure or unexpected output → logged; paste proceeds as before (the + native transaction still falls back to the clipboard on its own errors). + +The pill shows a warning-tone shield icon, "Secure Input blocked paste", and +"Transcript copied — press ⌘V to paste." A screen-reader-only sentence explains +the cause. The result stays visible for 4 s instead of 1.2 s. + +## Tests + +- `main/services/dictation-paste.test.ts`: injectable detector (active, failing, + precedence), probe output mapping, live darwin probe. +- `main/services/dictation-coordinator.test.ts`: reason reaches the pill and the + hide delay outlasts a normal paste. +- `renderer/pill/pill-copied-notice.test.tsx`: rendered warning and fallbacks. + +## Follow-ups + +- Recheck Secure Input right before the keystroke inside the atomic AppleScript + transaction (it can turn on during the 80 ms focus revalidation window). +- Optionally surface the condition in Settings → Dictation diagnostics. diff --git a/main/services/dictation-coordinator.test.ts b/main/services/dictation-coordinator.test.ts index 87d58c32b..98bcba58e 100644 --- a/main/services/dictation-coordinator.test.ts +++ b/main/services/dictation-coordinator.test.ts @@ -282,6 +282,40 @@ test("cleanup failures still paste the original transcript", async () => { ); }); +test("a Secure Input copy result reaches the pill and lingers longer than a paste", async () => { + async function deliver(result: Awaited>) { + const delays: number[] = []; + const subject = harness({ + paste: async () => result, + setTimer: (_callback, delayMs) => { + delays.push(delayMs); + return dormantTimer(); + }, + }); + await subject.coordinator.ready(); + await subject.coordinator.press(); + await subject.coordinator.press(); + const before = delays.length; + await subject.coordinator.result("hello there", subject.coordinator.currentOperationId!); + assert.ok(delays.length > before, "delivery schedules the pill hide"); + return { events: subject.events, hideDelay: delays[delays.length - 1]! }; + } + + const secure = await deliver({ + outcome: "copied", + reason: "secure-input", + message: "Transcript copied — press ⌘V to paste.", + }); + const pasted = await deliver({ outcome: "pasted" }); + assert.deepEqual(secure.events[secure.events.length - 1], { + state: "copied", + operationId: secure.events[0]!.operationId, + reason: "secure-input", + message: "Transcript copied — press ⌘V to paste.", + }); + assert.ok(secure.hideDelay > pasted.hideDelay); +}); + test("hold release during cold startup is latched and stops after ready", async () => { const shown = deferred(); const subject = harness({ diff --git a/main/services/dictation-coordinator.ts b/main/services/dictation-coordinator.ts index fa3b5ea8e..4adeb784b 100644 --- a/main/services/dictation-coordinator.ts +++ b/main/services/dictation-coordinator.ts @@ -30,6 +30,7 @@ export interface DictationCoordinatorDeps { const RESULT_HIDE_DELAY_MS = 1_200; const ERROR_HIDE_DELAY_MS = 2_000; +export const WARNING_HIDE_DELAY_MS = 4_000; const MAX_TRANSCRIPT_LENGTH = 100_000; export const HOLD_RELEASE_GRACE_MS = 50; // Cloud renderers fail within 45 seconds. Parakeet owns a 120-second process @@ -354,15 +355,19 @@ export class DictationCoordinator { } const pasteResult = await this.deps.paste(transcript); const outcome = typeof pasteResult === "string" ? pasteResult : pasteResult.outcome; + const reason = typeof pasteResult === "string" ? undefined : pasteResult.reason; this.stage = "idle"; this.operationId = null; this.deps.broadcast({ state: outcome, operationId, - reason: typeof pasteResult === "string" ? undefined : pasteResult.reason, + reason, message: typeof pasteResult === "string" ? undefined : pasteResult.message, }); - this.scheduleHide(RESULT_HIDE_DELAY_MS); + // A Secure Input warning explains a manual next step; keep it readable. + this.scheduleHide( + reason === "secure-input" ? WARNING_HIDE_DELAY_MS : RESULT_HIDE_DELAY_MS, + ); } catch (error) { this.stage = "idle"; this.operationId = null; diff --git a/main/services/dictation-paste.test.ts b/main/services/dictation-paste.test.ts index 34b586958..e19645c36 100644 --- a/main/services/dictation-paste.test.ts +++ b/main/services/dictation-paste.test.ts @@ -7,7 +7,10 @@ import test from "node:test"; import { promisify } from "node:util"; import { ATOMIC_PASTE_SCRIPT, + detectMacSecureInput, pasteTranscript, + runJxa, + SECURE_INPUT_PROBE_SCRIPT, type PasteDeps, } from "./dictation-paste.js"; @@ -21,6 +24,7 @@ function harness(overrides: Partial = {}) { clipboard = text; }, isAccessibilityTrusted: () => true, + isSecureInputActive: async () => false, pasteWithPreservedClipboard: async (text) => { pastedText = text; return true; @@ -112,3 +116,67 @@ test("paste failures leave the transcript on the clipboard instead of throwing", }); assert.equal(subject.clipboard(), "hello world"); }); + +test("active Secure Input keeps the transcript on the clipboard without sending a keystroke", async () => { + let attempts = 0; + const subject = harness({ + isSecureInputActive: async () => true, + pasteWithPreservedClipboard: async () => { + attempts += 1; + return true; + }, + }); + const result = await pasteTranscript("my secret note", subject.deps); + assert.equal(result.outcome, "copied"); + assert.equal(result.reason, "secure-input"); + assert.match(result.message ?? "", /⌘V/); + assert.equal(subject.clipboard(), "my secret note"); + assert.equal(attempts, 0); +}); + +test("missing Accessibility access takes precedence over Secure Input detection", async () => { + let probes = 0; + const subject = harness({ + isAccessibilityTrusted: () => false, + isSecureInputActive: async () => { + probes += 1; + return true; + }, + }); + const result = await pasteTranscript("hello world", subject.deps); + assert.equal(result.reason, "accessibility-required"); + assert.equal(probes, 0); +}); + +test("a failed Secure Input probe does not block the paste attempt", async () => { + const logged: string[] = []; + const subject = harness({ + isSecureInputActive: async () => { + throw new Error("osascript timed out"); + }, + log: (message) => logged.push(message), + }); + assert.deepEqual(await pasteTranscript("hello world", subject.deps), { outcome: "pasted" }); + assert.equal(subject.pastedText(), "hello world"); + assert.equal(logged.length, 1); +}); + +test("Secure Input detection maps probe output and rejects unrecognized output", async () => { + const probe = (output: string) => async () => `${output}\n`; + assert.equal(await detectMacSecureInput(probe("secure")), true); + assert.equal(await detectMacSecureInput(probe("clear")), false); + await assert.rejects(detectMacSecureInput(probe("execution error: -2700"))); + await assert.rejects( + detectMacSecureInput(async () => { + throw new Error("spawn failed"); + }), + ); +}); + +test( + "Secure Input probe runs against the live CoreGraphics session", + { skip: process.platform !== "darwin" }, + async () => { + assert.match(await runJxa(SECURE_INPUT_PROBE_SCRIPT), /^(secure|clear)$/); + }, +); diff --git a/main/services/dictation-paste.ts b/main/services/dictation-paste.ts index 244d49307..0354dd645 100644 --- a/main/services/dictation-paste.ts +++ b/main/services/dictation-paste.ts @@ -3,21 +3,31 @@ // and restores only when the transcript is still on the clipboard. import { execFile } from "node:child_process"; +import type { DictationCopiedReason } from "../../renderer/shared/dictation.js"; export type PasteOutcome = "pasted" | "copied"; export interface PasteDeliveryResult { outcome: PasteOutcome; - reason?: "accessibility-required" | "paste-unavailable"; + reason?: DictationCopiedReason; message?: string; } export interface PasteDeps { writeClipboard: (text: string) => void; isAccessibilityTrusted: () => boolean; + /** + * macOS Secure Event Input (password fields, a terminal's Secure Keyboard + * Entry, some password managers) silently drops synthetic keystrokes, so a + * paste would look successful while nothing arrives. Resolves true while + * any process in the console session holds it. + */ + isSecureInputActive: () => Promise; pasteWithPreservedClipboard: (text: string) => Promise; log?: (message: string, error?: unknown) => void; } +export const SECURE_INPUT_COPIED_MESSAGE = "Transcript copied — press ⌘V to paste."; + export const ATOMIC_PASTE_SCRIPT = `on run argv set transcriptText to item 1 of argv set previousClipboard to the clipboard as record @@ -77,6 +87,42 @@ export const ATOMIC_PASTE_SCRIPT = `on run argv return "pasted" end run`; +/** + * Reads the current CoreGraphics session: WindowServer publishes + * kCGSSessionSecureInputPID only while some process holds Secure Event Input. + * Carbon's IsSecureEventInput is neither bridged to JXA nor exported by the + * current macOS SDK, so the session dictionary is the probe that works in the + * shipped app without a native helper. + */ +export const SECURE_INPUT_PROBE_SCRIPT = `ObjC.import("CoreGraphics"); +var session = ObjC.deepUnwrap(ObjC.castRefToObject($.CGSessionCopyCurrentDictionary())); +session && session.kCGSSessionSecureInputPID ? "secure" : "clear";`; + +export type RunJxa = (script: string) => Promise; + +/** Run a JavaScript for Automation snippet with a short timeout. */ +export function runJxa(script: string): Promise { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/osascript", + ["-l", "JavaScript", "-e", script], + { timeout: 2_000 }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout.trim()); + }, + ); + }); +} + +/** Detect macOS Secure Event Input. Rejects when the probe output is unrecognized. */ +export async function detectMacSecureInput(run: RunJxa = runJxa): Promise { + const output = (await run(SECURE_INPUT_PROBE_SCRIPT)).trim(); + if (output === "secure") return true; + if (output === "clear") return false; + throw new Error(`Unexpected Secure Input probe output: ${output.slice(0, 80)}`); +} + /** Run an AppleScript handler with data passed as argv, never interpolated. */ export function runOsascript(script: string, args: string[] = []): Promise { return new Promise((resolve, reject) => { @@ -97,8 +143,9 @@ export async function runAtomicMacPaste(text: string): Promise { } /** - * Deliver a finished transcript. Without Accessibility access or after any - * failure, the transcript remains available on the clipboard. + * Deliver a finished transcript. Without Accessibility access, while macOS + * Secure Input would swallow the keystroke, or after any failure, the + * transcript remains available on the clipboard. */ export async function pasteTranscript( text: string, @@ -112,6 +159,22 @@ export async function pasteTranscript( message: "Copied — allow Accessibility to paste automatically.", }; } + let secureInput = false; + try { + secureInput = await deps.isSecureInputActive(); + } catch (error) { + // An unknown state must not block delivery; the native transaction still + // falls back to the clipboard on its own failures. + deps.log?.("Secure Input detection failed; attempting paste.", error); + } + if (secureInput) { + deps.writeClipboard(text); + return { + outcome: "copied", + reason: "secure-input", + message: SECURE_INPUT_COPIED_MESSAGE, + }; + } try { return (await deps.pasteWithPreservedClipboard(text)) ? { outcome: "pasted" } diff --git a/main/services/dictation.ts b/main/services/dictation.ts index 2160392c2..fc05b8b3e 100644 --- a/main/services/dictation.ts +++ b/main/services/dictation.ts @@ -12,7 +12,12 @@ import { shouldAcceptDictationPress } from "./dictation-hotkey.js"; import { watchMacKeyUntilUp } from "./dictation-key-state.js"; import { acceleratorPrimaryMacKeyCode } from "./dictation-keycode.js"; import { dictationPlatformBehavior } from "./dictation-platform.js"; -import { pasteTranscript, runAtomicMacPaste, type PasteDeps } from "./dictation-paste.js"; +import { + detectMacSecureInput, + pasteTranscript, + runAtomicMacPaste, + type PasteDeps, +} from "./dictation-paste.js"; import { DictationCoordinator } from "./dictation-coordinator.js"; import { activeLinuxDictationHoldShortcut, initLinuxDictationSessionLost, subscribeLinuxDictationRelease } from "./shortcut.js"; @@ -28,6 +33,9 @@ function livePasteDeps(): PasteDeps { isAccessibilityTrusted: () => behavior.accessibilityPaste && systemPreferences.isTrustedAccessibilityClient(false), + isSecureInputActive: behavior.accessibilityPaste + ? () => detectMacSecureInput() + : async () => false, pasteWithPreservedClipboard: behavior.accessibilityPaste ? runAtomicMacPaste : async () => false, diff --git a/package.json b/package.json index fe9c8a2ff..9e3d7ecfe 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "test:telegram": "tsx --test main/services/telegram/telegram-profile-mutation-fence.test.ts main/services/telegram/telegram-controls.test.ts main/services/telegram/telegram-inbound.test.ts main/services/telegram/telegram-outbound.test.ts main/services/telegram/telegram-queue.test.ts main/services/telegram/telegram-markdown.test.ts main/services/telegram/telegram-bot-api.test.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-session.test.ts main/services/telegram/telegram-service-core.test.ts main/services/telegram/telegram-workspace-core.test.ts main/services/telegram/telegram-activity.test.ts main/services/telegram/telegram-profile-config.test.ts main/services/telegram/telegram-extension-registry.test.ts main/services/telegram/telegram-thread-store.test.ts main/services/telegram/telegram-ownership.test.ts main/services/telegram/telegram-agent-tools.test.ts main/services/telegram/telegram-bot-binding-store.test.ts main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts renderer/lib/telegram-workspace-options.test.ts", "test:bots": "tsx --test main/services/telegram/telegram-bot-chat-lifecycle.test.ts main/services/telegram/telegram-bot-binding-reconciliation.test.ts main/services/telegram/telegram-bot-binding-validation.test.ts main/services/telegram/telegram-bot-binding-authority.test.ts main/services/bot-avatar-generator-core.test.ts main/services/bot-avatar-operation-registry.test.ts main/services/bot-avatar-store.test.ts main/services/bot-store-core.test.ts main/services/bot-chat-store.test.ts main/services/bot-mutation-gate.test.ts main/services/bot-inbox-projection.test.ts main/services/bot-system-prompt.test.ts main/services/bot-generation-preparation.test.ts main/services/bot-inbound-attachment-home.test.ts main/services/bot-file-tool-router.test.ts main/services/bot-tool-authority.test.ts main/services/bot-capability-store-core.test.ts main/services/bot-capability-store.test.ts main/services/bot-capability-state-checkpoint.test.ts main/services/bot-capability-keychain-anchor.test.ts main/services/bot-capability-secret-service-anchor.test.ts main/services/bot-capability-lease.test.ts main/services/bot-runtime-inventory-lease.test.ts main/services/bot-runtime-inventory-publication.test.ts main/services/bot-runtime-authority.test.ts main/services/bot-capability-catalog-core.test.ts main/services/bot-capability-bindings.test.ts main/services/bot-capability-key-store.test.ts main/services/bot-capability-migration-seal.test.ts main/services/bot-capability-incarnation-store.test.ts main/services/bot-capability-inventory-ports.test.ts main/services/bot-capability-production-shape.test.ts main/services/bot-mcp-inventory.test.ts main/services/bot-skill-inventory.test.ts main/services/bot-skill-content-watcher.test.ts main/services/bot-managed-workspace-core.test.ts main/services/bot-lifecycle-journal-core.test.ts main/services/bot-application-service.test.ts main/handlers/bot-params.test.ts main/handlers/bots.contract.test.ts renderer/main/bots-view.test.tsx renderer/lib/model-picker-data.test.ts renderer/lib/command-system-core.test.ts renderer/shared/bot-capabilities.test.ts", "test:bots:coverage": "node scripts/run-registered-tests-with-coverage.mjs test:bots", - "test:voice": "tsx --test main/services/dictation-hold-settings.test.ts main/services/linux-dictation-portal.test.ts main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts", + "test:voice": "tsx --test main/services/dictation-hold-settings.test.ts main/services/linux-dictation-portal.test.ts main/services/transcription-core.test.ts main/services/gemini-live-transcription-core.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/parakeet-protocol.test.ts main/services/parakeet-process-core.test.ts main/services/parakeet-transcription-lane.test.ts renderer/shared/voice-models.test.ts renderer/shared/gemini-usage-scope.test.ts renderer/components/settings/gemini-voice-setup.test.tsx renderer/lib/accessibility-permission-core.test.ts renderer/lib/accessibility-refresh.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/gemini-recorded-retry.test.ts renderer/lib/live-pcm-capture.test.ts renderer/lib/voice-recorder-core.test.ts renderer/lib/wav-audio.test.ts renderer/pill/pill-copied-notice.test.tsx", "test:tts": "tsx --test main/services/tts/authority-contract.test.ts renderer/components/settings/tts-settings.test.tsx main/services/tts/audio-store.test.ts renderer/lib/tts-client.test.ts renderer/lib/tts-player.test.ts main/services/tts/speech-text.test.ts main/services/tts/source.test.ts main/services/tts/gemini-wire.test.ts main/services/tts/service.test.ts main/services/tts/gemini-provider.loopback.test.ts renderer/components/message-bubble.test.tsx renderer/shared/tts.test.ts", "test:diagnostics": "tsx --test main/services/diagnostics-contract.test.ts main/services/diagnostic-health.test.ts main/services/diagnostic-journal.test.ts main/services/diagnostic-support.test.ts main/services/dev-log.test.ts main/services/process-diagnostics.test.ts main/services/renderer-crash-recovery.test.ts main/services/renderer-diagnostic-rate.test.ts main/services/subagents/subagent-runtime-diagnostics.test.ts renderer/components/settings/diagnostics-settings.test.tsx && node --test scripts/diagnostic-policy.test.mjs", "diagnostics:failure-receipt": "node scripts/write-diagnostic-failure-receipt.mjs", diff --git a/renderer/pill/pill-app.tsx b/renderer/pill/pill-app.tsx index 42aa2aea9..bca2750cc 100644 --- a/renderer/pill/pill-app.tsx +++ b/renderer/pill/pill-app.tsx @@ -5,7 +5,8 @@ // it on the clipboard. import * as React from "react"; -import { Check, ClipboardCopy, Loader2, X } from "lucide-react"; +import { Check, Loader2, X } from "lucide-react"; +import { PillCopiedNotice, type PillCopiedNoticeProps } from "./pill-copied-notice"; import { dictationApi, onNotification, settingsApi } from "../lib/ipc"; import type { DictationStatePayload } from "../shared/dictation"; import { @@ -70,7 +71,7 @@ export function PillApp() { const [phase, setPhase] = React.useState("idle"); const [errorMessage, setErrorMessage] = React.useState(""); const [recordingHint, setRecordingHint] = React.useState(""); - const [copiedMessage, setCopiedMessage] = React.useState("Copied to clipboard"); + const [copiedNotice, setCopiedNotice] = React.useState({}); const [elapsed, setElapsed] = React.useState(0); const [liveTranscript, setLiveTranscript] = React.useState({ committed: "", @@ -419,12 +420,7 @@ export function PillApp() { break; case "copied": if (soundsEnabledRef.current) void playDictationCue("success"); - setCopiedMessage( - payload.message ?? - (payload.reason === "accessibility-required" - ? "Copied — allow Accessibility to paste" - : "Copied to clipboard"), - ); + setCopiedNotice({ reason: payload.reason, message: payload.message }); setPhase("copied"); break; case "error": @@ -573,10 +569,7 @@ export function PillApp() { Pasted ) : phase === "copied" ? ( - <> - - {copiedMessage} - + ) : ( {errorMessage} diff --git a/renderer/pill/pill-copied-notice.test.tsx b/renderer/pill/pill-copied-notice.test.tsx new file mode 100644 index 000000000..0d09b28ae --- /dev/null +++ b/renderer/pill/pill-copied-notice.test.tsx @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PillCopiedNotice } from "./pill-copied-notice.js"; + +function visibleText(markup: string): string { + return markup + .replace(/.*?<\/span>/g, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +test("Secure Input copy explains why paste was blocked and what to do next", () => { + const markup = renderToStaticMarkup( + , + ); + const text = visibleText(markup); + assert.match(text, /Secure Input blocked paste/); + assert.match(text, /press ⌘V to paste/); + // Assistive technology hears the cause, not just the symptom. + assert.match(markup, /Secure Input is on[^<]*password field/); +}); + +test("Secure Input copy still explains itself when the main process omits a message", () => { + const text = visibleText(renderToStaticMarkup()); + assert.match(text, /Secure Input blocked paste/); + assert.match(text, /⌘V/); +}); + +test("other copy results keep their plain message without a Secure Input warning", () => { + const accessibility = visibleText( + renderToStaticMarkup(), + ); + assert.equal(accessibility, "Copied — allow Accessibility to paste"); + + const custom = visibleText( + renderToStaticMarkup( + , + ), + ); + assert.equal(custom, "Copied — the original text field was no longer focused."); + assert.equal(visibleText(renderToStaticMarkup()), "Copied to clipboard"); +}); diff --git a/renderer/pill/pill-copied-notice.tsx b/renderer/pill/pill-copied-notice.tsx new file mode 100644 index 000000000..2b34df3d5 --- /dev/null +++ b/renderer/pill/pill-copied-notice.tsx @@ -0,0 +1,44 @@ +// Result shown in the dictation pill when a transcript was left on the +// clipboard instead of being pasted automatically. + +import { ClipboardCopy, ShieldAlert } from "lucide-react"; +import type { DictationCopiedReason } from "../shared/dictation"; + +export interface PillCopiedNoticeProps { + reason?: DictationCopiedReason; + message?: string; +} + +const SECURE_INPUT_EXPLANATION = + "macOS Secure Input is on — usually a password field or a terminal's Secure Keyboard Entry — so automatic paste would be blocked."; + +function copiedMessage({ reason, message }: PillCopiedNoticeProps): string { + if (message) return message; + if (reason === "accessibility-required") return "Copied — allow Accessibility to paste"; + if (reason === "secure-input") return "Transcript copied — press ⌘V to paste."; + return "Copied to clipboard"; +} + +export function PillCopiedNotice(props: PillCopiedNoticeProps) { + const detail = copiedMessage(props); + if (props.reason === "secure-input") { + // Warning, not error: the transcript is safe on the clipboard. Status color + // stays in the icon and label per the design guide. + return ( + <> +