@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
@@ -0,0 +1,34 @@
1
+ import SwiftUI
2
+ import AppKit
3
+
4
+ /// One live macOS material per surface. No opaque tint below the blur and no
5
+ /// per-meter-tick Liquid Glass surfaces; content remains legible in accessibility mode.
6
+ public struct RecordingGlassBackground: View {
7
+ @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
8
+ public var radius: CGFloat
9
+ public init(radius: CGFloat = 16) { self.radius = radius }
10
+ public var body: some View {
11
+ ZStack {
12
+ if ChromeSurface.forReducedTransparency(reduceTransparency) == .opaque {
13
+ Color(NSColor.windowBackgroundColor)
14
+ } else {
15
+ NativeRecordingGlass()
16
+ LinearGradient(colors: [.white.opacity(0.18), .clear, .white.opacity(0.06)], startPoint: .topLeading, endPoint: .bottomTrailing)
17
+ }
18
+ }
19
+ .clipShape(RoundedRectangle(cornerRadius: radius))
20
+ .overlay(RoundedRectangle(cornerRadius: radius).strokeBorder(.white.opacity(0.45), lineWidth: 0.5))
21
+ }
22
+ }
23
+
24
+ private struct NativeRecordingGlass: NSViewRepresentable {
25
+ func makeNSView(context: Context) -> NSVisualEffectView {
26
+ let view = NSVisualEffectView()
27
+ view.material = .hudWindow
28
+ view.alphaValue = 0.9
29
+ view.blendingMode = .behindWindow
30
+ view.state = .active
31
+ return view
32
+ }
33
+ func updateNSView(_ view: NSVisualEffectView, context: Context) {}
34
+ }
@@ -0,0 +1,102 @@
1
+ import Foundation
2
+
3
+ /// A separate application must opt into its own filesystem and preferences roots.
4
+ /// This configuration never installs shortcuts or uses the legacy provider, Keychain,
5
+ /// environment variables, helper CLI, or service connection. Providers own their credentials.
6
+ public struct RecordingEngineConfiguration: Sendable {
7
+ public let isolatedHomePath: String
8
+ public let preferencesSuiteName: String
9
+
10
+ public init(isolatedHomePath: String, preferencesSuiteName: String) throws {
11
+ let path = URL(fileURLWithPath: isolatedHomePath).standardizedFileURL.resolvingSymlinksInPath()
12
+ let liveHome = FileManager.default.homeDirectoryForCurrentUser.resolvingSymlinksInPath()
13
+ let state = path.appendingPathComponent(".hasna/recordings").resolvingSymlinksInPath()
14
+ let liveState = liveHome.appendingPathComponent(".hasna/recordings").resolvingSymlinksInPath()
15
+ guard isolatedHomePath.hasPrefix("/"), path != liveHome,
16
+ state != liveState,
17
+ !preferencesSuiteName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
18
+ preferencesSuiteName != "com.hasna.recordings",
19
+ preferencesSuiteName != "NSGlobalDomain",
20
+ preferencesSuiteName != "NSArgumentDomain" else {
21
+ throw RecordingProviderError.invalidIsolation
22
+ }
23
+ self.isolatedHomePath = path.path
24
+ self.preferencesSuiteName = preferencesSuiteName
25
+ }
26
+ }
27
+
28
+ /// The native capture format is fixed so providers can forward PCM without conversion.
29
+ public struct RecordingProviderSessionConfiguration: Sendable {
30
+ public let captureID: String
31
+ public let language: String
32
+ public let sampleRate: Int = 24_000
33
+ public let channels: Int = 1
34
+ public let bitsPerSample: Int = 16
35
+
36
+ public init(captureID: String, language: String) {
37
+ self.captureID = captureID
38
+ self.language = language
39
+ }
40
+ }
41
+
42
+ public struct RecordingTranscriptionRequest: Sendable {
43
+ public let captureID: String
44
+ /// A complete 24 kHz, mono, signed little-endian PCM16 WAV, excluding paused audio.
45
+ public let audioURL: URL
46
+ public let duration: TimeInterval
47
+ public let language: String
48
+
49
+ public init(captureID: String, audioURL: URL, duration: TimeInterval, language: String) {
50
+ self.captureID = captureID
51
+ self.audioURL = audioURL
52
+ self.duration = duration
53
+ self.language = language
54
+ }
55
+ }
56
+
57
+ public struct RecordingProviderResult: Sendable, Equatable {
58
+ public let rawText: String
59
+ public let processedText: String?
60
+
61
+ public init(rawText: String, processedText: String? = nil) {
62
+ self.rawText = rawText
63
+ self.processedText = processedText
64
+ }
65
+ }
66
+
67
+ public protocol RecordingTranscriptionProvider: Sendable {
68
+ /// Construct promptly; start network/recognition work asynchronously inside the session.
69
+ /// Freeze mutable provider settings here so they cannot switch beneath a capture.
70
+ /// Each partial callback replaces the complete current transcript, rather than appending.
71
+ func makeSession(
72
+ configuration: RecordingProviderSessionConfiguration,
73
+ onPartialTranscript: @escaping @Sendable (String) -> Void
74
+ ) throws -> any RecordingTranscriptionSession
75
+ }
76
+
77
+ public protocol RecordingTranscriptionSession: Sendable {
78
+ /// Called in capture order, in roughly 100 ms chunks, off the audio callback.
79
+ /// Return promptly. Providers must bound any queue while connecting or sending.
80
+ /// The final short chunk is delivered before finish. An in-flight append may race cancel;
81
+ /// a cancelled session must ignore it.
82
+ func appendPCM(_ data: Data)
83
+ /// Called once after capture drains and the fallback WAV is safely written.
84
+ /// File-based providers may ignore appendPCM and recognize this file locally.
85
+ func finish(_ request: RecordingTranscriptionRequest) async throws -> RecordingProviderResult
86
+ /// Must be thread-safe and idempotent, including cancellation during finish.
87
+ func cancel()
88
+ }
89
+
90
+ public enum RecordingProviderError: Error, LocalizedError, Sendable {
91
+ case invalidIsolation
92
+ case noAudio
93
+ case emptyTranscript
94
+
95
+ public var errorDescription: String? {
96
+ switch self {
97
+ case .invalidIsolation: "Provide a separate absolute state home and preferences suite for this app."
98
+ case .noAudio: "No audio was captured."
99
+ case .emptyTranscript: "No speech was recognized."
100
+ }
101
+ }
102
+ }
@@ -33,25 +33,32 @@ public enum RecordingsCLI {
33
33
  command.argumentsPrefix.isEmpty else {
34
34
  throw Failure(message: "Packaged companion resolver did not select the embedded helper")
35
35
  }
36
- let versionOutput = CLIRunner.run(["--version"], home: home)
36
+ // The resolver smoke checks a throwaway local store, never the owner's API.
37
+ let probeEnvironment = [
38
+ "HOME": home,
39
+ "PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
40
+ "HASNA_RECORDINGS_CLIENT_STORE": "sqlite",
41
+ "HASNA_RECORDINGS_DB_PATH": "\(home)/recordings.db",
42
+ ]
43
+ let versionOutput = CLIRunner.run(["--version"], home: home, environment: probeEnvironment)
37
44
  .trimmingCharacters(in: .whitespacesAndNewlines)
38
45
  if let error = CLIRunner.parseError(versionOutput) { throw Failure(message: error) }
39
46
  let expectedVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
40
47
  guard !versionOutput.isEmpty, versionOutput == expectedVersion else {
41
48
  throw Failure(message: "Packaged companion version does not match the app")
42
49
  }
43
- let project = try registerProject(
44
- name: "Activated Helper Contract",
45
- path: "recordings-app://install/activated-helper-contract",
46
- description: "Installed app resolver verification",
47
- home: home
48
- )
50
+ let project = try decode(CanonicalProject.self, from: CLIRunner.run([
51
+ "--json", "project", "register", "--name", "Activated Helper Contract",
52
+ "--path", "recordings-app://install/activated-helper-contract",
53
+ "--description", "Installed app resolver verification",
54
+ ], home: home, environment: probeEnvironment))
49
55
  guard project.name == "Activated Helper Contract" else {
50
56
  throw Failure(message: "Packaged companion project capability failed")
51
57
  }
52
58
  let saved = CLIRunner.run(
53
59
  ["--json", "save-text", "Activated helper contract", "--source", "native_install_contract", "--post-processing", "off"],
54
- home: home
60
+ home: home,
61
+ environment: probeEnvironment
55
62
  )
56
63
  struct SavedRecording: Decodable { let raw_text: String }
57
64
  let recording = try decode(SavedRecording.self, from: saved)
@@ -115,7 +122,7 @@ public enum RecordingsCLI {
115
122
 
116
123
  public static func delete(id: String, home: String = defaultHome) throws {
117
124
  let output = CLIRunner.run(["delete", id], home: home)
118
- if let err = CLIRunner.parseError(output) { throw Failure(message: err) }
125
+ if let err = CLIRunner.parseError(output, serviceAPI: true) { throw Failure(message: err) }
119
126
  }
120
127
 
121
128
  public struct CanonicalProject: Codable, Sendable {
@@ -141,7 +148,7 @@ public enum RecordingsCLI {
141
148
 
142
149
  private static func runDecoding<T: Decodable>(_ type: T.Type, _ args: [String], home: String) throws -> T {
143
150
  let output = CLIRunner.run(args, home: home)
144
- if let err = CLIRunner.parseError(output) { throw Failure(message: err) }
151
+ if let err = CLIRunner.parseError(output, serviceAPI: true) { throw Failure(message: err) }
145
152
  return try decode(type, from: output)
146
153
  }
147
154
 
@@ -0,0 +1,107 @@
1
+ import Foundation
2
+ import Security
3
+
4
+ /// Bridges Finder/menu-bar launches to the same environment contract as the CLI.
5
+ /// The endpoint is user configuration; credentials live in Keychain, scoped to that URL.
6
+ public enum ServiceAPIConfiguration {
7
+ public static let defaultsKey = "recordingsServiceAPIURL"
8
+ public static let didChangeNotification = Notification.Name("recordingsServiceAPIConfigurationDidChange")
9
+ private static let keychainService = "com.hasna.recordings.service-api"
10
+
11
+ enum Failure: Error, LocalizedError {
12
+ case invalidURL
13
+ case keychain(OSStatus)
14
+
15
+ var errorDescription: String? {
16
+ switch self {
17
+ case .invalidURL:
18
+ "Enter an HTTPS API URL without credentials, query parameters, or a fragment. Localhost may use HTTP."
19
+ case .keychain(let status):
20
+ "Could not access the Recordings API credential in Keychain (\(status))."
21
+ }
22
+ }
23
+ }
24
+
25
+ public static func normalizedURL(_ value: String) throws -> String {
26
+ guard var url = URLComponents(string: value.trimmingCharacters(in: .whitespacesAndNewlines)),
27
+ let host = url.host, !host.isEmpty,
28
+ url.user == nil, url.password == nil, url.query == nil, url.fragment == nil,
29
+ url.scheme == "https" || (url.scheme == "http" && ["localhost", "127.0.0.1", "[::1]", "::1"].contains(host)) else {
30
+ throw Failure.invalidURL
31
+ }
32
+ var path = url.path
33
+ while path.hasSuffix("/") { path.removeLast() }
34
+ if !path.hasSuffix("/v1") { path += "/v1" }
35
+ url.path = path
36
+ guard let result = url.url?.absoluteString else { throw Failure.invalidURL }
37
+ return result
38
+ }
39
+
40
+ public static func configuredURL(
41
+ environment: [String: String] = ProcessInfo.processInfo.environment,
42
+ defaults: UserDefaults = .standard
43
+ ) -> String {
44
+ environment["HASNA_RECORDINGS_API_URL"] ?? defaults.string(forKey: defaultsKey) ?? ""
45
+ }
46
+
47
+ public static func save(url: String, apiKey: String, defaults: UserDefaults = .standard) throws {
48
+ let endpoint = try normalizedURL(url)
49
+ let key = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
50
+ if !key.isEmpty {
51
+ let query: [String: Any] = [
52
+ kSecClass as String: kSecClassGenericPassword,
53
+ kSecAttrService as String: keychainService,
54
+ kSecAttrAccount as String: endpoint,
55
+ ]
56
+ let value = [kSecValueData as String: Data(key.utf8)]
57
+ var status = SecItemUpdate(query as CFDictionary, value as CFDictionary)
58
+ if status == errSecItemNotFound {
59
+ var item = query.merging(value) { _, new in new }
60
+ item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
61
+ status = SecItemAdd(item as CFDictionary, nil)
62
+ }
63
+ guard status == errSecSuccess else { throw Failure.keychain(status) }
64
+ }
65
+ defaults.set(endpoint, forKey: defaultsKey)
66
+ NotificationCenter.default.post(name: didChangeNotification, object: nil)
67
+ }
68
+
69
+ static func loadKey(for endpoint: String) throws -> String? {
70
+ let query: [String: Any] = [
71
+ kSecClass as String: kSecClassGenericPassword,
72
+ kSecAttrService as String: keychainService,
73
+ kSecAttrAccount as String: endpoint,
74
+ kSecReturnData as String: true,
75
+ kSecMatchLimit as String: kSecMatchLimitOne,
76
+ kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail,
77
+ ]
78
+ var result: CFTypeRef?
79
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
80
+ if status == errSecItemNotFound { return nil }
81
+ guard status == errSecSuccess else { throw Failure.keychain(status) }
82
+ return (result as? Data).flatMap { String(data: $0, encoding: .utf8) }
83
+ }
84
+
85
+ static func childEnvironment(
86
+ base: [String: String],
87
+ defaults: UserDefaults = .standard,
88
+ keyLoader: (String) throws -> String? = loadKey
89
+ ) throws -> [String: String] {
90
+ // Explicit environment routing wins. Never attach a saved credential to an
91
+ // unrelated environment endpoint, or silently turn a local override into HTTP.
92
+ if base["HASNA_RECORDINGS_LOCAL"] == "1" || base["RECORDINGS_LOCAL"] == "1" { return base }
93
+ if ["HASNA_RECORDINGS_API_URL"]
94
+ .contains(where: { !(base[$0] ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
95
+ return base
96
+ }
97
+ guard let saved = defaults.string(forKey: defaultsKey), !saved.isEmpty else { return base }
98
+ let endpoint = try normalizedURL(saved)
99
+ var result = base
100
+ result["HASNA_RECORDINGS_API_URL"] = endpoint
101
+ if ["HASNA_RECORDINGS_API_KEY_OVERRIDE", "HASNA_RECORDINGS_API_KEY"]
102
+ .allSatisfy({ (base[$0] ?? "").isEmpty }), let key = try keyLoader(endpoint), !key.isEmpty {
103
+ result["HASNA_RECORDINGS_API_KEY_OVERRIDE"] = key
104
+ }
105
+ return result
106
+ }
107
+ }
@@ -0,0 +1,76 @@
1
+ import SwiftUI
2
+
3
+ struct ServiceConnectionView: View {
4
+ let home: String
5
+ @State private var apiURL = ServiceAPIConfiguration.configuredURL()
6
+ @State private var apiKey = ""
7
+ @State private var status: String?
8
+ @State private var checking = false
9
+
10
+ private var environmentManaged: Bool {
11
+ ["HASNA_RECORDINGS_API_URL", "HASNA_RECORDINGS_CLIENT_STORE", "RECORDINGS_CLIENT_STORE"]
12
+ .contains { !(ProcessInfo.processInfo.environment[$0] ?? "").isEmpty }
13
+ }
14
+
15
+ var body: some View {
16
+ Section("Recordings API") {
17
+ TextField("API URL", text: $apiURL)
18
+ .textFieldStyle(.roundedBorder)
19
+ .disabled(environmentManaged)
20
+ SecureField("API key", text: $apiKey)
21
+ .textFieldStyle(.roundedBorder)
22
+ .disabled(environmentManaged)
23
+ Text(environmentManaged
24
+ ? "Connection managed by the launch environment."
25
+ : "The URL is configurable. Saved API keys stay in Keychain and are used only for this endpoint.")
26
+ .font(.caption).foregroundStyle(.secondary)
27
+ HStack {
28
+ if !environmentManaged {
29
+ Button("Save Connection", action: save)
30
+ .disabled(checking || apiURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
31
+ }
32
+ Button("Test Connection", action: testConnection)
33
+ .disabled(checking)
34
+ if checking { ProgressView().controlSize(.small) }
35
+ }
36
+ if let status { Text(status).font(.caption).foregroundStyle(.secondary) }
37
+ }
38
+ }
39
+
40
+ private func save() {
41
+ do {
42
+ try ServiceAPIConfiguration.save(url: apiURL, apiKey: apiKey)
43
+ apiURL = ServiceAPIConfiguration.configuredURL()
44
+ apiKey = ""
45
+ status = "Connection saved."
46
+ } catch {
47
+ status = error.localizedDescription
48
+ }
49
+ }
50
+
51
+ private func testConnection() {
52
+ if !environmentManaged {
53
+ do {
54
+ try ServiceAPIConfiguration.save(url: apiURL, apiKey: apiKey)
55
+ apiKey = ""
56
+ } catch {
57
+ status = error.localizedDescription
58
+ return
59
+ }
60
+ }
61
+ checking = true
62
+ status = nil
63
+ Task {
64
+ let result = await Task.detached {
65
+ do {
66
+ _ = try RecordingsCLI.list(limit: 1, home: home)
67
+ return "Connected. Recordings are available."
68
+ } catch {
69
+ return (error as? RecordingsCLI.Failure)?.message ?? error.localizedDescription
70
+ }
71
+ }.value
72
+ status = result
73
+ checking = false
74
+ }
75
+ }
76
+ }
@@ -4,29 +4,31 @@ import SwiftUI
4
4
  public struct SettingsView: View {
5
5
  @ObservedObject public var engine: RecordingEngine
6
6
  @ObservedObject public var shortcuts: VoiceShortcuts
7
- @ObservedObject public var projectStore: ProjectStore
8
- @AppStorage("openAIAPIKey") private var openAIAPIKey = ""
7
+ @ObservedObject public var preferences: ProjectStore
8
+ @State private var openAIAPIKey = ""
9
+ @State private var providerStatus = ""
9
10
 
10
- public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, projectStore: ProjectStore) {
11
+ public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, preferences: ProjectStore) {
11
12
  self.engine = engine
12
13
  self.shortcuts = shortcuts
13
- self.projectStore = projectStore
14
+ self.preferences = preferences
14
15
  }
15
16
 
16
17
  public var body: some View {
17
18
  TabView {
18
19
  generalTab.tabItem { Label("General", systemImage: "gear") }
19
- projectsTab.tabItem { Label("Projects", systemImage: "folder") }
20
20
  shortcutsTab.tabItem { Label("Voice Shortcuts", systemImage: "text.badge.star") }
21
21
  }
22
- .frame(width: 520, height: 500)
23
- .alert("Project Settings Error", isPresented: Binding(
24
- get: { projectStore.persistenceError != nil },
25
- set: { if !$0 { projectStore.clearPersistenceError() } }
22
+ .font(.system(size: 13))
23
+ .controlSize(.small)
24
+ .frame(width: 520, height: 460)
25
+ .alert("Settings Error", isPresented: Binding(
26
+ get: { preferences.persistenceError != nil },
27
+ set: { if !$0 { preferences.clearPersistenceError() } }
26
28
  )) {
27
29
  Button("OK", role: .cancel) {}
28
30
  } message: {
29
- Text(projectStore.persistenceError ?? "The project settings could not be saved.")
31
+ Text(preferences.persistenceError ?? "The settings could not be saved.")
30
32
  }
31
33
  }
32
34
 
@@ -34,18 +36,38 @@ public struct SettingsView: View {
34
36
 
35
37
  private var generalTab: some View {
36
38
  Form {
39
+ ServiceConnectionView(home: engine.home)
37
40
  Section("OpenAI") {
38
41
  SecureField("API key", text: $openAIAPIKey)
39
42
  .textFieldStyle(.roundedBorder)
40
- .onChange(of: openAIAPIKey) {
41
- // Keep the CLI config in sync — final transcription shells out to it.
42
- try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
43
+ Button("Save OpenAI Key") {
44
+ do {
45
+ try OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
46
+ openAIAPIKey = ""
47
+ providerStatus = "OpenAI key saved in Keychain."
48
+ } catch {
49
+ providerStatus = error.localizedDescription
50
+ }
51
+ }
52
+ .disabled(openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
53
+ Button("Connect Saved Key") {
54
+ do {
55
+ providerStatus = (try OpenAIAPIKeyStore.loadKeychain(allowInteraction: true))?.isEmpty == false
56
+ ? "OpenAI key available from Keychain."
57
+ : "No saved OpenAI key found in Keychain."
58
+ } catch { providerStatus = error.localizedDescription }
59
+ }
60
+ Text(providerStatus).foregroundStyle(.secondary)
61
+ .onAppear {
62
+ providerStatus = OpenAIAPIKeyStore.load(homePath: engine.home).isEmpty
63
+ ? "Add an OpenAI key for transcription."
64
+ : "OpenAI key available for transcription."
43
65
  }
44
66
  Picker("Language", selection: $engine.transcriptionLanguage) {
45
67
  Text("English").tag("en")
46
68
  Text("Auto Detect").tag("auto")
47
69
  }
48
- Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
70
+ Text("Used for live transcription and the final paste. New keys are stored in macOS Keychain.")
49
71
  .foregroundStyle(.secondary)
50
72
  }
51
73
 
@@ -143,82 +165,26 @@ public struct SettingsView: View {
143
165
  }
144
166
 
145
167
  Section("Transcription Cleanup") {
146
- Picker("Mode", selection: $projectStore.settings.postProcessingMode) {
168
+ Picker("Mode", selection: $preferences.settings.postProcessingMode) {
147
169
  ForEach(PostProcessingMode.allCases) { mode in
148
170
  Text(mode.label).tag(mode.rawValue)
149
171
  }
150
172
  }
151
173
  .pickerStyle(.segmented)
152
- .onChange(of: projectStore.settings.postProcessingMode) {
153
- try? projectStore.save()
174
+ .onChange(of: preferences.settings.postProcessingMode) {
175
+ try? preferences.save()
154
176
  }
155
- TextEditor(text: $projectStore.settings.globalSystemPrompt)
156
- .frame(height: 80)
157
- .onChange(of: projectStore.settings.globalSystemPrompt) {
158
- try? projectStore.save()
177
+ TextEditor(text: $preferences.settings.globalSystemPrompt)
178
+ .frame(height: 80).scrollContentBackground(.hidden)
179
+ .onChange(of: preferences.settings.globalSystemPrompt) {
180
+ try? preferences.save()
159
181
  }
160
182
  Text("Instructions for post-transcription cleanup and formatting.")
161
183
  .foregroundStyle(.secondary)
162
184
  }
163
- .disabled(!projectStore.canMutateProjects)
164
- }
165
- .formStyle(.grouped).padding()
166
- }
167
-
168
- // MARK: - Projects
169
-
170
- @State private var newProjectName = ""
171
- @State private var editingProject: RecProject?
172
-
173
- private var projectsTab: some View {
174
- VStack(spacing: 0) {
175
- HStack(spacing: 8) {
176
- TextField("New project name", text: $newProjectName)
177
- .textFieldStyle(.roundedBorder)
178
- Button("Add") {
179
- guard !newProjectName.isEmpty else { return }
180
- let name = newProjectName
181
- Task {
182
- do {
183
- try await projectStore.addProject(name: name)
184
- newProjectName = ""
185
- } catch {}
186
- }
187
- }
188
- .disabled(newProjectName.isEmpty)
189
- }
190
- .padding()
191
-
192
- Divider()
193
-
194
- if projectStore.settings.projects.isEmpty {
195
- Spacer()
196
- VStack(spacing: 8) {
197
- Image(systemName: "folder").font(.largeTitle).foregroundStyle(.quaternary)
198
- Text("No projects yet").foregroundStyle(.secondary)
199
- }
200
- Spacer()
201
- } else {
202
- List {
203
- ForEach(projectStore.settings.projects) { project in
204
- ProjectRow(project: project) {
205
- editingProject = project
206
- }
207
- }
208
- .onDelete { indexSet in
209
- for i in indexSet {
210
- try? projectStore.removeProject(id: projectStore.settings.projects[i].id)
211
- }
212
- }
213
- }
214
- }
215
- }
216
- .disabled(!projectStore.canMutateProjects)
217
- .sheet(item: $editingProject) { project in
218
- ProjectEditView(project: project, store: projectStore) {
219
- editingProject = nil
220
- }
185
+ .disabled(!preferences.canMutateProjects)
221
186
  }
187
+ .formStyle(.grouped).scrollContentBackground(.hidden).padding(8)
222
188
  }
223
189
 
224
190
  // MARK: - Voice Shortcuts
@@ -263,73 +229,3 @@ public struct SettingsView: View {
263
229
  }
264
230
  }
265
231
  }
266
-
267
- // MARK: - Project Row
268
-
269
- struct ProjectRow: View {
270
- let project: RecProject
271
- let onEdit: () -> Void
272
-
273
- var body: some View {
274
- HStack {
275
- VStack(alignment: .leading, spacing: 2) {
276
- Text(project.name)
277
- if let path = project.path, !path.isEmpty {
278
- Text(path).foregroundStyle(.secondary).lineLimit(1)
279
- }
280
- }
281
- Spacer()
282
- Button("Edit") { onEdit() }
283
- .controlSize(.small)
284
- }
285
- }
286
- }
287
-
288
- // MARK: - Project Edit
289
-
290
- struct ProjectEditView: View {
291
- @State var project: RecProject
292
- let store: ProjectStore
293
- let onDismiss: () -> Void
294
-
295
- var body: some View {
296
- VStack(spacing: 0) {
297
- Form {
298
- Section("Project") {
299
- TextField("Name", text: $project.name)
300
- TextField("Path", text: Binding(
301
- get: { project.path ?? "" },
302
- set: { project.path = $0.isEmpty ? nil : $0 }
303
- ))
304
- .textFieldStyle(.roundedBorder)
305
- }
306
-
307
- Section("Transcriber Instructions") {
308
- TextEditor(text: Binding(
309
- get: { project.systemPrompt ?? "" },
310
- set: { project.systemPrompt = $0.isEmpty ? nil : $0 }
311
- ))
312
- .frame(height: 100)
313
- Text("Project-specific cleanup and formatting instructions.")
314
- .foregroundStyle(.secondary)
315
- }
316
- }
317
- .formStyle(.grouped)
318
- .disabled(!store.canMutateProjects)
319
-
320
- HStack {
321
- Spacer()
322
- Button("Cancel") { onDismiss() }
323
- Button("Save") {
324
- do {
325
- try store.updateProject(project)
326
- onDismiss()
327
- } catch {}
328
- }
329
- .disabled(!store.canMutateProjects)
330
- }
331
- .padding()
332
- }
333
- .frame(width: 420, height: 340)
334
- }
335
- }
@@ -0,0 +1,48 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ /// Settings must also open from an accessory app with no SwiftUI workspace scene.
5
+ /// Retain the window so repeated menu actions and closing/reopening preserve its state.
6
+ @MainActor
7
+ public final class SettingsWindowController {
8
+ private var window: NSWindow?
9
+ private let makeContent: @MainActor () -> NSView
10
+
11
+ public init(content: @escaping @MainActor () -> NSView) {
12
+ makeContent = content
13
+ }
14
+
15
+ @discardableResult
16
+ public func show() -> NSWindow {
17
+ let settings: NSWindow
18
+ if let window {
19
+ settings = window
20
+ } else {
21
+ settings = NSWindow(
22
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 500),
23
+ styleMask: [.titled, .closable],
24
+ backing: .buffered,
25
+ defer: false
26
+ )
27
+ settings.title = "Hasna Recordings Settings"
28
+ settings.isReleasedWhenClosed = false
29
+ settings.isOpaque = false
30
+ settings.backgroundColor = .clear
31
+ settings.titlebarAppearsTransparent = true
32
+ let content = makeContent()
33
+ let backdrop = NSHostingView(rootView: RecordingGlassBackground())
34
+ backdrop.frame = content.bounds
35
+ backdrop.autoresizingMask = [.width, .height]
36
+ content.addSubview(backdrop, positioned: .below, relativeTo: nil)
37
+ settings.contentView = content
38
+ settings.center()
39
+ window = settings
40
+ }
41
+ // Activation is needed when invoked from a menu-bar popover. Preserve the
42
+ // activation policy so a bar-only app stays out of the Dock and app switcher.
43
+ NSApplication.shared.activate()
44
+ NSRunningApplication.current.activate(options: [.activateAllWindows])
45
+ settings.makeKeyAndOrderFront(nil)
46
+ return settings
47
+ }
48
+ }