// 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) }