diff --git a/.gitignore b/.gitignore
index a323fd5..bc4b6c5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,4 +19,6 @@ Thumbs.db
sidecar/dist/
sidecar/build/
sidecar/__pycache__/
+sidecar/darwin/PeakFlowHelper/.build/
+resources/darwin/peakflow-helper
nul
diff --git a/electron-builder.yml b/electron-builder.yml
index 4c5acf6..472eb9a 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -3,6 +3,7 @@ productName: PeakFlow
directories:
buildResources: resources
output: release
+afterSign: scripts/notarize.js
files:
- '!**/.vscode/*'
- '!src/*'
@@ -10,6 +11,19 @@ files:
- '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
- '!sidecar/*'
+mac:
+ category: public.app-category.productivity
+ hardenedRuntime: true
+ gatekeeperAssess: false
+ entitlements: resources/entitlements.mac.plist
+ entitlementsInherit: resources/entitlements.mac.inherit.plist
+ target:
+ - dmg
+ - zip
+ extendInfo:
+ NSCameraUsageDescription: PeakFlow needs camera access for meeting prep and focus tools.
+ NSMicrophoneUsageDescription: PeakFlow needs microphone access for meeting prep and mic mute controls.
+ NSAppleEventsUsageDescription: PeakFlow needs automation access to send paste shortcuts and control supported workflows.
win:
target:
- target: nsis
@@ -31,6 +45,8 @@ extraResources:
to: icon.png
- from: resources/tray-icon.png
to: tray-icon.png
+ - from: resources/darwin/peakflow-helper
+ to: peakflow-helper
asarUnpack:
- "**/*.node"
- "node_modules/koffi/**/*"
diff --git a/package-lock.json b/package-lock.json
index 210d0ff..2cf47e3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "peakflow-electron",
- "version": "1.5.0",
+ "version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peakflow-electron",
- "version": "1.5.0",
+ "version": "1.6.0",
"hasInstallScript": true,
"dependencies": {
"@tensorflow-models/blazeface": "^0.1.0",
@@ -20,6 +20,7 @@
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
+ "@electron/notarize": "^2.5.0",
"@tailwindcss/vite": "^4.1.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
diff --git a/package.json b/package.json
index f1161cf..e70e1d2 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,10 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"postinstall": "electron-builder install-app-deps",
+ "build:mac:helper": "node scripts/prepare-mac-helper.js",
"build:win": "npm run build && electron-builder --win",
+ "build:mac": "npm run build && npm run build:mac:helper && electron-builder --mac",
+ "build:mac:dir": "npm run build && npm run build:mac:helper && electron-builder --mac --dir",
"build:unpack": "npm run build && electron-builder --dir",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json",
@@ -26,6 +29,7 @@
"node-ical": "^0.25.4"
},
"devDependencies": {
+ "@electron/notarize": "^2.5.0",
"@electron-toolkit/eslint-config-ts": "^3.0.0",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
diff --git a/resources/entitlements.mac.inherit.plist b/resources/entitlements.mac.inherit.plist
new file mode 100644
index 0000000..a427614
--- /dev/null
+++ b/resources/entitlements.mac.inherit.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.inherit
+
+
+
diff --git a/resources/entitlements.mac.plist b/resources/entitlements.mac.plist
new file mode 100644
index 0000000..fe8ec5d
--- /dev/null
+++ b/resources/entitlements.mac.plist
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+
+
diff --git a/resources/icon.png b/resources/icon.png
index f0f92bb..2f81c27 100644
Binary files a/resources/icon.png and b/resources/icon.png differ
diff --git a/scripts/notarize.js b/scripts/notarize.js
new file mode 100644
index 0000000..4b5bd6f
--- /dev/null
+++ b/scripts/notarize.js
@@ -0,0 +1,42 @@
+/**
+ * Post-sign notarization script for macOS builds.
+ *
+ * Called by electron-builder after code signing (configured via afterSign in
+ * electron-builder.yml). Submits the signed .app to Apple for notarization,
+ * which is required for Gatekeeper to allow the app to run.
+ *
+ * Requires environment variables:
+ * APPLE_ID — Apple Developer account email
+ * APPLE_APP_SPECIFIC_PASSWORD — App-specific password (not account password)
+ * APPLE_TEAM_ID — 10-character team identifier
+ */
+
+const { notarize } = require('@electron/notarize')
+
+exports.default = async function notarizing(context) {
+ const { electronPlatformName, appOutDir } = context
+
+ // Only notarize macOS builds
+ if (electronPlatformName !== 'darwin') return
+
+ // Skip if signing credentials aren't available (local dev builds)
+ if (!process.env.APPLE_ID || !process.env.APPLE_APP_SPECIFIC_PASSWORD || !process.env.APPLE_TEAM_ID) {
+ console.log('[Notarize] Skipping — APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, or APPLE_TEAM_ID not set')
+ return
+ }
+
+ const appName = context.packager.appInfo.productFilename
+ const appPath = `${appOutDir}/${appName}.app`
+
+ console.log(`[Notarize] Submitting ${appPath} to Apple...`)
+
+ await notarize({
+ appBundleId: 'pro.getpeakflow.core',
+ appPath,
+ appleId: process.env.APPLE_ID,
+ appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
+ teamId: process.env.APPLE_TEAM_ID
+ })
+
+ console.log('[Notarize] Done')
+}
diff --git a/scripts/prepare-mac-helper.js b/scripts/prepare-mac-helper.js
new file mode 100644
index 0000000..d9883b6
--- /dev/null
+++ b/scripts/prepare-mac-helper.js
@@ -0,0 +1,54 @@
+const fs = require('fs')
+const path = require('path')
+const { spawnSync } = require('child_process')
+
+const projectRoot = path.resolve(__dirname, '..')
+const helperRoot = path.join(projectRoot, 'sidecar', 'darwin', 'PeakFlowHelper')
+const outputPath = path.join(projectRoot, 'resources', 'darwin', 'peakflow-helper')
+
+function ensureMac() {
+ if (process.platform !== 'darwin') {
+ console.log('[prepare-mac-helper] Skipping helper build on non-macOS host')
+ return false
+ }
+
+ return true
+}
+
+function runSwiftBuild() {
+ const result = spawnSync('swift', ['build', '-c', 'release'], {
+ cwd: helperRoot,
+ stdio: 'inherit'
+ })
+
+ if (result.status !== 0) {
+ process.exit(result.status ?? 1)
+ }
+}
+
+function findBuiltHelper() {
+ const candidates = [
+ path.join(helperRoot, '.build', 'apple', 'Products', 'Release', 'PeakFlowHelper'),
+ path.join(helperRoot, '.build', 'release', 'PeakFlowHelper')
+ ]
+
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) {
+ return candidate
+ }
+ }
+
+ throw new Error('PeakFlowHelper binary not found after swift build')
+}
+
+function copyHelper(sourcePath) {
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
+ fs.copyFileSync(sourcePath, outputPath)
+ fs.chmodSync(outputPath, 0o755)
+ console.log(`[prepare-mac-helper] Copied helper to ${outputPath}`)
+}
+
+if (ensureMac()) {
+ runSwiftBuild()
+ copyHelper(findBuiltHelper())
+}
diff --git a/sidecar/darwin/PeakFlowHelper/Package.swift b/sidecar/darwin/PeakFlowHelper/Package.swift
new file mode 100644
index 0000000..b25ae68
--- /dev/null
+++ b/sidecar/darwin/PeakFlowHelper/Package.swift
@@ -0,0 +1,18 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "PeakFlowHelper",
+ platforms: [.macOS(.v13)],
+ targets: [
+ .executableTarget(
+ name: "PeakFlowHelper",
+ path: "Sources",
+ linkerSettings: [
+ .linkedFramework("AppKit"),
+ .linkedFramework("CoreAudio"),
+ .linkedFramework("ApplicationServices")
+ ]
+ )
+ ]
+)
diff --git a/sidecar/darwin/PeakFlowHelper/Sources/ActiveWindow.swift b/sidecar/darwin/PeakFlowHelper/Sources/ActiveWindow.swift
new file mode 100644
index 0000000..f370439
--- /dev/null
+++ b/sidecar/darwin/PeakFlowHelper/Sources/ActiveWindow.swift
@@ -0,0 +1,348 @@
+/**
+ * Active window tracking for macOS using CGWindowList and Accessibility APIs.
+ *
+ * CGWindowListCopyWindowInfo provides window bounds for all on-screen windows.
+ * AXUIElement provides focused window info (requires Accessibility permission).
+ */
+
+import Cocoa
+import ApplicationServices
+
+// MARK: - Data types
+
+struct WindowInfo {
+ let pid: Int32
+ let x: Double
+ let y: Double
+ let w: Double
+ let h: Double
+ let title: String
+ let bundleId: String
+ let appName: String
+
+ func toJSON() -> String {
+ let escapedTitle = title.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ let escapedName = appName.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ return """
+ {"pid":\(pid),"x":\(x),"y":\(y),"w":\(w),"h":\(h),\
+ "title":"\(escapedTitle)","bundleId":"\(bundleId)","appName":"\(escapedName)"}
+ """
+ }
+}
+
+struct WindowRect {
+ let x: Double
+ let y: Double
+ let w: Double
+ let h: Double
+
+ func toJSON() -> String {
+ return "{\"x\":\(x),\"y\":\(y),\"w\":\(w),\"h\":\(h)}"
+ }
+}
+
+struct AppInfo {
+ let bundleId: String
+ let name: String
+
+ func toJSON() -> String {
+ let escapedName = name.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ return "{\"exe\":\"\(bundleId)\",\"name\":\"\(escapedName)\"}"
+ }
+}
+
+struct ProcessInfo {
+ let name: String
+ let bundleId: String
+
+ func toJSON() -> String {
+ let escapedName = name.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ return "{\"name\":\"\(escapedName)\",\"bundleId\":\"\(bundleId)\"}"
+ }
+}
+
+// MARK: - Ignored bundle IDs (macOS equivalents of Windows IGNORED_CLASSES)
+
+private let ignoredBundleIds: Set = [
+ "com.apple.dock",
+ "com.apple.WindowManager",
+ "com.apple.controlcenter",
+ "com.apple.notificationcenterui",
+ "com.apple.systemuiserver",
+ "com.apple.Spotlight",
+ "com.apple.loginwindow"
+]
+
+/// Bundle IDs filtered during window enumeration (system chrome, helpers)
+private let enumIgnoredBundleIds: Set = [
+ "com.apple.dock",
+ "com.apple.WindowManager",
+ "com.apple.controlcenter",
+ "com.apple.notificationcenterui",
+ "com.apple.systemuiserver",
+ "com.apple.Spotlight",
+ "com.apple.loginwindow",
+ "com.apple.ViewBridgeAuxiliary",
+ "com.apple.universalcontrol",
+ "com.apple.TextInputMenuAgent",
+ "com.apple.TextInputSwitcher"
+]
+
+/// Owner names to skip (for windows without a bundle ID)
+private let ignoredOwnerNames: Set = [
+ "Window Manager",
+ "Dock",
+ "SystemUIServer",
+ "Control Center",
+ "Notification Center"
+]
+
+// MARK: - Accessibility check
+
+func checkAccessibility() -> Bool {
+ return AXIsProcessTrusted()
+}
+
+// MARK: - Active window (focused)
+
+func getActiveWindow() -> WindowInfo? {
+ guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil }
+ let pid = frontApp.processIdentifier
+ let bundleId = frontApp.bundleIdentifier ?? ""
+ let appName = frontApp.localizedName ?? ""
+
+ // Skip system apps
+ if ignoredBundleIds.contains(bundleId) { return nil }
+
+ // Try Accessibility API for focused window bounds (most accurate)
+ let appElement = AXUIElementCreateApplication(pid)
+ var focusedWindow: AnyObject?
+ let result = AXUIElementCopyAttributeValue(appElement, kAXFocusedWindowAttribute as CFString, &focusedWindow)
+
+ guard result == .success, let window = focusedWindow else {
+ // Fallback: use CGWindowList to find the frontmost window for this PID
+ return getWindowFromCGWindowList(pid: pid, bundleId: bundleId, appName: appName)
+ }
+
+ // Get position
+ var position: AnyObject?
+ AXUIElementCopyAttributeValue(window as! AXUIElement, kAXPositionAttribute as CFString, &position)
+
+ // Get size
+ var size: AnyObject?
+ AXUIElementCopyAttributeValue(window as! AXUIElement, kAXSizeAttribute as CFString, &size)
+
+ guard let posValue = position, let sizeValue = size else {
+ return getWindowFromCGWindowList(pid: pid, bundleId: bundleId, appName: appName)
+ }
+
+ var point = CGPoint.zero
+ var cgSize = CGSize.zero
+ AXValueGetValue(posValue as! AXValue, .cgPoint, &point)
+ AXValueGetValue(sizeValue as! AXValue, .cgSize, &cgSize)
+
+ // Skip tiny windows
+ if cgSize.width < 10 || cgSize.height < 10 { return nil }
+
+ // Get title
+ var titleValue: AnyObject?
+ AXUIElementCopyAttributeValue(window as! AXUIElement, kAXTitleAttribute as CFString, &titleValue)
+ let title = (titleValue as? String) ?? ""
+
+ return WindowInfo(
+ pid: pid,
+ x: Double(point.x),
+ y: Double(point.y),
+ w: Double(cgSize.width),
+ h: Double(cgSize.height),
+ title: title,
+ bundleId: bundleId,
+ appName: appName
+ )
+}
+
+/// Fallback: find the frontmost window for a PID using CGWindowList
+private func getWindowFromCGWindowList(pid: Int32, bundleId: String, appName: String) -> WindowInfo? {
+ guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
+ return nil
+ }
+
+ for windowInfo in windowList {
+ guard let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? Int32,
+ ownerPID == pid,
+ let bounds = windowInfo[kCGWindowBounds as String] as? [String: CGFloat],
+ let x = bounds["X"], let y = bounds["Y"],
+ let w = bounds["Width"], let h = bounds["Height"] else {
+ continue
+ }
+
+ if w < 10 || h < 10 { continue }
+
+ // Skip windows at or above the status bar layer
+ if let layer = windowInfo[kCGWindowLayer as String] as? Int, layer > 0 { continue }
+
+ let title = windowInfo[kCGWindowName as String] as? String ?? ""
+
+ return WindowInfo(
+ pid: pid,
+ x: Double(x), y: Double(y), w: Double(w), h: Double(h),
+ title: title,
+ bundleId: bundleId,
+ appName: appName
+ )
+ }
+
+ return nil
+}
+
+// MARK: - All visible windows
+
+func getAllVisibleWindows(filterPid: Int32? = nil) -> [WindowRect] {
+ guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
+ return []
+ }
+
+ var results: [WindowRect] = []
+ let maxWindows = 50
+
+ for windowInfo in windowList {
+ if results.count >= maxWindows { break }
+
+ guard let bounds = windowInfo[kCGWindowBounds as String] as? [String: CGFloat],
+ let x = bounds["X"], let y = bounds["Y"],
+ let w = bounds["Width"], let h = bounds["Height"] else {
+ continue
+ }
+
+ // Skip tiny windows
+ if w < 10 || h < 10 { continue }
+
+ // Skip system-level windows (menubar, dock, etc.)
+ if let layer = windowInfo[kCGWindowLayer as String] as? Int, layer > 0 { continue }
+
+ // Skip ignored apps
+ let ownerName = windowInfo[kCGWindowOwnerName as String] as? String ?? ""
+ if ignoredOwnerNames.contains(ownerName) { continue }
+
+ // Get owner PID for filtering
+ guard let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? Int32 else { continue }
+
+ // Get bundle ID for this PID
+ if let app = NSRunningApplication(processIdentifier: ownerPID) {
+ if let bid = app.bundleIdentifier, enumIgnoredBundleIds.contains(bid) { continue }
+ }
+
+ // PID filter
+ if let fpid = filterPid, ownerPID != fpid { continue }
+
+ // Skip PeakFlow overlay windows
+ let title = windowInfo[kCGWindowName as String] as? String ?? ""
+ if title == "__peakflow_dim__" { continue }
+
+ results.append(WindowRect(x: Double(x), y: Double(y), w: Double(w), h: Double(h)))
+ }
+
+ return results
+}
+
+// MARK: - Windows for bundle ID
+
+func getWindowsForBundleId(_ targetBundleId: String) -> [WindowRect] {
+ guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
+ return []
+ }
+
+ // Find all PIDs matching this bundle ID
+ let matchingPids = NSWorkspace.shared.runningApplications
+ .filter { $0.bundleIdentifier == targetBundleId }
+ .map { $0.processIdentifier }
+ let pidSet = Set(matchingPids)
+
+ var results: [WindowRect] = []
+ let maxWindows = 50
+
+ for windowInfo in windowList {
+ if results.count >= maxWindows { break }
+
+ guard let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? Int32,
+ pidSet.contains(ownerPID),
+ let bounds = windowInfo[kCGWindowBounds as String] as? [String: CGFloat],
+ let x = bounds["X"], let y = bounds["Y"],
+ let w = bounds["Width"], let h = bounds["Height"] else {
+ continue
+ }
+
+ if w < 10 || h < 10 { continue }
+ if let layer = windowInfo[kCGWindowLayer as String] as? Int, layer > 0 { continue }
+
+ let title = windowInfo[kCGWindowName as String] as? String ?? ""
+ if title == "__peakflow_dim__" { continue }
+
+ results.append(WindowRect(x: Double(x), y: Double(y), w: Double(w), h: Double(h)))
+ }
+
+ return results
+}
+
+// MARK: - Visible app list
+
+func getVisibleAppList() -> [AppInfo] {
+ guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
+ return []
+ }
+
+ var seen = [String: String]() // bundleId -> name
+ let selfBundleIds: Set = ["pro.getpeakflow.core"]
+
+ for windowInfo in windowList {
+ guard let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? Int32 else { continue }
+
+ // Skip system-level windows
+ if let layer = windowInfo[kCGWindowLayer as String] as? Int, layer > 0 { continue }
+
+ let ownerName = windowInfo[kCGWindowOwnerName as String] as? String ?? ""
+ if ignoredOwnerNames.contains(ownerName) { continue }
+
+ // Skip PeakFlow overlay windows
+ let title = windowInfo[kCGWindowName as String] as? String ?? ""
+ if title == "__peakflow_dim__" || title.isEmpty { continue }
+
+ guard let app = NSRunningApplication(processIdentifier: ownerPID),
+ let bundleId = app.bundleIdentifier else { continue }
+
+ if enumIgnoredBundleIds.contains(bundleId) { continue }
+ if selfBundleIds.contains(bundleId) { continue }
+ if seen[bundleId] != nil { continue }
+
+ let name = app.localizedName ?? ownerName
+ seen[bundleId] = name
+ }
+
+ return seen.map { AppInfo(bundleId: $0.key, name: $0.value) }
+ .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+}
+
+// MARK: - Process info lookup
+
+/// PID-to-process-info cache
+private var pidCache = [Int32: ProcessInfo]()
+
+func clearPidCache() {
+ pidCache.removeAll()
+}
+
+func getProcessInfo(pid: Int32) -> ProcessInfo? {
+ if let cached = pidCache[pid] { return cached }
+
+ guard let app = NSRunningApplication(processIdentifier: pid) else { return nil }
+ let info = ProcessInfo(
+ name: app.localizedName ?? "",
+ bundleId: app.bundleIdentifier ?? ""
+ )
+ pidCache[pid] = info
+ return info
+}
diff --git a/sidecar/darwin/PeakFlowHelper/Sources/AudioControl.swift b/sidecar/darwin/PeakFlowHelper/Sources/AudioControl.swift
new file mode 100644
index 0000000..85956c3
--- /dev/null
+++ b/sidecar/darwin/PeakFlowHelper/Sources/AudioControl.swift
@@ -0,0 +1,99 @@
+/**
+ * CoreAudio mic mute control for macOS.
+ *
+ * Uses AudioObjectGetPropertyData / AudioObjectSetPropertyData to read and
+ * toggle the mute state of the default input (capture) device.
+ */
+
+import CoreAudio
+import Foundation
+
+// MARK: - Constants
+
+private let kAudioObjectSystemObject: AudioObjectID = 1
+
+private var defaultInputDeviceAddress = AudioObjectPropertyAddress(
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain
+)
+
+private func muteAddress() -> AudioObjectPropertyAddress {
+ return AudioObjectPropertyAddress(
+ mSelector: kAudioDevicePropertyMute,
+ mScope: kAudioDevicePropertyScopeInput,
+ mElement: kAudioObjectPropertyElementMain
+ )
+}
+
+// MARK: - Helpers
+
+private func getDefaultInputDevice() -> AudioDeviceID? {
+ var deviceId: AudioDeviceID = 0
+ var size = UInt32(MemoryLayout.size)
+
+ let status = AudioObjectGetPropertyData(
+ kAudioObjectSystemObject,
+ &defaultInputDeviceAddress,
+ 0, nil,
+ &size, &deviceId
+ )
+
+ return status == noErr ? deviceId : nil
+}
+
+private func getMuteValue(device: AudioDeviceID) -> Bool? {
+ var address = muteAddress()
+ var muted: UInt32 = 0
+ var size = UInt32(MemoryLayout.size)
+
+ let status = AudioObjectGetPropertyData(device, &address, 0, nil, &size, &muted)
+ return status == noErr ? (muted == 1) : nil
+}
+
+private func setMuteValue(device: AudioDeviceID, muted: Bool) -> Bool? {
+ var address = muteAddress()
+ var value: UInt32 = muted ? 1 : 0
+ let size = UInt32(MemoryLayout.size)
+
+ let status = AudioObjectSetPropertyData(device, &address, 0, nil, size, &value)
+ if status != noErr { return nil }
+
+ // Read back to confirm
+ return getMuteValue(device: device)
+}
+
+// MARK: - Public API (returns JSON strings)
+
+func getMicMuteState() -> String {
+ guard let device = getDefaultInputDevice() else {
+ return "{\"muted\":false,\"error\":\"No microphone found\"}"
+ }
+ guard let muted = getMuteValue(device: device) else {
+ return "{\"muted\":false,\"error\":\"Failed to read mute state\"}"
+ }
+ return "{\"muted\":\(muted),\"error\":null}"
+}
+
+func setMicMute(muted: Bool) -> String {
+ guard let device = getDefaultInputDevice() else {
+ return "{\"muted\":false,\"error\":\"No microphone found\"}"
+ }
+ guard let result = setMuteValue(device: device, muted: muted) else {
+ return "{\"muted\":false,\"error\":\"Failed to set mute state\"}"
+ }
+ return "{\"muted\":\(result),\"error\":null}"
+}
+
+func toggleMicMute() -> String {
+ guard let device = getDefaultInputDevice() else {
+ return "{\"muted\":false,\"error\":\"No microphone found\"}"
+ }
+ guard let current = getMuteValue(device: device) else {
+ return "{\"muted\":false,\"error\":\"Failed to read mute state\"}"
+ }
+ guard let result = setMuteValue(device: device, muted: !current) else {
+ return "{\"muted\":false,\"error\":\"Failed to toggle mute\"}"
+ }
+ return "{\"muted\":\(result),\"error\":null}"
+}
diff --git a/sidecar/darwin/PeakFlowHelper/Sources/main.swift b/sidecar/darwin/PeakFlowHelper/Sources/main.swift
new file mode 100644
index 0000000..f9556ab
--- /dev/null
+++ b/sidecar/darwin/PeakFlowHelper/Sources/main.swift
@@ -0,0 +1,112 @@
+/**
+ * PeakFlowHelper — persistent sidecar for macOS native API access.
+ *
+ * Reads commands from stdin, executes them using macOS APIs,
+ * and writes JSON results to stdout. Same pattern as the
+ * PowerShell sidecar on Windows.
+ *
+ * Commands:
+ * active-window → focused window info (JSON or "null")
+ * all-windows → all visible windows (JSON array)
+ * all-windows → windows filtered by PID
+ * app-windows → windows filtered by bundle ID
+ * visible-apps → deduplicated list of visible apps
+ * process-name → app name + bundle ID for a PID
+ * mic-get-mute → {"muted": true/false}
+ * mic-set-mute <0|1> → {"muted": true/false}
+ * mic-toggle-mute → {"muted": true/false}
+ * accessibility-check → {"trusted": true/false}
+ * ping → "pong"
+ * exit → terminates
+ */
+
+import Foundation
+
+// Flush stdout after every write
+setbuf(stdout, nil)
+
+func respond(_ json: String) {
+ print(json)
+ fflush(stdout)
+}
+
+func respondError(_ message: String) {
+ let escaped = message.replacingOccurrences(of: "\"", with: "\\\"")
+ respond("{\"error\":\"\(escaped)\"}")
+}
+
+// Main loop: read commands from stdin
+while let line = readLine(strippingNewline: true) {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ if trimmed.isEmpty { continue }
+
+ let parts = trimmed.split(separator: " ", maxSplits: 1)
+ let command = String(parts[0])
+ let arg = parts.count > 1 ? String(parts[1]) : nil
+
+ switch command {
+ case "active-window":
+ if let info = getActiveWindow() {
+ respond(info.toJSON())
+ } else {
+ respond("null")
+ }
+
+ case "all-windows":
+ let pid = arg.flatMap { Int32($0) }
+ let windows = getAllVisibleWindows(filterPid: pid)
+ let jsonArray = windows.map { $0.toJSON() }.joined(separator: ",")
+ respond("[\(jsonArray)]")
+
+ case "app-windows":
+ guard let bundleId = arg else {
+ respondError("app-windows requires a bundle ID argument")
+ continue
+ }
+ let windows = getWindowsForBundleId(bundleId)
+ let jsonArray = windows.map { $0.toJSON() }.joined(separator: ",")
+ respond("[\(jsonArray)]")
+
+ case "visible-apps":
+ let apps = getVisibleAppList()
+ let jsonArray = apps.map { $0.toJSON() }.joined(separator: ",")
+ respond("[\(jsonArray)]")
+
+ case "process-name":
+ guard let pidStr = arg, let pid = Int32(pidStr) else {
+ respondError("process-name requires a PID argument")
+ continue
+ }
+ if let info = getProcessInfo(pid: pid) {
+ respond(info.toJSON())
+ } else {
+ respond("null")
+ }
+
+ case "mic-get-mute":
+ let result = getMicMuteState()
+ respond(result)
+
+ case "mic-set-mute":
+ let mute = arg == "1" || arg == "true"
+ let result = setMicMute(muted: mute)
+ respond(result)
+
+ case "mic-toggle-mute":
+ let result = toggleMicMute()
+ respond(result)
+
+ case "accessibility-check":
+ let trusted = checkAccessibility()
+ respond("{\"trusted\":\(trusted)}")
+
+ case "ping":
+ respond("\"pong\"")
+
+ case "exit":
+ exit(0)
+
+ default:
+ respondError("Unknown command: \(command)")
+ }
+}
diff --git a/src/main/native/active-window-darwin.ts b/src/main/native/active-window-darwin.ts
new file mode 100644
index 0000000..eb321ff
--- /dev/null
+++ b/src/main/native/active-window-darwin.ts
@@ -0,0 +1,299 @@
+/**
+ * macOS active window tracking via PeakFlowHelper Swift sidecar.
+ *
+ * Spawns a persistent Swift process and communicates via stdin/stdout
+ * JSON-RPC (same pattern as the PowerShell sidecar for SoundSplit).
+ *
+ * The Swift helper uses CGWindowListCopyWindowInfo + Accessibility API
+ * to get window positions and the frontmost application.
+ */
+
+import { spawn, ChildProcess } from 'child_process'
+import { join } from 'path'
+import { app } from 'electron'
+
+// ─── Types (matching the shared interfaces in active-window.ts router) ──────
+
+export interface ActiveWindowInfo {
+ hwnd: unknown
+ pid: number
+ x: number
+ y: number
+ w: number
+ h: number
+ title: string
+ className: string
+}
+
+export interface WindowRect {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+export interface DisplayBounds {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+// ─── Swift sidecar management ───────────────────────────────────────────────
+
+let helper: ChildProcess | null = null
+let pendingData = ''
+let requestQueue: Array<{ resolve: (data: string) => void; reject: (err: Error) => void }> = []
+let restartTimeout: ReturnType | null = null
+
+function getHelperPath(): string {
+ if (app.isPackaged) {
+ return join(process.resourcesPath, 'peakflow-helper')
+ }
+ // Dev mode: use the repo-level resources directory, not the compiled out/ tree.
+ return join(app.getAppPath(), 'resources', 'darwin', 'peakflow-helper')
+}
+
+function ensureHelper(): void {
+ if (helper && !helper.killed) return
+
+ const helperPath = getHelperPath()
+ try {
+ helper = spawn(helperPath, [], {
+ stdio: ['pipe', 'pipe', 'pipe']
+ })
+ } catch (err) {
+ console.error('[ActiveWindow-Darwin] Failed to spawn helper:', err)
+ return
+ }
+
+ pendingData = ''
+
+ helper.stdout?.setEncoding('utf8')
+ helper.stdout?.on('data', (chunk: string) => {
+ pendingData += chunk
+ processOutput()
+ })
+
+ helper.stderr?.setEncoding('utf8')
+ helper.stderr?.on('data', (data: string) => {
+ console.warn('[ActiveWindow-Darwin] Helper stderr:', data.trim())
+ })
+
+ helper.on('exit', (code) => {
+ console.warn(`[ActiveWindow-Darwin] Helper exited with code ${code}`)
+ helper = null
+ // Reject any pending requests
+ while (requestQueue.length > 0) {
+ const req = requestQueue.shift()!
+ req.reject(new Error('Helper process exited'))
+ }
+ // Auto-restart after delay
+ if (!restartTimeout) {
+ restartTimeout = setTimeout(() => {
+ restartTimeout = null
+ ensureHelper()
+ }, 2000)
+ }
+ })
+}
+
+function processOutput(): void {
+ // Each response is a single line of JSON
+ const lines = pendingData.split('\n')
+ // Keep the last incomplete line
+ pendingData = lines.pop() || ''
+
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+
+ const req = requestQueue.shift()
+ if (req) {
+ req.resolve(trimmed)
+ }
+ }
+}
+
+function sendCommand(command: string): Promise {
+ return new Promise((resolve, reject) => {
+ ensureHelper()
+ if (!helper || !helper.stdin) {
+ reject(new Error('Helper not available'))
+ return
+ }
+
+ requestQueue.push({ resolve, reject })
+ helper.stdin.write(command + '\n')
+ })
+}
+
+/** Synchronous wrapper — blocks using a cached result for polling. */
+let cachedActiveWindow: ActiveWindowInfo | null = null
+let lastActiveWindowTime = 0
+const CACHE_TTL_MS = 8 // ~120fps cache, caller polls at 16ms
+
+function sendCommandSync(command: string): string {
+ // For synchronous callers, we fire-and-forget and return cached data.
+ // This is necessary because the Win32 API is synchronous but the
+ // macOS sidecar is async. We warm the cache via a background loop.
+ return ''
+}
+
+// Background polling loop: keeps cachedActiveWindow fresh
+let pollTimer: ReturnType | null = null
+
+function startPolling(): void {
+ if (pollTimer) return
+ pollTimer = setInterval(async () => {
+ try {
+ const raw = await sendCommand('active-window')
+ if (raw === 'null' || raw.startsWith('{\"error\"')) {
+ cachedActiveWindow = null
+ } else {
+ const data = JSON.parse(raw)
+ cachedActiveWindow = {
+ hwnd: null,
+ pid: data.pid,
+ x: data.x,
+ y: data.y,
+ w: data.w,
+ h: data.h,
+ title: data.title || '',
+ className: data.bundleId || ''
+ }
+ }
+ lastActiveWindowTime = Date.now()
+ } catch {
+ cachedActiveWindow = null
+ }
+ }, 16) // 60fps polling
+}
+
+// ─── Public API (matching active-window-win32.ts exports) ───────────────────
+
+export function getActiveWindow(): ActiveWindowInfo | null {
+ startPolling()
+ return cachedActiveWindow
+}
+
+export function getAllVisibleWindows(filterPid?: number, _displayBounds?: DisplayBounds[]): WindowRect[] {
+ // Async: fire command, return cached result keyed by command
+ const command = filterPid !== undefined ? `all-windows ${filterPid}` : 'all-windows'
+ const cacheKey = command
+
+ let entry = allWindowsCache.get(cacheKey)
+ if (!entry) {
+ entry = { data: [], inFlight: false }
+ allWindowsCache.set(cacheKey, entry)
+ }
+
+ if (!entry.inFlight) {
+ entry.inFlight = true
+ const e = entry // capture for closure
+ sendCommand(command).then((raw) => {
+ try {
+ e.data = JSON.parse(raw) as WindowRect[]
+ } catch {
+ e.data = []
+ }
+ e.inFlight = false
+ }).catch(() => {
+ e.data = []
+ e.inFlight = false
+ })
+ }
+ return entry.data
+}
+
+const allWindowsCache = new Map()
+
+export function getWindowsForExeNames(exeNames: string[], _displayBounds?: DisplayBounds[]): WindowRect[] {
+ // On macOS, exeNames contains bundle IDs. Query for each.
+ // Use cached results with async refresh.
+ if (!cachedExeWindows.inFlight && exeNames.length > 0) {
+ cachedExeWindows.inFlight = true
+ // Query the first bundle ID (most common case: single excluded app)
+ const promises = exeNames.map(bid => sendCommand(`app-windows ${bid}`))
+ Promise.all(promises).then((results) => {
+ const allRects: WindowRect[] = []
+ for (const raw of results) {
+ try {
+ const rects = JSON.parse(raw) as WindowRect[]
+ allRects.push(...rects)
+ } catch { /* skip */ }
+ }
+ cachedExeWindows.data = allRects
+ cachedExeWindows.inFlight = false
+ }).catch(() => {
+ cachedExeWindows.data = []
+ cachedExeWindows.inFlight = false
+ })
+ }
+ return cachedExeWindows.data
+}
+
+const cachedExeWindows: { data: WindowRect[]; inFlight: boolean } = { data: [], inFlight: false }
+
+/** PID → bundleId cache */
+const pidBundleCache = new Map()
+
+export function getProcessExeName(pid: number): string | null {
+ const cached = pidBundleCache.get(pid)
+ if (cached !== undefined) return cached
+
+ // Async fetch, return null first time
+ sendCommand(`process-name ${pid}`).then((raw) => {
+ try {
+ const data = JSON.parse(raw)
+ if (data && data.bundleId) {
+ pidBundleCache.set(pid, data.bundleId)
+ }
+ } catch { /* skip */ }
+ }).catch(() => { /* skip */ })
+
+ return null
+}
+
+export function clearPidExeCache(): void {
+ pidBundleCache.clear()
+}
+
+export function getVisibleAppList(_skipSet?: Set): Array<{ exe: string; name: string }> {
+ // Async with cache
+ if (!cachedAppList.inFlight) {
+ cachedAppList.inFlight = true
+ sendCommand('visible-apps').then((raw) => {
+ try {
+ cachedAppList.data = JSON.parse(raw) as Array<{ exe: string; name: string }>
+ } catch {
+ cachedAppList.data = []
+ }
+ cachedAppList.inFlight = false
+ }).catch(() => {
+ cachedAppList.data = []
+ cachedAppList.inFlight = false
+ })
+ }
+ return cachedAppList.data
+}
+
+const cachedAppList: { data: Array<{ exe: string; name: string }>; inFlight: boolean } = { data: [], inFlight: false }
+
+/** Clean up the helper process. Called on app quit. */
+export function destroyHelper(): void {
+ if (pollTimer) {
+ clearInterval(pollTimer)
+ pollTimer = null
+ }
+ if (restartTimeout) {
+ clearTimeout(restartTimeout)
+ restartTimeout = null
+ }
+ if (helper && !helper.killed) {
+ helper.stdin?.write('exit\n')
+ setTimeout(() => {
+ if (helper && !helper.killed) helper.kill()
+ }, 500)
+ }
+}
diff --git a/src/main/native/active-window-win32.ts b/src/main/native/active-window-win32.ts
new file mode 100644
index 0000000..3ab26ba
--- /dev/null
+++ b/src/main/native/active-window-win32.ts
@@ -0,0 +1,456 @@
+/**
+ * Native active window tracking using Win32 API via koffi.
+ *
+ * Calls GetForegroundWindow() + GetWindowRect() to get the position
+ * and size of the currently focused window on Windows.
+ *
+ * All koffi/DLL bindings are deferred to first use via initBindings()
+ * so this module can be safely imported on macOS without crashing.
+ */
+
+// ─── Lazy koffi bindings (initialized on first call) ────────────────────────
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let K: any = null // koffi module
+let _user32: any, _dwmapi: any, _kernel32: any
+let _RECT: any
+let _GetForegroundWindow: any, _GetWindowRect: any, _IsWindow: any, _IsWindowVisible: any
+let _GetWindowTextW: any, _GetClassNameW: any
+let _DwmGetWindowAttribute: any, _DwmGetWindowAttributeDword: any
+let _OpenProcess: any, _QueryFullProcessImageNameW: any, _CloseHandle: any
+let _EnumWindowsCallback: any, _EnumWindows: any, _GetWindowThreadProcessId: any, _IsIconic: any
+let _bindingsReady = false
+
+function initBindings(): void {
+ if (_bindingsReady) return
+
+ K = require('koffi')
+
+ _user32 = K.load('user32.dll')
+ _dwmapi = K.load('dwmapi.dll')
+ _kernel32 = K.load('kernel32.dll')
+
+ _RECT = K.struct('RECT', {
+ left: 'int32',
+ top: 'int32',
+ right: 'int32',
+ bottom: 'int32'
+ })
+
+ _GetForegroundWindow = _user32.func('GetForegroundWindow', 'void *', [])
+ _GetWindowRect = _user32.func('GetWindowRect', 'bool', ['void *', K.out(K.pointer(_RECT))])
+ _IsWindow = _user32.func('IsWindow', 'bool', ['void *'])
+ _IsWindowVisible = _user32.func('IsWindowVisible', 'bool', ['void *'])
+ _GetWindowTextW = _user32.func('GetWindowTextW', 'int', ['void *', 'void *', 'int'])
+ _GetClassNameW = _user32.func('GetClassNameW', 'int', ['void *', 'void *', 'int'])
+
+ _DwmGetWindowAttribute = _dwmapi.func('DwmGetWindowAttribute', 'long', [
+ 'void *', 'uint32', K.out(K.pointer(_RECT)), 'uint32'
+ ])
+ _DwmGetWindowAttributeDword = _dwmapi.func('DwmGetWindowAttribute', 'long', [
+ 'void *', 'uint32', K.out(K.pointer('uint32')), 'uint32'
+ ])
+
+ const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
+ _OpenProcess = _kernel32.func('OpenProcess', 'void *', ['uint32', 'bool', 'uint32'])
+ _QueryFullProcessImageNameW = _kernel32.func('QueryFullProcessImageNameW', 'bool', [
+ 'void *', 'uint32', 'void *', K.inout(K.pointer('uint32'))
+ ])
+ _CloseHandle = _kernel32.func('CloseHandle', 'bool', ['void *'])
+ // Store the constant for use in getProcessExeName
+ ;(initBindings as any)._PQLI = PROCESS_QUERY_LIMITED_INFORMATION
+
+ _EnumWindowsCallback = K.proto('bool __stdcall EnumWindowsCallback(void *, intptr)')
+ _EnumWindows = _user32.func('EnumWindows', 'bool', [K.pointer(_EnumWindowsCallback), 'intptr'])
+ _GetWindowThreadProcessId = _user32.func('GetWindowThreadProcessId', 'uint32', [
+ 'void *', K.out(K.pointer('uint32'))
+ ])
+ _IsIconic = _user32.func('IsIconic', 'bool', ['void *'])
+
+ _bindingsReady = true
+}
+
+// ─── Public interface ────────────────────────────────────────────────────────
+
+export interface ActiveWindowInfo {
+ hwnd: unknown
+ pid: number
+ x: number
+ y: number
+ w: number
+ h: number
+ title: string
+ className: string
+}
+
+export interface WindowRect {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+export interface DisplayBounds {
+ x: number
+ y: number
+ w: number
+ h: number
+}
+
+// Classes ignored by getActiveWindow (desktop, taskbar — things that mean "no app focused")
+const IGNORED_CLASSES = new Set([
+ 'Progman', // Desktop
+ 'WorkerW', // Desktop worker
+ 'Shell_TrayWnd', // Taskbar
+ 'Shell_SecondaryTrayWnd', // Secondary taskbar
+ 'Windows.UI.Core.CoreWindow', // Start menu, Action Center
+ 'MultitaskingViewFrame' // Task view
+])
+
+// Additional classes filtered during EnumWindows (system chrome, invisible helpers, bad bounds)
+const ENUM_IGNORED_CLASSES = new Set([
+ ...IGNORED_CLASSES,
+ 'ThumbnailDeviceHelperWnd', // Thumbnail helper
+ 'EdgeUiInputWndClass', // Edge UI input
+ 'EdgeUiInputTopWndClass', // Edge UI top input
+ 'ApplicationManager_ImmersiveShellWindow', // Immersive shell
+ 'Internet Explorer_Hidden', // IE hidden window
+ 'CEF-OSC-WIDGET', // NVIDIA GeForce overlay
+ 'PseudoConsoleWindow', // ConPTY pseudo-console
+ 'ForegroundStaging', // Window staging
+ 'MSCTFIME UI', // IME
+ 'IME', // Input method editor
+ 'tooltips_class32', // Tooltips
+ 'NotifyIconOverflowWindow', // System tray overflow
+ 'DummyDWMListenerWindow', // DWM listener
+ 'WinUIDesktopWin32WindowClass' // PowerToys, Command Palette
+])
+
+/** Get the process ID for a window handle. */
+function getProcessId(hwnd: unknown): number {
+ const pid = [0]
+ _GetWindowThreadProcessId(hwnd, pid)
+ return pid[0]
+}
+
+/**
+ * Get the currently focused window's position and size.
+ * Returns null if no valid foreground window is found, or if it's a
+ * system window that should be ignored (desktop, taskbar, etc.).
+ */
+export function getActiveWindow(): ActiveWindowInfo | null {
+ initBindings()
+ try {
+ const hwnd = _GetForegroundWindow()
+ if (!hwnd || !_IsWindow(hwnd) || !_IsWindowVisible(hwnd)) {
+ return null
+ }
+
+ // Get class name to filter system windows
+ const classNameBuf = Buffer.alloc(512)
+ const classLen = _GetClassNameW(hwnd, classNameBuf, 256)
+ const className = classLen > 0 ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '') : ''
+
+ if (IGNORED_CLASSES.has(className)) {
+ return null
+ }
+
+ const pid = getProcessId(hwnd)
+
+ // Try DwmGetWindowAttribute first for accurate bounds (handles DPI scaling)
+ const rect = { left: 0, top: 0, right: 0, bottom: 0 }
+ const DWMWA_EXTENDED_FRAME_BOUNDS = 9
+ const sizeOfRect = 16 // 4 int32s = 16 bytes
+
+ const dwmResult = _DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, sizeOfRect)
+
+ if (dwmResult !== 0) {
+ // Fallback to GetWindowRect
+ const success = _GetWindowRect(hwnd, rect)
+ if (!success) return null
+ }
+
+ const w = rect.right - rect.left
+ const h = rect.bottom - rect.top
+
+ // Skip zero-size or tiny windows
+ if (w < 10 || h < 10) return null
+
+ // Get window title
+ const titleBuf = Buffer.alloc(512)
+ const titleLen = _GetWindowTextW(hwnd, titleBuf, 256)
+ const title = titleLen > 0 ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '') : ''
+
+ return {
+ hwnd,
+ pid,
+ x: rect.left,
+ y: rect.top,
+ w,
+ h,
+ title,
+ className
+ }
+ } catch (error) {
+ console.warn('[ActiveWindow] FFI error:', error)
+ return null
+ }
+}
+
+/**
+ * Enumerate all visible, non-minimized, non-system windows.
+ * Returns their rects in physical pixel coordinates.
+ * If `filterPid` is provided, only returns windows belonging to that process.
+ * If `displayBounds` is provided, windows covering 90%+ of any display are skipped
+ * (catches UWP ApplicationFrameWindow host frames that report screen-sized bounds).
+ * Capped at 50 results to prevent pathological clip-path complexity.
+ */
+export function getAllVisibleWindows(filterPid?: number, displayBounds?: DisplayBounds[]): WindowRect[] {
+ initBindings()
+ const results: WindowRect[] = []
+ const DWMWA_EXTENDED_FRAME_BOUNDS = 9
+ const sizeOfRect = 16
+ const MAX_WINDOWS = 50
+
+ const callback = K.register((hwnd: unknown, _lParam: number): boolean => {
+ if (results.length >= MAX_WINDOWS) return false
+ try {
+ if (!_IsWindow(hwnd) || !_IsWindowVisible(hwnd) || _IsIconic(hwnd)) return true
+
+ const classNameBuf = Buffer.alloc(512)
+ const classLen = _GetClassNameW(hwnd, classNameBuf, 256)
+ const className = classLen > 0
+ ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '')
+ : ''
+ if (ENUM_IGNORED_CLASSES.has(className)) return true
+
+ // Skip cloaked (hidden by DWM) windows — invisible UWP apps, virtual desktop windows
+ const DWMWA_CLOAKED = 14
+ const cloaked = [0]
+ const cloakResult = _DwmGetWindowAttributeDword(hwnd, DWMWA_CLOAKED, cloaked, 4)
+ if (cloakResult === 0 && cloaked[0] !== 0) return true
+
+ // Get window title for filtering
+ const titleBuf = Buffer.alloc(512)
+ const titleLen = _GetWindowTextW(hwnd, titleBuf, 256)
+ const title = titleLen > 0
+ ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '')
+ : ''
+
+ // Skip PeakFlow overlay windows (but NOT settings/dashboard — those are real windows)
+ if (title === '__peakflow_dim__') return true
+ const winPid = getProcessId(hwnd)
+
+
+ if (filterPid !== undefined && winPid !== filterPid) return true
+
+ const rect = { left: 0, top: 0, right: 0, bottom: 0 }
+ const dwmResult = _DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, sizeOfRect)
+ if (dwmResult !== 0) {
+ const success = _GetWindowRect(hwnd, rect)
+ if (!success) return true
+ }
+
+ const w = rect.right - rect.left
+ const h = rect.bottom - rect.top
+ if (w < 10 || h < 10) return true
+
+ // ApplicationFrameWindow is the UWP host frame — it sometimes reports screen-sized
+ // bounds even for windowed apps. Only skip it if its bounds cover 90%+ of a display.
+ if (className === 'ApplicationFrameWindow' && displayBounds) {
+ const winArea = w * h
+ for (const db of displayBounds) {
+ if (winArea >= db.w * db.h * 0.9) {
+ return true
+ }
+ }
+ }
+
+ results.push({ x: rect.left, y: rect.top, w, h })
+ } catch { /* skip this window */ }
+ return true
+ }, K.pointer(_EnumWindowsCallback))
+
+ try {
+ _EnumWindows(callback, 0)
+ } finally {
+ K.unregister(callback)
+ }
+
+ return results
+}
+
+// ─── PID → exe name resolution ─────────────────────────────────────────────
+
+/** Cache of PID → lowercase exe filename. Cleared when FocusDim enables/disables. */
+const pidExeCache = new Map()
+
+/** Clear the PID→exe cache (call on FocusDim enable/disable). */
+export function clearPidExeCache(): void {
+ pidExeCache.clear()
+}
+
+/**
+ * Get the lowercase exe filename for a process ID (e.g., "chrome.exe").
+ * Returns null if the process can't be opened or queried.
+ */
+export function getProcessExeName(pid: number): string | null {
+ initBindings()
+ const cached = pidExeCache.get(pid)
+ if (cached !== undefined) return cached
+
+ try {
+ const hProcess = _OpenProcess(0x1000, false, pid)
+ if (!hProcess) return null
+
+ try {
+ const buf = Buffer.alloc(1024)
+ const size = [512]
+ const ok = _QueryFullProcessImageNameW(hProcess, 0, buf, size)
+ if (!ok || size[0] === 0) return null
+
+ const fullPath = buf.toString('utf16le', 0, size[0] * 2).replace(/\0/g, '')
+ const lastSlash = Math.max(fullPath.lastIndexOf('\\'), fullPath.lastIndexOf('/'))
+ const exeName = (lastSlash >= 0 ? fullPath.substring(lastSlash + 1) : fullPath).toLowerCase()
+ pidExeCache.set(pid, exeName)
+ return exeName
+ } finally {
+ _CloseHandle(hProcess)
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Enumerate all visible windows whose exe name matches one of the given names.
+ * Returns their rects in physical pixel coordinates.
+ * Uses the same filters as getAllVisibleWindows (skip cloaked, minimized, system classes, overlay windows).
+ */
+export function getWindowsForExeNames(exeNames: string[], displayBounds?: DisplayBounds[]): WindowRect[] {
+ initBindings()
+ if (exeNames.length === 0) return []
+
+ const exeSet = new Set(exeNames)
+ const results: WindowRect[] = []
+ const DWMWA_EXTENDED_FRAME_BOUNDS = 9
+ const sizeOfRect = 16
+ const MAX_WINDOWS = 50
+
+ const callback = K.register((hwnd: unknown, _lParam: number): boolean => {
+ if (results.length >= MAX_WINDOWS) return false
+ try {
+ if (!_IsWindow(hwnd) || !_IsWindowVisible(hwnd) || _IsIconic(hwnd)) return true
+
+ const classNameBuf = Buffer.alloc(512)
+ const classLen = _GetClassNameW(hwnd, classNameBuf, 256)
+ const className = classLen > 0
+ ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '')
+ : ''
+ if (ENUM_IGNORED_CLASSES.has(className)) return true
+
+ // Skip cloaked windows
+ const DWMWA_CLOAKED = 14
+ const cloaked = [0]
+ const cloakResult = _DwmGetWindowAttributeDword(hwnd, DWMWA_CLOAKED, cloaked, 4)
+ if (cloakResult === 0 && cloaked[0] !== 0) return true
+
+ // Skip PeakFlow overlay windows
+ const titleBuf = Buffer.alloc(512)
+ const titleLen = _GetWindowTextW(hwnd, titleBuf, 256)
+ const title = titleLen > 0
+ ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '')
+ : ''
+ if (title === '__peakflow_dim__') return true
+
+ // Check exe name
+ const winPid = getProcessId(hwnd)
+ const exeName = getProcessExeName(winPid)
+ if (!exeName || !exeSet.has(exeName)) return true
+
+ const rect = { left: 0, top: 0, right: 0, bottom: 0 }
+ const dwmResult = _DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, sizeOfRect)
+ if (dwmResult !== 0) {
+ const success = _GetWindowRect(hwnd, rect)
+ if (!success) return true
+ }
+
+ const w = rect.right - rect.left
+ const h = rect.bottom - rect.top
+ if (w < 10 || h < 10) return true
+
+ // Skip full-screen UWP frames
+ if (className === 'ApplicationFrameWindow' && displayBounds) {
+ const winArea = w * h
+ for (const db of displayBounds) {
+ if (winArea >= db.w * db.h * 0.9) return true
+ }
+ }
+
+ results.push({ x: rect.left, y: rect.top, w, h })
+ } catch { /* skip */ }
+ return true
+ }, K.pointer(_EnumWindowsCallback))
+
+ try {
+ _EnumWindows(callback, 0)
+ } finally {
+ K.unregister(callback)
+ }
+
+ return results
+}
+
+/**
+ * Get a deduplicated list of visible apps (exe + window title).
+ * Excludes PeakFlow, system windows, and any exe names in the skipSet.
+ * Returns one entry per unique exe, sorted by title.
+ */
+export function getVisibleAppList(skipSet?: Set): Array<{ exe: string; name: string }> {
+ initBindings()
+ const seen = new Map()
+ const selfExes = new Set(['electron.exe', 'peakflow.exe'])
+
+ const callback = K.register((hwnd: unknown, _lParam: number): boolean => {
+ try {
+ if (!_IsWindow(hwnd) || !_IsWindowVisible(hwnd) || _IsIconic(hwnd)) return true
+
+ const classNameBuf = Buffer.alloc(512)
+ const classLen = _GetClassNameW(hwnd, classNameBuf, 256)
+ const className = classLen > 0
+ ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '')
+ : ''
+ if (ENUM_IGNORED_CLASSES.has(className)) return true
+
+ const DWMWA_CLOAKED = 14
+ const cloaked = [0]
+ const cloakResult = _DwmGetWindowAttributeDword(hwnd, DWMWA_CLOAKED, cloaked, 4)
+ if (cloakResult === 0 && cloaked[0] !== 0) return true
+
+ const titleBuf = Buffer.alloc(512)
+ const titleLen = _GetWindowTextW(hwnd, titleBuf, 256)
+ const title = titleLen > 0
+ ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '')
+ : ''
+ if (title === '__peakflow_dim__' || !title) return true
+
+ const pid = getProcessId(hwnd)
+ const exe = getProcessExeName(pid)
+ if (!exe || selfExes.has(exe) || seen.has(exe)) return true
+ if (skipSet && skipSet.has(exe)) return true
+
+ seen.set(exe, title)
+ } catch { /* skip */ }
+ return true
+ }, K.pointer(_EnumWindowsCallback))
+
+ try {
+ _EnumWindows(callback, 0)
+ } finally {
+ K.unregister(callback)
+ }
+
+ return Array.from(seen.entries())
+ .map(([exe, name]) => ({ exe, name }))
+ .sort((a, b) => a.name.localeCompare(b.name))
+}
diff --git a/src/main/native/active-window.ts b/src/main/native/active-window.ts
index edb96e7..bb3658e 100644
--- a/src/main/native/active-window.ts
+++ b/src/main/native/active-window.ts
@@ -1,439 +1,42 @@
-/**
- * Native active window tracking using Win32 API via koffi.
- *
- * Calls GetForegroundWindow() + GetWindowRect() to get the position
- * and size of the currently focused window on Windows.
- */
+import { isMac } from './platform'
-import koffi from 'koffi'
+type ActiveWindowModule = typeof import('./active-window-win32')
-// ─── Win32 types and bindings ────────────────────────────────────────────────
-
-const user32 = koffi.load('user32.dll')
-const dwmapi = koffi.load('dwmapi.dll')
-const kernel32 = koffi.load('kernel32.dll')
-
-// RECT struct: { left, top, right, bottom } — all int32
-const RECT = koffi.struct('RECT', {
- left: 'int32',
- top: 'int32',
- right: 'int32',
- bottom: 'int32'
-})
-
-// Win32 function bindings
-const GetForegroundWindow = user32.func('GetForegroundWindow', 'void *', [])
-const GetWindowRect = user32.func('GetWindowRect', 'bool', ['void *', koffi.out(koffi.pointer(RECT))])
-const IsWindow = user32.func('IsWindow', 'bool', ['void *'])
-const IsWindowVisible = user32.func('IsWindowVisible', 'bool', ['void *'])
-// Pass output string buffers as void* — caller allocates Buffer.alloc(512) and reads utf16le
-const GetWindowTextW = user32.func('GetWindowTextW', 'int', ['void *', 'void *', 'int'])
-const GetClassNameW = user32.func('GetClassNameW', 'int', ['void *', 'void *', 'int'])
-
-// DwmGetWindowAttribute — for getting the actual rendered bounds (respects DPI, shadows)
-// DWMWA_EXTENDED_FRAME_BOUNDS = 9
-const DwmGetWindowAttribute = dwmapi.func('DwmGetWindowAttribute', 'long', [
- 'void *',
- 'uint32',
- koffi.out(koffi.pointer(RECT)),
- 'uint32'
-])
-// Variant for DWORD output (used for DWMWA_CLOAKED check)
-const DwmGetWindowAttributeDword = dwmapi.func('DwmGetWindowAttribute', 'long', [
- 'void *',
- 'uint32',
- koffi.out(koffi.pointer('uint32')),
- 'uint32'
-])
-
-// Process query bindings (for PID → exe name resolution)
-const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
-const OpenProcess = kernel32.func('OpenProcess', 'void *', ['uint32', 'bool', 'uint32'])
-const QueryFullProcessImageNameW = kernel32.func('QueryFullProcessImageNameW', 'bool', ['void *', 'uint32', 'void *', koffi.inout(koffi.pointer('uint32'))])
-const CloseHandle = kernel32.func('CloseHandle', 'bool', ['void *'])
-
-// Window enumeration bindings (for multi-window highlight modes)
-const EnumWindowsCallback = koffi.proto('bool __stdcall EnumWindowsCallback(void *, intptr)')
-const EnumWindows = user32.func('EnumWindows', 'bool', [koffi.pointer(EnumWindowsCallback), 'intptr'])
-const GetWindowThreadProcessId = user32.func('GetWindowThreadProcessId', 'uint32', ['void *', koffi.out(koffi.pointer('uint32'))])
-const IsIconic = user32.func('IsIconic', 'bool', ['void *'])
-
-// ─── Public interface ────────────────────────────────────────────────────────
-
-export interface ActiveWindowInfo {
- hwnd: unknown
- pid: number
- x: number
- y: number
- w: number
- h: number
- title: string
- className: string
-}
-
-export interface WindowRect {
- x: number
- y: number
- w: number
- h: number
-}
-
-// Classes ignored by getActiveWindow (desktop, taskbar — things that mean "no app focused")
-const IGNORED_CLASSES = new Set([
- 'Progman', // Desktop
- 'WorkerW', // Desktop worker
- 'Shell_TrayWnd', // Taskbar
- 'Shell_SecondaryTrayWnd', // Secondary taskbar
- 'Windows.UI.Core.CoreWindow', // Start menu, Action Center
- 'MultitaskingViewFrame' // Task view
-])
-
-// Additional classes filtered during EnumWindows (system chrome, invisible helpers, bad bounds)
-const ENUM_IGNORED_CLASSES = new Set([
- ...IGNORED_CLASSES,
- 'ThumbnailDeviceHelperWnd', // Thumbnail helper
- 'EdgeUiInputWndClass', // Edge UI input
- 'EdgeUiInputTopWndClass', // Edge UI top input
- 'ApplicationManager_ImmersiveShellWindow', // Immersive shell
- 'Internet Explorer_Hidden', // IE hidden window
- 'CEF-OSC-WIDGET', // NVIDIA GeForce overlay
- 'PseudoConsoleWindow', // ConPTY pseudo-console
- 'ForegroundStaging', // Window staging
- 'MSCTFIME UI', // IME
- 'IME', // Input method editor
- 'tooltips_class32', // Tooltips
- 'NotifyIconOverflowWindow', // System tray overflow
- 'DummyDWMListenerWindow', // DWM listener
- 'WinUIDesktopWin32WindowClass' // PowerToys, Command Palette
-])
-
-/** Get the process ID for a window handle. */
-function getProcessId(hwnd: unknown): number {
- const pid = [0]
- GetWindowThreadProcessId(hwnd, pid)
- return pid[0]
-}
-
-/**
- * Get the currently focused window's position and size.
- * Returns null if no valid foreground window is found, or if it's a
- * system window that should be ignored (desktop, taskbar, etc.).
- */
-export function getActiveWindow(): ActiveWindowInfo | null {
- try {
- const hwnd = GetForegroundWindow()
- if (!hwnd || !IsWindow(hwnd) || !IsWindowVisible(hwnd)) {
- return null
- }
-
- // Get class name to filter system windows
- const classNameBuf = Buffer.alloc(512)
- const classLen = GetClassNameW(hwnd, classNameBuf, 256)
- const className = classLen > 0 ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '') : ''
-
- if (IGNORED_CLASSES.has(className)) {
- return null
- }
-
- const pid = getProcessId(hwnd)
-
- // Try DwmGetWindowAttribute first for accurate bounds (handles DPI scaling)
- const rect = { left: 0, top: 0, right: 0, bottom: 0 }
- const DWMWA_EXTENDED_FRAME_BOUNDS = 9
- const sizeOfRect = 16 // 4 int32s = 16 bytes
-
- const dwmResult = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, sizeOfRect)
-
- if (dwmResult !== 0) {
- // Fallback to GetWindowRect
- const success = GetWindowRect(hwnd, rect)
- if (!success) return null
- }
-
- const w = rect.right - rect.left
- const h = rect.bottom - rect.top
-
- // Skip zero-size or tiny windows
- if (w < 10 || h < 10) return null
-
- // Get window title
- const titleBuf = Buffer.alloc(512)
- const titleLen = GetWindowTextW(hwnd, titleBuf, 256)
- const title = titleLen > 0 ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '') : ''
-
- return {
- hwnd,
- pid,
- x: rect.left,
- y: rect.top,
- w,
- h,
- title,
- className
- }
- } catch (error) {
- console.warn('[ActiveWindow] FFI error:', error)
- return null
+function getImpl(): ActiveWindowModule {
+ if (isMac) {
+ return require('./active-window-darwin') as ActiveWindowModule
}
+ return require('./active-window-win32') as ActiveWindowModule
}
-export interface DisplayBounds {
- x: number
- y: number
- w: number
- h: number
-}
-
-/**
- * Enumerate all visible, non-minimized, non-system windows.
- * Returns their rects in physical pixel coordinates.
- * If `filterPid` is provided, only returns windows belonging to that process.
- * If `displayBounds` is provided, windows covering 90%+ of any display are skipped
- * (catches UWP ApplicationFrameWindow host frames that report screen-sized bounds).
- * Capped at 50 results to prevent pathological clip-path complexity.
- */
-export function getAllVisibleWindows(filterPid?: number, displayBounds?: DisplayBounds[]): WindowRect[] {
- const results: WindowRect[] = []
- const DWMWA_EXTENDED_FRAME_BOUNDS = 9
- const sizeOfRect = 16
- const MAX_WINDOWS = 50
-
- const callback = koffi.register((hwnd: unknown, _lParam: number): boolean => {
- if (results.length >= MAX_WINDOWS) return false
- try {
- if (!IsWindow(hwnd) || !IsWindowVisible(hwnd) || IsIconic(hwnd)) return true
-
- const classNameBuf = Buffer.alloc(512)
- const classLen = GetClassNameW(hwnd, classNameBuf, 256)
- const className = classLen > 0
- ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '')
- : ''
- if (ENUM_IGNORED_CLASSES.has(className)) return true
-
- // Skip cloaked (hidden by DWM) windows — invisible UWP apps, virtual desktop windows
- const DWMWA_CLOAKED = 14
- const cloaked = [0]
- const cloakResult = DwmGetWindowAttributeDword(hwnd, DWMWA_CLOAKED, cloaked, 4)
- if (cloakResult === 0 && cloaked[0] !== 0) return true
-
- // Get window title for filtering
- const titleBuf = Buffer.alloc(512)
- const titleLen = GetWindowTextW(hwnd, titleBuf, 256)
- const title = titleLen > 0
- ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '')
- : ''
-
- // Skip PeakFlow overlay windows (but NOT settings/dashboard — those are real windows)
- if (title === '__peakflow_dim__') return true
- const winPid = getProcessId(hwnd)
-
-
- if (filterPid !== undefined && winPid !== filterPid) return true
+export type { ActiveWindowInfo, WindowRect, DisplayBounds } from './active-window-win32'
- const rect = { left: 0, top: 0, right: 0, bottom: 0 }
- const dwmResult = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, sizeOfRect)
- if (dwmResult !== 0) {
- const success = GetWindowRect(hwnd, rect)
- if (!success) return true
- }
-
- const w = rect.right - rect.left
- const h = rect.bottom - rect.top
- if (w < 10 || h < 10) return true
-
- // ApplicationFrameWindow is the UWP host frame — it sometimes reports screen-sized
- // bounds even for windowed apps. Only skip it if its bounds cover 90%+ of a display.
- if (className === 'ApplicationFrameWindow' && displayBounds) {
- const winArea = w * h
- for (const db of displayBounds) {
- if (winArea >= db.w * db.h * 0.9) {
- return true
- }
- }
- }
-
- results.push({ x: rect.left, y: rect.top, w, h })
- } catch { /* skip this window */ }
- return true
- }, koffi.pointer(EnumWindowsCallback))
-
- try {
- EnumWindows(callback, 0)
- } finally {
- koffi.unregister(callback)
- }
-
- return results
+export function getActiveWindow() {
+ return getImpl().getActiveWindow()
}
-// ─── PID → exe name resolution ─────────────────────────────────────────────
-
-/** Cache of PID → lowercase exe filename. Cleared when FocusDim enables/disables. */
-const pidExeCache = new Map()
-
-/** Clear the PID→exe cache (call on FocusDim enable/disable). */
-export function clearPidExeCache(): void {
- pidExeCache.clear()
+export function getAllVisibleWindows(
+ filterPid?: number,
+ displayBounds?: import('./active-window-win32').DisplayBounds[]
+) {
+ return getImpl().getAllVisibleWindows(filterPid, displayBounds)
}
-/**
- * Get the lowercase exe filename for a process ID (e.g., "chrome.exe").
- * Returns null if the process can't be opened or queried.
- */
-export function getProcessExeName(pid: number): string | null {
- const cached = pidExeCache.get(pid)
- if (cached !== undefined) return cached
-
- try {
- const hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
- if (!hProcess) return null
-
- try {
- const buf = Buffer.alloc(1024)
- const size = [512]
- const ok = QueryFullProcessImageNameW(hProcess, 0, buf, size)
- if (!ok || size[0] === 0) return null
-
- const fullPath = buf.toString('utf16le', 0, size[0] * 2).replace(/\0/g, '')
- const lastSlash = Math.max(fullPath.lastIndexOf('\\'), fullPath.lastIndexOf('/'))
- const exeName = (lastSlash >= 0 ? fullPath.substring(lastSlash + 1) : fullPath).toLowerCase()
- pidExeCache.set(pid, exeName)
- return exeName
- } finally {
- CloseHandle(hProcess)
- }
- } catch {
- return null
- }
+export function getWindowsForExeNames(
+ exeNames: string[],
+ displayBounds?: import('./active-window-win32').DisplayBounds[]
+) {
+ return getImpl().getWindowsForExeNames(exeNames, displayBounds)
}
-/**
- * Enumerate all visible windows whose exe name matches one of the given names.
- * Returns their rects in physical pixel coordinates.
- * Uses the same filters as getAllVisibleWindows (skip cloaked, minimized, system classes, overlay windows).
- */
-export function getWindowsForExeNames(exeNames: string[], displayBounds?: DisplayBounds[]): WindowRect[] {
- if (exeNames.length === 0) return []
-
- const exeSet = new Set(exeNames)
- const results: WindowRect[] = []
- const DWMWA_EXTENDED_FRAME_BOUNDS = 9
- const sizeOfRect = 16
- const MAX_WINDOWS = 50
-
- const callback = koffi.register((hwnd: unknown, _lParam: number): boolean => {
- if (results.length >= MAX_WINDOWS) return false
- try {
- if (!IsWindow(hwnd) || !IsWindowVisible(hwnd) || IsIconic(hwnd)) return true
-
- const classNameBuf = Buffer.alloc(512)
- const classLen = GetClassNameW(hwnd, classNameBuf, 256)
- const className = classLen > 0
- ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '')
- : ''
- if (ENUM_IGNORED_CLASSES.has(className)) return true
-
- // Skip cloaked windows
- const DWMWA_CLOAKED = 14
- const cloaked = [0]
- const cloakResult = DwmGetWindowAttributeDword(hwnd, DWMWA_CLOAKED, cloaked, 4)
- if (cloakResult === 0 && cloaked[0] !== 0) return true
-
- // Skip PeakFlow overlay windows
- const titleBuf = Buffer.alloc(512)
- const titleLen = GetWindowTextW(hwnd, titleBuf, 256)
- const title = titleLen > 0
- ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '')
- : ''
- if (title === '__peakflow_dim__') return true
-
- // Check exe name
- const winPid = getProcessId(hwnd)
- const exeName = getProcessExeName(winPid)
- if (!exeName || !exeSet.has(exeName)) return true
-
- const rect = { left: 0, top: 0, right: 0, bottom: 0 }
- const dwmResult = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, rect, sizeOfRect)
- if (dwmResult !== 0) {
- const success = GetWindowRect(hwnd, rect)
- if (!success) return true
- }
-
- const w = rect.right - rect.left
- const h = rect.bottom - rect.top
- if (w < 10 || h < 10) return true
-
- // Skip full-screen UWP frames
- if (className === 'ApplicationFrameWindow' && displayBounds) {
- const winArea = w * h
- for (const db of displayBounds) {
- if (winArea >= db.w * db.h * 0.9) return true
- }
- }
-
- results.push({ x: rect.left, y: rect.top, w, h })
- } catch { /* skip */ }
- return true
- }, koffi.pointer(EnumWindowsCallback))
-
- try {
- EnumWindows(callback, 0)
- } finally {
- koffi.unregister(callback)
- }
-
- return results
+export function getProcessExeName(pid: number) {
+ return getImpl().getProcessExeName(pid)
}
-/**
- * Get a deduplicated list of visible apps (exe + window title).
- * Excludes PeakFlow, system windows, and any exe names in the skipSet.
- * Returns one entry per unique exe, sorted by title.
- */
-export function getVisibleAppList(skipSet?: Set): Array<{ exe: string; name: string }> {
- const seen = new Map()
- const selfExes = new Set(['electron.exe', 'peakflow.exe'])
-
- const callback = koffi.register((hwnd: unknown, _lParam: number): boolean => {
- try {
- if (!IsWindow(hwnd) || !IsWindowVisible(hwnd) || IsIconic(hwnd)) return true
-
- const classNameBuf = Buffer.alloc(512)
- const classLen = GetClassNameW(hwnd, classNameBuf, 256)
- const className = classLen > 0
- ? classNameBuf.toString('utf16le', 0, classLen * 2).replace(/\0/g, '')
- : ''
- if (ENUM_IGNORED_CLASSES.has(className)) return true
-
- const DWMWA_CLOAKED = 14
- const cloaked = [0]
- const cloakResult = DwmGetWindowAttributeDword(hwnd, DWMWA_CLOAKED, cloaked, 4)
- if (cloakResult === 0 && cloaked[0] !== 0) return true
-
- const titleBuf = Buffer.alloc(512)
- const titleLen = GetWindowTextW(hwnd, titleBuf, 256)
- const title = titleLen > 0
- ? titleBuf.toString('utf16le', 0, titleLen * 2).replace(/\0/g, '')
- : ''
- if (title === '__peakflow_dim__' || !title) return true
-
- const pid = getProcessId(hwnd)
- const exe = getProcessExeName(pid)
- if (!exe || selfExes.has(exe) || seen.has(exe)) return true
- if (skipSet && skipSet.has(exe)) return true
-
- seen.set(exe, title)
- } catch { /* skip */ }
- return true
- }, koffi.pointer(EnumWindowsCallback))
-
- try {
- EnumWindows(callback, 0)
- } finally {
- koffi.unregister(callback)
- }
+export function clearPidExeCache() {
+ return getImpl().clearPidExeCache()
+}
- return Array.from(seen.entries())
- .map(([exe, name]) => ({ exe, name }))
- .sort((a, b) => a.name.localeCompare(b.name))
+export function getVisibleAppList(skipSet?: Set) {
+ return getImpl().getVisibleAppList(skipSet)
}
diff --git a/src/main/native/keyboard-darwin.ts b/src/main/native/keyboard-darwin.ts
new file mode 100644
index 0000000..b506aa3
--- /dev/null
+++ b/src/main/native/keyboard-darwin.ts
@@ -0,0 +1,26 @@
+/**
+ * macOS keyboard simulation — Cmd+V paste via osascript.
+ *
+ * Uses AppleScript's System Events to simulate keystrokes.
+ * Requires Accessibility permission (same as FocusDim).
+ * ~50ms latency per call, which is fine for one-shot paste.
+ */
+
+import { execSync } from 'child_process'
+
+/**
+ * Simulate Cmd+V keystroke to paste from clipboard on macOS.
+ * Returns true if the keystroke was sent successfully.
+ */
+export function simulateCmdV(): boolean {
+ try {
+ execSync(
+ 'osascript -e \'tell application "System Events" to keystroke "v" using command down\'',
+ { timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'] }
+ )
+ return true
+ } catch (error) {
+ console.error('[Keyboard-Darwin] osascript error:', error)
+ return false
+ }
+}
diff --git a/src/main/native/keyboard-win32.ts b/src/main/native/keyboard-win32.ts
new file mode 100644
index 0000000..798e798
--- /dev/null
+++ b/src/main/native/keyboard-win32.ts
@@ -0,0 +1,99 @@
+/**
+ * Native keyboard simulation using Win32 SendInput API via koffi.
+ *
+ * Used by QuickBoard to simulate Ctrl+V paste after writing to clipboard.
+ *
+ * All koffi/DLL bindings are deferred to first use via initBindings()
+ * so this module can be safely imported on macOS without crashing.
+ */
+
+// ─── Lazy koffi bindings ────────────────────────────────────────────────────
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let K: any = null
+let _KEYBDINPUT: any, _INPUT_KEYBOARD: any, _SendInput: any
+let _bindingsReady = false
+
+function initBindings(): void {
+ if (_bindingsReady) return
+
+ K = require('koffi')
+ const user32 = K.load('user32.dll')
+
+ _KEYBDINPUT = K.struct('KEYBDINPUT', {
+ wVk: 'uint16',
+ wScan: 'uint16',
+ dwFlags: 'uint32',
+ time: 'uint32',
+ dwExtraInfo: 'uintptr'
+ })
+
+ _INPUT_KEYBOARD = K.struct('INPUT_KEYBOARD', {
+ type: 'uint32',
+ _padding1: 'uint32',
+ ki: _KEYBDINPUT,
+ _padding2: K.array('uint8', 8)
+ })
+
+ _SendInput = user32.func('SendInput', 'uint32', [
+ 'uint32',
+ K.pointer(_INPUT_KEYBOARD),
+ 'int'
+ ])
+
+ _bindingsReady = true
+}
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const INPUT_TYPE_KEYBOARD = 1
+const KEYEVENTF_KEYUP = 0x0002
+
+// Virtual key codes
+const VK_CONTROL = 0x11
+const VK_V = 0x56
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Simulate Ctrl+V keystroke to paste from clipboard.
+ * Sends: Ctrl down → V down → V up → Ctrl up
+ */
+export function simulateCtrlV(): boolean {
+ initBindings()
+ try {
+ const sizeOfInput = K.sizeof(_INPUT_KEYBOARD)
+
+ const inputs = [
+ // Ctrl down
+ makeKeyInput(VK_CONTROL, 0),
+ // V down
+ makeKeyInput(VK_V, 0),
+ // V up
+ makeKeyInput(VK_V, KEYEVENTF_KEYUP),
+ // Ctrl up
+ makeKeyInput(VK_CONTROL, KEYEVENTF_KEYUP)
+ ]
+
+ const result = _SendInput(inputs.length, inputs, sizeOfInput)
+ return result === inputs.length
+ } catch (error) {
+ console.error('[Keyboard] SendInput error:', error)
+ return false
+ }
+}
+
+function makeKeyInput(vk: number, flags: number): unknown {
+ return {
+ type: INPUT_TYPE_KEYBOARD,
+ _padding1: 0,
+ ki: {
+ wVk: vk,
+ wScan: 0,
+ dwFlags: flags,
+ time: 0,
+ dwExtraInfo: 0
+ },
+ _padding2: [0, 0, 0, 0, 0, 0, 0, 0]
+ }
+}
diff --git a/src/main/native/keyboard.ts b/src/main/native/keyboard.ts
index 2333c5b..4eb0539 100644
--- a/src/main/native/keyboard.ts
+++ b/src/main/native/keyboard.ts
@@ -1,90 +1,20 @@
-/**
- * Native keyboard simulation using Win32 SendInput API via koffi.
- *
- * Used by QuickBoard to simulate Ctrl+V paste after writing to clipboard.
- */
+import { isMac } from './platform'
-import koffi from 'koffi'
+type KeyboardModule = typeof import('./keyboard-win32')
+type MacKeyboardModule = typeof import('./keyboard-darwin')
-// ─── Win32 types and bindings ────────────────────────────────────────────────
-
-const user32 = koffi.load('user32.dll')
-
-// INPUT struct for SendInput — keyboard variant
-// INPUT_KEYBOARD = 1
-// sizeof(INPUT) = 40 on x64 (type:4 + padding:4 + ki:24 + padding:8)
-// KEYBDINPUT: wVk(2) + wScan(2) + dwFlags(4) + time(4) + dwExtraInfo(8) = 20 bytes
-
-const KEYBDINPUT = koffi.struct('KEYBDINPUT', {
- wVk: 'uint16',
- wScan: 'uint16',
- dwFlags: 'uint32',
- time: 'uint32',
- dwExtraInfo: 'uintptr'
-})
-
-const INPUT_KEYBOARD = koffi.struct('INPUT_KEYBOARD', {
- type: 'uint32',
- _padding1: 'uint32',
- ki: KEYBDINPUT,
- _padding2: koffi.array('uint8', 8)
-})
-
-const SendInput = user32.func('SendInput', 'uint32', [
- 'uint32', // nInputs
- koffi.pointer(INPUT_KEYBOARD), // pInputs
- 'int' // cbSize
-])
-
-// ─── Constants ───────────────────────────────────────────────────────────────
-
-const INPUT_TYPE_KEYBOARD = 1
-const KEYEVENTF_KEYUP = 0x0002
-
-// Virtual key codes
-const VK_CONTROL = 0x11
-const VK_V = 0x56
+function getWinImpl(): KeyboardModule {
+ return require('./keyboard-win32') as KeyboardModule
+}
-// ─── Public API ──────────────────────────────────────────────────────────────
+function getMacImpl(): MacKeyboardModule {
+ return require('./keyboard-darwin') as MacKeyboardModule
+}
-/**
- * Simulate Ctrl+V keystroke to paste from clipboard.
- * Sends: Ctrl down → V down → V up → Ctrl up
- */
export function simulateCtrlV(): boolean {
- try {
- const sizeOfInput = koffi.sizeof(INPUT_KEYBOARD)
-
- const inputs = [
- // Ctrl down
- makeKeyInput(VK_CONTROL, 0),
- // V down
- makeKeyInput(VK_V, 0),
- // V up
- makeKeyInput(VK_V, KEYEVENTF_KEYUP),
- // Ctrl up
- makeKeyInput(VK_CONTROL, KEYEVENTF_KEYUP)
- ]
-
- const result = SendInput(inputs.length, inputs, sizeOfInput)
- return result === inputs.length
- } catch (error) {
- console.error('[Keyboard] SendInput error:', error)
- return false
+ if (isMac) {
+ return getMacImpl().simulateCmdV()
}
-}
-function makeKeyInput(vk: number, flags: number): InstanceType {
- return {
- type: INPUT_TYPE_KEYBOARD,
- _padding1: 0,
- ki: {
- wVk: vk,
- wScan: 0,
- dwFlags: flags,
- time: 0,
- dwExtraInfo: 0
- },
- _padding2: [0, 0, 0, 0, 0, 0, 0, 0]
- } as unknown as InstanceType
+ return getWinImpl().simulateCtrlV()
}
diff --git a/src/main/native/platform.ts b/src/main/native/platform.ts
new file mode 100644
index 0000000..667717e
--- /dev/null
+++ b/src/main/native/platform.ts
@@ -0,0 +1,5 @@
+/** Platform detection constants. */
+
+export const isWindows = process.platform === 'win32'
+export const isMac = process.platform === 'darwin'
+export const isLinux = process.platform === 'linux'
diff --git a/src/main/services/mic-mute-darwin.ts b/src/main/services/mic-mute-darwin.ts
new file mode 100644
index 0000000..0081f51
--- /dev/null
+++ b/src/main/services/mic-mute-darwin.ts
@@ -0,0 +1,215 @@
+/**
+ * macOS mic mute control via CoreAudio C API through koffi.
+ *
+ * Loads the CoreAudio framework and uses AudioObjectGetPropertyData /
+ * AudioObjectSetPropertyData to read and toggle the mute state of
+ * the default input (capture) device.
+ *
+ * CoreAudio is a pure C API (not Objective-C), so koffi can call it directly.
+ */
+
+import { BrowserWindow } from 'electron'
+import { IPC_SEND } from '@shared/ipc-types'
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface MicMuteResult {
+ muted: boolean
+ error: string | null
+}
+
+// ─── CoreAudio bindings via koffi (deferred to first use) ───────────────────
+
+let loaded = false
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let K: any = null
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let AudioObjectGetPropertyData: any
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let AudioObjectSetPropertyData: any
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let _AudioObjectPropertyAddress: any
+
+function loadCoreAudio(): boolean {
+ if (loaded) return true
+ try {
+ K = require('koffi')
+
+ _AudioObjectPropertyAddress = K.struct('AudioObjectPropertyAddress', {
+ mSelector: 'uint32',
+ mScope: 'uint32',
+ mElement: 'uint32'
+ })
+
+ const lib = K.load('/System/Library/Frameworks/CoreAudio.framework/CoreAudio')
+
+ AudioObjectGetPropertyData = lib.func(
+ 'AudioObjectGetPropertyData', 'int32', [
+ 'uint32', // inObjectID
+ K.pointer(_AudioObjectPropertyAddress), // inAddress
+ 'uint32', // inQualifierDataSize
+ 'void *', // inQualifierData
+ K.inout(K.pointer('uint32')), // ioDataSize
+ 'void *' // outData
+ ]
+ )
+
+ AudioObjectSetPropertyData = lib.func(
+ 'AudioObjectSetPropertyData', 'int32', [
+ 'uint32', // inObjectID
+ K.pointer(_AudioObjectPropertyAddress), // inAddress
+ 'uint32', // inQualifierDataSize
+ 'void *', // inQualifierData
+ 'uint32', // inDataSize
+ 'void *' // inData
+ ]
+ )
+
+ loaded = true
+ return true
+ } catch (err) {
+ console.error('[MicMute-Darwin] Failed to load CoreAudio:', err)
+ return false
+ }
+}
+
+// ─── Constants ──────────────────────────────────────────────────────────────
+
+const kAudioObjectSystemObject = 1
+// 'dIn ' in big-endian → 0x64496E20
+const kAudioHardwarePropertyDefaultInputDevice = 0x64496E20
+// 'mute' in big-endian → 0x6D757465
+const kAudioDevicePropertyMute = 0x6D757465
+// 'inpt' in big-endian → 0x696E7074
+const kAudioDevicePropertyScopeInput = 0x696E7074
+// 'glob' in big-endian → 0x676C6F62
+const kAudioObjectPropertyScopeGlobal = 0x676C6F62
+// 'mast' in big-endian → 0x6D617374
+const kAudioObjectPropertyElementMain = 0x6D617374
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+function getDefaultInputDevice(): number | null {
+ const address = {
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
+ mScope: kAudioObjectPropertyScopeGlobal,
+ mElement: kAudioObjectPropertyElementMain
+ }
+ const size = [4]
+ const deviceId = Buffer.alloc(4)
+
+ const status = (AudioObjectGetPropertyData as Function)(
+ kAudioObjectSystemObject, address, 0, null, size, deviceId
+ )
+ if (status !== 0) return null
+ return deviceId.readUInt32LE()
+}
+
+function getMuteValue(deviceId: number): boolean | null {
+ const address = {
+ mSelector: kAudioDevicePropertyMute,
+ mScope: kAudioDevicePropertyScopeInput,
+ mElement: kAudioObjectPropertyElementMain
+ }
+ const size = [4]
+ const muted = Buffer.alloc(4)
+
+ const status = (AudioObjectGetPropertyData as Function)(
+ deviceId, address, 0, null, size, muted
+ )
+ if (status !== 0) return null
+ return muted.readUInt32LE() === 1
+}
+
+function setMuteValue(deviceId: number, muted: boolean): boolean | null {
+ const address = {
+ mSelector: kAudioDevicePropertyMute,
+ mScope: kAudioDevicePropertyScopeInput,
+ mElement: kAudioObjectPropertyElementMain
+ }
+ const value = Buffer.alloc(4)
+ value.writeUInt32LE(muted ? 1 : 0)
+
+ const status = (AudioObjectSetPropertyData as Function)(
+ deviceId, address, 0, null, 4, value
+ )
+ if (status !== 0) return null
+
+ // Read back to confirm
+ return getMuteValue(deviceId)
+}
+
+// ─── Broadcast helper ───────────────────────────────────────────────────────
+
+function broadcastMuteState(muted: boolean): void {
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (!win.isDestroyed()) {
+ win.webContents.send(IPC_SEND.MIC_MUTE_CHANGED, muted)
+ }
+ }
+}
+
+// ─── Public API ─────────────────────────────────────────────────────────────
+
+export async function getMicMuteState(): Promise {
+ if (!loadCoreAudio()) {
+ return { muted: false, error: 'CoreAudio not available' }
+ }
+ try {
+ const deviceId = getDefaultInputDevice()
+ if (deviceId === null) {
+ return { muted: false, error: 'No microphone found' }
+ }
+ const muted = getMuteValue(deviceId)
+ if (muted === null) {
+ return { muted: false, error: 'Failed to read mute state' }
+ }
+ return { muted, error: null }
+ } catch (err) {
+ return { muted: false, error: (err as Error).message }
+ }
+}
+
+export async function setMicMute(muted: boolean): Promise {
+ if (!loadCoreAudio()) {
+ return { muted: false, error: 'CoreAudio not available' }
+ }
+ try {
+ const deviceId = getDefaultInputDevice()
+ if (deviceId === null) {
+ return { muted: false, error: 'No microphone found' }
+ }
+ const result = setMuteValue(deviceId, muted)
+ if (result === null) {
+ return { muted: false, error: 'Failed to set mute state' }
+ }
+ broadcastMuteState(result)
+ return { muted: result, error: null }
+ } catch (err) {
+ return { muted: false, error: (err as Error).message }
+ }
+}
+
+export async function toggleMicMute(): Promise {
+ if (!loadCoreAudio()) {
+ return { muted: false, error: 'CoreAudio not available' }
+ }
+ try {
+ const deviceId = getDefaultInputDevice()
+ if (deviceId === null) {
+ return { muted: false, error: 'No microphone found' }
+ }
+ const current = getMuteValue(deviceId)
+ if (current === null) {
+ return { muted: false, error: 'Failed to read mute state' }
+ }
+ const result = setMuteValue(deviceId, !current)
+ if (result === null) {
+ return { muted: false, error: 'Failed to toggle mute' }
+ }
+ broadcastMuteState(result)
+ return { muted: result, error: null }
+ } catch (err) {
+ return { muted: false, error: (err as Error).message }
+ }
+}
diff --git a/src/main/services/mic-mute-win32.ts b/src/main/services/mic-mute-win32.ts
new file mode 100644
index 0000000..f800e6f
--- /dev/null
+++ b/src/main/services/mic-mute-win32.ts
@@ -0,0 +1,199 @@
+/**
+ * Mic Mute Service — toggle system microphone mute via Windows WASAPI.
+ *
+ * Uses async PowerShell `execFile` with -EncodedCommand to invoke C# COM interop
+ * on the default capture device (eCapture, eCommunications). Same API that
+ * Windows Settings uses.
+ *
+ * No persistent sidecar — mic mute is an infrequent toggle, so one-shot
+ * PowerShell per call is fine.
+ */
+
+import { execFile } from 'child_process'
+import { BrowserWindow } from 'electron'
+import { IPC_SEND } from '@shared/ipc-types'
+
+// ─── Types ──────────────────────────────────────────────────────────────────
+
+export interface MicMuteResult {
+ muted: boolean
+ error: string | null
+}
+
+// ─── C# script (compiled once per PowerShell invocation) ────────────────────
+//
+// IMPORTANT: This goes into a @'...'@ single-quoted here-string in the
+// PowerShell wrapper, so $ is literal (no PS variable expansion).
+// Uses String.Format / concatenation instead of C# string interpolation.
+
+const CSHARP_SOURCE = `
+using System;
+using System.Runtime.InteropServices;
+
+[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
+class MMDeviceEnumerator {}
+
+[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+interface IMMDeviceEnumerator {
+ int NotImpl1();
+ int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice);
+}
+
+[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+interface IMMDevice {
+ int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
+}
+
+[ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+interface IAudioEndpointVolume {
+ int NotImpl1();
+ int NotImpl2();
+ int GetChannelCount(out uint pnChannelCount);
+ int SetMasterVolumeLevel(float fLevelDB, ref Guid pguidEventContext);
+ int SetMasterVolumeLevelScalar(float fLevel, ref Guid pguidEventContext);
+ int GetMasterVolumeLevel(out float pfLevelDB);
+ int GetMasterVolumeLevelScalar(out float pfLevel);
+ int SetChannelVolumeLevel(uint nChannel, float fLevelDB, ref Guid pguidEventContext);
+ int SetChannelVolumeLevelScalar(uint nChannel, float fLevel, ref Guid pguidEventContext);
+ int GetChannelVolumeLevel(uint nChannel, out float pfLevelDB);
+ int GetChannelVolumeLevelScalar(uint nChannel, out float pfLevel);
+ int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, ref Guid pguidEventContext);
+ int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
+}
+
+public class MicMuteHelper {
+ private static Guid IID_IAudioEndpointVolume = new Guid("5CDF2C82-841E-4546-9722-0CF74078229A");
+ private static Guid GUID_NULL = Guid.Empty;
+
+ public static IAudioEndpointVolume GetCaptureEndpointVolume() {
+ var enumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
+ IMMDevice device;
+ // eCapture=1, eCommunications=1
+ enumerator.GetDefaultAudioEndpoint(1, 1, out device);
+ object o;
+ device.Activate(ref IID_IAudioEndpointVolume, 23, IntPtr.Zero, out o);
+ return (IAudioEndpointVolume)o;
+ }
+
+ public static bool GetMute() {
+ var vol = GetCaptureEndpointVolume();
+ bool muted;
+ vol.GetMute(out muted);
+ return muted;
+ }
+
+ public static bool SetMute(bool mute) {
+ var vol = GetCaptureEndpointVolume();
+ vol.SetMute(mute, ref GUID_NULL);
+ bool result;
+ vol.GetMute(out result);
+ return result;
+ }
+
+ public static bool Toggle() {
+ var vol = GetCaptureEndpointVolume();
+ bool current;
+ vol.GetMute(out current);
+ vol.SetMute(!current, ref GUID_NULL);
+ bool result;
+ vol.GetMute(out result);
+ return result;
+ }
+}
+`
+
+// ─── PowerShell wrappers ────────────────────────────────────────────────────
+
+function buildScript(action: 'get' | 'set' | 'toggle', muted?: boolean): string {
+ // Use @'...'@ (single-quoted here-string) so PowerShell does NOT expand $
+ const addType = `try {\n Add-Type -TypeDefinition @'\n${CSHARP_SOURCE}\n'@\n} catch {\n if ($_.Exception.Message -notmatch 'already exists') { throw }\n}`
+
+ let call: string
+ if (action === 'get') {
+ call = '[MicMuteHelper]::GetMute()'
+ } else if (action === 'set') {
+ call = `[MicMuteHelper]::SetMute([bool]::Parse("${muted}"))`
+ } else {
+ call = '[MicMuteHelper]::Toggle()'
+ }
+
+ return `${addType}\ntry {\n [Console]::Out.WriteLine(${call})\n} catch {\n [Console]::Error.WriteLine($_.Exception.Message)\n exit 1\n}`
+}
+
+function runPowerShellAsync(script: string): Promise<{ stdout: string; stderr: string }> {
+ return new Promise((resolve, reject) => {
+ const encoded = Buffer.from(script, 'utf16le').toString('base64')
+ execFile(
+ 'powershell.exe',
+ ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
+ { timeout: 5000, windowsHide: true },
+ (error, stdout, stderr) => {
+ if (error) {
+ // Check for ENOENT (PowerShell not found)
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ reject(new Error('PowerShell not found'))
+ } else {
+ reject(new Error(stderr?.trim() || error.message))
+ }
+ } else {
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() })
+ }
+ }
+ )
+ })
+}
+
+// ─── Broadcast helper ───────────────────────────────────────────────────────
+
+function broadcastMuteState(muted: boolean): void {
+ for (const win of BrowserWindow.getAllWindows()) {
+ if (!win.isDestroyed()) {
+ win.webContents.send(IPC_SEND.MIC_MUTE_CHANGED, muted)
+ }
+ }
+}
+
+// ─── Public API ─────────────────────────────────────────────────────────────
+
+export async function getMicMuteState(): Promise {
+ try {
+ const { stdout } = await runPowerShellAsync(buildScript('get'))
+ return { muted: stdout.toLowerCase() === 'true', error: null }
+ } catch (err) {
+ const msg = (err as Error).message
+ if (msg.includes('E_NOTFOUND') || msg.includes('No audio')) {
+ return { muted: false, error: 'No microphone found' }
+ }
+ return { muted: false, error: msg }
+ }
+}
+
+export async function setMicMute(muted: boolean): Promise {
+ try {
+ const { stdout } = await runPowerShellAsync(buildScript('set', muted))
+ const result = stdout.toLowerCase() === 'true'
+ broadcastMuteState(result)
+ return { muted: result, error: null }
+ } catch (err) {
+ const msg = (err as Error).message
+ if (msg.includes('E_NOTFOUND') || msg.includes('No audio')) {
+ return { muted: false, error: 'No microphone found' }
+ }
+ return { muted: false, error: msg }
+ }
+}
+
+export async function toggleMicMute(): Promise {
+ try {
+ const { stdout } = await runPowerShellAsync(buildScript('toggle'))
+ const result = stdout.toLowerCase() === 'true'
+ broadcastMuteState(result)
+ return { muted: result, error: null }
+ } catch (err) {
+ const msg = (err as Error).message
+ if (msg.includes('E_NOTFOUND') || msg.includes('No audio')) {
+ return { muted: false, error: 'No microphone found' }
+ }
+ return { muted: false, error: msg }
+ }
+}
diff --git a/src/main/services/mic-mute.ts b/src/main/services/mic-mute.ts
index f800e6f..5081d35 100644
--- a/src/main/services/mic-mute.ts
+++ b/src/main/services/mic-mute.ts
@@ -1,199 +1,25 @@
-/**
- * Mic Mute Service — toggle system microphone mute via Windows WASAPI.
- *
- * Uses async PowerShell `execFile` with -EncodedCommand to invoke C# COM interop
- * on the default capture device (eCapture, eCommunications). Same API that
- * Windows Settings uses.
- *
- * No persistent sidecar — mic mute is an infrequent toggle, so one-shot
- * PowerShell per call is fine.
- */
+import { isMac } from '../native/platform'
-import { execFile } from 'child_process'
-import { BrowserWindow } from 'electron'
-import { IPC_SEND } from '@shared/ipc-types'
+type MicMuteModule = typeof import('./mic-mute-win32')
-// ─── Types ──────────────────────────────────────────────────────────────────
-
-export interface MicMuteResult {
- muted: boolean
- error: string | null
-}
-
-// ─── C# script (compiled once per PowerShell invocation) ────────────────────
-//
-// IMPORTANT: This goes into a @'...'@ single-quoted here-string in the
-// PowerShell wrapper, so $ is literal (no PS variable expansion).
-// Uses String.Format / concatenation instead of C# string interpolation.
-
-const CSHARP_SOURCE = `
-using System;
-using System.Runtime.InteropServices;
-
-[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
-class MMDeviceEnumerator {}
-
-[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-interface IMMDeviceEnumerator {
- int NotImpl1();
- int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice ppDevice);
-}
-
-[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-interface IMMDevice {
- int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, [MarshalAs(UnmanagedType.IUnknown)] out object ppInterface);
-}
-
-[ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
-interface IAudioEndpointVolume {
- int NotImpl1();
- int NotImpl2();
- int GetChannelCount(out uint pnChannelCount);
- int SetMasterVolumeLevel(float fLevelDB, ref Guid pguidEventContext);
- int SetMasterVolumeLevelScalar(float fLevel, ref Guid pguidEventContext);
- int GetMasterVolumeLevel(out float pfLevelDB);
- int GetMasterVolumeLevelScalar(out float pfLevel);
- int SetChannelVolumeLevel(uint nChannel, float fLevelDB, ref Guid pguidEventContext);
- int SetChannelVolumeLevelScalar(uint nChannel, float fLevel, ref Guid pguidEventContext);
- int GetChannelVolumeLevel(uint nChannel, out float pfLevelDB);
- int GetChannelVolumeLevelScalar(uint nChannel, out float pfLevel);
- int SetMute([MarshalAs(UnmanagedType.Bool)] bool bMute, ref Guid pguidEventContext);
- int GetMute([MarshalAs(UnmanagedType.Bool)] out bool pbMute);
-}
-
-public class MicMuteHelper {
- private static Guid IID_IAudioEndpointVolume = new Guid("5CDF2C82-841E-4546-9722-0CF74078229A");
- private static Guid GUID_NULL = Guid.Empty;
-
- public static IAudioEndpointVolume GetCaptureEndpointVolume() {
- var enumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator());
- IMMDevice device;
- // eCapture=1, eCommunications=1
- enumerator.GetDefaultAudioEndpoint(1, 1, out device);
- object o;
- device.Activate(ref IID_IAudioEndpointVolume, 23, IntPtr.Zero, out o);
- return (IAudioEndpointVolume)o;
- }
-
- public static bool GetMute() {
- var vol = GetCaptureEndpointVolume();
- bool muted;
- vol.GetMute(out muted);
- return muted;
- }
-
- public static bool SetMute(bool mute) {
- var vol = GetCaptureEndpointVolume();
- vol.SetMute(mute, ref GUID_NULL);
- bool result;
- vol.GetMute(out result);
- return result;
- }
-
- public static bool Toggle() {
- var vol = GetCaptureEndpointVolume();
- bool current;
- vol.GetMute(out current);
- vol.SetMute(!current, ref GUID_NULL);
- bool result;
- vol.GetMute(out result);
- return result;
- }
-}
-`
-
-// ─── PowerShell wrappers ────────────────────────────────────────────────────
-
-function buildScript(action: 'get' | 'set' | 'toggle', muted?: boolean): string {
- // Use @'...'@ (single-quoted here-string) so PowerShell does NOT expand $
- const addType = `try {\n Add-Type -TypeDefinition @'\n${CSHARP_SOURCE}\n'@\n} catch {\n if ($_.Exception.Message -notmatch 'already exists') { throw }\n}`
-
- let call: string
- if (action === 'get') {
- call = '[MicMuteHelper]::GetMute()'
- } else if (action === 'set') {
- call = `[MicMuteHelper]::SetMute([bool]::Parse("${muted}"))`
- } else {
- call = '[MicMuteHelper]::Toggle()'
+function getImpl(): MicMuteModule {
+ if (isMac) {
+ return require('./mic-mute-darwin') as MicMuteModule
}
- return `${addType}\ntry {\n [Console]::Out.WriteLine(${call})\n} catch {\n [Console]::Error.WriteLine($_.Exception.Message)\n exit 1\n}`
-}
-
-function runPowerShellAsync(script: string): Promise<{ stdout: string; stderr: string }> {
- return new Promise((resolve, reject) => {
- const encoded = Buffer.from(script, 'utf16le').toString('base64')
- execFile(
- 'powershell.exe',
- ['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
- { timeout: 5000, windowsHide: true },
- (error, stdout, stderr) => {
- if (error) {
- // Check for ENOENT (PowerShell not found)
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
- reject(new Error('PowerShell not found'))
- } else {
- reject(new Error(stderr?.trim() || error.message))
- }
- } else {
- resolve({ stdout: stdout.trim(), stderr: stderr.trim() })
- }
- }
- )
- })
+ return require('./mic-mute-win32') as MicMuteModule
}
-// ─── Broadcast helper ───────────────────────────────────────────────────────
+export type { MicMuteResult } from './mic-mute-win32'
-function broadcastMuteState(muted: boolean): void {
- for (const win of BrowserWindow.getAllWindows()) {
- if (!win.isDestroyed()) {
- win.webContents.send(IPC_SEND.MIC_MUTE_CHANGED, muted)
- }
- }
+export function getMicMuteState() {
+ return getImpl().getMicMuteState()
}
-// ─── Public API ─────────────────────────────────────────────────────────────
-
-export async function getMicMuteState(): Promise {
- try {
- const { stdout } = await runPowerShellAsync(buildScript('get'))
- return { muted: stdout.toLowerCase() === 'true', error: null }
- } catch (err) {
- const msg = (err as Error).message
- if (msg.includes('E_NOTFOUND') || msg.includes('No audio')) {
- return { muted: false, error: 'No microphone found' }
- }
- return { muted: false, error: msg }
- }
+export function setMicMute(muted: boolean) {
+ return getImpl().setMicMute(muted)
}
-export async function setMicMute(muted: boolean): Promise {
- try {
- const { stdout } = await runPowerShellAsync(buildScript('set', muted))
- const result = stdout.toLowerCase() === 'true'
- broadcastMuteState(result)
- return { muted: result, error: null }
- } catch (err) {
- const msg = (err as Error).message
- if (msg.includes('E_NOTFOUND') || msg.includes('No audio')) {
- return { muted: false, error: 'No microphone found' }
- }
- return { muted: false, error: msg }
- }
-}
-
-export async function toggleMicMute(): Promise {
- try {
- const { stdout } = await runPowerShellAsync(buildScript('toggle'))
- const result = stdout.toLowerCase() === 'true'
- broadcastMuteState(result)
- return { muted: result, error: null }
- } catch (err) {
- const msg = (err as Error).message
- if (msg.includes('E_NOTFOUND') || msg.includes('No audio')) {
- return { muted: false, error: 'No microphone found' }
- }
- return { muted: false, error: msg }
- }
+export function toggleMicMute() {
+ return getImpl().toggleMicMute()
}
diff --git a/src/main/services/permissions-darwin.ts b/src/main/services/permissions-darwin.ts
new file mode 100644
index 0000000..51e6fcf
--- /dev/null
+++ b/src/main/services/permissions-darwin.ts
@@ -0,0 +1,75 @@
+/**
+ * macOS permission checks for Accessibility, Camera, and Microphone.
+ *
+ * FocusDim and QuickBoard (paste) need Accessibility access.
+ * MeetReady and LiquidFocus need Camera and Microphone access.
+ */
+
+import { systemPreferences, dialog } from 'electron'
+
+/**
+ * Check if Accessibility permission is granted.
+ * Does NOT show the system prompt (pass false to isTrustedAccessibilityClient).
+ */
+export function checkAccessibility(): boolean {
+ return systemPreferences.isTrustedAccessibilityClient(false)
+}
+
+/**
+ * Request Accessibility permission.
+ * Shows the macOS system prompt that guides the user to
+ * System Preferences > Privacy & Security > Accessibility.
+ * Returns true if already granted, false if the user needs to grant it.
+ */
+export function requestAccessibility(): boolean {
+ return systemPreferences.isTrustedAccessibilityClient(true)
+}
+
+/**
+ * Show a dialog explaining why Accessibility permission is needed,
+ * then trigger the system prompt.
+ */
+export async function promptAccessibility(reason: string): Promise {
+ if (checkAccessibility()) return true
+
+ await dialog.showMessageBox({
+ type: 'info',
+ title: 'Accessibility Permission Required',
+ message: 'PeakFlow needs Accessibility access.',
+ detail: `${reason}\n\nAfter granting access in System Preferences, you may need to restart PeakFlow.`,
+ buttons: ['Open Settings'],
+ defaultId: 0
+ })
+
+ requestAccessibility()
+ return false
+}
+
+/**
+ * Check camera permission status.
+ * Returns 'granted', 'denied', 'restricted', or 'not-determined'.
+ */
+export function checkCameraPermission(): string {
+ return systemPreferences.getMediaAccessStatus('camera')
+}
+
+/**
+ * Request camera access. Returns true if granted.
+ */
+export async function requestCameraPermission(): Promise {
+ return systemPreferences.askForMediaAccess('camera')
+}
+
+/**
+ * Check microphone permission status.
+ */
+export function checkMicrophonePermission(): string {
+ return systemPreferences.getMediaAccessStatus('microphone')
+}
+
+/**
+ * Request microphone access. Returns true if granted.
+ */
+export async function requestMicrophonePermission(): Promise {
+ return systemPreferences.askForMediaAccess('microphone')
+}