@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
@@ -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,114 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ /// An observed app process, not a bundle-ID request to launch or find another instance.
5
+ public struct RecordingPasteTarget: Equatable, Sendable {
6
+ public let processIdentifier: pid_t
7
+ public let bundleIdentifier: String
8
+ public let launchDate: Date
9
+ public let applicationName: String
10
+
11
+ init?(observation: PasteApplicationObservation, currentPID: pid_t) {
12
+ guard observation.pid > 0, observation.pid != currentPID,
13
+ observation.isRegular, !observation.isTerminated,
14
+ let bundle = observation.bundleIdentifier, !bundle.isEmpty,
15
+ let launchDate = observation.launchDate else { return nil }
16
+ processIdentifier = observation.pid
17
+ bundleIdentifier = bundle
18
+ self.launchDate = launchDate
19
+ applicationName = observation.name ?? "Application"
20
+ }
21
+
22
+ func matches(_ observation: PasteApplicationObservation) -> Bool {
23
+ observation.pid == processIdentifier && observation.bundleIdentifier == bundleIdentifier
24
+ && observation.launchDate == launchDate && observation.isRegular && !observation.isTerminated
25
+ }
26
+ }
27
+
28
+ /// Omission preserves the recorder's existing frontmost-app behavior. A frozen nil
29
+ /// explicitly means no destination; it never adopts another app at delivery time.
30
+ public enum RecordingPasteTargetSelection: Equatable, Sendable {
31
+ case frontmostApplication
32
+ case frozen(RecordingPasteTarget?)
33
+ }
34
+
35
+ struct PasteApplicationObservation: Sendable {
36
+ var pid: pid_t
37
+ var bundleIdentifier: String?
38
+ var launchDate: Date?
39
+ var name: String?
40
+ var isRegular: Bool
41
+ var isTerminated: Bool
42
+
43
+ init(_ app: NSRunningApplication) {
44
+ pid = app.processIdentifier; bundleIdentifier = app.bundleIdentifier
45
+ launchDate = app.launchDate; name = app.localizedName
46
+ isRegular = app.activationPolicy == .regular; isTerminated = app.isTerminated
47
+ }
48
+ init(pid: pid_t, bundleIdentifier: String?, launchDate: Date?, name: String? = nil,
49
+ isRegular: Bool = true, isTerminated: Bool = false) {
50
+ self.pid = pid; self.bundleIdentifier = bundleIdentifier; self.launchDate = launchDate
51
+ self.name = name; self.isRegular = isRegular; self.isTerminated = isTerminated
52
+ }
53
+ }
54
+
55
+ /// Keep one tracker for the host application's lifetime, before presenting its UI.
56
+ /// Observes workspace activation only; never installs input handlers, queries AX,
57
+ /// activates apps, reads their documents, or accesses the clipboard.
58
+ @MainActor public final class RecordingPasteTargetTracker {
59
+ private let currentPID: pid_t
60
+ private let frontmost: () -> PasteApplicationObservation?
61
+ private let lookup: (pid_t) -> PasteApplicationObservation?
62
+ private var lastExternal: RecordingPasteTarget?
63
+ private var observers: [NSObjectProtocol] = []
64
+ private var center: NotificationCenter?
65
+
66
+ public convenience init() {
67
+ self.init(currentPID: ProcessInfo.processInfo.processIdentifier,
68
+ frontmost: { NSWorkspace.shared.frontmostApplication.map(PasteApplicationObservation.init) },
69
+ lookup: { NSRunningApplication(processIdentifier: $0).map(PasteApplicationObservation.init) })
70
+ let center = NSWorkspace.shared.notificationCenter
71
+ self.center = center
72
+ observers.append(center.addObserver(forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main) { [weak self] notification in
73
+ guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
74
+ let observation = PasteApplicationObservation(app)
75
+ MainActor.assumeIsolated { self?.observe(observation) }
76
+ })
77
+ observers.append(center.addObserver(forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main) { [weak self] notification in
78
+ guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
79
+ let observation = PasteApplicationObservation(app)
80
+ MainActor.assumeIsolated { self?.terminated(observation) }
81
+ })
82
+ }
83
+
84
+ init(currentPID: pid_t, frontmost: @escaping () -> PasteApplicationObservation?,
85
+ lookup: @escaping (pid_t) -> PasteApplicationObservation?) {
86
+ self.currentPID = currentPID; self.frontmost = frontmost; self.lookup = lookup
87
+ if let app = frontmost() { observe(app) }
88
+ }
89
+
90
+ isolated deinit { for observer in observers { center?.removeObserver(observer) } }
91
+
92
+ func observe(_ app: PasteApplicationObservation) {
93
+ guard let target = RecordingPasteTarget(observation: app, currentPID: currentPID) else { return }
94
+ lastExternal = target
95
+ }
96
+
97
+ func terminated(_ app: PasteApplicationObservation) {
98
+ guard let target = lastExternal, app.pid == target.processIdentifier,
99
+ app.bundleIdentifier == target.bundleIdentifier, app.launchDate == target.launchDate else { return }
100
+ lastExternal = nil
101
+ }
102
+
103
+ /// Revalidate now, then pass this value to startRecording as `.frozen(value)`.
104
+ /// A terminated or replaced process clears the remembered destination; older
105
+ /// apps are not searched as a substitute.
106
+ public func snapshot() -> RecordingPasteTarget? {
107
+ if let app = frontmost() { observe(app) }
108
+ guard let target = lastExternal else { return nil }
109
+ guard let live = lookup(target.processIdentifier), target.matches(live) else {
110
+ lastExternal = nil; return nil
111
+ }
112
+ return target
113
+ }
114
+ }
@@ -0,0 +1,112 @@
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 with variable-sized admitted PCM packets, off the audio
79
+ /// callback. Empty packets are omitted; all admitted packets precede inputEnded().
80
+ /// Return promptly. Providers must bound any queue while connecting or sending.
81
+ /// An in-flight append may race cancel; a cancelled session must ignore it.
82
+ func appendPCM(_ data: Data)
83
+ /// Called once after all capture/converter PCM drains, before the fallback WAV is written.
84
+ /// Streaming providers may close input now and cache an early final result. Return promptly;
85
+ /// this does not authorize publishing text or reading audioURL before finish is called.
86
+ /// Must be thread-safe and idempotent, and ignore calls after cancellation.
87
+ func inputEnded()
88
+ /// Called once after capture drains and the fallback WAV is safely written.
89
+ /// File-based providers may ignore appendPCM and recognize this file locally.
90
+ func finish(_ request: RecordingTranscriptionRequest) async throws -> RecordingProviderResult
91
+ /// Must be thread-safe and idempotent, including cancellation during finish.
92
+ func cancel()
93
+ }
94
+
95
+ public extension RecordingTranscriptionSession {
96
+ /// File providers and existing adapters keep their finish-after-WAV behavior.
97
+ func inputEnded() {}
98
+ }
99
+
100
+ public enum RecordingProviderError: Error, LocalizedError, Sendable {
101
+ case invalidIsolation
102
+ case noAudio
103
+ case emptyTranscript
104
+
105
+ public var errorDescription: String? {
106
+ switch self {
107
+ case .invalidIsolation: "Provide a separate absolute state home and preferences suite for this app."
108
+ case .noAudio: "No audio was captured."
109
+ case .emptyTranscript: "No speech was recognized."
110
+ }
111
+ }
112
+ }
@@ -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
+ }