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') +}