diff --git a/README.md b/README.md index 10b90d1..c622011 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,10 @@ 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. +changes its folder. What holds the microphone is not always an app you could +pick — a FaceTime call belongs to `avconferenced`, which has no app to find — +so once detection has named something since yap started, + offers it beside +"Choose Application…" and only asks for the 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 diff --git a/Sources/yap/Daemon.swift b/Sources/yap/Daemon.swift index 01db073..2d63fcb 100644 --- a/Sources/yap/Daemon.swift +++ b/Sources/yap/Daemon.swift @@ -683,6 +683,9 @@ final class Daemon: NSObject, NSApplicationDelegate { detector?.ignoreCurrentClient() return } + // A daemon like avconferenced has no .app for the Settings picker + // to find, so the route list offers what detection has named. + if let app { SettingsModel.noteSeen(app) } let title = MeetingTitle.capture(forCapturePID: pid) let who = app?.name ?? "Your microphone" diff --git a/Sources/yap/UI/SettingsModel.swift b/Sources/yap/UI/SettingsModel.swift index f08bfd1..59b891d 100644 --- a/Sources/yap/UI/SettingsModel.swift +++ b/Sources/yap/UI/SettingsModel.swift @@ -63,6 +63,18 @@ final class SettingsModel: ObservableObject { } @Published private(set) var excludedApps: [AppRow] @Published private(set) var routes: [AppRow] + /// What meeting detection has named since launch, newest first — the + /// route list's `+` offers these beside the application picker, because + /// a daemon like avconferenced has no .app for the picker to find. A + /// snapshot at open, like everything else here; in memory only, so the + /// list starts empty at each launch. + let seenClients: [MeetingApp] + private static var seen: [MeetingApp] = [] + + static func noteSeen(_ app: MeetingApp) { + seen.removeAll { $0.bundleID == app.bundleID } + seen.insert(app, at: 0) + } /// Suppresses the write-through while `init` fills the properties in. private var loading = true @@ -91,6 +103,7 @@ final class SettingsModel: ObservableObject { meetingAutoRecord = Config.meetingAutoRecord() excludedApps = Config.meetingExcludedApps().map { Self.resolve($0, detail: nil) } routes = Self.sorted(Config.recordingRoutes().map { Self.resolve($0.key, detail: $0.value) }) + seenClients = Self.seen loading = false updateObserver = Updater.shared.observe { [weak self] state in self?.updateStatus = state.description @@ -130,6 +143,12 @@ final class SettingsModel: ObservableObject { setRoute(bundleID, folder: Self.abbreviated(folder)) } + /// A client detection has already named: no app picker, only the folder. + func addRoute(for app: MeetingApp) { + guard let folder = pickFolder() else { return } + setRoute(app.bundleID, folder: Self.abbreviated(folder)) + } + func changeRouteFolder(_ bundleID: String) { guard let folder = pickFolder() else { return } setRoute(bundleID, folder: Self.abbreviated(folder)) diff --git a/Sources/yap/UI/SettingsPanes.swift b/Sources/yap/UI/SettingsPanes.swift index 0359994..3a3c45f 100644 --- a/Sources/yap/UI/SettingsPanes.swift +++ b/Sources/yap/UI/SettingsPanes.swift @@ -101,12 +101,19 @@ struct RecordingPane: View { model.removeRoute(routeSelection) self.routeSelection = nil }, + // Named by detection since launch: the only way to route + // a daemon, which has no .app for the picker to find. + addChoices: model.seenClients.map { app in + AppList.AddChoice(id: app.bundleID, label: app.name) { + model.addRoute(for: app) + } + }, onActivate: { model.changeRouteFolder($0) } ) } header: { Text("Route by app") } footer: { - Text("Calls detected from these apps are saved here instead of the folder above. Double-click a row to change its folder.") + Text("Calls detected from these apps are saved here instead of the folder above. Double-click a row to change its folder. Anything detection has named since yap started is listed under +, so a background process with no app can be routed too.") .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -186,9 +193,18 @@ private struct AppList: View { let removeHelp: String let onAdd: () -> Void let onRemove: () -> Void + /// Entries offered beside the picker. Any at all turn `+` into a menu, + /// with the picker first; none leaves it the plain button. + var addChoices: [AddChoice] = [] /// Double-click on a row, for lists where a row has something to edit. var onActivate: ((String) -> Void)? = nil + struct AddChoice: Identifiable { + let id: String + let label: String + let action: () -> Void + } + var body: some View { VStack(spacing: 0) { ScrollView { @@ -217,7 +233,23 @@ private struct AppList: View { Divider() HStack(spacing: 0) { - stepper("plus", help: addHelp, action: onAdd) + if addChoices.isEmpty { + stepper("plus", help: addHelp, action: onAdd) + } else { + Menu { + Button("Choose Application…", action: onAdd) + Divider() + ForEach(addChoices) { choice in + Button(choice.label, action: choice.action) + } + } label: { + symbol("plus") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(addHelp) + } Divider().frame(height: 16) stepper("minus", help: removeHelp, action: onRemove) .disabled(selection == nil) @@ -290,14 +322,16 @@ private struct AppList: View { private func stepper( _ symbol: String, help: String, action: @escaping () -> Void ) -> some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: 11, weight: .semibold)) - .frame(width: 30, height: 24) - .contentShape(Rectangle()) - } - .buttonStyle(.borderless) - .help(help) + Button(action: action) { self.symbol(symbol) } + .buttonStyle(.borderless) + .help(help) + } + + private func symbol(_ name: String) -> some View { + Image(systemName: name) + .font(.system(size: 11, weight: .semibold)) + .frame(width: 30, height: 24) + .contentShape(Rectangle()) } }