Skip to content

Commit 4545288

Browse files
Added initial files with end2end working
1 parent 4b6ddd7 commit 4545288

26 files changed

Lines changed: 1072 additions & 97 deletions

.gitignore

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Xcode
2+
.DS_Store
3+
build/
4+
*.pbxuser
5+
!default.pbxuser
6+
*.mode1v3
7+
!default.mode1v3
8+
*.mode2v3
9+
!default.mode2v3
10+
*.perspectivev3
11+
!default.perspectivev3
12+
xcuserdata/
13+
*.xccheckout
14+
*.moved-aside
15+
DerivedData/
16+
*.hmap
17+
*.ipa
18+
*.xcuserstate
19+
*.xcscmblueprint
20+
21+
# Swift Package Manager
22+
.swiftpm/
23+
.build/
24+
25+
# CocoaPods
26+
Pods/
27+
28+
# Carthage
29+
Carthage/Build/
30+
31+
# Accio dependency management
32+
Dependencies/
33+
.accio/
34+
35+
# fastlane
36+
fastlane/report.xml
37+
fastlane/Preview.html
38+
fastlane/screenshots/**/*.png
39+
fastlane/test_output
40+
41+
# Code Injection
42+
iOSInjectionProject/
43+
44+
# Icon generation script
45+
generate_icon.swift
46+
47+
# Releases
48+
releases/
49+
RELEASE_NOTES_*.md
50+
release.sh
51+
.vscode/

EchoX.xcodeproj/project.pbxproj

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,19 @@
1313
/* Begin PBXFileSystemSynchronizedRootGroup section */
1414
7C484A612EC5AF2800EE09A5 /* EchoX */ = {
1515
isa = PBXFileSystemSynchronizedRootGroup;
16+
exceptions = (
17+
7C484A6F2EC5B00000EE09A5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */,
18+
);
1619
path = EchoX;
1720
sourceTree = "<group>";
1821
};
22+
7C484A6F2EC5B00000EE09A5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
23+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
24+
membershipExceptions = (
25+
Info.plist,
26+
);
27+
target = 7C484A5E2EC5AF2800EE09A5 /* EchoX */;
28+
};
1929
/* End PBXFileSystemSynchronizedRootGroup section */
2030

2131
/* Begin PBXFrameworksBuildPhase section */
@@ -250,15 +260,17 @@
250260
buildSettings = {
251261
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
252262
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
263+
CODE_SIGN_ENTITLEMENTS = EchoX/EchoX.entitlements;
253264
CODE_SIGN_STYLE = Automatic;
254265
COMBINE_HIDPI_IMAGES = YES;
255266
CURRENT_PROJECT_VERSION = 1;
256267
DEVELOPMENT_TEAM = V4J43B279J;
257-
ENABLE_APP_SANDBOX = YES;
268+
ENABLE_APP_SANDBOX = NO;
258269
ENABLE_HARDENED_RUNTIME = YES;
259270
ENABLE_PREVIEWS = YES;
260271
ENABLE_USER_SELECTED_FILES = readonly;
261-
GENERATE_INFOPLIST_FILE = YES;
272+
GENERATE_INFOPLIST_FILE = NO;
273+
INFOPLIST_FILE = EchoX/Info.plist;
262274
INFOPLIST_KEY_NSHumanReadableCopyright = "";
263275
LD_RUNPATH_SEARCH_PATHS = (
264276
"$(inherited)",
@@ -282,15 +294,17 @@
282294
buildSettings = {
283295
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
284296
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
297+
CODE_SIGN_ENTITLEMENTS = EchoX/EchoX.entitlements;
285298
CODE_SIGN_STYLE = Automatic;
286299
COMBINE_HIDPI_IMAGES = YES;
287300
CURRENT_PROJECT_VERSION = 1;
288301
DEVELOPMENT_TEAM = V4J43B279J;
289-
ENABLE_APP_SANDBOX = YES;
302+
ENABLE_APP_SANDBOX = NO;
290303
ENABLE_HARDENED_RUNTIME = YES;
291304
ENABLE_PREVIEWS = YES;
292305
ENABLE_USER_SELECTED_FILES = readonly;
293-
GENERATE_INFOPLIST_FILE = YES;
306+
GENERATE_INFOPLIST_FILE = NO;
307+
INFOPLIST_FILE = EchoX/Info.plist;
294308
INFOPLIST_KEY_NSHumanReadableCopyright = "";
295309
LD_RUNPATH_SEARCH_PATHS = (
296310
"$(inherited)",

EchoX/AppDelegate.swift

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
//
2+
// AppDelegate.swift
3+
// EchoX
4+
//
5+
// Main application delegate managing menubar, shortcuts, and recording indicator
6+
//
7+
8+
import Cocoa
9+
import SwiftUI
10+
import Carbon.HIToolbox
11+
import AVFoundation
12+
13+
class AppDelegate: NSObject, NSApplicationDelegate {
14+
var statusItem: NSStatusItem?
15+
var audioManager: AudioManager!
16+
var permissionManager: PermissionManager!
17+
var settingsWindow: NSWindow?
18+
var globalEventMonitor: Any?
19+
var localEventMonitor: Any?
20+
var recordingIndicator: RecordingIndicatorWindow?
21+
var permissionCheckTimer: Timer?
22+
var eventTapCreationFailed = false
23+
24+
func applicationDidFinishLaunching(_ notification: Notification) {
25+
setupMenuBar()
26+
audioManager = AudioManager()
27+
permissionManager = PermissionManager()
28+
setupGlobalKeyboardShortcut()
29+
requestPermissions()
30+
31+
// Start monitoring for accessibility permission changes
32+
if eventTapCreationFailed {
33+
startPermissionMonitoring()
34+
}
35+
}
36+
37+
func requestPermissions() {
38+
// Request microphone permission
39+
AVCaptureDevice.requestAccess(for: .audio) { granted in
40+
DispatchQueue.main.async { [weak self] in
41+
self?.permissionManager.microphoneStatus = granted ? .granted : .denied
42+
if !granted {
43+
self?.showPermissionAlert(for: "Microphone")
44+
}
45+
}
46+
}
47+
48+
// Accessibility permission is checked automatically when creating event tap
49+
// If it fails, showAccessibilityAlert() will be called from setupGlobalKeyboardShortcut()
50+
permissionManager.checkAccessibilityPermission()
51+
}
52+
53+
func showPermissionAlert(for permission: String) {
54+
let alert = NSAlert()
55+
alert.messageText = "\(permission) Permission Required"
56+
alert.informativeText = "EchoX needs \(permission.lowercased()) access to function properly. Please grant permission in System Settings."
57+
alert.alertStyle = .warning
58+
alert.addButton(withTitle: "Open Settings")
59+
alert.addButton(withTitle: "Later")
60+
61+
let response = alert.runModal()
62+
if response == .alertFirstButtonReturn {
63+
permissionManager.openSystemPreferences(for: permission.lowercased())
64+
}
65+
}
66+
67+
func setupMenuBar() {
68+
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
69+
70+
if let button = statusItem?.button {
71+
button.image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "EchoX")
72+
}
73+
74+
let menu = NSMenu()
75+
76+
menu.addItem(NSMenuItem(title: "Settings", action: #selector(openSettings), keyEquivalent: ","))
77+
menu.addItem(NSMenuItem.separator())
78+
menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
79+
80+
statusItem?.menu = menu
81+
}
82+
83+
func setupGlobalKeyboardShortcut() {
84+
recordingIndicator = RecordingIndicatorWindow()
85+
86+
let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue) | CGEventMask(1 << CGEventType.keyUp.rawValue)
87+
88+
guard let eventTap = CGEvent.tapCreate(
89+
tap: .cgSessionEventTap,
90+
place: .headInsertEventTap,
91+
options: CGEventTapOptions(rawValue: 0)!, // Active filter (not passive listener)
92+
eventsOfInterest: eventMask,
93+
callback: { proxy, type, event, refcon in
94+
let appDelegate = Unmanaged<AppDelegate>.fromOpaque(refcon!).takeUnretainedValue()
95+
return appDelegate.handleGlobalKeyEvent(proxy: proxy, type: type, event: event)
96+
},
97+
userInfo: Unmanaged.passUnretained(self).toOpaque()
98+
) else {
99+
print("ERROR: Failed to create event tap. Grant Accessibility permission in System Settings.")
100+
// macOS will show its own system dialog automatically
101+
eventTapCreationFailed = true
102+
return
103+
}
104+
105+
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0)
106+
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
107+
CGEvent.tapEnable(tap: eventTap, enable: true)
108+
109+
print("Global keyboard shortcut enabled.")
110+
}
111+
112+
func handleGlobalKeyEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
113+
if type == .keyDown || type == .keyUp {
114+
let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode))
115+
let flags = event.flags
116+
117+
// Build required modifier flags
118+
var requiredModifiers: CGEventFlags = []
119+
if audioManager.shortcutModifierFlags.contains(.command) { requiredModifiers.insert(.maskCommand) }
120+
if audioManager.shortcutModifierFlags.contains(.option) { requiredModifiers.insert(.maskAlternate) }
121+
if audioManager.shortcutModifierFlags.contains(.shift) { requiredModifiers.insert(.maskShift) }
122+
if audioManager.shortcutModifierFlags.contains(.control) { requiredModifiers.insert(.maskControl) }
123+
124+
// Check if keyCode matches
125+
guard keyCode == audioManager.shortcutKeyCode else {
126+
return Unmanaged.passRetained(event)
127+
}
128+
129+
// Extract only the modifier flags we care about from the event
130+
let relevantFlags = flags.intersection([.maskCommand, .maskAlternate, .maskShift, .maskControl])
131+
132+
// Check if modifiers match exactly
133+
guard relevantFlags == requiredModifiers else {
134+
return Unmanaged.passRetained(event)
135+
}
136+
137+
// At this point, we have a match - consume the event completely
138+
let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) == 1
139+
140+
DispatchQueue.main.async { [weak self] in
141+
if type == .keyDown && !isRepeat {
142+
print("✓ Shortcut matched - starting recording")
143+
self?.audioManager.startRecording()
144+
self?.recordingIndicator?.show()
145+
} else if type == .keyUp {
146+
print("✓ Shortcut released - stopping recording")
147+
self?.audioManager.stopRecordingAndPlayback()
148+
self?.recordingIndicator?.hide()
149+
}
150+
}
151+
152+
// Return nil to completely block this event from propagating
153+
return nil
154+
}
155+
156+
return Unmanaged.passRetained(event)
157+
}
158+
159+
160+
@objc func openSettings() {
161+
if settingsWindow == nil {
162+
let settingsView = SettingsView(
163+
audioManager: audioManager,
164+
permissionManager: permissionManager,
165+
onClose: { [weak self] in
166+
self?.settingsWindow?.close()
167+
self?.settingsWindow = nil
168+
}
169+
)
170+
let hostingController = NSHostingController(rootView: settingsView)
171+
172+
settingsWindow = NSWindow(contentViewController: hostingController)
173+
settingsWindow?.title = "EchoX Settings"
174+
settingsWindow?.styleMask = [.titled, .closable]
175+
settingsWindow?.setContentSize(NSSize(width: 450, height: 500))
176+
settingsWindow?.center()
177+
}
178+
179+
permissionManager.refreshPermissions()
180+
settingsWindow?.makeKeyAndOrderFront(nil)
181+
NSApp.activate(ignoringOtherApps: true)
182+
}
183+
184+
@objc func quit() {
185+
NSApplication.shared.terminate(nil)
186+
}
187+
188+
// MARK: - Permission Monitoring
189+
190+
func startPermissionMonitoring() {
191+
// Check every 1 second if accessibility permission has been granted
192+
permissionCheckTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
193+
self?.checkAccessibilityPermissionAndRestart()
194+
}
195+
}
196+
197+
func checkAccessibilityPermissionAndRestart() {
198+
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false]
199+
let accessEnabled = AXIsProcessTrustedWithOptions(options)
200+
201+
if accessEnabled {
202+
print("✓ Accessibility permission granted! Restarting app...")
203+
permissionCheckTimer?.invalidate()
204+
permissionCheckTimer = nil
205+
restartApp()
206+
}
207+
}
208+
209+
func restartApp() {
210+
let task = Process()
211+
task.launchPath = "/usr/bin/open"
212+
task.arguments = [Bundle.main.bundlePath]
213+
task.launch()
214+
215+
NSApp.terminate(nil)
216+
}
217+
}

EchoX/Assets.xcassets/AppIcon.appiconset/Contents.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,61 @@
11
{
22
"images" : [
33
{
4+
"filename" : "icon_16x16.png",
45
"idiom" : "mac",
56
"scale" : "1x",
67
"size" : "16x16"
78
},
89
{
10+
"filename" : "icon_16x16@2x.png",
911
"idiom" : "mac",
1012
"scale" : "2x",
1113
"size" : "16x16"
1214
},
1315
{
16+
"filename" : "icon_32x32.png",
1417
"idiom" : "mac",
1518
"scale" : "1x",
1619
"size" : "32x32"
1720
},
1821
{
22+
"filename" : "icon_32x32@2x.png",
1923
"idiom" : "mac",
2024
"scale" : "2x",
2125
"size" : "32x32"
2226
},
2327
{
28+
"filename" : "icon_128x128.png",
2429
"idiom" : "mac",
2530
"scale" : "1x",
2631
"size" : "128x128"
2732
},
2833
{
34+
"filename" : "icon_128x128@2x.png",
2935
"idiom" : "mac",
3036
"scale" : "2x",
3137
"size" : "128x128"
3238
},
3339
{
40+
"filename" : "icon_256x256.png",
3441
"idiom" : "mac",
3542
"scale" : "1x",
3643
"size" : "256x256"
3744
},
3845
{
46+
"filename" : "icon_256x256@2x.png",
3947
"idiom" : "mac",
4048
"scale" : "2x",
4149
"size" : "256x256"
4250
},
4351
{
52+
"filename" : "icon_512x512.png",
4453
"idiom" : "mac",
4554
"scale" : "1x",
4655
"size" : "512x512"
4756
},
4857
{
58+
"filename" : "icon_512x512@2x.png",
4959
"idiom" : "mac",
5060
"scale" : "2x",
5161
"size" : "512x512"
13.8 KB
Loading
50.8 KB
Loading
565 Bytes
Loading
1.43 KB
Loading
50.8 KB
Loading
177 KB
Loading

0 commit comments

Comments
 (0)