Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.build/
build/
.DS_Store
*.o
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import PackageDescription

let package = Package(
name: "jumpcall",
platforms: [.macOS(.v14)],
platforms: [.macOS(.v13)],
targets: [
.target(
name: "JumpCallKit",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
Expand Down
7 changes: 7 additions & 0 deletions Sources/JumpCallKit/JumpCallMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
15 changes: 12 additions & 3 deletions Sources/JumpCallKit/Matchers/MeetMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,18 @@ public struct MeetMatcher: PlatformMatcher {
/// query/fragment) or meet.google.com/lookup/<slug> 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 {
Expand Down
6 changes: 5 additions & 1 deletion Sources/JumpCallKit/Probes/AXWindowProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 57 additions & 11 deletions Sources/JumpCallKit/Probes/AudioInputProbe.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import CoreAudio
import Darwin
import Foundation

struct AudioProcessInfo: Sendable {
Expand All @@ -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
Expand All @@ -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<T>(_ objectID: AudioObjectID, _ selector: AudioObjectPropertySelector) -> T? {
var address = AudioObjectPropertyAddress(
mSelector: selector,
Expand Down
17 changes: 9 additions & 8 deletions Sources/JumpCallKit/Probes/ProcessProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Sendable>(_ body: @MainActor () -> T) -> T {
func onMain<T: Sendable>(_ 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 {
Expand Down Expand Up @@ -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
}
Expand Down
8 changes: 7 additions & 1 deletion Sources/JumpCallKit/Probes/ScriptRunner.swift
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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()
Expand Down