From f743eb896c7b72623a0ee65e54273123dc0897a3 Mon Sep 17 00:00:00 2001 From: TerrifiedBug Date: Mon, 7 Sep 2026 11:43:13 +0100 Subject: [PATCH] Route detected calls to a folder per app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every recorded session landed under `recordings_dir`, so a Zoom call and a FaceTime call sat side by side in the same folder. `recording_routes` maps a bundle id to a folder: a value is `~`-expanded, a relative one sits under the root. Only a session yap starts from a detected call is routed — one started from the menu bar, or a call from an app not listed, still goes to the root. A route the disk cannot honour (an unmounted volume) falls back to the root with a logged warning rather than costing the call, and `meta.json` records the app so the folder can say why it is where it is. The daemon no longer captures the root at boot. It resolves the destination at each session start, which routing needs anyway, so `recordings_dir` now changes on the spot like everything else and the restart warning for it goes. Resume scans the root and every route folder, each once, so a route pointing back at the root cannot queue a session twice. Settings gains a "Route by app" list under Recordings, the same control as the ignored-apps list with the folder as the subtitle: + picks the app then its folder, − removes, double-click changes the folder. The two lists share one view and one pair of pickers. Backfill now puts a new key where the template lists it — under the nearest earlier key the file already has — instead of at the top of the file. Only a line that ends in a comma can anchor one, so a value spread over lines is never spliced into, and an empty object is written as `{}` rather than Foundation's three-line rendering of it. --- README.md | 27 ++++- Sources/yap/Config.swift | 19 ++++ Sources/yap/ConfigBackfill.swift | 51 ++++++++-- Sources/yap/Daemon.swift | 32 +++--- Sources/yap/RecordingSession.swift | 6 +- .../TranscriptionCoordinator.swift | 29 +++--- Sources/yap/UI/SettingsModel.swift | 95 +++++++++++++----- Sources/yap/UI/SettingsPanes.swift | 57 ++++++++--- Sources/yap/Yap.swift | 4 +- Tests/yapTests/ConfigBackfillTests.swift | 98 +++++++++++++++++++ Tests/yapTests/ConfigSerializerTests.swift | 2 +- 11 files changed, 341 insertions(+), 79 deletions(-) create mode 100644 Tests/yapTests/ConfigBackfillTests.swift diff --git a/README.md b/README.md index 8b5c1f7..10b90d1 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ what this yap can do. Your own values are never touched. ```json { "recordings_dir": "~/Recordings", + "recording_routes": {}, "meeting_detection": false, "meeting_auto_record": false, "meeting_excluded_apps": [], @@ -107,9 +108,9 @@ what this yap can do. Your own values are never touched. Save it and yap picks it up. The hotkey, `tap_to_toggle`, the overlay, `mute_output`, `newline_after_release`, `meeting_detection`, -`meeting_auto_record` and `meeting_excluded_apps` all change on the spot. A -new `model` or `recordings_dir` wants a restart, and yap says so when it sees -one. +`meeting_auto_record`, `meeting_excluded_apps`, `recordings_dir` and +`recording_routes` all change on the spot. A new `model` wants a restart, and +yap says so when it sees one. `hotkey` is a modifier held on its own — `fn`, `rightOption`, `rightCommand`, `rightControl`, `rightShift`, `leftOption`, `leftControl`, `leftShift` — or a @@ -154,6 +155,20 @@ speech service keeps an input stream open, so detection reads it as a call and offers to ignore it under the name `CoreSpeech`. Ignore it once and it stops being a meeting. +`recordings_dir` is where recorded sessions land, `~/Recordings` by default. + +`recording_routes` sends calls from particular apps somewhere else: a map from +bundle identifier to folder, so a Zoom call lands in a work folder and a +FaceTime call in a personal one. A value starting with `~` is expanded; a +relative one like `"work"` sits under `recordings_dir`. Only a recording yap +started from a detected call is routed — one you start from the menu bar +always goes to `recordings_dir`, and so does a call from an app that is not +listed. If a route's folder cannot be created (an unmounted volume, say), the +session falls back to `recordings_dir` and yap logs a warning naming the route. +Manage the list under Recordings in the Settings window: + picks the app and +then its folder, − removes the selected route, and double-clicking a row +changes its folder. + `mic_voice_processing` cancels speaker echo on the mic track. On by default: a call coming out of your speakers goes back into the mic. Without it, the other side gets transcribed twice, the second time as you. If some audio route @@ -169,8 +184,10 @@ Name… buttons. Open reveals the transcript in Finder; Name… renames the fold and updates its metadata and heading. Turn it off to use yap as a plain recorder: `on_stop` then fires when the recording stops rather than after the transcript. Nothing is lost either way. Turn it back on, restart, and yap works -through every session under `recordings_dir` that has no transcript yet, firing -`on_stop` again for each. +through every session under `recordings_dir` and every route folder that has +no transcript yet, firing `on_stop` again for each. A folder whose route was +removed is no longer scanned, so a session left there waits until the route +comes back. ## Models diff --git a/Sources/yap/Config.swift b/Sources/yap/Config.swift index f978f46..30cf3a5 100644 --- a/Sources/yap/Config.swift +++ b/Sources/yap/Config.swift @@ -4,6 +4,7 @@ import Foundation /// /// { /// "recordings_dir": "~/Recordings", +/// "recording_routes": { "us.zoom.xos": "~/Work" }, /// "transcription": { "enabled": true }, /// "mic_voice_processing": true, /// "meeting_detection": false, @@ -38,6 +39,23 @@ enum Config { return URL(fileURLWithPath: (dir as NSString).expandingTildeInPath, isDirectory: true) } + /// Bundle id -> folder for sessions yap starts from a detected call. A + /// value is `~`-expanded; a relative one ("work") sits under + /// `recordings_dir`. Manual sessions and apps not listed use `resolveRoot()`. + /// Read at each session start, so edits apply to the next call. + static func recordingRoutes() -> [String: String] { + load()?["recording_routes"] as? [String: String] ?? [:] + } + + /// Where a session for `bundleID` lands: its route, or the recordings root. + static func resolveRoot(for bundleID: String?) -> URL { + let root = resolveRoot() + guard let bundleID, let raw = recordingRoutes()[bundleID], !raw.isEmpty else { return root } + let expanded = (raw as NSString).expandingTildeInPath + // An absolute path ignores the base; a relative one is joined to it. + return URL(fileURLWithPath: expanded, isDirectory: true, relativeTo: root).standardizedFileURL + } + /// Shell command to spawn after each session's transcript is written (or /// after recording, if transcription is disabled), or nil. static func onStop() -> String? { @@ -176,6 +194,7 @@ enum Config { static let template = """ { "recordings_dir": "~/Recordings", + "recording_routes": {}, "transcription": { "enabled": true }, "mic_voice_processing": true, "meeting_detection": false, diff --git a/Sources/yap/ConfigBackfill.swift b/Sources/yap/ConfigBackfill.swift index 0046a72..365ba2e 100644 --- a/Sources/yap/ConfigBackfill.swift +++ b/Sources/yap/ConfigBackfill.swift @@ -93,7 +93,7 @@ extension Config { /// Nested section first: inserting inside `dictation` leaves the file's own /// opening brace where it was, while inserting at the top level moves every /// offset after it — including the one just computed. - private static func backfilled( + static func backfilled( _ text: String, inner: [String], outer: [String], defaults: [String: Any] ) -> String? { var updated = text @@ -127,8 +127,10 @@ extension Config { return result } - /// Put `"key": default` lines just inside the brace that opens an object — - /// the one after `marker`, or the file's own when there is no marker. + /// Put `"key": default` lines where the template lists them: after the + /// nearest earlier template key the object already has, or just inside + /// the brace that opens it — the one after `marker`, or the file's own + /// when there is no marker — when no such key exists. /// /// Text rather than a re-serialize, so nothing but the new lines moves. /// Nil when that brace does not end a line, which is the compact-file case @@ -150,14 +152,46 @@ extension Config { text[brace.upperBound...].first == "\n" else { return nil } - var lines = "" + // Keys go in template order, one at a time, each looking for its + // anchor in the text as it stands — so a key just added is the anchor + // for the next, and two missing neighbours come out in order. + let ordered = inTemplateOrder(defaults.keys) + var updated = text + // An offset rather than an index: every insertion lands at or after + // the brace, so the offset survives each one where the index would not. + let braceEnd = text.distance(from: text.startIndex, to: brace.upperBound) for key in keys { guard let value = defaults[key], let literal = literal(value, indent: indent) else { return nil } - lines += "\n\(indent)\"\(key)\": \(literal)," + let line = "\n\(indent)\"\(key)\": \(literal)," + let braceIndex = updated.index(updated.startIndex, offsetBy: braceEnd) + let at = anchor(for: key, among: ordered, in: updated, from: braceIndex) ?? braceIndex + updated.insert(contentsOf: line, at: at) + } + return updated + } + + /// The end of the line holding the nearest template key listed before + /// `key` that the object already has, so the new line lands under it. + /// Only a line that ends in `,` qualifies: a value that continues on the + /// next line (the `dictation` object, an array spread out by hand) has no + /// edge to insert on, and a last line without its comma would need + /// editing itself. Nil when nothing qualifies. + private static func anchor( + for key: String, among ordered: [String], in text: String, from start: String.Index + ) -> String.Index? { + guard let position = ordered.firstIndex(of: key) else { return nil } + for earlier in ordered[.. String? { // An object being written for the first time may as well have a stable - // key order; a scalar has nothing to sort. - let pretty = value is [String: Any] + // key order; a scalar has nothing to sort. An empty object is a single + // `{}` token — Foundation pretty-prints it as `{\n\n}`. + let pretty = (value as? [String: Any]).map { !$0.isEmpty } ?? false var options: JSONSerialization.WritingOptions = [.fragmentsAllowed, .withoutEscapingSlashes] if pretty { options.formUnion([.prettyPrinted, .sortedKeys]) } guard diff --git a/Sources/yap/Daemon.swift b/Sources/yap/Daemon.swift index 9a31034..01db073 100644 --- a/Sources/yap/Daemon.swift +++ b/Sources/yap/Daemon.swift @@ -15,7 +15,6 @@ import Foundation final class Daemon: NSObject, NSApplicationDelegate { private let transcriber: any Transcriber private let coordinator: TranscriptionCoordinator - private let root: URL private let monitor: HotkeyMonitor private let capture = AudioCapture() private let overlay = RecordingOverlay() @@ -93,13 +92,11 @@ final class Daemon: NSObject, NSApplicationDelegate { init( transcriber: any Transcriber, model: TranscriptionModel, - root: URL, hotkey: HotkeyBinding, echoTranscripts: Bool, debugHotkey: Bool ) { self.transcriber = transcriber - self.root = root self.model = model self.hotkey = hotkey self.echoTranscripts = echoTranscripts @@ -225,7 +222,7 @@ final class Daemon: NSObject, NSApplicationDelegate { // Only now: a backlog transcribing through a model that is // still loading would queue behind the same warm-up anyway, // and this way the state line tells one story at a time. - await self.coordinator.resumePending(root: self.root) + await self.coordinator.resumePending() } catch { warn("warmup failed: \(error)") self.menuBar.setModelFailed() @@ -595,7 +592,7 @@ final class Daemon: NSObject, NSApplicationDelegate { // MARK: - sessions - private func startSession(auto: Bool = false, title: String? = nil) { + private func startSession(auto: Bool = false, title: String? = nil, app: MeetingApp? = nil) { guard session == nil else { return } // Same gate as a press, different report: this one was asked for by a // click, so it owes an answer rather than a log line. @@ -605,8 +602,19 @@ final class Daemon: NSObject, NSApplicationDelegate { return } do { - let newSession = try RecordingSession(root: root) + let routed = Config.resolveRoot(for: app?.bundleID) + let newSession: RecordingSession + do { + newSession = try RecordingSession(root: routed) + } catch where routed != Config.resolveRoot() { + // A route the disk cannot honour (unmounted volume, permission + // denied) must not cost the call: fall back to the root and + // say so. + warn("route: cannot create \(routed.path) for \(app?.bundleID ?? "?") — using recordings_dir") + newSession = try RecordingSession(root: Config.resolveRoot()) + } newSession.title = title + newSession.appBundleID = app?.bundleID try newSession.start() session = newSession autoStarted = auto @@ -709,7 +717,7 @@ final class Daemon: NSObject, NSApplicationDelegate { // Standing consent is read at each event so config saves take // effect without a restart. Recording is always announced. if Config.meetingAutoRecord() { - self.startSession(auto: true, title: title) + self.startSession(auto: true, title: title, app: app) guard self.session != nil else { return } detector?.acceptCurrentMeeting() showToast( @@ -738,7 +746,7 @@ final class Daemon: NSObject, NSApplicationDelegate { ) { [weak self] in guard let self, self.session == nil else { return } detector?.acceptCurrentMeeting() - self.startSession(auto: true, title: title) + self.startSession(auto: true, title: title, app: app) } } // An unanswered prompt outlives the call it asked about (it sits for @@ -841,14 +849,6 @@ final class Daemon: NSObject, NSApplicationDelegate { warn("config: model changed to \(configured.id) — restart yap to load it") } - // Same reasoning, different reason: a live session is writing into the - // old root, and resumePending was handed it at boot. Moving the daemon - // mid-flight would strand both. - if Config.resolveRoot() != root { - warn("config: recordings_dir changed — restart yap to use it") - } - - warn("config reloaded") } diff --git a/Sources/yap/RecordingSession.swift b/Sources/yap/RecordingSession.swift index b6f7180..cbad753 100644 --- a/Sources/yap/RecordingSession.swift +++ b/Sources/yap/RecordingSession.swift @@ -9,6 +9,9 @@ final class RecordingSession { private(set) var dir: URL let startedAt = Date() var title: String? + /// Bundle id of the app whose call started this session; nil for manual + /// sessions and for detected clients without a bundle. + var appBundleID: String? private let mic = MicRecorder() private let system = SystemAudioRecorder() @@ -112,7 +115,7 @@ final class RecordingSession { let systemStart = system.firstBufferAt ?? startedAt let earliest = min(micStart, systemStart) - let meta: [String: Any] = [ + var meta: [String: Any] = [ "started": iso.string(from: startedAt), "ended": iso.string(from: ended), "duration_seconds": Int(ended.timeIntervalSince(startedAt)), @@ -122,6 +125,7 @@ final class RecordingSession { "system": Int(systemStart.timeIntervalSince(earliest) * 1000), ], ] + if let appBundleID { meta["app"] = appBundleID } if let data = try? JSONSerialization.data( withJSONObject: meta, options: [.prettyPrinted, .sortedKeys] diff --git a/Sources/yap/Transcription/TranscriptionCoordinator.swift b/Sources/yap/Transcription/TranscriptionCoordinator.swift index 744c955..bf4e9f6 100644 --- a/Sources/yap/Transcription/TranscriptionCoordinator.swift +++ b/Sources/yap/Transcription/TranscriptionCoordinator.swift @@ -72,22 +72,29 @@ actor TranscriptionCoordinator { while let task = drainTask { await task.value } } - /// Scan the recordings root for sessions that finished (meta.json exists) - /// but were never transcribed. Folder names sort chronologically, so - /// oldest-first is a name sort. - func resumePending(root: URL) { + /// Scan the recordings root and every route folder for sessions that + /// finished (meta.json exists) but were never transcribed. Folder names + /// sort chronologically, so oldest-first is a name sort. + func resumePending() { guard Config.transcriptionEnabled() else { return } - guard let entries = try? FileManager.default.contentsOfDirectory( - at: root, includingPropertiesForKeys: nil - ) else { return } - let fm = FileManager.default - let pending = entries - .filter { + // The root and every routed folder, once each: two routes to one + // folder, or a route pointing back at the root, must not queue a + // session twice. + var seen: Set = [] + let roots = ([Config.resolveRoot()] + Config.recordingRoutes().keys.map { Config.resolveRoot(for: $0) }) + .filter { seen.insert($0.standardizedFileURL.path).inserted } + var pending: [URL] = [] + for root in roots { + guard let entries = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else { + continue + } + pending += entries.filter { fm.fileExists(atPath: $0.appendingPathComponent("meta.json").path) && !fm.fileExists(atPath: $0.appendingPathComponent("transcript.json").path) } - .sorted { $0.lastPathComponent < $1.lastPathComponent } + } + pending.sort { $0.lastPathComponent < $1.lastPathComponent } for dir in pending where !queue.contains(dir) { queue.append(dir) } diff --git a/Sources/yap/UI/SettingsModel.swift b/Sources/yap/UI/SettingsModel.swift index acb7a08..f08bfd1 100644 --- a/Sources/yap/UI/SettingsModel.swift +++ b/Sources/yap/UI/SettingsModel.swift @@ -12,7 +12,8 @@ import UniformTypeIdentifiers /// text editor is whichever wrote first. @MainActor final class SettingsModel: ObservableObject { - struct ExcludedApp: Identifiable { + /// One row of an app list: an exclusion or a recording route. + struct AppRow: Identifiable { /// The bundle identifier, which is what the config file stores. let id: String let name: String @@ -21,6 +22,9 @@ final class SettingsModel: ObservableObject { let icon: NSImage? /// Whether the app is still on this Mac. let installed: Bool + /// The subtitle: the bundle id for an exclusion, the folder for a + /// route — and for a route, exactly the string the config stores. + let detail: String } /// The login item, which is a file rather than a config key — so unlike @@ -57,7 +61,8 @@ final class SettingsModel: ObservableObject { @Published var meetingAutoRecord: Bool { didSet { write("meeting_auto_record", meetingAutoRecord) } } - @Published private(set) var excludedApps: [ExcludedApp] + @Published private(set) var excludedApps: [AppRow] + @Published private(set) var routes: [AppRow] /// Suppresses the write-through while `init` fills the properties in. private var loading = true @@ -84,7 +89,8 @@ final class SettingsModel: ObservableObject { onStop = Config.onStop() ?? "" meetingDetection = Config.meetingDetectionEnabled() meetingAutoRecord = Config.meetingAutoRecord() - excludedApps = Config.meetingExcludedApps().map(Self.resolve) + excludedApps = Config.meetingExcludedApps().map { Self.resolve($0, detail: nil) } + routes = Self.sorted(Config.recordingRoutes().map { Self.resolve($0.key, detail: $0.value) }) loading = false updateObserver = Updater.shared.observe { [weak self] state in self?.updateStatus = state.description @@ -101,36 +107,69 @@ final class SettingsModel: ObservableObject { // MARK: actions func chooseRecordingsDir() { + guard let url = pickFolder() else { return } + recordingsDir = Self.abbreviated(url) + } + + func addExcludedApp() { + guard let bundleID = pickApp(prompt: "Ignore") else { return } + guard !excludedApps.contains(where: { $0.id == bundleID }) else { return } + excludedApps.append(Self.resolve(bundleID, detail: nil)) + writeExcludedApps() + } + + func removeExcludedApp(_ bundleID: String) { + excludedApps.removeAll { $0.id == bundleID } + writeExcludedApps() + } + + func addRoute() { + // App first, folder second; cancelling either adds nothing. + guard let bundleID = pickApp(prompt: "Route") else { return } + guard let folder = pickFolder() else { return } + setRoute(bundleID, folder: Self.abbreviated(folder)) + } + + func changeRouteFolder(_ bundleID: String) { + guard let folder = pickFolder() else { return } + setRoute(bundleID, folder: Self.abbreviated(folder)) + } + + func removeRoute(_ bundleID: String) { + routes.removeAll { $0.id == bundleID } + writeRoutes() + } + + /// Adding an app that already has a route just changes its folder. + private func setRoute(_ bundleID: String, folder: String) { + routes.removeAll { $0.id == bundleID } + routes = Self.sorted(routes + [Self.resolve(bundleID, detail: folder)]) + writeRoutes() + } + + private func pickFolder() -> URL? { let panel = NSOpenPanel() panel.canChooseDirectories = true panel.canChooseFiles = false panel.allowsMultipleSelection = false panel.prompt = "Choose" panel.directoryURL = Config.recordingsDir() ?? Config.defaultRoot - guard panel.runModal() == .OK, let url = panel.url else { return } - recordingsDir = Self.abbreviated(url) + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return url } - func addExcludedApp() { + private func pickApp(prompt: String) -> String? { let panel = NSOpenPanel() panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = [.applicationBundle] panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) - panel.prompt = "Ignore" - guard panel.runModal() == .OK, let url = panel.url else { return } + panel.prompt = prompt + guard panel.runModal() == .OK, let url = panel.url else { return nil } // An app with no identifier in its Info.plist has nothing we could // store, and nothing to match a capture pid against later. - guard let bundleID = Bundle(url: url)?.bundleIdentifier else { return } - guard !excludedApps.contains(where: { $0.id == bundleID }) else { return } - excludedApps.append(Self.resolve(bundleID)) - writeExcludedApps() - } - - func removeExcludedApp(_ bundleID: String) { - excludedApps.removeAll { $0.id == bundleID } - writeExcludedApps() + return Bundle(url: url)?.bundleIdentifier } func openConfigFile() { @@ -214,6 +253,10 @@ final class SettingsModel: ObservableObject { write("meeting_excluded_apps", excludedApps.map(\.id)) } + private func writeRoutes() { + write("recording_routes", Dictionary(uniqueKeysWithValues: routes.map { ($0.id, $0.detail) })) + } + private func scheduleOnStopWrite() { guard !loading else { return } onStopWrite?.cancel() @@ -233,23 +276,31 @@ final class SettingsModel: ObservableObject { (url.path as NSString).abbreviatingWithTildeInPath } + /// A stable name order, so a row does not jump after a click. + private static func sorted(_ rows: [AppRow]) -> [AppRow] { + rows.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + /// Bundle id back to something a person recognises. An app that has since /// been deleted keeps its place in the list under its raw identifier — /// removing an exclusion the user cannot see is not ours to decide. - private static func resolve(_ bundleID: String) -> ExcludedApp { + /// `detail` nil draws the exclusion subtitle: the id, or "Not installed". + private static func resolve(_ bundleID: String, detail: String?) -> AppRow { guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { - return ExcludedApp( + return AppRow( id: bundleID, name: bundleID, icon: nil, - installed: false + installed: false, + detail: detail ?? "Not installed" ) } - return ExcludedApp( + return AppRow( id: bundleID, name: FileManager.default.displayName(atPath: url.path), icon: NSWorkspace.shared.icon(forFile: url.path), - installed: true + installed: true, + detail: detail ?? bundleID ) } } diff --git a/Sources/yap/UI/SettingsPanes.swift b/Sources/yap/UI/SettingsPanes.swift index f0342cc..0359994 100644 --- a/Sources/yap/UI/SettingsPanes.swift +++ b/Sources/yap/UI/SettingsPanes.swift @@ -73,6 +73,7 @@ struct DictationPane: View { struct RecordingPane: View { @ObservedObject var model: SettingsModel + @State private var routeSelection: String? var body: some View { Form { @@ -86,8 +87,28 @@ struct RecordingPane: View { Button("Choose…") { model.chooseRecordingsDir() } } } + } + Section { + AppList( + apps: model.routes, + selection: $routeSelection, + emptyText: "Calls from apps you add here are saved to their own folder.", + addHelp: "Route an app…", + removeHelp: "Stop routing the selected app", + onAdd: { model.addRoute() }, + onRemove: { + guard let routeSelection else { return } + model.removeRoute(routeSelection) + self.routeSelection = nil + }, + onActivate: { model.changeRouteFolder($0) } + ) + } header: { + Text("Route by app") } footer: { - RestartNote() + Text("Calls detected from these apps are saved here instead of the folder above. Double-click a row to change its folder.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) } Section { Toggle("Transcribe recordings automatically", isOn: $model.transcriptionEnabled) @@ -121,9 +142,12 @@ struct MeetingsPane: View { .disabled(!model.meetingDetection) } Section("Ignored apps") { - IgnoredAppList( + AppList( apps: model.excludedApps, selection: $selection, + emptyText: "Apps you ignore never trigger a meeting prompt.", + addHelp: "Ignore an app…", + removeHelp: "Stop ignoring the selected app", onAdd: { model.addExcludedApp() }, onRemove: { guard let selection else { return } @@ -148,16 +172,22 @@ private struct RestartNote: View { } } -// MARK: - Ignored apps +// MARK: - App list /// The bordered list with `+` and `−` under it that macOS uses everywhere an /// editable set of things lives. Familiar beats invented here: anyone who has -/// added a login item already knows how to work this. -private struct IgnoredAppList: View { - let apps: [SettingsModel.ExcludedApp] +/// added a login item already knows how to work this. Serves both the ignored +/// apps and the recording routes; only the words and the subtitle differ. +private struct AppList: View { + let apps: [SettingsModel.AppRow] @Binding var selection: String? + let emptyText: String + let addHelp: String + let removeHelp: String let onAdd: () -> Void let onRemove: () -> Void + /// Double-click on a row, for lists where a row has something to edit. + var onActivate: ((String) -> Void)? = nil var body: some View { VStack(spacing: 0) { @@ -177,7 +207,7 @@ private struct IgnoredAppList: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .overlay { if apps.isEmpty { - Text("Apps you ignore never trigger a meeting prompt.") + Text(emptyText) .font(.system(size: 11)) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -187,9 +217,9 @@ private struct IgnoredAppList: View { Divider() HStack(spacing: 0) { - stepper("plus", help: "Ignore an app…", action: onAdd) + stepper("plus", help: addHelp, action: onAdd) Divider().frame(height: 16) - stepper("minus", help: "Stop ignoring the selected app", action: onRemove) + stepper("minus", help: removeHelp, action: onRemove) .disabled(selection == nil) Spacer(minLength: 0) } @@ -207,7 +237,7 @@ private struct IgnoredAppList: View { } } - private func row(_ app: SettingsModel.ExcludedApp) -> some View { + private func row(_ app: SettingsModel.AppRow) -> some View { let selected = selection == app.id return HStack(spacing: 8) { icon(app) @@ -221,7 +251,7 @@ private struct IgnoredAppList: View { .font(.system(size: 12)) .lineLimit(1) .truncationMode(.middle) - Text(app.installed ? app.id : "Not installed") + Text(app.detail) .font(.system(size: 10)) .foregroundStyle( selected ? AnyShapeStyle(.white.opacity(0.75)) : AnyShapeStyle(.secondary)) @@ -236,6 +266,9 @@ private struct IgnoredAppList: View { .frame(maxWidth: .infinity, alignment: .leading) .background(selected ? Color.accentColor : .clear) .contentShape(Rectangle()) + // The double-tap sits inside the single so it gets first refusal; + // the other way round the single tap eats both clicks. + .onTapGesture(count: 2) { onActivate?(app.id) } .onTapGesture { selection = selected ? nil : app.id } } @@ -243,7 +276,7 @@ private struct IgnoredAppList: View { /// squircle beside two real app icons reads as a failed image load, and /// the row is trying to say the app is gone. @ViewBuilder - private func icon(_ app: SettingsModel.ExcludedApp) -> some View { + private func icon(_ app: SettingsModel.AppRow) -> some View { if let image = app.icon { Image(nsImage: image).resizable() } else { diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index dbc7ba6..80aac25 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -69,7 +69,6 @@ struct Run: ParsableCommand { let chosenModel = try Resolve.model() let key = Resolve.hotkey() - let root = Config.resolveRoot() // Before the model loads, so a takeover never holds two copies of it // in memory at once. @@ -85,7 +84,6 @@ struct Run: ParsableCommand { let daemon = Daemon( transcriber: chosenModel.makeTranscriber(), model: chosenModel, - root: root, hotkey: key, echoTranscripts: echoTranscripts, debugHotkey: debugHotkey @@ -109,7 +107,7 @@ struct Run: ParsableCommand { "yap \(Yap.configuration.version ?? "?") · \(key.serialized) " + "\(Config.tapToToggle() ? "tap" : "hold") · \(chosenModel.id)" if Config.meetingDetectionEnabled() { - banner += " · watching for meetings → \(root.path)" + banner += " · watching for meetings → \(Config.resolveRoot().path)" } banner += " · ^C to quit" warn(banner) diff --git a/Tests/yapTests/ConfigBackfillTests.swift b/Tests/yapTests/ConfigBackfillTests.swift new file mode 100644 index 0000000..b08d40c --- /dev/null +++ b/Tests/yapTests/ConfigBackfillTests.swift @@ -0,0 +1,98 @@ +import Foundation +import XCTest + +@testable import yap + +/// `Config.backfilled` splices lines for missing keys into the file's own +/// text. What it owes the reader is that a line lands where the template +/// lists it, and what it owes the file is that nothing else moves and the +/// result still parses to exactly the old values plus the defaults. +/// +/// The pure function only. `ensureEveryKeyPresent` reads and writes the real +/// `~/.config/yap/config.json`, which no test may touch. +final class ConfigBackfillTests: XCTestCase { + private func parse(_ text: String) throws -> [String: Any] { + let data = try XCTUnwrap(text.data(using: .utf8)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private var defaults: [String: Any] { + get throws { try parse(Config.template) } + } + + /// Lines of `text`, trimmed, so a test can say "this line, then that one". + private func lines(_ text: String) -> [String] { + text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) } + } + + func testMissingKeyLandsAfterItsTemplatePredecessor() throws { + // A file written before recording_routes existed. + let old = Config.template.replacingOccurrences(of: " \"recording_routes\": {},\n", with: "") + XCTAssertNil(old.range(of: "recording_routes")) + + let updated = try XCTUnwrap( + Config.backfilled(old, inner: [], outer: ["recording_routes"], defaults: try defaults)) + + let all = lines(updated) + let dir = try XCTUnwrap(all.firstIndex { $0.hasPrefix("\"recordings_dir\"") }) + XCTAssertEqual(all[dir + 1], "\"recording_routes\": {},", "one line, one token, right under recordings_dir") + XCTAssertEqual(NSDictionary(dictionary: try parse(updated)), NSDictionary(dictionary: try defaults)) + } + + func testTwoMissingNeighboursKeepTemplateOrder() throws { + let old = Config.template + .replacingOccurrences(of: " \"recording_routes\": {},\n", with: "") + .replacingOccurrences(of: " \"transcription\": { \"enabled\": true },\n", with: "") + + let updated = try XCTUnwrap( + Config.backfilled( + old, inner: [], outer: ["recording_routes", "transcription"], defaults: try defaults)) + + let all = lines(updated) + let dir = try XCTUnwrap(all.firstIndex { $0.hasPrefix("\"recordings_dir\"") }) + XCTAssertTrue(all[dir + 1].hasPrefix("\"recording_routes\"")) + XCTAssertTrue(all[dir + 2].hasPrefix("\"transcription\"")) + XCTAssertEqual(NSDictionary(dictionary: try parse(updated)), NSDictionary(dictionary: try defaults)) + } + + func testNestedKeyLandsAfterItsPredecessorInsideTheSection() throws { + let old = Config.template.replacingOccurrences(of: " \"overlay\": true,\n", with: "") + + let updated = try XCTUnwrap( + Config.backfilled(old, inner: ["overlay"], outer: [], defaults: try defaults)) + + let all = lines(updated) + let tap = try XCTUnwrap(all.firstIndex { $0.hasPrefix("\"tap_to_toggle\"") }) + XCTAssertEqual(all[tap + 1], "\"overlay\": true,") + XCTAssertEqual(NSDictionary(dictionary: try parse(updated)), NSDictionary(dictionary: try defaults)) + } + + func testValueSpreadOverLinesIsNeverAnAnchor() throws { + // `dictation` follows `meeting_excluded_apps` in the template, but the + // array here continues past its own line (and closes without a comma), + // so there is no edge under it to insert on. The line has to go under + // the nearest single-line key instead, and the array must not move. + let old = """ + { + "recordings_dir": "~/Recordings", + "meeting_auto_record": false, + "meeting_excluded_apps": [ + "com.apple.PhotoBooth" + ] + } + + """ + let updated = try XCTUnwrap( + Config.backfilled(old, inner: [], outer: ["dictation"], defaults: try defaults)) + + let all = lines(updated) + let auto = try XCTUnwrap(all.firstIndex { $0.hasPrefix("\"meeting_auto_record\"") }) + XCTAssertEqual(all[auto + 1], "\"dictation\": {") + XCTAssertNotNil( + updated.range(of: " \"meeting_excluded_apps\": [\n \"com.apple.PhotoBooth\"\n ]\n}")) + + var expected = try parse(old) + expected["dictation"] = try defaults["dictation"] + XCTAssertEqual(NSDictionary(dictionary: try parse(updated)), NSDictionary(dictionary: expected)) + } +} diff --git a/Tests/yapTests/ConfigSerializerTests.swift b/Tests/yapTests/ConfigSerializerTests.swift index 0d06dc8..2527635 100644 --- a/Tests/yapTests/ConfigSerializerTests.swift +++ b/Tests/yapTests/ConfigSerializerTests.swift @@ -24,7 +24,7 @@ final class ConfigSerializerTests: XCTestCase { NSDictionary(dictionary: original) ) - let order = ["recordings_dir", "transcription", "mic_voice_processing", + let order = ["recordings_dir", "recording_routes", "transcription", "mic_voice_processing", "meeting_detection", "meeting_auto_record", "meeting_excluded_apps", "dictation"] let offsets = order.map { text.range(of: "\"\($0)\"")?.lowerBound }