Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ env:
jobs:
build_and_test:
if: startsWith(github.ref, 'refs/tags/v') == false
runs-on: macos-13
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -36,9 +36,20 @@ 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"

- 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:
Expand All @@ -51,7 +62,7 @@ jobs:

build_dmg:
if: startsWith(github.ref, 'refs/tags/v')
runs-on: macos-13
runs-on: macos-26
steps:
- name: Checkout
uses: actions/checkout@v4
Expand All @@ -67,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"

Expand Down
17 changes: 14 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
# 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.

## [Unreleased] – Alpha Preview
## [1.5.0] – Current

- 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
- BUGFIX: Forced ON/OFF state would stick, requiring app restart to clear
- build on MacOS 26

## [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.
- 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`.
59 changes: 56 additions & 3 deletions LuxaforPresence/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import AppKit
import ApplicationServices
import OSLog

final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusItem: NSStatusItem!
private var timer: Timer?
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")
Expand All @@ -16,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")
Expand All @@ -27,6 +31,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()")
Expand All @@ -40,6 +45,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()
}
Expand All @@ -49,8 +55,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
logger.debug("Status icon updated to state \(state.rawValue, privacy: .public)")
}

private func promptForAccessibilityIfNeeded() {
guard !AXIsProcessTrusted() 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"
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")
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. */ }
@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) }
}
4 changes: 2 additions & 2 deletions LuxaforPresence/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
<key>CFBundleName</key>
<string>LuxaforPresence</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>1.5.0</string>
<key>LSUIElement</key>
<true/>
<key>NSCalendarsUsageDescription</key>
Expand Down
1 change: 1 addition & 0 deletions LuxaforPresence/Model/PresenceState.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
enum PresenceState: String {
case inMeeting = "ON (Red)"
case inMeetingSilent = "ON (Yellow)"
case notMeeting = "OFF"
case unknown = "Unknown"
}
6 changes: 6 additions & 0 deletions LuxaforPresence/Model/TransportMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

enum TransportMode: String {
case local
case remote
}
Loading