@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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@hasna/recordings",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
- "description": "Speech-to-text recording tool with MCP and CLI \u2014 records, transcribes, and optionally enhances text using AI",
5
+ "description": "Speech-to-text recording tool with MCP and CLI records, transcribes, and optionally enhances text using AI",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "postinstall": "mkdir -p $HOME/.hasna/recordings/audio 2>/dev/null || true"
29
29
  },
30
30
  "dependencies": {
31
- "@hasna/cloud": "^0.1.0",
31
+ "@hasna/cloud": "^0.1.24",
32
32
  "@modelcontextprotocol/sdk": "^1.12.1",
33
33
  "chalk": "^5.4.1",
34
34
  "commander": "^13.1.0",
@@ -544,6 +544,71 @@ describe("processText", () => {
544
544
  });
545
545
  });
546
546
 
547
+ // ── systemPrompt support ──────────────────────────────────────────────────
548
+
549
+ describe("systemPrompt support", () => {
550
+ test("processText passes systemPrompt through to enhanceText", async () => {
551
+ let capturedMessages: Array<{ role: string; content: string }> = [];
552
+ mock.module("openai", () => ({
553
+ default: class MockOpenAI {
554
+ chat = {
555
+ completions: {
556
+ create: mock((opts: { messages: Array<{ role: string; content: string }> }) => {
557
+ capturedMessages = opts.messages;
558
+ return Promise.resolve({
559
+ choices: [{ message: { content: "Enhanced with context" } }],
560
+ });
561
+ }),
562
+ },
563
+ };
564
+ },
565
+ }));
566
+
567
+ resetEnhancementClient();
568
+ const { processText: process } = await import("../lib/enhancer.js");
569
+ resetEnhancementClient();
570
+
571
+ await process("write an email saying thanks", config, "You are working on the Acme project");
572
+ expect(capturedMessages[0]!.content).toContain("Additional context:");
573
+ expect(capturedMessages[0]!.content).toContain("Acme project");
574
+
575
+ resetEnhancementClient();
576
+ });
577
+
578
+ test("processText works without systemPrompt", async () => {
579
+ let capturedMessages: Array<{ role: string; content: string }> = [];
580
+ mock.module("openai", () => ({
581
+ default: class MockOpenAI {
582
+ chat = {
583
+ completions: {
584
+ create: mock((opts: { messages: Array<{ role: string; content: string }> }) => {
585
+ capturedMessages = opts.messages;
586
+ return Promise.resolve({
587
+ choices: [{ message: { content: "Enhanced without context" } }],
588
+ });
589
+ }),
590
+ },
591
+ };
592
+ },
593
+ }));
594
+
595
+ resetEnhancementClient();
596
+ const { processText: process } = await import("../lib/enhancer.js");
597
+ resetEnhancementClient();
598
+
599
+ await process("write an email saying thanks", config);
600
+ expect(capturedMessages[0]!.content).not.toContain("Additional context:");
601
+
602
+ resetEnhancementClient();
603
+ });
604
+
605
+ test("processText ignores systemPrompt for raw dictation", async () => {
606
+ const result = await processText("Just a regular note", config, "project context");
607
+ expect(result.mode).toBe("raw");
608
+ expect(result.text).toBe("Just a regular note");
609
+ });
610
+ });
611
+
547
612
  // ── extractInstruction (tested via needsEnhancement) ────────────────────────
548
613
 
549
614
  describe("extractInstruction behavior via needsEnhancement", () => {
package/src/cli/index.ts CHANGED
@@ -148,6 +148,7 @@ program
148
148
  .description("Transcribe an existing audio file")
149
149
  .option("--no-enhance", "Skip AI enhancement")
150
150
  .option("-t, --tags <tags>", "Comma-separated tags")
151
+ .option("--system-prompt <prompt>", "System prompt for enhancement context")
151
152
  .action(async (file, opts) => {
152
153
  const config = loadConfig();
153
154
  ensureDataDir(config);
@@ -156,7 +157,7 @@ program
156
157
  console.log(chalk.blue("Transcribing..."));
157
158
  const transcription = await transcribeAudio(file, config);
158
159
 
159
- const processed = await processText(transcription.text, config);
160
+ const processed = await processText(transcription.text, config, opts.systemPrompt);
160
161
  const parentOpts = program.opts();
161
162
  const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : [];
162
163
 
@@ -472,64 +473,6 @@ program
472
473
  }
473
474
  });
474
475
 
475
- // ── start ───────────────────────────────────────────────────────────────────
476
-
477
- program
478
- .command("start")
479
- .description("Launch the menu bar helper app (F5 to toggle recording)")
480
- .option("--login", "Also add to Login Items so it starts automatically")
481
- .action(async (opts) => {
482
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
483
- const { join: pathJoin } = require("node:path") as typeof import("node:path");
484
- const { homedir: getHome } = require("node:os") as typeof import("node:os");
485
- const { existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
486
- const home = getHome();
487
-
488
- const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
489
- const oldAppPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
490
-
491
- if (!fileExists(appPath) && !fileExists(oldAppPath)) {
492
- console.error(chalk.red("RecordingsHelper.app not found. Run: recordings shortcut --install"));
493
- process.exit(1);
494
- }
495
-
496
- const resolvedAppPath = fileExists(appPath) ? appPath : oldAppPath;
497
-
498
- // Kill existing instance
499
- try { execSync("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
500
-
501
- // Launch
502
- execSync(`open "${resolvedAppPath}"`, { stdio: "pipe" });
503
- console.log(chalk.green("Recordings helper launched — press F5 to record"));
504
-
505
- if (opts.login) {
506
- try {
507
- execSync(
508
- `osascript -e 'tell application "System Events" to make login item at end with properties {path:"${resolvedAppPath}", hidden:true}'`,
509
- { stdio: "pipe" }
510
- );
511
- console.log(chalk.green("Added to Login Items — will start on boot"));
512
- } catch {
513
- console.log(chalk.yellow("Could not add to Login Items — add manually in System Settings > General > Login Items"));
514
- }
515
- }
516
- });
517
-
518
- // ── stop ────────────────────────────────────────────────────────────────────
519
-
520
- program
521
- .command("stop")
522
- .description("Stop the menu bar helper app")
523
- .action(() => {
524
- const { execSync } = require("node:child_process") as typeof import("node:child_process");
525
- try {
526
- execSync("pkill -f RecordingsHelper", { stdio: "pipe" });
527
- console.log(chalk.green("Recordings helper stopped"));
528
- } catch {
529
- console.log(chalk.dim("Not running"));
530
- }
531
- });
532
-
533
476
  // ── listen ───────────────────────────────────────────────────────────────────
534
477
 
535
478
  program
@@ -680,13 +623,12 @@ program
680
623
  .command("shortcut")
681
624
  .description("Set up a global keyboard shortcut for recording (macOS)")
682
625
  .option("--raycast", "Generate Raycast script command")
683
- .option("--install", "Set up F5 global shortcut via macOS Services (no extra installs)")
684
626
  .option("--karabiner", "Set up Fn key via Karabiner-Elements")
685
627
  .option("--skhd", "Generate skhd hotkey config")
686
628
  .option("--hammerspoon", "Generate Hammerspoon config")
687
629
  .option("--script", "Just output the shell script path")
688
630
  .action((opts) => {
689
- const { writeFileSync, mkdirSync, chmodSync, existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
631
+ const { writeFileSync, mkdirSync, chmodSync } = require("node:fs") as typeof import("node:fs");
690
632
  const { join: pathJoin } = require("node:path") as typeof import("node:path");
691
633
  const { homedir: getHome } = require("node:os") as typeof import("node:os");
692
634
  const home = getHome();
@@ -746,72 +688,6 @@ fi
746
688
  writeFileSync(scriptPath, script, "utf-8");
747
689
  chmodSync(scriptPath, 0o755);
748
690
 
749
- if (opts.install) {
750
- // Install the native menu bar app — no config needed, just works with F5
751
- const { execSync: exec } = require("node:child_process") as typeof import("node:child_process");
752
-
753
- const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
754
- const srcSwift = pathJoin(__dirname, "..", "native", "RecordingsHelper.swift");
755
- const distApp = pathJoin(__dirname, "..", "RecordingsHelper.app");
756
-
757
- // Copy pre-built app if available, otherwise compile
758
- if (fileExists(pathJoin(distApp, "Contents", "MacOS", "RecordingsHelper"))) {
759
- exec(`rm -rf "${appPath}" && cp -R "${distApp}" "${appPath}"`, { stdio: "pipe", shell: "/bin/bash" });
760
- } else if (fileExists(srcSwift)) {
761
- // Compile from source
762
- console.log(chalk.blue("Compiling native helper app..."));
763
- const appDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents", "MacOS");
764
- mkdirSync(appDir, { recursive: true });
765
-
766
- const plistDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents");
767
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
768
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
769
- <plist version="1.0"><dict>
770
- <key>CFBundleExecutable</key><string>RecordingsHelper</string>
771
- <key>CFBundleIdentifier</key><string>com.hasna.recordings-helper</string>
772
- <key>CFBundleName</key><string>Recordings</string>
773
- <key>LSUIElement</key><true/>
774
- <key>NSMicrophoneUsageDescription</key><string>Recordings needs microphone access for speech transcription.</string>
775
- </dict></plist>`;
776
- writeFileSync(pathJoin(plistDir, "Info.plist"), plist, "utf-8");
777
-
778
- try {
779
- exec(
780
- `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcrun swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`,
781
- { stdio: "pipe" }
782
- );
783
- } catch {
784
- // Fallback to default toolchain
785
- exec(
786
- `swiftc -O -o "${appDir}/RecordingsHelper" "${srcSwift}" -framework Cocoa -framework Carbon`,
787
- { stdio: "pipe" }
788
- );
789
- }
790
- } else {
791
- console.error(chalk.red("Cannot find RecordingsHelper. Run from the project directory or rebuild."));
792
- process.exit(1);
793
- }
794
-
795
- // Kill existing instance and launch
796
- try { exec("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
797
- exec(`open "${appPath}"`, { stdio: "pipe" });
798
-
799
- // Add to Login Items
800
- try {
801
- exec(
802
- `osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`,
803
- { stdio: "pipe" }
804
- );
805
- } catch { /* already exists or no permission */ }
806
-
807
- console.log(chalk.green("\nRecordings helper installed and running!\n"));
808
- console.log(` ${chalk.yellow("F5")} Start/stop recording`);
809
- console.log(` ${chalk.dim("🎙")} Menu bar icon (click for options)`);
810
- console.log(` ${chalk.dim("Auto")} Starts on login\n`);
811
- console.log(chalk.dim(" Press F5 → speak → F5 → text is pasted where your cursor is."));
812
- return;
813
- }
814
-
815
691
  if (opts.karabiner) {
816
692
  const karabinerDir = pathJoin(home, ".config", "karabiner", "assets", "complex_modifications");
817
693
  mkdirSync(karabinerDir, { recursive: true });
@@ -900,9 +776,6 @@ ${scriptPath}
900
776
  console.log(chalk.cyan(` ${scriptPath}\n`));
901
777
  console.log("Bind it to a hotkey using any of these:\n");
902
778
 
903
- console.log(chalk.bold(" macOS built-in") + chalk.dim(" (no extra installs — recommended)"));
904
- console.log(` recordings shortcut --install\n`);
905
-
906
779
  console.log(chalk.bold(" Karabiner-Elements") + chalk.dim(" (for Fn key specifically)"));
907
780
  console.log(` brew install --cask karabiner-elements`);
908
781
  console.log(` recordings shortcut --karabiner\n`);
@@ -1082,7 +955,6 @@ program
1082
955
  .command("remove <id>")
1083
956
  .alias("rm")
1084
957
  .alias("uninstall")
1085
- .alias("delete")
1086
958
  .description("Delete a recording by ID")
1087
959
  .action((id: string) => {
1088
960
  const deleted = deleteRecording(id);
package/src/lib/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, mkdirSync, cpSync } from "fs";
1
+ import { existsSync, readFileSync, mkdirSync, cpSync, readdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import type { RecordingsConfig } from "../types/index.js";
@@ -42,7 +42,7 @@ export function loadConfig(configPath?: string): RecordingsConfig {
42
42
  try {
43
43
  const raw = readFileSync(filePath, "utf-8");
44
44
  const fileConfig = JSON.parse(raw) as Partial<RecordingsConfig>;
45
- Object.assign(config, fileConfig);
45
+ Object.assign(config, expandEnvBackedConfig(fileConfig));
46
46
  } catch {
47
47
  // Ignore invalid config files
48
48
  }
@@ -102,6 +102,19 @@ export function loadConfig(configPath?: string): RecordingsConfig {
102
102
  return config;
103
103
  }
104
104
 
105
+ function expandEnvBackedConfig(config: Partial<RecordingsConfig>): Partial<RecordingsConfig> {
106
+ const expanded = { ...config };
107
+
108
+ for (const key of ["openai_api_key", "enhancement_api_key"] as const) {
109
+ const value = expanded[key];
110
+ if (typeof value === "string" && value.startsWith("$") && value.length > 1) {
111
+ expanded[key] = process.env[value.slice(1)] || value;
112
+ }
113
+ }
114
+
115
+ return expanded;
116
+ }
117
+
105
118
  function findConfigFile(): string | null {
106
119
  // Walk up from cwd looking for .recordings/config.json
107
120
  let dir = process.cwd();
@@ -150,24 +163,51 @@ function loadSecretKey(keyName: string): string {
150
163
  const secretsPath = join(homedir(), ".secrets");
151
164
  if (!existsSync(secretsPath)) return "";
152
165
 
153
- try {
154
- const content = readFileSync(secretsPath, "utf-8");
155
- const match = content.match(
156
- new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`)
157
- );
158
- if (match) return match[1]!;
166
+ for (const candidate of listSecretFiles(secretsPath)) {
167
+ try {
168
+ const content = readFileSync(candidate, "utf-8");
169
+ const match = content.match(
170
+ new RegExp(`export\\s+${keyName}\\s*=\\s*"([^"]+)"`)
171
+ );
172
+ if (match) return match[1]!;
173
+
174
+ const match2 = content.match(
175
+ new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`)
176
+ );
177
+ if (match2) return match2[1]!;
178
+
179
+ const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
180
+ if (match3) return match3[1]!.trim().replace(/^["']|["']$/g, "");
181
+ } catch {
182
+ // Ignore unreadable secret files
183
+ }
184
+ }
159
185
 
160
- const match2 = content.match(
161
- new RegExp(`export\\s+${keyName}\\s*=\\s*'([^']+)'`)
162
- );
163
- if (match2) return match2[1]!;
186
+ return "";
187
+ }
164
188
 
165
- const match3 = content.match(new RegExp(`${keyName}\\s*=\\s*(.+)`));
166
- if (match3) return match3[1]!.trim().replace(/^["']|["']$/g, "");
189
+ function listSecretFiles(path: string): string[] {
190
+ try {
191
+ const stats = statSync(path);
192
+ if (stats.isFile()) return [path];
193
+ if (!stats.isDirectory()) return [];
194
+
195
+ return readdirSync(path)
196
+ .sort()
197
+ .flatMap((entry) => {
198
+ const child = join(path, entry);
199
+ try {
200
+ const childStats = statSync(child);
201
+ if (childStats.isDirectory()) return listSecretFiles(child);
202
+ if (childStats.isFile() && child.endsWith(".env")) return [child];
203
+ } catch {
204
+ // Ignore entries that disappear or are unreadable
205
+ }
206
+ return [];
207
+ });
167
208
  } catch {
168
- // Ignore
209
+ return [];
169
210
  }
170
- return "";
171
211
  }
172
212
 
173
213
  export function ensureDataDir(config: RecordingsConfig): void {
@@ -92,17 +92,12 @@ function extractInstruction(text: string, trigger: string): string {
92
92
  export async function enhanceText(
93
93
  rawText: string,
94
94
  instruction: string,
95
- config: RecordingsConfig
95
+ config: RecordingsConfig,
96
+ systemPrompt?: string
96
97
  ): Promise<EnhancementResult> {
97
98
  const client = getEnhancementClient(config);
98
99
 
99
- try {
100
- const response = await client.chat.completions.create({
101
- model: config.enhancement_model,
102
- messages: [
103
- {
104
- role: "system",
105
- content: `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
100
+ const basePrompt = `You are a writing assistant. The user has dictated speech that needs to be transformed into polished output.
106
101
 
107
102
  Rules:
108
103
  - Output ONLY the enhanced/rewritten text — no explanations, no preamble
@@ -110,7 +105,17 @@ Rules:
110
105
  - Fix grammar, structure, and clarity
111
106
  - If the user is giving instructions (e.g., "write an email saying..."), produce the actual output (the email), not a description of it
112
107
  - If the user says "say it better" or similar, rewrite their preceding text to be clearer and more professional
113
- - Match the appropriate tone (formal for business, casual for personal)`,
108
+ - Match the appropriate tone (formal for business, casual for personal)`;
109
+
110
+ const fullPrompt = systemPrompt ? `${basePrompt}\n\nAdditional context:\n${systemPrompt}` : basePrompt;
111
+
112
+ try {
113
+ const response = await client.chat.completions.create({
114
+ model: config.enhancement_model,
115
+ messages: [
116
+ {
117
+ role: "system",
118
+ content: fullPrompt,
114
119
  },
115
120
  {
116
121
  role: "user",
@@ -141,7 +146,8 @@ Rules:
141
146
  */
142
147
  export async function processText(
143
148
  rawText: string,
144
- config: RecordingsConfig
149
+ config: RecordingsConfig,
150
+ systemPrompt?: string
145
151
  ): Promise<{
146
152
  text: string;
147
153
  mode: "raw" | "enhanced";
@@ -157,7 +163,7 @@ export async function processText(
157
163
  return { text: rawText, mode: "raw", enhancement_model: null };
158
164
  }
159
165
 
160
- const result = await enhanceText(rawText, detection.instruction, config);
166
+ const result = await enhanceText(rawText, detection.instruction, config, systemPrompt);
161
167
 
162
168
  return {
163
169
  text: result.enhanced,
package/src/mcp/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { registerCloudTools } from "@hasna/cloud";
4
5
  import { z } from "zod";
5
6
  import { loadConfig, ensureDataDir } from "../lib/config.js";
6
7
  import { getDatabase, getAdapter } from "../db/database.js";
@@ -459,4 +460,5 @@ server.tool(
459
460
  );
460
461
 
461
462
  const transport = new StdioServerTransport();
463
+ registerCloudTools(server, "recordings");
462
464
  await server.connect(transport);
@@ -1,8 +1,8 @@
1
1
  import Cocoa
2
2
 
3
3
  /// Monitors the fn/Globe key using CGEventTap.
4
- /// Based on the proven pattern from CustomWispr (open-source WisprFlow alternative).
5
- /// User must set System Settings > Keyboard > "Press fn key to: Do Nothing" for this to work.
4
+ /// Fn is exposed as a modifier flag, so we watch flagsChanged events and
5
+ /// check CGEventFlags.maskSecondaryFn rather than relying on a raw bit mask.
6
6
  final class FnKeyMonitor: @unchecked Sendable {
7
7
  var onFnKeyDown: (() -> Void)?
8
8
  var onFnKeyUp: (() -> Void)?
@@ -13,7 +13,6 @@ final class FnKeyMonitor: @unchecked Sendable {
13
13
  private var fnIsDown = false
14
14
 
15
15
  private static let fnKeyCode: UInt16 = 63
16
- private static let fnFlagMask: UInt64 = 0x800000
17
16
 
18
17
  /// Start monitoring. Returns true if successful.
19
18
  func start() -> Bool {
@@ -22,46 +21,54 @@ final class FnKeyMonitor: @unchecked Sendable {
22
21
  return true
23
22
  }
24
23
 
25
- fputs("[FnKeyMonitor] Creating event tap...\n", stderr)
26
- let eventMask: CGEventMask = (1 << CGEventType.flagsChanged.rawValue)
27
-
28
- // passRetained so the callback reference stays alive
29
- let selfPtr = Unmanaged.passRetained(self).toOpaque()
30
-
31
- guard let tap = CGEvent.tapCreate(
32
- tap: .cgSessionEventTap,
33
- place: .headInsertEventTap,
34
- options: .defaultTap,
35
- eventsOfInterest: eventMask,
36
- callback: { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
37
- guard let refcon = refcon else { return Unmanaged.passRetained(event) }
38
- let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
39
- return monitor.handleEvent(type: type, event: event)
40
- },
41
- userInfo: selfPtr
42
- ) else {
43
- Unmanaged<FnKeyMonitor>.fromOpaque(selfPtr).release()
44
- return false
45
- }
46
-
47
- self.eventTap = tap
48
-
49
- let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
50
- self.runLoopSource = source
51
- CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes)
52
- CGEvent.tapEnable(tap: tap, enable: true)
53
- fputs("[FnKeyMonitor] Event tap created and enabled OK\n", stderr)
24
+ let eventMask: CGEventMask = 1 << CGEventType.flagsChanged.rawValue
25
+ let selfPtr = Unmanaged.passUnretained(self).toOpaque()
26
+ let candidates: [(CGEventTapLocation, CGEventTapPlacement, String)] = [
27
+ (.cgAnnotatedSessionEventTap, .tailAppendEventTap, "annotated-session/tail"),
28
+ (.cgSessionEventTap, .tailAppendEventTap, "session/tail"),
29
+ (.cghidEventTap, .headInsertEventTap, "hid/head"),
30
+ ]
31
+
32
+ for (tapLocation, tapPlacement, label) in candidates {
33
+ fputs("[FnKeyMonitor] Trying event tap: \(label)\n", stderr)
34
+
35
+ guard let tap = CGEvent.tapCreate(
36
+ tap: tapLocation,
37
+ place: tapPlacement,
38
+ options: .defaultTap,
39
+ eventsOfInterest: eventMask,
40
+ callback: { _, type, event, refcon -> Unmanaged<CGEvent>? in
41
+ guard let refcon else { return Unmanaged.passRetained(event) }
42
+ let monitor = Unmanaged<FnKeyMonitor>.fromOpaque(refcon).takeUnretainedValue()
43
+ return monitor.handleEvent(type: type, event: event)
44
+ },
45
+ userInfo: selfPtr
46
+ ) else {
47
+ continue
48
+ }
54
49
 
55
- // Health check: macOS silently disables taps — re-enable every 3 seconds
56
- healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
57
- guard let self = self, let tap = self.eventTap else { return }
58
- if !CGEvent.tapIsEnabled(tap: tap) {
59
- fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
60
- CGEvent.tapEnable(tap: tap, enable: true)
50
+ eventTap = tap
51
+ runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
52
+ if let runLoopSource {
53
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
54
+ }
55
+ CGEvent.tapEnable(tap: tap, enable: true)
56
+ fputs("[FnKeyMonitor] Event tap ready: \(label)\n", stderr)
57
+
58
+ // Health check: macOS can silently disable taps — re-enable every 3 seconds.
59
+ healthCheckTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
60
+ guard let self, let tap = self.eventTap else { return }
61
+ if !CGEvent.tapIsEnabled(tap: tap) {
62
+ fputs("[FnKeyMonitor] Tap was disabled, re-enabling\n", stderr)
63
+ CGEvent.tapEnable(tap: tap, enable: true)
64
+ }
61
65
  }
66
+
67
+ return true
62
68
  }
63
69
 
64
- return true
70
+ fputs("[FnKeyMonitor] Failed to create any event tap\n", stderr)
71
+ return false
65
72
  }
66
73
 
67
74
  func stop() {
@@ -80,7 +87,6 @@ final class FnKeyMonitor: @unchecked Sendable {
80
87
  }
81
88
 
82
89
  private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
83
- // Re-enable if macOS disabled the tap
84
90
  if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
85
91
  if let tap = eventTap {
86
92
  CGEvent.tapEnable(tap: tap, enable: true)
@@ -88,33 +94,36 @@ final class FnKeyMonitor: @unchecked Sendable {
88
94
  return Unmanaged.passRetained(event)
89
95
  }
90
96
 
97
+ guard type == .flagsChanged else {
98
+ return Unmanaged.passRetained(event)
99
+ }
100
+
91
101
  let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
102
+ let fnPressed = event.flags.contains(.maskSecondaryFn)
103
+ let isFnTransition = keyCode == FnKeyMonitor.fnKeyCode || fnPressed || fnIsDown
92
104
 
93
- // Only handle fn key (keyCode 63)
94
- guard keyCode == FnKeyMonitor.fnKeyCode else {
105
+ guard isFnTransition else {
95
106
  return Unmanaged.passRetained(event)
96
107
  }
97
108
 
98
109
  let flags = event.flags.rawValue
99
- let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
100
-
101
110
  fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
102
111
 
103
112
  if fnPressed && !fnIsDown {
104
- // fn just pressed
105
113
  fnIsDown = true
106
114
  fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
107
115
  DispatchQueue.main.async { [weak self] in
108
116
  self?.onFnKeyDown?()
109
117
  }
110
- return nil // Swallow — prevents emoji picker / language switch
111
- } else if !fnPressed && fnIsDown {
112
- // fn released
118
+ return nil
119
+ }
120
+
121
+ if !fnPressed && fnIsDown {
113
122
  fnIsDown = false
114
123
  DispatchQueue.main.async { [weak self] in
115
124
  self?.onFnKeyUp?()
116
125
  }
117
- return nil // Swallow release too
126
+ return nil
118
127
  }
119
128
 
120
129
  return Unmanaged.passRetained(event)