@hasna/recordings 0.2.14 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/Dockerfile.package +4 -5
  2. package/README.md +113 -8
  3. package/bun.lock +2 -2
  4. package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
  5. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
  6. package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
  7. package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
  8. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
  9. package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
  10. package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
  11. package/dist/__tests__/preload.d.ts +1 -4
  12. package/dist/__tests__/preload.d.ts.map +1 -1
  13. package/dist/cli/desktop-snapshot.d.ts +20 -0
  14. package/dist/cli/desktop-snapshot.d.ts.map +1 -0
  15. package/dist/cli/index.js +2094 -280
  16. package/dist/cli/macos-permissions.d.ts +72 -0
  17. package/dist/cli/macos-permissions.d.ts.map +1 -1
  18. package/dist/cli/macos-shortcut.d.ts +139 -0
  19. package/dist/cli/macos-shortcut.d.ts.map +1 -0
  20. package/dist/cli/trigger-probe.d.ts +172 -0
  21. package/dist/cli/trigger-probe.d.ts.map +1 -0
  22. package/dist/db/database.d.ts +9 -0
  23. package/dist/db/database.d.ts.map +1 -1
  24. package/dist/http/client.d.ts +19 -7
  25. package/dist/http/client.d.ts.map +1 -1
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +635 -50
  29. package/dist/lib/capture-probe.d.ts +216 -0
  30. package/dist/lib/capture-probe.d.ts.map +1 -0
  31. package/dist/lib/config.d.ts.map +1 -1
  32. package/dist/lib/macos-bundle.d.ts +50 -0
  33. package/dist/lib/macos-bundle.d.ts.map +1 -0
  34. package/dist/lib/persistence-probe.d.ts +208 -0
  35. package/dist/lib/persistence-probe.d.ts.map +1 -0
  36. package/dist/lib/recorder.d.ts.map +1 -1
  37. package/dist/lib/retired-deployment-modes.d.ts +24 -0
  38. package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
  39. package/dist/lib/transcriber.d.ts +60 -0
  40. package/dist/lib/transcriber.d.ts.map +1 -1
  41. package/dist/mcp/index.js +141 -57
  42. package/dist/server/cloud-config.d.ts +25 -3
  43. package/dist/server/cloud-config.d.ts.map +1 -1
  44. package/dist/server/cloud-readiness.d.ts.map +1 -1
  45. package/dist/server/cloud.d.ts +8 -7
  46. package/dist/server/cloud.d.ts.map +1 -1
  47. package/dist/server/index.js +188 -80
  48. package/dist/server/migrate-command.d.ts.map +1 -1
  49. package/dist/server/serve.d.ts.map +1 -1
  50. package/dist/storage.d.ts +2 -2
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +109 -50
  53. package/dist/store.d.ts +1 -1
  54. package/dist/store.d.ts.map +1 -1
  55. package/dist/version.d.ts +1 -1
  56. package/dist/version.d.ts.map +1 -1
  57. package/package.json +25 -6
  58. package/scripts/ci-linux-suite.ts +403 -0
  59. package/scripts/ci-native-build.ts +402 -0
  60. package/scripts/enforce_identity_migration.sh +110 -0
  61. package/scripts/generate-sdk.ts +1 -1
  62. package/scripts/install_macos_app.sh +192 -31
  63. package/scripts/macos_artifact.ts +323 -43
  64. package/scripts/native/realtime-settle-probe/README.md +39 -0
  65. package/scripts/native/realtime-settle-probe/main.swift +135 -0
  66. package/scripts/policy/local-only-approved-targets.txt +21 -0
  67. package/scripts/read_local_only_targets.sh +203 -0
  68. package/scripts/scan-artifact.ts +40 -0
  69. package/scripts/set-version.ts +145 -0
  70. package/scripts/smoke_macos_app.sh +62 -16
  71. package/scripts/vacuity-manifest-gen.ts +96 -0
  72. package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
  73. package/scripts/vacuity-manifests/install-chain.tsv +13 -0
  74. package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
  75. package/scripts/vacuity-manifests/reorder.tsv +4 -0
  76. package/scripts/vacuity-manifests/source-side.tsv +15 -0
  77. package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
  78. package/scripts/vacuity-manifests/version-sites.tsv +61 -0
  79. package/scripts/vacuity-mutation-battery.ts +266 -0
  80. package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
  81. package/src/native/Recordings/App/RecordingsApp.swift +37 -1
  82. package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
  83. package/src/native/Recordings/App/SidebarView.swift +1 -1
  84. package/src/native/Recordings/Package.swift +24 -5
  85. package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
  86. package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
  87. package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
  88. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
  89. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
  90. package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
  91. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
  92. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
  93. package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
  94. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
  95. package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
  96. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
  97. package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
  98. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
  99. package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
  100. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
  101. package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
  102. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
  103. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
  104. package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
  105. package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
  106. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
  107. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
  108. package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
  109. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
  110. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
  111. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
  112. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
  113. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
  114. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
  115. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
  116. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
  117. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
  118. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
  119. package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
  120. package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
  121. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
  122. package/src/native/Recordings/build.sh +226 -20
  123. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Mutation battery for the indexOf/-1 vacuity sweep (OPE41-00029).
3
+ *
4
+ * A converted ordering assertion is only proven non-vacuous by an input that makes it FAIL. This
5
+ * driver takes a manifest of mutations and, for each one:
6
+ *
7
+ * 1. asserts the mutation actually CHANGED the file — a positive control, because a needle that
8
+ * never matched, or an occurrence index past the last match, would otherwise read as a caught
9
+ * mutation when nothing was mutated at all;
10
+ * 2. runs the target test CLEAN and requires it to PASS — without this control an already-red
11
+ * test makes every mutation look caught, which is the inverse trap;
12
+ * 3. applies the mutation and requires the named test to FAIL;
13
+ * 4. restores the file byte-for-byte from the copy held in memory, never from git, so sibling
14
+ * working-tree edits are never reverted.
15
+ *
16
+ * Mutation kinds, and why one is not enough:
17
+ *
18
+ * delete — remove the Nth occurrence of a needle. The occurrence index is not optional.
19
+ * `options: .atomic` occurs twice in RecordingsApp.swift and only the SECOND is
20
+ * inside the region under test, so deleting the first must stay GREEN; a harness
21
+ * that always deletes the first records a false survivor and sends someone to
22
+ * "fix" a guard that was working. Three more needles are masked the same way by an
23
+ * earlier occurrence that is the helper's own DEFINITION rather than its call site.
24
+ * swap — exchange two needles' text so the ordering flips. An ordering assertion that
25
+ * survives a REORDER is the same vacuity class this sweep exists to close wearing
26
+ * different clothes, and it is entirely invisible to a deletion-only harness.
27
+ * duplicate — insert a second copy of a needle. This is what separates a uniqueness bound from
28
+ * a bare `toContain`: the `toContain` stays green on a duplicate, the bound does not.
29
+ * corrupt — replace a string literal in the TEST at an exact byte offset with a sentinel that
30
+ * cannot occur in any source. Equivalent, from the assertion's point of view, to
31
+ * deleting that needle from the source, but it needs no map from haystack variable
32
+ * to source path, so it covers every converted site mechanically.
33
+ *
34
+ * Needles may contain \n and \t as escapes, because several properties can only be pinned by a
35
+ * whole-line needle (a bare helper name matches its own definition first).
36
+ *
37
+ * Exit codes come from spawnSync status, never through a pipe. Runs are strictly serial: a sibling
38
+ * assertion in capture-probe.test.ts scans a shared /tmp, so concurrent suites corrupt each other.
39
+ *
40
+ * Usage: bun scripts/vacuity-mutation-battery.ts <manifest.tsv> [--only <substring>]
41
+ * Manifest, tab-separated: kind <TAB> testFile <TAB> testName <TAB> targetFile <TAB> a <TAB> b
42
+ */
43
+ import { readFileSync, writeFileSync } from "node:fs";
44
+ import { spawnSync } from "node:child_process";
45
+
46
+ type Kind = "delete" | "swap" | "duplicate" | "corrupt";
47
+ type Row = { kind: Kind; testFile: string; testName: string; target: string; a: string; b: string };
48
+
49
+ const [manifestPath, ...rest] = process.argv.slice(2);
50
+ if (!manifestPath) {
51
+ console.error("usage: bun scripts/vacuity-mutation-battery.ts <manifest.tsv> [--only <substr>]");
52
+ process.exit(2);
53
+ }
54
+ const onlyIndex = rest.indexOf("--only");
55
+ const only = onlyIndex >= 0 ? rest[onlyIndex + 1] : undefined;
56
+
57
+ const rows: Row[] = readFileSync(manifestPath, "utf8")
58
+ .split("\n")
59
+ .filter((line) => line.trim() && !line.startsWith("#"))
60
+ .map((line, index) => {
61
+ const [kind, testFile, testName, target, a, b] = line.split("\t");
62
+ // Fail loudly on a malformed row. Redirecting the generator with `2>&1` merges its progress line
63
+ // into the manifest, which parsed as a row with an undefined target and crashed the driver 164
64
+ // rows in — after all the expensive work, and with a stack trace that named the wrong cause.
65
+ if (!kind || !testFile || !testName || !target) {
66
+ console.error(`manifest line ${index + 1} is malformed (${line.split("\t").length} fields): ${line.slice(0, 120)}`);
67
+ process.exit(2);
68
+ }
69
+ return { kind: kind as Kind, testFile, testName, target, a: a ?? "", b: b ?? "" };
70
+ })
71
+ .filter((r) => !only || `${r.testFile} ${r.testName} ${r.a}`.includes(only));
72
+
73
+ /** Byte offset of the nth (1-based) occurrence, or -1. */
74
+ function nthIndex(haystack: string, needle: string, n: number): number {
75
+ let at = -1;
76
+ for (let i = 0; i < n; i++) {
77
+ at = haystack.indexOf(needle, i === 0 ? 0 : at + 1);
78
+ if (at < 0) return -1;
79
+ }
80
+ return at;
81
+ }
82
+
83
+ /** TSV cannot carry literal tabs or newlines, so needles arrive escaped. */
84
+ function unescape(text: string): string {
85
+ return text.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
86
+ }
87
+
88
+ /** Apply a mutation, or return undefined — which the caller treats as a control FAILURE. */
89
+ function mutate(row: Row, original: string): string | undefined {
90
+ const a = unescape(row.a);
91
+ const b = unescape(row.b);
92
+
93
+ if (row.kind === "corrupt") {
94
+ // Deliberately NOT unescaped: this mutates the test file's RAW bytes, and a `\n` inside a
95
+ // TypeScript string literal is two bytes on disk. Unescaping it would move the offset and the
96
+ // guard below would reject every row — silently, as a control failure rather than a survivor.
97
+ const offset = Number(row.a);
98
+ if (!Number.isFinite(offset)) return undefined;
99
+ if (original.slice(offset, offset + row.b.length) !== row.b) return undefined;
100
+ return (
101
+ original.slice(0, offset) +
102
+ row.b +
103
+ "__OPE41_00029_ABSENT__" +
104
+ original.slice(offset + row.b.length)
105
+ );
106
+ }
107
+
108
+ if (row.kind === "delete") {
109
+ const at = nthIndex(original, a, Number(row.b || "1"));
110
+ if (at < 0) return undefined;
111
+ return original.slice(0, at) + original.slice(at + a.length);
112
+ }
113
+
114
+ if (row.kind === "duplicate") {
115
+ const at = nthIndex(original, a, Number(row.b || "1"));
116
+ if (at < 0) return undefined;
117
+ return original.slice(0, at + a.length) + a + original.slice(at + a.length);
118
+ }
119
+
120
+ if (row.kind === "swap") {
121
+ const atA = original.indexOf(a);
122
+ const atB = original.indexOf(b);
123
+ if (atA < 0 || atB < 0 || a === b) return undefined;
124
+ const firstAt = Math.min(atA, atB);
125
+ const secondAt = Math.max(atA, atB);
126
+ const [first, second] = atA < atB ? [a, b] : [b, a];
127
+ if (firstAt + first.length > secondAt) return undefined; // overlapping; cannot swap cleanly
128
+ return (
129
+ original.slice(0, firstAt) +
130
+ second +
131
+ original.slice(firstAt + first.length, secondAt) +
132
+ first +
133
+ original.slice(secondAt + second.length)
134
+ );
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ /**
140
+ * `bun test -t` takes a REGEX, not a substring.
141
+ *
142
+ * A test named `rendering RecordingEngine.logResolvedTrigger()'s format string parses as expected`
143
+ * contains `()`, which as a pattern is an empty capture group, so the literal parentheses in the
144
+ * name are never matched and bun reports `matched 0 tests` and exits 1. Unescaped, that presented as
145
+ * RED-ON-CLEAN — a refusal to conclude rather than a false pass, but still two sites left unproven
146
+ * for a reason that had nothing to do with them.
147
+ */
148
+ function escapeForTestFilter(name: string): string {
149
+ return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
150
+ }
151
+
152
+ function runTest(testFile: string, testName: string): { code: number; output: string } {
153
+ const filter = escapeForTestFilter(testName);
154
+ const result = spawnSync("bun", ["test", testFile, "-t", filter, "--timeout", "120000"], {
155
+ encoding: "utf8",
156
+ env: { ...process.env, NO_COLOR: "1" },
157
+ maxBuffer: 64 * 1024 * 1024,
158
+ });
159
+ return { code: result.status ?? 1, output: `${result.stdout ?? ""}${result.stderr ?? ""}` };
160
+ }
161
+
162
+ /** Did the filter select anything? 0 pass 0 fail exits 0 and would read as a clean pass. */
163
+ function ranSomething(output: string): boolean {
164
+ const p = output.match(/(\d+)\s+pass/);
165
+ const f = output.match(/(\d+)\s+fail/);
166
+ return (p ? Number(p[1]) : 0) + (f ? Number(f[1]) : 0) > 0;
167
+ }
168
+
169
+ type Verdict =
170
+ | "KILLED"
171
+ | "KILLED-UNATTRIBUTED"
172
+ | "SURVIVED"
173
+ | "MUTATION-NOT-APPLIED"
174
+ | "RED-ON-CLEAN"
175
+ | "FILTER-MATCHED-NOTHING";
176
+
177
+ /**
178
+ * A red test is not automatically proof that the converted assertion fired.
179
+ *
180
+ * Most of these suites EXECUTE the script they also read, so deleting a needle from
181
+ * `install_macos_app.sh` can break the run and fail `expect(result.exitCode).toBe(0)` long before
182
+ * the ordering assertion is reached. The driver would see a non-zero exit and record KILLED while
183
+ * the assertion under test did nothing at all — a kill credited to the wrong cause is exactly the
184
+ * "evidence that does not prove the claim" this sweep exists to remove.
185
+ *
186
+ * So a kill only counts when the failure output carries one of the guard's own messages. Anything
187
+ * else is reported as KILLED-UNATTRIBUTED and proves nothing about the site.
188
+ *
189
+ * The patterns are deliberately fragments rather than whole sentences. Suites add their own named
190
+ * guards — "test override is missing entirely: ...", "resolver function is missing entirely: ..." —
191
+ * and a signal list enumerating only the helper's own wording reported those as UNATTRIBUTED, which
192
+ * cost two hand-attributions that the driver should have made itself.
193
+ */
194
+ const GUARD_SIGNAL =
195
+ /(is missing entirely|marker is missing|marker is not unique|too small to assert anything about|is missing or out of order|no longer sanitizes)/;
196
+
197
+ function attributed(output: string): boolean {
198
+ return GUARD_SIGNAL.test(output);
199
+ }
200
+
201
+ const results: { row: Row; verdict: Verdict }[] = [];
202
+ const cleanCache = new Map<string, "ok" | "red" | "no-match">();
203
+
204
+ for (const [n, row] of rows.entries()) {
205
+ const label = `[${n + 1}/${rows.length}] ${row.kind} ${row.testFile} :: ${row.testName}`;
206
+ const original = readFileSync(row.target, "utf8");
207
+
208
+ const mutated = mutate(row, original);
209
+ if (mutated === undefined || mutated === original) {
210
+ results.push({ row, verdict: "MUTATION-NOT-APPLIED" });
211
+ console.log(`${label}\n MUTATION-NOT-APPLIED ${row.a.slice(0, 70)}`);
212
+ continue;
213
+ }
214
+
215
+ // The cache records WHY a clean run was unusable, not merely that it was. Collapsing both causes
216
+ // into one boolean made every row after the first report RED-ON-CLEAN, so a broken `-t` filter
217
+ // masqueraded as a failing test and pointed the reader at the wrong thing to fix.
218
+ const cleanKey = `${row.testFile} ${row.testName}`;
219
+ if (!cleanCache.has(cleanKey)) {
220
+ const clean = runTest(row.testFile, row.testName);
221
+ cleanCache.set(
222
+ cleanKey,
223
+ !ranSomething(clean.output) ? "no-match" : clean.code === 0 ? "ok" : "red",
224
+ );
225
+ }
226
+ const cleanState = cleanCache.get(cleanKey);
227
+ if (cleanState !== "ok") {
228
+ const verdict: Verdict = cleanState === "no-match" ? "FILTER-MATCHED-NOTHING" : "RED-ON-CLEAN";
229
+ results.push({ row, verdict });
230
+ console.log(`${label}\n ${verdict} - no mutation verdict can be drawn here`);
231
+ continue;
232
+ }
233
+
234
+ try {
235
+ writeFileSync(row.target, mutated, "utf8");
236
+ const after = runTest(row.testFile, row.testName);
237
+ const verdict: Verdict =
238
+ after.code === 0 ? "SURVIVED" : attributed(after.output) ? "KILLED" : "KILLED-UNATTRIBUTED";
239
+ results.push({ row, verdict });
240
+ console.log(`${label}\n ${verdict} (mutated exit ${after.code}) ${row.a.slice(0, 60)}`);
241
+ } finally {
242
+ writeFileSync(row.target, original, "utf8");
243
+ }
244
+ }
245
+
246
+ const tally = results.reduce<Record<string, number>>((acc, r) => {
247
+ acc[r.verdict] = (acc[r.verdict] ?? 0) + 1;
248
+ return acc;
249
+ }, {});
250
+ console.log(`\n==== BATTERY SUMMARY (${results.length} mutations) ====`);
251
+ for (const [verdict, count] of Object.entries(tally).sort()) console.log(`${verdict}: ${count}`);
252
+
253
+ const notKilled = results.filter((r) => r.verdict !== "KILLED");
254
+ if (notKilled.length) {
255
+ console.log(`\n---- NOT KILLED (${notKilled.length}) ----`);
256
+ for (const s of notKilled) {
257
+ console.log(
258
+ `${s.verdict}\t${s.row.kind}\t${s.row.testFile}\t${s.row.testName}\t${s.row.a.slice(0, 60)}`,
259
+ );
260
+ }
261
+ }
262
+
263
+ // An instrument that always exits 0 is not a gate. Anything other than a clean kill — a survivor, a
264
+ // kill credited to the wrong cause, or a control that could not be applied — has to be able to fail
265
+ // a caller, or the summary is decoration that a script above it will happily ignore.
266
+ process.exit(notKilled.length === 0 ? 0 : 1);
@@ -13,8 +13,10 @@ struct MenuBarStatusLabel: View {
13
13
  private var presentation: MenuBarPresentation {
14
14
  MenuBarPresentation(
15
15
  isRecording: store.engine.isRecording,
16
+ isWarmingUpCapture: store.engine.isWarmingUpCapture,
16
17
  canStartRecording: store.engine.canStartRecording,
17
- statusMessage: store.engine.statusMessage
18
+ statusMessage: store.engine.statusMessage,
19
+ blockedReason: store.engine.blockedReason
18
20
  )
19
21
  }
20
22
  }
@@ -22,6 +24,9 @@ struct MenuBarStatusLabel: View {
22
24
  struct MenuBarStatusView: View {
23
25
  @ObservedObject var store: RecordingsStore
24
26
  let openRecordings: () -> Void
27
+ /// Bar-only builds have no workspace window to open; the entry point is hidden so
28
+ /// the affordance does not exist where the surface cannot exist.
29
+ let barOnly: Bool
25
30
 
26
31
  var body: some View {
27
32
  VStack(alignment: .leading, spacing: 12) {
@@ -43,16 +48,18 @@ struct MenuBarStatusView: View {
43
48
  .frame(maxWidth: .infinity)
44
49
  }
45
50
  .buttonStyle(.borderedProminent)
46
- .tint(store.engine.isRecording ? .red : .accentColor)
51
+ .tint(store.engine.captureIsActive ? .red : .accentColor)
47
52
  .disabled(!presentation.primaryActionEnabled)
48
53
 
49
54
  Divider()
50
55
 
51
- Button(action: openRecordings) {
52
- Label("Open Recordings", systemImage: "macwindow")
53
- .frame(maxWidth: .infinity, alignment: .leading)
56
+ if !barOnly {
57
+ Button(action: openRecordings) {
58
+ Label("Open Recordings", systemImage: "macwindow")
59
+ .frame(maxWidth: .infinity, alignment: .leading)
60
+ }
61
+ .buttonStyle(.plain)
54
62
  }
55
- .buttonStyle(.plain)
56
63
 
57
64
  SettingsLink {
58
65
  Label("Settings", systemImage: "gearshape")
@@ -73,27 +80,32 @@ struct MenuBarStatusView: View {
73
80
  }
74
81
 
75
82
  private var statusColor: Color {
76
- store.engine.isRecording ? .red : .accentColor
83
+ if store.engine.captureIsActive { return .red }
84
+ // The popover repeats the menu-bar signal rather than relying on the caption text, which
85
+ // is rendered `.secondary` and reads as an aside.
86
+ return presentation.isBlocked ? .orange : .accentColor
77
87
  }
78
88
 
79
89
  private var presentation: MenuBarPresentation {
80
90
  MenuBarPresentation(
81
91
  isRecording: store.engine.isRecording,
92
+ isWarmingUpCapture: store.engine.isWarmingUpCapture,
82
93
  canStartRecording: store.engine.canStartRecording,
83
- statusMessage: store.engine.statusMessage
94
+ statusMessage: store.engine.statusMessage,
95
+ blockedReason: store.engine.blockedReason
84
96
  )
85
97
  }
86
98
 
87
99
  private var recordButtonTitle: String {
88
- store.engine.isRecording ? "Stop and Transcribe" : "Start Recording"
100
+ store.engine.captureIsActive ? "Stop and Transcribe" : "Start Recording"
89
101
  }
90
102
 
91
103
  private var recordButtonIcon: String {
92
- store.engine.isRecording ? "stop.fill" : "mic.fill"
104
+ store.engine.captureIsActive ? "stop.fill" : "mic.fill"
93
105
  }
94
106
 
95
107
  private func toggleRecording() {
96
- if store.engine.isRecording {
108
+ if store.engine.captureIsActive {
97
109
  store.engine.stopAndTranscribe()
98
110
  } else {
99
111
  store.engine.startRecording()
@@ -56,6 +56,8 @@ final class RecordingsAppDelegate: NSObject, NSApplicationDelegate {
56
56
  final class RecordingsAppState: ObservableObject {
57
57
  let store: RecordingsStore?
58
58
  let declaresMenuBar: Bool
59
+ let barOnly: Bool
60
+ let declaresWindow: Bool
59
61
  let runtimeSmokeProbe: RuntimeSmokeProbe?
60
62
  private let runtimeSmokeMode: String?
61
63
  private let runtimeSmokeOutputPath: String?
@@ -67,6 +69,8 @@ final class RecordingsAppState: ObservableObject {
67
69
 
68
70
  init(plan: PermissionRequestLaunchPlan) {
69
71
  declaresMenuBar = plan.declaresMenuBar
72
+ barOnly = plan.isBarOnly
73
+ declaresWindow = plan.declaresWindow
70
74
  runtimeSmokeMode = plan.runtimeSmokeMode
71
75
  runtimeSmokeOutputPath = plan.runtimeSmokeOutputPath
72
76
  runtimeSmokeAcknowledgementPath = plan.runtimeSmokeAcknowledgementPath
@@ -86,6 +90,14 @@ final class RecordingsAppState: ObservableObject {
86
90
  }
87
91
 
88
92
  func openRecordings() {
93
+ // Bar launches never create the workspace window — on any path, including the
94
+ // reopen handler and the runtime smoke. Keyed on declaresWindow (NOT on
95
+ // declaresMainWindow, which excludes every runtime smoke): a full build's
96
+ // normal-mode smoke must keep exercising the window, and only bar builds finish
97
+ // windowless. This was the cycle-2 P1 of the terminated PR #269 lineage — a
98
+ // windowless branch keyed on declaresMainWindow made the FULL smoke fail
99
+ // deterministically on every build.
100
+ guard declaresWindow else { return }
89
101
  if let store {
90
102
  showWindow(contentView: NSHostingView(rootView: ContentView(store: store)))
91
103
  } else if runtimeSmokeMode == "normal" {
@@ -181,6 +193,23 @@ final class RecordingsAppState: ObservableObject {
181
193
  runtimeSmokeProbe.completed = { [weak self, weak runtimeSmokeProbe] in
182
194
  guard let self, let runtimeSmokeProbe else { return }
183
195
  let accessibility = RuntimeSmokeAccessibilitySnapshot.processMenuBarExtras()
196
+ if self.barOnly {
197
+ // Windowless by construction: the bar smoke must never create the
198
+ // workspace window, and it reports the windowless state directly. This
199
+ // branch keys on barOnly, NOT on declaresMainWindow — that property
200
+ // excludes every runtime smoke and made the FULL build's smoke fail
201
+ // deterministically in the terminated PR #269 lineage (cycle-2 P1). A
202
+ // nil-window comparison would also read as a retained window (nil ===
203
+ // nil), so the windowless result is finished here explicitly.
204
+ self.finishRuntimeSmoke(
205
+ mode: mode,
206
+ surfaceCount: runtimeSmokeProbe.surfaceAppearances,
207
+ labels: runtimeSmokeProbe.renderedLabels,
208
+ accessibility: accessibility,
209
+ retainedWindowReused: false
210
+ )
211
+ return
212
+ }
184
213
  self.openRecordings()
185
214
  let firstWindow = self.mainWindow
186
215
  self.openRecordings()
@@ -202,6 +231,9 @@ final class RecordingsAppState: ObservableObject {
202
231
  retainedWindowReused: Bool,
203
232
  attempt: Int = 0
204
233
  ) {
234
+ // Only the non-bar smoke reaches this (bar launches finish windowless in the
235
+ // probe completion handler above), so the settled state is exactly the retained
236
+ // window being key, unchanged from the base contract.
205
237
  let windowSettled = NSApplication.shared.isActive && (mainWindow?.isKeyWindow ?? false)
206
238
  guard windowSettled || attempt >= 60 else {
207
239
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
@@ -316,7 +348,11 @@ struct RecordingsApp: App {
316
348
  @SceneBuilder var body: some Scene {
317
349
  MenuBarExtra(isInserted: menuBarInsertion) {
318
350
  if let store = state.store {
319
- MenuBarStatusView(store: store, openRecordings: state.openRecordings)
351
+ MenuBarStatusView(
352
+ store: store,
353
+ openRecordings: state.openRecordings,
354
+ barOnly: state.barOnly
355
+ )
320
356
  } else if state.runtimeSmokeProbe != nil {
321
357
  EmptyView()
322
358
  }
@@ -21,8 +21,12 @@ final class RuntimeSmokeProbe: ObservableObject {
21
21
  var presentation: MenuBarPresentation {
22
22
  MenuBarPresentation(
23
23
  isRecording: phase == .recording,
24
+ // The smoke probe drives phases directly and never runs a real recorder, so it has
25
+ // no warm-up window.
26
+ isWarmingUpCapture: false,
24
27
  canStartRecording: phase == .idle,
25
- statusMessage: phase == .transcribing ? "Transcribing" : "Ready"
28
+ statusMessage: phase == .transcribing ? "Transcribing" : "Ready",
29
+ blockedReason: nil
26
30
  )
27
31
  }
28
32
 
@@ -37,7 +37,7 @@ struct SidebarView: View {
37
37
  withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { store.pane = .record }
38
38
  } label: {
39
39
  rowLabel(icon: "mic.fill", label: "Record", count: nil,
40
- selected: store.pane == .record, accentDot: store.engine.isRecording)
40
+ selected: store.pane == .record, accentDot: store.engine.captureIsActive)
41
41
  }
42
42
  .buttonStyle(.plain)
43
43
  }
@@ -20,10 +20,24 @@ let package = Package(
20
20
  ],
21
21
  dependencies: [
22
22
  .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", exact: "1.12.0"),
23
- // Swift Testing pinned to the toolchain-matched release. The stations build with
24
- // CommandLineTools (no bundled Testing module), so the package is required but it
25
- // must track the compiler: the archived 0.99 shim under the 6.2 compiler silently
26
- // corrupted `#expect` Bool-comparison evaluation, passing false assertions.
23
+ // Swift Testing. The stations build with CommandLineTools (no bundled Testing
24
+ // module), so the package is required. The original rule here was "the pin must
25
+ // track the compiler", because an archived 0.99 shim under the 6.2 compiler once
26
+ // silently corrupted `#expect` Bool-comparison evaluation, passing false assertions.
27
+ //
28
+ // Measured on station06 (Apple Swift 6.3.2, swiftlang-6.3.2.1.108): the 6.3.x line
29
+ // CANNOT be used here. From swift-6.3-RELEASE onward swift-testing's own manifest adds
30
+ // `.linkedLibrary("_TestingInterop")` under `#if compiler(>=6.3)`. That library ships
31
+ // in the toolchain at `$(xcode-select -p)/Library/Developer/usr/lib`, which is not on
32
+ // SwiftPM's default search path, so `swift build --build-tests` fails with
33
+ // `ld: library '_TestingInterop' not found`. It links only if every invocation adds
34
+ // `-Xlinker -L$(xcode-select -p)/Library/Developer/usr/lib`, which nothing in this repo
35
+ // supplies. Repinning to 6.3.2 would therefore break `swift test` for every caller.
36
+ //
37
+ // So the pin stays on the 6.2 line, and the rule it was standing in for is now
38
+ // enforced directly instead of by version matching: ExpectationIntegrityTests asserts
39
+ // that `#expect` both admits true Bool comparisons and *records issues* for false ones.
40
+ // That turns the silent failure mode into a red test, which a version pin never could.
27
41
  .package(url: "https://github.com/apple/swift-testing.git", revision: "swift-6.2-RELEASE"),
28
42
  ],
29
43
  targets: [
@@ -46,7 +60,12 @@ let package = Package(
46
60
  .executableTarget(
47
61
  name: "RecordingsUpdateBroker",
48
62
  dependencies: ["RecordingsUpdateProtocol", "RecordingsVerifierLauncher"],
49
- path: "Updater/Broker"
63
+ path: "Updater/Broker",
64
+ // PeerIdentity resolves the XPC peer's audit token into a PID and EUID with
65
+ // audit_token_to_pid/_euid, which live in libbsm. Without this the broker
66
+ // compiles and then fails at link, which `swift build --target` does not
67
+ // surface because it stops short of linking the executable product.
68
+ linkerSettings: [.linkedLibrary("bsm")]
50
69
  ),
51
70
  .target(
52
71
  name: "RecordingsVerifierLauncher",
@@ -0,0 +1,91 @@
1
+ import Foundation
2
+
3
+ /// Deterministic local pre-screen for the CLI's enhancement decision.
4
+ ///
5
+ /// The helper CLI owns the real decision (`needsEnhancement` in `src/lib/enhancer.ts`):
6
+ /// in `auto` post-processing mode it rewrites transcripts that carry an explicit
7
+ /// configured trigger phrase or an instruction-shaped opening. Delivery may only run
8
+ /// ahead of persistence when that rewrite provably cannot happen — the user asked for
9
+ /// the rewritten text, so an eligible transcript must keep pasting the helper's output.
10
+ ///
11
+ /// This screen therefore mirrors the CLI's detection *conservatively*: it answers
12
+ /// "may the helper enhance this?" and fails closed (true) whenever it cannot decide.
13
+ /// The trigger list is config-owned and arrives as the same frozen JSON array the
14
+ /// engine already forwards to the helper via `--enhance-triggers-json`, so both sides
15
+ /// read one source of truth for the configurable half. The instruction patterns mirror
16
+ /// the CLI's built-in regexes; `EnhancementScreenTests` and the `enhancer.test.ts`
17
+ /// "cross-language fixture" suite pin both implementations to one shared fixture table
18
+ /// so they cannot drift apart silently.
19
+ public enum EnhancementScreen {
20
+ /// Built-in instruction shapes, mirrored from `instructionPatterns` in
21
+ /// `src/lib/enhancer.ts`. Order and wording must match the CLI; the shared fixture
22
+ /// tests fail when either side changes alone.
23
+ static let instructionPatternSources: [String] = [
24
+ #"(?:write|draft|compose|create)\s+(?:an?\s+)?(?:email|message|response|reply|letter|note|text|slack|dm)"#,
25
+ #"(?:give|provide|send)\s+(?:them|him|her|it|the\s+agent|the\s+team)\s+(?:full\s+)?instructions"#,
26
+ #"(?:tell|ask)\s+(?:them|him|her|it|the\s+agent)\s+(?:to|that)"#,
27
+ #"(?:make\s+it|make\s+this)\s+(?:sound|look|read)\s+(?:more\s+)?(?:professional|formal|casual|friendly|better)"#,
28
+ #"(?:ok\s+so|okay\s+so|alright\s+so)\s+(?:say|write|tell|put)"#,
29
+ #"(?:i\s+need|i\s+want)\s+(?:the\s+agent|it|them|you)\s+to\s+(?:build|create|implement|design|make)"#,
30
+ ]
31
+
32
+ private static let instructionPatterns: [NSRegularExpression]? = {
33
+ var patterns: [NSRegularExpression] = []
34
+ for source in instructionPatternSources {
35
+ guard let pattern = try? NSRegularExpression(
36
+ pattern: source,
37
+ options: [.caseInsensitive]
38
+ ) else { return nil }
39
+ patterns.append(pattern)
40
+ }
41
+ return patterns
42
+ }()
43
+
44
+ /// True when the helper CLI could rewrite `text` in `auto` mode — or when this
45
+ /// screen cannot prove it will not (malformed trigger JSON, an unbuildable
46
+ /// pattern): enhancement-eligible speech must never be pasted raw ahead of the
47
+ /// helper, so every unprovable case persists first.
48
+ public static func mayRequireEnhancement(text: String, enhanceTriggersJSON: String) -> Bool {
49
+ guard let triggers = decodeTriggers(enhanceTriggersJSON) else { return true }
50
+ let lowered = text.lowercased()
51
+ for trigger in triggers {
52
+ let loweredTrigger = trigger.lowercased()
53
+ // An EMPTY configured trigger matches EVERYTHING in the CLI:
54
+ // `needsEnhancement` tests `lower.includes(trigger.toLowerCase())` and
55
+ // JavaScript's `includes("")` is true for every string, so one empty row
56
+ // makes the helper rewrite every transcript (MEASURED on the CLI:
57
+ // needs=true, reason `Explicit trigger: ""`). This screen must answer
58
+ // may-enhance for the same input or it admits that speech to
59
+ // paste-before-persistence and pastes raw text where the user asked for
60
+ // the rewrite.
61
+ //
62
+ // Special-cased rather than left to `contains`, because Swift's answer for
63
+ // an empty needle depends on which overload resolves — Foundation's
64
+ // `range(of:)`-based one answers false, the stdlib `Collection` one answers
65
+ // true — and NO machine has ever executed this file, so neither answer may
66
+ // be assumed. That overload claim is an UNVERIFIED INFERENCE from the
67
+ // language, not a measurement; `return true` is the fail-closed answer
68
+ // under either.
69
+ guard !loweredTrigger.isEmpty else { return true }
70
+ if lowered.contains(loweredTrigger) { return true }
71
+ }
72
+
73
+ guard let instructionPatterns else { return true }
74
+ let range = NSRange(text.startIndex..., in: text)
75
+ return instructionPatterns.contains { pattern in
76
+ pattern.firstMatch(in: text, range: range) != nil
77
+ }
78
+ }
79
+
80
+ /// Decodes the frozen trigger array the engine forwards to the helper. An empty
81
+ /// string means "no configured triggers" (the built-in patterns still apply);
82
+ /// anything else must parse as a JSON string array or the screen fails closed.
83
+ private static func decodeTriggers(_ json: String) -> [String]? {
84
+ let trimmed = json.trimmingCharacters(in: .whitespacesAndNewlines)
85
+ guard !trimmed.isEmpty else { return [] }
86
+ guard let data = trimmed.data(using: .utf8),
87
+ let decoded = try? JSONDecoder().decode([String].self, from: data)
88
+ else { return nil }
89
+ return decoded
90
+ }
91
+ }
@@ -12,15 +12,31 @@ final class FnKeyMonitor: @unchecked Sendable {
12
12
  private var healthCheckTimer: Timer?
13
13
  private var fnIsDown = false
14
14
 
15
- var isRunning: Bool { eventTap != nil }
15
+ /// Whether the tap exists *and* is still enabled.
16
+ ///
17
+ /// `eventTap != nil` was not enough: macOS disables a tap when Accessibility is revoked
18
+ /// at runtime, and only `stop()` ever cleared the handle — so a dead tap reported itself
19
+ /// as running, the engine saw no blocker, and the `!isRunning` retry guard prevented
20
+ /// recovery. Ask the tap, not the handle.
21
+ var isRunning: Bool {
22
+ guard let eventTap else { return false }
23
+ return CGEvent.tapIsEnabled(tap: eventTap)
24
+ }
16
25
 
17
26
  private static let fnKeyCode: UInt16 = 63
18
27
 
19
28
  /// Start monitoring. Returns true if successful.
20
29
  func start() -> Bool {
21
- guard eventTap == nil else {
22
- fputs("[FnKeyMonitor] Already running\n", stderr)
23
- return true
30
+ if eventTap != nil {
31
+ guard !isRunning else {
32
+ fputs("[FnKeyMonitor] Already running\n", stderr)
33
+ return true
34
+ }
35
+ // A handle we still hold but that the system has disabled cannot be revived by
36
+ // re-enabling it here — the grant that backed it is gone. Tear it down so the
37
+ // code below can build a fresh one once trust returns.
38
+ fputs("[FnKeyMonitor] Existing tap is disabled — discarding it before retry\n", stderr)
39
+ stop()
24
40
  }
25
41
 
26
42
  let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
@@ -63,6 +79,13 @@ final class FnKeyMonitor: @unchecked Sendable {
63
79
  if !CGEvent.tapIsEnabled(tap: tap) {
64
80
  fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
65
81
  CGEvent.tapEnable(tap: tap, enable: true)
82
+ // If it will not come back, the permission behind it is gone. Drop the
83
+ // tap so `isRunning` turns false and the engine can report a blocker and
84
+ // rebuild, instead of polling a corpse every three seconds forever.
85
+ if !CGEvent.tapIsEnabled(tap: tap) {
86
+ fputs("[FnKeyMonitor] Re-enable failed — tearing down\n", stderr)
87
+ self.stop()
88
+ }
66
89
  }
67
90
  }
68
91
 
@@ -17,11 +17,11 @@
17
17
  <key>CFBundleIdentifier</key>
18
18
  <string>com.hasna.recordings</string>
19
19
  <key>CFBundleVersion</key>
20
- <string>0.2.14</string>
20
+ <string>0.3.2</string>
21
21
  <key>CFBundlePackageType</key>
22
22
  <string>APPL</string>
23
23
  <key>CFBundleShortVersionString</key>
24
- <string>0.2.14</string>
24
+ <string>0.3.2</string>
25
25
  <key>LSMinimumSystemVersion</key>
26
26
  <string>26.0</string>
27
27
  <key>NSMicrophoneUsageDescription</key>