@hasna/recordings 0.1.3 → 0.1.6
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/LICENSE +170 -21
- package/dist/mcp/index.js +17 -2
- package/package.json +4 -4
- package/src/db/database.ts +8 -0
- package/src/db/recordings.ts +8 -2
- package/src/mcp/index.ts +6 -0
- package/src/native/Recordings/Package.resolved +15 -0
- package/src/native/Recordings/Package.swift +21 -0
- package/src/native/Recordings/Recordings/FnKeyMonitor.swift +122 -0
- package/src/native/Recordings/Recordings/Info.plist +22 -0
- package/src/native/Recordings/Recordings/MenuBarPopover.swift +188 -0
- package/src/native/Recordings/Recordings/RecordingEngine.swift +355 -0
- package/src/native/Recordings/Recordings/Recordings.entitlements +12 -0
- package/src/native/Recordings/Recordings/RecordingsApp.swift +30 -0
- package/src/native/Recordings/Recordings/SettingsView.swift +93 -0
- package/src/native/Recordings/Recordings/VoiceShortcuts.swift +82 -0
- package/src/native/Recordings/build.sh +40 -0
- package/src/native/Recordings/test_fn.swift +79 -0
- package/src/native/Recordings/test_fn2.swift +33 -0
- package/src/native/RecordingsHelper.swift +36 -0
- package/src/types/index.ts +6 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
// MARK: - Voice Shortcut
|
|
4
|
+
|
|
5
|
+
struct VoiceShortcut: Identifiable, Codable {
|
|
6
|
+
let id: UUID
|
|
7
|
+
var trigger: String // e.g. "add disclaimer"
|
|
8
|
+
var content: String // text to insert when trigger is spoken
|
|
9
|
+
|
|
10
|
+
init(trigger: String, content: String) {
|
|
11
|
+
self.id = UUID()
|
|
12
|
+
self.trigger = trigger
|
|
13
|
+
self.content = content
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// MARK: - Voice Shortcuts Manager
|
|
18
|
+
|
|
19
|
+
@MainActor
|
|
20
|
+
final class VoiceShortcuts: ObservableObject {
|
|
21
|
+
@Published var shortcuts: [VoiceShortcut] = []
|
|
22
|
+
|
|
23
|
+
private let storageURL: URL = {
|
|
24
|
+
let home = FileManager.default.homeDirectoryForCurrentUser
|
|
25
|
+
return home.appendingPathComponent(".recordings/voice-shortcuts.json")
|
|
26
|
+
}()
|
|
27
|
+
|
|
28
|
+
init() {
|
|
29
|
+
load()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func add(trigger: String, content: String) {
|
|
33
|
+
let shortcut = VoiceShortcut(trigger: trigger, content: content)
|
|
34
|
+
shortcuts.append(shortcut)
|
|
35
|
+
save()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func remove(at offsets: IndexSet) {
|
|
39
|
+
shortcuts.remove(atOffsets: offsets)
|
|
40
|
+
save()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
func update(_ shortcut: VoiceShortcut) {
|
|
44
|
+
if let index = shortcuts.firstIndex(where: { $0.id == shortcut.id }) {
|
|
45
|
+
shortcuts[index] = shortcut
|
|
46
|
+
save()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Check if transcribed text matches a voice shortcut trigger.
|
|
51
|
+
/// Returns the content to paste if matched, nil otherwise.
|
|
52
|
+
func match(_ text: String) -> String? {
|
|
53
|
+
let lower = text.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
|
54
|
+
for shortcut in shortcuts {
|
|
55
|
+
if lower.contains(shortcut.trigger.lowercased()) {
|
|
56
|
+
return shortcut.content
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// MARK: - Persistence
|
|
63
|
+
|
|
64
|
+
private func save() {
|
|
65
|
+
do {
|
|
66
|
+
let data = try JSONEncoder().encode(shortcuts)
|
|
67
|
+
try data.write(to: storageURL, options: .atomic)
|
|
68
|
+
} catch {
|
|
69
|
+
print("Failed to save voice shortcuts: \(error)")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func load() {
|
|
74
|
+
guard FileManager.default.fileExists(atPath: storageURL.path) else { return }
|
|
75
|
+
do {
|
|
76
|
+
let data = try Data(contentsOf: storageURL)
|
|
77
|
+
shortcuts = try JSONDecoder().decode([VoiceShortcut].self, from: data)
|
|
78
|
+
} catch {
|
|
79
|
+
print("Failed to load voice shortcuts: \(error)")
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Build Recordings.app for macOS 26
|
|
3
|
+
# Usage: ./build.sh [debug|release]
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
MODE="${1:-release}"
|
|
8
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
cd "$SCRIPT_DIR"
|
|
10
|
+
|
|
11
|
+
echo "Building Recordings.app ($MODE)..."
|
|
12
|
+
swift build -c "$MODE"
|
|
13
|
+
|
|
14
|
+
# Create .app bundle
|
|
15
|
+
BUILD_DIR=".build/$MODE"
|
|
16
|
+
APP_DIR="$BUILD_DIR/Recordings.app"
|
|
17
|
+
CONTENTS="$APP_DIR/Contents"
|
|
18
|
+
MACOS="$CONTENTS/MacOS"
|
|
19
|
+
|
|
20
|
+
rm -rf "$APP_DIR"
|
|
21
|
+
mkdir -p "$MACOS"
|
|
22
|
+
|
|
23
|
+
# Copy binary
|
|
24
|
+
cp "$BUILD_DIR/Recordings" "$MACOS/Recordings"
|
|
25
|
+
|
|
26
|
+
# Copy Info.plist
|
|
27
|
+
cp Recordings/Info.plist "$CONTENTS/Info.plist"
|
|
28
|
+
|
|
29
|
+
# Copy entitlements (for codesigning)
|
|
30
|
+
if [ -f Recordings/Recordings.entitlements ]; then
|
|
31
|
+
codesign --force --sign - --entitlements Recordings/Recordings.entitlements "$APP_DIR" 2>/dev/null || true
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
echo "✓ Built $APP_DIR"
|
|
35
|
+
echo ""
|
|
36
|
+
echo "To install to ~/.recordings/:"
|
|
37
|
+
echo " cp -r $APP_DIR ~/.recordings/Recordings.app"
|
|
38
|
+
echo ""
|
|
39
|
+
echo "To run:"
|
|
40
|
+
echo " open $APP_DIR"
|
|
@@ -0,0 +1,79 @@
|
|
|
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()
|
|
@@ -0,0 +1,33 @@
|
|
|
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()
|
|
@@ -60,11 +60,47 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
60
60
|
|
|
61
61
|
// ── Global Hotkey ───────────────────────────────────────────────────────
|
|
62
62
|
|
|
63
|
+
/// Check if Accessibility permission is granted. If not, show a dialog and open System Settings.
|
|
64
|
+
func checkAccessibilityPermission() -> Bool {
|
|
65
|
+
// First check without prompting
|
|
66
|
+
let trusted = AXIsProcessTrustedWithOptions(
|
|
67
|
+
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): false] as CFDictionary
|
|
68
|
+
)
|
|
69
|
+
if trusted { return true }
|
|
70
|
+
|
|
71
|
+
// Not trusted — show dialog explaining why we need it
|
|
72
|
+
let alert = NSAlert()
|
|
73
|
+
alert.messageText = "Accessibility Permission Required"
|
|
74
|
+
alert.informativeText = "RecordingsHelper needs Accessibility access to detect the global hotkey (hold Space to record).\n\nClick 'Open Settings' to grant permission, then restart the app."
|
|
75
|
+
alert.alertStyle = .warning
|
|
76
|
+
alert.addButton(withTitle: "Open Settings")
|
|
77
|
+
alert.addButton(withTitle: "Quit")
|
|
78
|
+
|
|
79
|
+
let response = alert.runModal()
|
|
80
|
+
if response == .alertFirstButtonReturn {
|
|
81
|
+
// Prompt the system dialog AND open System Settings > Accessibility
|
|
82
|
+
AXIsProcessTrustedWithOptions(
|
|
83
|
+
[kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
84
|
+
)
|
|
85
|
+
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") {
|
|
86
|
+
NSWorkspace.shared.open(url)
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
NSApplication.shared.terminate(nil)
|
|
90
|
+
}
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
|
|
63
94
|
func registerHotkey() {
|
|
64
95
|
// Hold Space for 1+ second to start recording, release to stop.
|
|
65
96
|
// Uses CGEventTap to monitor all keyboard events globally.
|
|
66
97
|
// Normal space presses (< 1 second) pass through untouched.
|
|
67
98
|
|
|
99
|
+
// Check Accessibility permission BEFORE attempting event tap
|
|
100
|
+
if !checkAccessibilityPermission() {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
68
104
|
let eventMask: CGEventMask = (1 << CGEventType.keyDown.rawValue) | (1 << CGEventType.keyUp.rawValue) | (1 << CGEventType.flagsChanged.rawValue)
|
|
69
105
|
|
|
70
106
|
guard let tap = CGEvent.tapCreate(
|
package/src/types/index.ts
CHANGED
|
@@ -14,6 +14,9 @@ export interface Recording {
|
|
|
14
14
|
agent_id: string | null;
|
|
15
15
|
project_id: string | null;
|
|
16
16
|
session_id: string | null;
|
|
17
|
+
goal: string | null;
|
|
18
|
+
role: string | null;
|
|
19
|
+
task_list_id: string | null;
|
|
17
20
|
metadata: Record<string, unknown>;
|
|
18
21
|
created_at: string;
|
|
19
22
|
}
|
|
@@ -33,6 +36,9 @@ export interface CreateRecordingInput {
|
|
|
33
36
|
agent_id?: string;
|
|
34
37
|
project_id?: string;
|
|
35
38
|
session_id?: string;
|
|
39
|
+
goal?: string;
|
|
40
|
+
role?: string;
|
|
41
|
+
task_list_id?: string;
|
|
36
42
|
metadata?: Record<string, unknown>;
|
|
37
43
|
}
|
|
38
44
|
|