@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.
- package/README.md +223 -37
- package/bun.lock +37 -38
- package/dist/cli/index.js +1884 -615
- package/dist/cli/macos-shortcut.d.ts +2 -2
- 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/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 +2858 -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 +1689 -456
- 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 +566 -284
- 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 +1334 -871
- 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/package.json +9 -8
- 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/install_macos_app.sh +27 -24
- package/scripts/macos_artifact.ts +20 -16
- package/scripts/migrate.ts +4 -20
- 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/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/Recording.swift +14 -2
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +316 -34
- package/src/native/Recordings/RecordingsLib/RecordingGlassBackground.swift +34 -0
- package/src/native/Recordings/RecordingsLib/RecordingProvider.swift +102 -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/CLIRunnerTests.swift +13 -4
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +43 -38
- package/src/native/Recordings/RecordingsTests/RecorderSurfaceTests.swift +60 -0
- package/src/native/Recordings/RecordingsTests/RecordingDateTests.swift +26 -0
- package/src/native/Recordings/RecordingsTests/RecordingProviderTests.swift +195 -0
- package/src/native/Recordings/RecordingsTests/RecordingStartGateTests.swift +1 -1
- package/src/native/Recordings/RecordingsTests/RecordingStartTimingTests.swift +30 -0
- package/src/native/Recordings/RecordingsTests/ServiceAPIConfigurationTests.swift +75 -0
- package/src/native/Recordings/RecordingsTests/SettingsWindowControllerTests.swift +28 -0
- 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 +6 -6
- 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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
|
8
|
-
@
|
|
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,
|
|
11
|
+
public init(engine: RecordingEngine, shortcuts: VoiceShortcuts, preferences: ProjectStore) {
|
|
11
12
|
self.engine = engine
|
|
12
13
|
self.shortcuts = shortcuts
|
|
13
|
-
self.
|
|
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
|
-
.
|
|
23
|
-
.
|
|
24
|
-
|
|
25
|
-
|
|
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(
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
try
|
|
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.
|
|
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: $
|
|
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:
|
|
153
|
-
try?
|
|
174
|
+
.onChange(of: preferences.settings.postProcessingMode) {
|
|
175
|
+
try? preferences.save()
|
|
154
176
|
}
|
|
155
|
-
TextEditor(text: $
|
|
156
|
-
.frame(height: 80)
|
|
157
|
-
.onChange(of:
|
|
158
|
-
try?
|
|
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(!
|
|
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
|
+
}
|