diff --git a/README.md b/README.md index 5990f27..1e57153 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,12 @@ you one transcript, with timings and speaker labels. If `fn` does something else on your Mac, `yap doctor` says how to get it back. There is also `--hotkey`, and `dictation.hotkey` in the config file. -"Copy last transcript" in the menu bar is there for the press that landed in +"Settings…" in the menu bar opens a window covering every setting below, and +every change lands immediately — the daemon does not need a restart to notice. +"Open Config File" at the bottom of it opens the JSON, for anyone who would +rather type. + +"Copy Last Transcript" in the menu bar is there for the press that landed in the wrong window. yap holds the most recent one in memory and nowhere else. "Quit yap" stops the background daemon until your next login. `yap start` @@ -73,7 +78,8 @@ Command and drag the mark out of it once; it stays where you put it. ## Configuration `~/.config/yap/config.json`. Every key is optional and a flag beats the file. -"Edit config…" in the menu bar opens it, filled in with the defaults — and an +The Settings window is a GUI over this exact file — there is no second store — +and "Open Config File" in it opens the JSON, filled in with the defaults. An upgrade adds a line for anything new, so the file always lists what this yap can do. Your own values are never touched. @@ -82,6 +88,7 @@ can do. Your own values are never touched. "recordings_dir": "~/Recordings", "meeting_detection": false, "meeting_auto_record": false, + "meeting_excluded_apps": [], "mic_voice_processing": true, "on_stop": "my-hook", "transcription": { "enabled": true }, @@ -97,9 +104,9 @@ 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` and -`meeting_auto_record` all change on the spot. A new `model` or `recordings_dir` wants a restart, and yap says so when it -sees one. +`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. `newline_after_release` hits Return once the text is in, which is what you want for chat boxes. @@ -121,6 +128,15 @@ transcript. visible Stop action. It only has an effect when `meeting_detection` is on, and is off by default. +`meeting_excluded_apps` is the bundle identifiers of apps that never trigger the +meeting prompt — the list behind the "Ignore " button on the prompt and the +"Ignore" button on the auto-record banner. Clicking it adds the app here, ends +any recording that button started, and yap says nothing about that app again. +Manage the list under Meetings in the Settings window: it shows each app by icon +and name, removes one with the minus button, and "Add App…" excludes an app +ahead of time. Detection stays fail-open — an app you have never excluded still +gets offered, even one yap has never heard of. + `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 diff --git a/Sources/yap/Config.swift b/Sources/yap/Config.swift index 4f49796..b22b1b8 100644 --- a/Sources/yap/Config.swift +++ b/Sources/yap/Config.swift @@ -8,6 +8,7 @@ import Foundation /// "mic_voice_processing": true, /// "meeting_detection": false, /// "meeting_auto_record": false, +/// "meeting_excluded_apps": [], /// "on_stop": "my-hook", /// "dictation": { /// "model": "parakeet-tdt-ctc-110m", @@ -67,6 +68,17 @@ enum Config { load()?["meeting_auto_record"] as? Bool ?? false } + /// Bundle identifiers that never trigger the meeting prompt. Built by the + /// "Ignore " button on the prompt and editable in Settings. + /// + /// An exclusion list rather than an allowlist: detection stays fail-open, + /// so a meeting app nobody has heard of still gets offered. Bundle ids, + /// not names or paths — stable across renames and localization, and + /// resolvable back to an icon and a name through `NSWorkspace`. + static func meetingExcludedApps() -> [String] { + load()?["meeting_excluded_apps"] as? [String] ?? [] + } + /// Apple voice processing (acoustic echo cancellation) on the mic, so /// speaker playback doesn't bleed into the mic track and get transcribed /// as "me". Default on: the mic track always pairs with a system track, so @@ -162,7 +174,7 @@ enum Config { // MARK: - File /// Every value here is the built-in default, so writing this file changes - /// nothing about how yap behaves — it exists so "Edit config…" has + /// nothing about how yap behaves — it exists so "Open Config File" has /// something to open and the watcher has something to watch. `on_stop` is /// left out deliberately: there is no sensible default hook. static let template = """ @@ -172,6 +184,7 @@ enum Config { "mic_voice_processing": true, "meeting_detection": false, "meeting_auto_record": false, + "meeting_excluded_apps": [], "dictation": { "model": "parakeet-tdt-ctc-110m", "hotkey": "fn", @@ -199,6 +212,80 @@ enum Config { warn("warning: could not create \(path.path): \(error)") } } + + // MARK: - Writing + + /// Apply a change to the config file. The Settings window's only write + /// path; the watcher turns the save back into a live reload. + /// + /// A file that does not parse is left exactly as it is. Someone is + /// mid-edit in a text editor, and losing their work to a toggle click is + /// far worse than a setting that does not stick. + static func update(_ mutate: (inout [String: Any]) -> Void) { + ensureFileExists() + guard + let data = try? Data(contentsOf: path), + var config = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + warn("warning: \(path.path) is not valid JSON — not writing") + return + } + mutate(&config) + do { + try serialized(config).write(to: path, atomically: true, encoding: .utf8) + } catch { + warn("warning: could not write \(path.path): \(error)") + } + } + + /// The config file as text: two-space JSON with keys in template order, + /// so a file the GUI writes reads like the one the template writes. + /// + /// Everything round-trips. Keys yap has never heard of keep their values + /// and land after the ones it has, ordered among themselves by name — + /// hand-adding a key to this file must never be punished by a click in + /// the Settings window. + static func serialized(_ config: [String: Any]) -> String { + var lines: [String] = [] + for key in inTemplateOrder(config.keys) { + guard let value = config[key] else { continue } + // `dictation` is the one object the template spreads over lines; + // every other value, nested objects included, is one token. + if key == "dictation", let section = value as? [String: Any] { + var inner: [String] = [] + for name in inTemplateOrder(section.keys) { + guard let value = section[name] else { continue } + inner.append(" \"\(name)\": \(token(value))") + } + lines.append(" \"\(key)\": {\n" + inner.joined(separator: ",\n") + "\n }") + } else { + lines.append(" \"\(key)\": \(token(value))") + } + } + return "{\n" + lines.joined(separator: ",\n") + "\n}\n" + } + + /// One JSON value on one line, spelled the way the template spells it. + /// + /// A nested object gets `{ "k": v }` rather than Foundation's + /// `{"k":v}` — `transcription` is written this way in the template, and a + /// GUI click should not reformat a line the user never touched. + private static func token(_ value: Any) -> String { + if let object = value as? [String: Any] { + guard !object.isEmpty else { return "{}" } + let pairs = inTemplateOrder(object.keys).compactMap { key -> String? in + object[key].map { "\"\(key)\": \(token($0))" } + } + return "{ " + pairs.joined(separator: ", ") + " }" + } + guard + let data = try? JSONSerialization.data( + withJSONObject: value, + options: [.fragmentsAllowed, .withoutEscapingSlashes]), + let text = String(data: data, encoding: .utf8) + else { return "null" } + return text + } } /// Calls back, on the main queue, whenever the config file is saved. @@ -285,7 +372,7 @@ final class ConfigWatcher { /// The file is gone. Watch its directory instead and re-arm the moment /// something puts one back: a rename still in flight, an editor that /// unlinks before it writes, or someone deleting the config and getting - /// it back from "Edit config…". A directory kqueue costs exactly what a + /// it back from "Open Config File". A directory kqueue costs exactly what a /// file one does — nothing until the kernel has news — and the /// alternative is hot reload staying dead until the next restart. private func watchDirectory() { diff --git a/Sources/yap/ConfigBackfill.swift b/Sources/yap/ConfigBackfill.swift index 78d4986..0046a72 100644 --- a/Sources/yap/ConfigBackfill.swift +++ b/Sources/yap/ConfigBackfill.swift @@ -4,9 +4,10 @@ import Foundation /// has, which is a different job from reading values out of it. /// /// A config written by an older yap has no line for anything added since, so -/// "Edit config…" opens a file that hides half the settings and the only way -/// to discover `tap_to_toggle` is the README. The template every new install -/// gets lists them all; this brings an existing file up to the same standard. +/// "Open Config File" opens a file that hides half the settings and the only +/// way to discover `tap_to_toggle` is the Settings window or the README. The +/// template every new install gets lists them all; this brings an existing +/// file up to the same standard. extension Config { /// Add the keys this build knows about that the file on disk does not. /// @@ -72,11 +73,18 @@ extension Config { /// The keys in the order the template lists them, so an inserted line /// reads where the documented file would have put it rather than wherever - /// the alphabet lands. - private static func inTemplateOrder(_ keys: some Collection) -> [String] { - keys.sorted { - (template.range(of: "\"\($0)\"")?.lowerBound ?? template.endIndex) - < (template.range(of: "\"\($1)\"")?.lowerBound ?? template.endIndex) + /// the alphabet lands. Shared with `Config.serialized(_:)`, which owes the + /// GUI-written file the same order. + /// + /// Keys the template does not list share one position and are broken apart + /// by name — a total order, so rewriting the file twice cannot shuffle + /// somebody's hand-added keys around. + static func inTemplateOrder(_ keys: some Collection) -> [String] { + func position(_ key: String) -> String.Index { + template.range(of: "\"\(key)\"")?.lowerBound ?? template.endIndex + } + return keys.sorted { + position($0) == position($1) ? $0 < $1 : position($0) < position($1) } } diff --git a/Sources/yap/Detection/MeetingTitle.swift b/Sources/yap/Detection/MeetingTitle.swift index 9c42331..9a8c703 100644 --- a/Sources/yap/Detection/MeetingTitle.swift +++ b/Sources/yap/Detection/MeetingTitle.swift @@ -25,6 +25,16 @@ enum MeetingTitle { return NSString.path(withComponents: Array(components[...end])) } + /// Bundle identifier of the app behind a capture pid, for the exclusion + /// list. Reading Info.plist off the bundle path — no TCC, no AX, and it + /// works for sandboxed and hardened apps alike. + /// + /// A process with no `.app` around it has no identity we could store, so + /// it can never be excluded and never grows an Ignore button. + static func bundleID(forPID pid: pid_t) -> String? { + appBundlePath(forPID: pid).flatMap { Bundle(path: $0)?.bundleIdentifier } + } + /// Best-effort meeting name from the capturing app's windows. static func capture(forCapturePID pid: pid_t) -> String? { let appPath = appBundlePath(forPID: pid) diff --git a/Sources/yap/UI/MenuBarController.swift b/Sources/yap/UI/MenuBarController.swift index cd65499..070c4d5 100644 --- a/Sources/yap/UI/MenuBarController.swift +++ b/Sources/yap/UI/MenuBarController.swift @@ -1,4 +1,5 @@ import AppKit +import SwiftUI /// Status bar item in the top-right of the menu bar. Shows what yap is doing /// at a glance and provides the only persistent control surface (we run as @@ -28,9 +29,17 @@ final class MenuBarController { private var tapToToggle: Bool private let statusItem: NSStatusItem - private let stateLabel: NSMenuItem private let toggleItem: NSMenuItem private let copyItem: NSMenuItem + /// Backs the header card. `refresh()` stays the single point of truth and + /// pushes finished strings into it; SwiftUI does the redraw, so nothing + /// here reaches into the view. + private let state = MenuState() + + /// Fixed, so the menu keeps one width as the state line changes length. + /// Wide enough for the longest of them ("idle · hold rightCommand to + /// dictate") and for a model id. + private static let headerWidth: CGFloat = 260 /// The last thing dictation produced. In process memory, one at a time, /// never written to disk or log by this feature — the log deliberately @@ -59,44 +68,43 @@ final class MenuBarController { let menu = NSMenu() // Without this AppKit greys out every item whose target doesn't answer - // a validation selector, which would disable the two status lines *and* - // the actions. We drive enablement ourselves instead. + // a validation selector, which would disable the header *and* the + // actions. We drive enablement ourselves instead. menu.autoenablesItems = false - stateLabel = NSMenuItem( - title: Self.idleTitle(hotkeyName, tapToToggle), - action: nil, - keyEquivalent: "" + state.line = Self.idleTitle(hotkeyName, tapToToggle) + + // A hosting view inside a menu item, not a run of disabled text lines: + // the mark, the state and the model read as one card. Fixed width so + // the menu never resizes as the state line changes length; the height + // comes from what SwiftUI lays out. + let header = NSMenuItem() + let hosting = NSHostingView(rootView: MenuHeaderView(state: state, modelID: modelID)) + hosting.frame.size = NSSize( + width: Self.headerWidth, + height: hosting.fittingSize.height ) - stateLabel.isEnabled = false - menu.addItem(stateLabel) - - // Never changes: the model is chosen once, at launch. - let modelLabel = NSMenuItem(title: "model: \(modelID)", action: nil, keyEquivalent: "") - modelLabel.isEnabled = false - menu.addItem(modelLabel) + header.view = hosting + header.isEnabled = false + menu.addItem(header) menu.addItem(.separator()) toggleItem = NSMenuItem( - title: "Start recording", + title: "Start Recording", action: #selector(toggleClicked), keyEquivalent: "r" ) + toggleItem.image = NSImage( + systemSymbolName: "record.circle", accessibilityDescription: nil) menu.addItem(toggleItem) - let editConfig = NSMenuItem( - title: "Edit config…", - action: #selector(editConfigClicked), - keyEquivalent: "," - ) - menu.addItem(editConfig) - copyItem = NSMenuItem( - title: "Copy last transcript", + title: "Copy Last Transcript", action: #selector(copyTranscriptClicked), keyEquivalent: "" ) + copyItem.image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: nil) // Nothing to copy until something has been dictated. That is the // whole of the empty state. copyItem.isEnabled = false @@ -104,6 +112,17 @@ final class MenuBarController { menu.addItem(.separator()) + let settings = NSMenuItem( + title: "Settings…", + action: #selector(settingsClicked), + keyEquivalent: "," + ) + settings.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: nil) + menu.addItem(settings) + + menu.addItem(.separator()) + + // No symbol: the system's own Quit items don't carry one. let quit = NSMenuItem( title: "Quit yap", action: #selector(quitClicked), @@ -111,7 +130,7 @@ final class MenuBarController { ) menu.addItem(quit) - for item in [toggleItem, editConfig, copyItem, quit] { + for item in [toggleItem, copyItem, settings, quit] { item.target = self } @@ -185,24 +204,32 @@ final class MenuBarController { refresh() } - /// Single point of truth for both titles. Called on every state change and - /// once a second while a session records. + /// Single point of truth for the toggle title and the header's state line. + /// Called on every state change and once a second while a session records. private func refresh() { if let recordingSince { let elapsed = formatElapsed(Date().timeIntervalSince(recordingSince)) - toggleItem.title = "Stop recording · \(elapsed)" + toggleItem.title = "Stop Recording · \(elapsed)" + toggleItem.image = NSImage( + systemSymbolName: "stop.circle", accessibilityDescription: nil) } else { - toggleItem.title = "Start recording" + toggleItem.title = "Start Recording" + toggleItem.image = NSImage( + systemSymbolName: "record.circle", accessibilityDescription: nil) } switch dictation { case .listening: - stateLabel.title = "● listening" + state.line = "● listening" case .transcribing: - stateLabel.title = "transcribing…" + state.line = "transcribing…" case .idle: - stateLabel.title = - recordingSince == nil ? Self.idleTitle(hotkeyName, tapToToggle) : "● recording" + if let recordingSince { + let elapsed = formatElapsed(Date().timeIntervalSince(recordingSince)) + state.line = "● recording · \(elapsed)" + } else { + state.line = Self.idleTitle(hotkeyName, tapToToggle) + } } } @@ -226,16 +253,10 @@ final class MenuBarController { NSPasteboard.general.setString(lastTranscript, forType: .string) } - /// Opens the config in whatever the user's editor for .json is. No - /// callback into the daemon: the module is flat, and the watcher picks up - /// the save on its own. - @objc private func editConfigClicked() { - Config.ensureFileExists() - // Before it opens, not after: a config written by an earlier yap has - // no line for the settings added since, and this is the moment someone - // is looking for them. - Config.ensureEveryKeyPresent() - NSWorkspace.shared.open(Config.path) + /// Opens the Settings window, building it on first click. "Open Config + /// File" lives inside it, for anyone who would rather type. + @objc private func settingsClicked() { + SettingsWindow.show() } /// No callback: quitting is unconditional. Anything that has to run on the @@ -245,3 +266,45 @@ final class MenuBarController { NSApp.terminate(nil) } } + +// MARK: - + +/// The one line of the header card that changes. Mutated by +/// `MenuBarController.refresh()`; the view redraws itself. +@MainActor +private final class MenuState: ObservableObject { + @Published var line: String = "" +} + +/// The card at the top of the dropdown: the yap mark, what it is doing, and +/// which model it will do it with. +private struct MenuHeaderView: View { + @ObservedObject var state: MenuState + /// Fixed for the life of the process — the model is chosen once, at launch. + let modelID: String + + var body: some View { + HStack(spacing: 10) { + // Same treatment as the prompt pill's icon, so the two surfaces + // read as one app. + Image(nsImage: StatusIcon.image(size: 19) ?? NSImage()) + .renderingMode(.template) + .foregroundStyle(Color.accentColor) + .frame(width: 34, height: 34) + .background(Color.accentColor.opacity(0.15), in: Circle()) + VStack(alignment: .leading, spacing: 1) { + Text("yap").font(.headline) + Text(state.line) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(modelID) + .font(.caption) + .foregroundStyle(.tertiary) + } + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + } +} diff --git a/Sources/yap/UI/PromptPanel.swift b/Sources/yap/UI/PromptPanel.swift index 66384bd..27fdaf2 100644 --- a/Sources/yap/UI/PromptPanel.swift +++ b/Sources/yap/UI/PromptPanel.swift @@ -1,19 +1,24 @@ import AppKit /// Ask the user something with a floating banner. `onAccept` runs if they click -/// `button`, `onDismiss` if they actively turn it down. Ignoring the prompt, or -/// having it retired, runs neither: only a click is an answer. Any prompt -/// already on screen is replaced. +/// `button`, `onDismiss` if they actively turn it down, `onSecondary` if they +/// click the optional third button. Ignoring the prompt, or having it retired, +/// runs none of them: only a click is an answer. Any prompt already on screen +/// is replaced. @MainActor func askUser( title: String, body: String, button: String, + secondaryButton: String? = nil, + onSecondary: (@MainActor () -> Void)? = nil, onDismiss: @escaping @MainActor () -> Void = {}, onAccept: @escaping @MainActor () -> Void ) { PromptPanel.present( - title: title, body: body, button: button, onDismiss: onDismiss, onAccept: onAccept + title: title, body: body, button: button, + secondaryButton: secondaryButton, onSecondary: onSecondary, + onDismiss: onDismiss, onAccept: onAccept ) } @@ -93,6 +98,8 @@ final class PromptPanel: NSPanel { title: String, body: String, button: String, + secondaryButton: String?, + onSecondary: (@MainActor () -> Void)?, onDismiss: @escaping @MainActor () -> Void, onAccept: @escaping @MainActor () -> Void ) { @@ -101,7 +108,7 @@ final class PromptPanel: NSPanel { current?.close() let panel = PromptPanel( heading: title, body: body, button: button, - secondaryButton: nil, onSecondary: nil, + secondaryButton: secondaryButton, onSecondary: onSecondary, namePrefill: nil, onNameSubmit: nil, onDismiss: onDismiss, onAccept: onAccept, toast: false ) diff --git a/Sources/yap/UI/SettingsWindow.swift b/Sources/yap/UI/SettingsWindow.swift new file mode 100644 index 0000000..bda0c24 --- /dev/null +++ b/Sources/yap/UI/SettingsWindow.swift @@ -0,0 +1,356 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// The Settings window: a GUI over `~/.config/yap/config.json`, covering every +/// key the file has. +/// +/// It is a view of the file, not a second store. Every control writes straight +/// back through `Config.update`, the existing `ConfigWatcher` turns that save +/// into a live reload, and "Open Config File" is still there for anyone who +/// would rather type. Nothing here is created until the menu item is clicked — +/// an app that spends most of its life idle does not build a settings window +/// at launch. +@MainActor +enum SettingsWindow { + private static var window: NSWindow? + + /// Wide enough for a grouped form's labels and controls without the two + /// columns colliding, narrow enough to stay a settings sheet. + private static let width: CGFloat = 440 + + static func show() { + let window = self.window ?? make() + // A fresh view on every open, so the form reflects the file as it is + // now — including hand edits made since the last time it was open. + let controller = NSHostingController(rootView: SettingsView()) + // The window owns its size, not the form. Both halves of that matter: + // `sizingOptions = []` stops the controller pushing a preferred size, + // and restoring the size around the swap stops AppKit collapsing the + // window onto the form's *minimum* height — the form is taller than + // any laptop screen and declares no ideal height of its own, so + // without this it reopens at 320 pt with everything below Dictation + // out of sight, and a size the user chose is thrown away too. + controller.sizingOptions = [] + let size = window.contentRect(forFrameRect: window.frame).size + window.contentViewController = controller + window.setContentSize(size) + window.makeKeyAndOrderFront(nil) + // We run as `.accessory`, which has no dock icon and does not become + // active on its own. Without this the window opens behind whatever the + // user was looking at. + NSApp.activate(ignoringOtherApps: true) + } + + private static func make() -> NSWindow { + // As tall as the screen comfortably allows. The whole form does not fit + // on any laptop display — it scrolls — but at this height the Meetings + // section, which is the one people open this window to reach, is on + // screen without a scroll. + let available = (NSScreen.main?.visibleFrame.height ?? 900) - 60 + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: width, height: min(880, available)), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.title = "yap Settings" + // NSWindow defaults to release-on-close, which over-releases under ARC + // — and we keep this one around between opens. + window.isReleasedWhenClosed = false + window.contentMinSize = NSSize(width: width, height: 320) + window.center() + self.window = window + return window + } +} + +// MARK: - View + +private struct SettingsView: View { + @StateObject private var model = SettingsModel() + + var body: some View { + Form { + Section("Dictation") { + Picker("Hotkey", selection: $model.hotkey) { + ForEach(HotkeyMonitor.Key.allCases, id: \.self) { key in + Text(SettingsModel.label(for: key)).tag(key.rawValue) + } + } + Toggle("Tap to toggle", isOn: $model.tapToToggle) + Toggle("Show recording pill", isOn: $model.overlay) + Toggle("Press Return after dictating", isOn: $model.newlineAfterRelease) + Toggle("Mute speakers while dictating", isOn: $model.muteOutput) + Picker("Model", selection: $model.model) { + ForEach(ModelRegistry.shared, id: \.id) { entry in + Text("\(entry.displayName) · \(entry.sizeMB) MB").tag(entry.id) + } + } + Text("Applies after yap restarts.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("Recording") { + LabeledContent("Folder") { + HStack(spacing: 8) { + Text(model.recordingsDir) + .lineLimit(1) + .truncationMode(.head) + .foregroundStyle(.secondary) + Button("Choose…") { model.chooseRecordingsDir() } + } + } + Text("Applies after yap restarts.") + .font(.caption) + .foregroundStyle(.secondary) + Toggle("Transcribe recordings automatically", isOn: $model.transcriptionEnabled) + Toggle("Voice processing on the mic", isOn: $model.micVoiceProcessing) + LabeledContent("Run after each recording") { + TextField("shell command, given the session folder", text: $model.onStop) + .font(.system(.body, design: .monospaced)) + .textFieldStyle(.roundedBorder) + } + } + + Section("Meetings") { + Toggle("Detect meetings", isOn: $model.meetingDetection) + Toggle("Record without asking", isOn: $model.meetingAutoRecord) + .disabled(!model.meetingDetection) + + LabeledContent("Ignored apps") { + VStack(alignment: .leading, spacing: 6) { + if model.excludedApps.isEmpty { + Text("Apps you ignore never trigger a meeting prompt.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(model.excludedApps) { app in + HStack(spacing: 6) { + Image(nsImage: app.icon) + .resizable() + .frame(width: 18, height: 18) + Text(app.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Button { + model.removeExcludedApp(app.id) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Stop ignoring \(app.name)") + } + } + } + Button("Add App…") { model.addExcludedApp() } + } + } + } + + Section { + Button("Open Config File") { model.openConfigFile() } + } + } + .formStyle(.grouped) + // Fills whatever the window is; the window owns the size, and the + // grouped form scrolls when the content outgrows it. + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +// MARK: - Model + +/// Snapshot of the config file, with every setter writing straight back. +/// +/// No Apply button: a toggle here is the same act as saving the file, and the +/// daemon reloads either way. It deliberately does not watch the file while +/// open — a fresh snapshot per open is enough, and the loser of a race with a +/// text editor is whichever wrote first. +@MainActor +private final class SettingsModel: ObservableObject { + struct ExcludedApp: Identifiable { + /// The bundle identifier, which is what the config file stores. + let id: String + let name: String + let icon: NSImage + } + + @Published var hotkey: String { didSet { writeDictation("hotkey", hotkey) } } + @Published var tapToToggle: Bool { didSet { writeDictation("tap_to_toggle", tapToToggle) } } + @Published var overlay: Bool { didSet { writeDictation("overlay", overlay) } } + @Published var newlineAfterRelease: Bool { + didSet { writeDictation("newline_after_release", newlineAfterRelease) } + } + @Published var muteOutput: Bool { didSet { writeDictation("mute_output", muteOutput) } } + @Published var model: String { didSet { writeDictation("model", model) } } + + @Published var recordingsDir: String { didSet { write("recordings_dir", recordingsDir) } } + @Published var transcriptionEnabled: Bool { + didSet { writeTranscription("enabled", transcriptionEnabled) } + } + @Published var micVoiceProcessing: Bool { + didSet { write("mic_voice_processing", micVoiceProcessing) } + } + /// Debounced, unlike every other control: a keystroke is not a decision, + /// and writing per character would rewrite the file — and wake the + /// watcher — a dozen times while someone types a command. + @Published var onStop: String { didSet { scheduleOnStopWrite() } } + + @Published var meetingDetection: Bool { didSet { write("meeting_detection", meetingDetection) } } + @Published var meetingAutoRecord: Bool { + didSet { write("meeting_auto_record", meetingAutoRecord) } + } + @Published private(set) var excludedApps: [ExcludedApp] + + /// Suppresses the write-through while `init` fills the properties in. + private var loading = true + private var onStopWrite: Task? + + init() { + hotkey = HotkeyMonitor.Key(name: Config.hotkey() ?? "")?.rawValue + ?? HotkeyMonitor.Key.fn.rawValue + tapToToggle = Config.tapToToggle() + overlay = Config.overlayEnabled() + newlineAfterRelease = Config.newlineAfterRelease() + muteOutput = Config.muteOutputWhileDictating() + model = Config.dictationModel().flatMap { ModelRegistry.find($0)?.id } + ?? ModelRegistry.recommended()?.id ?? "" + recordingsDir = Self.abbreviated(Config.recordingsDir() ?? Config.defaultRoot) + transcriptionEnabled = Config.transcriptionEnabled() + micVoiceProcessing = Config.micVoiceProcessing() + onStop = Config.onStop() ?? "" + meetingDetection = Config.meetingDetectionEnabled() + meetingAutoRecord = Config.meetingAutoRecord() + excludedApps = Config.meetingExcludedApps().map(Self.resolve) + loading = false + } + + // MARK: actions + + func chooseRecordingsDir() { + 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) + } + + func addExcludedApp() { + 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 } + // 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() + } + + func openConfigFile() { + Config.ensureFileExists() + // Before it opens, not after: a config written by an earlier yap has + // no line for the settings added since, and this is the moment someone + // is looking for them. + Config.ensureEveryKeyPresent() + NSWorkspace.shared.open(Config.path) + } + + static func label(for key: HotkeyMonitor.Key) -> String { + switch key { + case .fn: return "Fn (Globe)" + case .rightOption: return "Right Option ⌥" + case .rightCommand: return "Right Command ⌘" + case .rightControl: return "Right Control ⌃" + case .rightShift: return "Right Shift ⇧" + } + } + + // MARK: writing + + private func write(_ key: String, _ value: Any?) { + guard !loading else { return } + Config.update { config in + if let value { + config[key] = value + } else { + config.removeValue(forKey: key) + } + } + } + + private func writeDictation(_ key: String, _ value: Any) { + writeSection("dictation", key, value) + } + + private func writeTranscription(_ key: String, _ value: Any) { + writeSection("transcription", key, value) + } + + /// A nested key, creating the object if the file has never had one. + private func writeSection(_ section: String, _ key: String, _ value: Any) { + guard !loading else { return } + Config.update { config in + var object = config[section] as? [String: Any] ?? [:] + object[key] = value + config[section] = object + } + } + + private func writeExcludedApps() { + write("meeting_excluded_apps", excludedApps.map(\.id)) + } + + private func scheduleOnStopWrite() { + guard !loading else { return } + onStopWrite?.cancel() + let command = onStop.trimmingCharacters(in: .whitespacesAndNewlines) + onStopWrite = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled else { return } + // An empty hook is no hook: drop the key rather than leave an + // empty string for `Config.onStop()` to filter out forever. + self?.write("on_stop", command.isEmpty ? nil : command) + } + } + + // MARK: lookups + + private static func abbreviated(_ url: URL) -> String { + (url.path as NSString).abbreviatingWithTildeInPath + } + + /// 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 { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { + return ExcludedApp( + id: bundleID, + name: bundleID, + icon: NSWorkspace.shared.icon(for: .applicationBundle) + ) + } + return ExcludedApp( + id: bundleID, + name: FileManager.default.displayName(atPath: url.path), + icon: NSWorkspace.shared.icon(forFile: url.path) + ) + } +} diff --git a/Sources/yap/Yap.swift b/Sources/yap/Yap.swift index 86b10fc..27e36fd 100644 --- a/Sources/yap/Yap.swift +++ b/Sources/yap/Yap.swift @@ -431,7 +431,7 @@ final class Daemon: NSObject, NSApplicationDelegate { // Hot reload. Create the file first, so the watcher arms on an inode // rather than standing on the directory waiting for one, and so - // "Edit config…" always has something to show. + // "Open Config File" always has something to show. Config.ensureFileExists() // And bring it up to date: an upgrade adds settings, and a config // written by an older yap has no line for any of them. Runs before the @@ -696,12 +696,34 @@ final class Daemon: NSObject, NSApplicationDelegate { Task { [coordinator] in await coordinator.enqueue(dir) } } + /// End a session this app started by itself. For Ignore, which arrives + /// from the auto-record toast after a session is already running and from + /// the ask prompt before one exists. + private func stopSessionIfAutoStarted() { + guard session != nil, autoStarted else { return } + stopSession() + } + /// Attach the daemon's handlers. Starting the detector is the caller's /// call: a manual session holds the mic, and detection must stay down /// until it ends. private func wire(_ detector: MeetingDetector) { detector.onMeetingStart = { [weak self, weak detector] pid, appName in guard let self else { return } + + // Before anything else, including the AX title lookup and the + // back-to-back stop: an excluded app is invisible to every piece + // of meeting logic, not merely unprompted. Read fresh at each + // event, the same standing-consent pattern as auto-record below. + let bundleID = MeetingTitle.bundleID(forPID: pid) + if let bundleID, Config.meetingExcludedApps().contains(bundleID) { + warn("◇ \(appName ?? bundleID) is excluded — ignoring") + // Marks the pid, so the detector stops re-firing every poll; + // its end-of-meeting path clears the mark on its own. + detector?.declineCurrentMeeting() + return + } + let title = MeetingTitle.capture(forCapturePID: pid) let who = appName ?? "Your microphone" warn("◆ \(who) is in use" + (title.map { " · \($0)" } ?? "")) @@ -717,6 +739,21 @@ final class Daemon: NSObject, NSApplicationDelegate { self.stopSession() } + // "Never ask about this app again", offered from whichever surface + // the user is looking at. Both need it to also end the recording + // this event may have just started. + let ignoreApp: @MainActor () -> Void = { [weak self, weak detector] in + guard let bundleID else { return } + Config.update { config in + var list = config["meeting_excluded_apps"] as? [String] ?? [] + if !list.contains(bundleID) { list.append(bundleID) } + config["meeting_excluded_apps"] = list + } + warn("◇ \(appName ?? bundleID) added to the ignore list") + detector?.declineCurrentMeeting() + self?.stopSessionIfAutoStarted() + } + // Standing consent is read at each event so config saves take // effect without a restart. Recording is always announced. if Config.meetingAutoRecord() { @@ -726,7 +763,9 @@ final class Daemon: NSObject, NSApplicationDelegate { showToast( title: appName.map { "Recording \($0) call" } ?? "Recording meeting", body: title ?? "Stop from the menu bar or here", - button: "Stop" + button: "Stop", + secondaryButton: bundleID != nil ? "Ignore" : nil, + onSecondary: ignoreApp ) { [weak self, weak detector] in detector?.declineCurrentMeeting() self?.stopSession() @@ -738,6 +777,10 @@ final class Daemon: NSObject, NSApplicationDelegate { title: appName.map { "\($0) is in a call" } ?? "Your microphone is in use", body: title ?? "Record this meeting?", button: "Record", + // Only when we have both a name to show and an identity to + // store; a process with no app bundle gets Dismiss and no more. + secondaryButton: appName.flatMap { bundleID != nil ? "Ignore \($0)" : nil }, + onSecondary: ignoreApp, onDismiss: { detector?.declineCurrentMeeting() } ) { [weak self] in guard let self, self.session == nil else { return } diff --git a/Tests/yapTests/ConfigSerializerTests.swift b/Tests/yapTests/ConfigSerializerTests.swift new file mode 100644 index 0000000..0d06dc8 --- /dev/null +++ b/Tests/yapTests/ConfigSerializerTests.swift @@ -0,0 +1,59 @@ +import Foundation +import XCTest + +@testable import yap + +/// `Config.serialized(_:)` is the only thing that writes the config file for +/// the Settings window, so what it must never do is lose anything: a key it +/// has never heard of, a nested key, or a value type it does not model. +/// +/// The pure function only. `Config.update` writes the real +/// `~/.config/yap/config.json`, which no test may touch. +final class ConfigSerializerTests: 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]) + } + + func testTemplateRoundTripsWithTemplateKeyOrder() throws { + let original = try parse(Config.template) + let text = Config.serialized(original) + + XCTAssertEqual( + NSDictionary(dictionary: try parse(text)), + NSDictionary(dictionary: original) + ) + + let order = ["recordings_dir", "transcription", "mic_voice_processing", + "meeting_detection", "meeting_auto_record", "meeting_excluded_apps", + "dictation"] + let offsets = order.map { text.range(of: "\"\($0)\"")?.lowerBound } + XCTAssertFalse(offsets.contains(where: { $0 == nil }), "every template key is written") + XCTAssertEqual(offsets.compactMap { $0 }, offsets.compactMap { $0 }.sorted()) + } + + func testUnknownKeysSurviveAtBothLevels() throws { + var config = try parse(Config.template) + config["custom"] = true + config["custom_list"] = ["a", 2] as [Any] + var dictation = try XCTUnwrap(config["dictation"] as? [String: Any]) + dictation["custom_nested"] = "kept" + config["dictation"] = dictation + + let round = try parse(Config.serialized(config)) + XCTAssertEqual(NSDictionary(dictionary: round), NSDictionary(dictionary: config)) + XCTAssertEqual( + (round["dictation"] as? [String: Any])?["custom_nested"] as? String, "kept") + } + + func testExcludedAppsRoundTripAsAList() throws { + var config = try parse(Config.template) + config["meeting_excluded_apps"] = ["com.apple.PhotoBooth", "us.zoom.xos"] + + let round = try parse(Config.serialized(config)) + XCTAssertEqual( + round["meeting_excluded_apps"] as? [String], + ["com.apple.PhotoBooth", "us.zoom.xos"] + ) + } +}