@hasna/recordings 0.1.4 → 0.1.7

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/src/cli/index.ts CHANGED
@@ -485,24 +485,27 @@ program
485
485
  const { existsSync: fileExists } = require("node:fs") as typeof import("node:fs");
486
486
  const home = getHome();
487
487
 
488
- const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
488
+ const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
489
+ const oldAppPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
489
490
 
490
- if (!fileExists(appPath)) {
491
+ if (!fileExists(appPath) && !fileExists(oldAppPath)) {
491
492
  console.error(chalk.red("RecordingsHelper.app not found. Run: recordings shortcut --install"));
492
493
  process.exit(1);
493
494
  }
494
495
 
496
+ const resolvedAppPath = fileExists(appPath) ? appPath : oldAppPath;
497
+
495
498
  // Kill existing instance
496
499
  try { execSync("pkill -f RecordingsHelper", { stdio: "pipe" }); } catch { /* not running */ }
497
500
 
498
501
  // Launch
499
- execSync(`open "${appPath}"`, { stdio: "pipe" });
502
+ execSync(`open "${resolvedAppPath}"`, { stdio: "pipe" });
500
503
  console.log(chalk.green("Recordings helper launched — press F5 to record"));
501
504
 
502
505
  if (opts.login) {
503
506
  try {
504
507
  execSync(
505
- `osascript -e 'tell application "System Events" to make login item at end with properties {path:"${appPath}", hidden:true}'`,
508
+ `osascript -e 'tell application "System Events" to make login item at end with properties {path:"${resolvedAppPath}", hidden:true}'`,
506
509
  { stdio: "pipe" }
507
510
  );
508
511
  console.log(chalk.green("Added to Login Items — will start on boot"));
@@ -688,7 +691,7 @@ program
688
691
  const { homedir: getHome } = require("node:os") as typeof import("node:os");
689
692
  const home = getHome();
690
693
 
691
- const scriptDir = pathJoin(home, ".recordings");
694
+ const scriptDir = pathJoin(home, ".hasna", "recordings");
692
695
  mkdirSync(scriptDir, { recursive: true });
693
696
 
694
697
  const scriptPath = pathJoin(scriptDir, "record-toggle.sh");
@@ -747,7 +750,7 @@ fi
747
750
  // Install the native menu bar app — no config needed, just works with F5
748
751
  const { execSync: exec } = require("node:child_process") as typeof import("node:child_process");
749
752
 
750
- const appPath = pathJoin(home, ".recordings", "RecordingsHelper.app");
753
+ const appPath = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app");
751
754
  const srcSwift = pathJoin(__dirname, "..", "native", "RecordingsHelper.swift");
752
755
  const distApp = pathJoin(__dirname, "..", "RecordingsHelper.app");
753
756
 
@@ -757,10 +760,10 @@ fi
757
760
  } else if (fileExists(srcSwift)) {
758
761
  // Compile from source
759
762
  console.log(chalk.blue("Compiling native helper app..."));
760
- const appDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents", "MacOS");
763
+ const appDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents", "MacOS");
761
764
  mkdirSync(appDir, { recursive: true });
762
765
 
763
- const plistDir = pathJoin(home, ".recordings", "RecordingsHelper.app", "Contents");
766
+ const plistDir = pathJoin(home, ".hasna", "recordings", "RecordingsHelper.app", "Contents");
764
767
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
765
768
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
766
769
  <plist version="1.0"><dict>
package/src/lib/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "fs";
1
+ import { existsSync, readFileSync, mkdirSync, cpSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
4
  import type { RecordingsConfig } from "../types/index.js";
@@ -67,7 +67,9 @@ export function loadConfig(configPath?: string): RecordingsConfig {
67
67
  if (process.env.RECORDINGS_LANGUAGE) {
68
68
  config.language = process.env.RECORDINGS_LANGUAGE;
69
69
  }
70
- if (process.env.RECORDINGS_DB_PATH) {
70
+ if (process.env.HASNA_RECORDINGS_DB_PATH) {
71
+ config.db_path = process.env.HASNA_RECORDINGS_DB_PATH;
72
+ } else if (process.env.RECORDINGS_DB_PATH) {
71
73
  config.db_path = process.env.RECORDINGS_DB_PATH;
72
74
  }
73
75
  if (process.env.RECORDINGS_AUDIO_DIR) {
@@ -115,7 +117,7 @@ function findConfigFile(): string | null {
115
117
  }
116
118
 
117
119
  export function getDataDir(): string {
118
- // Check for .recordings in cwd hierarchy
120
+ // Check for .recordings in cwd hierarchy (project-local)
119
121
  let dir = process.cwd();
120
122
  const root = "/";
121
123
  while (dir !== root) {
@@ -125,8 +127,23 @@ export function getDataDir(): string {
125
127
  if (parent === dir) break;
126
128
  dir = parent;
127
129
  }
128
- // Fall back to ~/.recordings
129
- return join(homedir(), ".recordings");
130
+
131
+ // Global: ~/.hasna/recordings (with backward compat from ~/.recordings)
132
+ const home = homedir();
133
+ const newDir = join(home, ".hasna", "recordings");
134
+ const oldDir = join(home, ".recordings");
135
+
136
+ // Auto-migrate from old location if new dir doesn't exist yet
137
+ if (!existsSync(newDir) && existsSync(oldDir)) {
138
+ try {
139
+ mkdirSync(join(home, ".hasna"), { recursive: true });
140
+ cpSync(oldDir, newDir, { recursive: true });
141
+ } catch {
142
+ // Fall through to use new dir
143
+ }
144
+ }
145
+
146
+ return newDir;
130
147
  }
131
148
 
132
149
  function loadSecretKey(keyName: string): string {
@@ -0,0 +1,15 @@
1
+ {
2
+ "originHash" : "a6c896b6f831f2130914352c7417e95b036b1f02a1ff70318f1f4e5aa8966e9b",
3
+ "pins" : [
4
+ {
5
+ "identity" : "keyboardshortcuts",
6
+ "kind" : "remoteSourceControl",
7
+ "location" : "https://github.com/sindresorhus/KeyboardShortcuts",
8
+ "state" : {
9
+ "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27",
10
+ "version" : "2.4.0"
11
+ }
12
+ }
13
+ ],
14
+ "version" : 3
15
+ }
@@ -0,0 +1,21 @@
1
+ // swift-tools-version: 6.2
2
+
3
+ import PackageDescription
4
+
5
+ let package = Package(
6
+ name: "Recordings",
7
+ platforms: [
8
+ .macOS(.v26)
9
+ ],
10
+ dependencies: [
11
+ .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.0.0")
12
+ ],
13
+ targets: [
14
+ .executableTarget(
15
+ name: "Recordings",
16
+ dependencies: ["KeyboardShortcuts"],
17
+ path: "Recordings",
18
+ exclude: ["Info.plist", "Recordings.entitlements"]
19
+ )
20
+ ]
21
+ )
@@ -0,0 +1,122 @@
1
+ import Cocoa
2
+
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.
6
+ final class FnKeyMonitor: @unchecked Sendable {
7
+ var onFnKeyDown: (() -> Void)?
8
+ var onFnKeyUp: (() -> Void)?
9
+
10
+ private var eventTap: CFMachPort?
11
+ private var runLoopSource: CFRunLoopSource?
12
+ private var healthCheckTimer: Timer?
13
+ private var fnIsDown = false
14
+
15
+ private static let fnKeyCode: UInt16 = 63
16
+ private static let fnFlagMask: UInt64 = 0x800000
17
+
18
+ /// Start monitoring. Returns true if successful.
19
+ func start() -> Bool {
20
+ guard eventTap == nil else {
21
+ fputs("[FnKeyMonitor] Already running\n", stderr)
22
+ return true
23
+ }
24
+
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)
54
+
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)
61
+ }
62
+ }
63
+
64
+ return true
65
+ }
66
+
67
+ func stop() {
68
+ healthCheckTimer?.invalidate()
69
+ healthCheckTimer = nil
70
+
71
+ if let tap = eventTap {
72
+ CGEvent.tapEnable(tap: tap, enable: false)
73
+ }
74
+ if let source = runLoopSource {
75
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
76
+ }
77
+ eventTap = nil
78
+ runLoopSource = nil
79
+ fnIsDown = false
80
+ }
81
+
82
+ private func handleEvent(type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
83
+ // Re-enable if macOS disabled the tap
84
+ if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
85
+ if let tap = eventTap {
86
+ CGEvent.tapEnable(tap: tap, enable: true)
87
+ }
88
+ return Unmanaged.passRetained(event)
89
+ }
90
+
91
+ let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
92
+
93
+ // Only handle fn key (keyCode 63)
94
+ guard keyCode == FnKeyMonitor.fnKeyCode else {
95
+ return Unmanaged.passRetained(event)
96
+ }
97
+
98
+ let flags = event.flags.rawValue
99
+ let fnPressed = (flags & FnKeyMonitor.fnFlagMask) != 0
100
+
101
+ fputs("[FnKeyMonitor] flagsChanged keyCode=\(keyCode) flags=0x\(String(flags, radix: 16)) fnPressed=\(fnPressed) fnIsDown=\(fnIsDown)\n", stderr)
102
+
103
+ if fnPressed && !fnIsDown {
104
+ // fn just pressed
105
+ fnIsDown = true
106
+ fputs("[FnKeyMonitor] fn DOWN — starting recording\n", stderr)
107
+ DispatchQueue.main.async { [weak self] in
108
+ self?.onFnKeyDown?()
109
+ }
110
+ return nil // Swallow — prevents emoji picker / language switch
111
+ } else if !fnPressed && fnIsDown {
112
+ // fn released
113
+ fnIsDown = false
114
+ DispatchQueue.main.async { [weak self] in
115
+ self?.onFnKeyUp?()
116
+ }
117
+ return nil // Swallow release too
118
+ }
119
+
120
+ return Unmanaged.passRetained(event)
121
+ }
122
+ }
@@ -0,0 +1,22 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>LSUIElement</key>
6
+ <true/>
7
+ <key>CFBundleName</key>
8
+ <string>Recordings</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>Recordings</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>com.hasna.recordings</string>
13
+ <key>CFBundleVersion</key>
14
+ <string>1</string>
15
+ <key>CFBundleShortVersionString</key>
16
+ <string>0.1.0</string>
17
+ <key>LSMinimumSystemVersion</key>
18
+ <string>26.0</string>
19
+ <key>NSMicrophoneUsageDescription</key>
20
+ <string>Recordings needs microphone access to record and transcribe your speech.</string>
21
+ </dict>
22
+ </plist>
@@ -0,0 +1,188 @@
1
+ import SwiftUI
2
+ import KeyboardShortcuts
3
+
4
+ struct MenuBarPopover: View {
5
+ @ObservedObject var engine: RecordingEngine
6
+ @ObservedObject var shortcuts: VoiceShortcuts
7
+
8
+ var body: some View {
9
+ VStack(spacing: 0) {
10
+ header.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
11
+ Divider()
12
+ modeSelector.padding(.horizontal, 16).padding(.vertical, 10)
13
+ shortcutRow.padding(.horizontal, 16).padding(.bottom, 8)
14
+ Divider()
15
+ recordingArea.padding(.horizontal, 16).padding(.vertical, 12)
16
+ Divider()
17
+ recentList.frame(maxHeight: 180)
18
+ Divider()
19
+ footer.padding(.horizontal, 16).padding(.vertical, 8)
20
+ }
21
+ }
22
+
23
+ // MARK: - Header
24
+
25
+ private var header: some View {
26
+ HStack {
27
+ Image(systemName: "mic.fill").font(.title3).foregroundStyle(.tint)
28
+ Text("Recordings").font(.headline)
29
+ Spacer()
30
+ Toggle(isOn: $engine.isWhisperMode) {
31
+ Label("Whisper", systemImage: "speaker.wave.1.fill").font(.caption)
32
+ }
33
+ .toggleStyle(.button).buttonStyle(.bordered).controlSize(.small)
34
+ }
35
+ }
36
+
37
+ // MARK: - Mode Selector
38
+
39
+ private var modeSelector: some View {
40
+ HStack(spacing: 8) {
41
+ modeBtn(.pushToTalk)
42
+ modeBtn(.dictation)
43
+ modeBtn(.command)
44
+ }
45
+ }
46
+
47
+ @ViewBuilder
48
+ private func modeBtn(_ m: RecordingMode) -> some View {
49
+ if engine.mode == m {
50
+ Button { engine.mode = m } label: {
51
+ Label(m.rawValue, systemImage: m.icon).font(.caption)
52
+ .foregroundStyle(.white).frame(maxWidth: .infinity)
53
+ }.buttonStyle(.borderedProminent).controlSize(.small)
54
+ } else {
55
+ Button { engine.mode = m } label: {
56
+ Label(m.rawValue, systemImage: m.icon).font(.caption)
57
+ .frame(maxWidth: .infinity)
58
+ }.buttonStyle(.bordered).controlSize(.small)
59
+ }
60
+ }
61
+
62
+ // MARK: - Shortcut Row
63
+
64
+ private var shortcutRow: some View {
65
+ VStack(spacing: 6) {
66
+ // fn key toggle
67
+ HStack {
68
+ Toggle(isOn: $engine.useFnKey) {
69
+ HStack(spacing: 4) {
70
+ Text("fn").font(.system(.caption, design: .monospaced)).bold()
71
+ Text("Globe key").font(.caption).foregroundStyle(.secondary)
72
+ }
73
+ }
74
+ .toggleStyle(.switch).controlSize(.small)
75
+ }
76
+
77
+ // Custom shortcut
78
+ HStack(spacing: 8) {
79
+ Text("Custom").font(.caption).foregroundStyle(.secondary)
80
+ Spacer()
81
+ let current = KeyboardShortcuts.getShortcut(for: .toggleRecording)
82
+ Text(current?.description ?? "None")
83
+ .font(.system(.caption, design: .monospaced))
84
+ .padding(.horizontal, 8).padding(.vertical, 3)
85
+ .background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
86
+
87
+ SettingsLink {
88
+ Text("Set").font(.caption2)
89
+ }.buttonStyle(.bordered).controlSize(.mini)
90
+ }
91
+ }
92
+ }
93
+
94
+ // MARK: - Recording Area
95
+
96
+ private var recordingArea: some View {
97
+ VStack(spacing: 8) {
98
+ if engine.isRecording {
99
+ HStack(spacing: 12) {
100
+ Circle().fill(.red).frame(width: 10, height: 10)
101
+ Text(fmt(engine.recordingDuration))
102
+ .font(.system(.title2, design: .monospaced))
103
+ Spacer()
104
+ Button("Stop") { engine.stopAndTranscribe() }
105
+ .buttonStyle(.borderedProminent).tint(.red).controlSize(.small)
106
+ }
107
+ .padding(12).glassEffect(.regular.tint(.red))
108
+ } else if engine.isTranscribing {
109
+ HStack(spacing: 8) {
110
+ ProgressView().controlSize(.small)
111
+ Text("Transcribing...").font(.subheadline).foregroundStyle(.secondary)
112
+ }
113
+ .frame(maxWidth: .infinity).padding(12).glassEffect(.regular)
114
+ } else {
115
+ VStack(spacing: 10) {
116
+ Button { engine.startRecording() } label: {
117
+ Label("Record", systemImage: "mic.circle.fill").font(.title3)
118
+ }.buttonStyle(.borderedProminent).controlSize(.large)
119
+
120
+ Text(engine.mode.hint)
121
+ .font(.callout).foregroundStyle(.primary.opacity(0.7))
122
+ .multilineTextAlignment(.center)
123
+ .fixedSize(horizontal: false, vertical: true)
124
+ }
125
+ .frame(maxWidth: .infinity).padding(14).glassEffect(.clear)
126
+ }
127
+
128
+ Text(engine.statusMessage)
129
+ .font(.caption2).foregroundStyle(.secondary)
130
+ .lineLimit(2).truncationMode(.tail)
131
+ .frame(maxWidth: .infinity, alignment: .leading)
132
+ }
133
+ }
134
+
135
+ // MARK: - Recent
136
+
137
+ private var recentList: some View {
138
+ Group {
139
+ if engine.recentTranscriptions.isEmpty {
140
+ VStack { Spacer()
141
+ Text("No recent transcriptions").font(.caption).foregroundStyle(.quaternary)
142
+ Spacer()
143
+ }.frame(maxWidth: .infinity)
144
+ } else {
145
+ ScrollView {
146
+ LazyVStack(alignment: .leading, spacing: 4) {
147
+ ForEach(engine.recentTranscriptions.indices, id: \.self) { i in
148
+ TranscriptionRow(item: engine.recentTranscriptions[i])
149
+ }
150
+ }.padding(.horizontal, 16).padding(.vertical, 6)
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ // MARK: - Footer
157
+
158
+ private var footer: some View {
159
+ HStack {
160
+ SettingsLink { Image(systemName: "gear") }.buttonStyle(.borderless)
161
+ Spacer()
162
+ Button("Quit") { NSApplication.shared.terminate(nil) }
163
+ .buttonStyle(.borderless).foregroundStyle(.secondary)
164
+ }
165
+ }
166
+
167
+ private func fmt(_ t: TimeInterval) -> String {
168
+ String(format: "%d:%02d", Int(t) / 60, Int(t) % 60)
169
+ }
170
+ }
171
+
172
+ struct TranscriptionRow: View {
173
+ let item: TranscriptionResult
174
+ var body: some View {
175
+ HStack(alignment: .top, spacing: 8) {
176
+ Text(item.displayText).font(.caption).lineLimit(2)
177
+ .frame(maxWidth: .infinity, alignment: .leading)
178
+ Text({ let s = Date().timeIntervalSince(item.timestamp)
179
+ return s < 60 ? "now" : s < 3600 ? "\(Int(s/60))m" : "\(Int(s/3600))h"
180
+ }()).font(.caption2).foregroundStyle(.tertiary)
181
+ }
182
+ .padding(.vertical, 4).contentShape(Rectangle())
183
+ .onTapGesture {
184
+ NSPasteboard.general.clearContents()
185
+ NSPasteboard.general.setString(item.displayText, forType: .string)
186
+ }
187
+ }
188
+ }