Compare commits
2 Commits
5bed24a454
...
ddee2c4871
| Author | SHA1 | Date | |
|---|---|---|---|
| ddee2c4871 | |||
| c44a97575c |
@@ -7,7 +7,7 @@ Native macOS **menu-bar app** that detects video calls, records dual-track audio
|
|||||||
- Project is generated by **XcodeGen** from `project.yml` (`brew install xcodegen`). `*.xcodeproj` is **gitignored** — regenerate, don't edit.
|
- Project is generated by **XcodeGen** from `project.yml` (`brew install xcodegen`). `*.xcodeproj` is **gitignored** — regenerate, don't edit.
|
||||||
- Full Xcode lives at `/Applications/Xcode.app`, but `xcode-select` points at CommandLineTools → **set `DEVELOPER_DIR` for every `xcodebuild`**.
|
- Full Xcode lives at `/Applications/Xcode.app`, but `xcode-select` points at CommandLineTools → **set `DEVELOPER_DIR` for every `xcodebuild`**.
|
||||||
- Bundle id `xyz.ten31.transcripts`; `DEVELOPMENT_TEAM` (Apple Team ID) is set in a **gitignored `Config/Signing.xcconfig`** (copy `Config/Signing.xcconfig.example` and set your team). Keep it stable — a constant signing identity is what preserves TCC grants across rebuilds.
|
- Bundle id `xyz.ten31.transcripts`; `DEVELOPMENT_TEAM` (Apple Team ID) is set in a **gitignored `Config/Signing.xcconfig`** (copy `Config/Signing.xcconfig.example` and set your team). Keep it stable — a constant signing identity is what preserves TCC grants across rebuilds.
|
||||||
- Backend: SparkControl gateway at `$SPARK_BACKEND_URL` (a private LAN `.local` host; self-signed cert, so TLS-skip is intentional). Resolution order: a value saved in **Settings → SparkControl backend** (UserDefaults) wins, else the `SPARK_BACKEND_URL` env var, else the placeholder default in `AppSettings.swift`. Diarization = Sortformer/TitaNet (**mono-only**, ~4 speakers/chunk); LLM = Qwen3 via OpenAI-compatible `/v1/chat/completions`; audio via `/api/audio/label-merge`.
|
- Backend: SparkControl gateway at `$SPARK_BACKEND_URL` (a private LAN backend — IP or `.local` host; Start9 self-signed cert. Install the StartOS Root CA in the System keychain so normal TLS validation succeeds; skip-TLS is an opt-in, **host-scoped** escape hatch, **off by default** — see `InsecureTrustDelegate`). Resolution order: a value saved in **Settings → SparkControl backend** (UserDefaults) wins, else the `SPARK_BACKEND_URL` env var, else the placeholder default in `AppSettings.swift`. Diarization = Sortformer/TitaNet (**mono-only**, ~4 speakers/chunk); LLM = Qwen3 via OpenAI-compatible `/v1/chat/completions`; audio via `/api/audio/label-merge`.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
First time on a machine — create the local signing config (else `xcodegen generate`/signing won't find a team):
|
First time on a machine — create the local signing config (else `xcodegen generate`/signing won't find a team):
|
||||||
@@ -82,13 +82,14 @@ open /Applications/Ten31Transcripts.app
|
|||||||
- Never commit to `main` or force-push a shared branch; branch first and ask.
|
- Never commit to `main` or force-push a shared branch; branch first and ask.
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
Present tense; overwritten each session. 69 tests pass; `/Applications/Ten31Transcripts.app` matches HEAD and runs; working tree clean and pushed to `origin`/`main`. A full independent evaluation ran 2026-06-13 → `EVALUATION.md` (committed at repo root; overwritten + re-committed each run for a reviewable diff); its findings are triaged into the lists below.
|
Present tense; overwritten each session. 73 tests pass; `/Applications/Ten31Transcripts.app` matches HEAD and runs; working tree clean and pushed to `origin`/`main`. A full independent evaluation ran 2026-06-13 → `EVALUATION.md` (committed at repo root; overwritten + re-committed each run for a reviewable diff); its findings are triaged into the lists below. The eval's P1 (TLS) is now **fixed** and verified against the live backend.
|
||||||
- **Working:** call detection (Meet/Zoom/Teams/Signal), dual-track capture, dual-channel + chunked backend hand-off, speaker reconciliation, recap (`transcript.md` + recap-relay-styled `recap.html`), speaker editor, configurable chunk length, standalone Settings window.
|
- **Working:** call detection (Meet/Zoom/Teams/Signal), dual-track capture, dual-channel + chunked backend hand-off, speaker reconciliation, recap (`transcript.md` + recap-relay-styled `recap.html`), speaker editor, configurable chunk length, standalone Settings window.
|
||||||
- **In progress:** the Meet visual fix (reject solid camera-off tiles) is unverified end-to-end — no clean run exists yet; the saved Meet session's `visual_timeline.json` predates the fix.
|
- **In progress:** the Meet visual fix (reject solid camera-off tiles) is unverified end-to-end — no clean run exists yet; the saved Meet session's `visual_timeline.json` predates the fix.
|
||||||
- **Work queue (P1 — do first):** the TLS-trust override is global and on by default — it returns `URLCredential(trust:)` for *any* host (`InsecureTrustDelegate.swift:22`; default-on at `AppSettings.swift:109`), so the full mic+system audio, visual timeline, and voiceprint upload is MITM-able by anyone on the LAN. Scope the override to the configured backend host and pin the Start9 root CA (or the leaf SPKI hash); default skip-TLS to off. This gates trusting any later backend-integration test.
|
- **Done this session (was eval P1):** TLS validation is now **on by default** and the skip-TLS escape hatch is **scoped to the configured host** (`InsecureTrustDelegate.allowsTrustOverride`, covered by `InsecureTrustDelegateTests`). Supported path = the StartOS Root CA trusted in the System keychain; verified `URLSession` default validation returns 200 against both the primary backend IP and its fallback.
|
||||||
|
- **Work queue (next up):** wire the backend URL + primary→fallback into config. Today it's a single `backendBaseURL` with no fallback logic, and on this Mac no value is saved (so it resolves to the `your-spark-backend.local` placeholder); the real setup is a primary LAN IP with a fallback IP (both port 62419) — the actual addresses live in Settings/UserDefaults, never source.
|
||||||
- **Known debt (P2 — fix before wider use):**
|
- **Known debt (P2 — fix before wider use):**
|
||||||
- `RecapAnalyzer.mmss()` fatally crashes on NaN/∞ (reproduced 2×); a malformed/MITM'd backend `duration` (e.g. `1e400` → `Double.infinity`) aborts the app at recap-render time — add a finite-guard fallback (`RecapAnalyzer.swift:137`).
|
- `RecapAnalyzer.mmss()` fatally crashes on NaN/∞ (reproduced 2×); a malformed/MITM'd backend `duration` (e.g. `1e400` → `Double.infinity`) aborts the app at recap-render time — add a finite-guard fallback (`RecapAnalyzer.swift:137`).
|
||||||
- README is stale by six phases — still says "Phase 0 (scaffold) / no audio capture, detection, or backend hand-off yet" for a shipped Phase-6 app; same lie in source comment `AppSettings.swift:7`. Rewrite both to match reality.
|
- README is stale by six phases — still says "Phase 0 (scaffold) / no audio capture, detection, or backend hand-off yet" for a shipped Phase-6 app; same lie in source comment `AppSettings.swift:7`; and `README.md:49` still calls skip-TLS "on by default" (now off). Rewrite to match reality.
|
||||||
- `SessionController` (670 lines, the most concurrency-dense file) has zero unit tests — cover `pendingAutoStop` (auto-start-then-immediate-call-end) and the visual-adoption generation guard before any refactor.
|
- `SessionController` (670 lines, the most concurrency-dense file) has zero unit tests — cover `pendingAutoStop` (auto-start-then-immediate-call-end) and the visual-adoption generation guard before any refactor.
|
||||||
- **Deferred (P3 — later decision or bulk cleanup; full evidence in `EVALUATION.md`):** `docs/` specs drifted from the dual-channel API + recap phase; `docs/01` §7 lists already-resolved open items; `docs/02` §2.10 claims MenuBarUI features that don't exist; AGENTS.md Layout listings under `Audio/`/`Detection/` are incomplete; the `manifest.json` sha256 contract is specced but never written; env-var precedence footgun (saved URL shadows `SPARK_BACKEND_URL`); `SessionController` owns three jobs (extract the open-panel UI); unused `NSAppleEventsUsageDescription`; unauthenticated LAN backend (consider a shared bearer token).
|
- **Deferred (P3 — later decision or bulk cleanup; full evidence in `EVALUATION.md`):** `docs/` specs drifted from the dual-channel API + recap phase; `docs/01` §7 lists already-resolved open items; `docs/02` §2.10 claims MenuBarUI features that don't exist; AGENTS.md Layout listings under `Audio/`/`Detection/` are incomplete; the `manifest.json` sha256 contract is specced but never written; env-var precedence footgun (saved URL shadows `SPARK_BACKEND_URL`); `SessionController` owns three jobs (extract the open-panel UI); unused `NSAppleEventsUsageDescription`; unauthenticated LAN backend (consider a shared bearer token).
|
||||||
- **Known bugs:** Meet speaking-detection is sparse (faint blue border); the mic channel emits some sub-second junk "self" fragments; the same person on desktop-mic vs phone-speakerphone does not unify by voiceprint.
|
- **Known bugs:** Meet speaking-detection is sparse (faint blue border); the mic channel emits some sub-second junk "self" fragments; the same person on desktop-mic vs phone-speakerphone does not unify by voiceprint.
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ final class GatewayLLMClient {
|
|||||||
config.timeoutIntervalForRequest = 600
|
config.timeoutIntervalForRequest = 600
|
||||||
config.timeoutIntervalForResource = 900
|
config.timeoutIntervalForResource = 900
|
||||||
config.waitsForConnectivity = false
|
config.waitsForConnectivity = false
|
||||||
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
|
let delegate: URLSessionDelegate? = skipTLS
|
||||||
|
? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host)
|
||||||
|
: nil
|
||||||
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,42 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// URLSession delegate that trusts the server certificate without validation.
|
/// URLSession delegate that bypasses certificate validation for **one host only**
|
||||||
|
/// — the configured SparkControl backend.
|
||||||
///
|
///
|
||||||
/// SparkControl sits behind a Start9 self-signed Root CA on the LAN, so default
|
/// SparkControl sits behind a Start9 self-signed Root CA on the LAN. The supported
|
||||||
/// trust evaluation rejects it. This delegate is used **only** when the
|
/// path is to install that CA in the System keychain; default trust evaluation then
|
||||||
/// "Skip TLS verification" setting is on. It trusts any server certificate —
|
/// succeeds and this delegate is never used. It exists only as an opt-in escape
|
||||||
/// acceptable for a personal tool on a trusted local network and nothing else.
|
/// hatch (the "Skip TLS verification" setting, off by default) for a machine where
|
||||||
|
/// the CA isn't installed. Even then it trusts a certificate only when the challenge
|
||||||
|
/// host equals `allowedHost` — a server-trust challenge from any other host falls
|
||||||
|
/// back to default validation, so the bypass can never become "trust any server".
|
||||||
final class InsecureTrustDelegate: NSObject, URLSessionDelegate {
|
final class InsecureTrustDelegate: NSObject, URLSessionDelegate {
|
||||||
|
/// The single host the bypass is scoped to (the configured backend host). When
|
||||||
|
/// nil — only reachable via a malformed base URL — the gate never fires and every
|
||||||
|
/// challenge falls back to default validation: the safe degenerate case.
|
||||||
|
private let allowedHost: String?
|
||||||
|
|
||||||
|
init(allowedHost: String?) {
|
||||||
|
self.allowedHost = allowedHost
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The security gate: the trust override may fire only for a server-trust
|
||||||
|
/// challenge whose host matches `allowedHost`. Pure and synchronous so the
|
||||||
|
/// host-scoping can be unit-tested without fabricating a `SecTrust`; the
|
||||||
|
/// credential itself is built only when this is true *and* a serverTrust exists.
|
||||||
|
func allowsTrustOverride(for space: URLProtectionSpace) -> Bool {
|
||||||
|
guard let allowedHost else { return false }
|
||||||
|
return space.authenticationMethod == NSURLAuthenticationMethodServerTrust
|
||||||
|
&& space.host == allowedHost
|
||||||
|
}
|
||||||
|
|
||||||
func urlSession(
|
func urlSession(
|
||||||
_ session: URLSession,
|
_ session: URLSession,
|
||||||
didReceive challenge: URLAuthenticationChallenge,
|
didReceive challenge: URLAuthenticationChallenge,
|
||||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||||
) {
|
) {
|
||||||
guard
|
guard
|
||||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
allowsTrustOverride(for: challenge.protectionSpace),
|
||||||
let serverTrust = challenge.protectionSpace.serverTrust
|
let serverTrust = challenge.protectionSpace.serverTrust
|
||||||
else {
|
else {
|
||||||
completionHandler(.performDefaultHandling, nil)
|
completionHandler(.performDefaultHandling, nil)
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ final class SparkControlClient {
|
|||||||
config.timeoutIntervalForRequest = 600 // diarization can take up to ~600s
|
config.timeoutIntervalForRequest = 600 // diarization can take up to ~600s
|
||||||
config.timeoutIntervalForResource = 900
|
config.timeoutIntervalForResource = 900
|
||||||
config.waitsForConnectivity = false
|
config.waitsForConnectivity = false
|
||||||
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
|
let delegate: URLSessionDelegate? = skipTLS
|
||||||
|
? InsecureTrustDelegate(allowedHost: URL(string: self.baseURL)?.host)
|
||||||
|
: nil
|
||||||
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
self.urlSession = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ final class SparkControlHealth: ObservableObject {
|
|||||||
config.timeoutIntervalForRequest = 8
|
config.timeoutIntervalForRequest = 8
|
||||||
config.waitsForConnectivity = false
|
config.waitsForConnectivity = false
|
||||||
|
|
||||||
let delegate: URLSessionDelegate? = skipTLS ? InsecureTrustDelegate() : nil
|
let delegate: URLSessionDelegate? = skipTLS
|
||||||
|
? InsecureTrustDelegate(allowedHost: url.host)
|
||||||
|
: nil
|
||||||
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
let session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
|
||||||
defer { session.finishTasksAndInvalidate() }
|
defer { session.finishTasksAndInvalidate() }
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,10 @@ final class AppSettings: ObservableObject {
|
|||||||
?? ProcessInfo.processInfo.environment["SPARK_BACKEND_URL"]
|
?? ProcessInfo.processInfo.environment["SPARK_BACKEND_URL"]
|
||||||
?? Self.defaultBackendURL
|
?? Self.defaultBackendURL
|
||||||
|
|
||||||
self.skipTLSVerification = defaults.object(forKey: Keys.skipTLS) as? Bool ?? true
|
// 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)
|
self.outputFolderPath = defaults.string(forKey: Keys.outputFolder)
|
||||||
?? "~/Ten31Transcripts"
|
?? "~/Ten31Transcripts"
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import Ten31Transcripts
|
||||||
|
|
||||||
|
/// The TLS bypass is an opt-in escape hatch scoped to the configured backend host.
|
||||||
|
/// These cover the security gate (`allowsTrustOverride`) so a regression can't widen
|
||||||
|
/// it back to "trust any server". The gate is pure, so no network or SecTrust needed.
|
||||||
|
final class InsecureTrustDelegateTests: XCTestCase {
|
||||||
|
private func space(host: String,
|
||||||
|
method: String = NSURLAuthenticationMethodServerTrust) -> URLProtectionSpace {
|
||||||
|
URLProtectionSpace(host: host, port: 62419, protocol: "https",
|
||||||
|
realm: nil, authenticationMethod: method)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFiresForMatchingHost() {
|
||||||
|
let d = InsecureTrustDelegate(allowedHost: "192.0.2.1")
|
||||||
|
XCTAssertTrue(d.allowsTrustOverride(for: space(host: "192.0.2.1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRejectsMismatchedHost() {
|
||||||
|
let d = InsecureTrustDelegate(allowedHost: "192.0.2.1")
|
||||||
|
XCTAssertFalse(d.allowsTrustOverride(for: space(host: "evil.example.com")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNilAllowedHostNeverFires() {
|
||||||
|
let d = InsecureTrustDelegate(allowedHost: nil)
|
||||||
|
XCTAssertFalse(d.allowsTrustOverride(for: space(host: "192.0.2.1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOnlyServerTrustMethodFires() {
|
||||||
|
// Matching host but a non-server-trust challenge (e.g. HTTP Basic) must not override.
|
||||||
|
let d = InsecureTrustDelegate(allowedHost: "192.0.2.1")
|
||||||
|
XCTAssertFalse(d.allowsTrustOverride(
|
||||||
|
for: space(host: "192.0.2.1", method: NSURLAuthenticationMethodHTTPBasic)))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user