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()