Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Sources/JumpCallKit/CLICommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ enum CLI {
print("hotkey: \(hotkey["display"] as? String ?? "?") active (consumed only while a call is live)")
} else {
print("hotkey: \(hotkey["display"] as? String ?? "?") waiting for Accessibility permission (System Settings → Privacy & Security → Accessibility → JumpCall)")
print(" Already granted it? The grant goes stale when the app is rebuilt/upgraded, and")
print(" re-toggling the old entry never fixes it. Clear it and approve the fresh prompt:")
print(" tccutil reset Accessibility io.github.joncode.jumpcall (then relaunch JumpCall)")
}
}
let hidden = icon["hidden"] as? Bool ?? false
Expand Down
31 changes: 31 additions & 0 deletions Sources/JumpCallKit/Install/InstallCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,18 @@ public enum InstallCommand {
try? fm.createDirectory(
at: installedAppURL.deletingLastPathComponent(), withIntermediateDirectories: true)

var binaryChanged = false
if source.standardizedFileURL.path != installedAppURL.standardizedFileURL.path {
// Ad-hoc signatures pin TCC grants to the exact binary (cdhash),
// so replacing the binary silently kills the Accessibility grant
// behind the hotkey — and re-toggling the stale System Settings
// row never revalidates. Detect the upgrade so we can clear it.
binaryChanged = {
guard let old = try? Data(contentsOf: installedBinURL),
let new = try? Data(contentsOf: source.appending(path: "Contents/MacOS/jumpcall"))
else { return false }
return old != new
}()
terminateRunningInstances()
try? fm.removeItem(at: installedAppURL)
do {
Expand All @@ -61,6 +72,10 @@ public enum InstallCommand {
}
}

if binaryChanged, ConfigStore.load().hotkeyEnabled {
resetStaleAccessibilityGrant()
}

let open = Process()
open.executableURL = URL(fileURLWithPath: "/usr/bin/open")
open.arguments = [installedAppURL.path]
Expand Down Expand Up @@ -255,6 +270,22 @@ public enum InstallCommand {
}
}

/// The upgraded binary can never satisfy the old grant's cdhash, so the
/// hotkey would stay dead no matter how often the user re-toggles the
/// (stale) System Settings row. Clearing the row makes the app's fresh
/// permission prompt actually mean something.
private static func resetStaleAccessibilityGrant() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/tccutil")
proc.arguments = ["reset", "Accessibility", bundleID]
proc.standardOutput = Pipe()
proc.standardError = Pipe()
try? proc.run()
proc.waitUntilExit()
print("hotkey: upgraded binaries invalidate the old Accessibility grant — cleared it;")
print(" approve the fresh prompt (or System Settings → Accessibility → JumpCall)")
}

// MARK: - LaunchAgent fallback

private static func installLaunchAgent() {
Expand Down
100 changes: 63 additions & 37 deletions Sources/JumpCallKit/Matchers/MeetMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,27 @@ public struct Browser: Sendable {
public let appName: String
public let bundleID: String
public let isChromium: Bool
/// Bundle-ID prefixes whose mic usage counts as this browser's.
/// Chromium helpers share the app's prefix, but Safari captures audio in
/// com.apple.WebKit.GPU — no com.apple.Safari prefix — so without the
/// extra entry Safari's mic state is invisible to mic-priority ordering.
public let micBundlePrefixes: [String]

init(id: String, appName: String, bundleID: String, isChromium: Bool,
micBundlePrefixes: [String]? = nil) {
self.id = id
self.appName = appName
self.bundleID = bundleID
self.isChromium = isChromium
self.micBundlePrefixes = micBundlePrefixes ?? [bundleID]
}

/// Browsers jumpcall knows how to script. Chromium-family browsers all
/// share Chrome's AppleScript dictionary. Firefox is absent because it
/// has no AppleScript tab access at all — documented limitation.
public static let known: [String: Browser] = [
"safari": Browser(id: "safari", appName: "Safari", bundleID: "com.apple.Safari", isChromium: false),
"safari": Browser(id: "safari", appName: "Safari", bundleID: "com.apple.Safari", isChromium: false,
micBundlePrefixes: ["com.apple.Safari", "com.apple.WebKit."]),
"chrome": Browser(id: "chrome", appName: "Google Chrome", bundleID: "com.google.Chrome", isChromium: true),
"brave": Browser(id: "brave", appName: "Brave Browser", bundleID: "com.brave.Browser", isChromium: true),
"edge": Browser(id: "edge", appName: "Microsoft Edge", bundleID: "com.microsoft.edgemac", isChromium: true),
Expand All @@ -24,68 +39,79 @@ public struct MeetMatcher: PlatformMatcher {
public let displayName = "Google Meet"
let browsers: [Browser]

/// True when any of `micBundleIDs` belongs to this browser.
public static func holdsMic(_ browser: Browser, micBundleIDs: [String]) -> Bool {
browser.micBundlePrefixes.contains { prefix in
micBundleIDs.contains { $0.hasPrefix(prefix) }
}
}

/// Browsers actively holding the microphone are scanned first: a live
/// call always outranks a lobby/landing page open in another browser
/// (their URLs are indistinguishable — meet.google.com/xxx-yyyy-zzz is
/// the same joined or not, so the mic is the tiebreaker).
public static func orderByMicPriority(_ browsers: [Browser], micBundleIDs: [String]) -> [Browser] {
func holdsMic(_ b: Browser) -> Bool {
micBundleIDs.contains { $0.hasPrefix(b.bundleID) }
}
return browsers.filter(holdsMic) + browsers.filter { !holdsMic($0) }
}

private var micOrderedBrowsers: [Browser] {
Self.orderByMicPriority(
browsers,
micBundleIDs: AudioInputProbe.processesUsingMicrophone().map(\.bundleID))
browsers.filter { holdsMic($0, micBundleIDs: micBundleIDs) }
+ browsers.filter { !holdsMic($0, micBundleIDs: micBundleIDs) }
}

public func detect() -> CallHandle? {
for browser in micOrderedBrowsers {
let micBundleIDs = AudioInputProbe.processesUsingMicrophone().map(\.bundleID)
let micHolders = browsers.filter { Self.holdsMic($0, micBundleIDs: micBundleIDs) }
let rest = browsers.filter { !Self.holdsMic($0, micBundleIDs: micBundleIDs) }

// A browser holding the mic is where the call actually is. Exhaust it
// completely — tab scan, then the profile-agnostic AX window scan —
// before trusting a URL-only hit elsewhere: a lobby/green-room tab in
// an idle browser must never outrank the browser transmitting audio
// (e.g. the call lives in a Chrome profile AppleScript can't see).
for browser in micHolders {
guard onMain({ ProcessProbe.isAppRunning(bundleID: browser.bundleID) }) else { continue }
if let handle = probe(browser) { return handle }
if let handle = axWindowScan(browser) { return handle }
}
for browser in rest {
// Never send Apple Events to a browser that isn't running —
// that would launch it (and prompt for permission pointlessly).
guard onMain({ ProcessProbe.isAppRunning(bundleID: browser.bundleID) }) else { continue }
if let handle = probe(browser) { return handle }
}
// The tab scan only sees what the browser's scripting API exposes —
// Chrome hides other profiles, incognito windows, and PWAs from it.
// The Accessibility scan below is profile-agnostic: it checks every
// open window of every running browser and matches call-looking
// titles directly.
if let handle = axWindowScan() { return handle }
// The Accessibility scan is profile-agnostic: it checks every open
// window of every running browser and matches call-looking titles.
for browser in rest {
if let handle = axWindowScan(browser) { return handle }
}
// Last resort: a browser is actively using the microphone, so a web
// call exists even though no window could be identified.
return micFallback()
}

private func axWindowScan() -> CallHandle? {
for browser in micOrderedBrowsers {
let pids = onMain {
NSRunningApplication.runningApplications(withBundleIdentifier: browser.bundleID)
.map(\.processIdentifier)
}
guard !pids.isEmpty else { continue }
let windows = onMain { AXWindowProbe.allWindows(pids: pids) }
guard let window = AXWindowProbe.pickCallWindow(from: windows) else { continue }
return CallHandle(
platformID: id,
displayName: "Web call — \(browser.appName)",
detail: window.title,
activateBundleID: browser.bundleID,
browserID: browser.id,
windowIndex: nil,
tabIndex: nil,
axWindowTitle: window.title)
private func axWindowScan(_ browser: Browser) -> CallHandle? {
let pids = onMain {
NSRunningApplication.runningApplications(withBundleIdentifier: browser.bundleID)
.map(\.processIdentifier)
}
return nil
guard !pids.isEmpty else { return nil }
let windows = onMain { AXWindowProbe.allWindows(pids: pids) }
guard let window = AXWindowProbe.pickCallWindow(from: windows) else { return nil }
return CallHandle(
platformID: id,
displayName: "Web call — \(browser.appName)",
detail: window.title,
activateBundleID: browser.bundleID,
browserID: browser.id,
windowIndex: nil,
tabIndex: nil,
axWindowTitle: window.title)
}

private func micFallback() -> CallHandle? {
let micUsers = AudioInputProbe.processesUsingMicrophone()
let micBundleIDs = AudioInputProbe.processesUsingMicrophone().map(\.bundleID)
for browser in browsers {
guard onMain({ ProcessProbe.isAppRunning(bundleID: browser.bundleID) }) else { continue }
if micUsers.contains(where: { $0.bundleID.hasPrefix(browser.bundleID) }) {
if Self.holdsMic(browser, micBundleIDs: micBundleIDs) {
return CallHandle(
platformID: id,
displayName: "Web call — \(browser.appName)",
Expand Down
12 changes: 12 additions & 0 deletions Tests/TestRunner/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,18 @@ var failed = 0
[safari, chrome, brave], micBundleIDs: [])
expectEqual(unchanged.map(\.id), ["safari", "chrome", "brave"],
"no mic: config order preserved")

// Safari's mic capture lives in com.apple.WebKit.GPU — no Safari prefix.
let webkitMic = MeetMatcher.orderByMicPriority(
[chrome, safari, brave], micBundleIDs: ["com.apple.WebKit.GPU"])
expectEqual(webkitMic.map(\.id), ["safari", "chrome", "brave"],
"WebKit.GPU mic counts as Safari")
expect(MeetMatcher.holdsMic(safari, micBundleIDs: ["com.apple.WebKit.GPU"]),
"holdsMic: WebKit.GPU -> safari")
expect(!MeetMatcher.holdsMic(chrome, micBundleIDs: ["com.apple.WebKit.GPU"]),
"holdsMic: WebKit.GPU is not chrome")
expect(MeetMatcher.holdsMic(chrome, micBundleIDs: ["com.google.Chrome.helper"]),
"holdsMic: chrome helper -> chrome")
}

// MARK: - Install bundle resolution
Expand Down
Loading