85ea8fde45
The README still described "Phase 0 (scaffold)" — no audio capture, call detection, screen reading, or backend hand-off — for an app that ships all of it. Rewrite it to document the real detect/record/send/transcribe/recap pipeline, the standalone build+install commands, backend and Start9 Root CA setup (skip-TLS is off by default and host-scoped, not on by default), output files, and the real project layout. Also fix the matching "Phase 0" comment in AppSettings.
155 lines
6.6 KiB
Swift
155 lines
6.6 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
/// User-facing settings, persisted to `UserDefaults`.
|
|
///
|
|
/// Covers the backend host + TLS handling, output folder, your name, chunk
|
|
/// length, per-app adapter toggles, and the auto-record/auto-send/recap flags.
|
|
@MainActor
|
|
final class AppSettings: ObservableObject {
|
|
|
|
/// Adapters the app will eventually run, in display order.
|
|
static let adapterKeys: [(key: String, label: String)] = [
|
|
("zoom", "Zoom"),
|
|
("teams", "Microsoft Teams"),
|
|
("signal", "Signal"),
|
|
("meet", "Google Meet"),
|
|
]
|
|
|
|
@Published var backendBaseURL: String {
|
|
didSet { defaults.set(backendBaseURL, forKey: Keys.backendBaseURL) }
|
|
}
|
|
|
|
@Published var skipTLSVerification: Bool {
|
|
didSet { defaults.set(skipTLSVerification, forKey: Keys.skipTLS) }
|
|
}
|
|
|
|
@Published var outputFolderPath: String {
|
|
didSet { defaults.set(outputFolderPath, forKey: Keys.outputFolder) }
|
|
}
|
|
|
|
@Published var adapterEnabled: [String: Bool] {
|
|
didSet { defaults.set(adapterEnabled, forKey: Keys.adapterEnabled) }
|
|
}
|
|
|
|
@Published var autoRecordOnDetection: Bool {
|
|
didSet { defaults.set(autoRecordOnDetection, forKey: Keys.autoRecord) }
|
|
}
|
|
|
|
/// The user's name, pre-seeded into the timeline for mic-VAD "self" spans.
|
|
@Published var selfName: String {
|
|
didSet { defaults.set(selfName, forKey: Keys.selfName) }
|
|
}
|
|
|
|
/// Auto-send a finished recording to the backend for transcription. Default
|
|
/// off while developing; flip on for hands-free transcripts.
|
|
@Published var autoSendOnStop: Bool {
|
|
didSet { defaults.set(autoSendOnStop, forKey: Keys.autoSend) }
|
|
}
|
|
|
|
/// After transcription, build the readable recap (topic sections + meeting
|
|
/// extras) via the gateway LLM and write transcript.md / recap.html. Best-effort.
|
|
@Published var recapEnabled: Bool {
|
|
didSet { defaults.set(recapEnabled, forKey: Keys.recapEnabled) }
|
|
}
|
|
|
|
/// Reconcile speaker labels after transcription: merge a person split across
|
|
/// chunks (same voiceprint) and name placeholder/initial labels from what the
|
|
/// conversation reveals (gateway LLM). Best-effort.
|
|
@Published var reconcileSpeakers: Bool {
|
|
didSet { defaults.set(reconcileSpeakers, forKey: Keys.reconcileSpeakers) }
|
|
}
|
|
|
|
/// Diarization chunk length (raw value of `ChunkMode`). `.auto` shrinks chunks on
|
|
/// large calls so a window is less likely to exceed Sortformer's ~4-speaker cap.
|
|
@Published var chunkMode: String {
|
|
didSet { defaults.set(chunkMode, forKey: Keys.chunkMode) }
|
|
}
|
|
|
|
/// Typed accessor for `chunkMode`.
|
|
var chunk: ChunkMode { ChunkMode(rawValue: chunkMode) ?? .auto }
|
|
|
|
/// User-editable recap templates (takeaways categories per meeting type).
|
|
@Published var recapTemplates: [RecapTemplate] {
|
|
didSet { persist(recapTemplates, forKey: Keys.recapTemplates) }
|
|
}
|
|
|
|
/// Id of the template used automatically for new recaps.
|
|
@Published var defaultTemplateId: String {
|
|
didSet { defaults.set(defaultTemplateId, forKey: Keys.defaultTemplate) }
|
|
}
|
|
|
|
/// The active default template (falls back to the first available / built-in).
|
|
var defaultTemplate: RecapTemplate {
|
|
recapTemplates.first { $0.id == defaultTemplateId } ?? recapTemplates.first ?? .internalMeeting
|
|
}
|
|
|
|
/// Output folder as a resolved file URL (expands a leading `~`).
|
|
var outputFolderURL: URL {
|
|
URL(fileURLWithPath: (outputFolderPath as NSString).expandingTildeInPath,
|
|
isDirectory: true)
|
|
}
|
|
|
|
private let defaults: UserDefaults
|
|
|
|
/// Neutral placeholder. The real (private LAN) backend host is never committed —
|
|
/// it's entered in Settings (persisted to UserDefaults) or seeded from the
|
|
/// `SPARK_BACKEND_URL` env var for dev/CI/harness runs.
|
|
static let defaultBackendURL = "https://your-spark-backend.local"
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
|
|
// Precedence: a value the user saved in Settings wins; else the env var
|
|
// (handy when launching from Xcode/terminal); else the placeholder.
|
|
self.backendBaseURL = defaults.string(forKey: Keys.backendBaseURL)
|
|
?? ProcessInfo.processInfo.environment["SPARK_BACKEND_URL"]
|
|
?? Self.defaultBackendURL
|
|
|
|
// Off by default: install the Start9 Root CA in the System keychain and the
|
|
// backend's cert validates normally. The bypass is an opt-in escape hatch and,
|
|
// when on, is scoped to the configured host (see `InsecureTrustDelegate`).
|
|
self.skipTLSVerification = defaults.object(forKey: Keys.skipTLS) as? Bool ?? false
|
|
|
|
self.outputFolderPath = defaults.string(forKey: Keys.outputFolder)
|
|
?? "~/Ten31Transcripts"
|
|
|
|
let stored = defaults.dictionary(forKey: Keys.adapterEnabled) as? [String: Bool]
|
|
self.adapterEnabled = stored ?? Dictionary(
|
|
uniqueKeysWithValues: Self.adapterKeys.map { ($0.key, true) }
|
|
)
|
|
|
|
self.autoRecordOnDetection = defaults.object(forKey: Keys.autoRecord) as? Bool ?? true
|
|
self.selfName = defaults.string(forKey: Keys.selfName) ?? "Me"
|
|
self.autoSendOnStop = defaults.object(forKey: Keys.autoSend) as? Bool ?? false
|
|
self.recapEnabled = defaults.object(forKey: Keys.recapEnabled) as? Bool ?? true
|
|
self.reconcileSpeakers = defaults.object(forKey: Keys.reconcileSpeakers) as? Bool ?? true
|
|
self.chunkMode = defaults.string(forKey: Keys.chunkMode) ?? ChunkMode.auto.rawValue
|
|
|
|
let loaded = (defaults.data(forKey: Keys.recapTemplates))
|
|
.flatMap { try? JSONDecoder().decode([RecapTemplate].self, from: $0) }
|
|
self.recapTemplates = (loaded?.isEmpty == false) ? loaded! : RecapTemplate.builtIns
|
|
self.defaultTemplateId = defaults.string(forKey: Keys.defaultTemplate)
|
|
?? RecapTemplate.builtIns.first!.id
|
|
}
|
|
|
|
private func persist<T: Encodable>(_ value: T, forKey key: String) {
|
|
if let data = try? JSONEncoder().encode(value) { defaults.set(data, forKey: key) }
|
|
}
|
|
|
|
private enum Keys {
|
|
static let backendBaseURL = "backendBaseURL"
|
|
static let skipTLS = "skipTLSVerification"
|
|
static let outputFolder = "outputFolderPath"
|
|
static let adapterEnabled = "adapterEnabled"
|
|
static let autoRecord = "autoRecordOnDetection"
|
|
static let selfName = "selfName"
|
|
static let autoSend = "autoSendOnStop"
|
|
static let recapEnabled = "recapEnabled"
|
|
static let reconcileSpeakers = "reconcileSpeakers"
|
|
static let chunkMode = "chunkMode"
|
|
static let recapTemplates = "recapTemplates"
|
|
static let defaultTemplate = "defaultTemplateId"
|
|
}
|
|
}
|