@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.
- package/README.md +279 -43
- package/bun.lock +37 -38
- package/contracts/v1/fixtures.json +1206 -0
- package/dist/cli/index.js +1909 -619
- package/dist/cli/macos-shortcut.d.ts +2 -2
- package/dist/contracts/hosted-v1.d.ts +105 -0
- package/dist/contracts/hosted-v1.d.ts.map +1 -0
- package/dist/contracts/hosted-v1.js +41 -0
- package/dist/contracts/stream-v1.d.ts +117 -0
- package/dist/contracts/stream-v1.d.ts.map +1 -0
- package/dist/contracts/stream-v1.js +35 -0
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/pg-migrations.d.ts.map +1 -1
- package/dist/db/recordings.d.ts +6 -0
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/hosted/index.d.ts +58 -0
- package/dist/hosted/index.d.ts.map +1 -0
- package/dist/hosted/index.js +266 -0
- package/dist/hosted/transport.d.ts +36 -0
- package/dist/hosted/transport.d.ts.map +1 -0
- package/dist/hosted-v1-aavn7ktb.js +4114 -0
- package/dist/hosted-v1-gdr9extc.js +84 -0
- package/dist/http/client.d.ts +183 -22
- package/dist/http/client.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2879 -2288
- package/dist/lib/audio-artifact-storage.d.ts +98 -0
- package/dist/lib/audio-artifact-storage.d.ts.map +1 -0
- package/dist/lib/capture-probe.d.ts +3 -3
- package/dist/lib/capture-probe.d.ts.map +1 -1
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/local-opt-in.d.ts +132 -0
- package/dist/lib/local-opt-in.d.ts.map +1 -0
- package/dist/lib/macos-bundle.d.ts +1 -1
- package/dist/lib/persistence-probe.d.ts +56 -17
- package/dist/lib/persistence-probe.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1713 -459
- package/dist/sdk/index.d.ts +13 -5
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +904 -1
- package/dist/sdk/resolve.d.ts +53 -0
- package/dist/sdk/resolve.d.ts.map +1 -0
- package/dist/sdk/v1.generated.d.ts +6 -0
- package/dist/sdk/v1.generated.d.ts.map +1 -1
- package/dist/server/client-ip.d.ts +39 -0
- package/dist/server/client-ip.d.ts.map +1 -0
- package/dist/server/cloud-config.d.ts +12 -0
- package/dist/server/cloud-config.d.ts.map +1 -1
- package/dist/server/cloud-readiness.d.ts.map +1 -1
- package/dist/server/cloud.d.ts +20 -2
- package/dist/server/cloud.d.ts.map +1 -1
- package/dist/server/index.js +590 -287
- package/dist/server/migrate-command.d.ts +1 -1
- package/dist/server/migrate-command.d.ts.map +1 -1
- package/dist/server/openapi.d.ts +21 -0
- package/dist/server/openapi.d.ts.map +1 -1
- package/dist/server/repo.d.ts.map +1 -1
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/storage.d.ts +2 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +1358 -874
- package/dist/store.d.ts +12 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/types/index.d.ts +15 -0
- package/dist/types/index.d.ts.map +1 -1
- package/docs/hosted-sdk.md +71 -0
- package/docs/wire-contracts.md +24 -0
- package/package.json +33 -11
- package/packaging/macos/build_release_pkg.sh +3 -3
- package/packaging/macos/managed_bootstrap.sh +4 -4
- package/packaging/macos/scripts/postinstall +2 -2
- package/packaging/macos/scripts/preinstall +2 -2
- package/scripts/build_companion_cli.sh +7 -1
- package/scripts/ci-linux-suite.ts +23 -13
- package/scripts/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +60 -49
- package/scripts/migrate.ts +4 -20
- package/scripts/native/prebuilds/darwin-universal/recordings_fs_guard.node +0 -0
- package/scripts/native/recordings_fs_guard.c +36 -4
- package/scripts/native-core-receipt.py +171 -0
- package/scripts/native_fs_guard.ts +2 -0
- package/scripts/release-suite-gate.ts +227 -150
- package/scripts/resolve_tailscale_cli.sh +24 -3
- package/scripts/smoke_macos_app.sh +21 -21
- package/src/native/Recordings/App/ContentView.swift +53 -68
- package/src/native/Recordings/App/MenuBarStatusView.swift +171 -61
- package/src/native/Recordings/App/RecordWorkspaceView.swift +47 -449
- package/src/native/Recordings/App/RecorderSettingsView.swift +75 -0
- package/src/native/Recordings/App/RecordingDetailView.swift +3 -4
- package/src/native/Recordings/App/RecordingsApp.swift +185 -9
- package/src/native/Recordings/App/RecordingsListView.swift +60 -112
- package/src/native/Recordings/App/RecordingsStore.swift +124 -109
- package/src/native/Recordings/App/Theme.swift +73 -61
- package/src/native/Recordings/Package.swift +1 -0
- package/src/native/Recordings/RecordingsLib/BlockingOperation.swift +29 -0
- package/src/native/Recordings/RecordingsLib/CaptureMonitor.swift +65 -0
- package/src/native/Recordings/RecordingsLib/Info.plist +3 -3
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +75 -21
- package/src/native/Recordings/RecordingsLib/ProjectStore.swift +4 -4
- package/src/native/Recordings/RecordingsLib/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +539 -89
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingPasteTarget.swift +114 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +112 -0
- package/src/native/Recordings/RecordingsLib/RecordingsCLI.swift +17 -10
- package/src/native/Recordings/RecordingsLib/ServiceAPIConfiguration.swift +107 -0
- package/src/native/Recordings/RecordingsLib/ServiceConnectionView.swift +76 -0
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +45 -149
- package/src/native/Recordings/RecordingsLib/SettingsWindowController.swift +48 -0
- package/src/native/Recordings/RecordingsTests/BlockingOperationTests.swift +85 -0
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +454 -76
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/PipeClosureFixture.swift +215 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +10 -10
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingEngineDeliveryTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingFrozenPasteTargetTests.swift +50 -0
- package/src/native/Recordings/RecordingsTests/RecordingPasteTargetTrackerTests.swift +48 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +308 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +123 -21
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- package/src/native/Recordings/RecordingsTests/TestHomeDirectory.swift +5 -1
- package/src/native/Recordings/Updater/BootstrapPreflight/BootstrapPreflightMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/ApplicationNamespace.swift +2 -2
- package/src/native/Recordings/Updater/Broker/BrokerMain.swift +1 -1
- package/src/native/Recordings/Updater/Broker/CanonicalTreeCopy.swift +1 -1
- package/src/native/Recordings/Updater/Broker/InstallJournal.swift +1 -1
- package/src/native/Recordings/Updater/BrokerTests/ActivationRecoveryPolicyTests.swift +2 -2
- package/src/native/Recordings/Updater/Protocol/UpdateProtocol.swift +1 -1
- package/src/native/Recordings/Updater/VerifierLauncher/RecordingsVerifierLauncher.c +2 -2
- package/src/native/Recordings/build.sh +8 -7
- package/dist/__tests__/helpers/installer-guard-execution.d.ts +0 -22
- package/dist/__tests__/helpers/installer-guard-execution.d.ts.map +0 -1
- package/dist/__tests__/helpers/installer-preflight.d.ts +0 -22
- package/dist/__tests__/helpers/installer-preflight.d.ts.map +0 -1
- package/dist/__tests__/helpers/native-fs-guard.d.ts +0 -2
- package/dist/__tests__/helpers/native-fs-guard.d.ts.map +0 -1
- package/dist/__tests__/helpers/source-assertions.d.ts +0 -171
- package/dist/__tests__/helpers/source-assertions.d.ts.map +0 -1
- package/dist/__tests__/preload.d.ts +0 -2
- package/dist/__tests__/preload.d.ts.map +0 -1
- 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
|
-
|
|
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
|
|
44
|
-
name
|
|
45
|
-
path
|
|
46
|
-
description
|
|
47
|
-
|
|
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
|
+
}
|