@hasna/recordings 0.3.13 → 0.5.0

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 (148) hide show
  1. package/README.md +279 -43
  2. package/bun.lock +37 -38
  3. package/contracts/v1/fixtures.json +1206 -0
  4. package/dist/cli/index.js +1909 -619
  5. package/dist/cli/macos-shortcut.d.ts +2 -2
  6. package/dist/contracts/hosted-v1.d.ts +105 -0
  7. package/dist/contracts/hosted-v1.d.ts.map +1 -0
  8. package/dist/contracts/hosted-v1.js +41 -0
  9. package/dist/contracts/stream-v1.d.ts +117 -0
  10. package/dist/contracts/stream-v1.d.ts.map +1 -0
  11. package/dist/contracts/stream-v1.js +35 -0
  12. package/dist/db/database.d.ts.map +1 -1
  13. package/dist/db/pg-migrations.d.ts.map +1 -1
  14. package/dist/db/recordings.d.ts +6 -0
  15. package/dist/db/recordings.d.ts.map +1 -1
  16. package/dist/hosted/index.d.ts +58 -0
  17. package/dist/hosted/index.d.ts.map +1 -0
  18. package/dist/hosted/index.js +266 -0
  19. package/dist/hosted/transport.d.ts +36 -0
  20. package/dist/hosted/transport.d.ts.map +1 -0
  21. package/dist/hosted-v1-aavn7ktb.js +4114 -0
  22. package/dist/hosted-v1-gdr9extc.js +84 -0
  23. package/dist/http/client.d.ts +183 -22
  24. package/dist/http/client.d.ts.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2879 -2288
  28. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  29. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  30. package/dist/lib/capture-probe.d.ts +3 -3
  31. package/dist/lib/capture-probe.d.ts.map +1 -1
  32. package/dist/lib/config.d.ts.map +1 -1
  33. package/dist/lib/local-opt-in.d.ts +132 -0
  34. package/dist/lib/local-opt-in.d.ts.map +1 -0
  35. package/dist/lib/macos-bundle.d.ts +1 -1
  36. package/dist/lib/persistence-probe.d.ts +56 -17
  37. package/dist/lib/persistence-probe.d.ts.map +1 -1
  38. package/dist/lib/transcriber.d.ts.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +1713 -459
  41. package/dist/sdk/index.d.ts +13 -5
  42. package/dist/sdk/index.d.ts.map +1 -1
  43. package/dist/sdk/index.js +904 -1
  44. package/dist/sdk/resolve.d.ts +53 -0
  45. package/dist/sdk/resolve.d.ts.map +1 -0
  46. package/dist/sdk/v1.generated.d.ts +6 -0
  47. package/dist/sdk/v1.generated.d.ts.map +1 -1
  48. package/dist/server/client-ip.d.ts +39 -0
  49. package/dist/server/client-ip.d.ts.map +1 -0
  50. package/dist/server/cloud-config.d.ts +12 -0
  51. package/dist/server/cloud-config.d.ts.map +1 -1
  52. package/dist/server/cloud-readiness.d.ts.map +1 -1
  53. package/dist/server/cloud.d.ts +20 -2
  54. package/dist/server/cloud.d.ts.map +1 -1
  55. package/dist/server/index.js +590 -287
  56. package/dist/server/migrate-command.d.ts +1 -1
  57. package/dist/server/migrate-command.d.ts.map +1 -1
  58. package/dist/server/openapi.d.ts +21 -0
  59. package/dist/server/openapi.d.ts.map +1 -1
  60. package/dist/server/repo.d.ts.map +1 -1
  61. package/dist/server/serve.d.ts.map +1 -1
  62. package/dist/storage.d.ts +2 -2
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +1358 -874
  65. package/dist/store.d.ts +12 -3
  66. package/dist/store.d.ts.map +1 -1
  67. package/dist/types/index.d.ts +15 -0
  68. package/dist/types/index.d.ts.map +1 -1
  69. package/docs/hosted-sdk.md +71 -0
  70. package/docs/wire-contracts.md +24 -0
  71. package/package.json +33 -11
  72. package/packaging/macos/build_release_pkg.sh +3 -3
  73. package/packaging/macos/managed_bootstrap.sh +4 -4
  74. package/packaging/macos/scripts/postinstall +2 -2
  75. package/packaging/macos/scripts/preinstall +2 -2
  76. package/scripts/build_companion_cli.sh +7 -1
  77. package/scripts/ci-linux-suite.ts +23 -13
  78. package/scripts/install_macos_app.sh +27 -24
  79. package/scripts/macos_artifact.ts +60 -49
  80. package/scripts/migrate.ts +4 -20
  81. package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
  82. package/scripts/native/recordings_fs_guard.c +36 -4
  83. package/scripts/native-core-receipt.py +171 -0
  84. package/scripts/native_fs_guard.ts +2 -0
  85. package/scripts/release-suite-gate.ts +227 -150
  86. package/scripts/resolve_tailscale_cli.sh +24 -3
  87. package/scripts/smoke_macos_app.sh +21 -21
  88. package/src/native/Recordings/App/ContentView.swift +53 -68
  89. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  90. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  91. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  92. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  93. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  94. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  95. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  96. package/src/native/Recordings/App/Theme.swift +73 -61
  97. package/src/native/Recordings/Package.swift +1 -0
  98. package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
  99. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  100. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  101. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  102. package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
  103. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  104. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
  105. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  106. package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
  107. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
  108. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  109. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  110. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  111. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  112. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  113. package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
  114. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
  115. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  116. package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
  117. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
  118. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  119. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  120. package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
  121. package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
  122. package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
  123. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
  124. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  125. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
  126. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  127. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  128. package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
  129. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  130. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  131. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  132. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  133. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  134. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  135. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  136. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  137. package/src/native/Recordings/build.sh +8 -7
  138. package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
  139. package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
  140. package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
  141. package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
  142. package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
  143. package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
  144. package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
  145. package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
  146. package/dist/__tests__/preload.d.ts +0 -2
  147. package/dist/__tests__/preload.d.ts.map +0 -1
  148. package/src/native/Recordings/App/SidebarView.swift +0 -212
@@ -1,4 +1,5 @@
1
1
  import Foundation
2
+ import Security
2
3
 
3
4
  struct ProcessingModelSelection: Equatable, Sendable {
4
5
  let transcriptionPrompt: String
@@ -16,11 +17,17 @@ enum OpenAIAPIKeyStore {
16
17
  static func load(
17
18
  homePath: String,
18
19
  environment: [String: String] = ProcessInfo.processInfo.environment,
19
- userDefaultKey: String? = UserDefaults.standard.string(forKey: "openAIAPIKey")
20
+ userDefaultKey: String? = UserDefaults.standard.string(forKey: "openAIAPIKey"),
21
+ keychainLoader: (() throws -> String?)? = nil
20
22
  ) -> String {
21
- if let key = firstNonEmpty(environment["OPENAI_API_KEY"], environment["RECORDINGS_API_KEY"]) {
23
+ if let key = firstNonEmpty(environment["RECORDINGS_OPENAI_API_KEY"], environment["OPENAI_API_KEY"], environment["RECORDINGS_API_KEY"]) {
22
24
  return key
23
25
  }
26
+ // Test and resolver homes must never read the owner's credentials.
27
+ if let loader = keychainLoader,
28
+ let key = try? loader(), let key = firstNonEmpty(key) { return key }
29
+ if keychainLoader == nil, homePath == FileManager.default.homeDirectoryForCurrentUser.path,
30
+ let key = try? loadKeychain(), let key = firstNonEmpty(key) { return key }
24
31
  if let key = firstNonEmpty(userDefaultKey) {
25
32
  return key
26
33
  }
@@ -96,30 +103,77 @@ enum OpenAIAPIKeyStore {
96
103
  )
97
104
  }
98
105
 
99
- /// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
100
- /// shells out to for final transcription) uses the same key as the app itself.
101
- static func save(key: String, homePath: String) throws {
106
+ /// The app and its embedded helper share the provider credential through Keychain
107
+ /// and an in-memory child environment. Never write a new key into preferences.
108
+ static func save(
109
+ key: String, homePath: String,
110
+ defaults: UserDefaults = .standard,
111
+ keychainWriter: (String) throws -> Void = saveKeychain
112
+ ) throws {
102
113
  let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
103
- let dir = URL(fileURLWithPath: homePath)
104
- .appendingPathComponent(".hasna")
105
- .appendingPathComponent("recordings")
106
- let url = dir.appendingPathComponent("config.json")
107
-
108
- var json: [String: Any] = [:]
109
- if let data = try? Data(contentsOf: url),
110
- let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
111
- json = existing
114
+ try keychainWriter(trimmed)
115
+ var json = loadMutableConfig(homePath: homePath)
116
+ if json.removeValue(forKey: "openai_api_key") != nil || json["api_key"] != nil {
117
+ json.removeValue(forKey: "api_key")
118
+ try writeConfig(json, homePath: homePath)
112
119
  }
120
+ defaults.removeObject(forKey: "openAIAPIKey")
121
+ }
113
122
 
114
- if trimmed.isEmpty {
115
- json.removeValue(forKey: "openai_api_key")
116
- } else {
117
- json["openai_api_key"] = trimmed
123
+ private static var keychainQuery: [String: Any] {
124
+ [kSecClass as String: kSecClassGenericPassword,
125
+ kSecAttrService as String: "hasna.credentials.openai.api-key",
126
+ kSecAttrAccount as String: "openai/api_key"]
127
+ }
128
+
129
+ static func loadKeychain(allowInteraction: Bool = false) throws -> String? {
130
+ var query = keychainQuery
131
+ query[kSecReturnData as String] = true
132
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
133
+ if !allowInteraction { query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUIFail }
134
+ var result: CFTypeRef?
135
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
136
+ if status == errSecItemNotFound { return nil }
137
+ guard status == errSecSuccess else { throw KeychainFailure(status: status) }
138
+ return (result as? Data).flatMap { String(data: $0, encoding: .utf8) }
139
+ }
140
+
141
+ private static func saveKeychain(_ key: String) throws {
142
+ let query = keychainQuery
143
+ if key.isEmpty {
144
+ let status = SecItemDelete(query as CFDictionary)
145
+ guard status == errSecSuccess || status == errSecItemNotFound else { throw KeychainFailure(status: status) }
146
+ return
147
+ }
148
+ let value = [kSecValueData as String: Data(key.utf8)]
149
+ var status = SecItemUpdate(query as CFDictionary, value as CFDictionary)
150
+ if status == errSecItemNotFound {
151
+ var item = query.merging(value) { _, new in new }
152
+ item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
153
+ status = SecItemAdd(item as CFDictionary, nil)
118
154
  }
155
+ guard status == errSecSuccess else { throw KeychainFailure(status: status) }
156
+ }
119
157
 
120
- try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
121
- let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
122
- try data.write(to: url, options: .atomic)
158
+ struct KeychainFailure: Error, LocalizedError {
159
+ let status: OSStatus
160
+ var errorDescription: String? { "Could not access the OpenAI key in Keychain (\(status))." }
161
+ }
162
+
163
+ static func childEnvironment(
164
+ base: [String: String], homePath: String,
165
+ keyLoader: (() -> String)? = nil
166
+ ) -> [String: String] {
167
+ var result = base
168
+ let key = keyLoader?() ?? load(homePath: homePath, environment: base)
169
+ // RECORDINGS_API_KEY is also an alias in the Hasna service resolver. Keeping
170
+ // it would send a provider key to the service API as its authentication.
171
+ result.removeValue(forKey: "RECORDINGS_API_KEY")
172
+ if !key.isEmpty {
173
+ result["OPENAI_API_KEY"] = key
174
+ result["RECORDINGS_OPENAI_API_KEY"] = key
175
+ }
176
+ return result
123
177
  }
124
178
 
125
179
  static func saveLanguage(language: String, homePath: String) throws {
@@ -288,7 +288,7 @@ public final class ProjectStore: ObservableObject {
288
288
  isReadyForRecording = false
289
289
  let registrations: [(String, RecordingsCLI.CanonicalProject)]
290
290
  do {
291
- registrations = try await Task.detached(priority: .utility) {
291
+ registrations = try await BlockingOperation.runThrowing(qos: .utility) {
292
292
  try original.projects.map { project in
293
293
  let canonical = try RecordingsCLI.registerProject(
294
294
  name: project.name,
@@ -297,7 +297,7 @@ public final class ProjectStore: ObservableObject {
297
297
  )
298
298
  return (project.id, canonical)
299
299
  }
300
- }.value
300
+ }
301
301
  } catch {
302
302
  let message = "Failed to register projects: \((error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription)"
303
303
  synchronizationError = message
@@ -347,9 +347,9 @@ public final class ProjectStore: ObservableObject {
347
347
  let local = RecProject(name: name, path: path, systemPrompt: systemPrompt, color: color)
348
348
  let canonical: RecordingsCLI.CanonicalProject
349
349
  do {
350
- canonical = try await Task.detached(priority: .userInitiated) {
350
+ canonical = try await BlockingOperation.runThrowing {
351
351
  try RecordingsCLI.registerProject(name: local.name, path: local.registrationPath, home: home)
352
- }.value
352
+ }
353
353
  } catch {
354
354
  persistenceError = "Failed to register project: \((error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription)"
355
355
  throw error
@@ -100,12 +100,24 @@ public struct Recording: Identifiable, Codable, Sendable, Equatable {
100
100
 
101
101
  public static func parseDate(_ value: String) -> Date? {
102
102
  guard !value.isEmpty else { return nil }
103
+ // PostgreSQL NOW()::text uses a space and may abbreviate the UTC offset
104
+ // to hours, for example "2026-09-06 10:52:42.523193+00".
105
+ var internetDate = value
106
+ if value.range(of: #"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,6})?[+-]\d{2}(?::?\d{2})?$"#,
107
+ options: .regularExpression) != nil {
108
+ guard value.range(of: #"[+-](?:[01]\d|2[0-3])(?::?[0-5]\d)?$"#,
109
+ options: .regularExpression) != nil else { return nil }
110
+ internetDate = value.replacingOccurrences(of: " ", with: "T")
111
+ if internetDate.range(of: #"[+-]\d{2}$"#, options: .regularExpression) != nil {
112
+ internetDate += ":00"
113
+ }
114
+ }
103
115
  let withFrac = ISO8601DateFormatter()
104
116
  withFrac.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
105
- if let d = withFrac.date(from: value) { return d }
117
+ if let d = withFrac.date(from: internetDate) { return d }
106
118
  let plain = ISO8601DateFormatter()
107
119
  plain.formatOptions = [.withInternetDateTime]
108
- if let d = plain.date(from: value) { return d }
120
+ if let d = plain.date(from: internetDate) { return d }
109
121
  // SQLite "YYYY-MM-DD HH:MM:SS" fallback.
110
122
  let df = DateFormatter()
111
123
  df.locale = Locale(identifier: "en_US_POSIX")