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 }