@hasna/recordings 0.1.23 → 0.1.25
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/LICENSE +191 -170
- package/README.md +16 -7
- package/dist/cli/index.js +119 -65
- package/dist/cli/storage.d.ts +3 -0
- package/dist/cli/storage.d.ts.map +1 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/storage-config.d.ts +27 -0
- package/dist/db/storage-config.d.ts.map +1 -0
- package/dist/db/storage-sync.d.ts +36 -0
- package/dist/db/storage-sync.d.ts.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +110 -50
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +1 -0
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/http.d.ts +13 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/index.d.ts +2 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +519 -408
- package/dist/mcp/storage-tools.d.ts +3 -0
- package/dist/mcp/storage-tools.d.ts.map +1 -0
- package/dist/storage.d.ts +7 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +5656 -0
- package/dist/version.d.ts +1 -1
- package/package.json +8 -3
- package/scripts/install_macos_app.sh +17 -0
- package/src/native/Recordings/App/RecordingsApp.swift +3 -2
- package/src/native/Recordings/RecordingsLib/FnKeyMonitor.swift +2 -0
- package/src/native/Recordings/RecordingsLib/MenuBarPopover.swift +101 -80
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +26 -0
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +13 -1
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +103 -31
- package/src/native/Recordings/RecordingsLib/SettingsView.swift +5 -1
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +12 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +49 -0
- package/src/native/Recordings/RecordingsTests/PasteTargetTests.swift +54 -0
- package/src/native/Recordings/RecordingsTests/TranscriptResolutionTests.swift +58 -0
- package/dist/cli/cloud.d.ts +0 -3
- package/dist/cli/cloud.d.ts.map +0 -1
- package/dist/db/cloud-config.d.ts +0 -14
- package/dist/db/cloud-config.d.ts.map +0 -1
- package/dist/db/cloud-sync.d.ts +0 -29
- package/dist/db/cloud-sync.d.ts.map +0 -1
- package/dist/mcp/cloud-tools.d.ts +0 -3
- package/dist/mcp/cloud-tools.d.ts.map +0 -1
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.1.
|
|
1
|
+
export declare const VERSION = "0.1.25";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/recordings",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Speech-to-text recording tool with MCP and CLI — records, transcribes, and optionally enhances text using AI",
|
|
6
6
|
"repository": {
|
|
@@ -17,13 +17,18 @@
|
|
|
17
17
|
".": {
|
|
18
18
|
"import": "./dist/index.js",
|
|
19
19
|
"types": "./dist/index.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./storage": {
|
|
22
|
+
"import": "./dist/storage.js",
|
|
23
|
+
"types": "./dist/storage.d.ts"
|
|
20
24
|
}
|
|
21
25
|
},
|
|
22
26
|
"scripts": {
|
|
23
|
-
"
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"build": "bun run clean && bun run build:cli && bun run build:mcp && bun run build:lib && tsc --emitDeclarationOnly --outDir dist",
|
|
24
29
|
"build:cli": "bun build src/cli/index.ts --target=bun --outfile=dist/cli/index.js --external=commander --external=chalk --external=openai",
|
|
25
30
|
"build:mcp": "bun build src/mcp/index.ts --target=bun --outfile=dist/mcp/index.js --external=@modelcontextprotocol/sdk --external=openai",
|
|
26
|
-
"build:lib": "bun build src/index.ts --target=bun --
|
|
31
|
+
"build:lib": "bun build src/index.ts src/storage.ts --target=bun --outdir=dist --external=openai",
|
|
27
32
|
"typecheck": "tsc --noEmit",
|
|
28
33
|
"test": "bun test",
|
|
29
34
|
"test:coverage": "bun test --coverage",
|
|
@@ -105,4 +105,21 @@ APP_CDHASH="$(current_cdhash "$APP_DEST" || true)"
|
|
|
105
105
|
reset_stale_permission "Microphone" "kTCCServiceMicrophone" "${HOME}/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
106
106
|
reset_stale_permission "Accessibility" "kTCCServiceAccessibility" "/Library/Application Support/com.apple.TCC/TCC.db" "$APP_CDHASH"
|
|
107
107
|
|
|
108
|
+
# A second copy in ~/Applications splits TCC permissions and leaves users running
|
|
109
|
+
# stale builds. Keep that launch point but always point it at the fresh build.
|
|
110
|
+
ALT_COPY="${HOME}/Applications/Recordings.app"
|
|
111
|
+
if [ -d "$ALT_COPY" ]; then
|
|
112
|
+
rm -rf "$ALT_COPY"
|
|
113
|
+
cp -R "$APP_SOURCE" "$ALT_COPY" || true
|
|
114
|
+
echo "Updated stale copy at ${ALT_COPY} to the freshly built app."
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# If an old instance is running, restart it on the new build.
|
|
118
|
+
if pgrep -x Recordings >/dev/null 2>&1; then
|
|
119
|
+
pkill -x Recordings || true
|
|
120
|
+
sleep 1
|
|
121
|
+
open "$APP_DEST" || true
|
|
122
|
+
echo "Restarted Recordings.app on the new build."
|
|
123
|
+
fi
|
|
124
|
+
|
|
108
125
|
echo "Installed Recordings.app from package: ${APP_DEST}"
|
|
@@ -25,10 +25,11 @@ struct RecordingsApp: App {
|
|
|
25
25
|
}
|
|
26
26
|
} label: {
|
|
27
27
|
if engine.isRecording {
|
|
28
|
-
Image(systemName: "
|
|
29
|
-
.
|
|
28
|
+
Image(systemName: "waveform")
|
|
29
|
+
.symbolEffect(.variableColor.iterative, isActive: true)
|
|
30
30
|
} else if engine.isTranscribing {
|
|
31
31
|
Image(systemName: "ellipsis.circle")
|
|
32
|
+
.symbolEffect(.pulse, isActive: true)
|
|
32
33
|
} else {
|
|
33
34
|
Image(systemName: "mic.fill")
|
|
34
35
|
}
|
|
@@ -12,6 +12,8 @@ final class FnKeyMonitor: @unchecked Sendable {
|
|
|
12
12
|
private var healthCheckTimer: Timer?
|
|
13
13
|
private var fnIsDown = false
|
|
14
14
|
|
|
15
|
+
var isRunning: Bool { eventTap != nil }
|
|
16
|
+
|
|
15
17
|
private static let fnKeyCode: UInt16 = 63
|
|
16
18
|
|
|
17
19
|
/// Start monitoring. Returns true if successful.
|
|
@@ -28,11 +28,10 @@ public struct MenuBarPopover: View {
|
|
|
28
28
|
VStack(spacing: 0) {
|
|
29
29
|
header
|
|
30
30
|
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
|
31
|
-
Divider()
|
|
32
31
|
modePicker
|
|
33
|
-
.padding(.horizontal, 16).padding(.top,
|
|
32
|
+
.padding(.horizontal, 16).padding(.top, 2)
|
|
34
33
|
recordingArea
|
|
35
|
-
.padding(.horizontal, 16).padding(.top,
|
|
34
|
+
.padding(.horizontal, 16).padding(.top, 10).padding(.bottom, 12)
|
|
36
35
|
Divider()
|
|
37
36
|
if !engine.recentTranscriptions.isEmpty {
|
|
38
37
|
filterBar
|
|
@@ -44,6 +43,8 @@ public struct MenuBarPopover: View {
|
|
|
44
43
|
Divider()
|
|
45
44
|
footerMenu
|
|
46
45
|
}
|
|
46
|
+
.animation(.smooth(duration: 0.25), value: engine.isRecording)
|
|
47
|
+
.animation(.smooth(duration: 0.25), value: engine.isTranscribing)
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
// MARK: - Header
|
|
@@ -54,16 +55,21 @@ public struct MenuBarPopover: View {
|
|
|
54
55
|
Image(systemName: "mic.fill")
|
|
55
56
|
.foregroundStyle(.tint)
|
|
56
57
|
Text("Hasna Recordings")
|
|
58
|
+
.font(.headline)
|
|
57
59
|
Spacer()
|
|
58
60
|
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
59
61
|
Text(shortcut.description)
|
|
62
|
+
.font(.caption)
|
|
60
63
|
.foregroundStyle(.secondary)
|
|
64
|
+
.padding(.horizontal, 7)
|
|
65
|
+
.padding(.vertical, 3)
|
|
66
|
+
.glassEffect(.regular, in: .capsule)
|
|
61
67
|
}
|
|
62
68
|
}
|
|
63
69
|
if !projectStore.settings.projects.isEmpty {
|
|
64
70
|
HStack(spacing: 4) {
|
|
65
71
|
Image(systemName: "folder.fill")
|
|
66
|
-
.
|
|
72
|
+
.foregroundStyle(projectStore.activeProject != nil ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
|
67
73
|
Menu {
|
|
68
74
|
Button("None") { projectStore.setActive(nil) }
|
|
69
75
|
Divider()
|
|
@@ -102,91 +108,103 @@ public struct MenuBarPopover: View {
|
|
|
102
108
|
}
|
|
103
109
|
}
|
|
104
110
|
.pickerStyle(.segmented)
|
|
111
|
+
.help(engine.mode.hint)
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
@ViewBuilder
|
|
108
115
|
private var recordingArea: some View {
|
|
109
116
|
if engine.isRecording {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.multilineTextAlignment(.leading)
|
|
125
|
-
.lineLimit(3)
|
|
126
|
-
.padding(.vertical, 4)
|
|
127
|
-
Text("Listening...")
|
|
128
|
-
.font(.caption)
|
|
129
|
-
.foregroundStyle(.green)
|
|
130
|
-
}
|
|
131
|
-
if let project = projectStore.activeProject {
|
|
132
|
-
HStack(spacing: 4) {
|
|
133
|
-
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
134
|
-
Text(project.name).foregroundStyle(.secondary)
|
|
135
|
-
Spacer()
|
|
136
|
-
}
|
|
137
|
-
}
|
|
117
|
+
VStack(spacing: 8) {
|
|
118
|
+
HStack {
|
|
119
|
+
Image(systemName: "waveform")
|
|
120
|
+
.symbolEffect(.variableColor.iterative, isActive: true)
|
|
121
|
+
.foregroundStyle(.red)
|
|
122
|
+
Text(fmt(engine.recordingDuration))
|
|
123
|
+
.monospacedDigit()
|
|
124
|
+
.contentTransition(.numericText())
|
|
125
|
+
.font(.title3.weight(.medium))
|
|
126
|
+
Spacer()
|
|
127
|
+
Button("Stop", systemImage: "stop.fill") { engine.stopAndTranscribe() }
|
|
128
|
+
.buttonStyle(.glassProminent)
|
|
129
|
+
.tint(.red)
|
|
130
|
+
.controlSize(.small)
|
|
138
131
|
}
|
|
139
|
-
.
|
|
140
|
-
|
|
132
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
133
|
+
Text(engine.liveTranscriptionText)
|
|
134
|
+
.font(.callout)
|
|
135
|
+
.foregroundStyle(.primary)
|
|
136
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
137
|
+
.lineLimit(3)
|
|
138
|
+
.contentTransition(.opacity)
|
|
139
|
+
} else {
|
|
140
|
+
Text("Listening…")
|
|
141
|
+
.font(.caption)
|
|
142
|
+
.foregroundStyle(.secondary)
|
|
143
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
144
|
+
}
|
|
145
|
+
if let project = projectStore.activeProject {
|
|
146
|
+
activeProjectTag(project)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
.padding(12)
|
|
150
|
+
.glassEffect(.regular.tint(.red.opacity(0.1)), in: .rect(cornerRadius: 14))
|
|
141
151
|
} else if engine.isTranscribing {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
.lineLimit(3)
|
|
156
|
-
.padding(.vertical, 4)
|
|
157
|
-
}
|
|
158
|
-
if let project = projectStore.activeProject {
|
|
159
|
-
HStack(spacing: 4) {
|
|
160
|
-
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
161
|
-
Text(project.name).foregroundStyle(.secondary)
|
|
162
|
-
Spacer()
|
|
163
|
-
}
|
|
164
|
-
}
|
|
152
|
+
VStack(spacing: 8) {
|
|
153
|
+
HStack(spacing: 8) {
|
|
154
|
+
ProgressView().controlSize(.small)
|
|
155
|
+
Text("Transcribing…")
|
|
156
|
+
.foregroundStyle(.secondary)
|
|
157
|
+
Spacer()
|
|
158
|
+
}
|
|
159
|
+
if !engine.liveTranscriptionText.isEmpty {
|
|
160
|
+
Text(engine.liveTranscriptionText)
|
|
161
|
+
.font(.callout)
|
|
162
|
+
.foregroundStyle(.primary)
|
|
163
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
164
|
+
.lineLimit(3)
|
|
165
165
|
}
|
|
166
|
-
.
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
if let project = projectStore.activeProject {
|
|
167
|
+
activeProjectTag(project)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
.frame(maxWidth: .infinity)
|
|
171
|
+
.padding(12)
|
|
172
|
+
.glassEffect(.regular, in: .rect(cornerRadius: 14))
|
|
169
173
|
} else {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
174
|
+
VStack(spacing: 8) {
|
|
175
|
+
Button {
|
|
176
|
+
engine.startRecording()
|
|
177
|
+
} label: {
|
|
178
|
+
Label("Record", systemImage: "mic.fill")
|
|
179
|
+
.frame(maxWidth: .infinity)
|
|
180
|
+
}
|
|
181
|
+
.buttonStyle(.glassProminent)
|
|
182
|
+
.controlSize(.large)
|
|
175
183
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
.foregroundStyle(.tertiary)
|
|
179
|
-
}
|
|
180
|
-
Text(engine.statusMessage)
|
|
184
|
+
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
185
|
+
Text("or hold \(shortcut.description)")
|
|
181
186
|
.font(.caption)
|
|
182
|
-
.foregroundStyle(
|
|
183
|
-
.multilineTextAlignment(.center)
|
|
184
|
-
.lineLimit(2)
|
|
187
|
+
.foregroundStyle(.tertiary)
|
|
185
188
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
+
Text(engine.statusMessage)
|
|
190
|
+
.font(.caption)
|
|
191
|
+
.foregroundStyle(engine.statusMessage == "Ready" ? Color.secondary : Color.orange)
|
|
192
|
+
.multilineTextAlignment(.center)
|
|
193
|
+
.lineLimit(2)
|
|
194
|
+
}
|
|
195
|
+
.frame(maxWidth: .infinity)
|
|
196
|
+
.padding(12)
|
|
197
|
+
.glassEffect(.regular, in: .rect(cornerRadius: 14))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private func activeProjectTag(_ project: RecProject) -> some View {
|
|
202
|
+
HStack(spacing: 4) {
|
|
203
|
+
Image(systemName: "folder.fill").foregroundStyle(.tint)
|
|
204
|
+
Text(project.name).foregroundStyle(.secondary)
|
|
205
|
+
Spacer()
|
|
189
206
|
}
|
|
207
|
+
.font(.caption)
|
|
190
208
|
}
|
|
191
209
|
|
|
192
210
|
// MARK: - Filter Bar
|
|
@@ -300,13 +318,16 @@ struct FilterChip: View {
|
|
|
300
318
|
var body: some View {
|
|
301
319
|
Button(action: action) {
|
|
302
320
|
Text(label)
|
|
303
|
-
.
|
|
304
|
-
.padding(.
|
|
305
|
-
.
|
|
306
|
-
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
321
|
+
.font(.caption)
|
|
322
|
+
.padding(.horizontal, 9)
|
|
323
|
+
.padding(.vertical, 4)
|
|
307
324
|
}
|
|
308
325
|
.buttonStyle(.plain)
|
|
309
326
|
.foregroundStyle(isActive ? .primary : .secondary)
|
|
327
|
+
.glassEffect(
|
|
328
|
+
isActive ? .regular.tint(.accentColor.opacity(0.25)).interactive() : .regular.interactive(),
|
|
329
|
+
in: .capsule
|
|
330
|
+
)
|
|
310
331
|
}
|
|
311
332
|
}
|
|
312
333
|
|
|
@@ -21,6 +21,32 @@ enum OpenAIAPIKeyStore {
|
|
|
21
21
|
return ""
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/// Persist the key into ~/.hasna/recordings/config.json so the CLI (which the app
|
|
25
|
+
/// shells out to for final transcription) uses the same key as the app itself.
|
|
26
|
+
static func save(key: String, homePath: String) throws {
|
|
27
|
+
let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
28
|
+
let dir = URL(fileURLWithPath: homePath)
|
|
29
|
+
.appendingPathComponent(".hasna")
|
|
30
|
+
.appendingPathComponent("recordings")
|
|
31
|
+
let url = dir.appendingPathComponent("config.json")
|
|
32
|
+
|
|
33
|
+
var json: [String: Any] = [:]
|
|
34
|
+
if let data = try? Data(contentsOf: url),
|
|
35
|
+
let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
36
|
+
json = existing
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if trimmed.isEmpty {
|
|
40
|
+
json.removeValue(forKey: "openai_api_key")
|
|
41
|
+
} else {
|
|
42
|
+
json["openai_api_key"] = trimmed
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
46
|
+
let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])
|
|
47
|
+
try data.write(to: url, options: .atomic)
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
private static func loadConfigKey(homePath: String, environment: [String: String]) -> String? {
|
|
25
51
|
let url = URL(fileURLWithPath: homePath)
|
|
26
52
|
.appendingPathComponent(".hasna")
|
|
@@ -166,6 +166,12 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
166
166
|
/// Returns the final accumulated transcription text.
|
|
167
167
|
@discardableResult
|
|
168
168
|
public func stop() -> String {
|
|
169
|
+
if isStreaming {
|
|
170
|
+
NativeAppLog.write(
|
|
171
|
+
"realtime session summary items=\(itemOrder.count) committed=\(committedItemIDs.count) completed=\(completedItemIDs.count) deltaChars=\(deltaTextByItem.values.map(\.count).reduce(0, +)) finalChars=\(accumulatedText.count) error=\(error ?? "none")",
|
|
172
|
+
homePath: homePath
|
|
173
|
+
)
|
|
174
|
+
}
|
|
169
175
|
isStreaming = false
|
|
170
176
|
isConfigured = false
|
|
171
177
|
receiveTask?.cancel()
|
|
@@ -256,7 +262,13 @@ public final class RealtimeTranscriptionClient: ObservableObject, @unchecked Sen
|
|
|
256
262
|
if let detail = json["error"] as? [String: Any],
|
|
257
263
|
let msg = detail["message"] as? String {
|
|
258
264
|
fputs("[RealtimeClient] Error: \(msg)\n", stderr)
|
|
259
|
-
|
|
265
|
+
// "buffer too small" on commit just means server VAD already consumed
|
|
266
|
+
// the audio — the transcript is intact, so don't surface it as a failure.
|
|
267
|
+
if msg.localizedCaseInsensitiveContains("buffer too small") {
|
|
268
|
+
NativeAppLog.write("realtime benign commit error: \(msg)", homePath: homePath)
|
|
269
|
+
} else {
|
|
270
|
+
self.error = msg
|
|
271
|
+
}
|
|
260
272
|
}
|
|
261
273
|
|
|
262
274
|
default:
|
|
@@ -137,6 +137,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
137
137
|
|
|
138
138
|
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
139
139
|
private let fnMonitor = FnKeyMonitor()
|
|
140
|
+
private var permissionRetryTimer: Timer?
|
|
140
141
|
|
|
141
142
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
142
143
|
private var audioDir: String { "\(home)/.hasna/recordings/audio" }
|
|
@@ -193,6 +194,16 @@ public final class RecordingEngine: ObservableObject {
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
// Granting Accessibility does not revive a tap that failed to create,
|
|
198
|
+
// so retry until permissions arrive instead of requiring a relaunch.
|
|
199
|
+
permissionRetryTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
|
200
|
+
Task { @MainActor [weak self] in
|
|
201
|
+
guard let self, self.useFnKey, !self.fnMonitor.isRunning, AXIsProcessTrusted() else { return }
|
|
202
|
+
self.log("accessibility granted — retrying fn monitor")
|
|
203
|
+
self.updateFnMonitor()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
196
207
|
updateStatus()
|
|
197
208
|
}
|
|
198
209
|
|
|
@@ -411,7 +422,9 @@ public final class RecordingEngine: ObservableObject {
|
|
|
411
422
|
if let message = client.error, !message.isEmpty {
|
|
412
423
|
await MainActor.run {
|
|
413
424
|
self.log("realtime unavailable message=\(message)")
|
|
414
|
-
self.
|
|
425
|
+
if self.isRecording {
|
|
426
|
+
self.statusMessage = "Live preview unavailable — will transcribe after recording"
|
|
427
|
+
}
|
|
415
428
|
}
|
|
416
429
|
}
|
|
417
430
|
}
|
|
@@ -464,21 +477,33 @@ public final class RecordingEngine: ObservableObject {
|
|
|
464
477
|
self.liveTranscriptionText = ""
|
|
465
478
|
|
|
466
479
|
if let audioPath, self.writeCapturedWAV(to: audioPath) {
|
|
467
|
-
|
|
468
|
-
self.log("realtime preview ignored for final chars=\(realtimeText.count)")
|
|
469
|
-
}
|
|
470
|
-
self.log("transcribing captured full audio audioPath=\(audioPath)")
|
|
480
|
+
self.log("transcribing captured full audio audioPath=\(audioPath) realtimePreviewChars=\(realtimeText?.count ?? 0)")
|
|
471
481
|
self.fallbackTranscribe(
|
|
472
482
|
audioPath: audioPath,
|
|
473
483
|
curMode: curMode,
|
|
474
484
|
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
475
485
|
targetAppPid: targetAppPid,
|
|
476
486
|
activeProjectId: activeProjectId,
|
|
477
|
-
activeProjectName: activeProjectName
|
|
487
|
+
activeProjectName: activeProjectName,
|
|
488
|
+
realtimeText: realtimeText
|
|
478
489
|
)
|
|
479
490
|
} else {
|
|
480
|
-
|
|
481
|
-
|
|
491
|
+
let resolved = Self.resolveFinalTranscript(cliText: nil, cliError: "No audio captured", realtimeText: realtimeText)
|
|
492
|
+
if let text = resolved.text {
|
|
493
|
+
self.log("no audio file written; using realtime transcript chars=\(text.count)")
|
|
494
|
+
self.isTranscribing = false
|
|
495
|
+
self.finishWithText(
|
|
496
|
+
text,
|
|
497
|
+
curMode: curMode,
|
|
498
|
+
targetAppBundleIdentifier: targetAppBundleIdentifier,
|
|
499
|
+
targetAppPid: targetAppPid,
|
|
500
|
+
activeProjectId: activeProjectId,
|
|
501
|
+
activeProjectName: activeProjectName
|
|
502
|
+
)
|
|
503
|
+
} else {
|
|
504
|
+
self.log("no audio captured")
|
|
505
|
+
self.finish(resolved.failureStatus ?? "No audio captured")
|
|
506
|
+
}
|
|
482
507
|
}
|
|
483
508
|
|
|
484
509
|
self.activeAudioPath = nil
|
|
@@ -502,7 +527,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
502
527
|
|
|
503
528
|
let shortcutText = voiceShortcuts?.match(text)
|
|
504
529
|
let output = shortcutText ?? text
|
|
505
|
-
pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid)
|
|
530
|
+
pasteIntoFrontApp(output, targetAppBundleIdentifier: targetAppBundleIdentifier, targetAppPid: targetAppPid, restoreClipboard: true)
|
|
506
531
|
recentTranscriptions.insert(
|
|
507
532
|
TranscriptionResult(
|
|
508
533
|
rawText: text,
|
|
@@ -584,7 +609,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
584
609
|
|
|
585
610
|
// MARK: - Fallback Transcription
|
|
586
611
|
|
|
587
|
-
private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?) {
|
|
612
|
+
private func fallbackTranscribe(audioPath: String, curMode: RecordingMode, targetAppBundleIdentifier: String?, targetAppPid: pid_t?, activeProjectId: String?, activeProjectName: String?, realtimeText: String? = nil) {
|
|
588
613
|
let homePath = home
|
|
589
614
|
|
|
590
615
|
isTranscribing = true
|
|
@@ -592,25 +617,27 @@ public final class RecordingEngine: ObservableObject {
|
|
|
592
617
|
|
|
593
618
|
Task.detached {
|
|
594
619
|
let output = CLIRunner.run(["--json", "transcribe", audioPath, "--no-enhance"], home: homePath)
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
620
|
+
let cliError = CLIRunner.parseError(output)
|
|
621
|
+
let cliText = cliError == nil ? CLIRunner.parseJSON(output) : nil
|
|
622
|
+
|
|
623
|
+
await MainActor.run {
|
|
624
|
+
if let cliError {
|
|
625
|
+
self.log("cli transcription failed error=\(cliError)")
|
|
626
|
+
} else if cliText == nil {
|
|
627
|
+
self.log("cli transcription empty output=\(output.prefix(160))")
|
|
599
628
|
}
|
|
600
|
-
return
|
|
601
|
-
}
|
|
602
629
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
self.finish("Empty transcription")
|
|
630
|
+
let resolved = Self.resolveFinalTranscript(cliText: cliText, cliError: cliError, realtimeText: realtimeText)
|
|
631
|
+
guard let text = resolved.text else {
|
|
632
|
+
self.finish(resolved.failureStatus ?? "Transcription failed")
|
|
633
|
+
return
|
|
608
634
|
}
|
|
609
|
-
return
|
|
610
|
-
}
|
|
611
635
|
|
|
612
|
-
|
|
613
|
-
|
|
636
|
+
if cliText == nil {
|
|
637
|
+
self.log("using realtime transcript fallback chars=\(text.count)")
|
|
638
|
+
} else {
|
|
639
|
+
self.log("cli transcription succeeded chars=\(text.count)")
|
|
640
|
+
}
|
|
614
641
|
self.isTranscribing = false
|
|
615
642
|
self.finishWithText(
|
|
616
643
|
text,
|
|
@@ -707,9 +734,10 @@ public final class RecordingEngine: ObservableObject {
|
|
|
707
734
|
|
|
708
735
|
// MARK: - Paste
|
|
709
736
|
|
|
710
|
-
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil) {
|
|
737
|
+
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil, targetAppPid: pid_t? = nil, restoreClipboard: Bool = false) {
|
|
711
738
|
log("paste requested chars=\(text.count) target=\(targetAppBundleIdentifier ?? "nil") pid=\(targetAppPid.map(String.init) ?? "nil") accessibility=\(AXIsProcessTrusted())")
|
|
712
739
|
let pb = NSPasteboard.general
|
|
740
|
+
let previousClipboard = restoreClipboard ? pb.string(forType: .string) : nil
|
|
713
741
|
pb.clearContents()
|
|
714
742
|
pb.setString(text, forType: .string)
|
|
715
743
|
|
|
@@ -724,6 +752,7 @@ public final class RecordingEngine: ObservableObject {
|
|
|
724
752
|
|
|
725
753
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
726
754
|
let runningApps = NSWorkspace.shared.runningApplications
|
|
755
|
+
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
|
727
756
|
let candidates = runningApps.map {
|
|
728
757
|
PasteTargetCandidate(
|
|
729
758
|
pid: $0.processIdentifier,
|
|
@@ -735,7 +764,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
735
764
|
candidates: candidates,
|
|
736
765
|
currentPid: myPID,
|
|
737
766
|
targetBundleIdentifier: targetAppBundleIdentifier,
|
|
738
|
-
targetPid: targetAppPid
|
|
767
|
+
targetPid: targetAppPid,
|
|
768
|
+
frontmostPid: frontmostPid
|
|
739
769
|
)
|
|
740
770
|
let targetApp = selectedTarget.flatMap { selected in
|
|
741
771
|
runningApps.first { $0.processIdentifier == selected.pid }
|
|
@@ -748,9 +778,13 @@ public final class RecordingEngine: ObservableObject {
|
|
|
748
778
|
}
|
|
749
779
|
|
|
750
780
|
// Activate the exact app that owned focus when recording started, then paste after focus settles.
|
|
751
|
-
app.
|
|
781
|
+
let alreadyFrontmost = app.processIdentifier == frontmostPid
|
|
782
|
+
if !alreadyFrontmost {
|
|
783
|
+
app.activate(options: [.activateIgnoringOtherApps])
|
|
784
|
+
}
|
|
752
785
|
|
|
753
|
-
|
|
786
|
+
let pasteDelay: TimeInterval = alreadyFrontmost ? 0.15 : 0.5
|
|
787
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + pasteDelay) {
|
|
754
788
|
let src = CGEventSource(stateID: .hidSystemState)
|
|
755
789
|
if let down = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: true),
|
|
756
790
|
let up = CGEvent(keyboardEventSource: src, virtualKey: 0x09, keyDown: false) {
|
|
@@ -759,8 +793,19 @@ public final class RecordingEngine: ObservableObject {
|
|
|
759
793
|
down.post(tap: .cgSessionEventTap)
|
|
760
794
|
up.post(tap: .cgSessionEventTap)
|
|
761
795
|
}
|
|
762
|
-
self.log("paste event posted")
|
|
796
|
+
self.log("paste event posted target=\(app.bundleIdentifier ?? "?") alreadyFrontmost=\(alreadyFrontmost)")
|
|
763
797
|
self.statusMessage = "Pasted (\(text.count) chars)"
|
|
798
|
+
|
|
799
|
+
if let previousClipboard {
|
|
800
|
+
// Give the target app time to consume the paste, then hand the clipboard back.
|
|
801
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
|
|
802
|
+
let pb = NSPasteboard.general
|
|
803
|
+
if pb.string(forType: .string) == text {
|
|
804
|
+
pb.clearContents()
|
|
805
|
+
pb.setString(previousClipboard, forType: .string)
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
764
809
|
}
|
|
765
810
|
}
|
|
766
811
|
|
|
@@ -768,7 +813,8 @@ public final class RecordingEngine: ObservableObject {
|
|
|
768
813
|
candidates: [PasteTargetCandidate],
|
|
769
814
|
currentPid: pid_t,
|
|
770
815
|
targetBundleIdentifier: String?,
|
|
771
|
-
targetPid: pid_t
|
|
816
|
+
targetPid: pid_t?,
|
|
817
|
+
frontmostPid: pid_t? = nil
|
|
772
818
|
) -> PasteTargetCandidate? {
|
|
773
819
|
candidates.first {
|
|
774
820
|
guard let targetPid else { return false }
|
|
@@ -777,11 +823,28 @@ public final class RecordingEngine: ObservableObject {
|
|
|
777
823
|
guard $0.pid != currentPid, $0.isRegularApp else { return false }
|
|
778
824
|
guard let targetBundleIdentifier else { return false }
|
|
779
825
|
return $0.bundleIdentifier == targetBundleIdentifier
|
|
826
|
+
} ?? candidates.first {
|
|
827
|
+
guard let frontmostPid else { return false }
|
|
828
|
+
return $0.pid == frontmostPid && $0.pid != currentPid && $0.isRegularApp
|
|
780
829
|
} ?? candidates.first {
|
|
781
830
|
$0.isRegularApp && $0.pid != currentPid
|
|
782
831
|
}
|
|
783
832
|
}
|
|
784
833
|
|
|
834
|
+
nonisolated static func resolveFinalTranscript(
|
|
835
|
+
cliText: String?,
|
|
836
|
+
cliError: String?,
|
|
837
|
+
realtimeText: String?
|
|
838
|
+
) -> (text: String?, failureStatus: String?) {
|
|
839
|
+
if let cliText, !cliText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
840
|
+
return (cliText, nil)
|
|
841
|
+
}
|
|
842
|
+
if let realtimeText, !realtimeText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
843
|
+
return (realtimeText, nil)
|
|
844
|
+
}
|
|
845
|
+
return (nil, cliError ?? "Empty transcription")
|
|
846
|
+
}
|
|
847
|
+
|
|
785
848
|
private func ensureAccessibilityPermission(prompt: Bool) -> Bool {
|
|
786
849
|
if AXIsProcessTrusted() {
|
|
787
850
|
return true
|
|
@@ -861,6 +924,15 @@ enum CLIRunner: Sendable {
|
|
|
861
924
|
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
862
925
|
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
863
926
|
let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
927
|
+
let lowercased = message.lowercased()
|
|
928
|
+
if lowercased.contains("401") || lowercased.contains("incorrect api key")
|
|
929
|
+
|| lowercased.contains("invalid_api_key") || lowercased.contains("invalid or expired") {
|
|
930
|
+
return "OpenAI API key invalid or expired — update it in Recordings Settings"
|
|
931
|
+
}
|
|
932
|
+
if lowercased.contains("429") || lowercased.contains("exceeded your current quota")
|
|
933
|
+
|| lowercased.contains("insufficient_quota") || lowercased.contains("quota exceeded") {
|
|
934
|
+
return "OpenAI quota exceeded — check the OpenAI account billing"
|
|
935
|
+
}
|
|
864
936
|
if message.contains("OpenAI API key not configured") {
|
|
865
937
|
return "OpenAI API key not configured on this Mac"
|
|
866
938
|
}
|
|
@@ -29,7 +29,11 @@ public struct SettingsView: View {
|
|
|
29
29
|
Section("OpenAI") {
|
|
30
30
|
SecureField("API key", text: $openAIAPIKey)
|
|
31
31
|
.textFieldStyle(.roundedBorder)
|
|
32
|
-
|
|
32
|
+
.onChange(of: openAIAPIKey) {
|
|
33
|
+
// Keep the CLI config in sync — final transcription shells out to it.
|
|
34
|
+
try? OpenAIAPIKeyStore.save(key: openAIAPIKey, homePath: engine.home)
|
|
35
|
+
}
|
|
36
|
+
Text("Used for live transcription and the final paste. Stored in ~/.hasna/recordings/config.json.")
|
|
33
37
|
.foregroundStyle(.secondary)
|
|
34
38
|
}
|
|
35
39
|
|