@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.
Files changed (69) hide show
  1. package/README.md +2 -0
  2. package/dist/cli/index.js +395 -37
  3. package/dist/db/database.d.ts.map +1 -1
  4. package/dist/db/recordings.d.ts.map +1 -1
  5. package/dist/index.js +78 -11
  6. package/dist/lib/config.d.ts.map +1 -1
  7. package/dist/lib/enhancer.d.ts.map +1 -1
  8. package/dist/lib/recorder.d.ts.map +1 -1
  9. package/dist/lib/transcriber.d.ts +8 -2
  10. package/dist/lib/transcriber.d.ts.map +1 -1
  11. package/dist/mcp/index.js +147 -30
  12. package/dist/types/index.d.ts +3 -0
  13. package/dist/types/index.d.ts.map +1 -1
  14. package/dist/version.d.ts +2 -0
  15. package/dist/version.d.ts.map +1 -0
  16. package/package.json +20 -3
  17. package/scripts/install_macos_app.sh +76 -0
  18. package/src/native/Recordings/{Recordings → App}/RecordingsApp.swift +5 -1
  19. package/src/native/Recordings/Package.resolved +21 -3
  20. package/src/native/Recordings/Package.swift +16 -5
  21. package/src/native/Recordings/{Recordings → RecordingsLib}/Info.plist +6 -0
  22. package/src/native/Recordings/{Recordings → RecordingsLib}/MenuBarPopover.swift +55 -11
  23. package/src/native/Recordings/RecordingsLib/NativeAppDiagnostics.swift +36 -0
  24. package/src/native/Recordings/RecordingsLib/NativePCMRecorder.swift +164 -0
  25. package/src/native/Recordings/RecordingsLib/OpenAIAPIKeyStore.swift +113 -0
  26. package/src/native/Recordings/{Recordings → RecordingsLib}/ProjectStore.swift +11 -11
  27. package/src/native/Recordings/RecordingsLib/RealtimeTranscriptionClient.swift +383 -0
  28. package/src/native/Recordings/RecordingsLib/RecordingEngine.swift +835 -0
  29. package/src/native/Recordings/{Recordings → RecordingsLib}/SettingsView.swift +19 -5
  30. package/src/native/Recordings/{Recordings → RecordingsLib}/VoiceShortcuts.swift +12 -8
  31. package/src/native/Recordings/RecordingsTests/CLIRunnerTests.swift +63 -0
  32. package/src/native/Recordings/RecordingsTests/NativeAppDiagnosticsTests.swift +23 -0
  33. package/src/native/Recordings/RecordingsTests/NativePCMRecorderTests.swift +33 -0
  34. package/src/native/Recordings/RecordingsTests/OpenAIAPIKeyStoreTests.swift +92 -0
  35. package/src/native/Recordings/RecordingsTests/ProjectStoreTests.swift +58 -0
  36. package/src/native/Recordings/RecordingsTests/RealtimeTranscriptionTests.swift +113 -0
  37. package/src/native/Recordings/build.sh +6 -6
  38. package/.takumi/settings.local.json +0 -7
  39. package/bun.lock +0 -250
  40. package/bunfig.toml +0 -2
  41. package/src/__tests__/agents.test.ts +0 -136
  42. package/src/__tests__/config.test.ts +0 -252
  43. package/src/__tests__/database.test.ts +0 -167
  44. package/src/__tests__/enhancer.test.ts +0 -639
  45. package/src/__tests__/preload.ts +0 -4
  46. package/src/__tests__/projects.test.ts +0 -109
  47. package/src/__tests__/recorder.test.ts +0 -278
  48. package/src/__tests__/recordings.test.ts +0 -353
  49. package/src/__tests__/transcriber.test.ts +0 -322
  50. package/src/__tests__/types.test.ts +0 -75
  51. package/src/cli/index.ts +0 -988
  52. package/src/db/agents.ts +0 -104
  53. package/src/db/database.ts +0 -163
  54. package/src/db/pg-migrations.ts +0 -82
  55. package/src/db/projects.ts +0 -71
  56. package/src/db/recordings.ts +0 -225
  57. package/src/index.ts +0 -81
  58. package/src/lib/config.ts +0 -223
  59. package/src/lib/enhancer.ts +0 -173
  60. package/src/lib/recorder.ts +0 -198
  61. package/src/lib/transcriber.ts +0 -105
  62. package/src/mcp/index.ts +0 -464
  63. package/src/native/Recordings/Recordings/RecordingEngine.swift +0 -455
  64. package/src/native/Recordings/test_fn.swift +0 -79
  65. package/src/native/Recordings/test_fn2.swift +0 -33
  66. package/src/types/index.ts +0 -144
  67. package/tsconfig.json +0 -21
  68. /package/src/native/Recordings/{Recordings → RecordingsLib}/FnKeyMonitor.swift +0 -0
  69. /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()