3629dbdaaa
The app shipped with certificate validation bypassed globally and on by default — InsecureTrustDelegate trusted any cert from any host. That was the evaluation's P1: anyone on the LAN could MITM call audio, transcripts, and voiceprints. The backend's Start9 cert already validates under normal system trust when the StartOS Root CA is installed in the keychain (confirmed: URLSession default validation returns 200 against the backend and its fallback), so the bypass is unnecessary: - skip-TLS now defaults to off - when explicitly enabled, the bypass is scoped to the configured host via InsecureTrustDelegate.allowsTrustOverride, never "trust any server" - the host gate is pure and unit-tested (InsecureTrustDelegateTests) Docs reconciled: AGENTS.md backend/TLS line and Current state.
155 lines
6.6 KiB
Swift
155 lines
6.6 KiB
Swift
import Foundation
|
||
import Combine
|
||
|
||
/// User-facing settings, persisted to `UserDefaults`.
|
||
///
|
||
/// Phase 0 scope: backend host + TLS-skip, output folder, and adapter toggles.
|
||
/// The adapter toggles persist but do nothing yet (adapters arrive in Phase 3–4).
|
||
@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"
|
||
}
|
||
}
|