diff --git a/.gitignore b/.gitignore index 03d8314..444fc98 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .build/ build/ .DS_Store +*.o diff --git a/Makefile b/Makefile index 1e1978a..1dfe02e 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,8 @@ preflight: exit 1; } @swift -version 2>/dev/null | awk '/Swift version/ { split($$4, v, "."); if (v[1] < 6) { \ print "error: Swift 6+ required, found " $$4 " — update Command Line Tools (Software Update)"; exit 1 } }' - @sw_vers -productVersion | awk -F. '{ if ($$1 < 14) { \ - print "error: macOS 14 (Sonoma) or newer required, found " $$0; exit 1 } }' + @sw_vers -productVersion | awk -F. '{ if ($$1 < 13) { \ + print "error: macOS 13 (Ventura) or newer required, found " $$0; exit 1 } }' build: preflight swift build -c release diff --git a/Package.swift b/Package.swift index 38f6383..1eb1428 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "jumpcall", - platforms: [.macOS(.v14)], + platforms: [.macOS(.v13)], targets: [ .target( name: "JumpCallKit", diff --git a/README.md b/README.md index 4aee387..80e28fe 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ for Meet. ## Requirements -- macOS 14 (Sonoma) or newer — the mic-usage detection uses a CoreAudio API introduced in 14 +- macOS 13 (Ventura) or newer — on macOS 13, mic-usage detection is disabled since it uses a CoreAudio API introduced in 14 - Swift 6+ toolchain; the Xcode Command Line Tools are enough (`xcode-select --install`), no Xcode needed - Tested on Apple Silicon + macOS 26; Intel should work but is untested — reports welcome diff --git a/Resources/Info.plist b/Resources/Info.plist index 6e09a6a..e525206 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -17,7 +17,7 @@ CFBundleVersion 1 LSMinimumSystemVersion - 14.0 + 13.0 LSUIElement NSAppleEventsUsageDescription diff --git a/Sources/JumpCallKit/JumpCallMain.swift b/Sources/JumpCallKit/JumpCallMain.swift index 5811bec..a1d3eb7 100644 --- a/Sources/JumpCallKit/JumpCallMain.swift +++ b/Sources/JumpCallKit/JumpCallMain.swift @@ -28,11 +28,18 @@ public enum JumpCallMain { } } + // NSApplication.delegate is weak; without this, ARC is free to release + // the delegate (and everything it owns, including the status item) as + // soon as its last use — the assignment below — passes. + @MainActor + private static var delegate: AppDelegate? + @MainActor private static func runMenuBarApp() { let app = NSApplication.shared app.setActivationPolicy(.accessory) let delegate = AppDelegate() + self.delegate = delegate app.delegate = delegate app.run() } diff --git a/Sources/JumpCallKit/Matchers/MeetMatcher.swift b/Sources/JumpCallKit/Matchers/MeetMatcher.swift index 3ac1460..85cf5c9 100644 --- a/Sources/JumpCallKit/Matchers/MeetMatcher.swift +++ b/Sources/JumpCallKit/Matchers/MeetMatcher.swift @@ -139,9 +139,18 @@ public struct MeetMatcher: PlatformMatcher { /// query/fragment) or meet.google.com/lookup/ for edu accounts. /// The landing page, /new, etc. must NOT count as a live call. public static func isMeetingURL(_ url: String) -> Bool { - let code = /https:\/\/meet\.google\.com\/[a-z]{3}-[a-z]{4}-[a-z]{3}([\/?#].*)?/ - let lookup = /https:\/\/meet\.google\.com\/lookup\/[A-Za-z0-9-]+([\/?#].*)?/ - return url.wholeMatch(of: code) != nil || url.wholeMatch(of: lookup) != nil + let patterns = [ + "https://meet\\.google\\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}([/?#].*)?", + "https://meet\\.google\\.com/lookup/[A-Za-z0-9-]+([/?#].*)?", + ] + + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern), + let _ = regex.firstMatch(in: url, options: [], range: NSRange(url.startIndex..., in: url)) { + return true + } + } + return false } static func listScript(for browser: Browser) -> String { diff --git a/Sources/JumpCallKit/Probes/AXWindowProbe.swift b/Sources/JumpCallKit/Probes/AXWindowProbe.swift index bbb6905..7afac98 100644 --- a/Sources/JumpCallKit/Probes/AXWindowProbe.swift +++ b/Sources/JumpCallKit/Probes/AXWindowProbe.swift @@ -28,7 +28,11 @@ public enum AXWindowProbe { /// 2 = contains a Meet meeting code (strongest), 1 = platform marker, 0 = no. public static func titleMatchStrength(_ title: String) -> Int { - if title.contains(/[a-z]{3}-[a-z]{4}-[a-z]{3}/) { return 2 } + let meetCodePattern = "[a-z]{3}-[a-z]{4}-[a-z]{3}" + if let regex = try? NSRegularExpression(pattern: meetCodePattern), + regex.firstMatch(in: title, options: [], range: NSRange(title.startIndex..., in: title)) != nil { + return 2 + } let markers = [ "Meet – ", "Meet - ", "– Meet", "- Meet", "Microsoft Teams", "Webex", "Zoom Meeting", diff --git a/Sources/JumpCallKit/Probes/AudioInputProbe.swift b/Sources/JumpCallKit/Probes/AudioInputProbe.swift index 3efe288..dae49f1 100644 --- a/Sources/JumpCallKit/Probes/AudioInputProbe.swift +++ b/Sources/JumpCallKit/Probes/AudioInputProbe.swift @@ -1,4 +1,5 @@ import CoreAudio +import Darwin import Foundation struct AudioProcessInfo: Sendable { @@ -17,9 +18,38 @@ struct AudioProcessInfo: Sendable { /// Property *listeners* on these objects are known-flaky, so callers poll. enum AudioInputProbe { static func list() -> [AudioProcessInfo] { + guard #available(macOS 14, *), + let processListSelector = selector(named: "kAudioHardwarePropertyProcessObjectList"), + let processPropertyPIDSelector = selector(named: "kAudioProcessPropertyPID"), + let processPropertyIsRunningInputSelector = selector(named: "kAudioProcessPropertyIsRunningInput"), + let processPropertyIsRunningOutputSelector = selector(named: "kAudioProcessPropertyIsRunningOutput"), + let processPropertyBundleIDSelector = selector(named: "kAudioProcessPropertyBundleID") + else { + return [] + } + + return listForMacOS14(processListSelector: processListSelector, + processPropertyPIDSelector: processPropertyPIDSelector, + processPropertyIsRunningInputSelector: processPropertyIsRunningInputSelector, + processPropertyIsRunningOutputSelector: processPropertyIsRunningOutputSelector, + processPropertyBundleIDSelector: processPropertyBundleIDSelector) + } + + static func processesUsingMicrophone() -> [AudioProcessInfo] { + list().filter(\.isRunningInput) + } + + @available(macOS 14, *) + private static func listForMacOS14( + processListSelector: AudioObjectPropertySelector, + processPropertyPIDSelector: AudioObjectPropertySelector, + processPropertyIsRunningInputSelector: AudioObjectPropertySelector, + processPropertyIsRunningOutputSelector: AudioObjectPropertySelector, + processPropertyBundleIDSelector: AudioObjectPropertySelector + ) -> [AudioProcessInfo] { let system = AudioObjectID(kAudioObjectSystemObject) var address = AudioObjectPropertyAddress( - mSelector: kAudioHardwarePropertyProcessObjectList, + mSelector: processListSelector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) var dataSize: UInt32 = 0 @@ -31,25 +61,41 @@ enum AudioInputProbe { AudioObjectGetPropertyData(system, &address, 0, nil, &dataSize, raw.baseAddress!) } guard status == noErr else { return [] } - return objectIDs.compactMap(info(for:)) - } - - static func processesUsingMicrophone() -> [AudioProcessInfo] { - list().filter(\.isRunningInput) + return objectIDs.compactMap { objectID in + info(for: objectID, + processPropertyPIDSelector: processPropertyPIDSelector, + processPropertyIsRunningInputSelector: processPropertyIsRunningInputSelector, + processPropertyIsRunningOutputSelector: processPropertyIsRunningOutputSelector, + processPropertyBundleIDSelector: processPropertyBundleIDSelector) + } } - private static func info(for objectID: AudioObjectID) -> AudioProcessInfo? { - guard let pid: pid_t = scalar(objectID, kAudioProcessPropertyPID) else { return nil } - let input: UInt32 = scalar(objectID, kAudioProcessPropertyIsRunningInput) ?? 0 - let output: UInt32 = scalar(objectID, kAudioProcessPropertyIsRunningOutput) ?? 0 + private static func info( + for objectID: AudioObjectID, + processPropertyPIDSelector: AudioObjectPropertySelector, + processPropertyIsRunningInputSelector: AudioObjectPropertySelector, + processPropertyIsRunningOutputSelector: AudioObjectPropertySelector, + processPropertyBundleIDSelector: AudioObjectPropertySelector + ) -> AudioProcessInfo? { + guard let pid: pid_t = scalar(objectID, processPropertyPIDSelector) else { return nil } + let input: UInt32 = scalar(objectID, processPropertyIsRunningInputSelector) ?? 0 + let output: UInt32 = scalar(objectID, processPropertyIsRunningOutputSelector) ?? 0 return AudioProcessInfo( objectID: objectID, pid: pid, - bundleID: string(objectID, kAudioProcessPropertyBundleID) ?? "", + bundleID: string(objectID, processPropertyBundleIDSelector) ?? "", isRunningInput: input != 0, isRunningOutput: output != 0) } + private static func selector(named symbolName: String) -> AudioObjectPropertySelector? { + guard let handle = dlopen(nil, RTLD_LAZY), + let symbol = dlsym(handle, symbolName) else { + return nil + } + return symbol.assumingMemoryBound(to: AudioObjectPropertySelector.self).pointee + } + private static func scalar(_ objectID: AudioObjectID, _ selector: AudioObjectPropertySelector) -> T? { var address = AudioObjectPropertyAddress( mSelector: selector, diff --git a/Sources/JumpCallKit/Probes/ProcessProbe.swift b/Sources/JumpCallKit/Probes/ProcessProbe.swift index 1b2eca4..085e838 100644 --- a/Sources/JumpCallKit/Probes/ProcessProbe.swift +++ b/Sources/JumpCallKit/Probes/ProcessProbe.swift @@ -2,17 +2,21 @@ import AppKit import Darwin import Foundation +@_silgen_name("proc_listallpids") +func proc_listallpids(_ buffer: UnsafeMutableRawPointer?, _ buffersize: Int32) -> Int32 + +@_silgen_name("proc_name") +func proc_name(_ pid: Int32, _ buffer: UnsafeMutableRawPointer?, _ buffersize: UInt32) -> Int32 + /// Run a body on the main actor from any thread, synchronously. /// AppKit types like NSRunningApplication are main-actor isolated in the /// Swift 6 overlay; detection runs on a background queue, so probes hop over. @discardableResult -func onMain(_ body: @MainActor () -> T) -> T { +func onMain(_ body: @Sendable () -> T) -> T { if Thread.isMainThread { - return MainActor.assumeIsolated(body) - } - return DispatchQueue.main.sync { - MainActor.assumeIsolated(body) + return body() } + return DispatchQueue.main.sync { body() } } enum ProcessProbe { @@ -52,19 +56,16 @@ enum ProcessProbe { return String(decoding: bytes, as: UTF8.self) } - @MainActor static func runningApp(bundleID: String) -> NSRunningApplication? { NSRunningApplication.runningApplications(withBundleIdentifier: bundleID).first } - @MainActor static func runningApp(bundleIDPrefix: String) -> NSRunningApplication? { NSWorkspace.shared.runningApplications.first { $0.bundleIdentifier?.hasPrefix(bundleIDPrefix) == true } } - @MainActor static func isAppRunning(bundleID: String) -> Bool { runningApp(bundleID: bundleID) != nil } diff --git a/Sources/JumpCallKit/Probes/ScriptRunner.swift b/Sources/JumpCallKit/Probes/ScriptRunner.swift index 90c3cf2..20d0150 100644 --- a/Sources/JumpCallKit/Probes/ScriptRunner.swift +++ b/Sources/JumpCallKit/Probes/ScriptRunner.swift @@ -1,5 +1,11 @@ import Foundation +private final class ScriptRunnerSemaphore: @unchecked Sendable { + let semaphore = DispatchSemaphore(value: 0) + func signal() { semaphore.signal() } + func wait(timeout: DispatchTime) -> DispatchTimeoutResult { semaphore.wait(timeout: timeout) } +} + /// Runs AppleScript via /usr/bin/osascript as a child process. /// /// Why not NSAppleScript: it is documented main-thread-only, has no timeout, @@ -15,7 +21,7 @@ enum ScriptRunner { let out = Pipe() proc.standardOutput = out proc.standardError = Pipe() // discard; a denied Automation prompt is not our stdout - let done = DispatchSemaphore(value: 0) + let done = ScriptRunnerSemaphore() proc.terminationHandler = { _ in done.signal() } do { try proc.run()