Files
ten31-transcripts/Ten31Transcripts/Settings/AppSettings.swift
T
Grant Gilliam 3629dbdaaa Default TLS validation on; scope skip-TLS bypass to the configured host
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.
2026-06-13 16:02:57 -05:00

155 lines
6.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 34).
@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"
}
}