@hasna/recordings 0.3.13 → 0.4.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 (105) hide show
  1. package/README.md +223 -37
  2. package/bun.lock +37 -38
  3. package/dist/cli/index.js +1884 -615
  4. package/dist/cli/macos-shortcut.d.ts +2 -2
  5. package/dist/db/database.d.ts.map +1 -1
  6. package/dist/db/pg-migrations.d.ts.map +1 -1
  7. package/dist/db/recordings.d.ts +6 -0
  8. package/dist/db/recordings.d.ts.map +1 -1
  9. package/dist/http/client.d.ts +183 -22
  10. package/dist/http/client.d.ts.map +1 -1
  11. package/dist/index.d.ts +3 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2858 -2288
  14. package/dist/lib/audio-artifact-storage.d.ts +98 -0
  15. package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
  16. package/dist/lib/capture-probe.d.ts +3 -3
  17. package/dist/lib/capture-probe.d.ts.map +1 -1
  18. package/dist/lib/config.d.ts.map +1 -1
  19. package/dist/lib/local-opt-in.d.ts +132 -0
  20. package/dist/lib/local-opt-in.d.ts.map +1 -0
  21. package/dist/lib/macos-bundle.d.ts +1 -1
  22. package/dist/lib/persistence-probe.d.ts +56 -17
  23. package/dist/lib/persistence-probe.d.ts.map +1 -1
  24. package/dist/lib/transcriber.d.ts.map +1 -1
  25. package/dist/mcp/index.d.ts.map +1 -1
  26. package/dist/mcp/index.js +1689 -456
  27. package/dist/sdk/index.d.ts +13 -5
  28. package/dist/sdk/index.d.ts.map +1 -1
  29. package/dist/sdk/index.js +904 -1
  30. package/dist/sdk/resolve.d.ts +53 -0
  31. package/dist/sdk/resolve.d.ts.map +1 -0
  32. package/dist/sdk/v1.generated.d.ts +6 -0
  33. package/dist/sdk/v1.generated.d.ts.map +1 -1
  34. package/dist/server/client-ip.d.ts +39 -0
  35. package/dist/server/client-ip.d.ts.map +1 -0
  36. package/dist/server/cloud-config.d.ts +12 -0
  37. package/dist/server/cloud-config.d.ts.map +1 -1
  38. package/dist/server/cloud-readiness.d.ts.map +1 -1
  39. package/dist/server/cloud.d.ts +20 -2
  40. package/dist/server/cloud.d.ts.map +1 -1
  41. package/dist/server/index.js +566 -284
  42. package/dist/server/migrate-command.d.ts +1 -1
  43. package/dist/server/migrate-command.d.ts.map +1 -1
  44. package/dist/server/openapi.d.ts +21 -0
  45. package/dist/server/openapi.d.ts.map +1 -1
  46. package/dist/server/repo.d.ts.map +1 -1
  47. package/dist/server/serve.d.ts.map +1 -1
  48. package/dist/storage.d.ts +2 -2
  49. package/dist/storage.d.ts.map +1 -1
  50. package/dist/storage.js +1334 -871
  51. package/dist/store.d.ts +12 -3
  52. package/dist/store.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +15 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/package.json +9 -8
  56. package/packaging/macos/build_release_pkg.sh +3 -3
  57. package/packaging/macos/managed_bootstrap.sh +4 -4
  58. package/packaging/macos/scripts/postinstall +2 -2
  59. package/packaging/macos/scripts/preinstall +2 -2
  60. package/scripts/build_companion_cli.sh +7 -1
  61. package/scripts/install_macos_app.sh +27 -24
  62. package/scripts/macos_artifact.ts +20 -16
  63. package/scripts/migrate.ts +4 -20
  64. package/scripts/smoke_macos_app.sh +21 -21
  65. package/src/native/Recordings/App/ContentView.swift +53 -68
  66. package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
  67. package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
  68. package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
  69. package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
  70. package/src/native/Recordings/App/RecordingsApp.swift +185 -9
  71. package/src/native/Recordings/App/RecordingsListView.swift +60 -112
  72. package/src/native/Recordings/App/RecordingsStore.swift +124 -109
  73. package/src/native/Recordings/App/Theme.swift +73 -61
  74. package/src/native/Recordings/Package.swift +1 -0
  75. package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
  76. package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
  77. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
  78. package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
  79. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
  80. package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
  81. package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -0
  82. package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
  83. package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
  84. package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
  85. package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
  86. package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
  87. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +13 -4
  88. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
  89. package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
  90. package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
  91. package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
  92. package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
  93. package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
  94. package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
  95. package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
  96. package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
  97. package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
  98. package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
  99. package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
  100. package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
  101. package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
  102. package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
  103. package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
  104. package/src/native/Recordings/build.sh +6 -6
  105. 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 {
@@ -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")