@hasna/recordings 0.2.10 → 0.2.13

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 (167) hide show
  1. package/README.md +350 -35
  2. package/bun.lock +3 -0
  3. package/dist/__tests__/helpers/native-fs-guard.d.ts +2 -0
  4. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +1 -0
  5. package/dist/cli/index.js +1728 -320
  6. package/dist/cli/macos-permissions.d.ts +13 -0
  7. package/dist/cli/macos-permissions.d.ts.map +1 -0
  8. package/dist/cli/options.d.ts +12 -0
  9. package/dist/cli/options.d.ts.map +1 -1
  10. package/dist/db/pg-migrations.d.ts.map +1 -1
  11. package/dist/db/recordings.d.ts +2 -1
  12. package/dist/db/recordings.d.ts.map +1 -1
  13. package/dist/db/remote-storage.d.ts +5 -1
  14. package/dist/db/remote-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +628 -147
  18. package/dist/lib/bun-runtime.d.ts +11 -0
  19. package/dist/lib/bun-runtime.d.ts.map +1 -0
  20. package/dist/lib/config.d.ts +8 -1
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/enhancer.d.ts +10 -2
  23. package/dist/lib/enhancer.d.ts.map +1 -1
  24. package/dist/lib/install-maintenance.d.ts +24 -0
  25. package/dist/lib/install-maintenance.d.ts.map +1 -0
  26. package/dist/lib/machine.d.ts +2 -0
  27. package/dist/lib/machine.d.ts.map +1 -0
  28. package/dist/lib/recorder.d.ts +2 -1
  29. package/dist/lib/recorder.d.ts.map +1 -1
  30. package/dist/lib/recording-create-identity.d.ts +13 -0
  31. package/dist/lib/recording-create-identity.d.ts.map +1 -0
  32. package/dist/lib/release-install-policy.d.ts +31 -0
  33. package/dist/lib/release-install-policy.d.ts.map +1 -0
  34. package/dist/lib/transcriber.d.ts.map +1 -1
  35. package/dist/mcp/index.d.ts.map +1 -1
  36. package/dist/mcp/index.js +977 -194
  37. package/dist/sdk/index.js +5 -1
  38. package/dist/sdk/v1.generated.d.ts +4 -0
  39. package/dist/sdk/v1.generated.d.ts.map +1 -1
  40. package/dist/server/cloud-config.d.ts +9 -0
  41. package/dist/server/cloud-config.d.ts.map +1 -0
  42. package/dist/server/cloud-readiness.d.ts +10 -0
  43. package/dist/server/cloud-readiness.d.ts.map +1 -0
  44. package/dist/server/cloud.d.ts +9 -18
  45. package/dist/server/cloud.d.ts.map +1 -1
  46. package/dist/server/index.js +1654 -304
  47. package/dist/server/migrate-command.d.ts +11 -0
  48. package/dist/server/migrate-command.d.ts.map +1 -0
  49. package/dist/server/openapi.d.ts +22 -0
  50. package/dist/server/openapi.d.ts.map +1 -1
  51. package/dist/server/repo.d.ts +8 -1
  52. package/dist/server/repo.d.ts.map +1 -1
  53. package/dist/server/serve.d.ts +6 -1
  54. package/dist/server/serve.d.ts.map +1 -1
  55. package/dist/server/v1.d.ts.map +1 -1
  56. package/dist/storage.js +515 -80
  57. package/dist/store.d.ts +3 -1
  58. package/dist/store.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +10 -0
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/version.d.ts +1 -1
  62. package/package.json +7 -4
  63. package/packaging/macos/Empty.entitlements +5 -0
  64. package/packaging/macos/Library/LaunchDaemons/com.hasna.recordings.updater.plist +34 -0
  65. package/packaging/macos/Verifier.entitlements +10 -0
  66. package/packaging/macos/artifact-verifier.sb +33 -0
  67. package/packaging/macos/build_release_pkg.sh +872 -0
  68. package/packaging/macos/managed_bootstrap.sh +623 -0
  69. package/packaging/macos/pkgutil_fingerprint.awk +37 -0
  70. package/packaging/macos/release_lifecycle.ts +463 -0
  71. package/packaging/macos/scripts/postinstall +565 -0
  72. package/packaging/macos/scripts/preinstall +297 -0
  73. package/scripts/build_companion_cli.sh +337 -0
  74. package/scripts/build_native_fs_guard.sh +59 -0
  75. package/scripts/generate-sdk.ts +19 -1
  76. package/scripts/install_macos_app.sh +1922 -77
  77. package/scripts/macos_artifact.ts +5316 -0
  78. package/scripts/migrate.ts +38 -10
  79. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  80. package/scripts/native/recordings_fs_guard.c +1167 -0
  81. package/scripts/native_fs_guard.ts +158 -0
  82. package/scripts/release-guard.ts +20 -1
  83. package/scripts/resolve_tailscale_cli.sh +389 -0
  84. package/scripts/smoke_macos_app.sh +573 -0
  85. package/src/native/Recordings/App/ContentView.swift +83 -0
  86. package/src/native/Recordings/App/MenuBarStatusView.swift +102 -0
  87. package/src/native/Recordings/App/RecordWorkspaceView.swift +468 -0
  88. package/src/native/Recordings/App/RecordingDetailView.swift +138 -0
  89. package/src/native/Recordings/App/RecordingsApp.swift +367 -44
  90. package/src/native/Recordings/App/RecordingsListView.swift +130 -0
  91. package/src/native/Recordings/App/RecordingsStore.swift +206 -0
  92. package/src/native/Recordings/App/RuntimeSmoke.swift +158 -0
  93. package/src/native/Recordings/App/SidebarView.swift +212 -0
  94. package/src/native/Recordings/App/Theme.swift +87 -0
  95. package/src/native/Recordings/Package.resolved +5 -5
  96. package/src/native/Recordings/Package.swift +62 -2
  97. package/src/native/Recordings/RecordingsLib/AccessibilityPromptGate.swift +104 -0
  98. package/src/native/Recordings/RecordingsLib/BrandAssets.swift +0 -32
  99. package/src/native/Recordings/RecordingsLib/ChromeSurface.swift +17 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +7 -3
  101. package/src/native/Recordings/RecordingsLib/MenuBarPresentation.swift +37 -0
  102. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +32 -2
  103. package/src/native/Recordings/RecordingsLib/NativeMachineIdentity.swift +24 -0
  104. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +262 -23
  105. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +56 -0
  106. package/src/native/Recordings/RecordingsLib/PermissionRequestLaunchPlan.swift +56 -0
  107. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +321 -25
  108. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +605 -90
  109. package/src/native/Recordings/RecordingsLib/Recording.swift +141 -0
  110. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +3704 -376
  111. package/src/native/Recordings/RecordingsLib/RecordingStartControlPresentation.swift +29 -0
  112. package/src/native/Recordings/RecordingsLib/RecordingsCLI.entitlements +10 -0
  113. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +222 -0
  114. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -11
  115. package/src/native/Recordings/RecordingsLib/SpeechIntent.swift +331 -0
  116. package/src/native/Recordings/RecordingsLib/SpeechIntentClassifier.swift +232 -0
  117. package/src/native/Recordings/RecordingsLib/VoiceShortcuts.swift +17 -7
  118. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +1343 -0
  119. package/src/native/Recordings/RecordingsTests/MenuBarPresentationTests.swift +98 -0
  120. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +20 -0
  121. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +370 -1
  122. package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +987 -1
  123. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +385 -0
  124. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +835 -8
  125. package/src/native/Recordings/RecordingsTests/RecordingBridgeTests.swift +180 -0
  126. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +760 -0
  127. package/src/native/Recordings/RecordingsTests/RecordingStartControlPresentationTests.swift +42 -0
  128. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +264 -0
  129. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +129 -0
  130. package/src/native/Recordings/RecordingsTests/SpeechIntentTests.swift +600 -0
  131. package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +62 -1
  132. package/src/native/Recordings/RecordingsTests/TranscriptionResultIdentityTests.swift +21 -0
  133. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +739 -0
  134. package/src/native/Recordings/Updater/Broker/ActivationRecoveryPolicy.swift +254 -0
  135. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +463 -0
  136. package/src/native/Recordings/Updater/Broker/ApplicationProcessQuiescence.swift +55 -0
  137. package/src/native/Recordings/Updater/Broker/ArtifactIngest.swift +341 -0
  138. package/src/native/Recordings/Updater/Broker/AtomicActivation.swift +477 -0
  139. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +624 -0
  140. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +63 -0
  141. package/src/native/Recordings/Updater/Broker/CodeValidation.swift +561 -0
  142. package/src/native/Recordings/Updater/Broker/DarwinACLValidator.swift +66 -0
  143. package/src/native/Recordings/Updater/Broker/HostOSProductVersion.swift +50 -0
  144. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +362 -0
  145. package/src/native/Recordings/Updater/Broker/InstallRecovery.swift +662 -0
  146. package/src/native/Recordings/Updater/Broker/MonotonicState.swift +456 -0
  147. package/src/native/Recordings/Updater/Broker/PeerIdentity.swift +274 -0
  148. package/src/native/Recordings/Updater/Broker/VerifierRunner.swift +81 -0
  149. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +259 -0
  150. package/src/native/Recordings/Updater/BrokerTests/CanonicalReleaseOrderTests.swift +21 -0
  151. package/src/native/Recordings/Updater/Client/ClientMain.swift +119 -0
  152. package/src/native/Recordings/Updater/Protocol/BoundedProcess.swift +159 -0
  153. package/src/native/Recordings/Updater/Protocol/CandidateMetadataPolicy.swift +214 -0
  154. package/src/native/Recordings/Updater/Protocol/HostOSVersionPolicy.swift +55 -0
  155. package/src/native/Recordings/Updater/Protocol/MonotonicReleasePolicy.swift +152 -0
  156. package/src/native/Recordings/Updater/Protocol/ReleaseEnvelope.swift +229 -0
  157. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +119 -0
  158. package/src/native/Recordings/Updater/ProtocolTests/BoundedProcessRunnerTests.swift +60 -0
  159. package/src/native/Recordings/Updater/ProtocolTests/CandidateMetadataPolicyTests.swift +168 -0
  160. package/src/native/Recordings/Updater/ProtocolTests/HostOSVersionPolicyTests.swift +62 -0
  161. package/src/native/Recordings/Updater/ProtocolTests/MonotonicReleasePolicyTests.swift +172 -0
  162. package/src/native/Recordings/Updater/ProtocolTests/ReleaseEnvelopeValidationTests.swift +65 -0
  163. package/src/native/Recordings/Updater/Signer/SignerMain.swift +210 -0
  164. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +725 -0
  165. package/src/native/Recordings/Updater/VerifierLauncher/include/RecordingsVerifierLauncher.h +33 -0
  166. package/src/native/Recordings/build.sh +1990 -29
  167. package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +0 -380
@@ -1,5 +1,34 @@
1
1
  import Foundation
2
2
 
3
+ public enum NativeErrorSanitizer {
4
+ private static let replacements: [(pattern: String, template: String)] = [
5
+ (#"(?i)\b(?:sk|sess)-[A-Za-z0-9_-]{8,}\b"#, "[REDACTED]"),
6
+ (#"(?i)(\bBearer\s+)[^\s,;]+"#, "$1[REDACTED]"),
7
+ (#"(?i)(\bAuthorization\s*:\s*(?:Basic|Digest)\s+)[^;\n]+"#, "$1[REDACTED]"),
8
+ (#"(?i)(\b[A-Z0-9_]*(?:(?:API|SECRET|PRIVATE)[_-]?KEY|SECRET[_-]?ACCESS[_-]?KEY|ACCESS[_-]?TOKEN|AUTH[_-]?TOKEN|CLIENT[_-]?(?:SECRET|PASSWORD)|PASSWORD|PASSCODE|SECRET|PRIVATE)\s*=\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,;\n}]+)"#, "$1[REDACTED]"),
9
+ (#"(?i)((?:[{,]\s*)"?(?:(?:api|secret|private)[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?(?:secret|password)|password|passcode|token|secret|private)"?\s*:\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;}]+)"#, "$1[REDACTED]"),
10
+ (#"(?i)((?:[?&]|\b)(?:(?:api|secret|private)[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?(?:secret|password)|password|passcode|token|secret|private)=)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^&\s,;]+)"#, "$1[REDACTED]"),
11
+ (#"(?i)((?:api\s+key(?:\s+provided)?|client\s+(?:secret|password)|password|passcode|private\s+key|secret(?:\s+key)?)\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,;\n}]+)"#, "$1[REDACTED]"),
12
+ // Bare "private:" labels a value only at the start of a clause; mid-sentence uses
13
+ // like "resource is private: access denied" are ordinary error prose and stay.
14
+ (#"(?i)((?:^|[;,{\n])\s*)(private\s*[:=]\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^,;\n}]+)"#, "$1$2[REDACTED]"),
15
+ ]
16
+
17
+ public static func sanitize(_ message: String) -> String {
18
+ replacements.reduce(message) { result, replacement in
19
+ guard let expression = try? NSRegularExpression(pattern: replacement.pattern) else {
20
+ return result
21
+ }
22
+ let range = NSRange(result.startIndex..<result.endIndex, in: result)
23
+ return expression.stringByReplacingMatches(
24
+ in: result,
25
+ range: range,
26
+ withTemplate: replacement.template
27
+ )
28
+ }
29
+ }
30
+ }
31
+
3
32
  public enum NativeAppLog {
4
33
  private static let lock = NSLock()
5
34
 
@@ -9,7 +38,7 @@ public enum NativeAppLog {
9
38
 
10
39
  let dir = "\(homePath)/.hasna/recordings"
11
40
  let path = "\(dir)/Recordings.log"
12
- let line = "[\(Self.timestamp())] \(message)\n"
41
+ let line = "[\(Self.timestamp())] \(NativeErrorSanitizer.sanitize(message))\n"
13
42
 
14
43
  do {
15
44
  try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
@@ -24,7 +53,8 @@ public enum NativeAppLog {
24
53
  try line.write(toFile: path, atomically: true, encoding: .utf8)
25
54
  }
26
55
  } catch {
27
- fputs("[Recordings] log write failed: \(error.localizedDescription)\n", stderr)
56
+ let safeError = NativeErrorSanitizer.sanitize(error.localizedDescription)
57
+ fputs("[Recordings] log write failed: \(safeError)\n", stderr)
28
58
  }
29
59
  }
30
60
 
@@ -0,0 +1,24 @@
1
+ import Darwin
2
+ import Foundation
3
+
4
+ public enum NativeMachineIdentity {
5
+ public static func current(
6
+ environment: [String: String] = ProcessInfo.processInfo.environment,
7
+ hostName: String? = nil
8
+ ) -> String {
9
+ let configured = environment["HASNA_MACHINE_ID"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
10
+ if !configured.isEmpty { return configured }
11
+ return (hostName ?? posixHostName()).trimmingCharacters(in: .whitespacesAndNewlines)
12
+ }
13
+
14
+ static func posixHostName() -> String {
15
+ var buffer = [CChar](repeating: 0, count: 256)
16
+ let result = buffer.withUnsafeMutableBufferPointer { pointer -> String? in
17
+ guard let baseAddress = pointer.baseAddress,
18
+ gethostname(baseAddress, pointer.count) == 0 else { return nil }
19
+ pointer[pointer.count - 1] = 0
20
+ return String(cString: baseAddress)
21
+ }
22
+ return result ?? ProcessInfo.processInfo.hostName
23
+ }
24
+ }
@@ -2,6 +2,7 @@
2
2
  import Foundation
3
3
 
4
4
  enum NativePCMRecorderError: LocalizedError {
5
+ case alreadyActive
5
6
  case noInputDevice
6
7
  case unsupportedInputFormat
7
8
  case failedToCreateConverter
@@ -9,6 +10,8 @@ enum NativePCMRecorderError: LocalizedError {
9
10
 
10
11
  var errorDescription: String? {
11
12
  switch self {
13
+ case .alreadyActive:
14
+ return "Microphone capture is already starting, running, or stopping"
12
15
  case .noInputDevice:
13
16
  return "No microphone input device is available"
14
17
  case .unsupportedInputFormat:
@@ -22,86 +25,300 @@ enum NativePCMRecorderError: LocalizedError {
22
25
  }
23
26
 
24
27
  final class NativePCMRecorder: @unchecked Sendable {
28
+ private enum LifecycleState: Equatable {
29
+ case idle
30
+ case starting
31
+ case running
32
+ case stopping
33
+ }
34
+
25
35
  private let engine = AVAudioEngine()
26
36
  private let onPCM: @Sendable (Data) -> Void
37
+ private let lifecycle = NSCondition()
38
+ private let conversionLock = NSLock()
39
+ private let deliveryQueue = DispatchQueue(label: "com.hasna.recordings.native-pcm-delivery")
40
+ private let deliveryQueueKey = DispatchSpecificKey<UInt8>()
41
+ private let stopWorkQueue = DispatchQueue(label: "com.hasna.recordings.native-pcm-stop")
42
+ private let stopCaptureForTesting: (@Sendable () -> Void)?
43
+ private let startCaptureForTesting: (@Sendable () throws -> Void)?
44
+ private let onCallbackAdmittedForTesting: (@Sendable () -> Void)?
45
+ private let finalizeConverter: @Sendable (AVAudioConverter, AVAudioFormat) -> [Data]
27
46
  private var converter: AVAudioConverter?
28
47
  private var inputFormat: AVAudioFormat?
29
48
  private var outputFormat: AVAudioFormat?
30
- private var started = false
49
+ private var state = LifecycleState.idle
50
+ private var acceptingCallbacks = false
51
+ private var inFlightCallbacks = 0
31
52
 
32
53
  init(onPCM: @escaping @Sendable (Data) -> Void) {
33
54
  self.onPCM = onPCM
55
+ self.stopCaptureForTesting = nil
56
+ self.startCaptureForTesting = nil
57
+ self.onCallbackAdmittedForTesting = nil
58
+ self.finalizeConverter = Self.finalizeConverterTail
59
+ deliveryQueue.setSpecific(key: deliveryQueueKey, value: 1)
60
+ }
61
+
62
+ init(
63
+ testingInputFormat: AVAudioFormat,
64
+ outputFormat: AVAudioFormat,
65
+ converter: AVAudioConverter,
66
+ stopCapture: @escaping @Sendable () -> Void,
67
+ startCapture: (@Sendable () throws -> Void)? = nil,
68
+ onCallbackAdmitted: @escaping @Sendable () -> Void,
69
+ finalizeConverter: @escaping @Sendable (AVAudioConverter, AVAudioFormat) -> [Data],
70
+ startsRunning: Bool = true,
71
+ onPCM: @escaping @Sendable (Data) -> Void
72
+ ) {
73
+ self.onPCM = onPCM
74
+ self.stopCaptureForTesting = stopCapture
75
+ self.startCaptureForTesting = startCapture
76
+ self.onCallbackAdmittedForTesting = onCallbackAdmitted
77
+ self.finalizeConverter = finalizeConverter
78
+ self.converter = converter
79
+ self.inputFormat = testingInputFormat
80
+ self.outputFormat = outputFormat
81
+ self.state = startsRunning ? .running : .idle
82
+ self.acceptingCallbacks = startsRunning
83
+ deliveryQueue.setSpecific(key: deliveryQueueKey, value: 1)
34
84
  }
35
85
 
36
86
  func start() throws {
37
- guard !started else { return }
87
+ try reserveStart()
88
+
89
+ if let startCaptureForTesting {
90
+ do {
91
+ try startCaptureForTesting()
92
+ finishStart()
93
+ } catch {
94
+ resetAfterStartFailure()
95
+ throw NativePCMRecorderError.failedToStart(error.localizedDescription)
96
+ }
97
+ return
98
+ }
38
99
 
39
100
  let inputNode = engine.inputNode
40
101
  let inputFormat = inputNode.inputFormat(forBus: 0)
41
102
  guard inputFormat.channelCount > 0 else {
103
+ abandonStart()
42
104
  throw NativePCMRecorderError.noInputDevice
43
105
  }
44
106
  guard inputFormat.sampleRate > 0 else {
107
+ abandonStart()
45
108
  throw NativePCMRecorderError.unsupportedInputFormat
46
109
  }
47
110
  guard let outputFormat = Self.realtimeOutputFormat() else {
111
+ abandonStart()
48
112
  throw NativePCMRecorderError.unsupportedInputFormat
49
113
  }
50
114
  guard let converter = AVAudioConverter(from: inputFormat, to: outputFormat) else {
115
+ abandonStart()
51
116
  throw NativePCMRecorderError.failedToCreateConverter
52
117
  }
53
118
 
119
+ lifecycle.lock()
54
120
  self.converter = converter
55
121
  self.inputFormat = inputFormat
56
122
  self.outputFormat = outputFormat
123
+ lifecycle.unlock()
57
124
 
58
125
  inputNode.installTap(onBus: 0, bufferSize: 1_024, format: inputFormat) { [weak self] buffer, _ in
59
- self?.convertAndEmit(buffer)
126
+ self?.processInputBuffer(buffer)
60
127
  }
61
128
 
62
129
  do {
63
130
  engine.prepare()
64
131
  try engine.start()
65
- started = true
132
+ finishStart()
66
133
  } catch {
67
134
  inputNode.removeTap(onBus: 0)
68
- self.converter = nil
69
- self.inputFormat = nil
70
- self.outputFormat = nil
135
+ resetAfterStartFailure()
71
136
  throw NativePCMRecorderError.failedToStart(error.localizedDescription)
72
137
  }
73
138
  }
74
139
 
75
- func stop() {
76
- guard started else {
77
- converter = nil
78
- inputFormat = nil
79
- outputFormat = nil
80
- return
140
+ private func reserveStart() throws {
141
+ lifecycle.lock()
142
+ guard state == .idle else {
143
+ lifecycle.unlock()
144
+ throw NativePCMRecorderError.alreadyActive
81
145
  }
146
+ state = .starting
147
+ lifecycle.unlock()
148
+ }
82
149
 
83
- engine.inputNode.removeTap(onBus: 0)
84
- engine.stop()
150
+ private func finishStart() {
151
+ lifecycle.lock()
152
+ acceptingCallbacks = true
153
+ state = .running
154
+ lifecycle.broadcast()
155
+ lifecycle.unlock()
156
+ }
157
+
158
+ private func resetAfterStartFailure() {
159
+ lifecycle.lock()
85
160
  converter = nil
86
161
  inputFormat = nil
87
162
  outputFormat = nil
88
- started = false
163
+ state = .idle
164
+ acceptingCallbacks = false
165
+ lifecycle.broadcast()
166
+ lifecycle.unlock()
167
+ }
168
+
169
+ func stop() {
170
+ let isReentrantDelivery = DispatchQueue.getSpecific(key: deliveryQueueKey) != nil
171
+ lifecycle.lock()
172
+ while state == .starting || (state == .stopping && !isReentrantDelivery) {
173
+ lifecycle.wait()
174
+ }
175
+ guard state == .running else {
176
+ lifecycle.unlock()
177
+ return
178
+ }
179
+ acceptingCallbacks = false
180
+ state = .stopping
181
+ lifecycle.unlock()
182
+
183
+ if isReentrantDelivery {
184
+ stopWorkQueue.async { [self] in
185
+ stopCaptureAndFinish()
186
+ }
187
+ } else {
188
+ stopCaptureAndFinish()
189
+ }
190
+ }
191
+
192
+ private func stopCaptureAndFinish() {
193
+ if let stopCaptureForTesting {
194
+ stopCaptureForTesting()
195
+ } else {
196
+ engine.inputNode.removeTap(onBus: 0)
197
+ engine.stop()
198
+ }
199
+
200
+ lifecycle.lock()
201
+ while inFlightCallbacks > 0 {
202
+ lifecycle.wait()
203
+ }
204
+ let converter = converter
205
+ let outputFormat = outputFormat
206
+ lifecycle.unlock()
207
+
208
+ if let converter, let outputFormat {
209
+ conversionLock.lock()
210
+ let tailChunks = finalizeConverter(converter, outputFormat)
211
+ conversionLock.unlock()
212
+ for data in tailChunks where !data.isEmpty {
213
+ deliverPCM(data)
214
+ }
215
+ }
216
+
217
+ lifecycle.lock()
218
+ self.converter = nil
219
+ inputFormat = nil
220
+ self.outputFormat = nil
221
+ state = .idle
222
+ lifecycle.broadcast()
223
+ lifecycle.unlock()
89
224
  }
90
225
 
91
226
  deinit {
92
227
  stop()
93
228
  }
94
229
 
95
- private func convertAndEmit(_ inputBuffer: AVAudioPCMBuffer) {
96
- guard let converter, let inputFormat, let outputFormat else { return }
230
+ private func abandonStart() {
231
+ lifecycle.lock()
232
+ state = .idle
233
+ acceptingCallbacks = false
234
+ lifecycle.broadcast()
235
+ lifecycle.unlock()
236
+ }
97
237
 
238
+ func processInputBufferForTesting(_ inputBuffer: AVAudioPCMBuffer) {
239
+ processInputBuffer(inputBuffer)
240
+ }
241
+
242
+ func deliverPCMForTesting(_ data: Data) {
243
+ lifecycle.lock()
244
+ guard acceptingCallbacks else {
245
+ lifecycle.unlock()
246
+ return
247
+ }
248
+ inFlightCallbacks += 1
249
+ lifecycle.unlock()
250
+
251
+ defer { finishCallback() }
252
+ deliverPCM(data)
253
+ }
254
+
255
+ var isIdleForTesting: Bool {
256
+ lifecycle.lock()
257
+ defer { lifecycle.unlock() }
258
+ return state == .idle
259
+ }
260
+
261
+ private func processInputBuffer(_ inputBuffer: AVAudioPCMBuffer) {
262
+ lifecycle.lock()
263
+ guard acceptingCallbacks,
264
+ let converter,
265
+ let inputFormat,
266
+ let outputFormat else {
267
+ lifecycle.unlock()
268
+ return
269
+ }
270
+ inFlightCallbacks += 1
271
+ lifecycle.unlock()
272
+
273
+ onCallbackAdmittedForTesting?()
274
+
275
+ defer { finishCallback() }
276
+
277
+ conversionLock.lock()
278
+ let data = convert(
279
+ inputBuffer,
280
+ converter: converter,
281
+ inputFormat: inputFormat,
282
+ outputFormat: outputFormat
283
+ )
284
+ conversionLock.unlock()
285
+ if !data.isEmpty {
286
+ deliverPCM(data)
287
+ }
288
+ }
289
+
290
+ private func deliverPCM(_ data: Data) {
291
+ if DispatchQueue.getSpecific(key: deliveryQueueKey) != nil {
292
+ onPCM(data)
293
+ } else {
294
+ deliveryQueue.sync {
295
+ onPCM(data)
296
+ }
297
+ }
298
+ }
299
+
300
+ private func finishCallback() {
301
+ lifecycle.lock()
302
+ inFlightCallbacks -= 1
303
+ if inFlightCallbacks == 0 {
304
+ lifecycle.broadcast()
305
+ }
306
+ lifecycle.unlock()
307
+ }
308
+
309
+ private func convert(
310
+ _ inputBuffer: AVAudioPCMBuffer,
311
+ converter: AVAudioConverter,
312
+ inputFormat: AVAudioFormat,
313
+ outputFormat: AVAudioFormat
314
+ ) -> Data {
98
315
  let sampleRateRatio = outputFormat.sampleRate / inputFormat.sampleRate
99
316
  let estimatedFrames = AVAudioFrameCount(Double(inputBuffer.frameLength) * sampleRateRatio) + 512
100
317
  guard let outputBuffer = AVAudioPCMBuffer(
101
318
  pcmFormat: outputFormat,
102
319
  frameCapacity: max(estimatedFrames, 1)
103
320
  ) else {
104
- return
321
+ return Data()
105
322
  }
106
323
 
107
324
  let inputSource = AudioConverterInputSource(buffer: inputBuffer)
@@ -110,11 +327,33 @@ final class NativePCMRecorder: @unchecked Sendable {
110
327
  inputSource.next(status: status)
111
328
  }
112
329
 
113
- guard conversionError == nil else { return }
114
- let data = Self.extractPCM16Data(from: outputBuffer)
115
- if !data.isEmpty {
116
- onPCM(data)
330
+ guard conversionError == nil else { return Data() }
331
+ return Self.extractPCM16Data(from: outputBuffer)
332
+ }
333
+
334
+ static func finalizeConverterTail(_ converter: AVAudioConverter, outputFormat: AVAudioFormat) -> [Data] {
335
+ var chunks: [Data] = []
336
+ while let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: 4_096) {
337
+ var conversionError: NSError?
338
+ let status = converter.convert(to: outputBuffer, error: &conversionError) { _, inputStatus in
339
+ inputStatus.pointee = .endOfStream
340
+ return nil
341
+ }
342
+ guard conversionError == nil else { break }
343
+ let data = extractPCM16Data(from: outputBuffer)
344
+ if !data.isEmpty {
345
+ chunks.append(data)
346
+ }
347
+ switch status {
348
+ case .haveData:
349
+ guard !data.isEmpty else { return chunks }
350
+ case .inputRanDry, .endOfStream, .error:
351
+ return chunks
352
+ @unknown default:
353
+ return chunks
354
+ }
117
355
  }
356
+ return chunks
118
357
  }
119
358
 
120
359
  static func realtimeOutputFormat() -> AVAudioFormat? {
@@ -1,5 +1,15 @@
1
1
  import Foundation
2
2
 
3
+ struct ProcessingModelSelection: Equatable, Sendable {
4
+ let transcriptionPrompt: String
5
+ let transcriptionModel: String
6
+ let transcriberModel: String
7
+ let enhancementModel: String
8
+ let intentModel: String
9
+ let enhanceTriggersJSON: String
10
+ let keywordTransformsJSON: String
11
+ }
12
+
3
13
  enum OpenAIAPIKeyStore {
4
14
  static let defaultLanguage = "en"
5
15
 
@@ -40,6 +50,52 @@ enum OpenAIAPIKeyStore {
40
50
  return defaultLanguage
41
51
  }
42
52
 
53
+ static func loadProcessingModelSelection(
54
+ homePath: String,
55
+ environment: [String: String] = ProcessInfo.processInfo.environment
56
+ ) -> ProcessingModelSelection {
57
+ let json = loadMutableConfig(homePath: homePath)
58
+ let enhancementModel = firstNonEmpty(
59
+ environment["RECORDINGS_ENHANCEMENT_MODEL"],
60
+ json["enhancement_model"] as? String
61
+ ) ?? "gpt-4o"
62
+ let transcriberModel = firstNonEmpty(
63
+ environment["RECORDINGS_TRANSCRIBER_MODEL"],
64
+ json["transcriber_model"] as? String,
65
+ enhancementModel
66
+ ) ?? enhancementModel
67
+ let transcriptionModel = firstNonEmpty(
68
+ environment["RECORDINGS_MODEL"],
69
+ json["transcription_model"] as? String
70
+ ) ?? "gpt-4o-transcribe"
71
+ let intentModel = firstNonEmpty(
72
+ environment["RECORDINGS_INTENT_MODEL"],
73
+ json["intent_model"] as? String
74
+ ) ?? "gpt-4o-mini"
75
+ let triggers = (json["enhance_triggers"] as? [String]) ?? [
76
+ "say it better", "rewrite this", "make it sound", "clean this up",
77
+ "fix this", "rephrase", "write it properly", "make it professional",
78
+ "improve this", "polish this",
79
+ ]
80
+ let triggerData = try? JSONSerialization.data(withJSONObject: triggers)
81
+ let triggerJSON = triggerData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
82
+ let keywordTransforms = (json["keyword_transforms"] as? [String: String]) ?? [:]
83
+ let transformData = try? JSONSerialization.data(withJSONObject: keywordTransforms)
84
+ let transformJSON = transformData.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
85
+ return ProcessingModelSelection(
86
+ transcriptionPrompt: firstNonEmpty(
87
+ environment["RECORDINGS_TRANSCRIPTION_PROMPT"],
88
+ json["transcription_prompt"] as? String
89
+ ) ?? "",
90
+ transcriptionModel: transcriptionModel,
91
+ transcriberModel: transcriberModel,
92
+ enhancementModel: enhancementModel,
93
+ intentModel: intentModel,
94
+ enhanceTriggersJSON: triggerJSON,
95
+ keywordTransformsJSON: transformJSON
96
+ )
97
+ }
98
+
43
99
  /// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
44
100
  /// shells out to for final transcription) uses the same key as the app itself.
45
101
  static func save(key: String, homePath: String) throws {
@@ -0,0 +1,56 @@
1
+ import Foundation
2
+
3
+ public struct PermissionRequestLaunchPlan: Sendable, Equatable {
4
+ public let isHelper: Bool
5
+ public let opensPermissionSettings: Bool
6
+ public let runtimeSmokeMode: String?
7
+ public let runtimeSmokeOutputPath: String?
8
+ public let runtimeSmokeAcknowledgementPath: String?
9
+ public let runtimeSmokeCompletionPath: String?
10
+
11
+ public var isRuntimeSmoke: Bool { runtimeSmokeMode != nil }
12
+ public var installsGlobalHandlers: Bool { !isHelper && !isRuntimeSmoke }
13
+ public var declaresMainWindow: Bool { !isHelper && !isRuntimeSmoke }
14
+ public var declaresMenuBar: Bool {
15
+ if isRuntimeSmoke { return runtimeSmokeMode == "normal" }
16
+ return !isHelper
17
+ }
18
+ public var terminatesAfterHandling: Bool { isHelper }
19
+ public var requestsAccessibilityPrompt: Bool { isHelper && !isRuntimeSmoke }
20
+
21
+ public init(arguments: [String]) {
22
+ isHelper = arguments.contains("--request-permissions")
23
+ opensPermissionSettings = isHelper && arguments.contains("--open-permission-settings")
24
+ runtimeSmokeMode = Self.optionValue("--runtime-smoke", arguments: arguments)
25
+ runtimeSmokeOutputPath = Self.optionValue("--runtime-smoke-output", arguments: arguments)
26
+ runtimeSmokeAcknowledgementPath = Self.optionValue(
27
+ "--runtime-smoke-ack",
28
+ arguments: arguments
29
+ )
30
+ runtimeSmokeCompletionPath = Self.optionValue(
31
+ "--runtime-smoke-completion",
32
+ arguments: arguments
33
+ )
34
+ }
35
+
36
+ private static func optionValue(_ name: String, arguments: [String]) -> String? {
37
+ guard let index = arguments.firstIndex(of: name), arguments.indices.contains(index + 1) else {
38
+ return nil
39
+ }
40
+ return arguments[index + 1]
41
+ }
42
+ }
43
+
44
+ public struct PermissionRequestOutcome: Sendable, Equatable {
45
+ public let microphoneGranted: Bool
46
+ public let accessibilityTrusted: Bool
47
+
48
+ public init(microphoneGranted: Bool, accessibilityTrusted: Bool) {
49
+ self.microphoneGranted = microphoneGranted
50
+ self.accessibilityTrusted = accessibilityTrusted
51
+ }
52
+
53
+ public var succeeded: Bool {
54
+ microphoneGranted && accessibilityTrusted
55
+ }
56
+ }