@hasna/recordings 0.2.13 → 0.3.1
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 +2078 -277
- 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 +10 -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 +634 -49
- 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 +140 -56
- 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 +187 -79
- 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 +108 -49
- 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/build_native_fs_guard.sh +15 -2
- 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 +138 -24
- package/scripts/macos_artifact.ts +205 -33
- 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/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 +14 -7
- 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/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/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 +75 -9
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
|
@@ -59,17 +59,61 @@ public struct SettingsView: View {
|
|
|
59
59
|
HStack {
|
|
60
60
|
Text("Shortcut")
|
|
61
61
|
Spacer()
|
|
62
|
+
// Re-evaluate on change, not just log: a chord the system already
|
|
63
|
+
// reserves has to be reported the moment it is picked, not at next launch.
|
|
62
64
|
KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
|
|
63
|
-
engine.
|
|
65
|
+
engine.refreshTriggerDiagnostics()
|
|
64
66
|
}
|
|
65
67
|
Button("Reset to F5") {
|
|
66
68
|
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
67
|
-
engine.
|
|
69
|
+
engine.refreshTriggerDiagnostics()
|
|
68
70
|
}
|
|
69
71
|
}
|
|
72
|
+
// No .onChange here: `RecordingEngine.useFnKey`'s own didSet re-arms the
|
|
73
|
+
// monitor and logs the resolved trigger, so it also covers a change made
|
|
74
|
+
// from the CLI or anywhere else — a view-local hook would not.
|
|
70
75
|
Toggle("Use fn/Globe as recording key", isOn: $engine.useFnKey)
|
|
71
|
-
Text("Hold to record, release to transcribe and paste.")
|
|
76
|
+
Text("Hold to record, release to transcribe and paste. fn needs Accessibility; the hotkey above needs no permission.")
|
|
72
77
|
.foregroundStyle(.secondary)
|
|
78
|
+
// A trigger that is switched on but cannot arm must say so next to its own
|
|
79
|
+
// switch. Silence here is what made 51 recorded hotkey presses look like a
|
|
80
|
+
// working trigger while nothing was delivered.
|
|
81
|
+
//
|
|
82
|
+
// Scoped to the trigger sources, and the button keyed to each reason's own
|
|
83
|
+
// remedy. Rendering the composed `engine.blockedReason` here instead meant a
|
|
84
|
+
// secure-input paste failure showed "transcript copied, press Cmd-V" under
|
|
85
|
+
// Recording Shortcut beside an "Open Accessibility Settings" button — wrong
|
|
86
|
+
// remedy, wrong section, wrong cause. A hotkey collision took that button too,
|
|
87
|
+
// and the Accessibility pane does nothing for a chord clash either.
|
|
88
|
+
ForEach(engine.blockedReasonEntries.filter(\.isTriggerHealth)) { entry in
|
|
89
|
+
Label(entry.message, systemImage: "exclamationmark.triangle.fill")
|
|
90
|
+
.foregroundStyle(.orange)
|
|
91
|
+
// `switch`, not `==`. This file's engine forbids `==` against an enum case in
|
|
92
|
+
// three separate comments for the same reason: it answers `false` for every
|
|
93
|
+
// case added later, so a fourth remedy carrying a button would silently render
|
|
94
|
+
// none. Exhaustive here means adding one forces the decision.
|
|
95
|
+
switch entry.remedy {
|
|
96
|
+
case .openAccessibilitySettings:
|
|
97
|
+
Button("Open Accessibility Settings") {
|
|
98
|
+
engine.openAccessibilitySettings()
|
|
99
|
+
}
|
|
100
|
+
case .chooseAnotherShortcut, .messageOnly:
|
|
101
|
+
EmptyView()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Delivery reasons are not trigger health, but they are the ONLY message telling the
|
|
107
|
+
// owner their transcript is still recoverable — so they need a surface here, not just
|
|
108
|
+
// a wordless warning triangle in the menu bar and a caption behind a click. Scoping
|
|
109
|
+
// the trigger section was right; dropping these entirely was not.
|
|
110
|
+
if !engine.blockedReasonEntries.filter({ !$0.isTriggerHealth }).isEmpty {
|
|
111
|
+
Section("Last Delivery") {
|
|
112
|
+
ForEach(engine.blockedReasonEntries.filter { !$0.isTriggerHealth }) { entry in
|
|
113
|
+
Label(entry.message, systemImage: "exclamationmark.triangle.fill")
|
|
114
|
+
.foregroundStyle(.orange)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
Section("Permissions") {
|
|
@@ -20,12 +20,17 @@ public struct VoiceShortcut: Identifiable, Codable {
|
|
|
20
20
|
public final class VoiceShortcuts: ObservableObject {
|
|
21
21
|
@Published public var shortcuts: [VoiceShortcut] = []
|
|
22
22
|
|
|
23
|
-
private let storageURL: URL
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
private let storageURL: URL
|
|
24
|
+
|
|
25
|
+
/// Injected for the same reason as `RecordingEngine.home`, and named to match the
|
|
26
|
+
/// `homePath:` seam the rest of the package already uses: the default resolves through the
|
|
27
|
+
/// password database (`getpwuid`), not `$HOME`, so a test cannot redirect this write by
|
|
28
|
+
/// overriding the environment and has to pass a temp directory explicitly. Without it a
|
|
29
|
+
/// test that touches voice shortcuts rewrites the operator's real
|
|
30
|
+
/// `~/.hasna/recordings/voice-shortcuts.json`.
|
|
31
|
+
public init(homePath: String = FileManager.default.homeDirectoryForCurrentUser.path) {
|
|
32
|
+
storageURL = URL(fileURLWithPath: homePath)
|
|
33
|
+
.appendingPathComponent(".hasna/recordings/voice-shortcuts.json")
|
|
29
34
|
load()
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -360,7 +360,7 @@ struct CLIRunnerTests {
|
|
|
360
360
|
// commandCLI closure on a detached task, budgeted by commandRewriteTimeout — the
|
|
361
361
|
// observable wall time must stay inside the budget even though the helper never
|
|
362
362
|
// exits on its own and ignores SIGTERM.
|
|
363
|
-
let runCLI = RecordingEngine().commandCLI
|
|
363
|
+
let runCLI = RecordingEngine(homePath: home.path).commandCLI
|
|
364
364
|
let homePath = home.path
|
|
365
365
|
let startedAt = ContinuousClock.now
|
|
366
366
|
let output = await Task.detached {
|
|
@@ -454,7 +454,7 @@ struct CLIRunnerTests {
|
|
|
454
454
|
// also runs to exhaustion. The observable wall time must still land under the
|
|
455
455
|
// public ceiling — the return margin absorbs spawn, poll overshoot, capture
|
|
456
456
|
// shutdown, and the task hops.
|
|
457
|
-
let runCLI = RecordingEngine().commandCLI
|
|
457
|
+
let runCLI = RecordingEngine(homePath: home.path).commandCLI
|
|
458
458
|
let homePath = home.path
|
|
459
459
|
let startedAt = ContinuousClock.now
|
|
460
460
|
let output = await Task.detached {
|
|
@@ -1113,7 +1113,7 @@ struct CLIRunnerTests {
|
|
|
1113
1113
|
// SIGTERM and the silent holder keeps both pipes open, so every deadline in the
|
|
1114
1114
|
// chain — execution window, termination grace, drain wait — runs to exhaustion
|
|
1115
1115
|
// with zero bytes ever arriving to wake a reader.
|
|
1116
|
-
let runCLI = RecordingEngine().commandCLI
|
|
1116
|
+
let runCLI = RecordingEngine(homePath: home.path).commandCLI
|
|
1117
1117
|
let homePath = home.path
|
|
1118
1118
|
let startedAt = ContinuousClock.now
|
|
1119
1119
|
let output = await Task.detached {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
@testable import RecordingsLib
|
|
4
|
+
|
|
5
|
+
// MARK: - Cross-language enhancement fixtures
|
|
6
|
+
|
|
7
|
+
/// Fixture table shared verbatim with `src/__tests__/enhancer.test.ts`
|
|
8
|
+
/// ("cross-language fixtures pinned to EnhancementScreenTests.swift"). The CLI's
|
|
9
|
+
/// `needsEnhancement` and the app's `EnhancementScreen` must agree on every row;
|
|
10
|
+
/// change either implementation and both suites must be updated together.
|
|
11
|
+
enum EnhancementFixtures {
|
|
12
|
+
/// The default trigger list from `src/lib/config.ts`, frozen the same way the
|
|
13
|
+
/// engine forwards it to the helper (`--enhance-triggers-json`).
|
|
14
|
+
static let defaultTriggersJSON = """
|
|
15
|
+
["say it better","rewrite this","make it sound","clean this up","fix this",\
|
|
16
|
+
"rephrase","write it properly","make it professional","improve this","polish this"]
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
/// Rows the CLI enhances in `auto` mode: one per built-in instruction pattern,
|
|
20
|
+
/// plus an explicit configured trigger.
|
|
21
|
+
static let mayEnhance: [String] = [
|
|
22
|
+
"this is my draft say it better",
|
|
23
|
+
"write an email to the team about the outage",
|
|
24
|
+
"give them full instructions for the deploy",
|
|
25
|
+
"tell them to restart the ingest service",
|
|
26
|
+
"make this sound more professional",
|
|
27
|
+
"okay so write that we are postponing the launch",
|
|
28
|
+
"i need the agent to build a dashboard for the metrics",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/// Rows the CLI passes through untouched, including near-misses of the
|
|
32
|
+
/// instruction patterns ("draft beer", "asked them").
|
|
33
|
+
static let plainDictation: [String] = [
|
|
34
|
+
"meet me at noon by the north entrance",
|
|
35
|
+
"the deploy finished and the metrics look stable",
|
|
36
|
+
"draft beer is better than bottled in my opinion",
|
|
37
|
+
"she asked them about the schedule yesterday",
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
struct EnhancementScreenTests {
|
|
42
|
+
@Test("every enhancement-eligible fixture is screened as may-enhance")
|
|
43
|
+
func detectsEligibleRows() {
|
|
44
|
+
for text in EnhancementFixtures.mayEnhance {
|
|
45
|
+
#expect(
|
|
46
|
+
EnhancementScreen.mayRequireEnhancement(
|
|
47
|
+
text: text,
|
|
48
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
49
|
+
),
|
|
50
|
+
"expected may-enhance: \(text)"
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test("plain dictation fixtures are provably not enhanceable")
|
|
56
|
+
func passesPlainDictation() {
|
|
57
|
+
for text in EnhancementFixtures.plainDictation {
|
|
58
|
+
#expect(
|
|
59
|
+
!EnhancementScreen.mayRequireEnhancement(
|
|
60
|
+
text: text,
|
|
61
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
62
|
+
),
|
|
63
|
+
"expected plain dictation: \(text)"
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test("trigger matching is case-insensitive, like the CLI's")
|
|
69
|
+
func caseInsensitiveTriggers() {
|
|
70
|
+
#expect(EnhancementScreen.mayRequireEnhancement(
|
|
71
|
+
text: "REWRITE THIS whole paragraph",
|
|
72
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
73
|
+
))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test("an empty trigger payload still applies the built-in instruction patterns")
|
|
77
|
+
func emptyTriggersKeepPatterns() {
|
|
78
|
+
#expect(EnhancementScreen.mayRequireEnhancement(
|
|
79
|
+
text: "write an email to the team about the outage",
|
|
80
|
+
enhanceTriggersJSON: ""
|
|
81
|
+
))
|
|
82
|
+
#expect(!EnhancementScreen.mayRequireEnhancement(
|
|
83
|
+
text: "meet me at noon by the north entrance",
|
|
84
|
+
enhanceTriggersJSON: ""
|
|
85
|
+
))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test("an empty trigger STRING inside the payload fails closed to may-enhance")
|
|
89
|
+
func emptyTriggerRowFailsClosed() {
|
|
90
|
+
// The CLI's `lower.includes("")` is true for every transcript, so one empty row in the
|
|
91
|
+
// configured list means the helper rewrites everything. The mirror special-cases the row
|
|
92
|
+
// instead of testing it, because Swift's answer for an empty needle depends on which
|
|
93
|
+
// `contains` overload resolves and nothing executes this suite (see EnhancementScreen).
|
|
94
|
+
#expect(EnhancementScreen.mayRequireEnhancement(
|
|
95
|
+
text: "meet me at noon by the north entrance",
|
|
96
|
+
enhanceTriggersJSON: #"[""]"#
|
|
97
|
+
))
|
|
98
|
+
#expect(EnhancementScreen.mayRequireEnhancement(
|
|
99
|
+
text: "meet me at noon by the north entrance",
|
|
100
|
+
enhanceTriggersJSON: #"["rewrite this",""]"#
|
|
101
|
+
))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@Test("undecodable trigger payloads fail closed to may-enhance")
|
|
105
|
+
func malformedTriggersFailClosed() {
|
|
106
|
+
#expect(EnhancementScreen.mayRequireEnhancement(
|
|
107
|
+
text: "meet me at noon by the north entrance",
|
|
108
|
+
enhanceTriggersJSON: "not json"
|
|
109
|
+
))
|
|
110
|
+
#expect(EnhancementScreen.mayRequireEnhancement(
|
|
111
|
+
text: "meet me at noon by the north entrance",
|
|
112
|
+
enhanceTriggersJSON: #"{"triggers":[]}"#
|
|
113
|
+
))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - Paste-before-persistence in auto mode
|
|
118
|
+
|
|
119
|
+
struct AutoModePasteBeforePersistenceTests {
|
|
120
|
+
@Test("auto mode pastes plain dictation ahead of persistence")
|
|
121
|
+
func autoDictationPastesFirst() {
|
|
122
|
+
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
123
|
+
postProcessingMode: PostProcessingMode.auto.rawValue,
|
|
124
|
+
transcript: "meet me at noon by the north entrance",
|
|
125
|
+
hasSelection: false,
|
|
126
|
+
intentDetectionEnabled: true,
|
|
127
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
128
|
+
))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Test("auto mode persists first whenever the helper could rewrite the transcript")
|
|
132
|
+
func autoEnhanceableSpeechPersistsFirst() {
|
|
133
|
+
for text in EnhancementFixtures.mayEnhance {
|
|
134
|
+
#expect(
|
|
135
|
+
!RecordingEngine.shouldPasteBeforePersistence(
|
|
136
|
+
postProcessingMode: PostProcessingMode.auto.rawValue,
|
|
137
|
+
transcript: text,
|
|
138
|
+
hasSelection: false,
|
|
139
|
+
intentDetectionEnabled: true,
|
|
140
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
141
|
+
),
|
|
142
|
+
"expected persist-first: \(text)"
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@Test("auto mode still defers to the intent screens for non-dictation shapes")
|
|
148
|
+
func autoNonDictationPersistsFirst() {
|
|
149
|
+
// Question-shaped speech may become a conversation — persist first.
|
|
150
|
+
#expect(!RecordingEngine.shouldPasteBeforePersistence(
|
|
151
|
+
postProcessingMode: PostProcessingMode.auto.rawValue,
|
|
152
|
+
transcript: "what's the capital of France?",
|
|
153
|
+
hasSelection: false,
|
|
154
|
+
intentDetectionEnabled: true,
|
|
155
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
156
|
+
))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@Test("auto mode fails closed to persist-first on a malformed trigger payload")
|
|
160
|
+
func autoMalformedTriggersPersistFirst() {
|
|
161
|
+
#expect(!RecordingEngine.shouldPasteBeforePersistence(
|
|
162
|
+
postProcessingMode: PostProcessingMode.auto.rawValue,
|
|
163
|
+
transcript: "meet me at noon by the north entrance",
|
|
164
|
+
hasSelection: false,
|
|
165
|
+
intentDetectionEnabled: true,
|
|
166
|
+
enhanceTriggersJSON: "not json"
|
|
167
|
+
))
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@Test("always mode keeps persisting first even for plain dictation")
|
|
171
|
+
func alwaysModePersistsFirst() {
|
|
172
|
+
#expect(!RecordingEngine.shouldPasteBeforePersistence(
|
|
173
|
+
postProcessingMode: PostProcessingMode.always.rawValue,
|
|
174
|
+
transcript: "meet me at noon by the north entrance",
|
|
175
|
+
hasSelection: false,
|
|
176
|
+
intentDetectionEnabled: true,
|
|
177
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
178
|
+
))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
@Test("off mode is unchanged by the trigger payload")
|
|
182
|
+
func offModeUnchanged() {
|
|
183
|
+
#expect(RecordingEngine.shouldPasteBeforePersistence(
|
|
184
|
+
postProcessingMode: PostProcessingMode.off.rawValue,
|
|
185
|
+
transcript: "this is my draft say it better",
|
|
186
|
+
hasSelection: false,
|
|
187
|
+
intentDetectionEnabled: true,
|
|
188
|
+
enhanceTriggersJSON: EnhancementFixtures.defaultTriggersJSON
|
|
189
|
+
))
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -9,8 +9,10 @@ struct MenuBarPresentationTests {
|
|
|
9
9
|
func idle() {
|
|
10
10
|
let presentation = MenuBarPresentation(
|
|
11
11
|
isRecording: false,
|
|
12
|
+
isWarmingUpCapture: false,
|
|
12
13
|
canStartRecording: true,
|
|
13
|
-
statusMessage: "Ready"
|
|
14
|
+
statusMessage: "Ready",
|
|
15
|
+
blockedReason: nil
|
|
14
16
|
)
|
|
15
17
|
#expect(presentation.iconName == "mic.fill")
|
|
16
18
|
#expect(presentation.accessibilityLabel == "Recordings")
|
|
@@ -22,8 +24,10 @@ struct MenuBarPresentationTests {
|
|
|
22
24
|
func recording() {
|
|
23
25
|
let presentation = MenuBarPresentation(
|
|
24
26
|
isRecording: true,
|
|
27
|
+
isWarmingUpCapture: false,
|
|
25
28
|
canStartRecording: false,
|
|
26
|
-
statusMessage: "Recording — release to stop"
|
|
29
|
+
statusMessage: "Recording — release to stop",
|
|
30
|
+
blockedReason: nil
|
|
27
31
|
)
|
|
28
32
|
#expect(presentation.iconName == "waveform")
|
|
29
33
|
#expect(presentation.accessibilityLabel == "Recordings, recording")
|
|
@@ -36,8 +40,10 @@ struct MenuBarPresentationTests {
|
|
|
36
40
|
for status in ["Transcribing...", "Deciding...", "Answering...", "Rewriting...", "Pasting..."] {
|
|
37
41
|
let presentation = MenuBarPresentation(
|
|
38
42
|
isRecording: false,
|
|
43
|
+
isWarmingUpCapture: false,
|
|
39
44
|
canStartRecording: false,
|
|
40
|
-
statusMessage: status
|
|
45
|
+
statusMessage: status,
|
|
46
|
+
blockedReason: nil
|
|
41
47
|
)
|
|
42
48
|
#expect(presentation.iconName == "ellipsis.circle", "expected busy icon for \(status)")
|
|
43
49
|
#expect(!presentation.primaryActionEnabled, "Start must be disabled during \(status)")
|
|
@@ -47,6 +53,80 @@ struct MenuBarPresentationTests {
|
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
|
|
56
|
+
/// The defect this pins: the idle branch discarded `statusMessage` and set `mic.fill` /
|
|
57
|
+
/// "Recordings", and the always-visible menu-bar item renders ONLY the icon and the
|
|
58
|
+
/// accessibility label. So a blocked app was byte-identical to Ready for sighted and
|
|
59
|
+
/// VoiceOver users alike, and "press Cmd-V" lived only in a popover nobody had opened.
|
|
60
|
+
@Test("a blocked idle app is distinguishable from Ready by BOTH icon and label")
|
|
61
|
+
func blockedIsVisible() {
|
|
62
|
+
let reason = "This field blocks typing (secure input) — transcript copied, press Cmd-V"
|
|
63
|
+
let ready = MenuBarPresentation(
|
|
64
|
+
isRecording: false,
|
|
65
|
+
isWarmingUpCapture: false,
|
|
66
|
+
canStartRecording: true,
|
|
67
|
+
statusMessage: "Ready",
|
|
68
|
+
blockedReason: nil
|
|
69
|
+
)
|
|
70
|
+
let blocked = MenuBarPresentation(
|
|
71
|
+
isRecording: false,
|
|
72
|
+
isWarmingUpCapture: false,
|
|
73
|
+
canStartRecording: true,
|
|
74
|
+
statusMessage: "Ready",
|
|
75
|
+
blockedReason: reason
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
#expect(blocked.iconName != ready.iconName, "the icon is the only signal a sighted user gets")
|
|
79
|
+
#expect(
|
|
80
|
+
blocked.accessibilityLabel != ready.accessibilityLabel,
|
|
81
|
+
"the label is the only signal VoiceOver gets"
|
|
82
|
+
)
|
|
83
|
+
#expect(blocked.iconName == MenuBarPresentation.blockedIconName)
|
|
84
|
+
#expect(blocked.accessibilityLabel.contains(reason))
|
|
85
|
+
#expect(blocked.statusText == reason)
|
|
86
|
+
#expect(blocked.isBlocked)
|
|
87
|
+
#expect(!ready.isBlocked)
|
|
88
|
+
// A blocked trigger or a blocked paste does not make Start unavailable, and the
|
|
89
|
+
// presentation must keep matching `canStartRecording` exactly.
|
|
90
|
+
#expect(blocked.primaryActionEnabled)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@Test("an empty blocked reason is not a blocked state")
|
|
94
|
+
func emptyReasonIsNotBlocked() {
|
|
95
|
+
let presentation = MenuBarPresentation(
|
|
96
|
+
isRecording: false,
|
|
97
|
+
isWarmingUpCapture: false,
|
|
98
|
+
canStartRecording: true,
|
|
99
|
+
statusMessage: "Ready",
|
|
100
|
+
blockedReason: ""
|
|
101
|
+
)
|
|
102
|
+
#expect(presentation.iconName == MenuBarPresentation.idleIconName)
|
|
103
|
+
#expect(!presentation.isBlocked)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Test("recording and busy states outrank a blocked reason")
|
|
107
|
+
func liveStatesWin() {
|
|
108
|
+
let recording = MenuBarPresentation(
|
|
109
|
+
isRecording: true,
|
|
110
|
+
isWarmingUpCapture: false,
|
|
111
|
+
canStartRecording: false,
|
|
112
|
+
statusMessage: "Recording",
|
|
113
|
+
blockedReason: "stale reason"
|
|
114
|
+
)
|
|
115
|
+
#expect(recording.iconName == "waveform")
|
|
116
|
+
#expect(!recording.isBlocked)
|
|
117
|
+
|
|
118
|
+
let busy = MenuBarPresentation(
|
|
119
|
+
isRecording: false,
|
|
120
|
+
isWarmingUpCapture: false,
|
|
121
|
+
canStartRecording: false,
|
|
122
|
+
statusMessage: "Transcribing...",
|
|
123
|
+
blockedReason: "stale reason"
|
|
124
|
+
)
|
|
125
|
+
#expect(busy.iconName == "ellipsis.circle")
|
|
126
|
+
#expect(!busy.primaryActionEnabled)
|
|
127
|
+
#expect(!busy.isBlocked)
|
|
128
|
+
}
|
|
129
|
+
|
|
50
130
|
@Test("presentation follows canStartRecording exactly, not isTranscribing alone")
|
|
51
131
|
func presentationTracksTheStartGate() {
|
|
52
132
|
// A pending intent decision keeps canStartRecording false even though
|
|
@@ -54,12 +134,72 @@ struct MenuBarPresentationTests {
|
|
|
54
134
|
// the click. The contract requires busy.
|
|
55
135
|
let deciding = MenuBarPresentation(
|
|
56
136
|
isRecording: false,
|
|
137
|
+
isWarmingUpCapture: false,
|
|
57
138
|
canStartRecording: false,
|
|
58
|
-
statusMessage: "Deciding..."
|
|
139
|
+
statusMessage: "Deciding...",
|
|
140
|
+
blockedReason: nil
|
|
59
141
|
)
|
|
60
142
|
#expect(!deciding.primaryActionEnabled)
|
|
61
143
|
#expect(deciding.statusText == "Deciding")
|
|
62
144
|
}
|
|
145
|
+
|
|
146
|
+
@Test("the warm-up window presents as recording, never as busy or idle")
|
|
147
|
+
func warmUpPresentsAsRecording() {
|
|
148
|
+
// Between `recorder.start()` returning and the first PCM chunk the user is holding the
|
|
149
|
+
// key. `isRecording` is false there — no audio exists yet — so without this branch the
|
|
150
|
+
// glyph drops to the busy ellipsis for ~100 ms in the middle of a hold.
|
|
151
|
+
let presentation = MenuBarPresentation(
|
|
152
|
+
isRecording: false,
|
|
153
|
+
isWarmingUpCapture: true,
|
|
154
|
+
canStartRecording: false,
|
|
155
|
+
statusMessage: "Recording — release to stop",
|
|
156
|
+
blockedReason: nil
|
|
157
|
+
)
|
|
158
|
+
#expect(presentation.iconName == "waveform")
|
|
159
|
+
#expect(presentation.accessibilityLabel == "Recordings, recording")
|
|
160
|
+
#expect(presentation.statusText == "Recording")
|
|
161
|
+
#expect(presentation.primaryActionEnabled, "Stop must be live during warm-up")
|
|
162
|
+
#expect(!presentation.isBlocked)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@Test("an attempt that captured nothing is disclosed on the glyph, not just in a status line")
|
|
166
|
+
func emptyAttemptIsDisclosedOnTheGlyph() {
|
|
167
|
+
// Recordings is LSUIElement: a status line behind a popover click is not feedback. The
|
|
168
|
+
// engine routes these through `blockedReason`, so they must reach the always-visible
|
|
169
|
+
// icon and the VoiceOver label, and must not look like plain idle.
|
|
170
|
+
for alert in [RecordingAttemptAlert.releasedBeforeAudio, .noAudioCaptured] {
|
|
171
|
+
let presentation = MenuBarPresentation(
|
|
172
|
+
isRecording: false,
|
|
173
|
+
isWarmingUpCapture: false,
|
|
174
|
+
canStartRecording: true,
|
|
175
|
+
statusMessage: "Ready",
|
|
176
|
+
blockedReason: alert.message
|
|
177
|
+
)
|
|
178
|
+
#expect(presentation.iconName == MenuBarPresentation.blockedIconName, "expected the blocked glyph for \(alert)")
|
|
179
|
+
#expect(presentation.iconName != MenuBarPresentation.idleIconName)
|
|
180
|
+
#expect(presentation.statusText == alert.message)
|
|
181
|
+
#expect(presentation.accessibilityLabel == "Recordings, blocked: \(alert.message)")
|
|
182
|
+
#expect(presentation.isBlocked)
|
|
183
|
+
#expect(presentation.primaryActionEnabled, "the attempt is over — Start stays available")
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
@Test("a hold in progress outranks a still-visible empty-attempt disclosure")
|
|
188
|
+
func recordingOutranksADisclosure() {
|
|
189
|
+
// `blockedReason` is cleared by the next `startRecording`, but the warm-up branch must
|
|
190
|
+
// win regardless: showing "released too soon" over a live hold would be a lie.
|
|
191
|
+
for warmingUp in [true, false] {
|
|
192
|
+
let presentation = MenuBarPresentation(
|
|
193
|
+
isRecording: !warmingUp,
|
|
194
|
+
isWarmingUpCapture: warmingUp,
|
|
195
|
+
canStartRecording: false,
|
|
196
|
+
statusMessage: "Recording — release to stop",
|
|
197
|
+
blockedReason: RecordingAttemptAlert.releasedBeforeAudio.message
|
|
198
|
+
)
|
|
199
|
+
#expect(presentation.iconName == "waveform")
|
|
200
|
+
#expect(!presentation.isBlocked)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
63
203
|
}
|
|
64
204
|
|
|
65
205
|
/// Reduce Transparency surface contract: chrome must resolve to an opaque surface — never
|