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
8 changes: 4 additions & 4 deletions Zerm.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 271;
CURRENT_PROJECT_VERSION = 280;
DEVELOPMENT_ASSET_PATHS = "\"Zerm/Preview Content\"";
DEVELOPMENT_TEAM = V6J6A3VWY2;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -511,7 +511,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.4;
MARKETING_VERSION = 2.7.1;
MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.arcusis.zerm;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG ENABLE_NATIVE_SPEECH_ANALYZER $(inherited)";
Expand All @@ -537,7 +537,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 271;
CURRENT_PROJECT_VERSION = 280;
DEVELOPMENT_ASSET_PATHS = "\"Zerm/Preview Content\"";
DEVELOPMENT_TEAM = V6J6A3VWY2;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -552,7 +552,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.4;
MARKETING_VERSION = 2.7.1;
MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = com.arcusis.zerm;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "ENABLE_NATIVE_SPEECH_ANALYZER $(inherited)";
Expand Down
2 changes: 2 additions & 0 deletions Zerm/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@
<string>Zerm needs to interact with your browser to detect the current website for applying website-specific configurations.</string>
<key>NSScreenCaptureUsageDescription</key>
<string>Zerm needs screen recording access to understand context from your screen for improved transcription accuracy.</string>
<key>NSAudioCaptureUsageDescription</key>
<string>Zerm needs to record the audio your Mac is playing so it can capture and transcribe the other participants in a meeting.</string>
</dict>
</plist>
89 changes: 55 additions & 34 deletions Zerm/PowerMode/PowerModeConfigView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -665,53 +665,74 @@ struct ConfigurationView: View {
let systemAppURLs = FileManager.default.urls(for: .applicationDirectory, in: .systemDomainMask)
let allAppURLs = userAppURLs + localAppURLs + systemAppURLs

var allApps: [URL] = []
let allApps = Self.applicationURLs(in: allAppURLs)

func scanDirectory(_ baseURL: URL, depth: Int = 0) {
// Prevent infinite recursion from circular symlinks
guard depth < 5 else { return }
guard let enumerator = FileManager.default.enumerator(
at: baseURL,
includingPropertiesForKeys: [.isApplicationKey, .isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else { return }
installedApps = allApps.compactMap { url in
guard let bundle = Bundle(url: url),
let bundleId = bundle.bundleIdentifier,
let name = (bundle.infoDictionary?["CFBundleName"] as? String) ??
(bundle.infoDictionary?["CFBundleDisplayName"] as? String) else {
return nil
}
let icon = NSWorkspace.shared.icon(forFile: url.path)
return (url: url, name: name, bundleId: bundleId, icon: icon)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}

/// Collects every `.app` under the given directories without ever descending through a
/// symlink.
///
/// The previous version resolved symlinks and recursed into whatever they pointed at,
/// guarded only by a depth limit. A link into a large or remote tree — a network mount, or
/// anything pointing back up the hierarchy — turned opening the app picker into a long
/// filesystem walk, and the same bundle could be collected several times by different
/// paths. Ported from upstream VoiceInk.
static func applicationURLs(
in appDirectories: [URL],
fileManager: FileManager = .default
) -> [URL] {
var appURLs: [URL] = []
var seenPaths = Set<String>()

for appDirectory in appDirectories {
guard let enumerator = fileManager.enumerator(
at: appDirectory,
includingPropertiesForKeys: [.isApplicationKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else { continue }

for item in enumerator {
guard let url = item as? URL else { continue }
let resolvedURL = url.resolvingSymlinksInPath()
let values = try? url.resourceValues(forKeys: [.isApplicationKey, .isSymbolicLinkKey])

if resolvedURL.pathExtension == "app" {
allApps.append(resolvedURL)
if values?.isSymbolicLink == true {
// A linked app still counts, but nothing behind the link is walked.
enumerator.skipDescendants()

let resolvedURL = url.resolvingSymlinksInPath()
guard resolvedURL.pathExtension.caseInsensitiveCompare("app") == .orderedSame else {
continue
}
if seenPaths.insert(resolvedURL.standardizedFileURL.path).inserted {
appURLs.append(resolvedURL)
}
continue
}

// Traverse symlinked directories manually
var isDirectory: ObjCBool = false
if url != resolvedURL &&
FileManager.default.fileExists(atPath: resolvedURL.path, isDirectory: &isDirectory) &&
isDirectory.boolValue {
enumerator.skipDescendants()
scanDirectory(resolvedURL, depth: depth + 1)
guard values?.isApplication == true
|| url.pathExtension.caseInsensitiveCompare("app") == .orderedSame else {
continue
}
}
}

for baseURL in allAppURLs {
scanDirectory(baseURL)
}

installedApps = allApps.compactMap { url in
guard let bundle = Bundle(url: url),
let bundleId = bundle.bundleIdentifier,
let name = (bundle.infoDictionary?["CFBundleName"] as? String) ??
(bundle.infoDictionary?["CFBundleDisplayName"] as? String) else {
return nil
enumerator.skipDescendants()
if seenPaths.insert(url.standardizedFileURL.path).inserted {
appURLs.append(url)
}
}
let icon = NSWorkspace.shared.icon(forFile: url.path)
return (url: url, name: name, bundleId: bundleId, icon: icon)
}
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }

return appURLs
}

private func saveConfiguration() {
Expand Down
129 changes: 129 additions & 0 deletions Zerm/Recording/MeetingAppDetector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import AppKit
import Combine
import Foundation
import OSLog

/// Notices when a meeting starts, so recording can offer itself instead of being remembered.
///
/// Detection is by running application rather than by watching audio: a call app being *open*
/// is the signal a meeting is about to happen, and it arrives before anyone speaks — which is
/// the only useful moment to offer to record. Browser-based calls are caught through the browser
/// plus the tab URL, which `BrowserURLService` already resolves for Power Mode.
@MainActor
final class MeetingAppDetector: ObservableObject {

/// What a detected meeting looks like.
struct Detection: Equatable {
let appName: String
let bundleID: String
}

/// Native call apps, by bundle identifier.
static let meetingBundleIDs: Set<String> = [
"us.zoom.xos", // Zoom
"com.microsoft.teams", // Teams (classic)
"com.microsoft.teams2", // Teams (new)
"com.cisco.webexmeetingsapp", // Webex
"com.webex.meetingmanager",
"com.skype.skype",
"com.hnc.Discord",
"org.whispersystems.signal-desktop", // Signal
"com.tinyspeck.slackmacgap", // Slack huddles
"com.google.Chrome.app.kjgfgldnnfoeklkmfkjfagphfepbbdan", // Meet PWA
"com.apple.FaceTime"
]

/// URLs that mean a browser tab is in a call.
static let meetingURLFragments = [
"meet.google.com",
"zoom.us/j/",
"teams.microsoft.com/l/meetup-join",
"teams.live.com/meet",
"whereby.com",
"app.gather.town",
"meet.jit.si"
]

@Published private(set) var detected: Detection?

private let logger = Logger(subsystem: "com.arcusis.zerm", category: "MeetingAppDetector")
private var observers: [NSObjectProtocol] = []

/// Detections already offered, so a meeting that is declined is not offered again every
/// time the user switches back to the call window.
private var dismissed: Set<String> = []

var onMeetingStarted: ((Detection) -> Void)?

// MARK: - Lifecycle

func start() {
guard observers.isEmpty else { return }
let center = NSWorkspace.shared.notificationCenter

observers.append(center.addObserver(
forName: NSWorkspace.didLaunchApplicationNotification, object: nil, queue: .main
) { [weak self] note in
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
Task { @MainActor in self?.evaluate(app) }
})

observers.append(center.addObserver(
forName: NSWorkspace.didTerminateApplicationNotification, object: nil, queue: .main
) { [weak self] note in
guard let app = note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
let id = app.bundleIdentifier else { return }
Task { @MainActor in self?.clear(bundleID: id) }
})

// A call app already running when Zerm starts still counts.
for app in NSWorkspace.shared.runningApplications { evaluate(app) }
}

func stop() {
let center = NSWorkspace.shared.notificationCenter
observers.forEach { center.removeObserver($0) }
observers.removeAll()
detected = nil
}

/// The user said no. Do not offer this meeting again until the app goes away and comes back.
func dismiss() {
if let id = detected?.bundleID { dismissed.insert(id) }
detected = nil
}

// MARK: - Detection

private func evaluate(_ app: NSRunningApplication) {
guard let id = app.bundleIdentifier,
Self.meetingBundleIDs.contains(id),
!dismissed.contains(id),
detected == nil else { return }

let detection = Detection(appName: app.localizedName ?? id, bundleID: id)
detected = detection
logger.notice("Meeting app detected: \(id, privacy: .public)")
onMeetingStarted?(detection)
}

private func clear(bundleID: String) {
dismissed.remove(bundleID)
if detected?.bundleID == bundleID { detected = nil }
}

// MARK: - Pure helpers

static func isMeetingApp(bundleID: String) -> Bool {
meetingBundleIDs.contains(bundleID)
}

/// Whether a browser URL is a live call rather than just a meeting-service page.
///
/// Matched on the join path, not the bare domain — `zoom.us` alone is the marketing site,
/// and offering to record it would train the user to dismiss the prompt.
static func isMeetingURL(_ url: String) -> Bool {
let lowered = url.lowercased()
return meetingURLFragments.contains { lowered.contains($0) }
}
}
Loading
Loading