@hasna/recordings 0.1.9 → 0.1.11
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/.takumi/settings.local.json +7 -0
- package/README.md +52 -0
- package/bun.lock +2 -2
- package/dist/cli/index.js +3866 -3279
- package/dist/index.js +3848 -3174
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts +2 -2
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/mcp/index.js +9865 -8535
- package/package.json +3 -3
- package/src/__tests__/enhancer.test.ts +65 -0
- package/src/cli/index.ts +3 -131
- package/src/lib/config.ts +56 -16
- package/src/lib/enhancer.ts +17 -11
- package/src/mcp/index.ts +2 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +58 -49
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +247 -118
- package/src/native/Recordings/Recordings/ProjectStore.swift +126 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +157 -57
- package/src/native/Recordings/Recordings/RecordingsApp.swift +12 -4
- package/src/native/Recordings/Recordings/SettingsView.swift +147 -36
- package/src/native/RecordingsHelper.swift +0 -395
|
@@ -4,7 +4,7 @@ import KeyboardShortcuts
|
|
|
4
4
|
// MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
|
|
5
5
|
|
|
6
6
|
extension KeyboardShortcuts.Name {
|
|
7
|
-
static let toggleRecording = Self("toggleRecording")
|
|
7
|
+
static let toggleRecording = Self("toggleRecording", default: .init(.f5))
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
// MARK: - Recording Mode
|
|
@@ -24,9 +24,9 @@ enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
|
|
|
24
24
|
}
|
|
25
25
|
var hint: String {
|
|
26
26
|
switch self {
|
|
27
|
-
case .pushToTalk: return "Hold
|
|
28
|
-
case .dictation: return "Hold
|
|
29
|
-
case .command: return "Select text, hold
|
|
27
|
+
case .pushToTalk: return "Hold F5 or your chosen shortcut to record, then release to paste"
|
|
28
|
+
case .dictation: return "Hold F5 or your chosen shortcut to dictate, then release to paste"
|
|
29
|
+
case .command: return "Select text, then hold F5 or your chosen shortcut and release to rewrite"
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -37,16 +37,24 @@ struct TranscriptionResult: Sendable {
|
|
|
37
37
|
let rawText: String
|
|
38
38
|
let processedText: String?
|
|
39
39
|
let timestamp: Date
|
|
40
|
+
let projectId: String?
|
|
41
|
+
let projectName: String?
|
|
40
42
|
var displayText: String { processedText ?? rawText }
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
enum RecordingTrigger {
|
|
46
|
+
case manual
|
|
47
|
+
case fnKey
|
|
48
|
+
case keyboardShortcut
|
|
49
|
+
}
|
|
50
|
+
|
|
43
51
|
// MARK: - Recording Engine
|
|
44
52
|
|
|
45
53
|
@MainActor
|
|
46
54
|
final class RecordingEngine: ObservableObject {
|
|
47
55
|
@Published var isRecording = false
|
|
48
56
|
@Published var mode: RecordingMode = .pushToTalk
|
|
49
|
-
@Published var useFnKey: Bool =
|
|
57
|
+
@Published var useFnKey: Bool = false {
|
|
50
58
|
didSet {
|
|
51
59
|
UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
|
|
52
60
|
updateFnMonitor()
|
|
@@ -63,7 +71,12 @@ final class RecordingEngine: ObservableObject {
|
|
|
63
71
|
private var stdinPipe: Pipe?
|
|
64
72
|
private var currentAudioPath: String?
|
|
65
73
|
private var recordingTimer: Timer?
|
|
74
|
+
private var activeTrigger: RecordingTrigger?
|
|
75
|
+
private var keyboardShortcutIsDown = false
|
|
76
|
+
private var targetAppBundleIdentifier: String?
|
|
77
|
+
private var targetAppPid: pid_t?
|
|
66
78
|
private let maxDuration = 300
|
|
79
|
+
var projectStore: ProjectStore?
|
|
67
80
|
|
|
68
81
|
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
69
82
|
private let fnMonitor = FnKeyMonitor()
|
|
@@ -75,27 +88,40 @@ final class RecordingEngine: ObservableObject {
|
|
|
75
88
|
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
76
89
|
|
|
77
90
|
// Load preferences
|
|
78
|
-
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ??
|
|
91
|
+
useFnKey = UserDefaults.standard.object(forKey: "useFnKey") as? Bool ?? false
|
|
92
|
+
if KeyboardShortcuts.getShortcut(for: .toggleRecording) == nil {
|
|
93
|
+
KeyboardShortcuts.setShortcut(.init(.f5), for: .toggleRecording)
|
|
94
|
+
}
|
|
79
95
|
|
|
80
96
|
// Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
|
|
81
97
|
fnMonitor.onFnKeyDown = { [weak self] in
|
|
82
98
|
Task { @MainActor [weak self] in
|
|
83
99
|
guard let self, self.useFnKey, !self.isRecording else { return }
|
|
84
|
-
self.startRecording()
|
|
100
|
+
self.startRecording(trigger: .fnKey)
|
|
85
101
|
}
|
|
86
102
|
}
|
|
87
103
|
fnMonitor.onFnKeyUp = { [weak self] in
|
|
88
104
|
Task { @MainActor [weak self] in
|
|
89
|
-
guard let self, self.useFnKey, self.isRecording else { return }
|
|
105
|
+
guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
|
|
90
106
|
self.stopAndTranscribe()
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
109
|
updateFnMonitor()
|
|
94
110
|
|
|
95
|
-
|
|
111
|
+
KeyboardShortcuts.onKeyDown(for: .toggleRecording) { [weak self] in
|
|
112
|
+
Task { @MainActor [weak self] in
|
|
113
|
+
guard let self, !self.keyboardShortcutIsDown else { return }
|
|
114
|
+
self.keyboardShortcutIsDown = true
|
|
115
|
+
guard !self.isRecording else { return }
|
|
116
|
+
self.startRecording(trigger: .keyboardShortcut)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
96
119
|
KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
|
|
97
120
|
Task { @MainActor [weak self] in
|
|
98
|
-
self
|
|
121
|
+
guard let self, self.keyboardShortcutIsDown else { return }
|
|
122
|
+
self.keyboardShortcutIsDown = false
|
|
123
|
+
guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
|
|
124
|
+
self.stopAndTranscribe()
|
|
99
125
|
}
|
|
100
126
|
}
|
|
101
127
|
|
|
@@ -106,7 +132,7 @@ final class RecordingEngine: ObservableObject {
|
|
|
106
132
|
if useFnKey {
|
|
107
133
|
let ok = fnMonitor.start()
|
|
108
134
|
if !ok {
|
|
109
|
-
statusMessage = "fn needs Accessibility permission
|
|
135
|
+
statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
|
|
110
136
|
}
|
|
111
137
|
} else {
|
|
112
138
|
fnMonitor.stop()
|
|
@@ -115,32 +141,36 @@ final class RecordingEngine: ObservableObject {
|
|
|
115
141
|
|
|
116
142
|
func updateStatus() {
|
|
117
143
|
if isRecording || isTranscribing { return }
|
|
118
|
-
|
|
119
|
-
var parts: [String] = []
|
|
120
|
-
if useFnKey {
|
|
121
|
-
parts.append("fn (hold)")
|
|
122
|
-
}
|
|
123
|
-
if let shortcut = KeyboardShortcuts.getShortcut(for: .toggleRecording) {
|
|
124
|
-
parts.append(shortcut.description)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
if parts.isEmpty {
|
|
128
|
-
statusMessage = "No shortcut set — enable fn or set a custom shortcut"
|
|
129
|
-
} else {
|
|
130
|
-
statusMessage = "Ready — press \(parts.joined(separator: " or ")) to record"
|
|
131
|
-
}
|
|
144
|
+
statusMessage = "Ready"
|
|
132
145
|
}
|
|
133
146
|
|
|
134
147
|
// MARK: - Toggle
|
|
135
148
|
|
|
136
149
|
func toggleRecording() {
|
|
137
|
-
if isRecording { stopAndTranscribe() } else { startRecording() }
|
|
150
|
+
if isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
|
|
138
151
|
}
|
|
139
152
|
|
|
140
153
|
// MARK: - Start Recording
|
|
141
154
|
|
|
142
|
-
func startRecording() {
|
|
155
|
+
func startRecording(trigger: RecordingTrigger = .manual) {
|
|
143
156
|
guard !isRecording else { return }
|
|
157
|
+
activeTrigger = trigger
|
|
158
|
+
keyboardShortcutIsDown = trigger == .keyboardShortcut
|
|
159
|
+
|
|
160
|
+
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
161
|
+
let frontmostApp = NSWorkspace.shared.frontmostApplication
|
|
162
|
+
let isOwnApp = frontmostApp?.processIdentifier == myPID
|
|
163
|
+
targetAppBundleIdentifier = isOwnApp ? nil : frontmostApp?.bundleIdentifier
|
|
164
|
+
targetAppPid = isOwnApp ? nil : frontmostApp?.processIdentifier
|
|
165
|
+
|
|
166
|
+
if let store = projectStore {
|
|
167
|
+
let windowTitle = Self.focusedWindowTitle(pid: frontmostApp?.processIdentifier)
|
|
168
|
+
let projects = store.settings.projects
|
|
169
|
+
let detected = ProjectStore.matchProject(windowTitle: windowTitle, bundleId: targetAppBundleIdentifier, projects: projects)
|
|
170
|
+
if let detected {
|
|
171
|
+
store.setActive(detected.id)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
144
174
|
|
|
145
175
|
let ts = DateFormatter()
|
|
146
176
|
ts.dateFormat = "yyyyMMdd'T'HHmmss"
|
|
@@ -149,11 +179,12 @@ final class RecordingEngine: ObservableObject {
|
|
|
149
179
|
|
|
150
180
|
let proc = Process()
|
|
151
181
|
let stdin = Pipe()
|
|
182
|
+
let stderrPipe = Pipe()
|
|
152
183
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
153
184
|
proc.arguments = ["-c", """
|
|
154
185
|
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
155
186
|
if command -v ffmpeg &>/dev/null; then
|
|
156
|
-
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y
|
|
187
|
+
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y
|
|
157
188
|
elif command -v rec &>/dev/null; then
|
|
158
189
|
rec -r 16000 -c 1 -b 16 "\(path)" trim 0 \(maxDuration)
|
|
159
190
|
else
|
|
@@ -162,7 +193,7 @@ final class RecordingEngine: ObservableObject {
|
|
|
162
193
|
"""]
|
|
163
194
|
proc.standardInput = stdin
|
|
164
195
|
proc.standardOutput = FileHandle.nullDevice
|
|
165
|
-
proc.standardError =
|
|
196
|
+
proc.standardError = stderrPipe
|
|
166
197
|
|
|
167
198
|
do {
|
|
168
199
|
try proc.run()
|
|
@@ -170,7 +201,11 @@ final class RecordingEngine: ObservableObject {
|
|
|
170
201
|
stdinPipe = stdin
|
|
171
202
|
isRecording = true
|
|
172
203
|
recordingDuration = 0
|
|
173
|
-
statusMessage = mode
|
|
204
|
+
statusMessage = switch (mode, trigger) {
|
|
205
|
+
case (.command, _): "Speak your instruction..."
|
|
206
|
+
case (_, .manual): "Recording — click Stop when finished"
|
|
207
|
+
case (_, .fnKey), (_, .keyboardShortcut): "Recording — release to stop"
|
|
208
|
+
}
|
|
174
209
|
|
|
175
210
|
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
176
211
|
Task { @MainActor [weak self] in
|
|
@@ -178,6 +213,9 @@ final class RecordingEngine: ObservableObject {
|
|
|
178
213
|
}
|
|
179
214
|
}
|
|
180
215
|
} catch {
|
|
216
|
+
activeTrigger = nil
|
|
217
|
+
keyboardShortcutIsDown = false
|
|
218
|
+
targetAppBundleIdentifier = nil
|
|
181
219
|
statusMessage = "Failed: \(error.localizedDescription)"
|
|
182
220
|
}
|
|
183
221
|
}
|
|
@@ -204,6 +242,14 @@ final class RecordingEngine: ObservableObject {
|
|
|
204
242
|
currentAudioPath = nil
|
|
205
243
|
recordProcess = nil
|
|
206
244
|
stdinPipe = nil
|
|
245
|
+
let targetAppBundleIdentifier = targetAppBundleIdentifier
|
|
246
|
+
let systemPrompt = projectStore?.effectiveSystemPrompt ?? ""
|
|
247
|
+
let activeProjectId = projectStore?.settings.activeProjectId
|
|
248
|
+
let activeProjectName = projectStore?.activeProject?.name
|
|
249
|
+
activeTrigger = nil
|
|
250
|
+
keyboardShortcutIsDown = false
|
|
251
|
+
self.targetAppBundleIdentifier = nil
|
|
252
|
+
self.targetAppPid = nil
|
|
207
253
|
let homePath = home
|
|
208
254
|
|
|
209
255
|
Task.detached {
|
|
@@ -212,6 +258,12 @@ final class RecordingEngine: ObservableObject {
|
|
|
212
258
|
try? await Task.sleep(for: .milliseconds(300))
|
|
213
259
|
if proc.isRunning { proc.interrupt() }
|
|
214
260
|
|
|
261
|
+
// Check if ffmpeg exited with an error (e.g. no mic permission)
|
|
262
|
+
if proc.terminationStatus != 0, proc.terminationStatus != 1 {
|
|
263
|
+
await MainActor.run { self.finish("Recording failed (mic access denied — check System Settings > Privacy & Security > Microphone)") }
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
215
267
|
guard let audioPath else {
|
|
216
268
|
await MainActor.run { self.finish("No audio file") }
|
|
217
269
|
return
|
|
@@ -220,13 +272,21 @@ final class RecordingEngine: ObservableObject {
|
|
|
220
272
|
let attrs = try? FileManager.default.attributesOfItem(atPath: audioPath)
|
|
221
273
|
let size = (attrs?[.size] as? Int) ?? 0
|
|
222
274
|
guard size >= 1000 else {
|
|
223
|
-
await MainActor.run { self.finish("
|
|
275
|
+
await MainActor.run { self.finish("No audio captured — check microphone permissions") }
|
|
224
276
|
return
|
|
225
277
|
}
|
|
226
278
|
|
|
227
|
-
|
|
228
|
-
|
|
279
|
+
var cliArgs = ["transcribe", audioPath, "--json"]
|
|
280
|
+
if !systemPrompt.isEmpty {
|
|
281
|
+
cliArgs += ["--system-prompt", systemPrompt]
|
|
282
|
+
}
|
|
283
|
+
let output = CLIRunner.run(cliArgs, home: homePath)
|
|
284
|
+
if let error = CLIRunner.parseError(output) {
|
|
285
|
+
await MainActor.run { self.finish(error) }
|
|
286
|
+
return
|
|
287
|
+
}
|
|
229
288
|
|
|
289
|
+
let text = CLIRunner.parseJSON(output)
|
|
230
290
|
guard let text, !text.isEmpty else {
|
|
231
291
|
await MainActor.run { self.finish("Empty transcription") }
|
|
232
292
|
return
|
|
@@ -237,12 +297,12 @@ final class RecordingEngine: ObservableObject {
|
|
|
237
297
|
if curMode == .command {
|
|
238
298
|
self.runCommandMode(instruction: text)
|
|
239
299
|
} else {
|
|
240
|
-
self.pasteIntoFrontApp(text)
|
|
300
|
+
self.pasteIntoFrontApp(text, targetAppBundleIdentifier: targetAppBundleIdentifier)
|
|
241
301
|
self.recentTranscriptions.insert(
|
|
242
|
-
TranscriptionResult(rawText: text, processedText: nil, timestamp: Date()), at: 0
|
|
302
|
+
TranscriptionResult(rawText: text, processedText: nil, timestamp: Date(), projectId: activeProjectId, projectName: activeProjectName), at: 0
|
|
243
303
|
)
|
|
244
304
|
if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
|
|
245
|
-
|
|
305
|
+
// statusMessage updated by pasteIntoFrontApp
|
|
246
306
|
}
|
|
247
307
|
}
|
|
248
308
|
}
|
|
@@ -250,7 +310,7 @@ final class RecordingEngine: ObservableObject {
|
|
|
250
310
|
|
|
251
311
|
private func finish(_ msg: String) {
|
|
252
312
|
isTranscribing = false
|
|
253
|
-
|
|
313
|
+
updateStatus()
|
|
254
314
|
}
|
|
255
315
|
|
|
256
316
|
// MARK: - Command Mode
|
|
@@ -285,35 +345,53 @@ final class RecordingEngine: ObservableObject {
|
|
|
285
345
|
}
|
|
286
346
|
}
|
|
287
347
|
|
|
348
|
+
// MARK: - Window Title (Accessibility API)
|
|
349
|
+
|
|
350
|
+
private static func focusedWindowTitle(pid: pid_t?) -> String? {
|
|
351
|
+
guard let pid else { return nil }
|
|
352
|
+
let app = AXUIElementCreateApplication(pid)
|
|
353
|
+
var windowRef: CFTypeRef?
|
|
354
|
+
guard AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
|
|
355
|
+
let window = windowRef else { return nil }
|
|
356
|
+
var titleRef: CFTypeRef?
|
|
357
|
+
guard AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleRef) == .success,
|
|
358
|
+
let title = titleRef as? String else { return nil }
|
|
359
|
+
return title
|
|
360
|
+
}
|
|
361
|
+
|
|
288
362
|
// MARK: - Paste
|
|
289
363
|
|
|
290
|
-
func pasteIntoFrontApp(_ text: String) {
|
|
364
|
+
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
|
|
291
365
|
let pb = NSPasteboard.general
|
|
292
366
|
pb.clearContents()
|
|
293
367
|
pb.setString(text, forType: .string)
|
|
294
368
|
|
|
295
|
-
// Activate the last user app (not us)
|
|
296
369
|
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
297
|
-
|
|
370
|
+
let runningApps = NSWorkspace.shared.runningApplications
|
|
371
|
+
let targetApp = runningApps.first(where: {
|
|
372
|
+
guard $0.processIdentifier != myPID, $0.activationPolicy == .regular else { return false }
|
|
373
|
+
guard let bundleIdentifier = targetAppBundleIdentifier else { return false }
|
|
374
|
+
return $0.bundleIdentifier == bundleIdentifier
|
|
375
|
+
}) ?? runningApps.first(where: {
|
|
298
376
|
$0.activationPolicy == .regular && $0.processIdentifier != myPID
|
|
299
|
-
})
|
|
300
|
-
|
|
301
|
-
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
targetApp?.activate()
|
|
302
380
|
|
|
303
|
-
|
|
304
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
381
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
305
382
|
self.postKey(0x09, flags: .maskCommand)
|
|
383
|
+
self.updateStatus()
|
|
306
384
|
}
|
|
307
385
|
}
|
|
308
386
|
|
|
309
387
|
private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
|
|
310
388
|
let src = CGEventSource(stateID: .hidSystemState)
|
|
311
|
-
let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true)
|
|
312
|
-
|
|
313
|
-
down
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
up
|
|
389
|
+
guard let down = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: true),
|
|
390
|
+
let up = CGEvent(keyboardEventSource: src, virtualKey: key, keyDown: false) else { return }
|
|
391
|
+
down.flags = flags
|
|
392
|
+
up.flags = flags
|
|
393
|
+
down.post(tap: .cgSessionEventTap)
|
|
394
|
+
up.post(tap: .cgSessionEventTap)
|
|
317
395
|
}
|
|
318
396
|
}
|
|
319
397
|
|
|
@@ -324,19 +402,41 @@ enum CLIRunner: Sendable {
|
|
|
324
402
|
let bin = "\(home)/.bun/bin/recordings"
|
|
325
403
|
let escaped = args.map { "\"\($0)\"" }.joined(separator: " ")
|
|
326
404
|
let proc = Process()
|
|
327
|
-
let
|
|
405
|
+
let outPipe = Pipe()
|
|
406
|
+
let errPipe = Pipe()
|
|
328
407
|
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
329
408
|
proc.arguments = ["-c", """
|
|
330
409
|
export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
331
410
|
"\(bin)" \(escaped)
|
|
332
411
|
"""]
|
|
333
|
-
proc.standardOutput =
|
|
334
|
-
proc.standardError =
|
|
412
|
+
proc.standardOutput = outPipe
|
|
413
|
+
proc.standardError = errPipe
|
|
335
414
|
do {
|
|
336
415
|
try proc.run()
|
|
337
416
|
proc.waitUntilExit()
|
|
338
|
-
|
|
339
|
-
|
|
417
|
+
let stdout = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
418
|
+
let stderr = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
|
419
|
+
if proc.terminationStatus != 0 {
|
|
420
|
+
let details = stderr.isEmpty ? stdout : stderr
|
|
421
|
+
return "ERROR: \(details.trimmingCharacters(in: .whitespacesAndNewlines))"
|
|
422
|
+
}
|
|
423
|
+
return stdout.isEmpty ? stderr : stdout
|
|
424
|
+
} catch {
|
|
425
|
+
return "ERROR: \(error.localizedDescription)"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
static func parseError(_ output: String) -> String? {
|
|
430
|
+
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
431
|
+
guard trimmed.hasPrefix("ERROR:") else { return nil }
|
|
432
|
+
let message = trimmed.dropFirst("ERROR:".count).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
433
|
+
if message.contains("OpenAI API key not configured") {
|
|
434
|
+
return "OpenAI API key not configured on this Mac"
|
|
435
|
+
}
|
|
436
|
+
if message.isEmpty {
|
|
437
|
+
return "Transcription failed"
|
|
438
|
+
}
|
|
439
|
+
return String(message.prefix(120))
|
|
340
440
|
}
|
|
341
441
|
|
|
342
442
|
static func parseJSON(_ output: String) -> String? {
|
|
@@ -350,6 +450,6 @@ enum CLIRunner: Sendable {
|
|
|
350
450
|
}
|
|
351
451
|
return output.components(separatedBy: "\n")
|
|
352
452
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
353
|
-
.first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") }
|
|
453
|
+
.first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") && !$0.hasPrefix("ERROR:") }
|
|
354
454
|
}
|
|
355
455
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
@preconcurrency import Cocoa
|
|
1
2
|
import SwiftUI
|
|
2
3
|
import KeyboardShortcuts
|
|
3
4
|
|
|
@@ -5,11 +6,19 @@ import KeyboardShortcuts
|
|
|
5
6
|
struct RecordingsApp: App {
|
|
6
7
|
@StateObject private var engine = RecordingEngine()
|
|
7
8
|
@StateObject private var shortcuts = VoiceShortcuts()
|
|
9
|
+
@StateObject private var projectStore = ProjectStore()
|
|
10
|
+
|
|
11
|
+
init() {
|
|
12
|
+
AXIsProcessTrustedWithOptions(
|
|
13
|
+
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
14
|
+
)
|
|
15
|
+
}
|
|
8
16
|
|
|
9
17
|
var body: some Scene {
|
|
10
18
|
MenuBarExtra {
|
|
11
|
-
MenuBarPopover(engine: engine, shortcuts: shortcuts)
|
|
12
|
-
.frame(width: 320, height:
|
|
19
|
+
MenuBarPopover(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
|
|
20
|
+
.frame(width: 320, height: 420)
|
|
21
|
+
.onAppear { engine.projectStore = projectStore }
|
|
13
22
|
} label: {
|
|
14
23
|
if engine.isRecording {
|
|
15
24
|
Image(systemName: "record.circle.fill")
|
|
@@ -22,9 +31,8 @@ struct RecordingsApp: App {
|
|
|
22
31
|
}
|
|
23
32
|
.menuBarExtraStyle(.window)
|
|
24
33
|
|
|
25
|
-
// Settings window — opened via SettingsLink
|
|
26
34
|
Settings {
|
|
27
|
-
SettingsView(engine: engine, shortcuts: shortcuts)
|
|
35
|
+
SettingsView(engine: engine, shortcuts: shortcuts, projectStore: projectStore)
|
|
28
36
|
}
|
|
29
37
|
}
|
|
30
38
|
}
|
|
@@ -4,61 +4,101 @@ import KeyboardShortcuts
|
|
|
4
4
|
struct SettingsView: View {
|
|
5
5
|
@ObservedObject var engine: RecordingEngine
|
|
6
6
|
@ObservedObject var shortcuts: VoiceShortcuts
|
|
7
|
-
@
|
|
8
|
-
@State private var newContent = ""
|
|
7
|
+
@ObservedObject var projectStore: ProjectStore
|
|
9
8
|
|
|
10
9
|
var body: some View {
|
|
11
10
|
TabView {
|
|
12
11
|
generalTab.tabItem { Label("General", systemImage: "gear") }
|
|
12
|
+
projectsTab.tabItem { Label("Projects", systemImage: "folder") }
|
|
13
13
|
shortcutsTab.tabItem { Label("Voice Shortcuts", systemImage: "text.badge.star") }
|
|
14
14
|
}
|
|
15
|
-
.frame(width:
|
|
15
|
+
.frame(width: 520, height: 440)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// MARK: - General
|
|
19
|
+
|
|
18
20
|
private var generalTab: some View {
|
|
19
21
|
Form {
|
|
20
|
-
Section("Shortcut
|
|
21
|
-
Toggle("Use fn (Globe) key", isOn: $engine.useFnKey)
|
|
22
|
-
|
|
23
|
-
if engine.useFnKey {
|
|
24
|
-
Text("Hold fn to record, release to stop and paste.")
|
|
25
|
-
.font(.caption).foregroundStyle(.secondary)
|
|
26
|
-
HStack(spacing: 4) {
|
|
27
|
-
Image(systemName: "exclamationmark.triangle.fill")
|
|
28
|
-
.foregroundStyle(.orange)
|
|
29
|
-
Text("Required: System Settings → Keyboard → set \"Press 🌐 key to: Do Nothing\"")
|
|
30
|
-
.font(.caption)
|
|
31
|
-
}
|
|
32
|
-
Button("Open Keyboard Settings") {
|
|
33
|
-
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!)
|
|
34
|
-
}.controlSize(.small)
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
Section("Shortcut — Custom Key") {
|
|
22
|
+
Section("Recording Shortcut") {
|
|
39
23
|
HStack {
|
|
40
|
-
Text("
|
|
24
|
+
Text("Shortcut")
|
|
41
25
|
Spacer()
|
|
42
26
|
KeyboardShortcuts.Recorder(for: .toggleRecording) { _ in
|
|
43
27
|
engine.updateStatus()
|
|
44
28
|
}
|
|
45
29
|
}
|
|
46
|
-
Text("
|
|
47
|
-
.
|
|
30
|
+
Text("Hold to record, release to transcribe and paste.")
|
|
31
|
+
.foregroundStyle(.secondary)
|
|
48
32
|
}
|
|
49
33
|
|
|
50
|
-
Section("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
34
|
+
Section("System Prompt") {
|
|
35
|
+
TextEditor(text: $projectStore.settings.globalSystemPrompt)
|
|
36
|
+
.frame(height: 80)
|
|
37
|
+
.onChange(of: projectStore.settings.globalSystemPrompt) {
|
|
38
|
+
projectStore.save()
|
|
54
39
|
}
|
|
55
|
-
|
|
56
|
-
|
|
40
|
+
Text("Applied to all transcription enhancements.")
|
|
41
|
+
.foregroundStyle(.secondary)
|
|
57
42
|
}
|
|
58
43
|
}
|
|
59
44
|
.formStyle(.grouped).padding()
|
|
60
45
|
}
|
|
61
46
|
|
|
47
|
+
// MARK: - Projects
|
|
48
|
+
|
|
49
|
+
@State private var newProjectName = ""
|
|
50
|
+
@State private var editingProject: RecProject?
|
|
51
|
+
|
|
52
|
+
private var projectsTab: some View {
|
|
53
|
+
VStack(spacing: 0) {
|
|
54
|
+
HStack(spacing: 8) {
|
|
55
|
+
TextField("New project name", text: $newProjectName)
|
|
56
|
+
.textFieldStyle(.roundedBorder)
|
|
57
|
+
Button("Add") {
|
|
58
|
+
guard !newProjectName.isEmpty else { return }
|
|
59
|
+
projectStore.addProject(name: newProjectName)
|
|
60
|
+
newProjectName = ""
|
|
61
|
+
}
|
|
62
|
+
.disabled(newProjectName.isEmpty)
|
|
63
|
+
}
|
|
64
|
+
.padding()
|
|
65
|
+
|
|
66
|
+
Divider()
|
|
67
|
+
|
|
68
|
+
if projectStore.settings.projects.isEmpty {
|
|
69
|
+
Spacer()
|
|
70
|
+
VStack(spacing: 8) {
|
|
71
|
+
Image(systemName: "folder").font(.largeTitle).foregroundStyle(.quaternary)
|
|
72
|
+
Text("No projects yet").foregroundStyle(.secondary)
|
|
73
|
+
}
|
|
74
|
+
Spacer()
|
|
75
|
+
} else {
|
|
76
|
+
List {
|
|
77
|
+
ForEach(projectStore.settings.projects) { project in
|
|
78
|
+
ProjectRow(project: project) {
|
|
79
|
+
editingProject = project
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
.onDelete { indexSet in
|
|
83
|
+
for i in indexSet {
|
|
84
|
+
projectStore.removeProject(id: projectStore.settings.projects[i].id)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
.sheet(item: $editingProject) { project in
|
|
91
|
+
ProjectEditView(project: project, store: projectStore) {
|
|
92
|
+
editingProject = nil
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Voice Shortcuts
|
|
98
|
+
|
|
99
|
+
@State private var newTrigger = ""
|
|
100
|
+
@State private var newContent = ""
|
|
101
|
+
|
|
62
102
|
private var shortcutsTab: some View {
|
|
63
103
|
VStack(spacing: 0) {
|
|
64
104
|
HStack(spacing: 8) {
|
|
@@ -68,9 +108,13 @@ struct SettingsView: View {
|
|
|
68
108
|
guard !newTrigger.isEmpty, !newContent.isEmpty else { return }
|
|
69
109
|
shortcuts.add(trigger: newTrigger, content: newContent)
|
|
70
110
|
newTrigger = ""; newContent = ""
|
|
71
|
-
}
|
|
72
|
-
|
|
111
|
+
}
|
|
112
|
+
.disabled(newTrigger.isEmpty || newContent.isEmpty)
|
|
113
|
+
}
|
|
114
|
+
.padding()
|
|
115
|
+
|
|
73
116
|
Divider()
|
|
117
|
+
|
|
74
118
|
if shortcuts.shortcuts.isEmpty {
|
|
75
119
|
Spacer()
|
|
76
120
|
VStack(spacing: 8) {
|
|
@@ -82,12 +126,79 @@ struct SettingsView: View {
|
|
|
82
126
|
List {
|
|
83
127
|
ForEach(shortcuts.shortcuts) { s in
|
|
84
128
|
VStack(alignment: .leading) {
|
|
85
|
-
Text(s.trigger).
|
|
86
|
-
Text(s.content).
|
|
129
|
+
Text(s.trigger).bold()
|
|
130
|
+
Text(s.content).foregroundStyle(.secondary).lineLimit(2)
|
|
87
131
|
}
|
|
88
|
-
}
|
|
132
|
+
}
|
|
133
|
+
.onDelete(perform: shortcuts.remove)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// MARK: - Project Row
|
|
141
|
+
|
|
142
|
+
struct ProjectRow: View {
|
|
143
|
+
let project: RecProject
|
|
144
|
+
let onEdit: () -> Void
|
|
145
|
+
|
|
146
|
+
var body: some View {
|
|
147
|
+
HStack {
|
|
148
|
+
VStack(alignment: .leading, spacing: 2) {
|
|
149
|
+
Text(project.name)
|
|
150
|
+
if let path = project.path, !path.isEmpty {
|
|
151
|
+
Text(path).foregroundStyle(.secondary).lineLimit(1)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
Spacer()
|
|
155
|
+
Button("Edit") { onEdit() }
|
|
156
|
+
.controlSize(.small)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// MARK: - Project Edit
|
|
162
|
+
|
|
163
|
+
struct ProjectEditView: View {
|
|
164
|
+
@State var project: RecProject
|
|
165
|
+
let store: ProjectStore
|
|
166
|
+
let onDismiss: () -> Void
|
|
167
|
+
|
|
168
|
+
var body: some View {
|
|
169
|
+
VStack(spacing: 0) {
|
|
170
|
+
Form {
|
|
171
|
+
Section("Project") {
|
|
172
|
+
TextField("Name", text: $project.name)
|
|
173
|
+
TextField("Path", text: Binding(
|
|
174
|
+
get: { project.path ?? "" },
|
|
175
|
+
set: { project.path = $0.isEmpty ? nil : $0 }
|
|
176
|
+
))
|
|
177
|
+
.textFieldStyle(.roundedBorder)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
Section("System Prompt") {
|
|
181
|
+
TextEditor(text: Binding(
|
|
182
|
+
get: { project.systemPrompt ?? "" },
|
|
183
|
+
set: { project.systemPrompt = $0.isEmpty ? nil : $0 }
|
|
184
|
+
))
|
|
185
|
+
.frame(height: 100)
|
|
186
|
+
Text("Additional context for transcription enhancement in this project.")
|
|
187
|
+
.foregroundStyle(.secondary)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
.formStyle(.grouped)
|
|
191
|
+
|
|
192
|
+
HStack {
|
|
193
|
+
Spacer()
|
|
194
|
+
Button("Cancel") { onDismiss() }
|
|
195
|
+
Button("Save") {
|
|
196
|
+
store.updateProject(project)
|
|
197
|
+
onDismiss()
|
|
89
198
|
}
|
|
90
199
|
}
|
|
200
|
+
.padding()
|
|
91
201
|
}
|
|
202
|
+
.frame(width: 420, height: 340)
|
|
92
203
|
}
|
|
93
204
|
}
|