@hasna/recordings 0.1.11 → 0.1.13
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 +2 -0
- package/dist/cli/index.js +395 -37
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/recordings.d.ts.map +1 -1
- package/dist/index.js +78 -11
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/enhancer.d.ts.map +1 -1
- package/dist/lib/recorder.d.ts.map +1 -1
- package/dist/lib/transcriber.d.ts +8 -2
- package/dist/lib/transcriber.d.ts.map +1 -1
- package/dist/mcp/index.js +147 -30
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +20 -3
- package/scripts/install_macos_app.sh +76 -0
- package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
- package/src/native/Recordings/Package.resolved +21 -3
- package/src/native/Recordings/Package.swift +16 -5
- package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
- package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
- package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
- package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
- package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
- package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +835 -0
- package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
- package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
- package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
- package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -0
- package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
- package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
- package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
- package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
- package/src/native/Recordings/build.sh +6 -6
- package/.takumi/settings.local.json +0 -7
- package/bun.lock +0 -250
- package/bunfig.toml +0 -2
- package/src/__tests__/agents.test.ts +0 -136
- package/src/__tests__/config.test.ts +0 -252
- package/src/__tests__/database.test.ts +0 -167
- package/src/__tests__/enhancer.test.ts +0 -639
- package/src/__tests__/preload.ts +0 -4
- package/src/__tests__/projects.test.ts +0 -109
- package/src/__tests__/recorder.test.ts +0 -278
- package/src/__tests__/recordings.test.ts +0 -353
- package/src/__tests__/transcriber.test.ts +0 -322
- package/src/__tests__/types.test.ts +0 -75
- package/src/cli/index.ts +0 -988
- package/src/db/agents.ts +0 -104
- package/src/db/database.ts +0 -163
- package/src/db/pg-migrations.ts +0 -82
- package/src/db/projects.ts +0 -71
- package/src/db/recordings.ts +0 -225
- package/src/index.ts +0 -81
- package/src/lib/config.ts +0 -223
- package/src/lib/enhancer.ts +0 -173
- package/src/lib/recorder.ts +0 -198
- package/src/lib/transcriber.ts +0 -105
- package/src/mcp/index.ts +0 -464
- package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
- package/src/native/Recordings/test_fn.swift +0 -79
- package/src/native/Recordings/test_fn2.swift +0 -33
- package/src/types/index.ts +0 -144
- package/tsconfig.json +0 -21
- /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
- /package/src/native/Recordings/{Recordings → RecordingsLib}/Recordings.entitlements +0 -0
|
@@ -1,455 +0,0 @@
|
|
|
1
|
-
import SwiftUI
|
|
2
|
-
import KeyboardShortcuts
|
|
3
|
-
|
|
4
|
-
// MARK: - Custom shortcut (not fn — fn is handled by FnKeyMonitor)
|
|
5
|
-
|
|
6
|
-
extension KeyboardShortcuts.Name {
|
|
7
|
-
static let toggleRecording = Self("toggleRecording", default: .init(.f5))
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// MARK: - Recording Mode
|
|
11
|
-
|
|
12
|
-
enum RecordingMode: String, CaseIterable, Identifiable, Sendable {
|
|
13
|
-
case pushToTalk = "Push to Talk"
|
|
14
|
-
case dictation = "Dictation"
|
|
15
|
-
case command = "Command"
|
|
16
|
-
|
|
17
|
-
var id: String { rawValue }
|
|
18
|
-
var icon: String {
|
|
19
|
-
switch self {
|
|
20
|
-
case .pushToTalk: return "hand.tap.fill"
|
|
21
|
-
case .dictation: return "text.bubble.fill"
|
|
22
|
-
case .command: return "wand.and.stars"
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
var hint: String {
|
|
26
|
-
switch self {
|
|
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
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// MARK: - Transcription Result
|
|
35
|
-
|
|
36
|
-
struct TranscriptionResult: Sendable {
|
|
37
|
-
let rawText: String
|
|
38
|
-
let processedText: String?
|
|
39
|
-
let timestamp: Date
|
|
40
|
-
let projectId: String?
|
|
41
|
-
let projectName: String?
|
|
42
|
-
var displayText: String { processedText ?? rawText }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
enum RecordingTrigger {
|
|
46
|
-
case manual
|
|
47
|
-
case fnKey
|
|
48
|
-
case keyboardShortcut
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// MARK: - Recording Engine
|
|
52
|
-
|
|
53
|
-
@MainActor
|
|
54
|
-
final class RecordingEngine: ObservableObject {
|
|
55
|
-
@Published var isRecording = false
|
|
56
|
-
@Published var mode: RecordingMode = .pushToTalk
|
|
57
|
-
@Published var useFnKey: Bool = false {
|
|
58
|
-
didSet {
|
|
59
|
-
UserDefaults.standard.set(useFnKey, forKey: "useFnKey")
|
|
60
|
-
updateFnMonitor()
|
|
61
|
-
updateStatus()
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
@Published var isWhisperMode = false
|
|
65
|
-
@Published var recentTranscriptions: [TranscriptionResult] = []
|
|
66
|
-
@Published var statusMessage = "Starting..."
|
|
67
|
-
@Published var isTranscribing = false
|
|
68
|
-
@Published var recordingDuration: TimeInterval = 0
|
|
69
|
-
|
|
70
|
-
private var recordProcess: Process?
|
|
71
|
-
private var stdinPipe: Pipe?
|
|
72
|
-
private var currentAudioPath: String?
|
|
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?
|
|
78
|
-
private let maxDuration = 300
|
|
79
|
-
var projectStore: ProjectStore?
|
|
80
|
-
|
|
81
|
-
// fn key monitor (CGEventTap-based, swallows fn to prevent emoji picker)
|
|
82
|
-
private let fnMonitor = FnKeyMonitor()
|
|
83
|
-
|
|
84
|
-
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
85
|
-
private var audioDir: String { "\(home)/.hasna/recordings/audio" }
|
|
86
|
-
|
|
87
|
-
init() {
|
|
88
|
-
try? FileManager.default.createDirectory(atPath: audioDir, withIntermediateDirectories: true)
|
|
89
|
-
|
|
90
|
-
// Load preferences
|
|
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
|
-
}
|
|
95
|
-
|
|
96
|
-
// Set up fn key monitor — hold fn to record, release to stop (like WisprFlow)
|
|
97
|
-
fnMonitor.onFnKeyDown = { [weak self] in
|
|
98
|
-
Task { @MainActor [weak self] in
|
|
99
|
-
guard let self, self.useFnKey, !self.isRecording else { return }
|
|
100
|
-
self.startRecording(trigger: .fnKey)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
fnMonitor.onFnKeyUp = { [weak self] in
|
|
104
|
-
Task { @MainActor [weak self] in
|
|
105
|
-
guard let self, self.useFnKey, self.isRecording, self.activeTrigger == .fnKey else { return }
|
|
106
|
-
self.stopAndTranscribe()
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
updateFnMonitor()
|
|
110
|
-
|
|
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
|
-
}
|
|
119
|
-
KeyboardShortcuts.onKeyUp(for: .toggleRecording) { [weak self] in
|
|
120
|
-
Task { @MainActor [weak self] in
|
|
121
|
-
guard let self, self.keyboardShortcutIsDown else { return }
|
|
122
|
-
self.keyboardShortcutIsDown = false
|
|
123
|
-
guard self.isRecording, self.activeTrigger == .keyboardShortcut else { return }
|
|
124
|
-
self.stopAndTranscribe()
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
updateStatus()
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
private func updateFnMonitor() {
|
|
132
|
-
if useFnKey {
|
|
133
|
-
let ok = fnMonitor.start()
|
|
134
|
-
if !ok {
|
|
135
|
-
statusMessage = "fn needs Input Monitoring / Accessibility permission, and Globe must be set to Do Nothing"
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
fnMonitor.stop()
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
func updateStatus() {
|
|
143
|
-
if isRecording || isTranscribing { return }
|
|
144
|
-
statusMessage = "Ready"
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// MARK: - Toggle
|
|
148
|
-
|
|
149
|
-
func toggleRecording() {
|
|
150
|
-
if isRecording { stopAndTranscribe() } else { startRecording(trigger: .manual) }
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// MARK: - Start Recording
|
|
154
|
-
|
|
155
|
-
func startRecording(trigger: RecordingTrigger = .manual) {
|
|
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
|
-
}
|
|
174
|
-
|
|
175
|
-
let ts = DateFormatter()
|
|
176
|
-
ts.dateFormat = "yyyyMMdd'T'HHmmss"
|
|
177
|
-
let path = "\(audioDir)/rec-\(ts.string(from: Date())).wav"
|
|
178
|
-
currentAudioPath = path
|
|
179
|
-
|
|
180
|
-
let proc = Process()
|
|
181
|
-
let stdin = Pipe()
|
|
182
|
-
let stderrPipe = Pipe()
|
|
183
|
-
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
184
|
-
proc.arguments = ["-c", """
|
|
185
|
-
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
186
|
-
if command -v ffmpeg &>/dev/null; then
|
|
187
|
-
ffmpeg -f avfoundation -i ":0" -ar 16000 -ac 1 -t \(maxDuration) "\(path)" -y
|
|
188
|
-
elif command -v rec &>/dev/null; then
|
|
189
|
-
rec -r 16000 -c 1 -b 16 "\(path)" trim 0 \(maxDuration)
|
|
190
|
-
else
|
|
191
|
-
exit 1
|
|
192
|
-
fi
|
|
193
|
-
"""]
|
|
194
|
-
proc.standardInput = stdin
|
|
195
|
-
proc.standardOutput = FileHandle.nullDevice
|
|
196
|
-
proc.standardError = stderrPipe
|
|
197
|
-
|
|
198
|
-
do {
|
|
199
|
-
try proc.run()
|
|
200
|
-
recordProcess = proc
|
|
201
|
-
stdinPipe = stdin
|
|
202
|
-
isRecording = true
|
|
203
|
-
recordingDuration = 0
|
|
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
|
-
}
|
|
209
|
-
|
|
210
|
-
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
|
211
|
-
Task { @MainActor [weak self] in
|
|
212
|
-
self?.recordingDuration += 0.1
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
} catch {
|
|
216
|
-
activeTrigger = nil
|
|
217
|
-
keyboardShortcutIsDown = false
|
|
218
|
-
targetAppBundleIdentifier = nil
|
|
219
|
-
statusMessage = "Failed: \(error.localizedDescription)"
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// MARK: - Stop & Transcribe
|
|
224
|
-
|
|
225
|
-
func stopAndTranscribe() {
|
|
226
|
-
guard let proc = recordProcess else { return }
|
|
227
|
-
|
|
228
|
-
recordingTimer?.invalidate()
|
|
229
|
-
recordingTimer = nil
|
|
230
|
-
|
|
231
|
-
if let pipe = stdinPipe {
|
|
232
|
-
try? pipe.fileHandleForWriting.write(contentsOf: Data("q".utf8))
|
|
233
|
-
try? pipe.fileHandleForWriting.close()
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
isRecording = false
|
|
237
|
-
isTranscribing = true
|
|
238
|
-
statusMessage = "Transcribing..."
|
|
239
|
-
|
|
240
|
-
let audioPath = currentAudioPath
|
|
241
|
-
let curMode = mode
|
|
242
|
-
currentAudioPath = nil
|
|
243
|
-
recordProcess = nil
|
|
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
|
|
253
|
-
let homePath = home
|
|
254
|
-
|
|
255
|
-
Task.detached {
|
|
256
|
-
try? await Task.sleep(for: .seconds(1))
|
|
257
|
-
if proc.isRunning { proc.terminate() }
|
|
258
|
-
try? await Task.sleep(for: .milliseconds(300))
|
|
259
|
-
if proc.isRunning { proc.interrupt() }
|
|
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
|
-
|
|
267
|
-
guard let audioPath else {
|
|
268
|
-
await MainActor.run { self.finish("No audio file") }
|
|
269
|
-
return
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
let attrs = try? FileManager.default.attributesOfItem(atPath: audioPath)
|
|
273
|
-
let size = (attrs?[.size] as? Int) ?? 0
|
|
274
|
-
guard size >= 1000 else {
|
|
275
|
-
await MainActor.run { self.finish("No audio captured — check microphone permissions") }
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
|
|
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
|
-
}
|
|
288
|
-
|
|
289
|
-
let text = CLIRunner.parseJSON(output)
|
|
290
|
-
guard let text, !text.isEmpty else {
|
|
291
|
-
await MainActor.run { self.finish("Empty transcription") }
|
|
292
|
-
return
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
await MainActor.run {
|
|
296
|
-
self.isTranscribing = false
|
|
297
|
-
if curMode == .command {
|
|
298
|
-
self.runCommandMode(instruction: text)
|
|
299
|
-
} else {
|
|
300
|
-
self.pasteIntoFrontApp(text, targetAppBundleIdentifier: targetAppBundleIdentifier)
|
|
301
|
-
self.recentTranscriptions.insert(
|
|
302
|
-
TranscriptionResult(rawText: text, processedText: nil, timestamp: Date(), projectId: activeProjectId, projectName: activeProjectName), at: 0
|
|
303
|
-
)
|
|
304
|
-
if self.recentTranscriptions.count > 20 { self.recentTranscriptions.removeLast() }
|
|
305
|
-
// statusMessage updated by pasteIntoFrontApp
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
private func finish(_ msg: String) {
|
|
312
|
-
isTranscribing = false
|
|
313
|
-
updateStatus()
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// MARK: - Command Mode
|
|
317
|
-
|
|
318
|
-
private func runCommandMode(instruction: String) {
|
|
319
|
-
postKey(0x08, flags: .maskCommand) // Cmd+C
|
|
320
|
-
let homePath = home
|
|
321
|
-
|
|
322
|
-
Task {
|
|
323
|
-
try? await Task.sleep(for: .milliseconds(250))
|
|
324
|
-
let selected = NSPasteboard.general.string(forType: .string) ?? ""
|
|
325
|
-
guard !selected.isEmpty else {
|
|
326
|
-
statusMessage = "No text selected"
|
|
327
|
-
return
|
|
328
|
-
}
|
|
329
|
-
statusMessage = "Rewriting..."
|
|
330
|
-
isTranscribing = true
|
|
331
|
-
let prompt = "Rewrite: \"\(instruction)\"\n\nText:\n\(selected)"
|
|
332
|
-
|
|
333
|
-
Task.detached {
|
|
334
|
-
let result = CLIRunner.run(["transcribe", "--text", prompt, "--enhance"], home: homePath)
|
|
335
|
-
await MainActor.run {
|
|
336
|
-
self.isTranscribing = false
|
|
337
|
-
if !result.isEmpty {
|
|
338
|
-
self.pasteIntoFrontApp(result)
|
|
339
|
-
self.statusMessage = "Rewritten"
|
|
340
|
-
} else {
|
|
341
|
-
self.statusMessage = "Rewrite failed"
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
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
|
-
|
|
362
|
-
// MARK: - Paste
|
|
363
|
-
|
|
364
|
-
func pasteIntoFrontApp(_ text: String, targetAppBundleIdentifier: String? = nil) {
|
|
365
|
-
let pb = NSPasteboard.general
|
|
366
|
-
pb.clearContents()
|
|
367
|
-
pb.setString(text, forType: .string)
|
|
368
|
-
|
|
369
|
-
let myPID = ProcessInfo.processInfo.processIdentifier
|
|
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: {
|
|
376
|
-
$0.activationPolicy == .regular && $0.processIdentifier != myPID
|
|
377
|
-
})
|
|
378
|
-
|
|
379
|
-
targetApp?.activate()
|
|
380
|
-
|
|
381
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
382
|
-
self.postKey(0x09, flags: .maskCommand)
|
|
383
|
-
self.updateStatus()
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
private func postKey(_ key: CGKeyCode, flags: CGEventFlags) {
|
|
388
|
-
let src = CGEventSource(stateID: .hidSystemState)
|
|
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)
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
// MARK: - CLI Runner
|
|
399
|
-
|
|
400
|
-
enum CLIRunner: Sendable {
|
|
401
|
-
static func run(_ args: [String], home: String) -> String {
|
|
402
|
-
let bin = "\(home)/.bun/bin/recordings"
|
|
403
|
-
let escaped = args.map { "\"\($0)\"" }.joined(separator: " ")
|
|
404
|
-
let proc = Process()
|
|
405
|
-
let outPipe = Pipe()
|
|
406
|
-
let errPipe = Pipe()
|
|
407
|
-
proc.executableURL = URL(fileURLWithPath: "/bin/bash")
|
|
408
|
-
proc.arguments = ["-c", """
|
|
409
|
-
export PATH="\(home)/.bun/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
410
|
-
"\(bin)" \(escaped)
|
|
411
|
-
"""]
|
|
412
|
-
proc.standardOutput = outPipe
|
|
413
|
-
proc.standardError = errPipe
|
|
414
|
-
do {
|
|
415
|
-
try proc.run()
|
|
416
|
-
proc.waitUntilExit()
|
|
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))
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
static func parseJSON(_ output: String) -> String? {
|
|
443
|
-
if let s = output.range(of: "{"), let e = output.range(of: "}", options: .backwards) {
|
|
444
|
-
let json = String(output[s.lowerBound...e.upperBound])
|
|
445
|
-
if let data = json.data(using: .utf8),
|
|
446
|
-
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
447
|
-
if let t = obj["processed_text"] as? String, !t.isEmpty { return t }
|
|
448
|
-
if let t = obj["raw_text"] as? String, !t.isEmpty { return t }
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
return output.components(separatedBy: "\n")
|
|
452
|
-
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
453
|
-
.first { !$0.isEmpty && !$0.hasPrefix("{") && !$0.contains("Transcribing") && !$0.hasPrefix("Saved") && !$0.hasPrefix("ERROR:") }
|
|
454
|
-
}
|
|
455
|
-
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env swift
|
|
2
|
-
// Quick test: can we see fn key events via CGEventTap?
|
|
3
|
-
import Cocoa
|
|
4
|
-
|
|
5
|
-
print("Listening for ALL key events for 15 seconds...")
|
|
6
|
-
print("Press fn, F5, Option, or any key. Ctrl+C to quit.\n")
|
|
7
|
-
|
|
8
|
-
let mask: CGEventMask =
|
|
9
|
-
(1 << CGEventType.flagsChanged.rawValue) |
|
|
10
|
-
(1 << CGEventType.keyDown.rawValue) |
|
|
11
|
-
(1 << CGEventType.keyUp.rawValue)
|
|
12
|
-
|
|
13
|
-
let callback: CGEventTapCallBack = { proxy, type, event, refcon in
|
|
14
|
-
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
|
15
|
-
let flags = event.flags
|
|
16
|
-
|
|
17
|
-
if type == .flagsChanged {
|
|
18
|
-
let fn = flags.contains(.maskSecondaryFn) ? " [fn]" : ""
|
|
19
|
-
let cmd = flags.contains(.maskCommand) ? " [cmd]" : ""
|
|
20
|
-
let opt = flags.contains(.maskAlternate) ? " [opt]" : ""
|
|
21
|
-
let ctrl = flags.contains(.maskControl) ? " [ctrl]" : ""
|
|
22
|
-
let shift = flags.contains(.maskShift) ? " [shift]" : ""
|
|
23
|
-
print("flagsChanged keyCode=\(keyCode)\(fn)\(cmd)\(opt)\(ctrl)\(shift)")
|
|
24
|
-
} else if type == .keyDown {
|
|
25
|
-
print("keyDown keyCode=\(keyCode)")
|
|
26
|
-
} else if type == .keyUp {
|
|
27
|
-
print("keyUp keyCode=\(keyCode)")
|
|
28
|
-
} else if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
|
|
29
|
-
print("TAP DISABLED — re-enabling")
|
|
30
|
-
if let refcon {
|
|
31
|
-
let tap = Unmanaged<AnyObject>.fromOpaque(refcon).takeUnretainedValue() as! CFMachPort
|
|
32
|
-
CGEvent.tapEnable(tap: tap, enable: true)
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return Unmanaged.passRetained(event)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Try HID-level tap first
|
|
40
|
-
var tap = CGEvent.tapCreate(
|
|
41
|
-
tap: .cghidEventTap,
|
|
42
|
-
place: .headInsertEventTap,
|
|
43
|
-
options: .defaultTap,
|
|
44
|
-
eventsOfInterest: mask,
|
|
45
|
-
callback: callback,
|
|
46
|
-
userInfo: nil
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
if tap == nil {
|
|
50
|
-
print("⚠ cghidEventTap failed, trying cgSessionEventTap...")
|
|
51
|
-
tap = CGEvent.tapCreate(
|
|
52
|
-
tap: .cgSessionEventTap,
|
|
53
|
-
place: .headInsertEventTap,
|
|
54
|
-
options: .defaultTap,
|
|
55
|
-
eventsOfInterest: mask,
|
|
56
|
-
callback: callback,
|
|
57
|
-
userInfo: nil
|
|
58
|
-
)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
guard let tap else {
|
|
62
|
-
print("❌ FAILED to create event tap. Grant Accessibility permission:")
|
|
63
|
-
print(" System Settings > Privacy & Security > Accessibility")
|
|
64
|
-
exit(1)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
print("✅ Event tap created successfully\n")
|
|
68
|
-
|
|
69
|
-
let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
|
|
70
|
-
CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
|
|
71
|
-
CGEvent.tapEnable(tap: tap, enable: true)
|
|
72
|
-
|
|
73
|
-
// Run for 15 seconds
|
|
74
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
|
|
75
|
-
print("\nDone.")
|
|
76
|
-
exit(0)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
CFRunLoopRun()
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env swift
|
|
2
|
-
// Test: NSEvent.addGlobalMonitorForEvents — does it see fn key?
|
|
3
|
-
import Cocoa
|
|
4
|
-
|
|
5
|
-
print("Testing NSEvent global monitor for 15 seconds...")
|
|
6
|
-
print("Press fn, Option, any key. Ctrl+C to quit.\n")
|
|
7
|
-
|
|
8
|
-
let app = NSApplication.shared
|
|
9
|
-
app.setActivationPolicy(.accessory)
|
|
10
|
-
|
|
11
|
-
// Monitor flagsChanged (modifier keys including fn)
|
|
12
|
-
NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged, .keyDown, .keyUp]) { event in
|
|
13
|
-
if event.type == .flagsChanged {
|
|
14
|
-
let fn = event.modifierFlags.contains(.function) ? " [fn]" : ""
|
|
15
|
-
let cmd = event.modifierFlags.contains(.command) ? " [cmd]" : ""
|
|
16
|
-
let opt = event.modifierFlags.contains(.option) ? " [opt]" : ""
|
|
17
|
-
let ctrl = event.modifierFlags.contains(.control) ? " [ctrl]" : ""
|
|
18
|
-
print("flagsChanged keyCode=\(event.keyCode)\(fn)\(cmd)\(opt)\(ctrl)")
|
|
19
|
-
} else if event.type == .keyDown {
|
|
20
|
-
print("keyDown keyCode=\(event.keyCode) chars=\(event.characters ?? "")")
|
|
21
|
-
} else if event.type == .keyUp {
|
|
22
|
-
print("keyUp keyCode=\(event.keyCode)")
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
print("✅ Global monitor registered\n")
|
|
27
|
-
|
|
28
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 15) {
|
|
29
|
-
print("\nDone.")
|
|
30
|
-
exit(0)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
app.run()
|