Files
status-indicators/remote-access/solo-mode-agent.swift
christianandClaude Opus 5.5 0a4352bb92 Watch Pro Tools Solo Mode (AFL + Latch) on the Studio B Mac Studio
A LaunchAgent helper on that Mac (Accessibility) reads Options > Solo Mode
every second into a status file and ticks AFL/Latch on request. APPS
alarms PT SOLO NOT AFL (hold fixes it; in the Sunday auto-fix list) and
PT SOLO CHECK OFF when the helper is not running or not allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:26:26 -04:00

88 lines
4.4 KiB
Swift

// Status panel helper for the Studio B Mac Studio: watches Pro Tools' Options > Solo Mode menu.
//
// Every second it writes ~/Library/Application Support/status-panel/solo-mode.json:
// {"time": ..., "running": true, "trusted": true, "afl": true, "latch": true, "bad": false, "error": ""}
// "bad" is true only when Pro Tools is running and AFL or Latch is not ticked.
// If ~/Library/Application Support/status-panel/solo-mode.fix appears, it ticks what is missing
// (presses the menu items, as a person would) and removes the file.
//
// It reads and presses menus through Accessibility, so macOS asks once:
// System Settings > Privacy & Security > Accessibility > allow "solo-mode-agent".
// Installed as the LaunchAgent org.waterschurch.status-panel.solo-mode by remote-access/install-solo-mode.sh.
import Cocoa
import ApplicationServices
let dir = NSString(string: "~/Library/Application Support/status-panel").expandingTildeInPath
let stateFile = dir + "/solo-mode.json"
let fixFile = dir + "/solo-mode.fix"
func attr(_ e: AXUIElement, _ a: String) -> AnyObject? {
var v: AnyObject?
return AXUIElementCopyAttributeValue(e, a as CFString, &v) == .success ? v : nil
}
func children(_ e: AXUIElement) -> [AXUIElement] { (attr(e, kAXChildrenAttribute) as? [AXUIElement]) ?? [] }
func title(_ e: AXUIElement) -> String { (attr(e, kAXTitleAttribute) as? String) ?? "" }
func items(of e: AXUIElement) -> [AXUIElement] { children(e).flatMap { children($0) } } // item -> menu -> items
func ticked(_ e: AXUIElement) -> Bool { !((attr(e, kAXMenuItemMarkCharAttribute) as? String) ?? "").isEmpty }
func soloItems() -> (afl: AXUIElement?, latch: AXUIElement?, error: String) {
guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.avid.ProTools").first else {
return (nil, nil, "Pro Tools not running")
}
let ax = AXUIElementCreateApplication(app.processIdentifier)
guard let bar = attr(ax, kAXMenuBarAttribute) else { return (nil, nil, "no menu bar") }
guard let options = children(bar as! AXUIElement).first(where: { title($0) == "Options" }) else {
return (nil, nil, "no Options menu")
}
guard let solo = items(of: options).first(where: { title($0) == "Solo Mode" }) else {
return (nil, nil, "no Solo Mode menu")
}
let modes = items(of: solo)
return (modes.first { title($0).hasPrefix("AFL") }, modes.first { title($0) == "Latch" }, "")
}
func write(_ state: [String: Any]) {
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
if let data = try? JSONSerialization.data(withJSONObject: state, options: [.sortedKeys]) {
let tmp = stateFile + ".tmp"
FileManager.default.createFile(atPath: tmp, contents: data)
_ = try? FileManager.default.replaceItemAt(URL(fileURLWithPath: stateFile), withItemAt: URL(fileURLWithPath: tmp))
}
}
// Ask once for Accessibility (shows macOS's own prompt on this Mac's screen).
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
_ = AXIsProcessTrustedWithOptions([promptKey: true] as CFDictionary)
while true {
autoreleasepool {
var state: [String: Any] = ["time": Date().timeIntervalSince1970, "trusted": AXIsProcessTrusted()]
let running = !NSRunningApplication.runningApplications(withBundleIdentifier: "com.avid.ProTools").isEmpty
state["running"] = running
var bad = false
if running && AXIsProcessTrusted() {
var found = soloItems()
if FileManager.default.fileExists(atPath: fixFile) {
try? FileManager.default.removeItem(atPath: fixFile)
if let a = found.afl, !ticked(a) { AXUIElementPerformAction(a, kAXPressAction as CFString) }
Thread.sleep(forTimeInterval: 0.3)
found = soloItems()
if let l = found.latch, !ticked(l) { AXUIElementPerformAction(l, kAXPressAction as CFString) }
Thread.sleep(forTimeInterval: 0.3)
found = soloItems()
}
if let a = found.afl, let l = found.latch {
state["afl"] = ticked(a)
state["latch"] = ticked(l)
bad = !(ticked(a) && ticked(l))
}
state["error"] = found.error
} else {
state["error"] = running ? "Accessibility not allowed yet" : "Pro Tools not running"
}
state["bad"] = bad
write(state)
}
Thread.sleep(forTimeInterval: 1.0)
}