From 9874e16db76bb425b558c66d57d3c8d5e9c07e48 Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Wed, 4 Feb 2026 16:10:26 -0800 Subject: [PATCH 01/13] Update docs for v1.5.0 --- CHANGELOG.md | 10 +- LuxaforPresence/AppDelegate.swift | 21 ++ LuxaforPresence/Info.plist | 4 +- LuxaforPresence/Model/PresenceState.swift | 1 + LuxaforPresence/PresenceEngine.swift | 117 ++++++- LuxaforPresence/Resources/config.plist | 14 + .../Signals/AccessibilitySnapshot.swift | 251 +++++++++++++++ LuxaforPresence/Signals/MeetingDetector.swift | 28 ++ .../MeetingDetectors/GoogleMeetDetector.swift | 61 ++++ .../SlackMeetingDetector.swift | 74 +++++ .../TeamsMeetingDetector.swift | 84 +++++ .../WebexMeetingDetector.swift | 9 + .../ZoomMeetingDetector.swift | 9 + LuxaforPresence/Signals/MicCamSignal.swift | 35 ++- LuxaforPresence/Signals/ProcessSignal.swift | 20 ++ LuxaforPresence/Signals/SignalProtocols.swift | 7 + .../Signals/VoiceActivitySignal.swift | 104 +++++++ .../Tests/PresenceEngineTests.swift | 289 +++++++++++++++++- .../Tests/SlackMeetingDetectorTests.swift | 59 ++++ .../Tests/TeamsMeetingDetectorTests.swift | 60 ++++ LuxaforPresence/Transport/LuxaforClient.swift | 7 +- PLAN_2.md | 110 ------- README.md | 65 ++-- 23 files changed, 1262 insertions(+), 177 deletions(-) create mode 100644 LuxaforPresence/Signals/AccessibilitySnapshot.swift create mode 100644 LuxaforPresence/Signals/MeetingDetector.swift create mode 100644 LuxaforPresence/Signals/MeetingDetectors/GoogleMeetDetector.swift create mode 100644 LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift create mode 100644 LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift create mode 100644 LuxaforPresence/Signals/MeetingDetectors/WebexMeetingDetector.swift create mode 100644 LuxaforPresence/Signals/MeetingDetectors/ZoomMeetingDetector.swift create mode 100644 LuxaforPresence/Signals/ProcessSignal.swift create mode 100644 LuxaforPresence/Signals/VoiceActivitySignal.swift create mode 100644 LuxaforPresence/Tests/SlackMeetingDetectorTests.swift create mode 100644 LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift delete mode 100644 PLAN_2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 08e505c..3e0739b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,15 @@ All notable changes to LuxaforPresence will be documented here. This project is currently in alpha; expect rapid iteration and possible breaking changes between versions. -## [Unreleased] – Alpha Preview +## [1.5.0] – Current (from `LuxaforPresence/Info.plist`) +- Current app version is `1.5.0` (from `CFBundleShortVersionString`). +- “in meeting” state is detected using camera/mic activity, voice activity, plus checks for common meeting apps like Slack and Teams showing UI elements that indicate an active meeting. + +## [v0.01] – First Tagged Version (check via `git ls-remote --tags origin`) + +- First tagged version on the remote is `v0.01`. - Initial menu bar app that infers “in meeting” state using mic/camera activity plus a foreground-app allowlist and updates the Luxafor flag accordingly. - Manual overrides (Force On/Off) exposed via the status menu. - Packaging script (`scripts/package-dmg.sh`) to build a distributable `.dmg`. -- Roadmap for additional signals, logging, and override UX tracked in `PLAN.md` and `PLAN_2.md`. +- Roadmap for additional signals, logging, and override UX tracked in `PLAN.md`. diff --git a/LuxaforPresence/AppDelegate.swift b/LuxaforPresence/AppDelegate.swift index b053ce1..8083460 100644 --- a/LuxaforPresence/AppDelegate.swift +++ b/LuxaforPresence/AppDelegate.swift @@ -1,4 +1,5 @@ import AppKit +import ApplicationServices import OSLog final class AppDelegate: NSObject, NSApplicationDelegate { @@ -6,6 +7,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var timer: Timer? private let engine = PresenceEngine() private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "AppDelegate") + private let accessibilityPromptShownKey = "AccessibilityPromptShown" func applicationDidFinishLaunching(_ notification: Notification) { logger.log("Application did finish launching") @@ -27,6 +29,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.async { self?.updateStatusIcon(state) } } engine.prepare() + promptForAccessibilityIfNeeded() timer = Timer.scheduledTimer(withTimeInterval: engine.config.pollInterval, repeats: true) { [weak self] _ in self?.logger.debug("Timer fired; invoking PresenceEngine.tick()") @@ -40,6 +43,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let icon: NSImage? = { switch state { case .inMeeting: return StatusIconName.on.image() + case .inMeetingSilent: return StatusIconName.on.image() case .notMeeting: return StatusIconName.off.image() case .unknown: return StatusIconName.idle.image() } @@ -49,6 +53,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { logger.debug("Status icon updated to state \(state.rawValue, privacy: .public)") } + private func promptForAccessibilityIfNeeded() { + guard !AXIsProcessTrusted() else { return } + guard !UserDefaults.standard.bool(forKey: accessibilityPromptShownKey) else { return } + + let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + UserDefaults.standard.set(true, forKey: accessibilityPromptShownKey) + + let alert = NSAlert() + alert.messageText = "Enable Accessibility Access" + alert.informativeText = "LuxaforPresence needs Accessibility access to read meeting UI controls. Open System Settings → Privacy & Security → Accessibility, then enable LuxaforPresence (or Terminal/Xcode if running from there)." + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() + logger.info("Prompted for Accessibility access") + } + @objc private func forceOn() { engine.force(.inMeeting) } @objc private func forceOff() { engine.force(.notMeeting) } @objc private func openPrefs() { /* simple NSAlert or NSPanel for userId etc. */ } diff --git a/LuxaforPresence/Info.plist b/LuxaforPresence/Info.plist index 0e73c77..339c864 100644 --- a/LuxaforPresence/Info.plist +++ b/LuxaforPresence/Info.plist @@ -7,9 +7,9 @@ CFBundleName LuxaforPresence CFBundleVersion - 1 + 2 CFBundleShortVersionString - 1.0 + 1.5.0 LSUIElement NSCalendarsUsageDescription diff --git a/LuxaforPresence/Model/PresenceState.swift b/LuxaforPresence/Model/PresenceState.swift index 2ef66d0..d5dd03a 100644 --- a/LuxaforPresence/Model/PresenceState.swift +++ b/LuxaforPresence/Model/PresenceState.swift @@ -1,5 +1,6 @@ enum PresenceState: String { case inMeeting = "ON (Red)" + case inMeetingSilent = "ON (Yellow)" case notMeeting = "OFF" case unknown = "Unknown" } diff --git a/LuxaforPresence/PresenceEngine.swift b/LuxaforPresence/PresenceEngine.swift index a6da40f..7c2456c 100644 --- a/LuxaforPresence/PresenceEngine.swift +++ b/LuxaforPresence/PresenceEngine.swift @@ -8,6 +8,10 @@ final class PresenceEngine { var meetingBundles: Set var useCalendar: Bool var debugAssumeFrontmostImpliesMic: Bool + var enabledMeetingDetectors: Set? + var vadEnabled: Bool + var vadThreshold: Double + var vadGraceSeconds: TimeInterval private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "Config") init() { @@ -25,6 +29,10 @@ final class PresenceEngine { ] useCalendar = false debugAssumeFrontmostImpliesMic = false + enabledMeetingDetectors = nil + vadEnabled = true + vadThreshold = 0.02 + vadGraceSeconds = 10 // Try to load from user's config directory first let appSupportURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("LuxaforPresence/config.plist") @@ -49,6 +57,22 @@ final class PresenceEngine { if let debugFlag = userConfig["debugAssumeFrontmostImpliesMic"] as? Bool { debugAssumeFrontmostImpliesMic = debugFlag } + if let detectors = userConfig["enabledMeetingDetectors"] as? [String] { + enabledMeetingDetectors = Set(detectors) + } + if let vadFlag = userConfig["vadEnabled"] as? Bool { + vadEnabled = vadFlag + } + if let threshold = userConfig["vadThreshold"] as? Double { + vadThreshold = threshold + } else if let threshold = userConfig["vadThreshold"] as? NSNumber { + vadThreshold = threshold.doubleValue + } + if let grace = userConfig["vadGraceSeconds"] as? TimeInterval { + vadGraceSeconds = grace + } else if let grace = userConfig["vadGraceSeconds"] as? NSNumber { + vadGraceSeconds = grace.doubleValue + } } else if let bundledConfigURL = Bundle.main.url(forResource: "config", withExtension: "plist"), let bundledConfig = NSDictionary(contentsOf: bundledConfigURL) as? [String: Any] { logger.log("Loaded config from bundled resource at \(bundledConfigURL.path, privacy: .public)") @@ -68,6 +92,22 @@ final class PresenceEngine { if let debugFlag = bundledConfig["debugAssumeFrontmostImpliesMic"] as? Bool { debugAssumeFrontmostImpliesMic = debugFlag } + if let detectors = bundledConfig["enabledMeetingDetectors"] as? [String] { + enabledMeetingDetectors = Set(detectors) + } + if let vadFlag = bundledConfig["vadEnabled"] as? Bool { + vadEnabled = vadFlag + } + if let threshold = bundledConfig["vadThreshold"] as? Double { + vadThreshold = threshold + } else if let threshold = bundledConfig["vadThreshold"] as? NSNumber { + vadThreshold = threshold.doubleValue + } + if let grace = bundledConfig["vadGraceSeconds"] as? TimeInterval { + vadGraceSeconds = grace + } else if let grace = bundledConfig["vadGraceSeconds"] as? NSNumber { + vadGraceSeconds = grace.doubleValue + } } else { logger.error("No config file found; using default hard-coded values") } @@ -75,7 +115,12 @@ final class PresenceEngine { let finalizedBundleCount = meetingBundles.count let finalizedUseCalendar = useCalendar let finalizedDebugFlag = debugAssumeFrontmostImpliesMic - logger.log("Config initialized: pollInterval \(finalizedPollInterval, privacy: .public)s, meeting bundles count \(finalizedBundleCount, privacy: .public), useCalendar \(finalizedUseCalendar, privacy: .public), debugAssumeFrontmostImpliesMic \(finalizedDebugFlag)") + let finalizedMeetingDetectorCount = enabledMeetingDetectors?.count ?? 0 + let meetingDetectorMode = enabledMeetingDetectors == nil ? "all" : "custom" + let finalizedVadEnabled = vadEnabled + let finalizedVadThreshold = vadThreshold + let finalizedVadGrace = vadGraceSeconds + logger.log("Config initialized: pollInterval \(finalizedPollInterval, privacy: .public)s, meeting bundles count \(finalizedBundleCount, privacy: .public), useCalendar \(finalizedUseCalendar, privacy: .public), debugAssumeFrontmostImpliesMic \(finalizedDebugFlag), meeting detectors \(meetingDetectorMode, privacy: .public) count \(finalizedMeetingDetectorCount, privacy: .public), vadEnabled \(finalizedVadEnabled, privacy: .public), vadThreshold \(finalizedVadThreshold, privacy: .public), vadGraceSeconds \(finalizedVadGrace, privacy: .public)") } } @@ -85,7 +130,10 @@ final class PresenceEngine { private let micCam: MicCamSignalProtocol private let frontApp: FrontmostAppSignalProtocol private let calendar: CalendarSignalProtocol + private let meetingDetector: MeetingDetectorProtocol + private let voiceActivity: VoiceActivitySignalProtocol private let luxafor: LuxaforClientProtocol + private let now: () -> Date private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "PresenceEngine") private var lastState: PresenceState = .unknown private var forcedState: PresenceState? @@ -95,17 +143,28 @@ final class PresenceEngine { micCam: MicCamSignalProtocol = MicCamSignal(), frontApp: FrontmostAppSignalProtocol = FrontmostAppSignal(), calendar: CalendarSignalProtocol = CalendarSignal(), - luxafor: LuxaforClientProtocol = LuxaforClient() + meetingDetector: MeetingDetectorProtocol? = nil, + voiceActivity: VoiceActivitySignalProtocol? = nil, + luxafor: LuxaforClientProtocol = LuxaforClient(), + now: @escaping () -> Date = Date.init ) { self.config = config self.micCam = micCam self.frontApp = frontApp self.calendar = calendar + self.meetingDetector = meetingDetector ?? MeetingDetector(enabledNames: config.enabledMeetingDetectors) + self.voiceActivity = voiceActivity ?? VoiceActivitySignal(threshold: config.vadThreshold) self.luxafor = luxafor + self.now = now } func prepare() { micCam.requestAccessIfNeeded() + if config.vadEnabled { + voiceActivity.requestAccessIfNeeded() + } else { + logger.debug("VAD disabled in config; skipping audio access request") + } guard config.useCalendar else { logger.debug("Calendar disabled in config; skipping access request") @@ -134,19 +193,52 @@ final class PresenceEngine { return } - let isMeetingApp = frontApp.isFrontmostIn(allowlist: config.meetingBundles) - let debugForcingMic = config.debugAssumeFrontmostImpliesMic && isMeetingApp - let micOrCam = debugForcingMic ? true : micCam.anyInUse() - if debugForcingMic { - logger.debug("Debug flag forcing mic/cam true because frontmost app is allowlisted") + logger.debug("Evaluating meeting detectors") + let detectorMeetingActive = meetingDetector.isMeetingActive() + logger.debug("Meeting detector evaluation complete; active=\(detectorMeetingActive)") + let frontmostIsMeetingApp = frontApp.isFrontmostIn(allowlist: config.meetingBundles) + let debugForcingMeeting = config.debugAssumeFrontmostImpliesMic && frontmostIsMeetingApp + if debugForcingMeeting { + logger.debug("Debug flag forcing meeting active because frontmost app is allowlisted") } - var eventOK = false - if config.useCalendar { eventOK = calendar.hasOngoingMeetingEvent() } + let calendarMeetingActive = config.useCalendar ? calendar.hasOngoingMeetingEvent() : false + let meetingActive = detectorMeetingActive || calendarMeetingActive || debugForcingMeeting + let cameraActive = micCam.isCameraInUse() + let micActive = micCam.isMicrophoneInUse() + let voiceActive = config.vadEnabled ? voiceActivity.isVoiceActive() : false + let lastVoiceActivityDate = config.vadEnabled ? voiceActivity.lastVoiceActivityDate : nil + let now = now() + let secondsSinceVoiceActivity = lastVoiceActivityDate.map { now.timeIntervalSince($0) } + let withinGrace = config.vadEnabled ? (secondsSinceVoiceActivity.map { $0 <= config.vadGraceSeconds } ?? false) : false - let newState: PresenceState = - (micOrCam && (isMeetingApp || eventOK)) ? .inMeeting : .notMeeting + let newState: PresenceState + let decisionPath: String + if cameraActive { + newState = .inMeeting + decisionPath = "cameraActive" + } else if meetingActive { + if !config.vadEnabled { + newState = .inMeeting + decisionPath = "meeting+vadDisabled" + } else if voiceActive { + newState = .inMeeting + decisionPath = "meeting+voiceActive" + } else if withinGrace { + newState = .inMeeting + decisionPath = "meeting+vadGrace" + } else { + newState = .inMeetingSilent + decisionPath = "meeting+vadSilent" + } + } else { + newState = .notMeeting + decisionPath = "noMeeting" + } - logger.debug("Signals -> mic/cam: \(micOrCam), frontmost meeting: \(isMeetingApp), calendar: \(eventOK)") + logger.debug( + "Signals -> meeting detector: \(detectorMeetingActive), calendar: \(calendarMeetingActive), debug frontmost: \(debugForcingMeeting), camera: \(cameraActive), mic: \(micActive), vadEnabled: \(self.config.vadEnabled), voiceActive: \(voiceActive), secondsSinceVoiceActivity: \(String(describing: secondsSinceVoiceActivity))" + ) + logger.debug("Decision path: \(decisionPath, privacy: .public)") logger.log("Proposed state \(newState.rawValue, privacy: .public) (previous \(self.lastState.rawValue, privacy: .public))") if newState != lastState { @@ -162,6 +254,7 @@ final class PresenceEngine { logger.log("Applying state \(state.rawValue, privacy: .public)") switch state { case .inMeeting: luxafor.turnOnRed(userId: config.userId) + case .inMeetingSilent: luxafor.turnOnYellow(userId: config.userId) case .notMeeting: luxafor.turnOff(userId: config.userId) case .unknown: break } diff --git a/LuxaforPresence/Resources/config.plist b/LuxaforPresence/Resources/config.plist index 7f0ce5a..b654ac1 100644 --- a/LuxaforPresence/Resources/config.plist +++ b/LuxaforPresence/Resources/config.plist @@ -20,5 +20,19 @@ debugAssumeFrontmostImpliesMic + enabledMeetingDetectors + + Zoom + Webex + Teams + Slack + GoogleMeet + + vadEnabled + + vadThreshold + 0.02 + vadGraceSeconds + 10 diff --git a/LuxaforPresence/Signals/AccessibilitySnapshot.swift b/LuxaforPresence/Signals/AccessibilitySnapshot.swift new file mode 100644 index 0000000..610d871 --- /dev/null +++ b/LuxaforPresence/Signals/AccessibilitySnapshot.swift @@ -0,0 +1,251 @@ +import AppKit +import ApplicationServices +import Foundation +import OSLog + +struct AXNodeSnapshot: Equatable { + let role: String? + let roleDescription: String? + let label: String? + let placeholder: String? + let domIdentifier: String? +} + +protocol AXSnapshotProviding { + func snapshot(bundleIdentifiers: [String], processNames: [String]) -> [AXNodeSnapshot]? +} + +final class AccessibilitySnapshotProvider: AXSnapshotProviding { + private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "AccessibilitySnapshot") + private let maxDepth: Int + private let maxNodes: Int + + init(maxDepth: Int = 36, maxNodes: Int = 3600) { + self.maxDepth = maxDepth + self.maxNodes = maxNodes + } + + func snapshot(bundleIdentifiers: [String], processNames: [String]) -> [AXNodeSnapshot]? { + guard AXIsProcessTrusted() else { + logger.info("AX snapshot unavailable: not trusted") + return nil + } + guard let app = runningApplication(bundleIdentifiers: bundleIdentifiers, processNames: processNames) else { + logger.debug("AX snapshot: no running app for bundles=\(bundleIdentifiers, privacy: .public) names=\(processNames, privacy: .public)") + return [] + } + logger.debug( + "AX snapshot: app bundle=\(app.bundleIdentifier ?? "unknown", privacy: .public) name=\(app.localizedName ?? "unknown", privacy: .public) pid=\(app.processIdentifier, privacy: .public)" + ) + + let root = AXUIElementCreateApplication(app.processIdentifier) + var queue: [(AXUIElement, Int)] = [(root, 0)] + var nodes: [AXNodeSnapshot] = [] + var maxDepthVisited = 0 + var maxNodesHit = false + var maxDepthHit = false + var dequeuedCount = 0 + var appendedCount = 0 + var roleCount = 0 + var roleDescriptionCount = 0 + var labelCount = 0 + var placeholderCount = 0 + var domIdentifierCount = 0 + var attributeErrorCounts: [String: Int] = [:] + var attributeEmptyCounts: [String: Int] = [:] + var attributeNonStringCounts: [String: Int] = [:] + var childrenFetchErrorCount = 0 + var childrenNonArrayCount = 0 + var childBucketZero = 0 + var childBucketSmall = 0 + var childBucketMedium = 0 + var childBucketLarge = 0 + + while let (element, depth) = queue.first { + queue.removeFirst() + dequeuedCount += 1 + if nodes.count >= maxNodes { + maxNodesHit = true + break + } + if depth > maxDepthVisited { + maxDepthVisited = depth + } + + let role = stringAttribute( + element, + kAXRoleAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) + let roleDescription = stringAttribute( + element, + kAXRoleDescriptionAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) + var label = stringAttribute( + element, + kAXLabelValueAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) + if (label == nil || label?.isEmpty == true), role != (kAXWindowRole as String) { + let title = stringAttribute( + element, + kAXTitleAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) + if let title, !title.isEmpty { + label = title + } + } + let placeholder = stringAttribute( + element, + kAXPlaceholderValueAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) + let domIdentifier = stringAttribute( + element, + kAXDOMIdentifierAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) + if role != nil { roleCount += 1 } + if roleDescription != nil { roleDescriptionCount += 1 } + if label != nil { labelCount += 1 } + if placeholder != nil { placeholderCount += 1 } + if domIdentifier != nil { domIdentifierCount += 1 } + + nodes.append( + AXNodeSnapshot( + role: role, + roleDescription: roleDescription, + label: label, + placeholder: placeholder, + domIdentifier: domIdentifier + ) + ) + + let children = children( + of: element, + fetchErrorCount: &childrenFetchErrorCount, + nonArrayCount: &childrenNonArrayCount + ) + let childCount = children?.count ?? 0 + if childCount == 0 { + childBucketZero += 1 + } else if childCount <= 3 { + childBucketSmall += 1 + } else if childCount <= 10 { + childBucketMedium += 1 + } else { + childBucketLarge += 1 + } + + if depth < maxDepth { + if let children, !children.isEmpty { + appendedCount += children.count + children.forEach { queue.append(($0, depth + 1)) } + } + } else if let children, !children.isEmpty { + maxDepthHit = true + } + } + + logger.debug( + "AX snapshot summary: nodes=\(nodes.count) dequeued=\(dequeuedCount) appended=\(appendedCount) maxDepthVisited=\(maxDepthVisited) maxDepth=\(self.maxDepth) maxDepthHit=\(maxDepthHit) maxNodes=\(self.maxNodes) maxNodesHit=\(maxNodesHit) role=\(roleCount) roleDesc=\(roleDescriptionCount) label=\(labelCount) placeholder=\(placeholderCount) domId=\(domIdentifierCount)" + ) + logger.debug( + "AX snapshot attributes: errors=\(self.formatCounts(attributeErrorCounts), privacy: .public) empty=\(self.formatCounts(attributeEmptyCounts), privacy: .public) nonString=\(self.formatCounts(attributeNonStringCounts), privacy: .public)" + ) + logger.debug( + "AX snapshot children: zero=\(childBucketZero) small=\(childBucketSmall) medium=\(childBucketMedium) large=\(childBucketLarge) fetchErrors=\(childrenFetchErrorCount) nonArray=\(childrenNonArrayCount)" + ) + return nodes + } + + private func runningApplication(bundleIdentifiers: [String], processNames: [String]) -> NSRunningApplication? { + let normalizedBundles = Set(bundleIdentifiers.map { $0.lowercased() }) + let normalizedNames = Set(processNames.map { $0.lowercased() }) + return NSWorkspace.shared.runningApplications.first { app in + if let bundle = app.bundleIdentifier?.lowercased(), normalizedBundles.contains(bundle) { + return true + } + if let name = app.localizedName?.lowercased(), normalizedNames.contains(name) { + return true + } + if let exe = app.executableURL?.lastPathComponent.lowercased(), normalizedNames.contains(exe) { + return true + } + return false + } + } + + private func stringAttribute( + _ element: AXUIElement, + _ attribute: String, + errorCounts: inout [String: Int], + emptyCounts: inout [String: Int], + nonStringCounts: inout [String: Int] + ) -> String? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, attribute as CFString, &value) + guard error == .success else { + errorCounts[attribute, default: 0] += 1 + return nil + } + if let attributedValue = value as? NSAttributedString { + let stringValue = attributedValue.string + if stringValue.isEmpty { + emptyCounts[attribute, default: 0] += 1 + } + return stringValue + } + guard let stringValue = value as? String else { + nonStringCounts[attribute, default: 0] += 1 + return nil + } + if stringValue.isEmpty { + emptyCounts[attribute, default: 0] += 1 + } + return stringValue + } + + private func children( + of element: AXUIElement, + fetchErrorCount: inout Int, + nonArrayCount: inout Int + ) -> [AXUIElement]? { + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) + guard error == .success else { + fetchErrorCount += 1 + return nil + } + if let children = value as? [AXUIElement] { + return children + } + if let array = value as? [Any] { + return array.map { $0 as! AXUIElement } + } + nonArrayCount += 1 + return nil + } + + private func formatCounts(_ counts: [String: Int]) -> String { + guard !counts.isEmpty else { return "none" } + return counts + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: ",") + } +} diff --git a/LuxaforPresence/Signals/MeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetector.swift new file mode 100644 index 0000000..4d072d0 --- /dev/null +++ b/LuxaforPresence/Signals/MeetingDetector.swift @@ -0,0 +1,28 @@ +import Foundation + +final class MeetingDetector: MeetingDetectorProtocol { + private let detectors: [MeetingDetectorProtocol] + + init( + detectors: [MeetingDetectorProtocol] = [ + ZoomMeetingDetector(), + WebexMeetingDetector(), + TeamsMeetingDetector(), + SlackMeetingDetector(), + GoogleMeetDetector(), + ], + enabledNames: Set? = nil + ) { + if let enabledNames { + self.detectors = detectors.filter { enabledNames.contains($0.name) } + } else { + self.detectors = detectors + } + } + + var name: String { "Aggregate" } + + func isMeetingActive() -> Bool { + detectors.contains { $0.isMeetingActive() } + } +} diff --git a/LuxaforPresence/Signals/MeetingDetectors/GoogleMeetDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/GoogleMeetDetector.swift new file mode 100644 index 0000000..4d5ecb7 --- /dev/null +++ b/LuxaforPresence/Signals/MeetingDetectors/GoogleMeetDetector.swift @@ -0,0 +1,61 @@ +import AppKit +import Foundation + +final class GoogleMeetDetector: MeetingDetectorProtocol { + var name: String { "GoogleMeet" } + + func isMeetingActive() -> Bool { + guard isBrowserRunning else { return false } + return chromeMeetActive() || safariMeetActive() + } + + private var isBrowserRunning: Bool { + ProcessSignal.isRunning(executableNames: ["Google Chrome", "Safari"]) + } + + private func chromeMeetActive() -> Bool { + let script = """ + tell application "Google Chrome" + if it is running then + repeat with w in windows + repeat with t in tabs of w + if (URL of t contains "meet.google.com") then + if (audible of t is true) then return true + end if + end repeat + end repeat + end if + end tell + return false + """ + return runAppleScriptReturningBool(script) + } + + private func safariMeetActive() -> Bool { + let script = """ + tell application "Safari" + if it is running then + repeat with w in windows + repeat with t in tabs of w + if (URL of t contains "meet.google.com") then + if (audible of t is true) then return true + end if + end repeat + end repeat + end if + end tell + return false + """ + return runAppleScriptReturningBool(script) + } + + private func runAppleScriptReturningBool(_ source: String) -> Bool { + guard let script = NSAppleScript(source: source) else { return false } + var error: NSDictionary? + let result = script.executeAndReturnError(&error) + if error != nil { + return false + } + return result.booleanValue + } +} diff --git a/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift new file mode 100644 index 0000000..61350a3 --- /dev/null +++ b/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift @@ -0,0 +1,74 @@ +import Foundation +import OSLog + +final class SlackMeetingDetector: MeetingDetectorProtocol { + private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "SlackMeetingDetector") + private let snapshotProvider: AXSnapshotProviding + private let isProcessRunning: ([String]) -> Bool + private let processNames = ["Slack"] + private let bundleIdentifiers = ["com.tinyspeck.slackmacgap"] + private let huddleAnchorPrefix = "Huddle:" + private let huddleToolbarLabel = "Huddles actions" + private let huddleControlLabels = [ + "Share your screen", + "More actions", + "View members", + ] + private let huddleControlRoles = [ + "AXCheckBox", + "AXPopUpButton", + "AXButton", + ] + + var name: String { "Slack" } + + init( + snapshotProvider: AXSnapshotProviding = AccessibilitySnapshotProvider(), + isProcessRunning: @escaping ([String]) -> Bool = ProcessSignal.isRunning + ) { + self.snapshotProvider = snapshotProvider + self.isProcessRunning = isProcessRunning + } + + func isMeetingActive() -> Bool { + // Slack huddle detection uses AX-only, privacy-safe signals from the huddle control strip. + guard isProcessRunning(processNames) else { + logger.debug("Slack process not running") + return false + } + guard let nodes = snapshotProvider.snapshot(bundleIdentifiers: bundleIdentifiers, processNames: processNames) else { + logger.debug("AX snapshot unavailable (not authorized or failed)") + return false + } + + let anchorFound = nodes.contains { node in + if let label = node.label, label.hasPrefix(huddleAnchorPrefix) { + return true + } + if let label = node.label, label == huddleToolbarLabel { + return true + } + return false + } + if !anchorFound { + logger.debug("Slack AX snapshot: nodes=\(nodes.count) anchorFound=false") + return false + } + + var matchedControls = Set() + for node in nodes { + guard let label = node.label, let role = node.role else { continue } + guard huddleControlRoles.contains(role) else { continue } + let normalizedLabel = label.trimmingCharacters(in: .whitespacesAndNewlines) + if huddleControlLabels.contains(where: { normalizedLabel.localizedCaseInsensitiveContains($0) }) { + matchedControls.insert(normalizedLabel) + } + } + logger.debug("Slack AX snapshot: nodes=\(nodes.count) anchorFound=true matchedControls=\(matchedControls.sorted(), privacy: .public)") + + if matchedControls.isEmpty { + logger.debug("Slack huddle detected via anchor (no control matches)") + } + return true + } +} diff --git a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift new file mode 100644 index 0000000..891b1b0 --- /dev/null +++ b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift @@ -0,0 +1,84 @@ +import Foundation +import OSLog + +final class TeamsMeetingDetector: MeetingDetectorProtocol { + private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "TeamsMeetingDetector") + private let snapshotProvider: AXSnapshotProviding + private let isProcessRunning: ([String]) -> Bool + private let processNames = ["Microsoft Teams", "Teams"] + private let bundleIdentifiers = ["com.microsoft.teams2", "com.microsoft.teams"] + private let domIdentifiers: Set = [ + "microphone-button", + "video-button", + "share-button", + "hangup-button", + ] + private let toolbarLabels: Set = [ + "Calling controls", + "Meeting controls", + ] + + var name: String { "Teams" } + + init( + snapshotProvider: AXSnapshotProviding = AccessibilitySnapshotProvider(), + isProcessRunning: @escaping ([String]) -> Bool = ProcessSignal.isRunning + ) { + self.snapshotProvider = snapshotProvider + self.isProcessRunning = isProcessRunning + } + + func isMeetingActive() -> Bool { + // Teams meeting detection uses AX-only, privacy-safe signals from call controls. + guard isProcessRunning(processNames) else { + logger.debug("Teams process not running (names=\(self.processNames, privacy: .public))") + return false + } + guard let nodes = snapshotProvider.snapshot(bundleIdentifiers: bundleIdentifiers, processNames: processNames) else { + logger.debug("AX snapshot unavailable (not authorized or failed)") + return false + } + if nodes.isEmpty { + logger.debug("Teams AX snapshot empty (process running)") + } + + var matchedDomIdentifiers = Set() + var matchedToolbarLabels = Set() + var roleCount = 0 + var labelCount = 0 + var domIdentifierCount = 0 + var toolbarRoleCount = 0 + + for node in nodes { + if node.role != nil { roleCount += 1 } + if node.label != nil { labelCount += 1 } + if node.domIdentifier != nil { domIdentifierCount += 1 } + if let domIdentifier = node.domIdentifier, domIdentifiers.contains(domIdentifier) { + matchedDomIdentifiers.insert(domIdentifier) + } + if let role = node.role, role == "AXToolbar" { + toolbarRoleCount += 1 + } + if let label = node.label, toolbarLabels.contains(label) { + matchedToolbarLabels.insert(label) + } + } + + logger.debug( + "Teams AX snapshot: nodes=\(nodes.count) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public)" + ) + + if !matchedDomIdentifiers.isEmpty { + logger.debug("Teams meeting detected via DOM identifiers") + return true + } + + if !matchedToolbarLabels.isEmpty { + logger.debug("Teams meeting detected via toolbar label fallback") + return true + } + + logger.debug("Teams meeting not detected (no matching AX controls)") + return false + } +} diff --git a/LuxaforPresence/Signals/MeetingDetectors/WebexMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/WebexMeetingDetector.swift new file mode 100644 index 0000000..8a4d0fd --- /dev/null +++ b/LuxaforPresence/Signals/MeetingDetectors/WebexMeetingDetector.swift @@ -0,0 +1,9 @@ +import Foundation + +final class WebexMeetingDetector: MeetingDetectorProtocol { + var name: String { "Webex" } + + func isMeetingActive() -> Bool { + ProcessSignal.isRunning(executableNames: ["WebexAppLauncher"]) + } +} diff --git a/LuxaforPresence/Signals/MeetingDetectors/ZoomMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/ZoomMeetingDetector.swift new file mode 100644 index 0000000..b9a0741 --- /dev/null +++ b/LuxaforPresence/Signals/MeetingDetectors/ZoomMeetingDetector.swift @@ -0,0 +1,9 @@ +import Foundation + +final class ZoomMeetingDetector: MeetingDetectorProtocol { + var name: String { "Zoom" } + + func isMeetingActive() -> Bool { + ProcessSignal.isRunning(executableNames: ["CptHost"]) + } +} diff --git a/LuxaforPresence/Signals/MicCamSignal.swift b/LuxaforPresence/Signals/MicCamSignal.swift index fbe5c76..b6f730e 100644 --- a/LuxaforPresence/Signals/MicCamSignal.swift +++ b/LuxaforPresence/Signals/MicCamSignal.swift @@ -12,24 +12,13 @@ final class MicCamSignal: MicCamSignalProtocol { requestAccess(for: .video) } - func anyInUse() -> Bool { + func isMicrophoneInUse() -> Bool { let audioDevices = captureDevices(for: .audio) - let videoDevices = captureDevices(for: .video) - let audioInUse = audioDevices.contains { $0.isInUseByAnotherApplication } - let videoInUse = videoDevices.contains { $0.isInUseByAnotherApplication } let coreAudio = coreAudioSnapshot() - let cmio = cmioSnapshot(matchingVideoUIDs: Set(videoDevices.map { $0.uniqueID })) audioDevices.forEach { device in logger.debug("Audio device \(device.localizedName, privacy: .public) busy? \(device.isInUseByAnotherApplication)") } - videoDevices.forEach { device in - logger.debug("Video device \(device.localizedName, privacy: .public) busy? \(device.isInUseByAnotherApplication)") - } - cmio.statuses.forEach { status in - logger.debug("CMIO device \(status.name, privacy: .public) [\(status.id)] uid \(status.uid, privacy: .public) running? \(status.isRunning)") - } - if let defaultName = coreAudio.defaultDeviceName, let defaultID = coreAudio.defaultDeviceID { logger.debug("HAL default input \(defaultName, privacy: .public) [\(defaultID)] running? \(coreAudio.defaultRunning)") } else { @@ -42,9 +31,29 @@ final class MicCamSignal: MicCamSignalProtocol { ) } + let audioInUse = audioDevices.contains { $0.isInUseByAnotherApplication } let halRunning = coreAudio.statuses.contains { $0.hasInput && $0.isRunning } + return audioInUse || halRunning + } + + func isCameraInUse() -> Bool { + let videoDevices = captureDevices(for: .video) + let cmio = cmioSnapshot(matchingVideoUIDs: Set(videoDevices.map { $0.uniqueID })) + + videoDevices.forEach { device in + logger.debug("Video device \(device.localizedName, privacy: .public) busy? \(device.isInUseByAnotherApplication)") + } + cmio.statuses.forEach { status in + logger.debug("CMIO device \(status.name, privacy: .public) [\(status.id)] uid \(status.uid, privacy: .public) running? \(status.isRunning)") + } + + let videoInUse = videoDevices.contains { $0.isInUseByAnotherApplication } let cmioRunning = cmio.statuses.contains { $0.isRunning } - return audioInUse || videoInUse || halRunning || cmioRunning + return videoInUse || cmioRunning + } + + func anyInUse() -> Bool { + isMicrophoneInUse() || isCameraInUse() } private func coreAudioSnapshot() -> CoreAudioSnapshot { diff --git a/LuxaforPresence/Signals/ProcessSignal.swift b/LuxaforPresence/Signals/ProcessSignal.swift new file mode 100644 index 0000000..ba13254 --- /dev/null +++ b/LuxaforPresence/Signals/ProcessSignal.swift @@ -0,0 +1,20 @@ +import AppKit + +enum ProcessSignal { + static func isRunning(executableNames: [String]) -> Bool { + guard !executableNames.isEmpty else { return false } + let normalized = Set(executableNames.map { $0.lowercased() }) + return NSWorkspace.shared.runningApplications.contains { app in + if let name = app.localizedName?.lowercased(), normalized.contains(name) { + return true + } + if let exe = app.executableURL?.lastPathComponent.lowercased(), normalized.contains(exe) { + return true + } + if let bundle = app.bundleIdentifier?.lowercased(), normalized.contains(bundle) { + return true + } + return false + } + } +} diff --git a/LuxaforPresence/Signals/SignalProtocols.swift b/LuxaforPresence/Signals/SignalProtocols.swift index e8e819f..3526dd3 100644 --- a/LuxaforPresence/Signals/SignalProtocols.swift +++ b/LuxaforPresence/Signals/SignalProtocols.swift @@ -2,6 +2,8 @@ import Foundation protocol MicCamSignalProtocol { func requestAccessIfNeeded() + func isMicrophoneInUse() -> Bool + func isCameraInUse() -> Bool func anyInUse() -> Bool } @@ -13,3 +15,8 @@ protocol CalendarSignalProtocol { func requestAccess(completion: @escaping (Bool) -> Void) func hasOngoingMeetingEvent() -> Bool } + +protocol MeetingDetectorProtocol { + var name: String { get } + func isMeetingActive() -> Bool +} diff --git a/LuxaforPresence/Signals/VoiceActivitySignal.swift b/LuxaforPresence/Signals/VoiceActivitySignal.swift new file mode 100644 index 0000000..a5598dc --- /dev/null +++ b/LuxaforPresence/Signals/VoiceActivitySignal.swift @@ -0,0 +1,104 @@ +import AVFoundation +import Foundation +import OSLog + +protocol VoiceActivitySignalProtocol { + func requestAccessIfNeeded() + func isVoiceActive() -> Bool + var lastVoiceActivityDate: Date? { get } +} + +final class VoiceActivitySignal: VoiceActivitySignalProtocol { + private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "VoiceActivitySignal") + private let engine = AVAudioEngine() + private let threshold: Double + private let stateLock = NSLock() + private var voiceActive = false + private var lastActivity: Date? + private var started = false + + init(threshold: Double = 0.02) { + self.threshold = threshold + } + + var lastVoiceActivityDate: Date? { + stateLock.lock() + defer { stateLock.unlock() } + return lastActivity + } + + func isVoiceActive() -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + return voiceActive + } + + func requestAccessIfNeeded() { + let status = AVCaptureDevice.authorizationStatus(for: .audio) + switch status { + case .authorized: + startIfNeeded() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in + if granted { + self?.logger.log("Microphone access granted for VAD") + self?.startIfNeeded() + } else { + self?.logger.error("Microphone access denied; VAD disabled") + } + } + case .denied, .restricted: + logger.error("Microphone access denied or restricted; VAD disabled") + @unknown default: + logger.error("Unknown microphone authorization status \(status.rawValue, privacy: .public)") + } + } + + private func startIfNeeded() { + guard !started else { return } + started = true + let input = engine.inputNode + let format = input.outputFormat(forBus: 0) + input.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in + self?.process(buffer: buffer) + } + do { + try engine.start() + logger.log("Voice activity engine started") + } catch { + logger.error("Failed to start VAD engine: \(error.localizedDescription, privacy: .public)") + } + } + + private func process(buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { return } + let frameLength = Int(buffer.frameLength) + let channelCount = Int(buffer.format.channelCount) + guard frameLength > 0, channelCount > 0 else { return } + + var totalEnergy: Float = 0 + for channel in 0..= threshold + let now = Date() + + stateLock.lock() + voiceActive = isActive + if isActive { + lastActivity = now + } + stateLock.unlock() + } +} diff --git a/LuxaforPresence/Tests/PresenceEngineTests.swift b/LuxaforPresence/Tests/PresenceEngineTests.swift index a7123d5..fc72a45 100644 --- a/LuxaforPresence/Tests/PresenceEngineTests.swift +++ b/LuxaforPresence/Tests/PresenceEngineTests.swift @@ -2,64 +2,310 @@ import XCTest @testable import LuxaforPresence final class PresenceEngineTests: XCTestCase { - func testTick_transitionsToInMeeting_whenMicAndFrontAppTrue() { + func testTick_transitionsToInMeeting_whenMeetingDetectorActive_andVoiceActive() { var config = PresenceEngine.Config() config.useCalendar = false let mic = FakeMicCamSignal() - mic.nextValue = true + mic.nextMic = true let front = FakeFrontmostAppSignal() - front.isMeetingApp = true let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = true + let voiceActivity = FakeVoiceActivitySignal() + voiceActivity.active = true let lux = FakeLuxaforClient() - let engine = PresenceEngine(config: config, micCam: mic, frontApp: front, calendar: calendar, luxafor: lux) + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + voiceActivity: voiceActivity, + luxafor: lux + ) engine.tick() XCTAssertEqual(lux.actions, [.on(config.userId)]) } - func testTick_usesDebugFlagToAssumeMicActivity() { + func testTick_staysNotMeeting_whenMeetingDetectorInactive_evenIfMicActive() { + var config = PresenceEngine.Config() + config.useCalendar = false + let mic = FakeMicCamSignal() + mic.nextMic = true + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = false + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + luxafor: lux + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.off(config.userId)]) + } + + func testTick_transitionsToInMeeting_whenCameraActive_evenIfMeetingDetectorInactive() { + var config = PresenceEngine.Config() + config.useCalendar = false + let mic = FakeMicCamSignal() + mic.nextCamera = true + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = false + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + luxafor: lux + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.on(config.userId)]) + } + + func testTick_transitionsToInMeeting_whenCalendarActive_evenIfMeetingDetectorInactive() { + var config = PresenceEngine.Config() + config.useCalendar = true + config.vadEnabled = false + let mic = FakeMicCamSignal() + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + calendar.ongoingMeeting = true + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = false + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + luxafor: lux + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.on(config.userId)]) + } + + func testTick_usesDebugFlagToAssumeMeetingWhenFrontmostAllowlisted() { var config = PresenceEngine.Config() config.useCalendar = false config.debugAssumeFrontmostImpliesMic = true + config.vadEnabled = false let mic = FakeMicCamSignal() - mic.nextValue = false let front = FakeFrontmostAppSignal() front.isMeetingApp = true let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = false let lux = FakeLuxaforClient() - let engine = PresenceEngine(config: config, micCam: mic, frontApp: front, calendar: calendar, luxafor: lux) + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + luxafor: lux + ) engine.tick() XCTAssertEqual(lux.actions, [.on(config.userId)]) } + func testTick_frontmostAloneDoesNotTriggerMeetingWithoutDebug() { + var config = PresenceEngine.Config() + config.useCalendar = false + let mic = FakeMicCamSignal() + let front = FakeFrontmostAppSignal() + front.isMeetingApp = true + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = false + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + luxafor: lux + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.off(config.userId)]) + } + func testForceBypassesSignalsUntilChanged() { var config = PresenceEngine.Config() let mic = FakeMicCamSignal() - mic.nextValue = true + mic.nextMic = true let front = FakeFrontmostAppSignal() front.isMeetingApp = true let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = true + let voiceActivity = FakeVoiceActivitySignal() + voiceActivity.active = true let lux = FakeLuxaforClient() - let engine = PresenceEngine(config: config, micCam: mic, frontApp: front, calendar: calendar, luxafor: lux) + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + voiceActivity: voiceActivity, + luxafor: lux + ) engine.force(.notMeeting) - mic.nextValue = true + mic.nextMic = true front.isMeetingApp = true engine.tick() XCTAssertEqual(lux.actions, [.off(config.userId), .off(config.userId)]) } + + func testTick_meetingActiveAndVadSilentBeyondGrace_turnsYellow() { + var config = PresenceEngine.Config() + config.useCalendar = false + config.vadGraceSeconds = 10 + let mic = FakeMicCamSignal() + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = true + let voiceActivity = FakeVoiceActivitySignal() + voiceActivity.active = false + let fixedNow = Date() + voiceActivity.lastActivityDate = fixedNow.addingTimeInterval(-11) + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + voiceActivity: voiceActivity, + luxafor: lux, + now: { fixedNow } + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.yellow(config.userId)]) + } + + func testTick_meetingActiveAndVadActive_turnsRed() { + var config = PresenceEngine.Config() + config.useCalendar = false + let mic = FakeMicCamSignal() + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = true + let voiceActivity = FakeVoiceActivitySignal() + voiceActivity.active = true + voiceActivity.lastActivityDate = Date() + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + voiceActivity: voiceActivity, + luxafor: lux + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.on(config.userId)]) + } + + func testTick_meetingActiveAndVadSilentWithinGrace_turnsRed() { + var config = PresenceEngine.Config() + config.useCalendar = false + config.vadGraceSeconds = 10 + let mic = FakeMicCamSignal() + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = true + let voiceActivity = FakeVoiceActivitySignal() + voiceActivity.active = false + let fixedNow = Date() + voiceActivity.lastActivityDate = fixedNow.addingTimeInterval(-5) + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + voiceActivity: voiceActivity, + luxafor: lux, + now: { fixedNow } + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.on(config.userId)]) + } + + func testTick_cameraActiveOverridesVadSilent_turnsRed() { + var config = PresenceEngine.Config() + config.useCalendar = false + let mic = FakeMicCamSignal() + mic.nextCamera = true + let front = FakeFrontmostAppSignal() + let calendar = FakeCalendarSignal() + let meetingDetector = FakeMeetingDetector() + meetingDetector.isActive = true + let voiceActivity = FakeVoiceActivitySignal() + voiceActivity.active = false + let lux = FakeLuxaforClient() + let engine = PresenceEngine( + config: config, + micCam: mic, + frontApp: front, + calendar: calendar, + meetingDetector: meetingDetector, + voiceActivity: voiceActivity, + luxafor: lux + ) + + engine.tick() + + XCTAssertEqual(lux.actions, [.on(config.userId)]) + } } // MARK: - Test Doubles private final class FakeMicCamSignal: MicCamSignalProtocol { - var nextValue = false + var nextMic = false + var nextCamera = false func requestAccessIfNeeded() {} - func anyInUse() -> Bool { nextValue } + func isMicrophoneInUse() -> Bool { nextMic } + func isCameraInUse() -> Bool { nextCamera } + func anyInUse() -> Bool { nextMic || nextCamera } } private final class FakeFrontmostAppSignal: FrontmostAppSignalProtocol { @@ -74,9 +320,16 @@ private final class FakeCalendarSignal: CalendarSignalProtocol { func hasOngoingMeetingEvent() -> Bool { ongoingMeeting } } +private final class FakeMeetingDetector: MeetingDetectorProtocol { + var name: String { "Fake" } + var isActive = false + func isMeetingActive() -> Bool { isActive } +} + private final class FakeLuxaforClient: LuxaforClientProtocol { enum Action: Equatable { case on(String) + case yellow(String) case off(String) } @@ -86,7 +339,19 @@ private final class FakeLuxaforClient: LuxaforClientProtocol { actions.append(.on(userId)) } + func turnOnYellow(userId: String) { + actions.append(.yellow(userId)) + } + func turnOff(userId: String) { actions.append(.off(userId)) } } + +private final class FakeVoiceActivitySignal: VoiceActivitySignalProtocol { + var active = false + var lastActivityDate: Date? + func requestAccessIfNeeded() {} + func isVoiceActive() -> Bool { active } + var lastVoiceActivityDate: Date? { lastActivityDate } +} diff --git a/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift b/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift new file mode 100644 index 0000000..6323a41 --- /dev/null +++ b/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift @@ -0,0 +1,59 @@ +import XCTest +@testable import LuxaforPresence + +final class SlackMeetingDetectorTests: XCTestCase { + func test_isMeetingActive_returnsTrue_whenHuddlesToolbarAndControlPresent() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXGroup", + roleDescription: "group", + label: "Huddle: test", + placeholder: nil, + domIdentifier: nil + ), + AXNodeSnapshot( + role: "AXCheckBox", + roleDescription: "checkbox", + label: "Share your screen", + placeholder: nil, + domIdentifier: nil + ), + ] + let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertTrue(detector.isMeetingActive()) + } + + func test_isMeetingActive_returnsFalse_whenAXUnavailable() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = nil + let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertFalse(detector.isMeetingActive()) + } + + func test_isMeetingActive_returnsFalse_whenToolbarMissing() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXCheckBox", + roleDescription: "checkbox", + label: "Share your screen", + placeholder: nil, + domIdentifier: nil + ), + ] + let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertFalse(detector.isMeetingActive()) + } +} + +private final class FakeAXSnapshotProvider: AXSnapshotProviding { + var nextSnapshot: [AXNodeSnapshot]? + + func snapshot(bundleIdentifiers: [String], processNames: [String]) -> [AXNodeSnapshot]? { + nextSnapshot + } +} diff --git a/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift b/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift new file mode 100644 index 0000000..648825e --- /dev/null +++ b/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift @@ -0,0 +1,60 @@ +import XCTest +@testable import LuxaforPresence + +final class TeamsMeetingDetectorTests: XCTestCase { + func test_isMeetingActive_returnsTrue_whenDomIdentifierPresent() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXButton", + roleDescription: "button", + label: "Unmute mic", + placeholder: nil, + domIdentifier: "microphone-button" + ), + ] + let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertTrue(detector.isMeetingActive()) + } + + func test_isMeetingActive_returnsTrue_whenToolbarLabelPresent() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXToolbar", + roleDescription: "toolbar", + label: "Calling controls", + placeholder: nil, + domIdentifier: nil + ), + ] + let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertTrue(detector.isMeetingActive()) + } + + func test_isMeetingActive_returnsFalse_whenNoMeetingIndicators() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXButton", + roleDescription: "button", + label: "Random button", + placeholder: nil, + domIdentifier: "something-else" + ), + ] + let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in false }) + + XCTAssertFalse(detector.isMeetingActive()) + } +} + +private final class FakeAXSnapshotProvider: AXSnapshotProviding { + var nextSnapshot: [AXNodeSnapshot]? + + func snapshot(bundleIdentifiers: [String], processNames: [String]) -> [AXNodeSnapshot]? { + nextSnapshot + } +} diff --git a/LuxaforPresence/Transport/LuxaforClient.swift b/LuxaforPresence/Transport/LuxaforClient.swift index 87efb22..7fada1e 100644 --- a/LuxaforPresence/Transport/LuxaforClient.swift +++ b/LuxaforPresence/Transport/LuxaforClient.swift @@ -2,6 +2,7 @@ import Foundation protocol LuxaforClientProtocol { func turnOnRed(userId: String) + func turnOnYellow(userId: String) func turnOff(userId: String) } @@ -13,6 +14,10 @@ final class LuxaforClient: LuxaforClientProtocol { post(["userId": userId, "actionFields": ["color": "red"]]) } + func turnOnYellow(userId: String) { + post(["userId": userId, "actionFields": ["color": "yellow"]]) + } + func turnOff(userId: String) { post(["userId": userId, "actionFields": ["color": "custom", "custom_color": "000000"]]) } @@ -23,7 +28,7 @@ final class LuxaforClient: LuxaforClientProtocol { req.addValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: body) let task = session.dataTask(with: req) { data, resp, err in - // Optional: log errors, backoff, retry on 5xx + // TODO: log errors, Optional: backoff, retry on 5xx } task.resume() } diff --git a/PLAN_2.md b/PLAN_2.md deleted file mode 100644 index f802eb0..0000000 --- a/PLAN_2.md +++ /dev/null @@ -1,110 +0,0 @@ -# PLAN 2 — Additional Signals, Logging, and History - -## 1. Objectives -- Reduce false positives from always-on audio tools (e.g., Motiv Mix) without losing sensitivity to real meetings. -- Introduce higher-confidence cues (screen share, calendar context, per-app output). -- Capture rich diagnostics and local history (including manual overrides) so we can iterate or train heuristics later. -- Back all new behavior with tests and structured logging. - -## 2. Workstreams at a Glance -1. **Signal Layer Enhancements** - - Calendar signal (EventKit) with online-meeting detection. - - Screen-share indicator based on ScreenCaptureKit + Accessibility fallback. - - Per-app audio output monitor for allowlisted bundles. - - Audio device blocklist (Motiv Mix, Loopback drivers) applied before aggregation. -2. **Presence Engine Updates** - - Combine signals with weighted scoring + debounce. - - Persist manual overrides and emit corrective labels into history. -3. **Logging & History** - - Structured `os_log` for each tick (per-signal payload, chosen state, Luxafor updates). - - Local history writer (JSONL in `~/Library/Logs/LuxaforPresence/history.log`). -4. **Testing** - - Unit tests per signal + engine scenarios. - - Integration smoke test wiring the new signals behind fakes. - -## 3. Signal Layer Details - -### 3.1 CalendarSignal v2 -- **Scope**: detect active events with `hasRecurrenceRules`, `isAllDay == false`, start/end bounds, and `structuredLocation` or `URL` containing common meeting prefixes (`zoom.us`, `teams.microsoft.com`, `meet.google.com`, `webex.com`). -- **Implementation** - - Request calendar access on first use; cache authorization status. - - Poll once per minute; cache result for ticks in between to avoid EventKit churn. - - Emit payload: `CalendarSignal.State(isMeeting: Bool, confidence: Double, source: .event(.title, .urlScheme))`. -- **Tests** - - Event with valid URL → `isMeeting == true`. - - All-day events and duplicates suppressed. - -### 3.2 ScreenShareSignal -- **Primary**: use ScreenCaptureKit (`SCShareableContent.current.applicationRecordings`) to see if current process is sharing; requires Screen Recording permission. -- **Fallback**: Accessibility API to watch for the macOS screen-share menu bar indicator (`AXStatusBarButton` titled “Screen Sharing”). -- **Payload**: `isSharing: Bool`, `ownerBundleID`. -- **Risks**: permission prompt; add onboarding copy in README. -- **Tests**: injectable protocol with fake providers; verify debounce when state flaps quickly. - -### 3.3 AudioOutputSignal -- **Goal**: treat non-silent audio output from allowlisted bundles as a meeting cue even when mic is muted. -- **Implementation sketch** - - Enumerate output streams via CoreAudio (`AudioObjectID` of default output). - - Capture per-stream peak/RMS levels and associated `kAudioStreamPropertyOwningProcessPID`. - - Crosswalk PID → bundle ID via `NSRunningApplication`. - - Emit when > `-35 dB` for ≥ 2 consecutive ticks from a meeting app. -- **Tests**: fake CoreAudio provider returning sample dB values; verify threshold logic. - -### 3.4 MicCamSignal Hardening -- Maintain a blocklist of device names/UIDs (Motiv Mix, Loopback, Rogue Amoeba). Ignore them when aggregating mic/camera usage. -- Track per-device stats in logs for debugging. - -## 4. PresenceEngine Changes -- Convert heuristic into weighted scoring: - - `mic/cam active` (after blocklist) = +0.4 - - `frontmost allowlisted` = +0.2 - - `calendar meeting now` = +0.2 - - `screen sharing` = +0.4 - - `allowlisted audio output` = +0.2 - - Meeting detected if score ≥ 0.6 OR `(screen sharing || calendar meeting) AND (frontmost allowlisted || manual override == forceOn)`. -- Add **debounce**: require consistent score on 2 ticks before notifying Luxafor. -- Manual override now writes `HistoryEntry(kind: .manual, desiredState, reason, timestamp)` and decays after X minutes or explicit release. - -## 5. Logging & Local History -- **Structured logging**: use `Logger` (swift-log) with subsystem `com.example.LuxaforPresence`. - - Each tick logs JSON payload summarizing signals, calculated score, final decision, and Luxafor action. - - Errors (permission denied, EventKit failures) logged at `.error`. -- **History writer** - - Append-only JSONL file at `~/Library/Logs/LuxaforPresence/history.log`. - - Fields: timestamp, frontmost bundle, mic devices active, calendar event id, screenShareOwner, audioOutputBundles, manualOverride state, finalPresence. - - Rotate file at 5 MB to avoid unbounded growth. - - History used later for ML experiments; never leaves disk without explicit user action. - -## 6. Manual Override UX -- Add “Mark Not In Meeting for 10 min” option to status menu; countdown displayed in submenu. -- When override toggled, emit history entry with `labelSource: "user"` to serve as RL feedback. -- Provide “Clear Override & Resume Auto” menu item. - -## 7. Testing Plan -- **Unit** - - `CalendarSignalTests`, `ScreenShareSignalTests`, `AudioOutputSignalTests`, updated `MicCamSignalTests`. - - `PresenceEngineScoringTests` verifying combinations and debounce. - - `HistoryWriterTests` ensuring log rotation and JSON schema. -- **Integration** - - End-to-end test harness injecting fakes into `PresenceEngine` and asserting LuxaforClient calls + history entries. -- **Manual** - - Scenario matrix covering Motiv Mix running, passive webinar (audio output only), screen share without mic, and calendar-only meeting. - -## 8. Logging & Privacy Considerations -- Redact sensitive calendar titles/URLs before logging (e.g., hash event identifier, truncate title). -- Screen share logs only include bundle ID, never window titles. -- Allow users to clear history via menu item (delete file + restart writer). - -## 9. Rollout Steps -1. Ship CalendarSignal + blocklist first (behind feature flag). -2. Add history writer and override logging. -3. Introduce screen-share detection (opt-in, prompt users). -4. Layer per-app audio output once stability verified. -5. Iterate with history data to adjust weights; consider exposing weights in config for power users. - -## 10. Done Criteria -- All new signals gated behind configuration keys with sane defaults. -- `swift test` covers the added modules. -- README + AGENTS updated with permissions and troubleshooting for new signals. -- Users can inspect history log and understand override interactions. - diff --git a/README.md b/README.md index f5bc6bd..1a41e83 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,38 @@ # LuxaforPresence for macOS -Small macOS menu bar app that checks if you are in a meeting and updates [Luxafor flag](https://luxafor.com/product/flag/) - LED free/busy light. +macOS menu bar app that updates a [Luxafor flag](https://luxafor.com/product/flag/) based on meeting signals. ## Why It Exists -Physical “busy lights” only work when they reflect reality. Relying on calendar events or a single integration means the Luxafor flag often stays green even though you jumped into a huddle, picked up an impromptu Teams call, or joined a vendor Zoom from a clean calendar. The reverse also happens: long-running audio tools such as Motiv Mix, Loopback, or stream decks keep the mic interface open and force you to turn the Luxafor flag off by hand. LuxaforPresence exists to remove that manual babysitting. +Calendar-only detection misses ad hoc calls and huddles. Mic-only detection is noisy when other tools keep devices open. This app combines several signals so the light reflects what is happening. -Real-world presence requires combining multiple cues: +Signals used: -- **Foreground apps** – if Zoom, Teams, Meet, or Slack is frontmost, you are likely in a conversation even if the calendar is empty. -- **Mic/camera state** – CoreAudio, CoreMediaIO, and AVFoundation tell us when audio or video devices are actually in use. -- **Calendar context** (in testing) – meetings on the calendar help catch muted webinars or screen shares where neither mic nor camera is hot. -- **Screen sharing & audio output** (roadmap) – presenting your screen or streaming audio from a conference app is just as strong a signal as talking. -- **Manual overrides** – you can force the Luxafor on/off for edge cases, and the history log captures those overrides for later analysis. +- Meeting app detectors: Zoom, Teams, Webex, Slack Huddles, and Google Meet (process checks, Accessibility UI hints, or browser tab state). +- Mic/camera state from CoreAudio, CoreMediaIO, and AVFoundation. +- Voice activity (VAD) to distinguish active speaking from a silent meeting. +- Calendar context (optional) to catch muted webinars or screen shares. +- Screen sharing and audio output (roadmap). +- Manual overrides for edge cases. -LuxaforPresence runs locally, merges those signals, and pushes the final state to the Luxafor cloud API so your desk flag stays honest without leaking the details of every meeting. +Everything runs locally. The app only sends Luxafor state updates to the Luxafor webhook API. ## Project Status -LuxaforPresence is currently in an **alpha** stage: the core heuristic (mic/camera + foreground app) works, however need to expanded "in meeting" signals as described in `PLAN_2.md`. -Testing and feedback are welcome. +Alpha. Meeting detectors (Zoom/Webex/Teams/Slack/Google Meet), mic/cam state, and voice activity detection are implemented. Calendar signals are optional via config. Expect changes. ## How Detection Works 1. **Signals collect raw facts** - - `MicCamSignal` inspects mic and camera devices (with a blocklist for “always-hot” virtual devices on a roadmap) to decide if audio or video is live. - - `AppSignal` tracks the foreground bundle and checks it against an allowlist of conferencing apps. - - Upcoming additions such as `CalendarSignal`, `ScreenShareSignal`, and `AudioOutputSignal` are outlined in `PLAN_2.md` and will join the same pipeline. -2. **PresenceEngine scores the signals** - Each tick, the engine assigns weights to the active signals, debounces flapping states, and decides whether you are “in a meeting.” Manual overrides feed into the same logic so you can temporarily pin the light. + - `MeetingDetector` checks Zoom, Webex, Teams, Slack Huddles, and Google Meet. + - `MicCamSignal` inspects mic and camera devices to see if they are in use. + - `VoiceActivitySignal` listens for speech (when `vadEnabled` is on). + - `CalendarSignal` can optionally mark meetings from EventKit (when `useCalendar` is true). + - `FrontmostAppSignal` only contributes when `debugAssumeFrontmostImpliesMic` is enabled. +2. **PresenceEngine evaluates** + Each tick, the engine combines detectors, calendar signal, and the optional debug frontmost check. If a meeting is active, voice activity decides between `inMeeting` (red) and `inMeetingSilent` (yellow). Manual overrides can pin the state. 3. **LuxaforTransport updates the flag** - When the inferred state changes, the transport layer sends the new color to the Luxafor webhook API. All requests are driven from the local machine; no external service stores your history. + When the state changes, the transport layer sends the new color to the Luxafor webhook API. See `LuxaforPresence/Model` and `LuxaforPresence/Signals` for the types involved, and `LuxaforPresence/Resources/config.plist` for tunables such as the allowlisted bundles. @@ -44,14 +46,14 @@ See `LuxaforPresence/Model` and `LuxaforPresence/Signals` for the types involved * macOS 13.0 or newer (Apple Silicon or Intel). * Xcode 14.3+ or Xcode Command Line Tools with Swift 5.7 (`xcode-select --install`). -* A [Luxafor flag](https://luxafor.com/product/flag/) and your Luxafor webhook `userId`. +* A [Luxafor flag](https://luxafor.com/product/flag/) and Luxafor webhook `userId`. ## Setup 1. **Clone the repository.** 2. **Provide Luxafor User ID:** * The `userId` is loaded from a configuration file. You have two options: - * **Option 1: (Recommended)** Create a configuration file at `~/.config/LuxaforPresence/config.plist`. The app will create the directory for you. You can copy the bundled config file and edit it. + * **Option 1: (Recommended)** Create a configuration file at `~/.config/LuxaforPresence/config.plist` (or `~/Library/Application Support/LuxaforPresence/config.plist`). The app will create the directory for you. You can copy the bundled config file and edit it. * **Option 2:** Edit the bundled configuration file at `LuxaforPresence/Resources/config.plist` and replace `YOUR_USER_ID_HERE` with your actual Luxafor `userId`. Note that this change will be overwritten if you pull new updates from the repository. ```xml @@ -66,6 +68,17 @@ See `LuxaforPresence/Model` and `LuxaforPresence/Signals` for the types involved ``` 3. **Assets already included:** The status bar icons (`StatusIconOn/Off/Idle`) ship inside `LuxaforPresence/Resources/Assets.xcassets`; no manual setup is required. If you replace them, keep the same filenames or update `StatusIcon.swift`. +4. **Optional config knobs:** + `enabledMeetingDetectors` lets you limit which app detectors run (Zoom/Webex/Teams/Slack/GoogleMeet). `useCalendar` toggles EventKit checks. `vadEnabled`, `vadThreshold`, and `vadGraceSeconds` tune voice activity detection. `meetingBundles` is only used for the debug frontmost override. + +## Permissions + +LuxaforPresence relies on macOS privacy permissions to gather signals: + +- **Microphone/Camera:** required for mic/cam state and voice activity detection. +- **Accessibility:** required for Teams and Slack meeting UI detection. +- **Automation (Apple Events):** required for Google Meet tab checks in Chrome/Safari. +- **Calendar (optional):** required only when `useCalendar` is true. ## How to Build and Run @@ -80,10 +93,11 @@ All commands are executed from the repository root and require the Xcode toolcha If you prefer launching the compiled binary manually, run `.build/debug/LuxaforPresence`; the menu bar icon should appear within a second of launch. -## How to Run Tests +## How to Debug ```bash -swift test +# run as admin, set 'category' to specific areas, like SlackMeetingDetector or PresenceEngine +log stream --level debug --predicate 'subsystem == "com.example.LuxaforPresence" && (category == "PresenceEngine" || category == "VoiceActivitySignal")' ``` ## Package as a DMG @@ -96,14 +110,15 @@ Use the helper script to build the release binary, wrap it in an `.app`, and pro The script defaults to the `release` configuration and creates `dist/LuxaforPresence.dmg` containing `LuxaforPresence.app`. Pass `-c debug` to package a debug build or `-n ` to change the mounted volume title. You’ll need the standard macOS tools (`swift`, `hdiutil`, `plutil`) available in your `$PATH`. -## Troubleshooting Mic/Cam Detection +## Troubleshooting Detection -1. Tail diagnostics with `log stream --predicate 'subsystem == "com.example.LuxaforPresence"'`. Each timer tick now prints per-device mic/cam states plus CoreAudio and CoreMediaIO information, for example: +1. If Teams or Slack meetings are not detected, confirm Accessibility access is granted to LuxaforPresence (or Terminal/Xcode when running from `swift run`). The app prompts on first launch. +2. If Google Meet is not detected, ensure Chrome or Safari is allowed under System Settings → Privacy & Security → Automation, and that the Meet tab is audible. +3. Tail diagnostics with `log stream --predicate 'subsystem == "com.example.LuxaforPresence"'`. Each timer tick prints per-device mic/cam states plus CoreAudio and CoreMediaIO information, for example: * `MicCamSignal` logs every `AVCaptureDevice` by localized name and whether `isInUseByAnotherApplication` returned `true`. * CoreAudio status lines enumerate the default input plus every running input-capable device so you can see whether HAL reports activity even when AVFoundation does not. * CMIO status lines record each camera’s device/UID along with its `DeviceIsRunningSomewhere` flag, which catches cases where Teams/Zoom doesn’t toggle `AVCaptureDevice.isInUseByAnotherApplication`. -2. If you need to verify Luxafor state transitions while debugging mic detection, set `debugAssumeFrontmostImpliesMic` to `true` inside your `config.plist`. When the foreground bundle is allowlisted, `PresenceEngine` will treat the mic/cam signal as active and emit the usual Luxafor updates so the rest of the pipeline can be tested in isolation. -3. See `TRBL_PLAN1.md` for a step-by-step checklist when the mic/cam signal remains `false` despite being in a call. +4. If you need to verify Luxafor state transitions while debugging mic detection, set `debugAssumeFrontmostImpliesMic` to `true` inside your `config.plist`. When the foreground bundle is allowlisted, `PresenceEngine` will treat the mic/cam signal as active and emit the usual Luxafor updates so the rest of the pipeline can be tested in isolation. ## How to Install Dependencies From 6854c460db48f82b1041a44e0b808232b1f69d1f Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Wed, 4 Feb 2026 16:15:04 -0800 Subject: [PATCH 02/13] Switch to building on macOS-15 --- .github/workflows/build.yml | 4 ++-- CHANGELOG.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e83f4ee..82b3aff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ env: jobs: build_and_test: if: startsWith(github.ref, 'refs/tags/v') == false - runs-on: macos-13 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 @@ -51,7 +51,7 @@ jobs: build_dmg: if: startsWith(github.ref, 'refs/tags/v') - runs-on: macos-13 + runs-on: macos-15 steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0739b..cc8fc32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to LuxaforPresence will be documented here. This project is - Current app version is `1.5.0` (from `CFBundleShortVersionString`). - “in meeting” state is detected using camera/mic activity, voice activity, plus checks for common meeting apps like Slack and Teams showing UI elements that indicate an active meeting. +- build on MacOS 15 ## [v0.01] – First Tagged Version (check via `git ls-remote --tags origin`) From f17b02ee5f6b5a43de4c0532d760d68bc03136f5 Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Wed, 4 Feb 2026 16:27:36 -0800 Subject: [PATCH 03/13] Show SDKs on build --- .github/workflows/build.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82b3aff..9b02b51 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,6 +39,12 @@ jobs: - name: Set build env run: echo "CLANG_MODULE_CACHE_PATH=$RUNNER_TEMP/clang-module-cache" >> "$GITHUB_ENV" + - name: Show SDK and toolchain + run: | + xcrun --sdk macosx --show-sdk-path + xcrun --sdk macosx --show-sdk-version + swift --version + - name: Build release binary run: swift build $SWIFT_BUILD_FLAGS env: From e9015d775c0fb2cba8358801e686b812602f0df6 Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Wed, 4 Feb 2026 16:37:11 -0800 Subject: [PATCH 04/13] Use latest MacOS runner and xcod --- .github/workflows/build.yml | 14 ++++++++++++-- CHANGELOG.md | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b02b51..1f575e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ env: jobs: build_and_test: if: startsWith(github.ref, 'refs/tags/v') == false - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@v4 @@ -36,6 +36,11 @@ jobs: key: ${{ runner.os }}-swift-${{ hashFiles('Package.resolved') }} restore-keys: ${{ runner.os }}-swift- + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.1" + - name: Set build env run: echo "CLANG_MODULE_CACHE_PATH=$RUNNER_TEMP/clang-module-cache" >> "$GITHUB_ENV" @@ -57,7 +62,7 @@ jobs: build_dmg: if: startsWith(github.ref, 'refs/tags/v') - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@v4 @@ -73,6 +78,11 @@ jobs: key: ${{ runner.os }}-swift-${{ hashFiles('Package.resolved') }} restore-keys: ${{ runner.os }}-swift- + - name: Select Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.1" + - name: Set build env run: echo "CLANG_MODULE_CACHE_PATH=$RUNNER_TEMP/clang-module-cache" >> "$GITHUB_ENV" diff --git a/CHANGELOG.md b/CHANGELOG.md index cc8fc32..8f62a62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to LuxaforPresence will be documented here. This project is - Current app version is `1.5.0` (from `CFBundleShortVersionString`). - “in meeting” state is detected using camera/mic activity, voice activity, plus checks for common meeting apps like Slack and Teams showing UI elements that indicate an active meeting. -- build on MacOS 15 +- build on MacOS 26 ## [v0.01] – First Tagged Version (check via `git ls-remote --tags origin`) From 37671ca22a7c089c411d59c06ee0062a4abd2e4d Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Thu, 5 Feb 2026 12:54:19 -0800 Subject: [PATCH 05/13] Teams AX Fixes - 1st Draft --- .../Signals/AccessibilitySnapshot.swift | 57 +++++++++++++++++-- .../TeamsMeetingDetector.swift | 27 +++++++-- .../Tests/SlackMeetingDetectorTests.swift | 9 ++- .../Tests/TeamsMeetingDetectorTests.swift | 9 ++- 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/LuxaforPresence/Signals/AccessibilitySnapshot.swift b/LuxaforPresence/Signals/AccessibilitySnapshot.swift index 610d871..cac7401 100644 --- a/LuxaforPresence/Signals/AccessibilitySnapshot.swift +++ b/LuxaforPresence/Signals/AccessibilitySnapshot.swift @@ -9,6 +9,7 @@ struct AXNodeSnapshot: Equatable { let label: String? let placeholder: String? let domIdentifier: String? + let identifier: String? } protocol AXSnapshotProviding { @@ -30,12 +31,29 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { logger.info("AX snapshot unavailable: not trusted") return nil } - guard let app = runningApplication(bundleIdentifiers: bundleIdentifiers, processNames: processNames) else { + let apps = runningApplications(bundleIdentifiers: bundleIdentifiers, processNames: processNames) + guard !apps.isEmpty else { logger.debug("AX snapshot: no running app for bundles=\(bundleIdentifiers, privacy: .public) names=\(processNames, privacy: .public)") return [] } + if apps.count > 1 { + logger.debug( + "AX snapshot: found \(apps.count, privacy: .public) matching apps \(self.formatApps(apps), privacy: .public)" + ) + } + + var allNodes: [AXNodeSnapshot] = [] + for app in apps { + allNodes.append(contentsOf: snapshotNodes(for: app)) + } + return allNodes + } + + private func snapshotNodes(for app: NSRunningApplication) -> [AXNodeSnapshot] { + let isFrontmost = NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier + let hasFocusedWindow = self.hasFocusedWindow(app) logger.debug( - "AX snapshot: app bundle=\(app.bundleIdentifier ?? "unknown", privacy: .public) name=\(app.localizedName ?? "unknown", privacy: .public) pid=\(app.processIdentifier, privacy: .public)" + "AX snapshot: app bundle=\(app.bundleIdentifier ?? "unknown", privacy: .public) name=\(app.localizedName ?? "unknown", privacy: .public) pid=\(app.processIdentifier, privacy: .public) frontmost=\(isFrontmost) focusedWindow=\(hasFocusedWindow)" ) let root = AXUIElementCreateApplication(app.processIdentifier) @@ -51,6 +69,7 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { var labelCount = 0 var placeholderCount = 0 var domIdentifierCount = 0 + var identifierCount = 0 var attributeErrorCounts: [String: Int] = [:] var attributeEmptyCounts: [String: Int] = [:] var attributeNonStringCounts: [String: Int] = [:] @@ -119,11 +138,19 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { emptyCounts: &attributeEmptyCounts, nonStringCounts: &attributeNonStringCounts ) + let identifier = stringAttribute( + element, + kAXIdentifierAttribute, + errorCounts: &attributeErrorCounts, + emptyCounts: &attributeEmptyCounts, + nonStringCounts: &attributeNonStringCounts + ) if role != nil { roleCount += 1 } if roleDescription != nil { roleDescriptionCount += 1 } if label != nil { labelCount += 1 } if placeholder != nil { placeholderCount += 1 } if domIdentifier != nil { domIdentifierCount += 1 } + if identifier != nil { identifierCount += 1 } nodes.append( AXNodeSnapshot( @@ -131,7 +158,8 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { roleDescription: roleDescription, label: label, placeholder: placeholder, - domIdentifier: domIdentifier + domIdentifier: domIdentifier, + identifier: identifier ) ) @@ -162,7 +190,7 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { } logger.debug( - "AX snapshot summary: nodes=\(nodes.count) dequeued=\(dequeuedCount) appended=\(appendedCount) maxDepthVisited=\(maxDepthVisited) maxDepth=\(self.maxDepth) maxDepthHit=\(maxDepthHit) maxNodes=\(self.maxNodes) maxNodesHit=\(maxNodesHit) role=\(roleCount) roleDesc=\(roleDescriptionCount) label=\(labelCount) placeholder=\(placeholderCount) domId=\(domIdentifierCount)" + "AX snapshot summary: nodes=\(nodes.count) dequeued=\(dequeuedCount) appended=\(appendedCount) maxDepthVisited=\(maxDepthVisited) maxDepth=\(self.maxDepth) maxDepthHit=\(maxDepthHit) maxNodes=\(self.maxNodes) maxNodesHit=\(maxNodesHit) role=\(roleCount) roleDesc=\(roleDescriptionCount) label=\(labelCount) placeholder=\(placeholderCount) domId=\(domIdentifierCount) identifier=\(identifierCount)" ) logger.debug( "AX snapshot attributes: errors=\(self.formatCounts(attributeErrorCounts), privacy: .public) empty=\(self.formatCounts(attributeEmptyCounts), privacy: .public) nonString=\(self.formatCounts(attributeNonStringCounts), privacy: .public)" @@ -173,10 +201,10 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { return nodes } - private func runningApplication(bundleIdentifiers: [String], processNames: [String]) -> NSRunningApplication? { + private func runningApplications(bundleIdentifiers: [String], processNames: [String]) -> [NSRunningApplication] { let normalizedBundles = Set(bundleIdentifiers.map { $0.lowercased() }) let normalizedNames = Set(processNames.map { $0.lowercased() }) - return NSWorkspace.shared.runningApplications.first { app in + return NSWorkspace.shared.runningApplications.filter { app in if let bundle = app.bundleIdentifier?.lowercased(), normalizedBundles.contains(bundle) { return true } @@ -190,6 +218,23 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { } } + private func formatApps(_ apps: [NSRunningApplication]) -> String { + apps.map { app in + let bundle = app.bundleIdentifier ?? "unknown" + let name = app.localizedName ?? "unknown" + let pid = app.processIdentifier + return "\(name) [\(bundle)] pid=\(pid)" + } + .joined(separator: "; ") + } + + private func hasFocusedWindow(_ app: NSRunningApplication) -> Bool { + let root = AXUIElementCreateApplication(app.processIdentifier) + var value: CFTypeRef? + let error = AXUIElementCopyAttributeValue(root, kAXFocusedWindowAttribute as CFString, &value) + return error == .success && value != nil + } + private func stringAttribute( _ element: AXUIElement, _ attribute: String, diff --git a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift index 891b1b0..6dd65a6 100644 --- a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift +++ b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift @@ -5,8 +5,19 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "TeamsMeetingDetector") private let snapshotProvider: AXSnapshotProviding private let isProcessRunning: ([String]) -> Bool - private let processNames = ["Microsoft Teams", "Teams"] - private let bundleIdentifiers = ["com.microsoft.teams2", "com.microsoft.teams"] + private let processNames = [ + "Microsoft Teams", + "Teams", + "Microsoft Teams WebView Helper", + "Microsoft Teams WebView Helper (Renderer)", + "Microsoft Teams WebView Helper (GPU)", + "Microsoft Teams WebView Helper (Plugin)", + ] + private let bundleIdentifiers = [ + "com.microsoft.teams2", + "com.microsoft.teams", + "com.microsoft.teams2.helper", + ] private let domIdentifiers: Set = [ "microphone-button", "video-button", @@ -43,19 +54,25 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { } var matchedDomIdentifiers = Set() + var matchedIdentifiers = Set() var matchedToolbarLabels = Set() var roleCount = 0 var labelCount = 0 var domIdentifierCount = 0 + var identifierCount = 0 var toolbarRoleCount = 0 for node in nodes { if node.role != nil { roleCount += 1 } if node.label != nil { labelCount += 1 } if node.domIdentifier != nil { domIdentifierCount += 1 } + if node.identifier != nil { identifierCount += 1 } if let domIdentifier = node.domIdentifier, domIdentifiers.contains(domIdentifier) { matchedDomIdentifiers.insert(domIdentifier) } + if let identifier = node.identifier, domIdentifiers.contains(identifier) { + matchedIdentifiers.insert(identifier) + } if let role = node.role, role == "AXToolbar" { toolbarRoleCount += 1 } @@ -65,11 +82,11 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { } logger.debug( - "Teams AX snapshot: nodes=\(nodes.count) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public)" + "Teams AX snapshot: nodes=\(nodes.count) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) identifier=\(identifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) identifierMatches=\(matchedIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public)" ) - if !matchedDomIdentifiers.isEmpty { - logger.debug("Teams meeting detected via DOM identifiers") + if !matchedDomIdentifiers.isEmpty || !matchedIdentifiers.isEmpty { + logger.debug("Teams meeting detected via identifiers") return true } diff --git a/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift b/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift index 6323a41..b9c69e6 100644 --- a/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift +++ b/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift @@ -10,14 +10,16 @@ final class SlackMeetingDetectorTests: XCTestCase { roleDescription: "group", label: "Huddle: test", placeholder: nil, - domIdentifier: nil + domIdentifier: nil, + identifier: nil ), AXNodeSnapshot( role: "AXCheckBox", roleDescription: "checkbox", label: "Share your screen", placeholder: nil, - domIdentifier: nil + domIdentifier: nil, + identifier: nil ), ] let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) @@ -41,7 +43,8 @@ final class SlackMeetingDetectorTests: XCTestCase { roleDescription: "checkbox", label: "Share your screen", placeholder: nil, - domIdentifier: nil + domIdentifier: nil, + identifier: nil ), ] let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) diff --git a/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift b/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift index 648825e..c68a799 100644 --- a/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift +++ b/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift @@ -10,7 +10,8 @@ final class TeamsMeetingDetectorTests: XCTestCase { roleDescription: "button", label: "Unmute mic", placeholder: nil, - domIdentifier: "microphone-button" + domIdentifier: "microphone-button", + identifier: nil ), ] let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) @@ -26,7 +27,8 @@ final class TeamsMeetingDetectorTests: XCTestCase { roleDescription: "toolbar", label: "Calling controls", placeholder: nil, - domIdentifier: nil + domIdentifier: nil, + identifier: nil ), ] let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) @@ -42,7 +44,8 @@ final class TeamsMeetingDetectorTests: XCTestCase { roleDescription: "button", label: "Random button", placeholder: nil, - domIdentifier: "something-else" + domIdentifier: "something-else", + identifier: nil ), ] let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in false }) From 0862a9e4b25f20addc62d592d62425fe9d86095f Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Thu, 5 Feb 2026 13:13:24 -0800 Subject: [PATCH 06/13] Teams fix - add more lables --- .../TeamsMeetingDetector.swift | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift index 6dd65a6..7922c75 100644 --- a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift +++ b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift @@ -28,6 +28,20 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { "Calling controls", "Meeting controls", ] + private let meetingControlLabels: Set = [ + "Raise", + "Raise your hand", + "Camera", + "Mic", + "Share", + "Share content", + "Leave", + "Unmute mic", + "Mute mic", + "Turn camera on", + "Turn camera off", + ] + private let meetingControlLabelThreshold = 2 var name: String { "Teams" } @@ -56,6 +70,7 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { var matchedDomIdentifiers = Set() var matchedIdentifiers = Set() var matchedToolbarLabels = Set() + var matchedMeetingControlLabels = Set() var roleCount = 0 var labelCount = 0 var domIdentifierCount = 0 @@ -76,13 +91,18 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { if let role = node.role, role == "AXToolbar" { toolbarRoleCount += 1 } - if let label = node.label, toolbarLabels.contains(label) { - matchedToolbarLabels.insert(label) + if let label = node.label { + if toolbarLabels.contains(label) { + matchedToolbarLabels.insert(label) + } + if let role = node.role, role == "AXButton", meetingControlLabels.contains(label) { + matchedMeetingControlLabels.insert(label) + } } } logger.debug( - "Teams AX snapshot: nodes=\(nodes.count) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) identifier=\(identifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) identifierMatches=\(matchedIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public)" + "Teams AX snapshot: nodes=\(nodes.count) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) identifier=\(identifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) identifierMatches=\(matchedIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public) meetingControlMatches=\(matchedMeetingControlLabels.sorted(), privacy: .public)" ) if !matchedDomIdentifiers.isEmpty || !matchedIdentifiers.isEmpty { @@ -95,6 +115,13 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { return true } + if matchedMeetingControlLabels.count >= meetingControlLabelThreshold { + logger.debug( + "Teams meeting detected via meeting control labels (matched=\(matchedMeetingControlLabels.sorted(), privacy: .public), threshold=\(self.meetingControlLabelThreshold))" + ) + return true + } + logger.debug("Teams meeting not detected (no matching AX controls)") return false } From a37d0f4b591be6ff8a801bf337db9eb3016598ae Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 09:57:56 -0800 Subject: [PATCH 07/13] Additional logging and permission changes --- LuxaforPresence/AppDelegate.swift | 36 ++++++++++- .../Signals/AccessibilitySnapshot.swift | 14 +++-- .../AccessibilityTrustDiagnostics.swift | 30 +++++++++ .../SlackMeetingDetector.swift | 8 ++- .../TeamsMeetingDetector.swift | 4 +- .../Tests/SlackMeetingDetectorTests.swift | 9 ++- .../Tests/TeamsMeetingDetectorTests.swift | 63 ++++++++++++++++++- 7 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 LuxaforPresence/Signals/AccessibilityTrustDiagnostics.swift diff --git a/LuxaforPresence/AppDelegate.swift b/LuxaforPresence/AppDelegate.swift index 8083460..941fea3 100644 --- a/LuxaforPresence/AppDelegate.swift +++ b/LuxaforPresence/AppDelegate.swift @@ -8,6 +8,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let engine = PresenceEngine() private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "AppDelegate") private let accessibilityPromptShownKey = "AccessibilityPromptShown" + private let accessibilityPromptedExecutablePathKey = "AccessibilityPromptedExecutablePath" func applicationDidFinishLaunching(_ notification: Notification) { logger.log("Application did finish launching") @@ -55,21 +56,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func promptForAccessibilityIfNeeded() { guard !AXIsProcessTrusted() else { return } - guard !UserDefaults.standard.bool(forKey: accessibilityPromptShownKey) else { return } + AccessibilityTrustDiagnostics.logNotTrusted(logger: logger, context: "startup") + + let executablePath = AccessibilityTrustDiagnostics.currentExecutablePath() + let lastPromptedPath = UserDefaults.standard.string(forKey: accessibilityPromptedExecutablePathKey) + if UserDefaults.standard.bool(forKey: accessibilityPromptShownKey), lastPromptedPath == executablePath { + AccessibilityTrustDiagnostics.logNotTrusted(logger: logger, context: "prompt suppressed; already shown for this executable") + return + } let options = [kAXTrustedCheckOptionPrompt.takeRetainedValue() as String: true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) UserDefaults.standard.set(true, forKey: accessibilityPromptShownKey) + UserDefaults.standard.set(executablePath, forKey: accessibilityPromptedExecutablePathKey) let alert = NSAlert() alert.messageText = "Enable Accessibility Access" - alert.informativeText = "LuxaforPresence needs Accessibility access to read meeting UI controls. Open System Settings → Privacy & Security → Accessibility, then enable LuxaforPresence (or Terminal/Xcode if running from there)." + let appPath = AccessibilityTrustDiagnostics.currentBundlePath() + alert.informativeText = """ +LuxaforPresence needs Accessibility access to read meeting UI controls. + +Running app path: +\(appPath) + +Open System Settings → Privacy & Security → Accessibility, then enable this app. +""" alert.alertStyle = .warning + alert.addButton(withTitle: "Open Settings") alert.addButton(withTitle: "OK") - alert.runModal() + let response = alert.runModal() + if response == .alertFirstButtonReturn { + self.openAccessibilitySettings() + } logger.info("Prompted for Accessibility access") } + private func openAccessibilitySettings() { + guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { + logger.error("Failed to build Accessibility settings URL") + return + } + NSWorkspace.shared.open(url) + logger.info("Opened Accessibility settings") + } + @objc private func forceOn() { engine.force(.inMeeting) } @objc private func forceOff() { engine.force(.notMeeting) } @objc private func openPrefs() { /* simple NSAlert or NSPanel for userId etc. */ } diff --git a/LuxaforPresence/Signals/AccessibilitySnapshot.swift b/LuxaforPresence/Signals/AccessibilitySnapshot.swift index cac7401..141080f 100644 --- a/LuxaforPresence/Signals/AccessibilitySnapshot.swift +++ b/LuxaforPresence/Signals/AccessibilitySnapshot.swift @@ -10,6 +10,7 @@ struct AXNodeSnapshot: Equatable { let placeholder: String? let domIdentifier: String? let identifier: String? + let pid: Int32? } protocol AXSnapshotProviding { @@ -28,6 +29,7 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { func snapshot(bundleIdentifiers: [String], processNames: [String]) -> [AXNodeSnapshot]? { guard AXIsProcessTrusted() else { + AccessibilityTrustDiagnostics.logNotTrusted(logger: logger, context: "snapshot") logger.info("AX snapshot unavailable: not trusted") return nil } @@ -52,8 +54,9 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { private func snapshotNodes(for app: NSRunningApplication) -> [AXNodeSnapshot] { let isFrontmost = NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier let hasFocusedWindow = self.hasFocusedWindow(app) + let pid = app.processIdentifier logger.debug( - "AX snapshot: app bundle=\(app.bundleIdentifier ?? "unknown", privacy: .public) name=\(app.localizedName ?? "unknown", privacy: .public) pid=\(app.processIdentifier, privacy: .public) frontmost=\(isFrontmost) focusedWindow=\(hasFocusedWindow)" + "AX snapshot: app bundle=\(app.bundleIdentifier ?? "unknown", privacy: .public) name=\(app.localizedName ?? "unknown", privacy: .public) pid=\(pid, privacy: .public) frontmost=\(isFrontmost) focusedWindow=\(hasFocusedWindow)" ) let root = AXUIElementCreateApplication(app.processIdentifier) @@ -159,7 +162,8 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { label: label, placeholder: placeholder, domIdentifier: domIdentifier, - identifier: identifier + identifier: identifier, + pid: pid ) ) @@ -190,13 +194,13 @@ final class AccessibilitySnapshotProvider: AXSnapshotProviding { } logger.debug( - "AX snapshot summary: nodes=\(nodes.count) dequeued=\(dequeuedCount) appended=\(appendedCount) maxDepthVisited=\(maxDepthVisited) maxDepth=\(self.maxDepth) maxDepthHit=\(maxDepthHit) maxNodes=\(self.maxNodes) maxNodesHit=\(maxNodesHit) role=\(roleCount) roleDesc=\(roleDescriptionCount) label=\(labelCount) placeholder=\(placeholderCount) domId=\(domIdentifierCount) identifier=\(identifierCount)" + "AX snapshot summary: pid=\(pid, privacy: .public) nodes=\(nodes.count) dequeued=\(dequeuedCount) appended=\(appendedCount) maxDepthVisited=\(maxDepthVisited) maxDepth=\(self.maxDepth) maxDepthHit=\(maxDepthHit) maxNodes=\(self.maxNodes) maxNodesHit=\(maxNodesHit) role=\(roleCount) roleDesc=\(roleDescriptionCount) label=\(labelCount) placeholder=\(placeholderCount) domId=\(domIdentifierCount) identifier=\(identifierCount)" ) logger.debug( - "AX snapshot attributes: errors=\(self.formatCounts(attributeErrorCounts), privacy: .public) empty=\(self.formatCounts(attributeEmptyCounts), privacy: .public) nonString=\(self.formatCounts(attributeNonStringCounts), privacy: .public)" + "AX snapshot attributes: pid=\(pid, privacy: .public) errors=\(self.formatCounts(attributeErrorCounts), privacy: .public) empty=\(self.formatCounts(attributeEmptyCounts), privacy: .public) nonString=\(self.formatCounts(attributeNonStringCounts), privacy: .public)" ) logger.debug( - "AX snapshot children: zero=\(childBucketZero) small=\(childBucketSmall) medium=\(childBucketMedium) large=\(childBucketLarge) fetchErrors=\(childrenFetchErrorCount) nonArray=\(childrenNonArrayCount)" + "AX snapshot children: pid=\(pid, privacy: .public) zero=\(childBucketZero) small=\(childBucketSmall) medium=\(childBucketMedium) large=\(childBucketLarge) fetchErrors=\(childrenFetchErrorCount) nonArray=\(childrenNonArrayCount)" ) return nodes } diff --git a/LuxaforPresence/Signals/AccessibilityTrustDiagnostics.swift b/LuxaforPresence/Signals/AccessibilityTrustDiagnostics.swift new file mode 100644 index 0000000..5254b80 --- /dev/null +++ b/LuxaforPresence/Signals/AccessibilityTrustDiagnostics.swift @@ -0,0 +1,30 @@ +import Foundation +import OSLog + +struct AccessibilityTrustDiagnostics { + static func currentBundleIdentifier() -> String { + Bundle.main.bundleIdentifier ?? "unknown" + } + + static func currentBundlePath() -> String { + Bundle.main.bundlePath + } + + static func currentExecutablePath() -> String { + Bundle.main.executableURL?.path ?? "unknown" + } + + static func currentProcessIdentifier() -> Int { + Int(ProcessInfo.processInfo.processIdentifier) + } + + static func logNotTrusted(logger: Logger, context: String) { + let bundleId = currentBundleIdentifier() + let bundlePath = currentBundlePath() + let executablePath = currentExecutablePath() + let pid = currentProcessIdentifier() + logger.info( + "AX not trusted: context=\(context, privacy: .public) bundleId=\(bundleId, privacy: .public) bundlePath=\(bundlePath, privacy: .public) executablePath=\(executablePath, privacy: .public) pid=\(pid, privacy: .public)" + ) + } +} diff --git a/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift index 61350a3..0cd545d 100644 --- a/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift +++ b/LuxaforPresence/Signals/MeetingDetectors/SlackMeetingDetector.swift @@ -37,6 +37,7 @@ final class SlackMeetingDetector: MeetingDetectorProtocol { return false } guard let nodes = snapshotProvider.snapshot(bundleIdentifiers: bundleIdentifiers, processNames: processNames) else { + AccessibilityTrustDiagnostics.logNotTrusted(logger: logger, context: "slack snapshot") logger.debug("AX snapshot unavailable (not authorized or failed)") return false } @@ -50,8 +51,9 @@ final class SlackMeetingDetector: MeetingDetectorProtocol { } return false } + let pids = Set(nodes.compactMap { $0.pid }).sorted() if !anchorFound { - logger.debug("Slack AX snapshot: nodes=\(nodes.count) anchorFound=false") + logger.debug("Slack AX snapshot: nodes=\(nodes.count) pids=\(pids, privacy: .public) anchorFound=false") return false } @@ -64,7 +66,9 @@ final class SlackMeetingDetector: MeetingDetectorProtocol { matchedControls.insert(normalizedLabel) } } - logger.debug("Slack AX snapshot: nodes=\(nodes.count) anchorFound=true matchedControls=\(matchedControls.sorted(), privacy: .public)") + logger.debug( + "Slack AX snapshot: nodes=\(nodes.count) pids=\(pids, privacy: .public) anchorFound=true matchedControls=\(matchedControls.sorted(), privacy: .public)" + ) if matchedControls.isEmpty { logger.debug("Slack huddle detected via anchor (no control matches)") diff --git a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift index 7922c75..de7b0a3 100644 --- a/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift +++ b/LuxaforPresence/Signals/MeetingDetectors/TeamsMeetingDetector.swift @@ -60,6 +60,7 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { return false } guard let nodes = snapshotProvider.snapshot(bundleIdentifiers: bundleIdentifiers, processNames: processNames) else { + AccessibilityTrustDiagnostics.logNotTrusted(logger: logger, context: "teams snapshot") logger.debug("AX snapshot unavailable (not authorized or failed)") return false } @@ -76,6 +77,7 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { var domIdentifierCount = 0 var identifierCount = 0 var toolbarRoleCount = 0 + let pids = Set(nodes.compactMap { $0.pid }).sorted() for node in nodes { if node.role != nil { roleCount += 1 } @@ -102,7 +104,7 @@ final class TeamsMeetingDetector: MeetingDetectorProtocol { } logger.debug( - "Teams AX snapshot: nodes=\(nodes.count) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) identifier=\(identifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) identifierMatches=\(matchedIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public) meetingControlMatches=\(matchedMeetingControlLabels.sorted(), privacy: .public)" + "Teams AX snapshot: nodes=\(nodes.count) pids=\(pids, privacy: .public) role=\(roleCount) label=\(labelCount) domId=\(domIdentifierCount) identifier=\(identifierCount) toolbarRole=\(toolbarRoleCount) domMatches=\(matchedDomIdentifiers.sorted(), privacy: .public) identifierMatches=\(matchedIdentifiers.sorted(), privacy: .public) toolbarMatches=\(matchedToolbarLabels.sorted(), privacy: .public) meetingControlMatches=\(matchedMeetingControlLabels.sorted(), privacy: .public)" ) if !matchedDomIdentifiers.isEmpty || !matchedIdentifiers.isEmpty { diff --git a/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift b/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift index b9c69e6..5b16238 100644 --- a/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift +++ b/LuxaforPresence/Tests/SlackMeetingDetectorTests.swift @@ -11,7 +11,8 @@ final class SlackMeetingDetectorTests: XCTestCase { label: "Huddle: test", placeholder: nil, domIdentifier: nil, - identifier: nil + identifier: nil, + pid: nil ), AXNodeSnapshot( role: "AXCheckBox", @@ -19,7 +20,8 @@ final class SlackMeetingDetectorTests: XCTestCase { label: "Share your screen", placeholder: nil, domIdentifier: nil, - identifier: nil + identifier: nil, + pid: nil ), ] let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) @@ -44,7 +46,8 @@ final class SlackMeetingDetectorTests: XCTestCase { label: "Share your screen", placeholder: nil, domIdentifier: nil, - identifier: nil + identifier: nil, + pid: nil ), ] let detector = SlackMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) diff --git a/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift b/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift index c68a799..d547e60 100644 --- a/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift +++ b/LuxaforPresence/Tests/TeamsMeetingDetectorTests.swift @@ -11,7 +11,8 @@ final class TeamsMeetingDetectorTests: XCTestCase { label: "Unmute mic", placeholder: nil, domIdentifier: "microphone-button", - identifier: nil + identifier: nil, + pid: nil ), ] let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) @@ -28,7 +29,8 @@ final class TeamsMeetingDetectorTests: XCTestCase { label: "Calling controls", placeholder: nil, domIdentifier: nil, - identifier: nil + identifier: nil, + pid: nil ), ] let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) @@ -45,13 +47,68 @@ final class TeamsMeetingDetectorTests: XCTestCase { label: "Random button", placeholder: nil, domIdentifier: "something-else", - identifier: nil + identifier: nil, + pid: nil ), ] let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in false }) XCTAssertFalse(detector.isMeetingActive()) } + + func test_isMeetingActive_returnsTrue_whenMeetingControlLabelsPresent() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXButton", + roleDescription: "button", + label: "Mic", + placeholder: nil, + domIdentifier: nil, + identifier: nil, + pid: nil + ), + AXNodeSnapshot( + role: "AXButton", + roleDescription: "button", + label: "Share", + placeholder: nil, + domIdentifier: nil, + identifier: nil, + pid: nil + ), + ] + let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertTrue(detector.isMeetingActive()) + } + + func test_isMeetingActive_returnsFalse_whenMeetingControlLabelsNotButtons() { + let provider = FakeAXSnapshotProvider() + provider.nextSnapshot = [ + AXNodeSnapshot( + role: "AXStaticText", + roleDescription: "text", + label: "Mic", + placeholder: nil, + domIdentifier: nil, + identifier: nil, + pid: nil + ), + AXNodeSnapshot( + role: "AXStaticText", + roleDescription: "text", + label: "Share", + placeholder: nil, + domIdentifier: nil, + identifier: nil, + pid: nil + ), + ] + let detector = TeamsMeetingDetector(snapshotProvider: provider, isProcessRunning: { _ in true }) + + XCTAssertFalse(detector.isMeetingActive()) + } } private final class FakeAXSnapshotProvider: AXSnapshotProviding { From ecbfc443f116030e403601fb7f37dc8e77d7e363 Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 15:07:48 -0800 Subject: [PATCH 08/13] Add support for local webhook - 1st draft --- LuxaforPresence/AppDelegate.swift | 2 +- LuxaforPresence/Model/TransportMode.swift | 6 ++ LuxaforPresence/PresenceEngine.swift | 64 +++++++++++++++---- LuxaforPresence/Resources/config.plist | 8 ++- .../Tests/PresenceEngineTests.swift | 24 +++---- .../Tests/TransportSelectionTests.swift | 25 ++++++++ LuxaforPresence/Transport/LuxaforClient.swift | 18 ++++-- LuxaforPresence/Transport/LuxaforColor.swift | 26 ++++++++ .../Transport/LuxaforLocalWebhookClient.swift | 50 +++++++++++++++ 9 files changed, 192 insertions(+), 31 deletions(-) create mode 100644 LuxaforPresence/Model/TransportMode.swift create mode 100644 LuxaforPresence/Tests/TransportSelectionTests.swift create mode 100644 LuxaforPresence/Transport/LuxaforColor.swift create mode 100644 LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift diff --git a/LuxaforPresence/AppDelegate.swift b/LuxaforPresence/AppDelegate.swift index 941fea3..826635f 100644 --- a/LuxaforPresence/AppDelegate.swift +++ b/LuxaforPresence/AppDelegate.swift @@ -102,6 +102,6 @@ Open System Settings → Privacy & Security → Accessibility, then enable this @objc private func forceOn() { engine.force(.inMeeting) } @objc private func forceOff() { engine.force(.notMeeting) } - @objc private func openPrefs() { /* simple NSAlert or NSPanel for userId etc. */ } + @objc private func openPrefs() { /* simple NSAlert or NSPanel for remoteWebhookUserId etc. */ } @objc private func quit() { NSApp.terminate(nil) } } diff --git a/LuxaforPresence/Model/TransportMode.swift b/LuxaforPresence/Model/TransportMode.swift new file mode 100644 index 0000000..51d0888 --- /dev/null +++ b/LuxaforPresence/Model/TransportMode.swift @@ -0,0 +1,6 @@ +import Foundation + +enum TransportMode: String { + case local + case remote +} diff --git a/LuxaforPresence/PresenceEngine.swift b/LuxaforPresence/PresenceEngine.swift index 7c2456c..590ddbd 100644 --- a/LuxaforPresence/PresenceEngine.swift +++ b/LuxaforPresence/PresenceEngine.swift @@ -3,7 +3,10 @@ import OSLog final class PresenceEngine { struct Config { - var userId: String + var transportMode: TransportMode + var localWebhookBaseUrl: String + var localWebhookToken: String + var remoteWebhookUserId: String var pollInterval: TimeInterval var meetingBundles: Set var useCalendar: Bool @@ -16,7 +19,10 @@ final class PresenceEngine { init() { // Default values - userId = "YOUR_USER_ID_HERE" // Fallback default + transportMode = .local + localWebhookBaseUrl = "http://127.0.0.1:5383" + localWebhookToken = "luxafor" + remoteWebhookUserId = "YOUR_USER_ID_HERE" // Fallback default pollInterval = 2.0 meetingBundles = [ "us.zoom.xos", @@ -42,8 +48,20 @@ final class PresenceEngine { if let userConfigURL = candidateURLs.first(where: { FileManager.default.fileExists(atPath: $0.path) }), let userConfig = NSDictionary(contentsOf: userConfigURL) as? [String: Any] { logger.log("Loaded config from user path at \(userConfigURL.path(percentEncoded: false), privacy: .public)") - if let id = userConfig["userId"] as? String { - userId = id + if let mode = userConfig["transportMode"] as? String, + let parsed = TransportMode(rawValue: mode.lowercased()) { + transportMode = parsed + } + if let baseUrl = userConfig["localWebhookBaseUrl"] as? String { + localWebhookBaseUrl = baseUrl + } + if let token = userConfig["localWebhookToken"] as? String { + localWebhookToken = token + } + if let id = userConfig["remoteWebhookUserId"] as? String { + remoteWebhookUserId = id + } else if let legacyId = userConfig["userId"] as? String { + remoteWebhookUserId = legacyId } if let interval = userConfig["pollInterval"] as? TimeInterval { pollInterval = interval @@ -77,8 +95,20 @@ final class PresenceEngine { let bundledConfig = NSDictionary(contentsOf: bundledConfigURL) as? [String: Any] { logger.log("Loaded config from bundled resource at \(bundledConfigURL.path, privacy: .public)") // Fallback to bundled config - if let id = bundledConfig["userId"] as? String { - userId = id + if let mode = bundledConfig["transportMode"] as? String, + let parsed = TransportMode(rawValue: mode.lowercased()) { + transportMode = parsed + } + if let baseUrl = bundledConfig["localWebhookBaseUrl"] as? String { + localWebhookBaseUrl = baseUrl + } + if let token = bundledConfig["localWebhookToken"] as? String { + localWebhookToken = token + } + if let id = bundledConfig["remoteWebhookUserId"] as? String { + remoteWebhookUserId = id + } else if let legacyId = bundledConfig["userId"] as? String { + remoteWebhookUserId = legacyId } if let interval = bundledConfig["pollInterval"] as? TimeInterval { pollInterval = interval @@ -111,6 +141,7 @@ final class PresenceEngine { } else { logger.error("No config file found; using default hard-coded values") } + let finalizedTransportMode = transportMode let finalizedPollInterval = pollInterval let finalizedBundleCount = meetingBundles.count let finalizedUseCalendar = useCalendar @@ -120,7 +151,16 @@ final class PresenceEngine { let finalizedVadEnabled = vadEnabled let finalizedVadThreshold = vadThreshold let finalizedVadGrace = vadGraceSeconds - logger.log("Config initialized: pollInterval \(finalizedPollInterval, privacy: .public)s, meeting bundles count \(finalizedBundleCount, privacy: .public), useCalendar \(finalizedUseCalendar, privacy: .public), debugAssumeFrontmostImpliesMic \(finalizedDebugFlag), meeting detectors \(meetingDetectorMode, privacy: .public) count \(finalizedMeetingDetectorCount, privacy: .public), vadEnabled \(finalizedVadEnabled, privacy: .public), vadThreshold \(finalizedVadThreshold, privacy: .public), vadGraceSeconds \(finalizedVadGrace, privacy: .public)") + logger.log("Config initialized: transport \(finalizedTransportMode.rawValue, privacy: .public), pollInterval \(finalizedPollInterval, privacy: .public)s, meeting bundles count \(finalizedBundleCount, privacy: .public), useCalendar \(finalizedUseCalendar, privacy: .public), debugAssumeFrontmostImpliesMic \(finalizedDebugFlag), meeting detectors \(meetingDetectorMode, privacy: .public) count \(finalizedMeetingDetectorCount, privacy: .public), vadEnabled \(finalizedVadEnabled, privacy: .public), vadThreshold \(finalizedVadThreshold, privacy: .public), vadGraceSeconds \(finalizedVadGrace, privacy: .public)") + } + + func makeLuxaforClient() -> LuxaforClientProtocol { + switch transportMode { + case .local: + return LuxaforLocalWebhookClient(baseURL: localWebhookBaseUrl, token: localWebhookToken) + case .remote: + return LuxaforClient() + } } } @@ -145,7 +185,7 @@ final class PresenceEngine { calendar: CalendarSignalProtocol = CalendarSignal(), meetingDetector: MeetingDetectorProtocol? = nil, voiceActivity: VoiceActivitySignalProtocol? = nil, - luxafor: LuxaforClientProtocol = LuxaforClient(), + luxafor: LuxaforClientProtocol? = nil, now: @escaping () -> Date = Date.init ) { self.config = config @@ -154,7 +194,7 @@ final class PresenceEngine { self.calendar = calendar self.meetingDetector = meetingDetector ?? MeetingDetector(enabledNames: config.enabledMeetingDetectors) self.voiceActivity = voiceActivity ?? VoiceActivitySignal(threshold: config.vadThreshold) - self.luxafor = luxafor + self.luxafor = luxafor ?? config.makeLuxaforClient() self.now = now } @@ -253,9 +293,9 @@ final class PresenceEngine { onStateChange?(state) logger.log("Applying state \(state.rawValue, privacy: .public)") switch state { - case .inMeeting: luxafor.turnOnRed(userId: config.userId) - case .inMeetingSilent: luxafor.turnOnYellow(userId: config.userId) - case .notMeeting: luxafor.turnOff(userId: config.userId) + case .inMeeting: luxafor.turnOnRed(userId: config.remoteWebhookUserId) + case .inMeetingSilent: luxafor.turnOnYellow(userId: config.remoteWebhookUserId) + case .notMeeting: luxafor.turnOff(userId: config.remoteWebhookUserId) case .unknown: break } } diff --git a/LuxaforPresence/Resources/config.plist b/LuxaforPresence/Resources/config.plist index b654ac1..88305ae 100644 --- a/LuxaforPresence/Resources/config.plist +++ b/LuxaforPresence/Resources/config.plist @@ -2,7 +2,13 @@ - userId + transportMode + local + localWebhookBaseUrl + http://127.0.0.1:5383 + localWebhookToken + luxafor + remoteWebhookUserId YOUR_USER_ID_HERE pollInterval 2 diff --git a/LuxaforPresence/Tests/PresenceEngineTests.swift b/LuxaforPresence/Tests/PresenceEngineTests.swift index fc72a45..e45041f 100644 --- a/LuxaforPresence/Tests/PresenceEngineTests.swift +++ b/LuxaforPresence/Tests/PresenceEngineTests.swift @@ -26,7 +26,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } func testTick_staysNotMeeting_whenMeetingDetectorInactive_evenIfMicActive() { @@ -50,7 +50,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.off(config.userId)]) + XCTAssertEqual(lux.actions, [.off(config.remoteWebhookUserId)]) } func testTick_transitionsToInMeeting_whenCameraActive_evenIfMeetingDetectorInactive() { @@ -74,7 +74,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } func testTick_transitionsToInMeeting_whenCalendarActive_evenIfMeetingDetectorInactive() { @@ -99,7 +99,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } func testTick_usesDebugFlagToAssumeMeetingWhenFrontmostAllowlisted() { @@ -125,7 +125,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } func testTick_frontmostAloneDoesNotTriggerMeetingWithoutDebug() { @@ -149,11 +149,11 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.off(config.userId)]) + XCTAssertEqual(lux.actions, [.off(config.remoteWebhookUserId)]) } func testForceBypassesSignalsUntilChanged() { - var config = PresenceEngine.Config() + let config = PresenceEngine.Config() let mic = FakeMicCamSignal() mic.nextMic = true let front = FakeFrontmostAppSignal() @@ -179,7 +179,7 @@ final class PresenceEngineTests: XCTestCase { front.isMeetingApp = true engine.tick() - XCTAssertEqual(lux.actions, [.off(config.userId), .off(config.userId)]) + XCTAssertEqual(lux.actions, [.off(config.remoteWebhookUserId), .off(config.remoteWebhookUserId)]) } func testTick_meetingActiveAndVadSilentBeyondGrace_turnsYellow() { @@ -209,7 +209,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.yellow(config.userId)]) + XCTAssertEqual(lux.actions, [.yellow(config.remoteWebhookUserId)]) } func testTick_meetingActiveAndVadActive_turnsRed() { @@ -236,7 +236,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } func testTick_meetingActiveAndVadSilentWithinGrace_turnsRed() { @@ -266,7 +266,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } func testTick_cameraActiveOverridesVadSilent_turnsRed() { @@ -293,7 +293,7 @@ final class PresenceEngineTests: XCTestCase { engine.tick() - XCTAssertEqual(lux.actions, [.on(config.userId)]) + XCTAssertEqual(lux.actions, [.on(config.remoteWebhookUserId)]) } } diff --git a/LuxaforPresence/Tests/TransportSelectionTests.swift b/LuxaforPresence/Tests/TransportSelectionTests.swift new file mode 100644 index 0000000..d4a2e72 --- /dev/null +++ b/LuxaforPresence/Tests/TransportSelectionTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import LuxaforPresence + +final class TransportSelectionTests: XCTestCase { + func testMakeLuxaforClient_usesLocalClientWhenConfigured() { + var config = PresenceEngine.Config() + config.transportMode = .local + config.localWebhookBaseUrl = "http://127.0.0.1:5383" + config.localWebhookToken = "test" + + let client = config.makeLuxaforClient() + + XCTAssertTrue(client is LuxaforLocalWebhookClient) + } + + func testMakeLuxaforClient_usesRemoteClientWhenConfigured() { + var config = PresenceEngine.Config() + config.transportMode = .remote + config.remoteWebhookUserId = "user" + + let client = config.makeLuxaforClient() + + XCTAssertTrue(client is LuxaforClient) + } +} diff --git a/LuxaforPresence/Transport/LuxaforClient.swift b/LuxaforPresence/Transport/LuxaforClient.swift index 7fada1e..f8ccdbf 100644 --- a/LuxaforPresence/Transport/LuxaforClient.swift +++ b/LuxaforPresence/Transport/LuxaforClient.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog protocol LuxaforClientProtocol { func turnOnRed(userId: String) @@ -9,17 +10,18 @@ protocol LuxaforClientProtocol { final class LuxaforClient: LuxaforClientProtocol { private let endpoint = URL(string: "https://api.luxafor.com/webhook/v1/actions/solid_color")! private let session = URLSession(configuration: .ephemeral) + private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "LuxaforClient") func turnOnRed(userId: String) { - post(["userId": userId, "actionFields": ["color": "red"]]) + post(["userId": userId, "actionFields": LuxaforColor.red.remoteActionFields]) } func turnOnYellow(userId: String) { - post(["userId": userId, "actionFields": ["color": "yellow"]]) + post(["userId": userId, "actionFields": LuxaforColor.orange.remoteActionFields]) } func turnOff(userId: String) { - post(["userId": userId, "actionFields": ["color": "custom", "custom_color": "000000"]]) + post(["userId": userId, "actionFields": LuxaforColor.off.remoteActionFields]) } private func post(_ body: [String: Any]) { @@ -27,8 +29,14 @@ final class LuxaforClient: LuxaforClientProtocol { req.httpMethod = "POST" req.addValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: body) - let task = session.dataTask(with: req) { data, resp, err in - // TODO: log errors, Optional: backoff, retry on 5xx + let task = session.dataTask(with: req) { _, resp, err in + if let err = err { + self.logger.error("Local webhook request failed: \(err.localizedDescription, privacy: .public)") + return + } + if let http = resp as? HTTPURLResponse, http.statusCode != 200 { + self.logger.error("Local webhook returned status \(http.statusCode, privacy: .public)") + } } task.resume() } diff --git a/LuxaforPresence/Transport/LuxaforColor.swift b/LuxaforPresence/Transport/LuxaforColor.swift new file mode 100644 index 0000000..67c4ef7 --- /dev/null +++ b/LuxaforPresence/Transport/LuxaforColor.swift @@ -0,0 +1,26 @@ +import Foundation + +enum LuxaforColor { + case red + case orange + case off + + var hex: String { + switch self { + case .red: + return "FF0000" + case .orange: + return "FF7000" + case .off: + return "000000" + } + } + + var localHex: String { + "#\(hex)" + } + + var remoteActionFields: [String: Any] { + ["color": "custom", "custom_color": hex] + } +} diff --git a/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift b/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift new file mode 100644 index 0000000..fdaa4b5 --- /dev/null +++ b/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift @@ -0,0 +1,50 @@ +import Foundation +import OSLog + +final class LuxaforLocalWebhookClient: LuxaforClientProtocol { + private let baseURL: URL + private let token: String + private let session: URLSession + private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "LuxaforLocalWebhookClient") + + init(baseURL: String, token: String, session: URLSession = URLSession(configuration: .ephemeral)) { + self.baseURL = URL(string: baseURL)?.standardized ?? URL(string: "http://127.0.0.1:5383")! + self.token = token + self.session = session + } + + func turnOnRed(userId: String) { + postColor(.red) + } + + func turnOnYellow(userId: String) { + postColor(.orange) + } + + func turnOff(userId: String) { + postColor(.off) + } + + private func postColor(_ color: LuxaforColor) { + guard let url = URL(string: "color", relativeTo: baseURL) else { + logger.error("Failed to build local webhook URL for color endpoint") + return + } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.addValue("application/json", forHTTPHeaderField: "Content-Type") + req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + req.httpBody = try? JSONSerialization.data(withJSONObject: ["color": color.localHex]) + + let task = session.dataTask(with: req) { _, resp, err in + if let err = err { + self.logger.error("Local webhook request failed: \(err.localizedDescription, privacy: .public)") + return + } + if let http = resp as? HTTPURLResponse, http.statusCode != 200 { + self.logger.error("Local webhook returned status \(http.statusCode, privacy: .public)") + } + } + task.resume() + } +} From 80fc7542b1bb6a5086c26379b859596d94adbd2e Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 15:55:07 -0800 Subject: [PATCH 09/13] Add proper logging and re-try logic in Luxafor local and remote transport clients --- LuxaforPresence/Transport/LuxaforClient.swift | 45 ++++++++++++++++--- .../Transport/LuxaforLocalWebhookClient.swift | 41 +++++++++++++++-- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/LuxaforPresence/Transport/LuxaforClient.swift b/LuxaforPresence/Transport/LuxaforClient.swift index f8ccdbf..f46d53e 100644 --- a/LuxaforPresence/Transport/LuxaforClient.swift +++ b/LuxaforPresence/Transport/LuxaforClient.swift @@ -11,33 +11,64 @@ final class LuxaforClient: LuxaforClientProtocol { private let endpoint = URL(string: "https://api.luxafor.com/webhook/v1/actions/solid_color")! private let session = URLSession(configuration: .ephemeral) private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "LuxaforClient") + private let maxAttempts = 3 func turnOnRed(userId: String) { - post(["userId": userId, "actionFields": LuxaforColor.red.remoteActionFields]) + post(["userId": userId, "actionFields": LuxaforColor.red.remoteActionFields], action: "red", attempt: 1) } func turnOnYellow(userId: String) { - post(["userId": userId, "actionFields": LuxaforColor.orange.remoteActionFields]) + post(["userId": userId, "actionFields": LuxaforColor.orange.remoteActionFields], action: "orange", attempt: 1) } func turnOff(userId: String) { - post(["userId": userId, "actionFields": LuxaforColor.off.remoteActionFields]) + post(["userId": userId, "actionFields": LuxaforColor.off.remoteActionFields], action: "off", attempt: 1) } - private func post(_ body: [String: Any]) { + private func post(_ body: [String: Any], action: String, attempt: Int) { + logger.debug("Sending remote webhook action \(action, privacy: .public) attempt \(attempt, privacy: .public) to \(self.endpoint.absoluteString, privacy: .public)") var req = URLRequest(url: endpoint) req.httpMethod = "POST" req.addValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: body) let task = session.dataTask(with: req) { _, resp, err in if let err = err { - self.logger.error("Local webhook request failed: \(err.localizedDescription, privacy: .public)") + self.logger.error("Remote webhook request failed on attempt \(attempt, privacy: .public): \(err.localizedDescription, privacy: .public)") + self.retryIfNeeded(body, action: action, attempt: attempt) return } - if let http = resp as? HTTPURLResponse, http.statusCode != 200 { - self.logger.error("Local webhook returned status \(http.statusCode, privacy: .public)") + if let http = resp as? HTTPURLResponse { + if http.statusCode != 200 { + self.logger.error("Remote webhook returned status \(http.statusCode, privacy: .public) on attempt \(attempt, privacy: .public)") + self.retryIfNeeded(body, action: action, attempt: attempt) + } else { + self.logger.debug("Remote webhook succeeded for action \(action, privacy: .public) on attempt \(attempt, privacy: .public)") + } + } else { + self.logger.error("Remote webhook missing HTTP response on attempt \(attempt, privacy: .public)") + self.retryIfNeeded(body, action: action, attempt: attempt) } } task.resume() } + + private func retryIfNeeded(_ body: [String: Any], action: String, attempt: Int) { + guard attempt < maxAttempts else { + logger.error("Remote webhook giving up after \(attempt, privacy: .public) attempts for action \(action, privacy: .public)") + return + } + let delaySeconds = retryDelaySeconds(for: attempt) + logger.debug("Scheduling retry \(attempt + 1, privacy: .public) in \(delaySeconds, privacy: .public)s for action \(action, privacy: .public)") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delaySeconds) { + self.post(body, action: action, attempt: attempt + 1) + } + } + + private func retryDelaySeconds(for attempt: Int) -> Double { + switch attempt { + case 1: return 0.2 + case 2: return 0.5 + default: return 1.0 + } + } } diff --git a/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift b/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift index fdaa4b5..4aee9dd 100644 --- a/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift +++ b/LuxaforPresence/Transport/LuxaforLocalWebhookClient.swift @@ -6,6 +6,7 @@ final class LuxaforLocalWebhookClient: LuxaforClientProtocol { private let token: String private let session: URLSession private let logger = Logger(subsystem: "com.example.LuxaforPresence", category: "LuxaforLocalWebhookClient") + private let maxAttempts = 3 init(baseURL: String, token: String, session: URLSession = URLSession(configuration: .ephemeral)) { self.baseURL = URL(string: baseURL)?.standardized ?? URL(string: "http://127.0.0.1:5383")! @@ -26,10 +27,15 @@ final class LuxaforLocalWebhookClient: LuxaforClientProtocol { } private func postColor(_ color: LuxaforColor) { + postColor(color, attempt: 1) + } + + private func postColor(_ color: LuxaforColor, attempt: Int) { guard let url = URL(string: "color", relativeTo: baseURL) else { logger.error("Failed to build local webhook URL for color endpoint") return } + logger.debug("Sending local webhook color \(color.localHex, privacy: .public) attempt \(attempt, privacy: .public) to \(url.absoluteString, privacy: .public)") var req = URLRequest(url: url) req.httpMethod = "POST" req.addValue("application/json", forHTTPHeaderField: "Content-Type") @@ -38,13 +44,42 @@ final class LuxaforLocalWebhookClient: LuxaforClientProtocol { let task = session.dataTask(with: req) { _, resp, err in if let err = err { - self.logger.error("Local webhook request failed: \(err.localizedDescription, privacy: .public)") + self.logger.error("Local webhook request failed on attempt \(attempt, privacy: .public): \(err.localizedDescription, privacy: .public)") + self.retryIfNeeded(color, attempt: attempt) return } - if let http = resp as? HTTPURLResponse, http.statusCode != 200 { - self.logger.error("Local webhook returned status \(http.statusCode, privacy: .public)") + if let http = resp as? HTTPURLResponse { + if http.statusCode != 200 { + self.logger.error("Local webhook returned status \(http.statusCode, privacy: .public) on attempt \(attempt, privacy: .public)") + self.retryIfNeeded(color, attempt: attempt) + } else { + self.logger.debug("Local webhook succeeded for color \(color.localHex, privacy: .public) on attempt \(attempt, privacy: .public)") + } + } else { + self.logger.error("Local webhook missing HTTP response on attempt \(attempt, privacy: .public)") + self.retryIfNeeded(color, attempt: attempt) } } task.resume() } + + private func retryIfNeeded(_ color: LuxaforColor, attempt: Int) { + guard attempt < maxAttempts else { + logger.error("Local webhook giving up after \(attempt, privacy: .public) attempts for color \(color.localHex, privacy: .public)") + return + } + let delaySeconds = retryDelaySeconds(for: attempt) + logger.debug("Scheduling retry \(attempt + 1, privacy: .public) in \(delaySeconds, privacy: .public)s for color \(color.localHex, privacy: .public)") + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delaySeconds) { + self.postColor(color, attempt: attempt + 1) + } + } + + private func retryDelaySeconds(for attempt: Int) -> Double { + switch attempt { + case 1: return 0.2 + case 2: return 0.5 + default: return 1.0 + } + } } From 79e4c9607791aef4fc83d948967988abe23e15ac Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 16:57:19 -0800 Subject: [PATCH 10/13] Fix README - 1st Draft --- README.md | 151 +++++++++++++++++++++++++++--------------------------- 1 file changed, 76 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 1a41e83..6202423 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,31 @@ # LuxaforPresence for macOS -macOS menu bar app that updates a [Luxafor flag](https://luxafor.com/product/flag/) based on meeting signals. +Native macOS menu bar app that updates a [Luxafor flag](https://luxafor.com/product/flag/) if you are actually in a meeting. -## Why It Exists +## Why -Calendar-only detection misses ad hoc calls and huddles. Mic-only detection is noisy when other tools keep devices open. This app combines several signals so the light reflects what is happening. +To show your family members that you are on a call at the moment. -Signals used: +Luxafor presense light comes witn an app, that integrates into Teams, Google Calendar, Zoom and a few other things. However, +Only one integration can be active at a time, and Teams integration require approval from corporate IT team, which makes this a non starter for a lot of folks. -- Meeting app detectors: Zoom, Teams, Webex, Slack Huddles, and Google Meet (process checks, Accessibility UI hints, or browser tab state). -- Mic/camera state from CoreAudio, CoreMediaIO, and AVFoundation. -- Voice activity (VAD) to distinguish active speaking from a silent meeting. -- Calendar context (optional) to catch muted webinars or screen shares. -- Screen sharing and audio output (roadmap). -- Manual overrides for edge cases. +This is normal to use multiple call apps, for example Slack huddles for pairing, Teams for scheduled meetings, Zoom with external customers and Google Meet for Google Cloud Support calls. -Everything runs locally. The app only sends Luxafor state updates to the Luxafor webhook API. +It's actually non-trivial to detect "on a call". Stracking camara use works, but a lot of calls do not use camera. +Mic-only detection does not work when there when apps like MOTIVMix or OBS Stido keep mic always in use +Calendar-based detection misses ad hoc calls and huddles and there could be meeting on the calendar that you will not attend. -## Project Status - -Alpha. Meeting detectors (Zoom/Webex/Teams/Slack/Google Meet), mic/cam state, and voice activity detection are implemented. Calendar signals are optional via config. Expect changes. +## How it works -## How Detection Works +This app works as add-on to the exiting Luxafor App, both apps need to installed. -1. **Signals collect raw facts** - - `MeetingDetector` checks Zoom, Webex, Teams, Slack Huddles, and Google Meet. - - `MicCamSignal` inspects mic and camera devices to see if they are in use. - - `VoiceActivitySignal` listens for speech (when `vadEnabled` is on). - - `CalendarSignal` can optionally mark meetings from EventKit (when `useCalendar` is true). - - `FrontmostAppSignal` only contributes when `debugAssumeFrontmostImpliesMic` is enabled. -2. **PresenceEngine evaluates** - Each tick, the engine combines detectors, calendar signal, and the optional debug frontmost check. If a meeting is active, voice activity decides between `inMeeting` (red) and `inMeetingSilent` (yellow). Manual overrides can pin the state. -3. **LuxaforTransport updates the flag** - When the state changes, the transport layer sends the new color to the Luxafor webhook API. +The app runs on the backround and tries to detect if camera is on +or if there an active meeting UI app running, like Slack Huddle or Teams call by using Accessiblity framework. +If a meeting is active, voice activity decides between `inMeeting` (red) and `inMeetingSilent` (yellow) -See `LuxaforPresence/Model` and `LuxaforPresence/Signals` for the types involved, and `LuxaforPresence/Resources/config.plist` for tunables such as the allowlisted bundles. +When on meeting state changes, the app calls the Luxafor webhook API to change the LED light. +By default it uses the local Luxafor webhook (`http://127.0.0.1:5383`) and can be switched to the remote Luxafor webhook via config. +Sometimes local webhook can be less reliable than remote webhook api. ## Screenshots @@ -42,87 +33,97 @@ See `LuxaforPresence/Model` and `LuxaforPresence/Signals` for the types involved | --- | --- | | ![LuxaforPresence menu when On](docs/images/on.png) | ![LuxaforPresence menu when Off)](docs/images/off.png) | +## Project Status + +Beta. Should work for Slack and Teams for resent versions of MacOS. + +| Info | Status | Notes | Method | +| -------------| ---------|---------------------------------------------------|---------------------| +| Mic | 🟢 | Detected, not used in the meeting detection logic | MacOS Native | +| Camera | 🟢 | Detected, camera usage turns "on a call" flag | MacOS Native | +| Slack Huddle | 🟢 | Detected, Slack Huddle turns "on a call", "muted" | MacOS Accessibility | +| Slack Call | | Roadmap | MacOS Accessibility | +| Teams Meeting | 🟢 | Detected, Teams Meeting turns "on a call", "muted" | MacOS Accessibility | +| Teams Call | 🟡 | Implemented, needs more testing | MacOS Accessibility | +| Voice Actovity| 🟢 | Voice Activity transtions "on a call", "muted" -> "on a call" | MacOS Native, VAD | +| Calendar | 🟡 | Implemented, not tested. | MacOS Calendar | +| Manual | 🟢 | Manually set "on a call" ON or OF | Menu Bar UI | +| Screen Sharing| | Roadmap | MacOS Native ? | +| Zoom | | Roadmap | | +| Google Meet | | Roadmap | | +| Facetime | | Roadmap | | + ## Prerequisites * macOS 13.0 or newer (Apple Silicon or Intel). * Xcode 14.3+ or Xcode Command Line Tools with Swift 5.7 (`xcode-select --install`). -* A [Luxafor flag](https://luxafor.com/product/flag/) and Luxafor webhook `userId`. +* A [Luxafor flag](https://luxafor.com/product/flag/) with [Luxafor software](https://www.luxaformanual.com/) installed. +* If using the remote Luxafor webhook, register Luxafor `userId`. + ## Setup -1. **Clone the repository.** -2. **Provide Luxafor User ID:** - * The `userId` is loaded from a configuration file. You have two options: - * **Option 1: (Recommended)** Create a configuration file at `~/.config/LuxaforPresence/config.plist` (or `~/Library/Application Support/LuxaforPresence/config.plist`). The app will create the directory for you. You can copy the bundled config file and edit it. - * **Option 2:** Edit the bundled configuration file at `LuxaforPresence/Resources/config.plist` and replace `YOUR_USER_ID_HERE` with your actual Luxafor `userId`. Note that this change will be overwritten if you pull new updates from the repository. +1. [Download](https://github.com/kantselovich/LuxaforPresence/releases) and install the app. + +2. **Configure Luxafor transport:** + * Create a configuration file at `~/.config/LuxaforPresence/config.plist` The app will create the directory for you. You can copy the bundeled config file and edit it. ```xml - - - - - userId - YOUR_USER_ID_HERE + transportMode + local + localWebhookBaseUrl + http://127.0.0.1:5383 + localWebhookToken + luxafor + remoteWebhookUserId + LUXAFOR_USER_ID_HERE - ``` -3. **Assets already included:** - The status bar icons (`StatusIconOn/Off/Idle`) ship inside `LuxaforPresence/Resources/Assets.xcassets`; no manual setup is required. If you replace them, keep the same filenames or update `StatusIcon.swift`. -4. **Optional config knobs:** - `enabledMeetingDetectors` lets you limit which app detectors run (Zoom/Webex/Teams/Slack/GoogleMeet). `useCalendar` toggles EventKit checks. `vadEnabled`, `vadThreshold`, and `vadGraceSeconds` tune voice activity detection. `meetingBundles` is only used for the debug frontmost override. + * To use the remote webhook, set `transportMode` to `remote` and provide `remoteWebhookUserId`. ## Permissions -LuxaforPresence relies on macOS privacy permissions to gather signals: +LuxaforPresence relies on macOS privacy permissions to be able to detect "on a call" state: -- **Microphone/Camera:** required for mic/cam state and voice activity detection. -- **Accessibility:** required for Teams and Slack meeting UI detection. -- **Automation (Apple Events):** required for Google Meet tab checks in Chrome/Safari. +- **Microphone:** required for voice activity detection. The app does not record audio, it capures small buffer of audio input to detect if there is voice pattern present. +- **Microphone/Camera:** required to detect when camera is on and detect available camera devices. No video recoding. +- **Accessibility:** required to detect if there is active Teams Meeting or Slack huddle. It checks Accessiblity for a list of apps defined - **Calendar (optional):** required only when `useCalendar` is true. + +# Development + ## How to Build and Run All commands are executed from the repository root and require the Xcode toolchain. -| Action | Command | Notes | -| --- | --- | --- | -| Build (debug) | `swift build` | Produces `.build/debug/LuxaforPresence`. | -| Run (debug) | `swift run` | Launches the menu bar app with sandbox + LSUIElement settings. | -| Run (release) | `swift run -c release` | Good for long manual tests with the physical Luxafor. | -| Run tests | `swift test` | Executes `PresenceEngineTests` and `LuxaforClientTests`. | +`swift build Produces debug build in `.build/debug/LuxaforPresence`. +`swift run` Produces debug build and launches the menu bar app. The app started this way will be identified as it's parent Terminal app (iTerm2, Ghostty, etc) +`swift run -c release` Produces normal build. +`swift test` Produces debug build and runs test suite `LuxaforPresence/Tests` -If you prefer launching the compiled binary manually, run `.build/debug/LuxaforPresence`; the menu bar icon should appear within a second of launch. - -## How to Debug +## Packaging ```bash -# run as admin, set 'category' to specific areas, like SlackMeetingDetector or PresenceEngine -log stream --level debug --predicate 'subsystem == "com.example.LuxaforPresence" && (category == "PresenceEngine" || category == "VoiceActivitySignal")' +./scripts/package-dmg.sh ``` -## Package as a DMG +The script defaults to the `release` configuration and creates `dist/LuxaforPresence.dmg` containing `LuxaforPresence.app`. +It needs the standard macOS tools (`swift`, `hdiutil`, `plutil`) available in `$PATH`. -Use the helper script to build the release binary, wrap it in an `.app`, and produce a disk image you can distribute: +## Troubleshooting & Debugging +1. Check log stream ```bash -./scripts/package-dmg.sh +# run as admin, set 'category' to specific areas, like SlackMeetingDetector or PresenceEngine +log stream --level debug --predicate 'subsystem == "com.example.LuxaforPresence" && (category == "PresenceEngine" || category == "VoiceActivitySignal")' ``` +2. Confirm Accessibility access is granted to LuxaforPresence (or Terminal/Xcode when running from `swift run`). The app prompts on first launch. + Remove LuxaforPresence app from Accessibility and add it back. -The script defaults to the `release` configuration and creates `dist/LuxaforPresence.dmg` containing `LuxaforPresence.app`. Pass `-c debug` to package a debug build or `-n ` to change the mounted volume title. You’ll need the standard macOS tools (`swift`, `hdiutil`, `plutil`) available in your `$PATH`. - -## Troubleshooting Detection - -1. If Teams or Slack meetings are not detected, confirm Accessibility access is granted to LuxaforPresence (or Terminal/Xcode when running from `swift run`). The app prompts on first launch. -2. If Google Meet is not detected, ensure Chrome or Safari is allowed under System Settings → Privacy & Security → Automation, and that the Meet tab is audible. -3. Tail diagnostics with `log stream --predicate 'subsystem == "com.example.LuxaforPresence"'`. Each timer tick prints per-device mic/cam states plus CoreAudio and CoreMediaIO information, for example: - * `MicCamSignal` logs every `AVCaptureDevice` by localized name and whether `isInUseByAnotherApplication` returned `true`. - * CoreAudio status lines enumerate the default input plus every running input-capable device so you can see whether HAL reports activity even when AVFoundation does not. - * CMIO status lines record each camera’s device/UID along with its `DeviceIsRunningSomewhere` flag, which catches cases where Teams/Zoom doesn’t toggle `AVCaptureDevice.isInUseByAnotherApplication`. -4. If you need to verify Luxafor state transitions while debugging mic detection, set `debugAssumeFrontmostImpliesMic` to `true` inside your `config.plist`. When the foreground bundle is allowlisted, `PresenceEngine` will treat the mic/cam signal as active and emit the usual Luxafor updates so the rest of the pipeline can be tested in isolation. - -## How to Install Dependencies +## Dependencies -This project uses native macOS frameworks (`AppKit`, `AVFoundation`, `CoreAudio`, `EventKit`) and has no external package dependencies. The Swift Package Manager will handle the project setup. +This project has no external package dependencies. The Swift Package Manager will handle the project setup. ## License From 57f4f3df4757c99dc6e6e4d752d871d77b7da18d Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 17:18:15 -0800 Subject: [PATCH 11/13] Edited README --- README.md | 72 +++++++++++++++++++++++++++---------------------------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 6202423..cec3923 100644 --- a/README.md +++ b/README.md @@ -4,28 +4,26 @@ Native macOS menu bar app that updates a [Luxafor flag](https://luxafor.com/prod ## Why -To show your family members that you are on a call at the moment. +To show your family members that you are on a call at the moment. -Luxafor presense light comes witn an app, that integrates into Teams, Google Calendar, Zoom and a few other things. However, -Only one integration can be active at a time, and Teams integration require approval from corporate IT team, which makes this a non starter for a lot of folks. +The Luxafor Presence light comes with an app that integrates with Teams, Google Calendar, Zoom, and a few other tools. However, only one integration can be active at a time, and Teams integration can require approval from a corporate IT team, which makes this a non-starter for many people. -This is normal to use multiple call apps, for example Slack huddles for pairing, Teams for scheduled meetings, Zoom with external customers and Google Meet for Google Cloud Support calls. +It is normal to use multiple call apps, for example Slack huddles for pairing, Teams for scheduled meetings, Zoom with external customers, and Google Meet for Google Cloud Support calls. -It's actually non-trivial to detect "on a call". Stracking camara use works, but a lot of calls do not use camera. -Mic-only detection does not work when there when apps like MOTIVMix or OBS Stido keep mic always in use -Calendar-based detection misses ad hoc calls and huddles and there could be meeting on the calendar that you will not attend. +It is non-trivial to detect "on a call." Tracking camera use helps, but many calls do not use a camera. +Mic-only detection does not work well when apps like MOTIVMix or OBS Studio keep the mic in use. +Calendar-based detection misses ad hoc calls and huddles, and there may be calendar meetings you will not attend. ## How it works -This app works as add-on to the exiting Luxafor App, both apps need to installed. +This app works as an add-on to the existing Luxafor app, and both apps need to be installed. -The app runs on the backround and tries to detect if camera is on -or if there an active meeting UI app running, like Slack Huddle or Teams call by using Accessiblity framework. -If a meeting is active, voice activity decides between `inMeeting` (red) and `inMeetingSilent` (yellow) +The app runs in the background and tries to detect whether the camera is on, or whether there is active meeting UI in apps like Slack Huddle or Teams via the Accessibility framework. +If a meeting is active, voice activity decides between `inMeeting` (red) and `inMeetingSilent` (yellow). -When on meeting state changes, the app calls the Luxafor webhook API to change the LED light. +When meeting state changes, the app calls the Luxafor webhook API to change the LED light. By default it uses the local Luxafor webhook (`http://127.0.0.1:5383`) and can be switched to the remote Luxafor webhook via config. -Sometimes local webhook can be less reliable than remote webhook api. +Sometimes the local webhook can be less reliable than the remote webhook API. ## Screenshots @@ -35,23 +33,24 @@ Sometimes local webhook can be less reliable than remote webhook api. ## Project Status -Beta. Should work for Slack and Teams for resent versions of MacOS. +Beta. Should work for Slack and Teams on recent versions of macOS. | Info | Status | Notes | Method | | -------------| ---------|---------------------------------------------------|---------------------| -| Mic | 🟢 | Detected, not used in the meeting detection logic | MacOS Native | -| Camera | 🟢 | Detected, camera usage turns "on a call" flag | MacOS Native | -| Slack Huddle | 🟢 | Detected, Slack Huddle turns "on a call", "muted" | MacOS Accessibility | -| Slack Call | | Roadmap | MacOS Accessibility | -| Teams Meeting | 🟢 | Detected, Teams Meeting turns "on a call", "muted" | MacOS Accessibility | -| Teams Call | 🟡 | Implemented, needs more testing | MacOS Accessibility | -| Voice Actovity| 🟢 | Voice Activity transtions "on a call", "muted" -> "on a call" | MacOS Native, VAD | -| Calendar | 🟡 | Implemented, not tested. | MacOS Calendar | -| Manual | 🟢 | Manually set "on a call" ON or OF | Menu Bar UI | -| Screen Sharing| | Roadmap | MacOS Native ? | -| Zoom | | Roadmap | | -| Google Meet | | Roadmap | | -| Facetime | | Roadmap | | +| Mic | 🟢 | Detected, not used in the meeting detection logic | macOS Native | +| Camera | 🟢 | Detected, camera usage sets "on a call" | macOS Native | +| Slack Huddle | 🟢 | Detected, Slack Huddle turns "on a call", "muted" | macOS Accessibility | +| Slack Call | | Roadmap | macOS Accessibility | +| Teams Meeting | 🟢 | Detected, Teams Meeting turns "on a call", "muted" | macOS Accessibility | +| Teams Call | 🟡 | Implemented, needs more testing | macOS Accessibility | +| Voice Activity| 🟢 | Voice activity transitions "on a call", "muted" -> "on a call" | macOS Native, VAD | +| Calendar | 🟡 | Implemented, limited testing | macOS Calendar | +| Manual | 🟢 | Manually set "on a call" ON or OFF | Menu Bar UI | +| Screen Sharing| | Roadmap | macOS Native ? | +| Zoom | 🟡 | Implemented (process-based), needs more testing | Process check | +| Webex | 🟡 | Implemented (process-based), needs more testing | Process check | +| Google Meet | 🟡 | Implemented (browser tab + audio), needs more testing | AppleScript + browser | +| FaceTime | | Roadmap | | ## Prerequisites @@ -66,7 +65,7 @@ Beta. Should work for Slack and Teams for resent versions of MacOS. 1. [Download](https://github.com/kantselovich/LuxaforPresence/releases) and install the app. 2. **Configure Luxafor transport:** - * Create a configuration file at `~/.config/LuxaforPresence/config.plist` The app will create the directory for you. You can copy the bundeled config file and edit it. + * Create a configuration file at `~/.config/LuxaforPresence/config.plist`. The app will create the directory for you. You can copy the bundled config file and edit it. ```xml transportMode @@ -85,9 +84,10 @@ Beta. Should work for Slack and Teams for resent versions of MacOS. LuxaforPresence relies on macOS privacy permissions to be able to detect "on a call" state: -- **Microphone:** required for voice activity detection. The app does not record audio, it capures small buffer of audio input to detect if there is voice pattern present. -- **Microphone/Camera:** required to detect when camera is on and detect available camera devices. No video recoding. -- **Accessibility:** required to detect if there is active Teams Meeting or Slack huddle. It checks Accessiblity for a list of apps defined +- **Microphone:** required for voice activity detection. The app does not record audio; it analyzes small input buffers to detect speech patterns. +- **Camera:** required to detect when the camera is in use and enumerate available camera devices. No video recording. +- **Accessibility:** required to detect active Teams meetings or Slack huddles. +- **Automation (Apple Events):** required for Google Meet checks in Chrome/Safari. - **Calendar (optional):** required only when `useCalendar` is true. @@ -97,10 +97,10 @@ LuxaforPresence relies on macOS privacy permissions to be able to detect "on a c All commands are executed from the repository root and require the Xcode toolchain. -`swift build Produces debug build in `.build/debug/LuxaforPresence`. -`swift run` Produces debug build and launches the menu bar app. The app started this way will be identified as it's parent Terminal app (iTerm2, Ghostty, etc) -`swift run -c release` Produces normal build. -`swift test` Produces debug build and runs test suite `LuxaforPresence/Tests` +- `swift build` produces a debug build in `.build/debug/LuxaforPresence`. +- `swift run` produces a debug build and launches the menu bar app. The app started this way will be identified as its parent Terminal app (iTerm2, Ghostty, etc.). +- `swift run -c release` produces an optimized build. +- `swift test` produces a debug build and runs test suite `LuxaforPresence/Tests`. ## Packaging @@ -119,7 +119,7 @@ It needs the standard macOS tools (`swift`, `hdiutil`, `plutil`) available in `$ log stream --level debug --predicate 'subsystem == "com.example.LuxaforPresence" && (category == "PresenceEngine" || category == "VoiceActivitySignal")' ``` 2. Confirm Accessibility access is granted to LuxaforPresence (or Terminal/Xcode when running from `swift run`). The app prompts on first launch. - Remove LuxaforPresence app from Accessibility and add it back. + Remove LuxaforPresence from Accessibility and add it back. ## Dependencies From e3c264aa4eae1b471261d445eb55682adda8b564 Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 17:55:57 -0800 Subject: [PATCH 12/13] Fix CHANGELOG and update README --- CHANGELOG.md | 15 +++++++++------ README.md | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f62a62..e8f9f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,17 @@ # Changelog -All notable changes to LuxaforPresence will be documented here. This project is currently in alpha; expect rapid iteration and possible breaking changes between versions. +All notable changes to LuxaforPresence will be documented here. -## [1.5.0] – Current (from `LuxaforPresence/Info.plist`) +## [1.5.0] – Current -- Current app version is `1.5.0` (from `CFBundleShortVersionString`). -- “in meeting” state is detected using camera/mic activity, voice activity, plus checks for common meeting apps like Slack and Teams showing UI elements that indicate an active meeting. -- build on MacOS 26 +- Version `1.5.0` "Beta", the app actually works for Teams and Slack, needs more testing +- Accessibliy Framework to detect Teams and Slack meetings +- Voice Activity Detection (VAD) support +- 3 states: in a meeting (muted) , in a meeting, Off +- Support Local Luxafor Webhook +- build on MacOS 26 -## [v0.01] – First Tagged Version (check via `git ls-remote --tags origin`) +## [v0.01] – First Tagged Version - First tagged version on the remote is `v0.01`. - Initial menu bar app that infers “in meeting” state using mic/camera activity plus a foreground-app allowlist and updates the Luxafor flag accordingly. diff --git a/README.md b/README.md index cec3923..68baf9b 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,8 @@ All commands are executed from the repository root and require the Xcode toolcha - `swift build` produces a debug build in `.build/debug/LuxaforPresence`. - `swift run` produces a debug build and launches the menu bar app. The app started this way will be identified as its parent Terminal app (iTerm2, Ghostty, etc.). - `swift run -c release` produces an optimized build. -- `swift test` produces a debug build and runs test suite `LuxaforPresence/Tests`. +- `swift test --enable-code-coverage` produces a debug build, runs test suite `LuxaforPresence/Tests` and creates coverage data in `.build/debug/codecov` +- `xcrun llvm-cov report .build/debug/LuxaforPresencePackageTests.xctest/Contents/MacOS/LuxaforPresencePackageTests -instr-profile=.build/debug/codecov/default.profdata -ignore-filename-regex=".build/|Tests/" -use-color` test coverage report ## Packaging From 8c606fc82c5e1edc5e69c0a367ff32cbced7f68b Mon Sep 17 00:00:00 2001 From: Konstantin Antselovich Date: Fri, 6 Feb 2026 19:08:52 -0800 Subject: [PATCH 13/13] Fix Force ON/OFF State --- CHANGELOG.md | 1 + LuxaforPresence/AppDelegate.swift | 6 ++++-- LuxaforPresence/PresenceEngine.swift | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f9f55..689cc2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to LuxaforPresence will be documented here. - Voice Activity Detection (VAD) support - 3 states: in a meeting (muted) , in a meeting, Off - Support Local Luxafor Webhook +- BUGFIX: Forced ON/OFF state would stick, requiring app restart to clear - build on MacOS 26 ## [v0.01] – First Tagged Version diff --git a/LuxaforPresence/AppDelegate.swift b/LuxaforPresence/AppDelegate.swift index 826635f..4175f4c 100644 --- a/LuxaforPresence/AppDelegate.swift +++ b/LuxaforPresence/AppDelegate.swift @@ -19,8 +19,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { updateStatusIcon(.unknown) let menu = NSMenu() - menu.addItem(withTitle: "Force ON (Red)", action: #selector(forceOn), keyEquivalent: "") - menu.addItem(withTitle: "Force OFF", action: #selector(forceOff), keyEquivalent: "") + menu.addItem(withTitle: "Force ON (Red)", action: #selector(forceOn), keyEquivalent: "o") + menu.addItem(withTitle: "Force OFF", action: #selector(forceOff), keyEquivalent: "f") + menu.addItem(withTitle: "Auto Detect", action: #selector(forceClear), keyEquivalent: "a") menu.addItem(NSMenuItem.separator()) menu.addItem(withTitle: "Preferences…", action: #selector(openPrefs), keyEquivalent: ",") menu.addItem(withTitle: "Quit", action: #selector(quit), keyEquivalent: "q") @@ -102,6 +103,7 @@ Open System Settings → Privacy & Security → Accessibility, then enable this @objc private func forceOn() { engine.force(.inMeeting) } @objc private func forceOff() { engine.force(.notMeeting) } + @objc private func forceClear() { engine.clear(.unknown) } @objc private func openPrefs() { /* simple NSAlert or NSPanel for remoteWebhookUserId etc. */ } @objc private func quit() { NSApp.terminate(nil) } } diff --git a/LuxaforPresence/PresenceEngine.swift b/LuxaforPresence/PresenceEngine.swift index 590ddbd..bd4c88c 100644 --- a/LuxaforPresence/PresenceEngine.swift +++ b/LuxaforPresence/PresenceEngine.swift @@ -225,6 +225,12 @@ final class PresenceEngine { apply(state) } + func clear(_ state: PresenceState) { + forcedState = nil + logger.log("Force invoked; new forced state \(state.rawValue, privacy: .public)") + apply(state) + } + func tick() { logger.debug("Tick start; forced state \(String(describing: self.forcedState), privacy: .public)") if let s = self.forcedState {