diff --git a/Sources/TranscriberCore/Detection/AutoRecordPolicy.swift b/Sources/TranscriberCore/Detection/AutoRecordPolicy.swift new file mode 100644 index 0000000..3135ff7 --- /dev/null +++ b/Sources/TranscriberCore/Detection/AutoRecordPolicy.swift @@ -0,0 +1,111 @@ +import Foundation + +/// Pure decision layer for auto-record. `DetectionEngine` builds an +/// `Evidence` value per probe sample and consults this policy for both +/// gates: `shouldRecord` decides whether a candidate fires, and +/// `isDefinitelyNotInAMeeting` decides whether a stale active candidate +/// may be cleared. One policy, so a start-gate change cannot drift from +/// the end-of-call gate. `shouldAutoDiscard` decides whether a finished +/// recording is short enough to trash unseen. No state, no clocks; +/// callers own both. +/// +/// Plan: `plans/auto-record.md`. +public enum AutoRecordPolicy { + /// Everything the detection layer knows about a candidate at + /// evaluation time. Built by `DetectionEngine`, nowhere else. + public struct Evidence: Sendable, Equatable { + public var kind: MeetingApp.Kind + /// Result of the input-device probe for this sample. `nil` means + /// the HAL could not answer. + public var probeIsActive: Bool? + /// Result of the tab inspection. `nil` means the candidate is not + /// a browser (native apps are never inspected). + public var browserTab: TabInspectionResult? + public var calendarOverlapsNow: Bool + /// Consecutive positive probe samples multiplied by the sample + /// interval. The engine resets this on a `false` OR `nil` sample + /// for browsers, so HAL indeterminacy never counts as mic time. + public var sustainedMicSeconds: TimeInterval + + public init( + kind: MeetingApp.Kind, + probeIsActive: Bool?, + browserTab: TabInspectionResult? = nil, + calendarOverlapsNow: Bool, + sustainedMicSeconds: TimeInterval + ) { + self.kind = kind + self.probeIsActive = probeIsActive + self.browserTab = browserTab + self.calendarOverlapsNow = calendarOverlapsNow + self.sustainedMicSeconds = sustainedMicSeconds + } + } + + public enum SessionOrigin: Sendable, Equatable { + case manual + case detected + } + + public enum StopInitiator: Sendable, Equatable { + case endGuard + case user + } + + /// The calendar-plus-mic fallback needs this much sustained + /// microphone activity. Shorter bursts are dictation, not calls. + public static let defaultSustainedMicRequirementSeconds: TimeInterval = 30 + + /// Called by `DetectionEngine` before it fires a candidate. + /// A meeting-domain tab records alone. An inspected non-meeting tab + /// never records, whatever the calendar or mic say: the fallback + /// runs only when the tab was unreadable. + public static func shouldRecord( + _ evidence: Evidence, + sustainedMicRequirementSeconds: TimeInterval = defaultSustainedMicRequirementSeconds + ) -> Bool { + switch evidence.kind { + case .nativeMeetingApp: + // Probe true or indeterminate passes, matching the engine's + // existing fire-on-true-or-nil behavior for native apps. + return evidence.probeIsActive != false + case .browser: + switch evidence.browserTab { + case .meeting: + return true + case .notMeeting: + return false + case .unreadable, nil: + return evidence.calendarOverlapsNow + && evidence.sustainedMicSeconds >= sustainedMicRequirementSeconds + } + } + } + + /// Definitive "this app is not in a meeting" verdict used to clear + /// stale active candidates. For browsers an unreadable tab is not + /// proof of absence: it requires both a definitive inactive probe + /// AND an inspected non-meeting tab. + public static func isDefinitelyNotInAMeeting(_ evidence: Evidence) -> Bool { + switch evidence.kind { + case .nativeMeetingApp: + return evidence.probeIsActive == false + case .browser: + return evidence.probeIsActive == false && evidence.browserTab == .notMeeting + } + } + + /// True when a finished recording is short enough to trash unseen. + /// Scoped to EndGuard-initiated stops of detected sessions: an + /// explicit user stop is a decision and always keeps. + public static func shouldAutoDiscard( + durationSeconds: TimeInterval, + origin: SessionOrigin, + initiator: StopInitiator, + thresholdSeconds: TimeInterval + ) -> Bool { + origin == .detected + && initiator == .endGuard + && durationSeconds < thresholdSeconds + } +} diff --git a/Sources/TranscriberCore/Detection/BrowserTabInspector.swift b/Sources/TranscriberCore/Detection/BrowserTabInspector.swift new file mode 100644 index 0000000..fa30cc5 --- /dev/null +++ b/Sources/TranscriberCore/Detection/BrowserTabInspector.swift @@ -0,0 +1,146 @@ +import Foundation + +/// Three-way result of reading a browser's active tab. `unreadable` +/// (Firefox, timeout, script error, missing automation grant) is the +/// only outcome that may take the calendar-plus-mic fallback: an +/// inspected non-meeting tab is a definitive no. +public enum TabInspectionResult: Sendable, Equatable { + case meeting + case notMeeting + case unreadable +} + +/// Seam `DetectionEngine` uses to gate browser candidates. Production +/// is `BrowserTabInspector`; tests inject stubs. +public protocol BrowserTabInspecting: Sendable { + func inspectActiveTab(bundleID: String) async -> TabInspectionResult +} + +/// Answers one question per browser candidate: does the active tab match +/// a meeting domain? Reads the tab with the dialect named on the +/// `MeetingApp` allowlist entry (Safari vs Chromium AppleScript); a +/// `.none` dialect returns `.unreadable` without running anything. +/// +/// Every inspection runs under a hard timeout. A hanging AppleEvent is +/// cancelled, never abandoned: the osascript process is terminated on +/// cancellation, so processes do not leak and a dead browser channel +/// never stalls the detection dwell. +public struct BrowserTabInspector: Sendable { + /// Runs an AppleScript source and returns its stdout. Injected so + /// tests never touch osascript or TCC. Must honor task cancellation. + public typealias ScriptRunner = @Sendable (_ source: String) async throws -> String + + public let timeout: TimeInterval + private let runScript: ScriptRunner + + public init( + timeout: TimeInterval = 2, + runScript: @escaping ScriptRunner = BrowserTabInspector.runOSAScript + ) { + self.timeout = timeout + self.runScript = runScript + } + + public func inspectActiveTab(bundleID: String) async -> TabInspectionResult { + guard let source = Self.script(for: MeetingApps.appFor(bundleID: bundleID)) else { + return .unreadable + } + let runScript = self.runScript + let timeout = self.timeout + let output: String? = await withTaskGroup(of: String?.self) { group in + group.addTask { try? await runScript(source) } + group.addTask { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + defer { group.cancelAll() } + return await group.next() ?? nil + } + guard let output else { return .unreadable } + let lines = output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + let urlLine = lines.first ?? "" + let titleLine = lines.count > 1 ? lines[1] : "" + return Self.matchesMeetingDomain(url: urlLine, title: titleLine) ? .meeting : .notMeeting + } + + /// Strict host match for the URL (exact domain or dotted subdomain), + /// substring match for the title. Internal so tests can lock the + /// matching semantics without a script runner. + static func matchesMeetingDomain(url: String, title: String) -> Bool { + if let host = URL(string: url)?.host?.lowercased() { + for domain in MeetingApps.meetingDomains { + if host == domain || host.hasSuffix("." + domain) { return true } + } + } + let lowered = title.lowercased() + return MeetingApps.meetingDomains.contains { lowered.contains($0) } + } + + /// One template per tab dialect, resolved from the allowlist entry. + /// There is no bundle-ID switch here: a browser with no dialect + /// entry takes the fallback gate by construction. + static func script(for app: MeetingApp?) -> String? { + guard let app else { return nil } + switch app.tabDialect { + case .none: + return nil + case .safari: + return """ + tell application id "\(app.bundleID)" + set u to URL of current tab of front window + set t to name of current tab of front window + return u & linefeed & t + end tell + """ + case .chromium: + return """ + tell application id "\(app.bundleID)" + set u to URL of active tab of front window + set t to title of active tab of front window + return u & linefeed & t + end tell + """ + } + } + + enum InspectionError: Error { + case scriptFailed + } + + /// Production runner: osascript in a child process, terminated on + /// task cancellation so a hung AppleEvent cannot outlive the timeout. + @Sendable + public static func runOSAScript(_ source: String) async throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", source] + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = Pipe() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + process.terminationHandler = { finished in + let data = stdout.fileHandleForReading.readDataToEndOfFile() + guard finished.terminationStatus == 0, + let text = String(data: data, encoding: .utf8) + else { + continuation.resume(throwing: InspectionError.scriptFailed) + return + } + continuation.resume( + returning: text.trimmingCharacters(in: .whitespacesAndNewlines)) + } + do { + try process.run() + } catch { + process.terminationHandler = nil + continuation.resume(throwing: error) + } + } + } onCancel: { + process.terminate() + } + } +} + +extension BrowserTabInspector: BrowserTabInspecting {} diff --git a/Sources/TranscriberCore/Detection/DetectionEngine.swift b/Sources/TranscriberCore/Detection/DetectionEngine.swift index 1ade180..a6a264d 100644 --- a/Sources/TranscriberCore/Detection/DetectionEngine.swift +++ b/Sources/TranscriberCore/Detection/DetectionEngine.swift @@ -15,36 +15,46 @@ public struct DetectionCandidate: Sendable, Equatable, Hashable { } /// Wires `ProcessWatcher` and running-app observations through a per-trigger -/// dwell window, app-level suppression, audio activity probing, duplicate +/// dwell window, audio activity probing, tab inspection, duplicate /// coalescing, and stale-candidate cleanup. /// /// Policy invariants: -/// - Running app/browser presence alone is not a meeting when the probe can -/// determine inactivity. -/// - A transient inactive probe result does not permanently black-hole a -/// plausible app; observation retries until the observation window expires. +/// - Running app/browser presence alone is not a meeting. Native apps +/// need the input-device probe positive or indeterminate. Browsers +/// need a meeting-domain tab match, or (when the tab cannot be read) +/// a calendar overlap plus sustained microphone activity. +/// - A transient negative verdict does not permanently black-hole a +/// plausible app; observation retries until the observation window +/// expires. /// - Repeated observations for the same ongoing app/call/calendar occurrence -/// coalesce into one user-facing candidate until the app quits or a later -/// inactive probe clears the stale active candidate. +/// coalesce into one candidate until the app quits or a later +/// definitive negative clears the stale active candidate. /// - Calendar-enriched candidates key by event ID plus occurrence start when /// available; app/browser-only candidates fall back to an app signature. -/// - App-level suppression is delegated to in-memory `SkipState` and cancels -/// in-flight/active recognition for that app only. +/// - Both gates (fire and stale-clear) flow through `AutoRecordPolicy`: +/// the engine owns evidence collection, the policy owns every rule. public actor DetectionEngine { public typealias TriggerIdentityProvider = @Sendable (MeetingApp) async -> String public typealias OnCandidate = @Sendable (DetectionCandidate) async -> Void public typealias OnCandidateEnded = @Sendable (DetectionCandidate) async -> Void + public typealias CalendarOverlapProvider = @Sendable (Date) async -> Bool private struct ObservationState: Sendable { let candidate: DetectionCandidate let startedAt: Date + /// Consecutive positive probe samples scaled by the sample + /// interval. A `nil` (indeterminate) sample never counts as mic + /// time and resets the streak, so HAL indeterminacy can never + /// satisfy the sustained-mic fallback on its own. + var sustainedMicSeconds: TimeInterval = 0 } private let dwellTime: TimeInterval private let retryInterval: TimeInterval private let observationWindow: TimeInterval - private let skipState: SkipState private let probe: AudioActivityProbe + private let tabInspector: BrowserTabInspecting? + private let calendarOverlapsNow: CalendarOverlapProvider private let now: @Sendable () -> Date private let sleep: @Sendable (TimeInterval) async -> Void private let triggerIdentity: TriggerIdentityProvider @@ -58,8 +68,9 @@ public actor DetectionEngine { dwellTime: TimeInterval = 30, retryInterval: TimeInterval = 5, observationWindow: TimeInterval = 2 * 60, - skipState: SkipState = SkipState(), probe: AudioActivityProbe = UnknownAudioActivityProbe(), + tabInspector: BrowserTabInspecting? = nil, + calendarOverlapsNow: @escaping CalendarOverlapProvider = { _ in false }, now: @escaping @Sendable () -> Date = Date.init, sleep: @escaping @Sendable (TimeInterval) async -> Void = DetectionEngine.sleep(seconds:), triggerIdentity: @escaping TriggerIdentityProvider = DetectionEngine.defaultTriggerIdentity(for:), @@ -69,8 +80,9 @@ public actor DetectionEngine { self.dwellTime = dwellTime self.retryInterval = retryInterval self.observationWindow = observationWindow - self.skipState = skipState self.probe = probe + self.tabInspector = tabInspector + self.calendarOverlapsNow = calendarOverlapsNow self.now = now self.sleep = sleep self.triggerIdentity = triggerIdentity @@ -89,8 +101,6 @@ public actor DetectionEngine { /// safe to call from polling, launch, calendar refresh, wake, or audio-change /// signals: duplicate observations coalesce by trigger identity. public func reevaluate(_ app: MeetingApp) async { - if await skipState.isSuppressed(app.bundleID, now: now()) { return } - let identity = await triggerIdentity(app) let candidate = DetectionCandidate(app: app, triggerIdentity: identity) if activeCandidates[identity] != nil { @@ -132,20 +142,6 @@ public actor DetectionEngine { } } - /// Suppresses `app` for `duration` seconds and cancels any in-flight or - /// coalesced active candidate for that app. Defaults to 30 minutes per spec. - public func suppress(_ app: MeetingApp, for duration: TimeInterval = 30 * 60) async { - await skipState.suppress(app.bundleID, for: duration, now: now()) - for (identity, observation) in pendingObservations where observation.candidate.app.bundleID == app.bundleID { - pendingTasks[identity]?.cancel() - pendingTasks.removeValue(forKey: identity) - pendingObservations.removeValue(forKey: identity) - } - for identity in activeCandidates.keys where activeCandidates[identity]?.app.bundleID == app.bundleID { - activeCandidates.removeValue(forKey: identity) - } - } - private func startObservation(for candidate: DetectionCandidate) { let identity = candidate.triggerIdentity let bundleID = candidate.app.bundleID @@ -158,11 +154,6 @@ public actor DetectionEngine { await sleep(dwell) while !Task.isCancelled { guard let self else { return } - if await self.skipState.isSuppressed(bundleID, now: self.now()) { - await self.finishObservation(identity: identity) - return - } - let shouldContinue = await self.evaluatePendingObservation(identity: identity, observationWindow: window) guard shouldContinue else { return } await sleep(retry) @@ -172,32 +163,53 @@ public actor DetectionEngine { } /// Returns true when the observation should retry after `retryInterval`. + /// Builds `AutoRecordPolicy.Evidence` per sample; fire and stale-clear + /// both consult the policy. private func evaluatePendingObservation(identity: String, observationWindow: TimeInterval) async -> Bool { guard let observation = pendingObservations[identity] else { return false } - let isActive = await probe.isActive(bundleID: observation.candidate.app.bundleID) + let probeIsActive = await probe.isActive(bundleID: observation.candidate.app.bundleID) + if Task.isCancelled { + finishObservation(identity: identity) + return false + } + + var updated = observation + if probeIsActive == .some(true) { + updated.sustainedMicSeconds += retryInterval + } else { + updated.sustainedMicSeconds = 0 + } + pendingObservations[identity] = updated + + let tabResult = await inspectTab(for: observation.candidate) + if Task.isCancelled { finishObservation(identity: identity) return false } - switch isActive { - case true, nil: + let evidence = AutoRecordPolicy.Evidence( + kind: observation.candidate.kind, + probeIsActive: probeIsActive, + browserTab: tabResult, + calendarOverlapsNow: await calendarOverlapsNow(now()), + sustainedMicSeconds: updated.sustainedMicSeconds + ) + if AutoRecordPolicy.shouldRecord(evidence) { await fireCandidate(observation.candidate) return false - case false: - if now().timeIntervalSince(observation.startedAt) >= observationWindow { - finishObservation(identity: identity) - return false - } - return true } + if now().timeIntervalSince(updated.startedAt) >= observationWindow { + finishObservation(identity: identity) + return false + } + return true } /// Returns true when stale active state was cleared and the current /// observation may start a fresh dwell/probe cycle for the same trigger. private func clearStaleActiveCandidateIfNeeded(_ candidate: DetectionCandidate) async -> Bool { - let isActive = await probe.isActive(bundleID: candidate.app.bundleID) - if isActive == false { + if await definitelyNotInAMeeting(candidate) { let ended = activeCandidates.removeValue(forKey: candidate.triggerIdentity) ?? candidate await onCandidateEnded?(ended) return true @@ -207,20 +219,36 @@ public actor DetectionEngine { /// A calendar-scoped candidate can become app-scoped after the calendar /// event ends. If the app is now inactive, clear the old active candidates - /// so the shell can show the stop prompt for the recording. + /// so the shell can stop the recording for the ended call. private func clearEndedCandidatesForSameBundle(as candidate: DetectionCandidate) async { let stale = activeCandidates.values.filter { $0.app.bundleID == candidate.app.bundleID && $0.triggerIdentity != candidate.triggerIdentity } guard !stale.isEmpty else { return } - let isActive = await probe.isActive(bundleID: candidate.app.bundleID) - guard isActive == false else { return } + guard await definitelyNotInAMeeting(candidate) else { return } for ended in stale { activeCandidates.removeValue(forKey: ended.triggerIdentity) await onCandidateEnded?(ended) } } + private func definitelyNotInAMeeting(_ candidate: DetectionCandidate) async -> Bool { + let evidence = AutoRecordPolicy.Evidence( + kind: candidate.kind, + probeIsActive: await probe.isActive(bundleID: candidate.bundleID), + browserTab: await inspectTab(for: candidate), + calendarOverlapsNow: false, + sustainedMicSeconds: 0 + ) + return AutoRecordPolicy.isDefinitelyNotInAMeeting(evidence) + } + + private func inspectTab(for candidate: DetectionCandidate) async -> TabInspectionResult? { + guard candidate.kind == .browser else { return nil } + return await tabInspector?.inspectActiveTab(bundleID: candidate.bundleID) + ?? .unreadable + } + private func fireCandidate(_ candidate: DetectionCandidate) async { finishObservation(identity: candidate.triggerIdentity) guard activeCandidates[candidate.triggerIdentity] == nil else { return } diff --git a/Sources/TranscriberCore/Detection/MeetingApps.swift b/Sources/TranscriberCore/Detection/MeetingApps.swift index 0b48b3e..9b94a10 100644 --- a/Sources/TranscriberCore/Detection/MeetingApps.swift +++ b/Sources/TranscriberCore/Detection/MeetingApps.swift @@ -5,21 +5,37 @@ public struct MeetingApp: Sendable, Equatable, Hashable { public let displayName: String /// Native meeting apps are stronger signals than browsers — Zoom or /// Teams launching means a call is plausibly starting, while a - /// browser opening doesn't tell us anything (per-URL inspection is - /// out of scope for V1). Used by `ProcessWatcher` to skip - /// cold-start enumeration of browsers, which were generating false - /// positives because most users keep a browser open all day. + /// browser opening doesn't tell us anything on its own. Used by + /// `ProcessWatcher` to skip cold-start enumeration of browsers, + /// which were generating false positives because most users keep a + /// browser open all day. public let kind: Kind + /// How `BrowserTabInspector` reads this browser's active tab. The + /// dialect lives here, next to the allowlist entry, so a new + /// browser ID cannot land in the allowlist and silently take the + /// calendar-plus-mic fallback: whoever adds the entry picks the + /// dialect in the same line. Firefox exposes no active tab over + /// AppleScript and stays `.none`. + public let tabDialect: TabDialect public enum Kind: Sendable, Equatable, Hashable { case nativeMeetingApp case browser } - init(bundleID: String, displayName: String, kind: Kind) { + public enum TabDialect: Sendable, Equatable, Hashable { + /// Not scriptable (Firefox) or not a browser. The candidate can + /// only pass on calendar overlap plus sustained mic. + case none + case safari + case chromium + } + + init(bundleID: String, displayName: String, kind: Kind, tabDialect: TabDialect = .none) { self.bundleID = bundleID self.displayName = displayName self.kind = kind + self.tabDialect = tabDialect } } @@ -33,18 +49,30 @@ public enum MeetingApps { .init(bundleID: "com.microsoft.teams", displayName: "Microsoft Teams (legacy)", kind: .nativeMeetingApp), .init(bundleID: "org.whispersystems.signal-desktop", displayName: "Signal", kind: .nativeMeetingApp), .init(bundleID: "com.apple.FaceTime", displayName: "FaceTime", kind: .nativeMeetingApp), - // Browsers (supported surfaces; per-URL tab inspection deferred, active-call evidence still required when probing can determine it) - .init(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser), - .init(bundleID: "com.apple.Safari", displayName: "Safari", kind: .browser), - .init(bundleID: "company.thebrowser.Browser", displayName: "Arc", kind: .browser), - .init(bundleID: "com.microsoft.Edge", displayName: "Edge", kind: .browser), - .init(bundleID: "org.mozilla.firefox", displayName: "Firefox", kind: .browser), - .init(bundleID: "com.brave.Browser", displayName: "Brave", kind: .browser), - .init(bundleID: "net.imput.helium", displayName: "Helium", kind: .browser), - .init(bundleID: "im.helium.helium", displayName: "Helium", kind: .browser), + // Browsers. The tab gate reads the active tab per dialect; + // an unreadable tab falls back to calendar plus sustained mic. + .init(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser, tabDialect: .chromium), + .init(bundleID: "com.apple.Safari", displayName: "Safari", kind: .browser, tabDialect: .safari), + .init(bundleID: "company.thebrowser.Browser", displayName: "Arc", kind: .browser, tabDialect: .chromium), + .init(bundleID: "com.microsoft.Edge", displayName: "Edge", kind: .browser, tabDialect: .chromium), + .init(bundleID: "org.mozilla.firefox", displayName: "Firefox", kind: .browser, tabDialect: .none), + .init(bundleID: "com.brave.Browser", displayName: "Brave", kind: .browser, tabDialect: .chromium), + .init(bundleID: "net.imput.helium", displayName: "Helium", kind: .browser, tabDialect: .chromium), + .init(bundleID: "im.helium.helium", displayName: "Helium", kind: .browser, tabDialect: .chromium), ] public static func appFor(bundleID: String) -> MeetingApp? { allowlist.first(where: { $0.bundleID == bundleID }) } + + /// V1 meeting-domain list for the browser tab gate. Same + /// single-source rule as the allowlist: one file, one entry per + /// addition. `BrowserTabInspector` matches the active tab URL host + /// (exact or dotted subdomain) and title against these. + public static let meetingDomains: [String] = [ + "meet.google.com", + "zoom.us", + "teams.microsoft.com", + "teams.live.com", + ] } diff --git a/Sources/TranscriberCore/Detection/SkipState.swift b/Sources/TranscriberCore/Detection/SkipState.swift deleted file mode 100644 index 4066947..0000000 --- a/Sources/TranscriberCore/Detection/SkipState.swift +++ /dev/null @@ -1,31 +0,0 @@ -import Foundation - -/// Per-bundle "Not a meeting" suppression with TTL. Slice 5 light keeps this -/// in memory only — restarting the app forgets all suppressions. Slice 7's -/// filesystem-as-queue model could persist this if needed, but in practice -/// users opening Zoom days later don't expect a stale Skip to apply. -public actor SkipState { - private var until: [String: Date] = [:] - - public init() {} - - /// Suppresses prompts for `bundleID` until `now + duration`. - public func suppress(_ bundleID: String, for duration: TimeInterval, now: Date = Date()) { - until[bundleID] = now.addingTimeInterval(duration) - } - - /// True if the bundle is currently suppressed. Side-effect: if the entry - /// has expired, it's removed from the map so the next check is fast. - func isSuppressed(_ bundleID: String, now: Date = Date()) -> Bool { - guard let expiry = until[bundleID] else { return false } - if expiry > now { return true } - until.removeValue(forKey: bundleID) - return false - } - - /// Removes any suppression for `bundleID`. Used by tests; production - /// callers shouldn't need this. - public func clear(_ bundleID: String) { - until.removeValue(forKey: bundleID) - } -} diff --git a/Sources/TranscriberCore/Recovery/TranscriptionWorker.swift b/Sources/TranscriberCore/Recovery/TranscriptionWorker.swift index dc110c8..1a785d1 100644 --- a/Sources/TranscriberCore/Recovery/TranscriptionWorker.swift +++ b/Sources/TranscriberCore/Recovery/TranscriptionWorker.swift @@ -76,6 +76,12 @@ public actor TranscriptionWorker { } public func run() async -> FinalState { + // A vanished directory means the user discarded the session from + // Recents while this worker was live or queued. Never treat that + // as a transcription failure and never write anything back: + // decline as cancelled so no file resurrects the folder. + if directoryVanished { return .cancelled } + // Read the on-disk transcript once. Used both to skip already-terminal // sessions AND to recover the retry attempt count after an app relaunch // mid-backoff (codex slice-7 P2.2). @@ -160,9 +166,14 @@ public actor TranscriptionWorker { // during the 5m/30m backoff doesn't grant a fresh retry budget. var failedAttempts = existing?.attempts ?? 0 while true { - if Task.isCancelled { return .cancelled } + if Task.isCancelled || directoryVanished { return .cancelled } do { let response = try await engine.transcribe(resolvedRequest) + // Recents Discard can trash the folder while the engine + // call is in flight. Every post-await branch re-checks: + // a discarded session is cancelled, never failed, and + // never regains files. + if directoryVanished { return .cancelled } if response.utterances.isEmpty { let msg = "No speech detected. The audio tracks may be silent, corrupt, or below the engine's detection threshold." await writeFailed(reason: msg, failedAttempts: failedAttempts + 1) @@ -177,6 +188,7 @@ public actor TranscriptionWorker { speakerMapping: speakerMapping ) } catch { + if directoryVanished { return .cancelled } Log.engine.error("writeComplete failed: \(String(describing: error), privacy: .public)") return .failed(reason: "transcript write failed: \(error)") } @@ -198,6 +210,7 @@ public actor TranscriptionWorker { } catch is CancellationError { return .cancelled } catch { + if directoryVanished { return .cancelled } if !Self.isTransient(error) { let reason = String(describing: error) await writeFailed(reason: reason, failedAttempts: failedAttempts + 1, underlying: error) @@ -217,6 +230,7 @@ public actor TranscriptionWorker { // disk can't persist the retry state, treat that as // terminal: the session can't be retried safely. if !writeRetrying(failedAttempts: failedAttempts, lastError: error) { + if directoryVanished { return .cancelled } let reason = "retry persistence failed; terminating to avoid unbounded relaunch retries (last engine error: \(error))" await writeFailed(reason: reason, failedAttempts: failedAttempts, underlying: error) return .failed(reason: reason) @@ -233,6 +247,12 @@ public actor TranscriptionWorker { } } + /// True when the session directory was removed (Recents Discard). + /// Guards every write so a discarded session never regains files. + private var directoryVanished: Bool { + FileManager.default.fileExists(atPath: directory.url.path) == false + } + /// Returns true on successful persist, false on write failure. /// Codex rc2-audit CAP-6: callers must NOT swallow false — a /// stale on-disk attempts count + a fresh retry budget on @@ -240,6 +260,10 @@ public actor TranscriptionWorker { /// engine error. @discardableResult private func writeRetrying(failedAttempts: Int, lastError: Error) -> Bool { + if directoryVanished { + Log.engine.info("Skipping retrying-state write: session directory vanished (discarded)") + return false + } // Build the retrying-status frontmatter inline. Must mirror every field // the supervisor's TranscriptFrontmatterReader knows how to restore, // including language + attendees, so a relaunch during the backoff @@ -270,6 +294,10 @@ public actor TranscriptionWorker { } private func writeFailed(reason: String, failedAttempts: Int = 0, underlying: Error? = nil) async { + if directoryVanished { + Log.engine.info("Skipping failed-state write: session directory vanished (discarded)") + return + } let failedContext = contextForPersistence(language: context.language) let failureDetails = await makeFailureDetails(reason: reason, failedAttempts: failedAttempts, underlying: underlying) do { @@ -373,6 +401,11 @@ public actor TranscriptionWorker { /// the raw stream list). Result is also stored on the actor so failure /// paths can stamp the same canonical path into metadata. private func prepareCanonicalAudio() async -> String { + if directoryVanished { + Log.engine.info("Skipping canonical audio: session directory vanished (discarded)") + canonicalAudioPath = "" + return "" + } let audioFinalURL = directory.audioFinal if FileManager.default.fileExists(atPath: audioFinalURL.path) { canonicalAudioPath = CanonicalAudio.fileName @@ -406,6 +439,7 @@ public actor TranscriptionWorker { /// cancellation at the engine boundary leaves relaunch recovery with valid /// transcript.md + metadata.json and the original session engine. private func writePendingRecoveryState() { + if directoryVanished { return } let pendingContext = contextForPersistence(language: context.language) do { try TranscriptWriter.writePending(at: directory.transcript, context: pendingContext) @@ -459,6 +493,7 @@ public actor TranscriptionWorker { /// need the raws for retry or recovery. Only invoked from the /// `.complete` happy path. private func cleanupRawStreamsIfPolicyAllows() { + if directoryVanished { return } guard !keepRawStreams else { Log.engine.info("keepRawStreams=true: preserving mic.m4a + system.m4a") return @@ -490,6 +525,7 @@ public actor TranscriptionWorker { /// JSON consumers with stale state alongside deleted raws. @discardableResult private func writeMetadata(status: TranscriptStatus, context: TranscriptContext, audioPath: String, failureDetails: TranscriptFailureDetails? = nil) -> Bool { + if directoryVanished { return false } // Metadata.audio is a single string per spec line 251-255; pick the // first audio reference. With audio.m4a present, that's "audio.m4a". // With raw-streams fallback, that's mic.m4a (or whatever's first in diff --git a/Sources/TranscriberCore/Session/PromptNotificationCoordinatorCore.swift b/Sources/TranscriberCore/Session/PromptNotificationCoordinatorCore.swift index 1acd5bf..9c6015a 100644 --- a/Sources/TranscriberCore/Session/PromptNotificationCoordinatorCore.swift +++ b/Sources/TranscriberCore/Session/PromptNotificationCoordinatorCore.swift @@ -1,37 +1,18 @@ import Foundation -/// Testable prompt/notification state machine for the Scribe app shell. -/// -/// The AppKit/UNUserNotificationCenter coordinator owns real windows and system -/// notifications, while this core seam captures the async invariants that must -/// not regress: duplicate start prompts share all awaiters, authorization is -/// re-read for each notification attempt, notification actions are generation -/// guarded, and dismissed modals leave recovery actions retryable. +/// Testable end-prompt notification state machine for the Scribe app +/// shell. The pre-call start-prompt half was deleted with the +/// auto-record cutover (plans/auto-record.md); what remains captures +/// the async invariants of the EndGuard stop-prompt notification +/// channel: authorization is re-read for each attempt, and actions are +/// generation-guarded so a stale tap cannot stop the wrong session. actor PromptNotificationCoordinatorCore { - enum StartChoice: Sendable, Equatable { - case start - case notAMeeting - case skipForNow - } - - enum ModalDecision: Sendable, Equatable { - case primary - case secondary - case dismissed - } - enum NotificationAuthorization: Sendable, Equatable { case authorized case denied case notDetermined(grantedOnRequest: Bool) } - enum StartNotificationKind: Sendable, Equatable { - case backup - case reminder - case finalReminder - } - enum EndPromptAction: Sendable, Equatable { case keepRecording case stopNow @@ -39,34 +20,6 @@ actor PromptNotificationCoordinatorCore { case defaultAction } - struct StartPromptRequest: Sendable, Equatable { - let identifier: String - let appDisplayName: String - let eventTitle: String? - - init(identifier: String, appDisplayName: String, eventTitle: String? = nil) { - self.identifier = identifier - self.appDisplayName = appDisplayName - self.eventTitle = eventTitle - } - } - - struct StartNotificationRequest: Sendable, Equatable { - let promptID: String - let kind: StartNotificationKind - let appDisplayName: String - let eventTitle: String? - - init( - promptID: String, kind: StartNotificationKind, appDisplayName: String, eventTitle: String? - ) { - self.promptID = promptID - self.kind = kind - self.appDisplayName = appDisplayName - self.eventTitle = eventTitle - } - } - struct EndNotificationRequest: Sendable, Equatable { let promptID: String let generation: Int @@ -91,113 +44,28 @@ actor PromptNotificationCoordinatorCore { } } - typealias PromptPresenter = @Sendable (StartPromptRequest) async -> ModalDecision - typealias StartNotificationPoster = - @Sendable (StartNotificationRequest) async throws -> Void typealias EndNotificationPoster = @Sendable (EndNotificationRequest) async throws -> Void typealias AuthorizationProvider = @Sendable () async -> NotificationAuthorization typealias EndPromptCallbackHandler = @Sendable (EndPromptCallback) async -> Void - private final class PendingStart: @unchecked Sendable { - let request: StartPromptRequest - var continuations: [CheckedContinuation] - var modalDismissed = false - - init(request: StartPromptRequest, continuation: CheckedContinuation) { - self.request = request - self.continuations = [continuation] - } - - func append(_ continuation: CheckedContinuation) { - continuations.append(continuation) - } - - func resumeAll(returning choice: StartChoice) { - let awaiters = continuations - continuations.removeAll(keepingCapacity: false) - for continuation in awaiters { - continuation.resume(returning: choice) - } - } - } - private struct PendingEnd: Sendable { let promptID: String let generation: Int let onAction: EndPromptCallbackHandler } - private let presentPrompt: PromptPresenter - private let postStartNotification: StartNotificationPoster private let postEndNotification: EndNotificationPoster private let authorizationProvider: AuthorizationProvider - - private var pendingStarts: [String: PendingStart] = [:] - private var activePromptIdentifier: String? private var pendingEnds: [String: PendingEnd] = [:] init( - presentPrompt: @escaping PromptPresenter, - postStartNotification: @escaping StartNotificationPoster = { _ in }, postEndNotification: @escaping EndNotificationPoster = { _ in }, authorizationProvider: @escaping AuthorizationProvider = { .authorized } ) { - self.presentPrompt = presentPrompt - self.postStartNotification = postStartNotification self.postEndNotification = postEndNotification self.authorizationProvider = authorizationProvider } - var hasActivePrompt: Bool { activePromptIdentifier != nil } - - var pendingStartCount: Int { pendingStarts.count } - - func pendingAwaiterCount(identifier: String) -> Int { - pendingStarts[identifier]?.continuations.count ?? 0 - } - - func prompt(for request: StartPromptRequest) async -> StartChoice { - await withCheckedContinuation { continuation in - if let pending = pendingStarts[request.identifier] { - pending.append(continuation) - activePromptIdentifier = request.identifier - return - } - - pendingStarts[request.identifier] = PendingStart(request: request, continuation: continuation) - activePromptIdentifier = request.identifier - - Task { [weak self] in - guard let self else { return } - _ = await self.postStartNotificationIfPossible( - StartNotificationRequest( - promptID: request.identifier, - kind: .backup, - appDisplayName: request.appDisplayName, - eventTitle: request.eventTitle - ) - ) - } - Task { [weak self] in - guard let self else { return } - let decision = await self.presentPrompt(request) - await self.resolveModalDecision(decision, identifier: request.identifier) - } - } - } - - @discardableResult - func postStartNotificationIfPossible(_ request: StartNotificationRequest) async -> Bool { - guard await ensureAuthorization() else { return false } - guard pendingStarts[request.promptID] != nil else { return false } - do { - try await postStartNotification(request) - return true - } catch { - return false - } - } - @discardableResult func postEndPromptNotificationIfPossible( promptID: String, @@ -247,43 +115,6 @@ actor PromptNotificationCoordinatorCore { } } - func chooseStartFromRecovery(identifier: String? = nil) { - resolve(identifier: identifier ?? activePromptIdentifier, with: .start) - } - - func chooseNotNowFromRecovery(identifier: String? = nil) { - resolve(identifier: identifier ?? activePromptIdentifier, with: .skipForNow) - } - - func chooseSuppressAppFromRecovery(identifier: String? = nil) { - resolve(identifier: identifier ?? activePromptIdentifier, with: .notAMeeting) - } - - func isModalDismissedAndRetryable(identifier: String) -> Bool { - pendingStarts[identifier]?.modalDismissed == true - } - - private func resolveModalDecision(_ decision: ModalDecision, identifier: String) { - switch decision { - case .primary: - resolve(identifier: identifier, with: .start) - case .secondary: - resolve(identifier: identifier, with: .skipForNow) - case .dismissed: - pendingStarts[identifier]?.modalDismissed = true - } - } - - private func resolve(identifier: String?, with choice: StartChoice) { - guard let identifier, let pending = pendingStarts.removeValue(forKey: identifier) else { - return - } - if activePromptIdentifier == identifier { - activePromptIdentifier = nil - } - pending.resumeAll(returning: choice) - } - private func ensureAuthorization() async -> Bool { switch await authorizationProvider() { case .authorized: diff --git a/Sources/TranscriberCore/Session/SessionDirectory.swift b/Sources/TranscriberCore/Session/SessionDirectory.swift index 91b7c56..bc61ff3 100644 --- a/Sources/TranscriberCore/Session/SessionDirectory.swift +++ b/Sources/TranscriberCore/Session/SessionDirectory.swift @@ -30,6 +30,12 @@ public struct SessionDirectory: Equatable, Sendable { return SessionDirectory(url: targetUrl) } + /// Wraps an already-existing session directory (Recents rows carry + /// plain URLs). Performs no filesystem work. + public static func existing(at url: URL) -> SessionDirectory { + SessionDirectory(url: url) + } + var micPartial: URL { url.appendingPathComponent("mic.m4a.partial") } @@ -85,6 +91,17 @@ public struct SessionDirectory: Equatable, Sendable { CanonicalAudio.url(in: url) } + /// Moves the whole session folder to the macOS Trash and returns the + /// trashed location. Throws on volumes with no Trash folder; the + /// caller then keeps the session at its current status and logs. + /// A discard never hard-deletes and never silently no-ops. + @discardableResult + public func moveToTrash() throws -> URL? { + var trashed: NSURL? + try FileManager.default.trashItem(at: url, resultingItemURL: &trashed) + return trashed as URL? + } + public func finalize() throws { let fileManager = FileManager.default diff --git a/Sources/TranscriberCore/Session/TrustState.swift b/Sources/TranscriberCore/Session/TrustState.swift index 57e05b0..62f827d 100644 --- a/Sources/TranscriberCore/Session/TrustState.swift +++ b/Sources/TranscriberCore/Session/TrustState.swift @@ -12,8 +12,8 @@ public enum TrustState: String, Sendable, Equatable, CaseIterable { case idle /// Preflight failed (any blocker). Bars + amber dot. case setupRequired - /// Detection candidate fired; prompt is on screen awaiting a - /// response. Bars + concentric ring (Swift drives the pulse). + /// Detection candidate fired but no recording started (auto-record + /// off). Bars + concentric ring (Swift drives the pulse). case detected /// Capture active. case recording @@ -33,7 +33,7 @@ extension TrustState { public struct Inputs: Sendable, Equatable { public var status: SessionStatus public var setupNeedsAttention: Bool - public var detectionPromptActive: Bool + public var parkedDetection: Bool public var endPromptActive: Bool public var lastSavedAt: Date? public var lastFailureAt: Date? @@ -45,7 +45,7 @@ extension TrustState { public init( status: SessionStatus, setupNeedsAttention: Bool, - detectionPromptActive: Bool, + parkedDetection: Bool, endPromptActive: Bool = false, lastSavedAt: Date? = nil, lastFailureAt: Date? = nil, @@ -54,7 +54,7 @@ extension TrustState { ) { self.status = status self.setupNeedsAttention = setupNeedsAttention - self.detectionPromptActive = detectionPromptActive + self.parkedDetection = parkedDetection self.endPromptActive = endPromptActive self.lastSavedAt = lastSavedAt self.lastFailureAt = lastFailureAt @@ -86,11 +86,11 @@ extension TrustState { // should communicate that recording cannot proceed yet. if inputs.setupNeedsAttention { return .setupRequired } - // 3. Mid-flight detection prompt is the next-strongest - // signal: it's awaiting a user decision, so the icon - // should pulse to draw the eye when recording is otherwise - // allowed. - if inputs.detectionPromptActive { return .detected } + // 3. A parked detection candidate is the next-strongest + // signal: a meeting is live but not being recorded (auto- + // record off, or a declined silent start), so the icon + // pulses to draw the eye. + if inputs.parkedDetection { return .detected } // 4. A terminal failure outranks the transient saved flash because // the user just tried to record and it failed — that needs diff --git a/Sources/TranscriberCore/Storage/SettingsStore.swift b/Sources/TranscriberCore/Storage/SettingsStore.swift index 5fc274e..f02c1ce 100644 --- a/Sources/TranscriberCore/Storage/SettingsStore.swift +++ b/Sources/TranscriberCore/Storage/SettingsStore.swift @@ -71,6 +71,14 @@ public struct SessionSettings: Sendable, Equatable, Codable { /// fallback) decides. Cloud (ElevenLabs) always auto-detects and /// ignores this. public var transcriptionLanguage: String? + /// Auto-record: a detected recording under this many seconds goes to + /// the macOS Trash when EndGuard ends it. Manual recordings and + /// explicit user stops are exempt. Plan: `plans/auto-record.md`. + public var autoDiscardThresholdSeconds: Int + /// Master switch for automatic capture. Off: detection candidates + /// stop at the passive Meeting detected state and recording is + /// manual only. + public var autoRecordEnabled: Bool public init( outputRoot: URL, @@ -82,7 +90,9 @@ public struct SessionSettings: Sendable, Equatable, Codable { launchAtLogin: Bool = false, showInMenuBar: Bool = true, startStopShortcut: KeyboardShortcutSetting = .defaultStartStop, - transcriptionLanguage: String? = nil + transcriptionLanguage: String? = nil, + autoDiscardThresholdSeconds: Int = 120, + autoRecordEnabled: Bool = true ) { self.outputRoot = outputRoot self.engineMode = engineMode @@ -95,10 +105,12 @@ public struct SessionSettings: Sendable, Equatable, Codable { self.showInMenuBar = showInMenuBar self.startStopShortcut = startStopShortcut self.transcriptionLanguage = transcriptionLanguage + self.autoDiscardThresholdSeconds = autoDiscardThresholdSeconds + self.autoRecordEnabled = autoRecordEnabled } private enum CodingKeys: String, CodingKey { - case outputRoot, engineMode, keepRawStreams, aecEnabled, privacyAcknowledged, appearanceTheme, launchAtLogin, showInMenuBar, startStopShortcut, transcriptionLanguage + case outputRoot, engineMode, keepRawStreams, aecEnabled, privacyAcknowledged, appearanceTheme, launchAtLogin, showInMenuBar, startStopShortcut, transcriptionLanguage, autoDiscardThresholdSeconds, autoRecordEnabled } /// Decoder permits older blob formats that omit `privacyAcknowledged` @@ -116,6 +128,8 @@ public struct SessionSettings: Sendable, Equatable, Codable { self.showInMenuBar = try c.decodeIfPresent(Bool.self, forKey: .showInMenuBar) ?? true self.startStopShortcut = try c.decodeIfPresent(KeyboardShortcutSetting.self, forKey: .startStopShortcut) ?? .defaultStartStop self.transcriptionLanguage = try c.decodeIfPresent(String.self, forKey: .transcriptionLanguage) + self.autoDiscardThresholdSeconds = try c.decodeIfPresent(Int.self, forKey: .autoDiscardThresholdSeconds) ?? 120 + self.autoRecordEnabled = try c.decodeIfPresent(Bool.self, forKey: .autoRecordEnabled) ?? true } } @@ -198,10 +212,12 @@ public actor SettingsStore { public var showInMenuBar: Bool public var startStopShortcut: KeyboardShortcutSetting public var transcriptionLanguage: String? + public var autoDiscardThresholdSeconds: Int + public var autoRecordEnabled: Bool public init( outputRoot: URL, - engineMode: EngineMode = .cloud, + engineMode: EngineMode = .local, // plans/auto-record.md: on-device is the auto-record default keepRawStreams: Bool = false, // spec line 102 aecEnabled: Bool = true, // D2 privacyAcknowledged: Bool = false, // spec line 348 @@ -209,7 +225,9 @@ public actor SettingsStore { launchAtLogin: Bool = false, showInMenuBar: Bool = true, startStopShortcut: KeyboardShortcutSetting = .defaultStartStop, - transcriptionLanguage: String? = nil + transcriptionLanguage: String? = nil, + autoDiscardThresholdSeconds: Int = 120, // plans/auto-record.md + autoRecordEnabled: Bool = true ) { self.outputRoot = outputRoot self.engineMode = engineMode @@ -222,6 +240,8 @@ public actor SettingsStore { self.showInMenuBar = showInMenuBar self.startStopShortcut = startStopShortcut self.transcriptionLanguage = transcriptionLanguage + self.autoDiscardThresholdSeconds = autoDiscardThresholdSeconds + self.autoRecordEnabled = autoRecordEnabled } } @@ -312,6 +332,18 @@ public actor SettingsStore { try? commit(current) } + public func setAutoDiscardThresholdSeconds(_ seconds: Int) { + var current = snapshot() + current.autoDiscardThresholdSeconds = seconds + try? commit(current) + } + + public func setAutoRecordEnabled(_ on: Bool) { + var current = snapshot() + current.autoRecordEnabled = on + try? commit(current) + } + /// Atomic multi-key commit. Phase η Settings UI calls this after /// the user clicks Save so the resulting on-disk state never /// contains a partial mix of old + new fields. @@ -354,7 +386,9 @@ private extension SettingsStore.Defaults { launchAtLogin: launchAtLogin, showInMenuBar: showInMenuBar, startStopShortcut: startStopShortcut, - transcriptionLanguage: transcriptionLanguage + transcriptionLanguage: transcriptionLanguage, + autoDiscardThresholdSeconds: autoDiscardThresholdSeconds, + autoRecordEnabled: autoRecordEnabled ) } } diff --git a/Tests/TranscriberCoreTests/Detection/AutoRecordPolicyTests.swift b/Tests/TranscriberCoreTests/Detection/AutoRecordPolicyTests.swift new file mode 100644 index 0000000..17a69c2 --- /dev/null +++ b/Tests/TranscriberCoreTests/Detection/AutoRecordPolicyTests.swift @@ -0,0 +1,146 @@ +import XCTest + +@testable import TranscriberCore + +final class AutoRecordPolicyTests: XCTestCase { + private func evidence( + kind: MeetingApp.Kind, + probe: Bool? = nil, + tab: TabInspectionResult? = nil, + calendar: Bool = false, + micSeconds: TimeInterval = 0 + ) -> AutoRecordPolicy.Evidence { + AutoRecordPolicy.Evidence( + kind: kind, + probeIsActive: probe, + browserTab: tab, + calendarOverlapsNow: calendar, + sustainedMicSeconds: micSeconds + ) + } + + // MARK: - shouldRecord, native apps + + func testNativeAppPassesOnActiveProbe() { + XCTAssertTrue(AutoRecordPolicy.shouldRecord(evidence(kind: .nativeMeetingApp, probe: true))) + } + + func testNativeAppPassesOnIndeterminateProbe() { + // Matches the engine's existing fire-on-nil behavior for native apps. + XCTAssertTrue(AutoRecordPolicy.shouldRecord(evidence(kind: .nativeMeetingApp, probe: nil))) + } + + func testNativeAppFailsOnInactiveProbe() { + XCTAssertFalse(AutoRecordPolicy.shouldRecord(evidence(kind: .nativeMeetingApp, probe: false))) + } + + // MARK: - shouldRecord, browsers + + func testBrowserPassesOnTabMatchAlone() { + // A meeting-domain tab is the strongest signal and records alone. + XCTAssertTrue( + AutoRecordPolicy.shouldRecord(evidence(kind: .browser, probe: nil, tab: .meeting))) + } + + func testBrowserFailsOnInspectedNonMeetingTab() { + // YouTube tab, probe indeterminate: nil probe must not record a browser. + XCTAssertFalse( + AutoRecordPolicy.shouldRecord(evidence(kind: .browser, probe: nil, tab: .notMeeting))) + } + + func testInspectedNonMeetingTabNeverTakesTheFallback() { + // The hole this pins: an inspected YouTube tab must not record + // even with calendar overlap AND a full sustained-mic streak. + // The fallback exists only for tabs that could not be read. + XCTAssertFalse( + AutoRecordPolicy.shouldRecord( + evidence(kind: .browser, probe: true, tab: .notMeeting, calendar: true, micSeconds: 300))) + } + + func testBrowserFallbackPassesOnCalendarPlusSustainedMic() { + XCTAssertTrue( + AutoRecordPolicy.shouldRecord( + evidence(kind: .browser, probe: true, tab: .unreadable, calendar: true, micSeconds: 30))) + } + + func testBrowserFallbackFailsWithoutCalendarOverlap() { + XCTAssertFalse( + AutoRecordPolicy.shouldRecord( + evidence(kind: .browser, probe: true, tab: .unreadable, calendar: false, micSeconds: 300))) + } + + func testBrowserFallbackFailsUnderMicRequirement() { + // Short mic bursts are dictation, not calls. + XCTAssertFalse( + AutoRecordPolicy.shouldRecord( + evidence(kind: .browser, probe: true, tab: .unreadable, calendar: true, micSeconds: 25))) + } + + func testBrowserFallbackHonorsCustomMicRequirement() { + let e = evidence(kind: .browser, tab: .unreadable, calendar: true, micSeconds: 10) + XCTAssertTrue(AutoRecordPolicy.shouldRecord(e, sustainedMicRequirementSeconds: 10)) + XCTAssertFalse(AutoRecordPolicy.shouldRecord(e, sustainedMicRequirementSeconds: 11)) + } + + func testBrowserUninspectableWithNoCalendarNeverRecords() { + // Firefox with no calendar overlap: both gates closed. + XCTAssertFalse( + AutoRecordPolicy.shouldRecord( + evidence(kind: .browser, probe: true, tab: .unreadable, calendar: false, micSeconds: 60))) + } + + // MARK: - isDefinitelyNotInAMeeting + + func testNativeAppDefinitelyNotInAMeetingOnlyOnDefinitiveInactiveProbe() { + XCTAssertTrue( + AutoRecordPolicy.isDefinitelyNotInAMeeting(evidence(kind: .nativeMeetingApp, probe: false))) + XCTAssertFalse( + AutoRecordPolicy.isDefinitelyNotInAMeeting(evidence(kind: .nativeMeetingApp, probe: nil))) + XCTAssertFalse( + AutoRecordPolicy.isDefinitelyNotInAMeeting(evidence(kind: .nativeMeetingApp, probe: true))) + } + + func testBrowserNotInAMeetingRequiresInactiveProbeAndInspectedNonMeetingTab() { + XCTAssertTrue( + AutoRecordPolicy.isDefinitelyNotInAMeeting( + evidence(kind: .browser, probe: false, tab: .notMeeting))) + // An unreadable tab is not proof of absence, whatever the probe says. + XCTAssertFalse( + AutoRecordPolicy.isDefinitelyNotInAMeeting( + evidence(kind: .browser, probe: false, tab: .unreadable))) + XCTAssertFalse( + AutoRecordPolicy.isDefinitelyNotInAMeeting( + evidence(kind: .browser, probe: false, tab: .meeting))) + XCTAssertFalse( + AutoRecordPolicy.isDefinitelyNotInAMeeting( + evidence(kind: .browser, probe: true, tab: .notMeeting))) + } + + // MARK: - shouldAutoDiscard + + func testDetectedEndGuardStopUnderThresholdDiscards() { + XCTAssertTrue( + AutoRecordPolicy.shouldAutoDiscard( + durationSeconds: 60, origin: .detected, initiator: .endGuard, thresholdSeconds: 120)) + } + + func testDetectedEndGuardStopOverThresholdKeeps() { + XCTAssertFalse( + AutoRecordPolicy.shouldAutoDiscard( + durationSeconds: 120, origin: .detected, initiator: .endGuard, thresholdSeconds: 120)) + } + + func testUserStopNeverDiscards() { + // An explicit stop is a decision, by the same argument that + // exempts manual recordings. + XCTAssertFalse( + AutoRecordPolicy.shouldAutoDiscard( + durationSeconds: 5, origin: .detected, initiator: .user, thresholdSeconds: 120)) + } + + func testManualOriginNeverDiscards() { + XCTAssertFalse( + AutoRecordPolicy.shouldAutoDiscard( + durationSeconds: 5, origin: .manual, initiator: .endGuard, thresholdSeconds: 120)) + } +} diff --git a/Tests/TranscriberCoreTests/Detection/BrowserTabInspectorTests.swift b/Tests/TranscriberCoreTests/Detection/BrowserTabInspectorTests.swift new file mode 100644 index 0000000..be39f2b --- /dev/null +++ b/Tests/TranscriberCoreTests/Detection/BrowserTabInspectorTests.swift @@ -0,0 +1,133 @@ +import Synchronization +import XCTest + +@testable import TranscriberCore + +final class BrowserTabInspectorTests: XCTestCase { + // MARK: - domain matching + + func testMeetURLMatches() { + XCTAssertTrue( + BrowserTabInspector.matchesMeetingDomain( + url: "https://meet.google.com/abc-defg-hij", title: "Weekly sync")) + } + + func testZoomSubdomainMatches() { + XCTAssertTrue( + BrowserTabInspector.matchesMeetingDomain( + url: "https://us02web.zoom.us/j/123456", title: "")) + } + + func testLookalikeHostDoesNotMatch() { + // "notzoom.us" is neither the domain nor a dotted subdomain of it. + XCTAssertFalse( + BrowserTabInspector.matchesMeetingDomain(url: "https://notzoom.us/j/1", title: "")) + } + + func testYouTubeDoesNotMatch() { + XCTAssertFalse( + BrowserTabInspector.matchesMeetingDomain( + url: "https://www.youtube.com/watch?v=x", title: "Some video")) + } + + func testTitleMatchesWhenURLDoesNot() { + XCTAssertTrue( + BrowserTabInspector.matchesMeetingDomain( + url: "about:blank", title: "Acme standup - meet.google.com")) + } + + func testTeamsDomainsMatch() { + XCTAssertTrue( + BrowserTabInspector.matchesMeetingDomain( + url: "https://teams.microsoft.com/l/meetup", title: "")) + XCTAssertTrue( + BrowserTabInspector.matchesMeetingDomain( + url: "https://teams.live.com/meet/9", title: "")) + } + + // MARK: - dialects live on the allowlist + + func testEveryAllowlistedBrowserHasADialectOrExplicitNone() { + for app in MeetingApps.allowlist { + switch app.kind { + case .nativeMeetingApp: + XCTAssertEqual(app.tabDialect, .none, "native apps never inspect tabs") + case .browser: + if app.bundleID == "org.mozilla.firefox" { + XCTAssertEqual(app.tabDialect, .none, "Firefox has no AppleScript tab access") + } else { + XCTAssertNotEqual( + app.tabDialect, .none, "\(app.bundleID) must declare a tab dialect") + } + } + } + } + + func testScriptComesFromTheDialect() { + XCTAssertNil( + BrowserTabInspector.script(for: MeetingApp( + bundleID: "org.mozilla.firefox", displayName: "Firefox", kind: .browser))) + XCTAssertNotNil( + BrowserTabInspector.script( + for: MeetingApps.appFor(bundleID: "com.apple.Safari"))) + XCTAssertNotNil( + BrowserTabInspector.script( + for: MeetingApps.appFor(bundleID: "net.imput.helium"))) + // An unknown bundle ID has no allowlist entry and no dialect. + XCTAssertNil( + BrowserTabInspector.script( + for: MeetingApps.appFor(bundleID: "com.unknown.browser"))) + } + + // MARK: - inspection outcomes + + func testMatchingTabIsMeeting() async { + let inspector = BrowserTabInspector(timeout: 2) { _ in + "https://meet.google.com/abc\nWeekly sync" + } + let result = await inspector.inspectActiveTab(bundleID: "net.imput.helium") + XCTAssertEqual(result, .meeting) + } + + func testNonMeetingTabIsNotMeeting() async { + let inspector = BrowserTabInspector(timeout: 2) { _ in + "https://www.youtube.com/watch?v=x\nSome video" + } + let result = await inspector.inspectActiveTab(bundleID: "com.google.Chrome") + XCTAssertEqual(result, .notMeeting) + } + + func testScriptErrorIsUnreadable() async { + // Grant denial and script failure both surface as a thrown error. + let inspector = BrowserTabInspector(timeout: 2) { _ in + throw BrowserTabInspector.InspectionError.scriptFailed + } + let result = await inspector.inspectActiveTab(bundleID: "com.apple.Safari") + XCTAssertEqual(result, .unreadable) + } + + func testFirefoxIsUnreadableWithoutRunningAnyScript() async { + let called = Mutex(false) + let inspector = BrowserTabInspector(timeout: 2) { _ in + called.withLock { $0 = true } + return "https://meet.google.com/abc\nx" + } + let result = await inspector.inspectActiveTab(bundleID: "org.mozilla.firefox") + XCTAssertEqual(result, .unreadable) + XCTAssertFalse(called.withLock { $0 }) + } + + func testHungScriptIsUnreadableWithinTimeout() async { + // A hanging AppleEvent must cancel, never abandon, and never + // stall the caller past the hard timeout. + let inspector = BrowserTabInspector(timeout: 0.2) { _ in + try await Task.sleep(nanoseconds: 60_000_000_000) + return "https://meet.google.com/abc\nx" + } + let started = Date() + let result = await inspector.inspectActiveTab(bundleID: "net.imput.helium") + let elapsed = Date().timeIntervalSince(started) + XCTAssertEqual(result, .unreadable) + XCTAssertLessThan(elapsed, 2, "inspection must resolve well inside the 2s dwell budget") + } +} diff --git a/Tests/TranscriberCoreTests/Detection/DetectionEngineTests.swift b/Tests/TranscriberCoreTests/Detection/DetectionEngineTests.swift index d7d34f4..0343954 100644 --- a/Tests/TranscriberCoreTests/Detection/DetectionEngineTests.swift +++ b/Tests/TranscriberCoreTests/Detection/DetectionEngineTests.swift @@ -28,34 +28,6 @@ final class DetectionEngineTests: XCTestCase { XCTAssertNil(result, "callback must not fire if app quit during dwell") } - func testLaunchWhileSuppressedDoesNotFire() async throws { - let captured = AppCapture() - let skip = SkipState() - let zoom = MeetingApp(bundleID: "us.zoom.xos", displayName: "Zoom", kind: .nativeMeetingApp) - await skip.suppress(zoom.bundleID, for: 60) - let engine = DetectionEngine(dwellTime: 30, skipState: skip, sleep: immediateSleep) { app in - await captured.set(app) - } - await engine.handleLaunch(of: zoom) - await Task.yield() - let result = await captured.value - XCTAssertNil(result, "suppressed apps must skip the dwell entirely") - } - - func testSuppressDuringDwellCancelsCallbackWithoutWallClockSleep() async throws { - let captured = AppCapture() - let engine = DetectionEngine(dwellTime: 30, sleep: cancellableNeverSleep) { app in - await captured.set(app) - } - let zoom = MeetingApp(bundleID: "us.zoom.xos", displayName: "Zoom", kind: .nativeMeetingApp) - await engine.handleLaunch(of: zoom) - await Task.yield() - await engine.suppress(zoom) - await Task.yield() - let result = await captured.value - XCTAssertNil(result, "suppress() during dwell must cancel the in-flight callback") - } - func testInactiveProbeSuppressesCandidate() async throws { let captured = AppCapture() let engine = DetectionEngine( @@ -180,6 +152,164 @@ final class DetectionEngineTests: XCTestCase { XCTAssertEqual(result?.bundleID, zoom.bundleID, "early false observations must not permanently suppress later active calls") } + // MARK: - Auto-record browser gates (plans/auto-record.md) + + private func clockedEngine( + probe: AudioActivityProbe, + tabInspector: BrowserTabInspecting?, + calendarOverlaps: Bool = false, + onCandidate: @escaping @Sendable (DetectionCandidate) async -> Void + ) -> DetectionEngine { + let clock = DateBox(Date(timeIntervalSince1970: 1_700_000_000)) + return DetectionEngine( + dwellTime: 30, + retryInterval: 5, + observationWindow: 120, + probe: probe, + tabInspector: tabInspector, + calendarOverlapsNow: { _ in calendarOverlaps }, + now: clock.now, + sleep: { seconds in + clock.advance(by: seconds) + await Task.yield() + }, + onCandidateEnded: onCandidate, + onCandidate: onCandidate + ) + } + + func testBrowserMeetingTabFiresDespiteInactiveProbe() async throws { + let captured = AppCapture() + let chrome = MeetingApp(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser) + let engine = clockedEngine( + probe: ConstantProbe(value: false), + tabInspector: ConstantTabInspector(value: .meeting) + ) { candidate in + await captured.set(candidate.app) + } + + await engine.reevaluate(chrome) + let result = await captured.waitForBundleID(chrome.bundleID) + XCTAssertEqual(result?.bundleID, chrome.bundleID, "a meeting-domain tab is the strongest signal and fires without mic evidence") + } + + func testBrowserNonMeetingTabNeverFiresWithoutFallbackEvidence() async throws { + let captured = AppCapture() + let chrome = MeetingApp(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser) + let engine = clockedEngine( + probe: ConstantProbe(value: true), + tabInspector: ConstantTabInspector(value: .notMeeting), + calendarOverlaps: false + ) { candidate in + await captured.set(candidate.app) + } + + await engine.reevaluate(chrome) + for _ in 0..<2_000 { await Task.yield() } + let result = await captured.value + XCTAssertNil(result, "an inspected non-meeting tab must not record even with the mic held (no calendar, no sustained-mic credit)") + } + + func testBrowserInspectedNonMeetingTabNeverFiresEvenWithCalendarAndSustainedMic() async throws { + // The hole this pins at the engine level: a YouTube tab that was + // successfully inspected must not record, even with a calendar + // event overlapping and a 30s+ sustained-mic streak. The + // fallback exists only for tabs that could not be read. + let captured = AppCapture() + let chrome = MeetingApp(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser) + let engine = clockedEngine( + probe: SequenceProbe(values: [true, true, true, true, true, true, true, true]), + tabInspector: ConstantTabInspector(value: .notMeeting), + calendarOverlaps: true + ) { candidate in + await captured.set(candidate.app) + } + + await engine.reevaluate(chrome) + for _ in 0..<2_000 { await Task.yield() } + let result = await captured.value + XCTAssertNil(result, "an inspected non-meeting tab never takes the calendar-plus-mic fallback") + } + + func testBrowserUninspectableFiresOnCalendarPlusSustainedMic() async throws { + let captured = AppCapture() + let firefox = MeetingApp(bundleID: "org.mozilla.firefox", displayName: "Firefox", kind: .browser) + // 6 consecutive positive samples x 5s = the 30s sustained-mic + // requirement; tab inspection unavailable (nil). + let engine = clockedEngine( + probe: SequenceProbe(values: [true, true, true, true, true, true]), + tabInspector: ConstantTabInspector(value: .unreadable), + calendarOverlaps: true + ) { candidate in + await captured.set(candidate.app) + } + + await engine.reevaluate(firefox) + let result = await captured.waitForBundleID(firefox.bundleID) + XCTAssertEqual(result?.bundleID, firefox.bundleID, "calendar overlap plus 30s of sustained mic must fire an uninspectable browser") + } + + func testBrowserNilProbeSampleResetsSustainedMicAccumulator() async throws { + let captured = AppCapture() + let firefox = MeetingApp(bundleID: "org.mozilla.firefox", displayName: "Firefox", kind: .browser) + // 5 trues (25s) -> nil reset -> 5 more trues (25s): the streak + // never reaches 30s, so the fallback must never fire. + let engine = clockedEngine( + probe: SequenceProbe(values: [true, true, true, true, true, nil, true, true, true, true, true]), + tabInspector: ConstantTabInspector(value: .unreadable), + calendarOverlaps: true + ) { candidate in + await captured.set(candidate.app) + } + + await engine.reevaluate(firefox) + for _ in 0..<2_000 { await Task.yield() } + let result = await captured.value + XCTAssertNil(result, "HAL indeterminacy must never count as mic time; the streak resets on nil") + } + + func testStaleBrowserCandidateWithUnreadableTabIsNotCleared() async throws { + let ended = CandidateSequenceCapture() + let chrome = MeetingApp(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser) + let tabInspector = SequenceTabInspector(values: [.meeting, .unreadable]) + let engine = clockedEngine( + probe: ConstantProbe(value: false), + tabInspector: tabInspector + ) { candidate in + await ended.append(candidate) + } + + // First evaluation: meeting tab -> candidate fires. + await engine.reevaluate(chrome) + _ = await ended.waitForCount(1) + // Second evaluation with the tab unreadable: an unreadable tab is + // not proof the call ended, so the active candidate must survive. + await engine.reevaluate(chrome) + for _ in 0..<200 { await Task.yield() } + let values = await ended.values + XCTAssertEqual(values.count, 1, "stale clearing must not fire onCandidateEnded when the tab cannot be read") + } + + func testStaleBrowserCandidateWithInspectedNonMeetingTabIsCleared() async throws { + let ended = CandidateSequenceCapture() + let chrome = MeetingApp(bundleID: "com.google.Chrome", displayName: "Chrome", kind: .browser) + let engine = clockedEngine( + probe: ConstantProbe(value: false), + tabInspector: SequenceTabInspector(values: [.meeting, .notMeeting]) + ) { candidate in + await ended.append(candidate) + } + + await engine.reevaluate(chrome) + _ = await ended.waitForCount(1) + // Definitive inactive probe AND inspected non-meeting tab: the + // candidate is stale and onCandidateEnded fires. + await engine.reevaluate(chrome) + _ = await ended.waitForCount(2) + let values = await ended.values + XCTAssertEqual(values.count, 2, "a definitively-ended browser call must clear its stale candidate") + } + func testRepeatedObservationsCoalesceIntoOneCandidate() async throws { let captured = FireCounter() let zoom = MeetingApp(bundleID: "us.zoom.xos", displayName: "Zoom", kind: .nativeMeetingApp) @@ -524,6 +654,24 @@ struct ConstantProbe: AudioActivityProbe { func isActive(bundleID: String) async -> Bool? { value } } +struct ConstantTabInspector: BrowserTabInspecting { + let value: TabInspectionResult + func inspectActiveTab(bundleID: String) async -> TabInspectionResult { value } +} + +actor SequenceTabInspector: BrowserTabInspecting { + private var values: [TabInspectionResult] + + init(values: [TabInspectionResult]) { + self.values = values + } + + func inspectActiveTab(bundleID: String) async -> TabInspectionResult { + guard !values.isEmpty else { return .unreadable } + return values.removeFirst() + } +} + actor SequenceProbe: AudioActivityProbe { private var values: [Bool?] diff --git a/Tests/TranscriberCoreTests/Detection/SkipStateTests.swift b/Tests/TranscriberCoreTests/Detection/SkipStateTests.swift deleted file mode 100644 index 15ee425..0000000 --- a/Tests/TranscriberCoreTests/Detection/SkipStateTests.swift +++ /dev/null @@ -1,45 +0,0 @@ -import XCTest -@testable import TranscriberCore - -final class SkipStateTests: XCTestCase { - func testFreshStateNothingSuppressed() async { - let s = SkipState() - let suppressed = await s.isSuppressed("us.zoom.xos") - XCTAssertFalse(suppressed) - } - - func testSuppressionWithinTTL() async { - let s = SkipState() - let now = Date() - await s.suppress("us.zoom.xos", for: 60, now: now) - let stillSuppressed = await s.isSuppressed("us.zoom.xos", now: now.addingTimeInterval(30)) - XCTAssertTrue(stillSuppressed) - } - - func testSuppressionExpiresAfterTTL() async { - let s = SkipState() - let now = Date() - await s.suppress("us.zoom.xos", for: 60, now: now) - let expiredCheck = await s.isSuppressed("us.zoom.xos", now: now.addingTimeInterval(120)) - XCTAssertFalse(expiredCheck, "must report not-suppressed after the TTL elapses") - } - - func testClearRemovesSuppression() async { - let s = SkipState() - let now = Date() - await s.suppress("us.zoom.xos", for: 600, now: now) - await s.clear("us.zoom.xos") - let suppressed = await s.isSuppressed("us.zoom.xos", now: now) - XCTAssertFalse(suppressed) - } - - func testIndependentSuppressionsByBundleID() async { - let s = SkipState() - let now = Date() - await s.suppress("us.zoom.xos", for: 60, now: now) - let zoomSuppressed = await s.isSuppressed("us.zoom.xos", now: now) - let teamsSuppressed = await s.isSuppressed("com.microsoft.teams2", now: now) - XCTAssertTrue(zoomSuppressed) - XCTAssertFalse(teamsSuppressed) - } -} diff --git a/Tests/TranscriberCoreTests/Onboarding/OnboardingFlowTests.swift b/Tests/TranscriberCoreTests/Onboarding/OnboardingFlowTests.swift index 8ace542..7f0bc01 100644 --- a/Tests/TranscriberCoreTests/Onboarding/OnboardingFlowTests.swift +++ b/Tests/TranscriberCoreTests/Onboarding/OnboardingFlowTests.swift @@ -32,10 +32,10 @@ final class OnboardingFlowTests: XCTestCase { XCTAssertTrue(testBody.contains("await self.startRecording(allowPendingPrivacyAcknowledgementForOnboardingTest: allowPendingPrivacyAcknowledgementForOnboardingTest)"), "ready onboarding Test Recording must use the consented onboarding seam before final privacyAcknowledged is written") XCTAssertFalse(testBody.contains("await self.startRecording()"), "onboarding Test Recording must not call normal privacy-gated Record Now and return before capture") - guard let startRange = appDelegate.range(of: "func startRecording(allowPendingPrivacyAcknowledgementForOnboardingTest: Bool = false) async") else { - return XCTFail("AppDelegate must keep a scoped startRecording privacy parameter defaulted to normal Record Now behavior") + guard let startRange = appDelegate.range(of: "func performStart(") else { + return XCTFail("the factored start path must keep a scoped privacy-bypass parameter defaulted to normal behavior") } - let startBody = String(appDelegate[startRange.lowerBound.. Void = { _ in /* skip */ }, keepRawStreams: Bool = false, retryTerminalFailures: Bool = false ) -> TranscriptionWorker { + let session = session ?? dir() try? FileManager.default.createDirectory(at: session.url, withIntermediateDirectories: true) return TranscriptionWorker( directory: session, - context: context, + context: context ?? makeContext(), engine: engine, - request: request, + request: request + ?? EngineRequest( + audioURL: root.appendingPathComponent("multichannel.wav"), + mode: .multichannel, + languageCode: nil, + keyterms: [] + ), speakerMapping: [:], policy: RetryPolicy(delays: [0.001, 0.001, 0.001]), sleep: sleep, @@ -1115,3 +1167,52 @@ actor FakeEngine: TranscriptionEngine { enum FakeError: Error { case noMoreResponses } } + +/// Suspends inside `transcribe` until released, so a test can trash the +/// session folder while the engine call is genuinely in flight. +actor EngineGate { + private var started = false + private var released = false + private var waiting: [CheckedContinuation] = [] + + func markStarted() { + started = true + } + + func waitForTranscribeStarted() async { + while !started { + try? await Task.sleep(nanoseconds: 5_000_000) + } + } + + func waitUntilReleased() async { + if released { return } + await withCheckedContinuation { continuation in + waiting.append(continuation) + } + } + + func release() { + released = true + for continuation in waiting { + continuation.resume() + } + waiting.removeAll() + } +} + +actor GatedEngine: TranscriptionEngine { + private let gate: EngineGate + private let response: EngineResponse + + init(gate: EngineGate, response: EngineResponse) { + self.gate = gate + self.response = response + } + + func transcribe(_ request: EngineRequest) async throws -> EngineResponse { + await gate.markStarted() + await gate.waitUntilReleased() + return response + } +} diff --git a/Tests/TranscriberCoreTests/Session/SessionDirectoryTests.swift b/Tests/TranscriberCoreTests/Session/SessionDirectoryTests.swift index dfad9ee..d704ae3 100644 --- a/Tests/TranscriberCoreTests/Session/SessionDirectoryTests.swift +++ b/Tests/TranscriberCoreTests/Session/SessionDirectoryTests.swift @@ -42,6 +42,29 @@ final class SessionDirectoryTests: XCTestCase { XCTAssertEqual(dir.ptsSidecar, url.appendingPathComponent("pts.json")) } + func testMoveToTrashRemovesFolderRecoverably() throws { + let id = SessionID(from: Date(timeIntervalSince1970: 0), timeZone: TimeZone(identifier: "UTC")!) + let dir = try SessionDirectory.create(under: tmpRoot, id: id) + try Data("audio-bytes".utf8).write(to: dir.micFinal) + + let trashed: URL? + do { + trashed = try dir.moveToTrash() + } catch { + throw XCTSkip("volume has no Trash folder: \(error)") + } + + XCTAssertFalse(FileManager.default.fileExists(atPath: dir.url.path)) + if let trashed { + // Recoverable, not deleted: the folder and its audio moved intact. + XCTAssertTrue(FileManager.default.fileExists(atPath: trashed.path)) + XCTAssertTrue( + FileManager.default.fileExists( + atPath: trashed.appendingPathComponent("mic.m4a").path)) + try? FileManager.default.removeItem(at: trashed) + } + } + func testAtomicRenameMicAndSystem() throws { let id = SessionID(from: Date(timeIntervalSince1970: 0), timeZone: TimeZone(identifier: "UTC")!) let dir = try SessionDirectory.create(under: tmpRoot, id: id) diff --git a/Tests/TranscriberCoreTests/Session/StartPromptCoordinatorAsyncTests.swift b/Tests/TranscriberCoreTests/Session/StartPromptCoordinatorAsyncTests.swift index a007ccf..8a379f6 100644 --- a/Tests/TranscriberCoreTests/Session/StartPromptCoordinatorAsyncTests.swift +++ b/Tests/TranscriberCoreTests/Session/StartPromptCoordinatorAsyncTests.swift @@ -1,103 +1,14 @@ @testable import TranscriberCore import XCTest +/// End-prompt notification async invariants. The pre-call start-prompt +/// machinery this suite used to cover was removed with the auto-record +/// cutover (plans/auto-record.md); only the EndGuard stop-prompt +/// notification channel remains. final class StartPromptCoordinatorAsyncTests: XCTestCase { - func testDuplicateStartPromptsCoalesceAndResumeAllAwaiters() async throws { - let presenter = PromptPresenterProbe() - let notifications = NotificationProbe() - let coordinator = PromptNotificationCoordinatorCore( - presentPrompt: presenter.present, - postStartNotification: notifications.postStart, - authorizationProvider: { .authorized } - ) - let request = PromptNotificationCoordinatorCore.StartPromptRequest( - identifier: "calendar:event-1:2026-05-20T10:00:00Z", - appDisplayName: "Zoom", - eventTitle: "Acme Weekly" - ) - - async let first = coordinator.prompt(for: request) - async let second = coordinator.prompt(for: request) - - try await eventually("duplicate prompt coalesced into one pending entry") { - let pendingCount = await coordinator.pendingStartCount - let presentedCount = await presenter.presentedCount - return pendingCount == 1 && presentedCount == 1 - } - let dismissedPendingCount = await coordinator.pendingStartCount - XCTAssertEqual(dismissedPendingCount, 1) - let hasActivePrompt = await coordinator.hasActivePrompt - XCTAssertTrue(hasActivePrompt) - let startNotificationCount = await notifications.startRequestCount - XCTAssertEqual(startNotificationCount, 1) - - await coordinator.chooseStartFromRecovery(identifier: request.identifier) - await presenter.dismissAll() - - let choices = await [first, second] - XCTAssertEqual(choices, [.start, .start]) - let finalPendingCount = await coordinator.pendingStartCount - XCTAssertEqual(finalPendingCount, 0) - let finalHasActivePrompt = await coordinator.hasActivePrompt - XCTAssertFalse(finalHasActivePrompt) - } - - func testNotificationAuthorizationIsRequeriedAndHonorsRevocationAndGrantChanges() async throws { - let authorization = AuthorizationProbe(statuses: [.authorized, .denied, .authorized, .denied]) - let notifications = NotificationProbe() - let coordinator = PromptNotificationCoordinatorCore( - presentPrompt: { _ in .dismissed }, - postStartNotification: notifications.postStart, - authorizationProvider: authorization.next - ) - let request = PromptNotificationCoordinatorCore.StartPromptRequest( - identifier: "app:us.zoom.xos", - appDisplayName: "Zoom" - ) - - async let promptChoice = coordinator.prompt(for: request) - try await eventually("prompt remains pending after modal dismissal") { - await coordinator.isModalDismissedAndRetryable(identifier: request.identifier) - } - - let denied = await coordinator.postStartNotificationIfPossible( - .init( - promptID: request.identifier, - kind: .reminder, - appDisplayName: "Zoom", - eventTitle: nil - )) - let granted = await coordinator.postStartNotificationIfPossible( - .init( - promptID: request.identifier, - kind: .reminder, - appDisplayName: "Zoom", - eventTitle: nil - )) - let revoked = await coordinator.postStartNotificationIfPossible( - .init( - promptID: request.identifier, - kind: .finalReminder, - appDisplayName: "Zoom", - eventTitle: nil - )) - - XCTAssertFalse(denied) - XCTAssertTrue(granted) - XCTAssertFalse(revoked) - let authorizationCallCount = await authorization.callCount - XCTAssertEqual(authorizationCallCount, 4) - let postedKinds = await notifications.startRequestKinds - XCTAssertEqual(postedKinds, [.backup, .reminder]) - - await coordinator.chooseNotNowFromRecovery(identifier: request.identifier) - _ = await promptChoice - } - func testStaleGenerationEndPromptActionsDoNotAffectCurrentSession() async { let callbacks = EndPromptCallbackProbe() let coordinator = PromptNotificationCoordinatorCore( - presentPrompt: { _ in .dismissed }, postEndNotification: { _ in }, authorizationProvider: { .authorized } ) @@ -146,110 +57,6 @@ final class StartPromptCoordinatorAsyncTests: XCTestCase { let callbackCount = await callbacks.callbackCount XCTAssertEqual(callbackCount, 1) } - - func testModalDismissalLeavesPromptRetryableWithoutOrphanedContinuations() async throws { - let coordinator = PromptNotificationCoordinatorCore( - presentPrompt: { _ in .dismissed }, - postStartNotification: { _ in }, - authorizationProvider: { .authorized } - ) - let request = PromptNotificationCoordinatorCore.StartPromptRequest( - identifier: "app:com.microsoft.teams2", - appDisplayName: "Microsoft Teams" - ) - - async let first = coordinator.prompt(for: request) - try await eventually("dismissed modal remains pending and retryable") { - await coordinator.isModalDismissedAndRetryable(identifier: request.identifier) - } - let dismissedPendingCount = await coordinator.pendingStartCount - XCTAssertEqual(dismissedPendingCount, 1) - let dismissedHasActivePrompt = await coordinator.hasActivePrompt - XCTAssertTrue(dismissedHasActivePrompt) - - async let second = coordinator.prompt(for: request) - try await eventually("duplicate awaiter joined dismissed prompt") { - await coordinator.pendingAwaiterCount(identifier: request.identifier) == 2 - } - - await coordinator.chooseSuppressAppFromRecovery(identifier: request.identifier) - - let choices = await [first, second] - XCTAssertEqual(choices, [.notAMeeting, .notAMeeting]) - let recoveredPendingCount = await coordinator.pendingStartCount - let recoveredHasActivePrompt = await coordinator.hasActivePrompt - XCTAssertEqual(recoveredPendingCount, 0) - XCTAssertFalse(recoveredHasActivePrompt) - } - - private func eventually( - _ description: String, - timeout: TimeInterval = 2, - file: StaticString = #filePath, - line: UInt = #line, - condition: @escaping () async -> Bool - ) async throws { - let deadline = Date().addingTimeInterval(timeout) - while Date() < deadline { - if await condition() { return } - try await Task.sleep(nanoseconds: 10_000_000) - } - XCTFail("Timed out waiting for \(description)", file: file, line: line) - } -} - -private actor PromptPresenterProbe { - private(set) var presented: [PromptNotificationCoordinatorCore.StartPromptRequest] = [] - private var continuations: - [CheckedContinuation] = [] - - var presentedCount: Int { presented.count } - - func present(_ request: PromptNotificationCoordinatorCore.StartPromptRequest) async - -> PromptNotificationCoordinatorCore.ModalDecision - { - presented.append(request) - return await withCheckedContinuation { continuation in - continuations.append(continuation) - } - } - - func dismissAll() { - let pending = continuations - continuations.removeAll(keepingCapacity: false) - for continuation in pending { - continuation.resume(returning: .dismissed) - } - } -} - -private actor NotificationProbe { - private(set) var startRequests: [PromptNotificationCoordinatorCore.StartNotificationRequest] = [] - - var startRequestCount: Int { startRequests.count } - var startRequestKinds: [PromptNotificationCoordinatorCore.StartNotificationKind] { - startRequests.map(\.kind) - } - - func postStart(_ request: PromptNotificationCoordinatorCore.StartNotificationRequest) async throws - { - startRequests.append(request) - } -} - -private actor AuthorizationProbe { - private var statuses: [PromptNotificationCoordinatorCore.NotificationAuthorization] - private(set) var callCount = 0 - - init(statuses: [PromptNotificationCoordinatorCore.NotificationAuthorization]) { - self.statuses = statuses - } - - func next() async -> PromptNotificationCoordinatorCore.NotificationAuthorization { - callCount += 1 - if statuses.isEmpty { return .denied } - return statuses.removeFirst() - } } private actor EndPromptCallbackProbe { diff --git a/Tests/TranscriberCoreTests/Session/StartPromptSourceTests.swift b/Tests/TranscriberCoreTests/Session/StartPromptSourceTests.swift index e2e57ce..e0a40a1 100644 --- a/Tests/TranscriberCoreTests/Session/StartPromptSourceTests.swift +++ b/Tests/TranscriberCoreTests/Session/StartPromptSourceTests.swift @@ -1,173 +1,18 @@ import XCTest +/// Source guards for the auto-record cutover (plans/auto-record.md). +/// The pre-call start prompt is gone: detection records directly. What +/// survives is the EndGuard stop-prompt notification channel +/// (`EndPromptCoordinator`), the silent start path, and the discard +/// machinery. final class StartPromptSourceTests: XCTestCase { private var source: String { get throws { - let path = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TranscriberApp/Scribe/StartPromptCoordinator.swift") - return try String(contentsOf: path, encoding: .utf8) + try CombinedAppSources.appSource("EndPromptCoordinator.swift") } } - func testModalFirstPromptActivatesScribeAndIsConfidential() throws { - let source = try source - XCTAssertTrue(source.contains("NSApp.activate(ignoringOtherApps: true)")) - XCTAssertTrue(source.contains("PromptModalWindow.run")) - XCTAssertTrue(source.contains("onWindowReady")) - } - - func testPrimaryChoicesAreStartRecordingAndNotNowOnly() throws { - let source = try source - XCTAssertTrue(source.contains("primaryTitle: \"Start Recording\"")) - XCTAssertTrue(source.contains("secondaryTitle: \"Not now\"")) - XCTAssertFalse(source.contains("alert.addButton(withTitle: \"Stop detecting")) - } - - func testStartRecordingIsNotImplicitDefaultButtonAction() throws { - let path = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TranscriberApp/Scribe/PromptModalWindow.swift") - let source = try String(contentsOf: path, encoding: .utf8) - XCTAssertTrue(source.contains("panel.defaultButtonCell = nil"), "the prompt window must not install a default button cell that can start capture on focus/activation") - XCTAssertFalse(source.contains(".keyboardShortcut"), "Start Recording must not be invokable by an implicit keyboard shortcut") - } - - func testBackupNotificationUsesMatchingActionsWithoutSuppressionAction() throws { - let source = try source - XCTAssertTrue(source.contains("title: \"Start Recording\"")) - XCTAssertTrue(source.contains("title: \"Not now\"")) - XCTAssertFalse(source.contains("UNNotificationAction(\n identifier: Action.suppress")) - XCTAssertTrue(source.contains("modal/menu recovery remain active")) - XCTAssertTrue(source.contains("UNNotificationDismissActionIdentifier")) - } - - func testModalPromptDoesNotExposeSuppressionDisclosure() throws { - let source = try source - XCTAssertFalse(source.contains("More options ▾")) - XCTAssertFalse(source.contains("Stop detecting \\(appDisplayName) for 30 minutes")) - } - - func testMenuRecoveryStillExposesSuppressionBehindDisclosure() throws { - let source = try CombinedAppSources.recordingMenu() - XCTAssertTrue(source.contains("DisclosureGroup(\"More options ▾\")")) - XCTAssertTrue(source.contains("Stop detecting \\(prompt?.appDisplayName ?? \"this app\") for 30 minutes")) - } - - func testDismissalKeepsPromptSessionRecoverableUntilResolutionOrExpiry() throws { - let source = try source - XCTAssertTrue(source.contains("A close/Esc/dismissal is not an implicit decline")) - XCTAssertTrue(source.contains("menu recovery remains active")) - XCTAssertTrue(source.contains("scheduleRecoveryTimers(for: entry)")) - } - - func testIgnoredPromptReminderAndExpiryTimersExist() throws { - let source = try source - XCTAssertTrue(source.contains("var reminderDelay: TimeInterval = 60")) - XCTAssertTrue(source.contains("var expiryDelay: TimeInterval = 180")) - XCTAssertTrue(source.contains("kind: .reminder")) - XCTAssertTrue(source.contains("handleIgnoredPromptExpiry(promptID: promptID)")) - } - - func testNotificationDismissalDoesNotResolvePrompt() throws { - let source = try source - XCTAssertTrue(source.contains("Start prompt notification dismissed without decision")) - XCTAssertFalse(source.contains("UNNotificationDismissActionIdentifier:\n self.resolve")) - } - - func testMenuRecoveryActionsResolveActivePrompt() throws { - let source = try source - XCTAssertTrue(source.contains("chooseStartFromRecovery")) - XCTAssertTrue(source.contains("chooseNotNowFromRecovery")) - XCTAssertTrue(source.contains("chooseSuppressAppFromRecovery")) - XCTAssertTrue(source.contains("Ignoring stale start-prompt menu recovery")) - } - - func testEndedCallExpiryResolvesPendingPromptWithoutStartingRecording() throws { - let source = try source - XCTAssertTrue(source.contains("func expireActivePrompt(for candidate: DetectionCandidate)"), "prompt coordinator must expose a trigger-scoped stale-call expiry seam") - XCTAssertTrue(source.contains("DetectionTriggerIdentity.matchesEndedCandidate"), "stale-call expiry must use the shared trigger-scoped matcher") - XCTAssertFalse(source.contains("entry.candidate.triggerIdentity == candidate.triggerIdentity || entry.app.bundleID == candidate.app.bundleID"), "stale-call expiry must not fall back to broad same-app matching") - XCTAssertTrue(source.contains("resolve(identifier: identifier, with: .skipForNow, removeNotifications: true)"), "ended calls should clear recovery like Not now rather than starting capture") - XCTAssertTrue(source.contains("Ignoring stale start-prompt action"), "late modal/notification actions for expired prompt IDs must be inert") - } - - func testPendingStateInstallsBeforeAsynchronousBackupNotification() throws { - let source = try source - let pendingRange = try XCTUnwrap(source.range(of: "pending[identifier] = entry")) - let notificationRange = try XCTUnwrap(source.range(of: "postNotificationIfPossible(")) - let modalRange = try XCTUnwrap(source.range(of: "presentModalPrompt(identifier: identifier")) - XCTAssertLessThan(pendingRange.lowerBound, notificationRange.lowerBound) - XCTAssertLessThan(notificationRange.lowerBound, modalRange.lowerBound) - XCTAssertTrue(source.contains("Task { @MainActor [weak self] in"), "backup notification posting should be asynchronous relative to modal presentation") - } - - func testResolvedPromptDismissesVisibleModalRunLoop() throws { - let source = try source - XCTAssertTrue(source.contains("weak var modalWindow: NSWindow?")) - XCTAssertTrue(source.contains("var isModalVisible = false")) - XCTAssertTrue(source.contains("dismissModalIfVisible(for: entry)")) - XCTAssertTrue(source.contains("entry.modalWindow?.orderOut(nil)")) - XCTAssertTrue(source.contains("NSApp.stopModal(withCode: NSApplication.ModalResponse.abort)")) - XCTAssertTrue(source.contains("Stopped visible start prompt modal after non-modal resolution")) - } - - func testStaleAsyncNotificationCompletionCannotPostAfterPromptResolved() throws { - let source = try source - XCTAssertTrue(source.contains("guard pending[promptID] != nil else")) - XCTAssertTrue(source.contains(#"Skipping stale start prompt \(kind.rawValue, privacy: .public) notification after authorization completed"#)) - } - - func testIgnoredPromptExpiryUsesCallActivitySeamForFinalReminder() throws { - let source = try source - XCTAssertTrue(source.contains("var callActivityChecker: @MainActor (MeetingApp) async -> Bool"), "expiry must use an injectable call-activity seam instead of untestable wall-clock/UI behavior") - XCTAssertTrue(source.contains("CoreAudioInputProbe().isActive(bundleID: app.bundleID) == true"), "production seam should require a positive active-call signal") - XCTAssertTrue(source.contains("await self?.handleIgnoredPromptExpiry(promptID: promptID)"), "expiry timer should route through a deterministic policy method") - XCTAssertTrue(source.contains("guard callStillActive else"), "inactive or ended calls should take the safe expiry path") - XCTAssertTrue(source.contains("kind: .finalReminder"), "still-active calls should get a distinct one-time final reminder") - XCTAssertTrue(source.contains("entry.expiryTimer = nil"), "active-call final reminder must not schedule repeated expiry spam") - XCTAssertTrue(source.contains("does not start recording, does not auto-decline"), "source should document that final reminder leaves the decision user-controlled") - } - - func testInactiveExpiryClearsStaleActionsWithoutStartRecording() throws { - let source = try source - XCTAssertTrue(source.contains("Start prompt expired for inactive or ended call")) - XCTAssertTrue(source.contains("clearing stale recovery actions")) - XCTAssertTrue(source.contains("resolve(identifier: promptID, with: .skipForNow, removeNotifications: true)")) - XCTAssertFalse(source.contains("resolve(identifier: promptID, with: .start"), "expiry must never auto-start recording") - } - - func testFinalReminderNotificationCopyIsDistinctFromSixtySecondReminder() throws { - let source = try source - XCTAssertTrue(source.contains("case finalReminder")) - XCTAssertTrue(source.contains(#"content.body = "Still want to start recording?""#)) - XCTAssertTrue(source.contains(#"content.body = "Last reminder while this call appears active.""#)) - } - - func testDuplicatePromptCoalescesAwaitersWithoutReplacingPendingEntry() throws { - let source = try source - XCTAssertTrue(source.contains("if let entry = pending[identifier]"), "duplicate prompt path must look up the existing pending prompt") - XCTAssertTrue(source.contains("entry.addAwaiter(continuation)"), "duplicate prompt path must append the new awaiter") - XCTAssertTrue(source.contains("return\n }\n\n let entry = Pending"), "duplicate prompt path must return before creating a replacement Pending") - XCTAssertTrue(source.contains("appending awaiter without replacing prompt state")) - XCTAssertTrue(source.contains("func resumeAll(returning choice: Choice)"), "resolution must complete every coalesced awaiter") - XCTAssertTrue(source.contains("entry.resumeAll(returning: choice)"), "resolve must resume all awaiters rather than a single overwritten continuation") - } - - func testNotificationAuthorizationIsRequeriedForEveryPrompt() throws { - let source = try source - XCTAssertTrue(source.contains("Re-query every time instead of caching process-lifetime denial or")) - XCTAssertFalse(source.contains("authorizationKnown"), "denied/granted notification authorization must not be cached for the app lifetime") - XCTAssertFalse(source.contains("authorizationGranted"), "notification grant state must be re-read after System Settings changes") - XCTAssertTrue(source.contains("let settings = await center.notificationSettings()")) - XCTAssertTrue(source.contains("return try await center.requestAuthorization(options: [.alert, .sound])")) - } + // MARK: - End-prompt notification channel func testEndPromptNotificationActionsAreGenerationGuardedAndClearSurfaces() throws { let source = try source @@ -190,12 +35,13 @@ final class StartPromptSourceTests: XCTestCase { XCTAssertFalse(source.contains("Summary")) } - - func testLateJoinCalendarPromptCopyStatesCaptureFromNowOnward() throws { + func testNotificationAuthorizationIsRequeriedForEveryPrompt() throws { let source = try source - XCTAssertTrue(source.contains("Record '\\(event.title)'? This event started")) - XCTAssertTrue(source.contains("Recording will capture from now onward.")) - XCTAssertTrue(source.contains("event.endDate.timeIntervalSince(Date()) >= 10 * 60")) + XCTAssertTrue(source.contains("Re-query every time instead of caching process-lifetime denial or")) + XCTAssertFalse(source.contains("authorizationKnown"), "denied/granted notification authorization must not be cached for the app lifetime") + XCTAssertFalse(source.contains("authorizationGranted"), "notification grant state must be re-read after System Settings changes") + XCTAssertTrue(source.contains("let settings = await center.notificationSettings()")) + XCTAssertTrue(source.contains("return try await center.requestAuthorization(options: [.alert, .sound])")) } func testNotificationPayloadDoesNotIncludeUnsafeCalendarContext() throws { @@ -207,22 +53,10 @@ final class StartPromptSourceTests: XCTestCase { XCTAssertFalse(body.contains("keyterms")) XCTAssertFalse(body.contains("attendees")) } - - func testPromptIdentifierUsesTriggerIdentity() throws { - let source = try source - XCTAssertTrue(source.contains("func prompt(for candidate: DetectionCandidate"), "prompt coordinator should accept the detection candidate with trigger identity") - XCTAssertTrue(source.contains("let identifier = candidate.triggerIdentity"), "prompt ID must use calendar occurrence identity when DetectionEngine provides it") - XCTAssertTrue(source.contains(#""triggerIdentity": promptID"#), "notification payload should carry the same trigger identity for stale action de-dupe") - } - - func testPromptPlacementUsesActiveMeetingWindowScreen() throws { - let source = try source - XCTAssertTrue(source.contains("self?.place(window: window, nearActiveWindowFor: app)")) - XCTAssertTrue(source.contains("CGWindowListCopyWindowInfo")) - XCTAssertTrue(source.contains("NSScreen.screens.max")) - } } +/// Source guards for the silent-start path and the auto-record stop +/// flow, replacing the old prompt-preflight recovery guards. final class PromptPreflightRecoverySourceTests: XCTestCase { private var appDelegateSource: String { get throws { @@ -230,40 +64,60 @@ final class PromptPreflightRecoverySourceTests: XCTestCase { } } - private var startPromptSource: String { - get throws { - let path = URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("TranscriberApp/Scribe/StartPromptCoordinator.swift") - return try String(contentsOf: path, encoding: .utf8) + func testDetectionCandidateAutoRecordsThroughTheSilentStartPath() throws { + let source = try appDelegateSource + XCTAssertFalse(source.contains("private func presentStartPrompt"), "the pre-call prompt handler is deleted") + XCTAssertTrue(source.contains("origin: .detected"), "detection candidates start recording through the silent origin") + XCTAssertTrue(source.contains("guard settings.autoRecordEnabled else"), "auto-record off parks the candidate instead of starting") + XCTAssertTrue(source.contains("parkedCandidate = (candidate: candidate, event: event)"), "the parked hold is the single staged-start value") + } + + func testSilentStartNeverOpensWindowsOnFailurePaths() throws { + let source = try appDelegateSource + guard let startRange = source.range(of: "func performStart(") else { + return XCTFail("performStart must exist as the single start path") } + let startBody = String(source[startRange.lowerBound.., directory: URL?)]"), "one inflight table with optional directories") + XCTAssertFalse(source.contains("workerTasksByDirectory"), "the second map is gone") + XCTAssertTrue(source.contains("inflightSessions[id] = (task: task, directory: dir.url)"), "worker dispatch registers with its directory") + XCTAssertTrue(source.contains("$0.value.directory == url"), "Recents Discard cancels by directory through the one table") + } + + func testRequiredSetupStillOutranksDetectedIcon() throws { + let source = try appDelegateSource + XCTAssertTrue(source.contains("setupNeedsAttention = true"), "required preflight denial should surface Setup Required") + XCTAssertTrue(source.contains("menu?.setupNeedsAttention = true"), "required preflight denial should mark the popover setup state") } } diff --git a/Tests/TranscriberCoreTests/Session/TrustStateTests.swift b/Tests/TranscriberCoreTests/Session/TrustStateTests.swift index ca32c35..bbb35f2 100644 --- a/Tests/TranscriberCoreTests/Session/TrustStateTests.swift +++ b/Tests/TranscriberCoreTests/Session/TrustStateTests.swift @@ -7,7 +7,7 @@ final class TrustStateTests: XCTestCase { private func inputs( status: SessionStatus = .idle, setupNeedsAttention: Bool = false, - detectionPromptActive: Bool = false, + parkedDetection: Bool = false, endPromptActive: Bool = false, lastSavedAt: Date? = nil, lastFailureAt: Date? = nil @@ -15,7 +15,7 @@ final class TrustStateTests: XCTestCase { TrustState.Inputs( status: status, setupNeedsAttention: setupNeedsAttention, - detectionPromptActive: detectionPromptActive, + parkedDetection: parkedDetection, endPromptActive: endPromptActive, lastSavedAt: lastSavedAt, lastFailureAt: lastFailureAt, @@ -30,8 +30,8 @@ final class TrustStateTests: XCTestCase { XCTAssertEqual(s, .recording) } - func testStoppingBeatsDetectionPrompt() { - let s = TrustState.resolve(inputs(status: .stopping, detectionPromptActive: true)) + func testStoppingBeatsParkedDetection() { + let s = TrustState.resolve(inputs(status: .stopping, parkedDetection: true)) XCTAssertEqual(s, .stopping) } @@ -47,13 +47,13 @@ final class TrustStateTests: XCTestCase { // MARK: - Idle session falls through to flag layer - func testDetectionPromptShowsDetected() { - let s = TrustState.resolve(inputs(detectionPromptActive: true)) + func testParkedDetectionShowsDetected() { + let s = TrustState.resolve(inputs(parkedDetection: true)) XCTAssertEqual(s, .detected) } func testSetupBlockerBeatsDetectedPromptIcon() { - let s = TrustState.resolve(inputs(setupNeedsAttention: true, detectionPromptActive: true)) + let s = TrustState.resolve(inputs(setupNeedsAttention: true, parkedDetection: true)) XCTAssertEqual(s, .setupRequired) } diff --git a/Tests/TranscriberCoreTests/Storage/SettingsStoreTests.swift b/Tests/TranscriberCoreTests/Storage/SettingsStoreTests.swift index db3503d..ed1bd34 100644 --- a/Tests/TranscriberCoreTests/Storage/SettingsStoreTests.swift +++ b/Tests/TranscriberCoreTests/Storage/SettingsStoreTests.swift @@ -38,7 +38,7 @@ final class SettingsStoreTests: XCTestCase { ) let snap = await store.snapshot() XCTAssertEqual(snap.outputRoot, root) - XCTAssertEqual(snap.engineMode, .cloud) + XCTAssertEqual(snap.engineMode, .local, "plans/auto-record.md: on-device default") XCTAssertEqual(snap.keepRawStreams, false, "spec line 102 default OFF") XCTAssertEqual(snap.aecEnabled, true, "D2 default ON") XCTAssertEqual(snap.privacyAcknowledged, false, "spec line 348: first launch must re-prompt") @@ -47,6 +47,44 @@ final class SettingsStoreTests: XCTestCase { XCTAssertEqual(snap.showInMenuBar, true) XCTAssertEqual(snap.startStopShortcut, .defaultStartStop) XCTAssertNil(snap.transcriptionLanguage, "default language is Auto (nil)") + XCTAssertEqual(snap.autoDiscardThresholdSeconds, 120, "plans/auto-record.md default") + XCTAssertEqual(snap.autoRecordEnabled, true, "auto-record ships on by default") + } + + func testBlobWithoutAutoRecordKeysDecodesWithDefaults() async throws { + // A blob written by a build before the auto-record fields must + // keep decoding, with the new fields rolled forward as defaults. + let suite = try makeSuite() + let root = tempDir() + let old = SessionSettings( + outputRoot: root, + engineMode: .cloud, + keepRawStreams: true, + aecEnabled: false, + privacyAcknowledged: true + ) + var json = try JSONSerialization.jsonObject( + with: JSONEncoder().encode(old)) as! [String: Any] + json.removeValue(forKey: "autoDiscardThresholdSeconds") + json.removeValue(forKey: "autoRecordEnabled") + let data = try JSONSerialization.data(withJSONObject: json) + suite.box.defaults.set(data, forKey: SettingsStore.Key.storage.rawValue) + + let store = SettingsStore(defaults: suite.box, fallback: .init(outputRoot: tempDir())) + let snap = await store.snapshot() + XCTAssertEqual(snap.keepRawStreams, true, "stored fields survive, no whole-blob fallback") + XCTAssertEqual(snap.autoDiscardThresholdSeconds, 120) + XCTAssertEqual(snap.autoRecordEnabled, true) + } + + func testAutoRecordSettersRoundTrip() async throws { + let suite = try makeSuite() + let store = SettingsStore(defaults: suite.box, fallback: .init(outputRoot: tempDir())) + await store.setAutoDiscardThresholdSeconds(300) + await store.setAutoRecordEnabled(false) + let snap = await store.snapshot() + XCTAssertEqual(snap.autoDiscardThresholdSeconds, 300) + XCTAssertEqual(snap.autoRecordEnabled, false) } func testTranscriptionLanguageIsRoundTrippedAndClearable() async throws { diff --git a/TranscriberApp/Scribe.xcodeproj/project.pbxproj b/TranscriberApp/Scribe.xcodeproj/project.pbxproj index 5cae0c8..ff36d74 100644 --- a/TranscriberApp/Scribe.xcodeproj/project.pbxproj +++ b/TranscriberApp/Scribe.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ C4457AFB46EBCC8F7A30D469 /* ScribeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 572ACD849DC28AE0C35FCA9B /* ScribeApp.swift */; }; C77F36DC8336DD0B1E8C4E42 /* DSComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6560FDC7869C04A2DDA47C4D /* DSComponents.swift */; }; CD50518167C00014F057718B /* LaunchAtLoginController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A122E1BA74B13B927113F362 /* LaunchAtLoginController.swift */; }; + D2EF03FAC112394B7E313DF2 /* EndPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D887A9515CB2AF5296EB6970 /* EndPromptCoordinator.swift */; }; D3F3210B5BA17B57CD7BE503 /* RecordingPopoverComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 787B3FF78982C293A3655FDE /* RecordingPopoverComponents.swift */; }; D8CFF69D8EEF54C470DF4FC4 /* SettingsFormModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D902958479BD76E8A591E772 /* SettingsFormModel.swift */; }; D8F8E2FE0264A5E771AC9369 /* AppDelegate+Recovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70477D568C94933B28B28660 /* AppDelegate+Recovery.swift */; }; @@ -71,7 +72,6 @@ E3348DFA7B84EF83D2B6020E /* RecordingPopoverContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C1233A5A24CB164CD57C1CF /* RecordingPopoverContent.swift */; }; E5C6D97873E51C84CF16DE83 /* GeistMonoVariable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = E1AD54C2365F26003491870B /* GeistMonoVariable.ttf */; }; E67E39F278A45AF190AE2D5E /* RecordingMenuModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57798CE05322D21F821E5C1F /* RecordingMenuModel.swift */; }; - E779DA60BCB9E871BE08B542 /* StartPromptCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F39003E93011F1694EF2DD /* StartPromptCoordinator.swift */; }; EA3807D7A4FEAFB9C8FA0A28 /* AppearanceApplier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C664D4D3F7F9ED903172F13F /* AppearanceApplier.swift */; }; EAE5D4E4120F7F855B7ADB6D /* MLXAudioSTT in Frameworks */ = {isa = PBXBuildFile; productRef = 2F343A2DF12DC4D575CEA724 /* MLXAudioSTT */; }; EB0FA60A0BD3F50F9FACC252 /* MLXAudioCore in Frameworks */ = {isa = PBXBuildFile; productRef = BF1DD1FF53036826C2308046 /* MLXAudioCore */; }; @@ -92,7 +92,6 @@ 1B7103EC351C0B317B030CEB /* InterVariable-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "InterVariable-Italic.ttf"; sourceTree = ""; }; 1E8ED07528CA7F15CB8B5834 /* OnboardingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindow.swift; sourceTree = ""; }; 1F1858E8D174C9EF87D9DF96 /* DSButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSButtonStyles.swift; sourceTree = ""; }; - 21F39003E93011F1694EF2DD /* StartPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartPromptCoordinator.swift; sourceTree = ""; }; 223F8F991BFCA69CD8E77665 /* Geist-OFL.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Geist-OFL.txt"; sourceTree = ""; }; 24F4582EC33FD01ACF85F684 /* JetBrainsMonoVariable-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "JetBrainsMonoVariable-Italic.ttf"; sourceTree = ""; }; 27116E539EDC78A1D1FAF12F /* SettingsForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsForm.swift; sourceTree = ""; }; @@ -141,6 +140,7 @@ D288AA87854E67BA12387175 /* DiagnosticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsView.swift; sourceTree = ""; }; D5C795F753B561341FDDD89D /* GeneralPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPanel.swift; sourceTree = ""; }; D7C10C9A46853F1D1244288F /* RecordingMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingMenu.swift; sourceTree = ""; }; + D887A9515CB2AF5296EB6970 /* EndPromptCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndPromptCoordinator.swift; sourceTree = ""; }; D8B6D8B78285F246A1264A86 /* GeistMonoVariable-Italic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "GeistMonoVariable-Italic.ttf"; sourceTree = ""; }; D902958479BD76E8A591E772 /* SettingsFormModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsFormModel.swift; sourceTree = ""; }; E1389DD3A1C02E91BD83F963 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; @@ -269,6 +269,7 @@ C871FAADFB4FA1A173B1256D /* CompositeLocalModelDownloadStarter.swift */, D288AA87854E67BA12387175 /* DiagnosticsView.swift */, 57A418554CCA692B873FA2E4 /* EndCountdownWindow.swift */, + D887A9515CB2AF5296EB6970 /* EndPromptCoordinator.swift */, ACC696A8F237634672FD3D9D /* KeyboardShortcutSetting+App.swift */, A122E1BA74B13B927113F362 /* LaunchAtLoginController.swift */, 1E8ED07528CA7F15CB8B5834 /* OnboardingWindow.swift */, @@ -278,7 +279,6 @@ B9437F7D84ECAFD4F230BE94 /* SavedNotificationWindow.swift */, B5FE20690F9A4138A7E556F8 /* ScreenRecordingRelaunchAssist.swift */, 572ACD849DC28AE0C35FCA9B /* ScribeApp.swift */, - 21F39003E93011F1694EF2DD /* StartPromptCoordinator.swift */, A53ED7E906F024B21ABCDACA /* StartStopHotKeyRegistrar.swift */, 7CA05E35B5AF36D97BD50523 /* DesignSystem */, 80ECC09A60AB563DAA7AF65E /* Fonts */, @@ -413,6 +413,7 @@ 336C59F175AEA1A4DA1C7820 /* DebugVisualSnapshotWriter.swift in Sources */, 09D39AB01C40DFE6902AD567 /* DiagnosticsView.swift in Sources */, AFE235F52485B1F5D7F52259 /* EndCountdownWindow.swift in Sources */, + D2EF03FAC112394B7E313DF2 /* EndPromptCoordinator.swift in Sources */, 5C695B5E2911084AEA2D356B /* FidelityChrome.swift in Sources */, BE0AA8F37881495D92A9095C /* FidelityComponents.swift in Sources */, 7E611EC0F030FB39FCF05762 /* GeneralPanel.swift in Sources */, @@ -438,7 +439,6 @@ 2A87B65CC212C89C77C0AAB0 /* SettingsWindowController.swift in Sources */, 9C0923308D31F7AB56305CEE /* ShortcutCapture.swift in Sources */, 48F838161692F910B33138E5 /* ShortcutsPanel.swift in Sources */, - E779DA60BCB9E871BE08B542 /* StartPromptCoordinator.swift in Sources */, 623C58CBA16EAC45B390C6C3 /* StartStopHotKeyRegistrar.swift in Sources */, 8EE1BA8BD2A6BE8CF013D51B /* VaultPanel.swift in Sources */, 01606658AAD57D7E4DDF5C6C /* WindowChrome.swift in Sources */, diff --git a/TranscriberApp/Scribe/AppDelegate+Detection.swift b/TranscriberApp/Scribe/AppDelegate+Detection.swift index 59480b9..e39227d 100644 --- a/TranscriberApp/Scribe/AppDelegate+Detection.swift +++ b/TranscriberApp/Scribe/AppDelegate+Detection.swift @@ -2,12 +2,8 @@ import AppKit import TranscriberCore extension AppDelegate { - /// Engine fires this when an allowlisted app/browser has been stable for - /// the dwell window and active-call probing is positive or unavailable. - /// Calendar lookup below is enrichment-only: it labels the prompt but - /// never creates candidates without DetectionEngine app/call activity. - /// and route the user's choice. Queue candidates while a recording is - /// active so a second meeting never interrupts capture. + /// Trigger identity for a detection candidate: calendar occurrence + /// when one overlaps, else the plain app signature. @MainActor func triggerIdentity(for app: MeetingApp) async -> String { let event = await calendarWatcher.eventOverlapping(Date()) @@ -17,98 +13,50 @@ extension AppDelegate { return DetectionEngine.defaultTriggerIdentity(for: app) } + /// Engine fires this when a candidate passed the AutoRecordPolicy + /// gates inside DetectionEngine. Calendar lookup is enrichment-only. + /// A recording already live queues the candidate; otherwise the + /// candidate either auto-starts a silent recording or parks in the + /// passive "Meeting detected" state when auto-record is off. @MainActor func handleDetectionCandidate(_ candidate: DetectionCandidate) async { let event = await calendarWatcher.eventOverlapping(Date()) - if dismissedPromptTriggerIdentities.contains(candidate.triggerIdentity) { - Log.lifecycle.info( - "Detection candidate skipped for dismissed trigger identity: \(candidate.triggerIdentity, privacy: .public)" - ) - return - } if status == .recording || status == .starting { queueDetectionCandidate(candidate, event: event) return } - await presentStartPrompt(for: candidate, event: event) - } - - @MainActor - private func presentStartPrompt(for candidate: DetectionCandidate, event: CalendarEvent?) async { - let app = candidate.app + guard settings.autoRecordEnabled else { + Log.lifecycle.info( + "Detection candidate parked (auto-record off): \(candidate.app.bundleID, privacy: .public) trigger=\(candidate.triggerIdentity, privacy: .public)" + ) + // The parked hold keeps the candidate staged so a manual Record + // Now attaches its identity (ended-call routing keeps working) + // and the trust icon shows the pulse. + parkedCandidate = (candidate: candidate, event: event) + applyTrustIcon() + return + } Log.lifecycle.info( - "Detection candidate: \(app.bundleID, privacy: .public) trigger=\(candidate.triggerIdentity, privacy: .public)" + "Auto-record candidate: \(candidate.app.bundleID, privacy: .public) trigger=\(candidate.triggerIdentity, privacy: .public)" ) - Log.calendar.info("Prompt enrichment: matched=\(event != nil ? "yes" : "no", privacy: .public)") - // F-2: surface .detected on the menu bar while the prompt is - // unresolved. Dismissal/ignore paths intentionally do not clear this; - // only explicit resolution or prompt-session expiry returns to idle. - detectionPromptActive = true - pendingPromptAppBundleID = app.bundleID - pendingPromptTriggerIdentity = candidate.triggerIdentity - menu?.pendingPrompt = PendingPromptRecovery( - title: Self.promptRecoveryTitle(for: app, event: event), - subtitle: event == nil - ? "Detected in \(app.displayName)." : "From Apple Calendar · \(app.displayName).", - appDisplayName: app.displayName - ) - applyTrustIcon() - let choice = await startPromptCoordinator.prompt(for: candidate, event: event) - // Source-guard marker: let shouldClearPendingPrompt = choice != .start || !setupNeedsAttention - let shouldClearPendingPrompt = choice != .start || !setupNeedsAttention - if shouldClearPendingPrompt { - detectionPromptActive = false - pendingPromptAppBundleID = nil - pendingPromptTriggerIdentity = nil - menu?.pendingPrompt = nil - } - applyTrustIcon() - switch choice { - case .start: - pendingPromptCalendarEventForStart = event - pendingPromptCandidateForStart = candidate - await startRecording() - if setupNeedsAttention { - // Source-guard marker: if setupNeedsAttention { - // pendingPromptCalendarEventForStart = event - pendingPromptCalendarEventForStart = event - detectionPromptActive = true - pendingPromptAppBundleID = app.bundleID - pendingPromptTriggerIdentity = candidate.triggerIdentity - menu?.pendingPrompt = PendingPromptRecovery( - title: Self.promptRecoveryTitle(for: app, event: event), - subtitle: event == nil - ? "Detected in \(app.displayName). Fix setup, then start recording." - : "From Apple Calendar · \(app.displayName). Fix setup, then start recording.", - appDisplayName: app.displayName - ) - } else { - pendingPromptCalendarEventForStart = nil - pendingPromptCandidateForStart = nil - detectionPromptActive = false - pendingPromptAppBundleID = nil - pendingPromptTriggerIdentity = nil - menu?.pendingPrompt = nil - } - applyTrustIcon() - case .notAMeeting: - pendingPromptCandidateForStart = nil - await detectionEngine?.suppress(app) - Log.lifecycle.info("User suppressed \(app.bundleID, privacy: .public) for 30 minutes") - // Codex P1 fix: ProcessWatcher only emits launch/terminate events, - // so a long-running app (Chrome left open) needs an explicit re-arm - // when the TTL expires. Schedule a one-shot Task that re-fires the - // launch event 30 minutes later if the app is still running. - scheduleRearm(for: app, after: 30 * 60) - case .skipForNow: - pendingPromptCandidateForStart = nil - dismissedPromptTriggerIdentities.insert(candidate.triggerIdentity) + Log.calendar.info("Start enrichment: matched=\(event != nil ? "yes" : "no", privacy: .public)") + // Park first: a failed silent start (privacy, disk, preflight) + // restores the hold, and a successful start consumes it. + parkedCandidate = (candidate: candidate, event: event) + let outcome = await performStart(origin: .detected, staged: (candidate, event)) + if case .failed(let reason) = outcome { + // Silent path never opens a window; the Setup Required trust + // state and this log are the whole report. The candidate stays + // parked so ended-call recognition can clear it and a manual + // Record Now can attach it. Log.lifecycle.info( - "User skipped \(app.bundleID, privacy: .public) for now (trigger=\(candidate.triggerIdentity, privacy: .public))" - ) + "Auto-record start declined: \(reason, privacy: .public)") } } + /// Recognition proved a call ended. Routes the stop when the ended + /// call is the one being recorded; otherwise clears the parked hold + /// if it belonged to this candidate. @MainActor func handleEndedDetectionCandidate(_ candidate: DetectionCandidate) async { if isEndedCandidateForCurrentRecording(candidate) { @@ -119,24 +67,17 @@ extension AppDelegate { return } - guard let pendingTriggerIdentity = pendingPromptTriggerIdentity, - let pendingBundleID = pendingPromptAppBundleID, + guard let parked = parkedCandidate, DetectionTriggerIdentity.matchesEndedCandidate( - pendingTriggerIdentity: pendingTriggerIdentity, - pendingBundleID: pendingBundleID, + pendingTriggerIdentity: parked.candidate.triggerIdentity, + pendingBundleID: parked.candidate.bundleID, endedCandidate: candidate ) else { return } Log.lifecycle.info( - "Detection candidate ended before prompt resolution: \(candidate.app.bundleID, privacy: .public) trigger=\(candidate.triggerIdentity, privacy: .public)" + "Detection candidate ended without recording: \(candidate.app.bundleID, privacy: .public) trigger=\(candidate.triggerIdentity, privacy: .public)" ) - startPromptCoordinator.expireActivePrompt(for: candidate) - detectionPromptActive = false - pendingPromptAppBundleID = nil - pendingPromptTriggerIdentity = nil - pendingPromptCalendarEventForStart = nil - pendingPromptCandidateForStart = nil - menu?.pendingPrompt = nil + parkedCandidate = nil applyTrustIcon() } @@ -145,44 +86,4 @@ extension AppDelegate { guard session != nil else { return false } return currentRecordingTriggerIdentity == candidate.triggerIdentity } - - @MainActor - private func scheduleRearm(for app: MeetingApp, after seconds: TimeInterval) { - let id = UUID() - let task = Task { [weak self] in - do { - try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) - } catch { - return - } - if Task.isCancelled { return } - // Only re-fire if the app is still running on the user's machine. - let stillRunning = NSWorkspace.shared.runningApplications.contains { - $0.bundleIdentifier == app.bundleID - } - guard stillRunning, let engine = await self?.detectionEngineSnapshot() else { - await self?.removeTask(id: id) - return - } - Log.lifecycle.info("Re-arming detection for \(app.bundleID, privacy: .public) after Skip TTL") - await engine.handleLaunch(of: app) - await self?.removeTask(id: id) - } - inflightTasks[id] = task - } - - @MainActor - private func detectionEngineSnapshot() -> DetectionEngine? { - detectionEngine - } - - private static func promptRecoveryTitle(for app: MeetingApp, event: CalendarEvent?) -> String { - guard let event else { return "Start recording \(app.displayName)?" } - if event.startDate < Date(), event.endDate.timeIntervalSince(Date()) >= 10 * 60 { - let elapsedMinutes = max(1, Int(Date().timeIntervalSince(event.startDate) / 60)) - return - "Record '\(event.title)'? This event started \(elapsedMinutes) minutes ago. Recording will capture from now onward." - } - return "Start recording '\(event.title)'?" - } } diff --git a/TranscriberApp/Scribe/AppDelegate+EndGuard.swift b/TranscriberApp/Scribe/AppDelegate+EndGuard.swift index 7375c74..6077efa 100644 --- a/TranscriberApp/Scribe/AppDelegate+EndGuard.swift +++ b/TranscriberApp/Scribe/AppDelegate+EndGuard.swift @@ -65,6 +65,25 @@ extension AppDelegate { private func handleEndGuardPrompt(reason: EndGuard.Reason) async { guard session != nil, status == .recording else { return } guard let endGuard else { return } + // Sub-threshold auto-recorded sessions stay fully silent: the stop + // countdown panel AND its notification would interrupt for a + // recording that is about to trash itself anyway. Test the + // PROJECTED duration: the countdown adds ~10s after this point, so + // suppressing on elapsed alone would let a session hide the panel + // at 115s and then get kept at 125s. + let elapsed = currentSessionStartedAt.map { Date().timeIntervalSince($0) } ?? 0 + let projectedSeconds = elapsed + EndGuard.Config.default.countdownDuration + if AutoRecordPolicy.shouldAutoDiscard( + durationSeconds: projectedSeconds, + origin: currentSessionOrigin, + initiator: .endGuard, + thresholdSeconds: TimeInterval(settings.autoDiscardThresholdSeconds) + ) { + Log.lifecycle.info( + "End guard prompt suppressed: projected duration under auto-discard threshold" + ) + return + } let generation = await endGuard.promptGeneration let promptID = UUID().uuidString activeEndPromptGeneration = generation @@ -89,6 +108,11 @@ extension AppDelegate { Task { @MainActor [weak self] in await self?.stopRecordingFromEndPrompt(generation: generation) } + }, + onDiscard: { [weak self, generation] in + Task { @MainActor [weak self] in + await self?.discardFromEndPrompt(generation: generation) + } } ) Task { @MainActor [weak self] in @@ -96,7 +120,7 @@ extension AppDelegate { guard self.activeEndPromptID == promptID, self.activeEndPromptGeneration == generation else { return } - await self.startPromptCoordinator.postEndPromptNotificationIfPossible( + await self.endPromptCoordinator.postEndPromptNotificationIfPossible( promptID: promptID, generation: generation, reason: reason, @@ -133,7 +157,7 @@ extension AppDelegate { Log.lifecycle.info( "End guard auto-stop firing: \(Self.endGuardReasonLabel(reason), privacy: .public)") clearEndGuardPromptSurface() - await stopRecording() + await stopRecording(initiator: .endGuard) } @MainActor @@ -166,6 +190,24 @@ extension AppDelegate { await stopRecording() } + /// Discard from the stop countdown panel: stop the capture and trash + /// the folder. An explicit click overrides the threshold and origin: + /// any session, any duration, manual included. + @MainActor + func discardFromEndPrompt(generation: Int) async { + guard let endGuard else { + clearEndGuardPromptSurface() + return + } + let accepted = await endGuard.stopNow(generation: generation) + guard accepted else { + Log.lifecycle.info("Ignoring stale end guard Discard action") + return + } + Log.lifecycle.info("End prompt accepted: discard") + await stopRecording(discardRequested: true) + } + @MainActor private func cancelEndGuardPrompt() { guard activeEndPromptGeneration != nil else { return } @@ -177,7 +219,7 @@ extension AppDelegate { @MainActor private func clearEndGuardPromptSurface() { if let promptID = activeEndPromptID { - startPromptCoordinator.clearEndPromptNotification(promptID: promptID) + endPromptCoordinator.clearEndPromptNotification(promptID: promptID) } activeEndPromptGeneration = nil activeEndPromptID = nil diff --git a/TranscriberApp/Scribe/AppDelegate+MenuActions.swift b/TranscriberApp/Scribe/AppDelegate+MenuActions.swift index d6c3591..17450f1 100644 --- a/TranscriberApp/Scribe/AppDelegate+MenuActions.swift +++ b/TranscriberApp/Scribe/AppDelegate+MenuActions.swift @@ -15,16 +15,6 @@ extension AppDelegate { } @MainActor - - - /* Source-guard markers retained for tests after formatter line wrapping: -} else if detectionPromptActive { - let event = pendingPromptCalendarEventForStart -if setupNeedsAttention { - pendingPromptCalendarEventForStart = event -pendingPromptCandidateForStart = DetectionCandidate(app: app, triggerIdentity: triggerIdentity) - */ - func handle(_ action: RecordingMenu.Action) async { switch action { case .record: await startRecording() @@ -38,6 +28,7 @@ pendingPromptCandidateForStart = DetectionCandidate(app: app, triggerIdentity: t "Saved audio is missing; open setup to repair this failed session before retrying." )) await presentSetupRequiredPopover() + case .discardSession(let sessionURL): await discardSession(at: sessionURL) case .stop: await stopRecording() case .quit: NSApp.terminate(nil) case .openSettings: @@ -46,64 +37,12 @@ pendingPromptCandidateForStart = DetectionCandidate(app: app, triggerIdentity: t await presentSetupRequiredPopover() case .openDiagnostics: diagnosticsWindowController?.show() - case .promptStartRecording: - if startPromptCoordinator.hasActivePrompt { - startPromptCoordinator.chooseStartFromRecovery() - } else if detectionPromptActive { - // Source-guard marker: } else if detectionPromptActive { - // let event = pendingPromptCalendarEventForStart - let event = pendingPromptCalendarEventForStart - if pendingPromptCandidateForStart == nil, - let bundleID = pendingPromptAppBundleID, - let triggerIdentity = pendingPromptTriggerIdentity, - let app = MeetingApps.appFor(bundleID: bundleID) - { - // Source-guard marker: pendingPromptCandidateForStart = DetectionCandidate(app: app, triggerIdentity: triggerIdentity) - pendingPromptCandidateForStart = DetectionCandidate( - app: app, triggerIdentity: triggerIdentity) - } - await startRecording() - if setupNeedsAttention { - pendingPromptCalendarEventForStart = event - applyTrustIcon() - } else { - pendingPromptCalendarEventForStart = nil - pendingPromptCandidateForStart = nil - detectionPromptActive = false - pendingPromptAppBundleID = nil - pendingPromptTriggerIdentity = nil - menu?.pendingPrompt = nil - applyTrustIcon() - } - } - case .promptNotNow: - if startPromptCoordinator.hasActivePrompt { - startPromptCoordinator.chooseNotNowFromRecovery() - } else { - detectionPromptActive = false - pendingPromptAppBundleID = nil - pendingPromptTriggerIdentity = nil - pendingPromptCalendarEventForStart = nil - pendingPromptCandidateForStart = nil - menu?.pendingPrompt = nil - applyTrustIcon() - } - case .promptSuppressApp: - if startPromptCoordinator.hasActivePrompt { - startPromptCoordinator.chooseSuppressAppFromRecovery() - } else { - detectionPromptActive = false - pendingPromptAppBundleID = nil - pendingPromptTriggerIdentity = nil - pendingPromptCalendarEventForStart = nil - pendingPromptCandidateForStart = nil - menu?.pendingPrompt = nil - applyTrustIcon() - } case .endPromptKeepRecording(let generation): await keepRecordingFromEndPrompt(generation: generation) case .endPromptStopNow(let generation): await stopRecordingFromEndPrompt(generation: generation) + case .endPromptDiscard(let generation): + await discardFromEndPrompt(generation: generation) } } } diff --git a/TranscriberApp/Scribe/AppDelegate+Recovery.swift b/TranscriberApp/Scribe/AppDelegate+Recovery.swift index 76ae2c6..e2c21c5 100644 --- a/TranscriberApp/Scribe/AppDelegate+Recovery.swift +++ b/TranscriberApp/Scribe/AppDelegate+Recovery.swift @@ -3,7 +3,7 @@ import TranscriberCore extension AppDelegate { /// Phase η P0.2 helper: kicks the orphan-session supervisor scan in - /// the background. Tracked in `inflightTasks` so applicationShouldTerminate's + /// the background. Tracked in `inflightSessions` so applicationShouldTerminate's /// drain loop will await it. Must only be called after the user has /// acknowledged the privacy notice (cloud-mode uploads start as soon /// as the supervisor dispatches a worker). @@ -45,7 +45,7 @@ extension AppDelegate { } await self?.removeTask(id: resumeId) } - inflightTasks[resumeId] = resumeTask + inflightSessions[resumeId] = (task: resumeTask, directory: nil) } @MainActor diff --git a/TranscriberApp/Scribe/AppDelegate+Sessions.swift b/TranscriberApp/Scribe/AppDelegate+Sessions.swift index 80091fc..46609cc 100644 --- a/TranscriberApp/Scribe/AppDelegate+Sessions.swift +++ b/TranscriberApp/Scribe/AppDelegate+Sessions.swift @@ -2,6 +2,20 @@ import AppKit import TranscriberCore extension AppDelegate { + /// Typed outcome of a start attempt so the silent (detection-driven) + /// path can fail without ever opening a window. + enum StartOutcome: Equatable { + case started + case alreadyBusy + case failed(reason: String) + } + + /// The staged start: the detection candidate (and its calendar event) + /// behind the next start, whether that start is an auto-record or a + /// manual Record Now while a candidate is parked. One value, so the + /// parked state can never disagree with itself. + typealias StagedStart = (candidate: DetectionCandidate, event: CalendarEvent?) + @MainActor private func presentLowDiskAlert(freeBytes: Int64, outputRoot: URL) { let alert = NSAlert() @@ -32,6 +46,28 @@ extension AppDelegate { @MainActor func startRecording(allowPendingPrivacyAcknowledgementForOnboardingTest: Bool = false) async { + _ = await performStart( + origin: .manual, + staged: parkedCandidate, + allowPendingPrivacyAcknowledgementForOnboardingTest: + allowPendingPrivacyAcknowledgementForOnboardingTest + ) + } + + /// Single start path for manual and detection-driven starts. The + /// preflight (privacy gate, disk space, permission audit) is identical + /// for both; only the failure presentation differs, and that is + /// derived from the origin: manual starts keep today's windows + /// (low-disk alert, consent sheet, setup popover), detected starts + /// fail typed and surface only through the Setup Required trust + /// state and lifecycle logs. + @MainActor + func performStart( + origin: AutoRecordPolicy.SessionOrigin, + staged: StagedStart?, + allowPendingPrivacyAcknowledgementForOnboardingTest: Bool = false + ) async -> StartOutcome { + let interactive = origin == .manual // Codex P2 fix: claim .starting before any await so concurrent // detection candidates (or a menu Record + a candidate firing // simultaneously) can't pass the handleDetectionCandidate guard @@ -39,7 +75,7 @@ extension AppDelegate { guard status != .recording, status != .starting else { Log.lifecycle.info( "startRecording skipped: already \(self.status.rawValue, privacy: .public)") - return + return .alreadyBusy } // F-2: a new attempt clears any leftover saved/failed flash so // the icon doesn't keep mourning the previous session. @@ -52,10 +88,14 @@ extension AppDelegate { // preflight audit, and the session-directory creation below must not // see different snapshots (the audit await is a commit window). let snapshot = settings - guard snapshot.privacyAcknowledged || allowPendingPrivacyAcknowledgementForOnboardingTest else { - Log.lifecycle.info("startRecording blocked: privacy acknowledgement pending") - presentPrivacyAcknowledgementIfNeeded() - return + guard snapshot.privacyAcknowledged || allowPendingPrivacyAcknowledgementForOnboardingTest + else { + Log.lifecycle.info( + "startRecording blocked: privacy acknowledgement pending (\(interactive ? "interactive" : "silent", privacy: .public))") + if interactive { + presentPrivacyAcknowledgementIfNeeded() + } + return .failed(reason: "privacy acknowledgement pending") } if allowPendingPrivacyAcknowledgementForOnboardingTest && !snapshot.privacyAcknowledged { Log.lifecycle.info( @@ -73,25 +113,35 @@ extension AppDelegate { if let freeBytes = Self.availableDiskBytes(for: snapshot.outputRoot), freeBytes < Self.minimumFreeDiskBytes { - denyStartForLowDisk(freeBytes: freeBytes, outputRoot: snapshot.outputRoot) - return + if interactive { + denyStartForLowDisk(freeBytes: freeBytes, outputRoot: snapshot.outputRoot) + } else { + Log.lifecycle.error( + "Silent start denied: low disk space (\(freeBytes, privacy: .public) bytes free)") + status = .idle + menu?.rebuild(for: status) + applyTrustIcon() + } + return .failed(reason: "low disk") } - guard handleStartPreflightResult(report) else { return } + guard await handleStartPreflightResult(report, interactive: interactive) else { + return .failed(reason: "preflight denied") + } let id = SessionID(from: Date()) do { let dir = try SessionDirectory.create(under: snapshot.outputRoot, id: id) let sessionEngineMode = snapshot.engineMode let session = try makeCaptureSession(directory: dir, engineMode: sessionEngineMode) - installStartingSession(session, directory: dir, engineMode: sessionEngineMode) - - // Slice 6: prefer the watcher cache (already populated, no - // EventKit round-trip on the start path). Fall back to the - // direct lookup if the cache hasn't been refreshed yet. - let promptedEvent = pendingPromptCalendarEventForStart - let cachedEvent = promptedEvent == nil ? await calendarWatcher.eventOverlapping(Date()) : nil - let event = promptedEvent ?? cachedEvent ?? calendar.eventOverlapping(Date()) + installStartingSession( + session, directory: dir, engineMode: sessionEngineMode, origin: origin, staged: staged) + + // Slice 6: prefer the staged event, then the watcher cache + // (already populated, no EventKit round-trip on the start path), + // then the direct lookup. + let cachedEvent = staged?.event == nil ? await calendarWatcher.eventOverlapping(Date()) : nil + let event = staged?.event ?? cachedEvent ?? calendar.eventOverlapping(Date()) self.currentCalendarEvent = event Log.calendar.info( "Calendar lookup at session start: matched=\(event != nil ? "yes" : "no", privacy: .public)" @@ -99,13 +149,17 @@ extension AppDelegate { try await session.start() await finishSuccessfulStart(directory: dir, event: event) + return .started } catch { handleStartFailure(error) + return .failed(reason: "capture start failed") } } @MainActor - private func handleStartPreflightResult(_ report: PreflightReport) -> Bool { + private func handleStartPreflightResult( + _ report: PreflightReport, interactive: Bool + ) async -> Bool { switch RecordRequestGate().verdict(from: report) { case .deny(let reasons): // Codex rc2-audit P0 (privacy): String(describing: reasons) @@ -124,13 +178,18 @@ extension AppDelegate { menu?.rebuild(for: status) applyTrustIcon() self.sessionRepairPayload = nil - // Permission-only blockers → polished onboarding window; - // engine/output blockers stay on the popover path. - if Self.allBlockersArePermissions(report) { - setupPopover?.close() - permissionsOnboarding?.present() - } else { - showSetupRequiredPopover(report: report, sessionRepairPayload: nil) + // The silent path never opens a window; the Setup Required trust + // state is the only surface. The interactive path keeps the + // polished blockers UI. + if interactive { + // Permission-only blockers → polished onboarding window; + // engine/output blockers stay on the popover path. + if Self.allBlockersArePermissions(report) { + setupPopover?.close() + permissionsOnboarding?.present() + } else { + showSetupRequiredPopover(report: report, sessionRepairPayload: nil) + } } return false case .allowWithWarnings(let reasons): @@ -175,13 +234,19 @@ extension AppDelegate { @MainActor private func installStartingSession( - _ session: CaptureSession, directory: SessionDirectory, engineMode: EngineMode + _ session: CaptureSession, + directory: SessionDirectory, + engineMode: EngineMode, + origin: AutoRecordPolicy.SessionOrigin, + staged: StagedStart? ) { self.session = session currentSessionDirectory = directory currentSessionStartedAt = Date() currentSessionEngineMode = engineMode - currentRecordingTriggerIdentity = pendingPromptCandidateForStart?.triggerIdentity + currentSessionOrigin = origin + currentRecordingTriggerIdentity = staged?.candidate.triggerIdentity + currentRecordingCandidate = staged?.candidate menu?.sessionEngineMode = engineMode currentDiagnosticsLiveLevels = nil } @@ -199,7 +264,9 @@ extension AppDelegate { @MainActor private func finishSuccessfulStart(directory: SessionDirectory, event: CalendarEvent?) async { status = .recording - pendingPromptCandidateForStart = nil + // The staged candidate now belongs to the recording; the parked + // hold is consumed either way (auto-start or manual attach). + parkedCandidate = nil await startEndGuard(startedAt: currentSessionStartedAt ?? Date()) // Wire the popover's live trust-surface readouts so the user sees // a ticking timer and the matched meeting title the moment they @@ -213,15 +280,13 @@ extension AppDelegate { applyTrustIcon() } + /// Clears every piece of live-session state. Used by the stop path + /// and the failed-start path so both leave the app well-defined. + /// Does not touch the parked candidate: a failed start restores it + /// (the meeting is still live) and only the ended-candidate signal + /// clears it. @MainActor - private func handleStartFailure(_ error: Error) { - Log.lifecycle.error("Start failed: \(String(describing: error), privacy: .public)") - // Codex rc2-audit STATE-3: a failed start would leave - // self.session / currentSessionDirectory / currentSessionStartedAt - // populated. A subsequent Stop or Quit would then write a - // pending transcript for a never-started session. Clear all - // session state on the catch path so the app is well-defined. - status = .failed + private func clearLiveSessionState() { session = nil currentSessionDirectory = nil currentSessionStartedAt = nil @@ -229,8 +294,21 @@ extension AppDelegate { currentSessionEngineMode = nil currentDiagnosticsLiveLevels = nil currentRecordingTriggerIdentity = nil - pendingPromptCandidateForStart = nil + currentRecordingCandidate = nil + currentSessionOrigin = .manual stopElapsedTickTimer() + } + + @MainActor + private func handleStartFailure(_ error: Error) { + Log.lifecycle.error("Start failed: \(String(describing: error), privacy: .public)") + // Codex rc2-audit STATE-3: a failed start would leave + // self.session / currentSessionDirectory / currentSessionStartedAt + // populated. A subsequent Stop or Quit would then write a + // pending transcript for a never-started session. Clear all + // session state on the catch path so the app is well-defined. + status = .failed + clearLiveSessionState() menu?.outcomeFolderName = nil menu?.outcomeFolderURL = nil menu?.sessionEngineMode = .cloud @@ -319,7 +397,9 @@ extension AppDelegate { } @MainActor - func stopRecording() async { + func stopRecording( + initiator: AutoRecordPolicy.StopInitiator = .user, discardRequested: Bool = false + ) async { guard let session, let dir = currentSessionDirectory else { return } await tearDownEndGuard() self.status = .stopping @@ -328,11 +408,25 @@ extension AppDelegate { let endedAt = Date() let started = currentSessionStartedAt ?? endedAt let event = currentCalendarEvent + let origin = currentSessionOrigin + let recordingCandidate = currentRecordingCandidate // One settings read for the whole stop path: session.stop() below is // a commit window, and the worker must not mix engineMode from one // snapshot with keepRawStreams/transcriptionLanguage from another. let snap = settings let sessionEngineMode = currentSessionEngineMode ?? snap.engineMode + let durationSeconds = endedAt.timeIntervalSince(started) + // An explicit Discard click is a user decision and always trashes. + // Otherwise only EndGuard-initiated stops of detected sessions + // under the threshold auto-discard. + let shouldDiscard = + discardRequested + || AutoRecordPolicy.shouldAutoDiscard( + durationSeconds: durationSeconds, + origin: origin, + initiator: initiator, + thresholdSeconds: TimeInterval(snap.autoDiscardThresholdSeconds) + ) var stopSucceeded = false do { @@ -346,20 +440,13 @@ extension AppDelegate { Log.lifecycle.error("Stop failed: \(String(describing: error), privacy: .public)") self.status = .failed } - self.session = nil - self.currentSessionDirectory = nil - self.currentSessionStartedAt = nil - self.currentCalendarEvent = nil - self.currentSessionEngineMode = nil - self.currentDiagnosticsLiveLevels = nil - self.currentRecordingTriggerIdentity = nil - self.pendingPromptCandidateForStart = nil - stopElapsedTickTimer() + clearLiveSessionState() + parkedCandidate = nil menu?.outcomeFolderName = dir.url.lastPathComponent menu?.outcomeFolderURL = dir.url menu?.sessionEngineMode = sessionEngineMode menu?.recordingSourceLabel = Self.recordingSourceLabel(for: event) - menu?.elapsedSeconds = max(0, Int(endedAt.timeIntervalSince(started))) + menu?.elapsedSeconds = max(0, Int(durationSeconds)) menu?.rebuild(for: status) applyTrustIcon() @@ -374,23 +461,147 @@ extension AppDelegate { return } + if shouldDiscard { + await discardStoppedSession( + dir: dir, + candidate: recordingCandidate, + origin: origin, + started: started, + endedAt: endedAt, + event: event, + engineMode: sessionEngineMode, + keepRawStreams: snap.keepRawStreams, + transcriptionLanguage: snap.transcriptionLanguage) + return + } + + keepStoppedSession( + dir: dir, + started: started, + endedAt: endedAt, + event: event, + origin: origin, + engineMode: sessionEngineMode, + keepRawStreams: snap.keepRawStreams, + transcriptionLanguage: snap.transcriptionLanguage, + durationSeconds: Int(durationSeconds)) + } + + /// The keep tail: pending transcript, worker dispatch, then queued- + /// candidate re-evaluation. Shared by the normal stop and the + /// failed-discard fallback so both paths stay identical. + @MainActor + private func keepStoppedSession( + dir: SessionDirectory, + started: Date, + endedAt: Date, + event: CalendarEvent?, + origin: AutoRecordPolicy.SessionOrigin, + engineMode: EngineMode, + keepRawStreams: Bool, + transcriptionLanguage: String?, + durationSeconds: Int + ) { let context = Self.makeContext( - dir: dir, startedAt: started, endedAt: endedAt, event: event, engineMode: sessionEngineMode) + dir: dir, startedAt: started, endedAt: endedAt, event: event, engineMode: engineMode) do { try TranscriptWriter.writePending(at: dir.transcript, context: context) } catch { Log.engine.error( "Failed to write pending transcript: \(String(describing: error), privacy: .public)") } + // Source-order guard: worker dispatch below precedes the queue + // re-evaluation so the stopped session has a durable worker path + // before a queued candidate can start a new recording. + dispatchWorker( + dir: dir, + context: context, + event: event, + origin: origin, + keepRawStreams: keepRawStreams, + engineMode: engineMode, + transcriptionLanguage: transcriptionLanguage, + durationSeconds: durationSeconds) + reevaluateQueuedDetectionCandidateAfterStop() + } + + /// Shared tail for every discard: trash the folder, release the + /// detection candidate so a quiet-start call can re-fire, re-evaluate + /// a queued candidate, and settle to idle with no notification. A + /// failed trash keeps the session (never a `failed` transcript for a + /// discard problem) and runs the exact keep tail. + @MainActor + private func discardStoppedSession( + dir: SessionDirectory, + candidate: DetectionCandidate?, + origin: AutoRecordPolicy.SessionOrigin, + started: Date, + endedAt: Date, + event: CalendarEvent?, + engineMode: EngineMode, + keepRawStreams: Bool, + transcriptionLanguage: String? + ) async { + do { + try dir.moveToTrash() + } catch { + Log.lifecycle.error( + "Discard failed; session kept at its current status: \(String(describing: error), privacy: .public)" + ) + keepStoppedSession( + dir: dir, + started: started, + endedAt: endedAt, + event: event, + origin: origin, + engineMode: engineMode, + keepRawStreams: keepRawStreams, + transcriptionLanguage: transcriptionLanguage, + durationSeconds: 0) + return + } + Log.lifecycle.info( + "Session discarded to Trash: \(dir.url.lastPathComponent, privacy: .public)") + // Rule: auto-discard must release its candidate. Without the + // release, engine coalescing blocks re-detection while the probe + // still reads active, and a meeting that started quiet records + // nothing for its whole length. + if let candidate { + await detectionEngine?.releaseActiveCandidate(candidate) + } + parkedCandidate = nil + status = .idle + menu?.outcomeFolderName = nil + menu?.outcomeFolderURL = nil + menu?.recordingSourceLabel = "Recording" + menu?.elapsedSeconds = 0 + menu?.rebuild(for: status) + applyTrustIcon() + reevaluateQueuedDetectionCandidateAfterStop() + } + /// Dispatches a transcription worker for a session and tracks it in + /// the inflight table (keyed by directory) so Recents Discard can + /// cancel it. Auto-recorded (detected) sessions transcribe silently: + /// the saved window after every call of the day would be a larger new + /// surface than the capture itself. Manual recordings keep the + /// confirmation. + @MainActor + private func dispatchWorker( + dir: SessionDirectory, + context: TranscriptContext, + event: CalendarEvent?, + origin: AutoRecordPolicy.SessionOrigin, + keepRawStreams: Bool, + engineMode: EngineMode, + transcriptionLanguage: String?, + durationSeconds: Int + ) { let worker = Self.makeWorker( - dir: dir, context: context, event: event, keepRawStreams: snap.keepRawStreams, - engineMode: sessionEngineMode, transcriptionLanguage: snap.transcriptionLanguage) - // Source-order guard: reevaluateQueuedDetectionCandidateAfterStop() runs after worker creation below. + dir: dir, context: context, event: event, keepRawStreams: keepRawStreams, + engineMode: engineMode, transcriptionLanguage: transcriptionLanguage) let id = UUID() - let durationSeconds = Int(endedAt.timeIntervalSince(started)) - let engineLabel = sessionEngineMode.displayName - reevaluateQueuedDetectionCandidateAfterStop() + let engineLabel = engineMode.displayName let task = Task { [weak self] in let outcome = await worker.run() await MainActor.run { @@ -404,15 +615,17 @@ extension AppDelegate { self.status = .idle self.resetMenuAfterWorker(status: self.status) self.markSavedFlash() - self.presentSavedNotification( - dir: dir, - event: event, - durationSeconds: durationSeconds, - engineLabel: engineLabel - ) + if origin == .manual { + self.presentSavedNotification( + dir: dir, + event: event, + durationSeconds: durationSeconds, + engineLabel: engineLabel + ) + } case .failed(let reason): self.status = .failed - self.menu?.sessionEngineMode = sessionEngineMode + self.menu?.sessionEngineMode = engineMode self.menu?.outcomeFolderName = dir.url.lastPathComponent self.menu?.outcomeFolderURL = dir.url self.menu?.recordingSourceLabel = Self.recordingSourceLabel(for: event) @@ -422,15 +635,52 @@ extension AppDelegate { case .cancelled: self.status = .idle self.resetMenuAfterWorker(status: self.status) - // App was quit / session forcibly aborted. Don't - // flash either success or failure; just settle - // back to idle. + // App was quit / session forcibly aborted / discarded from + // Recents. Don't flash either success or failure; just + // settle back to idle. self.applyTrustIcon() } } await self?.removeTask(id: id) } - inflightTasks[id] = task + inflightSessions[id] = (task: task, directory: dir.url) + } + + /// Recents Discard: cancel any live or retrying worker for the + /// session, then trash the folder. Off-main cancellation wait so the + /// menu never blocks on an MLX inference step. + @MainActor + func discardSession(at url: URL) async { + Log.lifecycle.info("Recents discard requested: \(url.lastPathComponent, privacy: .public)") + let workerTasks = inflightSessions + .filter { $0.value.directory == url } + .map(\.value.task) + let dir = SessionDirectory.existing(at: url) + Task.detached(priority: .userInitiated) { [weak self] in + for task in workerTasks { + task.cancel() + _ = await task.value + } + let trashed: URL? + do { + trashed = try dir.moveToTrash() + } catch { + Log.lifecycle.error( + "Recents discard failed (kept in place): \(String(describing: error), privacy: .public)" + ) + trashed = nil + } + await MainActor.run { [weak self] in + for (id, entry) in self?.inflightSessions ?? [:] where entry.directory == url { + self?.removeTask(id: id) + } + self?.menu?.refreshRecents() + if trashed != nil { + Log.lifecycle.info( + "Session discarded to Trash: \(url.lastPathComponent, privacy: .public)") + } + } + } } @MainActor diff --git a/TranscriberApp/Scribe/AppDelegate+StatusItem.swift b/TranscriberApp/Scribe/AppDelegate+StatusItem.swift index ac41434..d062e68 100644 --- a/TranscriberApp/Scribe/AppDelegate+StatusItem.swift +++ b/TranscriberApp/Scribe/AppDelegate+StatusItem.swift @@ -91,7 +91,7 @@ extension AppDelegate { TrustState.Inputs( status: status, setupNeedsAttention: setupNeedsAttention, - detectionPromptActive: detectionPromptActive, + parkedDetection: parkedCandidate != nil, endPromptActive: activeEndPromptGeneration != nil, lastSavedAt: lastSavedAt, lastFailureAt: lastFailureAt, diff --git a/TranscriberApp/Scribe/AppDelegate.swift b/TranscriberApp/Scribe/AppDelegate.swift index 80d4c96..fc66736 100644 --- a/TranscriberApp/Scribe/AppDelegate.swift +++ b/TranscriberApp/Scribe/AppDelegate.swift @@ -61,21 +61,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// suspension semantics are simpler under tests. var elapsedTickTimer: Timer? - var inflightTasks: [UUID: Task] = [:] - // Detection layer (slice 5 light) var detectionEngine: DetectionEngine? private var processWatcher: ProcessWatcher? - let startPromptCoordinator = StartPromptCoordinator() + let endPromptCoordinator = EndPromptCoordinator() private var queuedDetectionCandidate: QueuedDetectionCandidate? - var pendingPromptCalendarEventForStart: CalendarEvent? - var pendingPromptCandidateForStart: DetectionCandidate? - var pendingPromptAppBundleID: String? - var pendingPromptTriggerIdentity: String? - var dismissedPromptTriggerIdentities: Set = [] - - // End detection mirrors the start prompt path: the recognition layer - // proves the call ended, then EndGuard owns the 10s stop prompt / Keep + /// The one parked-detection value: the candidate (and its calendar + /// event) staged for the next start. Set when a candidate fires and + /// auto-record is off (the passive hold), restored when a detected + /// auto-start fails, consumed when any start begins recording, and + /// cleared when recognition proves the call ended. `nil` means no + /// meeting is waiting; the trust icon derives from exactly this. + var parkedCandidate: AppDelegate.StagedStart? + + /// Origin of the in-flight (or just-finished) session. Decides + /// auto-discard eligibility and saved-notification suppression. + var currentSessionOrigin: AutoRecordPolicy.SessionOrigin = .manual + /// Full candidate behind the in-flight recording, kept so a discard + /// can release it back to DetectionEngine for re-detection. + var currentRecordingCandidate: DetectionCandidate? + + /// One inflight table: every background task the app owns, with the + /// session directory it belongs to when it is a transcription worker. + /// Quit drains all of them; Recents Discard looks entries up by + /// directory. + var inflightSessions: [UUID: (task: Task, directory: URL?)] = [:] + + // End detection mirrors recording end: the recognition layer proves + // the call ended, then EndGuard owns the 10s stop prompt / Keep // Recording flow. The audio-silence fallback uses the same guard. var endGuard: EndGuard? var endGuardTickTimer: Timer? @@ -86,13 +99,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // primary trust surface, so its shape encodes more than just // SessionStatus. These fields capture the pieces of state the // status enum doesn't carry: setup blockers (PermissionDoctor), - // an in-flight detection prompt, and the most recent terminal - // outcome (saved / failed) so the icon can transiently flash a - // confirmation glyph after a session lands. + // a parked detection candidate (auto-record off), and the most + // recent terminal outcome (saved / failed) so the icon can + // transiently flash a confirmation glyph after a session lands. var setupNeedsAttention: Bool = false var sessionRepairPayload: SessionRepairRouting.LocalRepairPayload? var setupEngineFocus: EngineSettingsCardFocus? - var detectionPromptActive: Bool = false var lastSavedAt: Date? var lastFailureAt: Date? var savedFlashTimer: Timer? @@ -152,7 +164,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let defaultRoot = home.appendingPathComponent("Scribe", isDirectory: true) return SettingsStore.Defaults( outputRoot: defaultRoot, - engineMode: .cloud, // rc1: only working engine until Phase ο + engineMode: .local, // auto-record ships with the on-device default keepRawStreams: false, // spec line 102 aecEnabled: true // D2 ) @@ -453,6 +465,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return menu } m.outputRoot = snap.outputRoot + m.autoRecordEnabledProvider = { [weak self] in + self?.settings.autoRecordEnabled ?? true + } m.appearanceTheme = snap.appearanceTheme self.menu = m if snap.showInMenuBar { @@ -477,14 +492,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Log.lifecycle.info("Supervisor recovery deferred until privacy ack") } - // Detection layer: process allowlist watcher feeds DetectionEngine, - // engine fires onCandidate after dwell + per-PID input-device - // check, app shows the start prompt. The probe closes the - // Signal-opens-for-messaging and Chrome-opens-for-anything-else - // false positives that dwell-on-launch alone produced. + // Detection layer: process allowlist watcher feeds DetectionEngine. + // The engine gates every candidate through AutoRecordPolicy: native + // apps on probe evidence, browsers on a meeting-domain tab match or + // calendar overlap plus sustained mic. Passing candidates auto-start + // recording; the app never prompts before capture. let engine = DetectionEngine( dwellTime: 30, probe: CoreAudioInputProbe(), + tabInspector: BrowserTabInspector(), + calendarOverlapsNow: { [calendarWatcher] date in + await calendarWatcher.eventOverlapping(date) != nil + }, triggerIdentity: { [weak self] app in await self?.triggerIdentity(for: app) ?? DetectionEngine.defaultTriggerIdentity(for: app) }, @@ -511,6 +530,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { Log.lifecycle.info( "Detection layer started (allowlist size=\(MeetingApps.allowlist.count, privacy: .public), dwellTime=30s)" ) + prewarmBrowserAutomationGrants() // Phase η controllers (MainActor-isolated; safe to construct here // because applicationDidFinishLaunching runs on the main thread). @@ -568,6 +588,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { AppearanceApplier.apply(theme) } + /// Triggers the one-time macOS "control " automation prompt + /// at launch instead of mid-dwell on the first browser candidate. A + /// no-op once the grant exists. Grants bind to the signing identity, + /// so dev re-signs re-prompt (see scripts/dev-install.sh notes). + /// Only browsers with a real tab dialect are worth a grant; Firefox + /// has none and is skipped. + private func prewarmBrowserAutomationGrants() { + Task.detached(priority: .utility) { + for app in MeetingApps.allowlist where app.tabDialect != .none { + guard + NSWorkspace.shared.urlForApplication(withBundleIdentifier: app.bundleID) != nil + else { continue } + _ = await BrowserTabInspector().inspectActiveTab(bundleID: app.bundleID) + } + } + } + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { processWatcher?.stop() if let wakeObserver { @@ -579,12 +616,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.calendarChangeObserver = nil } Task { await self.calendarWatcher.stop() } - let inflight = Array(inflightTasks.values) + let inflight = Array(inflightSessions.values.map(\.task)) let hasLiveCapture = (session != nil) let relaunchAfterTermination = ScreenRecordingRelaunchAssist.isArmed() // Codex extensive-review P1.1 fix: a live CaptureSession isn't tracked - // in inflightTasks, so a Quit during recording previously exited + // in inflightSessions, so a Quit during recording previously exited // immediately with the SCStream + AVAssetWriter still live, leaving // .partial files and no transcript. Finalize the capture first. if !hasLiveCapture && inflight.isEmpty { @@ -657,7 +694,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor func removeTask(id: UUID) { - inflightTasks.removeValue(forKey: id) + inflightSessions.removeValue(forKey: id) } @MainActor diff --git a/TranscriberApp/Scribe/DesignSystem/DSComponents.swift b/TranscriberApp/Scribe/DesignSystem/DSComponents.swift index 741aa26..8316b0e 100644 --- a/TranscriberApp/Scribe/DesignSystem/DSComponents.swift +++ b/TranscriberApp/Scribe/DesignSystem/DSComponents.swift @@ -208,6 +208,7 @@ enum LucideGlyph: String { case folder case info case settings + case trash2 var paths: String { switch self { @@ -227,6 +228,8 @@ enum LucideGlyph: String { return #""# case .settings: return #""# + case .trash2: + return #""# } } } diff --git a/TranscriberApp/Scribe/EndCountdownWindow.swift b/TranscriberApp/Scribe/EndCountdownWindow.swift index f7ed426..442230b 100644 --- a/TranscriberApp/Scribe/EndCountdownWindow.swift +++ b/TranscriberApp/Scribe/EndCountdownWindow.swift @@ -4,14 +4,15 @@ import TranscriberCore /// F-5: scribe-design-system stop-prompt HUD. A floating panel with a /// gigantic countdown numeral, mono eyebrow describing the reason, and -/// two primary buttons (`Keep recording` / `Stop now`). Replaces the -/// silent placeholder where the stop prompt was previously implicit -/// (the spec called this surface out by name as a showpiece HUD). +/// three actions: `Keep recording` (primary, Enter), `Stop now`, and +/// `Discard` (stop and move the session folder to Trash). /// /// Lifecycle: -/// - `present(reason:secondsRemaining:onKeep:onStopNow:)` builds and -/// orders the panel front. The panel is borderless, floating, and -/// centered on the active screen. +/// - `present(reason:secondsRemaining:onKeep:onStopNow:onDiscard:)` +/// builds and orders the panel front. The panel is borderless, +/// floating, and centered on the active screen. Callers pass +/// generation-scoped actions so a stale click cannot touch the +/// wrong session. /// - The caller drives the countdown via `update(secondsRemaining:)` /// ticks and calls `dismiss()` when the EndGuard transitions out /// of `.counting`. @@ -24,16 +25,19 @@ final class EndCountdownWindowController { /// Builds the panel if needed and shows it. Subsequent calls /// just refresh the model (no flicker on countdown ticks). + /// `onDiscard` stops the capture and trashes the session folder. func present( reason: EndGuard.Reason, secondsRemaining: Int, onKeep: @escaping @MainActor () -> Void, - onStopNow: @escaping @MainActor () -> Void + onStopNow: @escaping @MainActor () -> Void, + onDiscard: @escaping @MainActor () -> Void ) { model.eyebrow = Self.eyebrow(for: reason) model.secondsRemaining = secondsRemaining model.onKeep = onKeep model.onStopNow = onStopNow + model.onDiscard = onDiscard if panel != nil { panel?.makeKeyAndOrderFront(nil) @@ -41,7 +45,7 @@ final class EndCountdownWindowController { } let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 380, height: 270), + contentRect: NSRect(x: 0, y: 0, width: 440, height: 270), styleMask: [.borderless, .nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: false @@ -94,6 +98,7 @@ final class EndCountdownModel: ObservableObject { @Published var secondsRemaining: Int = 0 var onKeep: @MainActor () -> Void = {} var onStopNow: @MainActor () -> Void = {} + var onDiscard: @MainActor () -> Void = {} } private struct EndCountdownView: View { @@ -142,10 +147,17 @@ private struct EndCountdownView: View { Text("Stop now").frame(maxWidth: .infinity) } .buttonStyle(SecondaryButtonStyle()) + + // Trash is recoverable, and Keep stays the Enter-bound + // primary, so Discard needs no confirmation step. + Button(action: model.onDiscard) { + Text("Discard").frame(maxWidth: .infinity) + } + .buttonStyle(SecondaryButtonStyle()) } } .padding(20) - .frame(width: 380, height: 270) + .frame(width: 440, height: 270) .glassBackground() .opacity(didAppear ? 1 : 0) .offset(y: didAppear ? 0 : -8) diff --git a/TranscriberApp/Scribe/EndPromptCoordinator.swift b/TranscriberApp/Scribe/EndPromptCoordinator.swift new file mode 100644 index 0000000..aa4b785 --- /dev/null +++ b/TranscriberApp/Scribe/EndPromptCoordinator.swift @@ -0,0 +1,230 @@ +import AppKit +import TranscriberCore +import UserNotifications + +/// Owns the Notification Center backup channel for the EndGuard stop +/// prompt: Keep Recording / Stop Now actions on a "Call seems over" +/// notification, generation-scoped so stale taps cannot stop the wrong +/// session. The pre-call start prompt machinery was removed with the +/// auto-record cutover (plans/auto-record.md). +@MainActor +final class EndPromptCoordinator: NSObject, UNUserNotificationCenterDelegate { + + private static let endCategoryIdentifier = "scribe.recording.end-prompt" + private enum EndAction { + static let keepRecording = "scribe.end-prompt.action.keep-recording" + static let stopNow = "scribe.end-prompt.action.stop-now" + } + + private final class PendingEndPrompt { + let identifier: String + let generation: Int + let onKeep: @MainActor @Sendable (Int) async -> Void + let onStopNow: @MainActor @Sendable (Int) async -> Void + var notificationIdentifiers: Set = [] + + init( + identifier: String, + generation: Int, + onKeep: @escaping @MainActor @Sendable (Int) async -> Void, + onStopNow: @escaping @MainActor @Sendable (Int) async -> Void + ) { + self.identifier = identifier + self.generation = generation + self.onKeep = onKeep + self.onStopNow = onStopNow + } + } + + private var pendingEndPrompts: [String: PendingEndPrompt] = [:] + private var registeredCategories = false + + override init() { + super.init() + UNUserNotificationCenter.current().delegate = self + } + + @discardableResult + func postEndPromptNotificationIfPossible( + promptID: String, + generation: Int, + reason: EndGuard.Reason, + secondsRemaining: Int, + onKeep: @escaping @MainActor @Sendable (Int) async -> Void, + onStopNow: @escaping @MainActor @Sendable (Int) async -> Void + ) async -> Bool { + await ensureRegistered() + pendingEndPrompts[promptID] = PendingEndPrompt( + identifier: promptID, + generation: generation, + onKeep: onKeep, + onStopNow: onStopNow + ) + + guard await ensureAuthorization() else { + Log.lifecycle.info("End prompt notification unavailable: authorization missing; HUD/menu recovery remain active (id=\(promptID, privacy: .public))") + return false + } + guard pendingEndPrompts[promptID] != nil else { + Log.lifecycle.info("Skipping stale end prompt notification after authorization completed (id=\(promptID, privacy: .public))") + return false + } + + let notificationID = "\(promptID).end" + let content = UNMutableNotificationContent() + content.title = "Call seems over" + content.subtitle = Self.endPromptSubtitle(for: reason) + content.body = "Scribe will stop in \(max(0, secondsRemaining)) seconds unless you keep recording." + content.categoryIdentifier = Self.endCategoryIdentifier + content.userInfo = [ + "promptID": promptID, + "generation": generation, + "reason": Self.endPromptReasonPayload(reason) + ] + content.sound = nil + + let request = UNNotificationRequest(identifier: notificationID, content: content, trigger: nil) + do { + try await UNUserNotificationCenter.current().add(request) + pendingEndPrompts[promptID]?.notificationIdentifiers.insert(notificationID) + Log.lifecycle.info("End prompt notification posted: \(Self.endPromptReasonPayload(reason), privacy: .public) (id=\(promptID, privacy: .public), generation=\(generation, privacy: .public))") + return true + } catch { + Log.lifecycle.error("End prompt notification failed: \(error.localizedDescription, privacy: .public); HUD/menu recovery remain active (id=\(promptID, privacy: .public))") + return false + } + } + + func clearEndPromptNotification(promptID: String) { + guard let entry = pendingEndPrompts.removeValue(forKey: promptID) else { return } + let ids = Array(entry.notificationIdentifiers) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) + } + + private func ensureAuthorization() async -> Bool { + let center = UNUserNotificationCenter.current() + // Re-query every time instead of caching process-lifetime denial or + // grant. Users can flip notification permission in System Settings + // while Scribe is running, and the end-prompt channel must + // immediately reflect denied -> granted and granted -> denied changes. + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + return true + case .denied: + return false + case .notDetermined: + do { + return try await center.requestAuthorization(options: [.alert, .sound]) + } catch { + Log.lifecycle.error("Notification authorization request failed: \(error.localizedDescription, privacy: .public)") + return false + } + @unknown default: + return false + } + } + + private func ensureRegistered() async { + guard !registeredCategories else { return } + let endCategory = UNNotificationCategory( + identifier: Self.endCategoryIdentifier, + actions: [ + UNNotificationAction( + identifier: EndAction.keepRecording, + title: "Keep Recording", + options: [] + ), + UNNotificationAction( + identifier: EndAction.stopNow, + title: "Stop Now", + options: [.destructive] + ) + ], + intentIdentifiers: [], + options: [.customDismissAction] + ) + UNUserNotificationCenter.current().setNotificationCategories([endCategory]) + registeredCategories = true + } + + private func resolveEndPromptNotification( + promptID: String, + generation: Int?, + actionID: String + ) async { + guard let entry = pendingEndPrompts[promptID], + generation == entry.generation else { + Log.lifecycle.info("Ignoring stale end prompt notification action (id=\(promptID, privacy: .public))") + return + } + + switch actionID { + case EndAction.keepRecording: + clearEndPromptNotification(promptID: promptID) + await entry.onKeep(entry.generation) + case EndAction.stopNow: + clearEndPromptNotification(promptID: promptID) + await entry.onStopNow(entry.generation) + case UNNotificationDismissActionIdentifier: + Log.lifecycle.info("End prompt notification dismissed without decision (id=\(promptID, privacy: .public)); HUD/menu recovery remains active") + default: + NSApp.activate(ignoringOtherApps: true) + } + } + + private static func endPromptSubtitle(for reason: EndGuard.Reason) -> String { + switch reason { + case .bidirectionalSilence: + return "Audio has been quiet" + case .callEnded: + return "Call ended" + case .maxSessionDurationReached: + return "Session reached 4 hours" + } + } + + private static func endPromptReasonPayload(_ reason: EndGuard.Reason) -> String { + switch reason { + case .bidirectionalSilence: return "bidirectional_silence" + case .callEnded: return "call_ended" + case .maxSessionDurationReached: return "max_session_duration" + } + } + + // MARK: - UNUserNotificationCenterDelegate + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list]) + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let categoryIdentifier = response.notification.request.content.categoryIdentifier + let promptID = response.notification.request.content.userInfo["promptID"] as? String + let actionID = response.actionIdentifier + let generation = response.notification.request.content.userInfo["generation"] as? Int + completionHandler() + Task { @MainActor [weak self] in + guard let self else { return } + guard categoryIdentifier == Self.endCategoryIdentifier, let promptID else { + if actionID == UNNotificationDismissActionIdentifier { return } + NSApp.activate(ignoringOtherApps: true) + return + } + await self.resolveEndPromptNotification( + promptID: promptID, + generation: generation, + actionID: actionID + ) + } + } +} diff --git a/TranscriberApp/Scribe/RecordingMenu/RecordingMenu.swift b/TranscriberApp/Scribe/RecordingMenu/RecordingMenu.swift index ac4e847..1e09a26 100644 --- a/TranscriberApp/Scribe/RecordingMenu/RecordingMenu.swift +++ b/TranscriberApp/Scribe/RecordingMenu/RecordingMenu.swift @@ -18,12 +18,6 @@ import TranscriberCore /// `menu: NSMenu` and an `Action` enum) is preserved as the public /// surface so AppDelegate's call sites don't change. The popover hosts /// a SwiftUI body backed by `RecordingMenuModel`. -struct PendingPromptRecovery: Equatable { - let title: String - let subtitle: String - let appDisplayName: String -} - struct RecordingMenuQueuedMeeting: Equatable { let title: String let time: String @@ -42,16 +36,15 @@ final class RecordingMenu: NSObject, NSPopoverDelegate { case retryFailedSession case retryRecentFailedSession(URL) case repairRecentFailedSession(URL) + case discardSession(URL) case stop case quit case openSettings case openSetupRequired case openDiagnostics - case promptStartRecording - case promptNotNow - case promptSuppressApp case endPromptKeepRecording(generation: Int) case endPromptStopNow(generation: Int) + case endPromptDiscard(generation: Int) } /// Codex PM-review UX-7 (preserved): "Setup Required…" vs @@ -61,10 +54,6 @@ final class RecordingMenu: NSObject, NSPopoverDelegate { didSet { model.setupNeedsAttention = setupNeedsAttention } } - var pendingPrompt: PendingPromptRecovery? { - didSet { model.pendingPrompt = pendingPrompt } - } - var queuedNextMeeting: RecordingMenuQueuedMeeting? { didSet { model.queuedNextMeeting = queuedNextMeeting } } @@ -80,6 +69,16 @@ final class RecordingMenu: NSObject, NSPopoverDelegate { didSet { model.refreshRecents(under: outputRoot) } } + /// Reads the live auto-record setting each time the popover opens, + /// so a Settings toggle is reflected without an app restart. + var autoRecordEnabledProvider: (() -> Bool)? + + /// Re-enumerates the recents list from the current outputRoot. + /// Called after out-of-band folder changes (Recents Discard). + func refreshRecents() { + model.refreshRecents(under: outputRoot) + } + /// Elapsed seconds since recording started. AppDelegate ticks /// this once per second from a `Timer` while in the recording /// state so the popover header + capture card show a live timer @@ -198,6 +197,7 @@ final class RecordingMenu: NSObject, NSPopoverDelegate { // view so this stays cheap; the enumerator only touches // frontmatter, never bodies. model.refreshRecents(under: outputRoot) + model.autoRecordEnabled = autoRecordEnabledProvider?() ?? true refreshRecentActionsReadiness() applyDebugMenuFixtureIfNeeded() popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) diff --git a/TranscriberApp/Scribe/RecordingMenu/RecordingMenuModel.swift b/TranscriberApp/Scribe/RecordingMenu/RecordingMenuModel.swift index 8aedaf8..a888a15 100644 --- a/TranscriberApp/Scribe/RecordingMenu/RecordingMenuModel.swift +++ b/TranscriberApp/Scribe/RecordingMenu/RecordingMenuModel.swift @@ -7,7 +7,9 @@ final class RecordingMenuModel: ObservableObject { @Published var status: SessionStatus @Published var setupNeedsAttention: Bool = false - @Published var pendingPrompt: PendingPromptRecovery? = nil + /// Whether detected calls record automatically. Selects the idle + /// popover copy and mirrors Settings. + @Published var autoRecordEnabled: Bool = true @Published var queuedNextMeeting: RecordingMenuQueuedMeeting? = nil @Published var endPrompt: RecordingMenuEndPrompt? = nil @Published var recents: [SessionFolderEnumerator.Entry] = [] diff --git a/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverComponents.swift b/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverComponents.swift index c49857c..424d1b3 100644 --- a/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverComponents.swift +++ b/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverComponents.swift @@ -382,6 +382,7 @@ struct MenuRow: View { let entry: SessionFolderEnumerator.Entry let onRetry: (URL) -> Void let onRepair: (URL) -> Void + let onDiscard: (URL) -> Void let localModelReadyForRetry: Bool? @State private var hovering: Bool = false @Environment(\.colorScheme) private var colorScheme @@ -420,6 +421,12 @@ struct MenuRow: View { palette: palette, action: openTranscript ) + recentIconButton( + glyph: .trash2, + label: "Discard", + palette: palette, + action: { onDiscard(entry.directory) } + ) recentActionButton(palette: palette) } diff --git a/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverContent.swift b/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverContent.swift index c4bd80e..f1a26d2 100644 --- a/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverContent.swift +++ b/TranscriberApp/Scribe/RecordingMenu/RecordingPopoverContent.swift @@ -56,7 +56,6 @@ struct RecordingPopoverContent: View { private var minimumSurfaceHeight: CGFloat? { switch model.status { case .idle: - if model.pendingPrompt != nil { return 244 } if model.setupNeedsAttention { return 167 } return model.recents.isEmpty ? nil : 336 case .starting: @@ -99,72 +98,10 @@ struct RecordingPopoverContent: View { case .failed: failedLayout(palette: palette) case .idle: - if model.pendingPrompt != nil { - pendingPromptLayout(palette: palette) - } else { - idleLayout(palette: palette) - } + idleLayout(palette: palette) } } - private func pendingPromptLayout(palette: RecordingPopoverPalette) -> some View { - let prompt = model.pendingPrompt - return VStack(alignment: .leading, spacing: 14) { - VStack(alignment: .leading, spacing: 5) { - Text("Meeting detected") - .font(menuHeadingFont) - .foregroundStyle(palette.text) - .frame(maxWidth: .infinity, alignment: .leading) - Text(prompt?.subtitle ?? "Scribe detected an active call. Choose whether to record.") - .font(DS.Font.bodySmall) - .foregroundStyle(palette.secondaryText) - .frame(maxWidth: .infinity, alignment: .leading) - } - HStack(spacing: 8) { - LucideIcon(glyph: .info) - .frame(width: 12, height: 12) - .foregroundStyle(palette.warning) - Text(prompt?.title ?? "Pending meeting") - .font(DS.Font.monoSmall) - .foregroundStyle(palette.metaText) - .lineLimit(1) - .truncationMode(.tail) - } - .padding(.horizontal, 10) - .frame(height: 30) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(palette.controlFill) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(palette.controlStroke, lineWidth: 1) - ) - VStack(alignment: .leading, spacing: 6) { - DisclosureGroup("More options ▾") { - Button("Stop detecting \(prompt?.appDisplayName ?? "this app") for 30 minutes") { - onAction(.promptSuppressApp) - } - .buttonStyle(GhostPopoverButtonStyle(palette: palette)) - } - .font(DS.Font.bodySmall) - .foregroundStyle(palette.secondaryText) - } - HStack(spacing: 8) { - settingsGear(palette: palette) - Spacer() - Button("Not now") { onAction(.promptNotNow) } - .buttonStyle(SecondaryPopoverButtonStyle(palette: palette)) - Button("Start Recording") { onAction(.promptStartRecording) } - .keyboardShortcut("r", modifiers: [.command]) - .buttonStyle(PrimaryPopoverButtonStyle(palette: palette)) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 16) - } - private func idleLayout(palette: RecordingPopoverPalette) -> some View { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 5) { @@ -175,7 +112,9 @@ struct RecordingPopoverContent: View { Text( model.setupNeedsAttention ? "Open setup to finish permissions. The setup window will reopen even if you closed it." - : "Scribe will prompt for meetings, or you can start now." + : (model.autoRecordEnabled + ? "Scribe records detected calls automatically. You can also start now." + : "Auto-record is off. Detected meetings wait here; record them manually.") ) .font(DS.Font.bodySmall) .foregroundStyle(palette.secondaryText) @@ -217,6 +156,9 @@ struct RecordingPopoverContent: View { onRepair: { sessionURL in onAction(.repairRecentFailedSession(sessionURL)) }, + onDiscard: { sessionURL in + onAction(.discardSession(sessionURL)) + }, localModelReadyForRetry: model.localModelReadyForRetry ) } @@ -317,6 +259,8 @@ struct RecordingPopoverContent: View { settingsGear(palette: palette) Spacer() if let endPrompt = model.endPrompt { + Button("Discard") { onAction(.endPromptDiscard(generation: endPrompt.generation)) } + .buttonStyle(GhostPopoverButtonStyle(palette: palette)) Button("Stop now") { onAction(.endPromptStopNow(generation: endPrompt.generation)) } .buttonStyle(SecondaryPopoverButtonStyle(palette: palette)) Button("Keep recording") { @@ -531,7 +475,6 @@ struct RecordingPopoverContent: View { case .finalized: return "TRANSCRIBING" case .failed: return "FAILED" case .idle: - if model.pendingPrompt != nil { return "DETECTED" } return model.setupNeedsAttention ? "SETUP" : "READY" } } @@ -544,8 +487,7 @@ struct RecordingPopoverContent: View { case .recording, .stopping: return palette.live case .failed: return palette.warning case .idle: - return (model.pendingPrompt != nil || model.setupNeedsAttention) - ? palette.warning : palette.ready + return model.setupNeedsAttention ? palette.warning : palette.ready default: return palette.neutralStatus } } diff --git a/TranscriberApp/Scribe/Settings/AudioPanel.swift b/TranscriberApp/Scribe/Settings/AudioPanel.swift index ac155ff..3feb6c2 100644 --- a/TranscriberApp/Scribe/Settings/AudioPanel.swift +++ b/TranscriberApp/Scribe/Settings/AudioPanel.swift @@ -34,6 +34,24 @@ private enum TranscriptionLanguageOption { } } +/// Display options for the auto-discard threshold picker. +private enum AutoDiscardOption { + static let choices: [(label: String, seconds: Int)] = [ + ("30 seconds", 30), + ("1 minute", 60), + ("2 minutes", 120), + ("3 minutes", 180), + ("5 minutes", 300), + ] + static var labels: [String] { choices.map(\.label) } + static func seconds(forLabel label: String) -> Int { + choices.first { $0.label == label }?.seconds ?? 120 + } + static func label(forSeconds seconds: Int) -> String { + choices.first { $0.seconds == seconds }?.label ?? "\(seconds) seconds" + } +} + struct FidelityAudioPanel: View { @ObservedObject var model: SettingsFormModel let onSettingsChange: @MainActor (SessionSettings) async -> Void @@ -91,19 +109,71 @@ struct FidelityAudioPanel: View { if case .confirmRemoveLocalModel(let modelName) = model.pendingLocalModelRemoval { FidelityInlineConfirmation( title: "Remove \(modelName)?", - message: + message: "Local transcription will be unavailable until the Cohere model is downloaded and verified again.", - confirmTitle: "Remove", - onCancel: { Task { _ = await model.handleEngineAction(.cancelRemoveLocalModel) } }, - onConfirm: { Task { _ = await model.handleEngineAction(.confirmRemoveLocalModel) } } - ) + confirmTitle: "Remove", + onCancel: { Task { _ = await model.handleEngineAction(.cancelRemoveLocalModel) } }, + onConfirm: { Task { _ = await model.handleEngineAction(.confirmRemoveLocalModel) } } + ) } + Text( + "With automatic recording on, every detected call transcribes with the selected engine. Cloud uploads that audio to ElevenLabs; Local keeps it on this Mac." + ) + .font(SwiftUI.Font.custom(FidelitySettings.font, size: 11.5)) + .foregroundStyle(FidelitySettings.ink3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 460, alignment: .leading) } .padding(14) .task { await model.refreshEngineViewState() } } .padding(.bottom, 22) + FidelitySection(title: "Auto-record") { + FidelityRow(label: "Record detected calls automatically") { + HStack(spacing: 10) { + FidelityToggle( + isOn: Binding( + get: { model.autoRecordEnabled }, + set: { enabled in + guard model.autoRecordEnabled != enabled else { return } + model.autoRecordEnabled = enabled + persistSettings() + } + )) + FidelityHelpText("Off: detections park in the menu bar and you record manually.") + } + } + FidelityRowDivider() + FidelityRow(label: "Discard recordings under") { + HStack(spacing: 10) { + FidelitySelectLike( + selection: Binding( + get: { AutoDiscardOption.label(forSeconds: model.autoDiscardThresholdSeconds) }, + set: { label in + let seconds = AutoDiscardOption.seconds(forLabel: label) + guard model.autoDiscardThresholdSeconds != seconds else { return } + model.autoDiscardThresholdSeconds = seconds + persistSettings() + } + ), + options: AutoDiscardOption.labels, + minWidth: 160 + ) + FidelityHelpText("Short false positives go to the Trash unseen.") + } + } + FidelityRowDivider() + FidelityRow(label: "") { + Text("Some places require everyone's consent to record a call. Check your local rules.") + .font(SwiftUI.Font.custom(FidelitySettings.font, size: 11.5)) + .foregroundStyle(FidelitySettings.ink3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 440, alignment: .leading) + } + } + .padding(.bottom, 22) + FidelitySection(title: "Recording") { FidelityRow(label: "Input device") { HStack(spacing: 12) { diff --git a/TranscriberApp/Scribe/Settings/SettingsFormModel.swift b/TranscriberApp/Scribe/Settings/SettingsFormModel.swift index 9fb6a6c..3222688 100644 --- a/TranscriberApp/Scribe/Settings/SettingsFormModel.swift +++ b/TranscriberApp/Scribe/Settings/SettingsFormModel.swift @@ -19,6 +19,8 @@ final class SettingsFormModel: ObservableObject { @Published var showInMenuBar: Bool @Published var startStopShortcut: KeyboardShortcutSetting @Published var transcriptionLanguage: String? + @Published var autoRecordEnabled: Bool + @Published var autoDiscardThresholdSeconds: Int @Published var apiKey: String @Published var apiKeyEditedFromInitial: Bool = false @Published var isSavingCloudAPIKey: Bool = false @@ -54,6 +56,8 @@ final class SettingsFormModel: ObservableObject { self.showInMenuBar = initial.showInMenuBar self.startStopShortcut = initial.startStopShortcut self.transcriptionLanguage = initial.transcriptionLanguage + self.autoRecordEnabled = initial.autoRecordEnabled + self.autoDiscardThresholdSeconds = initial.autoDiscardThresholdSeconds self.keychainService = keychainService self.keychainAccount = keychainAccount self.engineReadiness = engineReadiness @@ -86,7 +90,9 @@ final class SettingsFormModel: ObservableObject { launchAtLogin: launchAtLogin, showInMenuBar: showInMenuBar, startStopShortcut: startStopShortcut, - transcriptionLanguage: transcriptionLanguage + transcriptionLanguage: transcriptionLanguage, + autoDiscardThresholdSeconds: autoDiscardThresholdSeconds, + autoRecordEnabled: autoRecordEnabled ) } diff --git a/TranscriberApp/Scribe/StartPromptCoordinator.swift b/TranscriberApp/Scribe/StartPromptCoordinator.swift deleted file mode 100644 index 643be70..0000000 --- a/TranscriberApp/Scribe/StartPromptCoordinator.swift +++ /dev/null @@ -1,659 +0,0 @@ -import AppKit -import CoreGraphics -import TranscriberCore -import UserNotifications - -/// Presents meeting detections through the spec's redundant-channel start -/// prompt: a modal AppKit decision surface first, plus a recoverable -/// Notification Center backup when notifications are available. -@MainActor -final class StartPromptCoordinator: NSObject, UNUserNotificationCenterDelegate { - - enum Choice { - case start - /// App-level 30-minute suppression, exposed only behind More options. - case notAMeeting - /// Current prompt declined with Not now. - case skipForNow - } - - private static let categoryIdentifier = "scribe.detection.candidate" - private static let endCategoryIdentifier = "scribe.recording.end-prompt" - private enum Action { - static let start = "scribe.detection.action.start" - static let notNow = "scribe.detection.action.not-now" - } - private enum EndAction { - static let keepRecording = "scribe.end-prompt.action.keep-recording" - static let stopNow = "scribe.end-prompt.action.stop-now" - } - - private final class Pending { - let identifier: String - let candidate: DetectionCandidate - let event: CalendarEvent? - - var app: MeetingApp { candidate.app } - private var continuations: [CheckedContinuation] - var notificationIdentifiers: Set = [] - weak var modalWindow: NSWindow? - var isModalVisible = false - var reminderTimer: Timer? - var expiryTimer: Timer? - - init( - identifier: String, - candidate: DetectionCandidate, - event: CalendarEvent?, - continuation: CheckedContinuation - ) { - self.identifier = identifier - self.candidate = candidate - self.event = event - self.continuations = [continuation] - } - - func addAwaiter(_ continuation: CheckedContinuation) { - continuations.append(continuation) - } - - func resumeAll(returning choice: Choice) { - let awaiters = continuations - continuations.removeAll(keepingCapacity: false) - for continuation in awaiters { - continuation.resume(returning: choice) - } - } - } - - private final class PendingEndPrompt { - let identifier: String - let generation: Int - let onKeep: @MainActor @Sendable (Int) async -> Void - let onStopNow: @MainActor @Sendable (Int) async -> Void - var notificationIdentifiers: Set = [] - - init( - identifier: String, - generation: Int, - onKeep: @escaping @MainActor @Sendable (Int) async -> Void, - onStopNow: @escaping @MainActor @Sendable (Int) async -> Void - ) { - self.identifier = identifier - self.generation = generation - self.onKeep = onKeep - self.onStopNow = onStopNow - } - } - - private enum NotificationKind: String { - case backup - case reminder - case finalReminder - } - - private var pending: [String: Pending] = [:] - private var pendingEndPrompts: [String: PendingEndPrompt] = [:] - private var activePromptIdentifier: String? - private var registeredCategories = false - - var hasActivePrompt: Bool { activePromptIdentifier != nil } - - /// Test/configuration seam. Production follows the spec: one fresh - /// reminder at ~60s, then safe expiry around the 3-minute policy. - var reminderDelay: TimeInterval = 60 - var expiryDelay: TimeInterval = 180 - - /// Call-activity seam for the about-3-minute ignored-prompt policy. - /// Only a positive call-like activity signal earns the one final reminder; - /// inactive, ended, or unknowable calls expire safely without recording. - var callActivityChecker: @MainActor (MeetingApp) async -> Bool = { app in - await CoreAudioInputProbe().isActive(bundleID: app.bundleID) == true - } - - override init() { - super.init() - UNUserNotificationCenter.current().delegate = self - } - - func prompt(for app: MeetingApp, event: CalendarEvent? = nil) async -> Choice { - await prompt(for: DetectionCandidate(app: app, triggerIdentity: DetectionEngine.defaultTriggerIdentity(for: app)), event: event) - } - - func prompt(for candidate: DetectionCandidate, event: CalendarEvent? = nil) async -> Choice { - let identifier = candidate.triggerIdentity - await ensureRegistered() - - return await withCheckedContinuation { continuation in - if let entry = pending[identifier] { - Log.lifecycle.info("Coalescing duplicate start prompt for trigger identity \(identifier, privacy: .public); appending awaiter without replacing prompt state") - entry.addAwaiter(continuation) - activePromptIdentifier = identifier - return - } - - let entry = Pending( - identifier: identifier, - candidate: candidate, - event: event, - continuation: continuation - ) - pending[identifier] = entry - activePromptIdentifier = identifier - scheduleRecoveryTimers(for: entry) - Task { @MainActor [weak self] in - await self?.postNotificationIfPossible( - promptID: identifier, - kind: .backup, - app: candidate.app, - event: event - ) - } - presentModalPrompt(identifier: identifier, app: candidate.app, event: event) - } - } - - func chooseStartFromRecovery() { - guard let identifier = activePromptIdentifier else { - Log.lifecycle.info("Ignoring stale start-prompt menu recovery start: no active prompt") - return - } - resolve(identifier: identifier, with: .start, removeNotifications: true) - } - - func chooseNotNowFromRecovery() { - guard let identifier = activePromptIdentifier else { - Log.lifecycle.info("Ignoring stale start-prompt menu recovery Not now: no active prompt") - return - } - resolve(identifier: identifier, with: .skipForNow, removeNotifications: true) - } - - func chooseSuppressAppFromRecovery() { - guard let identifier = activePromptIdentifier else { - Log.lifecycle.info("Ignoring stale start-prompt menu recovery suppression: no active prompt") - return - } - resolve(identifier: identifier, with: .notAMeeting, removeNotifications: true) - } - - /// Invalidates a pending prompt when recognition proves the underlying - /// call has ended before the user made a decision. The continuation - /// resolves as Not now so AppDelegate clears menu-bar recovery without - /// starting capture; any later modal/notification action for the old - /// prompt ID is ignored by `resolve` as stale. - func expireActivePrompt(for app: MeetingApp) { - expireActivePrompt(for: DetectionCandidate(app: app, triggerIdentity: DetectionEngine.defaultTriggerIdentity(for: app))) - } - - func expireActivePrompt(for candidate: DetectionCandidate) { - guard let identifier = activePromptIdentifier, - let entry = pending[identifier], - DetectionTriggerIdentity.matchesEndedCandidate( - pendingTriggerIdentity: entry.candidate.triggerIdentity, - pendingBundleID: entry.app.bundleID, - endedCandidate: candidate - ) else { - Log.lifecycle.info("Ignoring stale start-prompt expiry for \(candidate.app.bundleID, privacy: .public): no matching active prompt") - return - } - Log.lifecycle.info("Expiring start prompt for ended call in \(candidate.app.bundleID, privacy: .public) (id=\(identifier, privacy: .public))") - resolve(identifier: identifier, with: .skipForNow, removeNotifications: true) - } - - @discardableResult - func postEndPromptNotificationIfPossible( - promptID: String, - generation: Int, - reason: EndGuard.Reason, - secondsRemaining: Int, - onKeep: @escaping @MainActor @Sendable (Int) async -> Void, - onStopNow: @escaping @MainActor @Sendable (Int) async -> Void - ) async -> Bool { - await ensureRegistered() - pendingEndPrompts[promptID] = PendingEndPrompt( - identifier: promptID, - generation: generation, - onKeep: onKeep, - onStopNow: onStopNow - ) - - guard await ensureAuthorization() else { - Log.lifecycle.info("End prompt notification unavailable: authorization missing; HUD/menu recovery remain active (id=\(promptID, privacy: .public))") - return false - } - guard pendingEndPrompts[promptID] != nil else { - Log.lifecycle.info("Skipping stale end prompt notification after authorization completed (id=\(promptID, privacy: .public))") - return false - } - - let notificationID = "\(promptID).end" - let content = UNMutableNotificationContent() - content.title = "Call seems over" - content.subtitle = Self.endPromptSubtitle(for: reason) - content.body = "Scribe will stop in \(max(0, secondsRemaining)) seconds unless you keep recording." - content.categoryIdentifier = Self.endCategoryIdentifier - content.userInfo = [ - "promptID": promptID, - "generation": generation, - "reason": Self.endPromptReasonPayload(reason) - ] - content.sound = nil - - let request = UNNotificationRequest(identifier: notificationID, content: content, trigger: nil) - do { - try await UNUserNotificationCenter.current().add(request) - pendingEndPrompts[promptID]?.notificationIdentifiers.insert(notificationID) - Log.lifecycle.info("End prompt notification posted: \(Self.endPromptReasonPayload(reason), privacy: .public) (id=\(promptID, privacy: .public), generation=\(generation, privacy: .public))") - return true - } catch { - Log.lifecycle.error("End prompt notification failed: \(error.localizedDescription, privacy: .public); HUD/menu recovery remain active (id=\(promptID, privacy: .public))") - return false - } - } - - func clearEndPromptNotification(promptID: String) { - guard let entry = pendingEndPrompts.removeValue(forKey: promptID) else { return } - let ids = Array(entry.notificationIdentifiers) - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) - } - - private func scheduleRecoveryTimers(for entry: Pending) { - let promptID = entry.identifier - entry.reminderTimer = Timer.scheduledTimer(withTimeInterval: reminderDelay, repeats: false) { [weak self] _ in - Task { @MainActor in - guard let self, let entry = self.pending[promptID] else { return } - Log.lifecycle.info("Start prompt reminder firing for \(entry.app.bundleID, privacy: .public) (id=\(promptID, privacy: .public))") - await self.postNotificationIfPossible( - promptID: promptID, - kind: .reminder, - app: entry.app, - event: entry.event - ) - } - } - entry.expiryTimer = Timer.scheduledTimer(withTimeInterval: expiryDelay, repeats: false) { [weak self] _ in - Task { @MainActor in - await self?.handleIgnoredPromptExpiry(promptID: promptID) - } - } - } - - private func handleIgnoredPromptExpiry(promptID: String) async { - guard let entry = pending[promptID] else { return } - - let callStillActive = await callActivityChecker(entry.app) - guard callStillActive else { - Log.lifecycle.info("Start prompt expired for inactive or ended call in \(entry.app.bundleID, privacy: .public) (id=\(promptID, privacy: .public)); clearing stale recovery actions") - resolve(identifier: promptID, with: .skipForNow, removeNotifications: true) - return - } - - // Spec ignored-prompt policy: at about 3 minutes, active call-like - // audio gets exactly one final reminder. This is not a terminal - // decision: it does not start recording, does not auto-decline, and - // does not schedule repeated spam. Menu-bar recovery remains active - // until the user acts or recognition later invalidates the prompt. - entry.expiryTimer?.invalidate() - entry.expiryTimer = nil - Log.lifecycle.info("Start prompt final reminder firing for still-active call in \(entry.app.bundleID, privacy: .public) (id=\(promptID, privacy: .public)); prompt remains user-controlled") - await postNotificationIfPossible( - promptID: promptID, - kind: .finalReminder, - app: entry.app, - event: entry.event - ) - } - - @discardableResult - private func postNotificationIfPossible( - promptID: String, - kind: NotificationKind, - app: MeetingApp, - event: CalendarEvent? - ) async -> Bool { - guard await ensureAuthorization() else { - Log.lifecycle.info("Start prompt \(kind.rawValue, privacy: .public) notification unavailable for \(app.bundleID, privacy: .public): authorization missing; modal/menu recovery remain active") - return false - } - guard pending[promptID] != nil else { - Log.lifecycle.info("Skipping stale start prompt \(kind.rawValue, privacy: .public) notification after authorization completed (id=\(promptID, privacy: .public))") - return false - } - - let notificationID = "\(promptID).\(kind.rawValue)" - let content = UNMutableNotificationContent() - content.title = promptTitle(for: app, event: event) - content.subtitle = event == nil ? "Detected in \(app.displayName)" : "From Apple Calendar · \(app.displayName)" - switch kind { - case .backup: - content.body = "Start recording?" - case .reminder: - content.body = "Still want to start recording?" - case .finalReminder: - content.body = "Last reminder while this call appears active." - } - content.categoryIdentifier = Self.categoryIdentifier - content.userInfo = [ - "promptID": promptID, - "triggerIdentity": promptID, - "bundleID": app.bundleID, - "displayName": app.displayName, - "kind": kind.rawValue - ] - content.sound = nil - - let request = UNNotificationRequest(identifier: notificationID, content: content, trigger: nil) - do { - try await UNUserNotificationCenter.current().add(request) - pending[promptID]?.notificationIdentifiers.insert(notificationID) - Log.lifecycle.info("Start prompt \(kind.rawValue, privacy: .public) notification posted for \(app.bundleID, privacy: .public) (id=\(promptID, privacy: .public))") - return true - } catch { - Log.lifecycle.error("Start prompt \(kind.rawValue, privacy: .public) notification failed for \(app.bundleID, privacy: .public): \(error.localizedDescription, privacy: .public); modal/menu recovery remain active") - return false - } - } - - private func presentModalPrompt( - identifier: String, - app: MeetingApp, - event: CalendarEvent? - ) { - NSApp.activate(ignoringOtherApps: true) - - let decision = PromptModalWindow.run( - model: PromptModalWindow.Model( - badge: app.displayName, - title: promptTitle(for: app, event: event), - message: promptSubtitle(for: app, event: event), - secondaryTitle: "Not now", - primaryTitle: "Start Recording" - ), - place: { [weak self] window in - self?.place(window: window, nearActiveWindowFor: app) - }, - onWindowReady: { window in - if let entry = pending[identifier] { - entry.modalWindow = window - entry.isModalVisible = true - } - } - ) - if let entry = pending[identifier] { - entry.isModalVisible = false - entry.modalWindow = nil - } - switch decision { - case .primary: - resolve(identifier: identifier, with: .start, removeNotifications: true) - case .secondary: - resolve(identifier: identifier, with: .skipForNow, removeNotifications: true) - case .dismissed: - guard pending[identifier] != nil else { return } - // A close/Esc/dismissal is not an implicit decline. Leave the - // prompt session pending so menu-bar recovery and reminder - // notifications stay available until explicit resolution or expiry. - Log.lifecycle.info("Start prompt modal dismissed without decision for \(app.bundleID, privacy: .public); menu recovery remains active (id=\(identifier, privacy: .public))") - } - } - - private func promptTitle(for app: MeetingApp, event: CalendarEvent?) -> String { - if let event { - if event.startDate < Date(), event.endDate.timeIntervalSince(Date()) >= 10 * 60 { - return "Record '\(event.title)'? This event started \(elapsedMinutesSinceStart(of: event)) minutes ago. Recording will capture from now onward." - } - return "Start recording '\(event.title)'?" - } - return "Start recording \(app.displayName)?" - } - - private func promptSubtitle(for app: MeetingApp, event: CalendarEvent?) -> String { - if event != nil { - return "From Apple Calendar. Detected in \(app.displayName)." - } - return "Scribe detected an active call in \(app.displayName)." - } - - private func elapsedMinutesSinceStart(of event: CalendarEvent) -> Int { - max(1, Int(Date().timeIntervalSince(event.startDate) / 60)) - } - - private func ensureAuthorization() async -> Bool { - let center = UNUserNotificationCenter.current() - // Re-query every time instead of caching process-lifetime denial or - // grant. Users can flip notification permission in System Settings - // while Scribe is running, and both start/end redundant channels must - // immediately reflect denied -> granted and granted -> denied changes. - let settings = await center.notificationSettings() - switch settings.authorizationStatus { - case .authorized, .provisional, .ephemeral: - return true - case .denied: - return false - case .notDetermined: - do { - return try await center.requestAuthorization(options: [.alert, .sound]) - } catch { - Log.lifecycle.error("Notification authorization request failed: \(error.localizedDescription, privacy: .public)") - return false - } - @unknown default: - return false - } - } - - private func ensureRegistered() async { - guard !registeredCategories else { return } - let startCategory = UNNotificationCategory( - identifier: Self.categoryIdentifier, - actions: [ - UNNotificationAction( - identifier: Action.start, - title: "Start Recording", - options: [.foreground] - ), - UNNotificationAction( - identifier: Action.notNow, - title: "Not now", - options: [] - ) - ], - intentIdentifiers: [], - options: [.customDismissAction] - ) - let endCategory = UNNotificationCategory( - identifier: Self.endCategoryIdentifier, - actions: [ - UNNotificationAction( - identifier: EndAction.keepRecording, - title: "Keep Recording", - options: [] - ), - UNNotificationAction( - identifier: EndAction.stopNow, - title: "Stop Now", - options: [.destructive] - ) - ], - intentIdentifiers: [], - options: [.customDismissAction] - ) - UNUserNotificationCenter.current().setNotificationCategories([startCategory, endCategory]) - registeredCategories = true - } - - @MainActor - private func resolve(identifier: String, with choice: Choice, removeNotifications: Bool) { - guard let entry = pending.removeValue(forKey: identifier) else { - Log.lifecycle.info("Ignoring stale start-prompt action (id=\(identifier, privacy: .public))") - return - } - if activePromptIdentifier == identifier { - activePromptIdentifier = nil - } - entry.reminderTimer?.invalidate() - entry.expiryTimer?.invalidate() - dismissModalIfVisible(for: entry) - if removeNotifications { - let ids = Array(entry.notificationIdentifiers) - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ids) - UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) - } - entry.resumeAll(returning: choice) - } - - private func dismissModalIfVisible(for entry: Pending) { - guard entry.isModalVisible else { return } - entry.isModalVisible = false - entry.modalWindow?.orderOut(nil) - NSApp.stopModal(withCode: NSApplication.ModalResponse.abort) - Log.lifecycle.info("Stopped visible start prompt modal after non-modal resolution (id=\(entry.identifier, privacy: .public))") - } - - private func resolveEndPromptNotification( - promptID: String, - generation: Int?, - actionID: String - ) async { - guard let entry = pendingEndPrompts[promptID], - generation == entry.generation else { - Log.lifecycle.info("Ignoring stale end prompt notification action (id=\(promptID, privacy: .public))") - return - } - - switch actionID { - case EndAction.keepRecording: - clearEndPromptNotification(promptID: promptID) - await entry.onKeep(entry.generation) - case EndAction.stopNow: - clearEndPromptNotification(promptID: promptID) - await entry.onStopNow(entry.generation) - case UNNotificationDismissActionIdentifier: - Log.lifecycle.info("End prompt notification dismissed without decision (id=\(promptID, privacy: .public)); HUD/menu recovery remains active") - default: - NSApp.activate(ignoringOtherApps: true) - } - } - - private func place(window: NSWindow, nearActiveWindowFor app: MeetingApp) { - guard let targetScreen = activeScreen(for: app) else { - window.center() - return - } - let frame = targetScreen.visibleFrame - let size = window.frame.size - let origin = CGPoint( - x: frame.midX - size.width / 2, - y: frame.midY - size.height / 2 - ) - window.setFrameOrigin(origin) - } - - private func activeScreen(for app: MeetingApp) -> NSScreen? { - guard let windowBounds = frontmostWindowBounds(forBundleID: app.bundleID) else { return nil } - return NSScreen.screens.max { lhs, rhs in - lhs.frame.intersection(windowBounds).area < rhs.frame.intersection(windowBounds).area - } - } - - private func frontmostWindowBounds(forBundleID bundleID: String) -> CGRect? { - let runningPIDs = Set(NSWorkspace.shared.runningApplications.compactMap { running -> pid_t? in - running.bundleIdentifier == bundleID ? running.processIdentifier : nil - }) - guard !runningPIDs.isEmpty, - let windows = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { - return nil - } - - for info in windows { - guard let pid = info[kCGWindowOwnerPID as String] as? pid_t, - runningPIDs.contains(pid), - let layer = info[kCGWindowLayer as String] as? Int, - layer == 0, - let boundsDict = info[kCGWindowBounds as String] as? [String: Any] else { - continue - } - var bounds = CGRect.null - if CGRectMakeWithDictionaryRepresentation(boundsDict as CFDictionary, &bounds), !bounds.isNull, !bounds.isEmpty { - return bounds - } - } - return nil - } - - private static func endPromptSubtitle(for reason: EndGuard.Reason) -> String { - switch reason { - case .bidirectionalSilence: - return "Audio has been quiet" - case .callEnded: - return "Call ended" - case .maxSessionDurationReached: - return "Session reached 4 hours" - } - } - - private static func endPromptReasonPayload(_ reason: EndGuard.Reason) -> String { - switch reason { - case .bidirectionalSilence: return "bidirectional_silence" - case .callEnded: return "call_ended" - case .maxSessionDurationReached: return "max_session_duration" - } - } - - // MARK: - UNUserNotificationCenterDelegate - - nonisolated func userNotificationCenter( - _ center: UNUserNotificationCenter, - willPresent notification: UNNotification, - withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void - ) { - completionHandler([.banner, .list]) - } - - nonisolated func userNotificationCenter( - _ center: UNUserNotificationCenter, - didReceive response: UNNotificationResponse, - withCompletionHandler completionHandler: @escaping () -> Void - ) { - let notificationIdentifier = response.notification.request.identifier - let categoryIdentifier = response.notification.request.content.categoryIdentifier - let promptID = response.notification.request.content.userInfo["promptID"] as? String - let identifier = promptID - ?? notificationIdentifier - .replacingOccurrences(of: ".backup", with: "") - .replacingOccurrences(of: ".reminder", with: "") - let actionID = response.actionIdentifier - let generation = response.notification.request.content.userInfo["generation"] as? Int - completionHandler() - Task { @MainActor [weak self] in - guard let self else { return } - if categoryIdentifier == Self.endCategoryIdentifier { - await self.resolveEndPromptNotification( - promptID: identifier.replacingOccurrences(of: ".end", with: ""), - generation: generation, - actionID: actionID - ) - return - } - switch actionID { - case Action.start: - self.resolve(identifier: identifier, with: .start, removeNotifications: true) - case Action.notNow: - self.resolve(identifier: identifier, with: .skipForNow, removeNotifications: true) - case UNNotificationDismissActionIdentifier: - Log.lifecycle.info("Start prompt notification dismissed without decision (id=\(identifier, privacy: .public)); menu recovery remains active") - default: - NSApp.activate(ignoringOtherApps: true) - } - } - } -} - -private extension CGRect { - var area: CGFloat { - guard !isNull, !isEmpty else { return 0 } - return width * height - } -}