@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.
- package/Dockerfile.package +4 -5
- package/README.md +113 -8
- package/bun.lock +2 -2
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +22 -0
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +1 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts +22 -0
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +1 -0
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +169 -0
- package/dist/__tests__/helpers/source-assertions.d.ts.map +1 -0
- package/dist/__tests__/preload.d.ts +1 -4
- package/dist/__tests__/preload.d.ts.map +1 -1
- package/dist/cli/desktop-snapshot.d.ts +20 -0
- package/dist/cli/desktop-snapshot.d.ts.map +1 -0
- package/dist/cli/index.js +2094 -280
- package/dist/cli/macos-permissions.d.ts +72 -0
- package/dist/cli/macos-permissions.d.ts.map +1 -1
- package/dist/cli/macos-shortcut.d.ts +139 -0
- package/dist/cli/macos-shortcut.d.ts.map +1 -0
- package/dist/cli/trigger-probe.d.ts +172 -0
- package/dist/cli/trigger-probe.d.ts.map +1 -0
- package/dist/db/database.d.ts +9 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/http/client.d.ts +19 -7
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +635 -50
- package/dist/lib/capture-probe.d.ts +216 -0
- package/dist/lib/capture-probe.d.ts.map +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/macos-bundle.d.ts +50 -0
- package/dist/lib/macos-bundle.d.ts.map +1 -0
- package/dist/lib/persistence-probe.d.ts +208 -0
- package/dist/lib/persistence-probe.d.ts.map +1 -0
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/retired-deployment-modes.d.ts +24 -0
- package/dist/lib/retired-deployment-modes.d.ts.map +1 -0
- package/dist/lib/transcriber.d.ts +60 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +141 -57
- package/dist/server/cloud-config.d.ts +25 -3
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +8 -7
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +188 -80
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +109 -50
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +25 -6
- package/scripts/ci-linux-suite.ts +403 -0
- package/scripts/ci-native-build.ts +402 -0
- package/scripts/enforce_identity_migration.sh +110 -0
- package/scripts/generate-sdk.ts +1 -1
- package/scripts/install_macos_app.sh +192 -31
- package/scripts/macos_artifact.ts +323 -43
- package/scripts/native/realtime-settle-probe/README.md +39 -0
- package/scripts/native/realtime-settle-probe/main.swift +135 -0
- package/scripts/policy/local-only-approved-targets.txt +21 -0
- package/scripts/read_local_only_targets.sh +203 -0
- package/scripts/scan-artifact.ts +40 -0
- package/scripts/set-version.ts +145 -0
- package/scripts/smoke_macos_app.sh +62 -16
- package/scripts/vacuity-manifest-gen.ts +96 -0
- package/scripts/vacuity-manifests/enhancement-screen-mirror.tsv +44 -0
- package/scripts/vacuity-manifests/install-chain.tsv +13 -0
- package/scripts/vacuity-manifests/must-survive-controls.tsv +10 -0
- package/scripts/vacuity-manifests/reorder.tsv +4 -0
- package/scripts/vacuity-manifests/source-side.tsv +15 -0
- package/scripts/vacuity-manifests/variable-operands.tsv +16 -0
- package/scripts/vacuity-manifests/version-sites.tsv +61 -0
- package/scripts/vacuity-mutation-battery.ts +266 -0
- package/src/native/Recordings/App/MenuBarStatusView.swift +23 -11
- package/src/native/Recordings/App/RecordingsApp.swift +37 -1
- package/src/native/Recordings/App/RuntimeSmoke.swift +5 -1
- package/src/native/Recordings/App/SidebarView.swift +1 -1
- package/src/native/Recordings/Package.swift +24 -5
- package/src/native/Recordings/RecordingsLib/EnhancementScreen.swift +91 -0
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +27 -4
- package/src/native/Recordings/RecordingsLib/Info.plist +2 -2
- package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +56 -3
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +13 -0
- package/src/native/Recordings/RecordingsLib/PasteDeliveryVerification.swift +460 -0
- package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +21 -1
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +41 -7
- package/src/native/Recordings/RecordingsLib/RecordingAttemptAlert.swift +36 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +1071 -75
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +47 -3
- package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +11 -6
- package/src/native/Recordings/RecordingsTests/BarOnlyLaunchPlanTests.swift +99 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +3 -3
- package/src/native/Recordings/RecordingsTests/EnhancementScreenTests.swift +191 -0
- package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +144 -4
- package/src/native/Recordings/RecordingsTests/PasteDeliveryVerificationTests.swift +463 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +33 -20
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBenchmark.swift +200 -0
- package/src/native/Recordings/RecordingsTests/RealtimeSettleBudgetTests.swift +80 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +22 -4
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +12 -6
- package/src/native/Recordings/RecordingsTests/RecordingEngineHomeIsolationTests.swift +67 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +12 -3
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +207 -11
- package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +17 -6
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +23 -0
- package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +7 -1
- package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +10 -10
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +5 -1
- package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +1 -0
- package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +59 -3
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +21 -2
- package/src/native/Recordings/Updater/ProtocolTests/ExpectationIntegrityTests.swift +60 -0
- package/src/native/Recordings/Updater/ProtocolTests/UpdateXPCInterfaceTests.swift +113 -0
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +106 -5
- package/src/native/Recordings/build.sh +226 -20
- 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.
|
|
51
|
+
.tint(store.engine.captureIsActive ? .red : .accentColor)
|
|
47
52
|
.disabled(!presentation.primaryActionEnabled)
|
|
48
53
|
|
|
49
54
|
Divider()
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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.
|
|
100
|
+
store.engine.captureIsActive ? "Stop and Transcribe" : "Start Recording"
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
private var recordButtonIcon: String {
|
|
92
|
-
store.engine.
|
|
104
|
+
store.engine.captureIsActive ? "stop.fill" : "mic.fill"
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
private func toggleRecording() {
|
|
96
|
-
if store.engine.
|
|
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(
|
|
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.
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
|
24
|
+
<string>0.3.2</string>
|
|
25
25
|
<key>LSMinimumSystemVersion</key>
|
|
26
26
|
<string>26.0</string>
|
|
27
27
|
<key>NSMicrophoneUsageDescription</key>
|