Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion Sources/Managers/MLDaemonManager+Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
let proc = Process()
proc.executableURL = python
proc.arguments = [script.path]
proc.environment = ProcessInfo.processInfo.environment.merging(["PYTHONUNBUFFERED": "1"]) { _, new in new }
proc.environment = Self.daemonEnvironment()

let stdin = Pipe()
let stdout = Pipe()
Expand Down Expand Up @@ -59,6 +59,46 @@
startStdoutReader(pipe: stdout)
}

/// Builds a minimal, allowlisted environment for the Python daemon instead
/// of inheriting the user's entire process environment (security hardening,
/// audit item E3). The daemon gets exactly what it needs to run Parakeet/MLX
/// transcription and nothing more.
///
/// Always-present keys: `PATH` (subprocess resolution), `HOME` (HuggingFace
/// cache root `~/.cache/huggingface`), and `PYTHONUNBUFFERED` (line-buffered
/// stdout so JSON-RPC responses flush immediately).
///
/// Pass-through-if-set keys: locale (`LANG`/`LC_ALL` — Python text codec),
/// `TMPDIR` (macOS per-user temp many ML libs use), `AUDIOWHISPER_APP_SUPPORT_DIR`
/// (test/override path resolution shared with `UvBootstrap`), virtualenv vars
/// (`VIRTUAL_ENV`, `PYTHONPATH` — keep tooling consistent even though the venv
/// python is invoked directly), `uv` vars (`UV_CACHE_DIR`, `UV_PYTHON`), and
/// HuggingFace cache overrides (`HF_HOME`, `HF_HUB_CACHE`, `HUGGINGFACE_HUB_CACHE`,
/// `XDG_CACHE_HOME`) so the daemon finds models the user already downloaded.
static func daemonEnvironment() -> [String: String] {
let parent = ProcessInfo.processInfo.environment

var env: [String: String] = [
"PATH": parent["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin",

Check warning on line 82 in Sources/Managers/MLDaemonManager+Process.swift

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor your code to get this URI from a customizable parameter.

See more on https://sonarcloud.io/project/issues?id=jtn0123_AudioWhisper&issues=AZ4zLjJDGVej_5Gp6nS1&open=AZ4zLjJDGVej_5Gp6nS1&pullRequest=20
"HOME": parent["HOME"] ?? NSHomeDirectory(),
"PYTHONUNBUFFERED": "1"
]

let passThroughIfSet = [
"LANG", "LC_ALL", "LC_CTYPE", "TMPDIR",
"AUDIOWHISPER_APP_SUPPORT_DIR",
"VIRTUAL_ENV", "PYTHONPATH",
"UV_CACHE_DIR", "UV_PYTHON",
"HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "XDG_CACHE_HOME"
]
for key in passThroughIfSet {
if let value = parent[key], !value.isEmpty {
env[key] = value
}
}
Comment on lines +87 to +98
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding SSL certificate environment variables for corporate network compatibility.

The allowlist doesn't include SSL certificate override variables (SSL_CERT_FILE, SSL_CERT_DIR, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE). In corporate environments with custom CA certificates or MITM proxies, the Python daemon may fail SSL verification when downloading HuggingFace models, since Python's requests/urllib3 check these variables.

This is an edge case—most users rely on system certificates which will work—but corporate users could hit hard-to-diagnose download failures.

🔧 Proposed fix to add SSL certificate pass-through
         let passThroughIfSet = [
             "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR",
             "AUDIOWHISPER_APP_SUPPORT_DIR",
             "VIRTUAL_ENV", "PYTHONPATH",
             "UV_CACHE_DIR", "UV_PYTHON",
-            "HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "XDG_CACHE_HOME"
+            "HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "XDG_CACHE_HOME",
+            "SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"
         ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Managers/MLDaemonManager`+Process.swift around lines 87 - 98, The
environment pass-through array passThroughIfSet in MLDaemonManager+Process.swift
currently omits SSL certificate overrides; update passThroughIfSet to include
"SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", and "CURL_CA_BUNDLE" so
the for loop that reads parent[...] and writes env[...] passes these through to
the Python daemon; ensure the new keys are added alongside the existing
variables used by the env population logic (passThroughIfSet, parent, env) so
corporate/custom CA settings are preserved.

return env
}

func startStdoutReader(pipe: Pipe) {
stdoutReaderTask?.cancel()
let handle = pipe.fileHandleForReading
Expand Down
2 changes: 1 addition & 1 deletion Sources/Services/MLXModelManager+Downloads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ extension MLXModelManager {
// Best-effort integrity verification. TOFU on first hit; failures
// are logged at the call site that triggers a re-download.
do {
try ModelIntegrity.verify(at: refsMain)
try ModelIntegrity.verify(at: refsMain, modelIdentifier: repo)
return true
} catch {
logger.error(
Expand Down
2 changes: 1 addition & 1 deletion Sources/Services/ModelManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ internal class ModelManager {
// missing and a redownload can be triggered.
guard let representative = ModelManager.representativeFileURL(for: model) else { return true }
do {
try ModelIntegrity.verify(at: representative)
try ModelIntegrity.verify(at: representative, modelIdentifier: model.rawValue)
return true
} catch {
Logger.modelManager.error("Integrity check failed for cached \(model.rawValue): \(error.localizedDescription)")
Expand Down
9 changes: 5 additions & 4 deletions Sources/Stores/DataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,11 @@ internal final class DataManager: DataManagerProtocol {
try context.save()

Logger.dataManager.info("Saved transcription record with ID: \(record.id)")

// Perform cleanup after save to maintain retention policy
await cleanupExpiredRecordsQuietly()


// Retention cleanup runs off the save critical path — the caller
// (and the UI) shouldn't wait on a full predicate fetch + delete.
Task { await cleanupExpiredRecordsQuietly() }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider using Task.detached instead of Task for background cleanup.

The PR description mentions a "detached Task," but the code uses unstructured Task { }, which inherits the MainActor context and current task priority. For background cleanup work that shouldn't compete with UI operations, Task.detached { } would be more semantically accurate and would default to a lower priority.

♻️ Suggested change
-            Task { await cleanupExpiredRecordsQuietly() }
+            Task.detached { await self.cleanupExpiredRecordsQuietly() }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Task { await cleanupExpiredRecordsQuietly() }
Task.detached { await self.cleanupExpiredRecordsQuietly() }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Stores/DataManager.swift` at line 169, The call launches an
unstructured Task which inherits the current actor/context; replace Task { await
cleanupExpiredRecordsQuietly() } with a detached background task so the cleanup
doesn't inherit MainActor/foreground priority. Locate the invocation in
DataManager (the cleanupExpiredRecordsQuietly() call) and change it to use
Task.detached { await cleanupExpiredRecordsQuietly() } so the work runs off the
main actor with detached/default priority.


} catch {
Logger.dataManager.error("Failed to save transcription record: \(error.localizedDescription)")
throw DataManagerError.saveFailed(error)
Expand Down
9 changes: 2 additions & 7 deletions Sources/Utilities/AppDefault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import Combine
///
/// See ADR 0004 for the migration plan from `@AppStorage`.
@propertyWrapper
struct AppDefault<Value>: DynamicProperty {
struct AppDefault<Value: Equatable>: DynamicProperty {
private let keyPath: ReferenceWritableKeyPath<AppDefaults.Type, Value>
@State private var value: Value
@StateObject private var observer: AppDefaultObserver
Expand Down Expand Up @@ -45,15 +45,10 @@ struct AppDefault<Value>: DynamicProperty {
// Re-read once per body invocation in case the underlying store changed
// out from under us (e.g. user toggled a setting in another window).
let current = AppDefaults.self[keyPath: keyPath]
if !valuesEqual(current, value) {
if current != value {
DispatchQueue.main.async { self.value = current }
}
}

private func valuesEqual(_ lhsValue: Value, _ rhsValue: Value) -> Bool {
guard let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? AnyHashable else { return false }
return lhs == rhs
}
}

/// Listens for UserDefaults change notifications and triggers a SwiftUI
Expand Down
97 changes: 85 additions & 12 deletions Sources/Utilities/DiskMutationSerializer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,62 @@ internal actor DiskMutationSerializer<Key: Hashable & Sendable> {
///
/// After a successful download, callers record a hash of a representative
/// file (typically a manifest or small config). Before loading a cached
/// model, callers verify against that recorded hash. The check is
/// trust-on-first-use: if no sidecar exists yet, `verify` records one and
/// returns successfully — this keeps existing caches from a pre-integrity
/// build working without forcing a redownload.
/// model, callers verify against that recorded hash.
///
/// Two verification modes (audit item E1):
///
/// 1. **Known-good hash** — for models the app itself ships/recommends
/// (`knownHashes` table below). The representative file's hash is
/// compared against a hash baked into this build, exactly like
/// `UvBootstrap.verifyBundledUvIfNeeded` does for the bundled `uv`
/// binary. A mismatch is a HARD FAIL — this is what prevents a
/// poisoned *first* download from being trusted.
///
/// 2. **Trust-on-first-use** — for user-added models we have no shipped
/// hash for. If no sidecar exists yet, `verify` records one and
/// returns successfully; later launches verify against it. This keeps
/// pre-integrity caches and arbitrary user models working without a
/// forced redownload.
///
/// This is defense in depth, not cryptographic assurance over every byte:
/// TLS already protects downloads in transit. The check guards against
/// cache corruption, partial/interrupted writes that left a truncated
/// file, and tampering by another local process.
internal enum ModelIntegrity {
/// Known-good SHA-256 hashes of the *representative integrity file* for
/// models the app ships or recommends. Keyed by the caller's model
/// identifier: `WhisperModel.rawValue` for WhisperKit models, or the
/// HuggingFace `repo` string for MLX/Parakeet models.
///
/// When an identifier is present here, `verify` uses known-good-hash mode
/// (hard fail on mismatch) instead of trust-on-first-use, so a poisoned
/// first download cannot be silently accepted.
///
/// IMPORTANT: This table is intentionally EMPTY. The real hashes are not
/// known at the time this mechanism was built and fabricating values
/// would be worse than an empty table (it would reject every legitimate
/// download). Populating it later is a one-liner per model, e.g.:
///
/// "openai_whisper-base": "a1b2c3…", // WhisperKit
/// "mlx-community/parakeet-tdt-0.6b-v2": "d4e5f6…", // MLX/Parakeet
///
/// The hash must be the SHA-256 of the representative file that the
/// matching caller passes to `record`/`verify` (`config.json` for
/// WhisperKit via `ModelManager.representativeFileURL`, `refs/main` for
/// MLX via `MLXModelManager.integrityFileURL`). Compute it from a release
/// build's cached download and paste it in here.
///
/// >>> ACTION REQUIRED <<< Populate this table with real release hashes
/// before shipping; until then app-shipped models silently fall through
/// to trust-on-first-use (the documented gap for audit item E1).
static let knownHashes: [String: String] = [:]

/// Returns the known-good hash for `modelIdentifier`, or nil if the model
/// is not one the app ships (user-added model → trust-on-first-use).
static func knownHash(for modelIdentifier: String?) -> String? {
guard let modelIdentifier else { return nil }
return knownHashes[modelIdentifier]
}
/// Compute SHA-256 of file at `url`. Streams the file in 64KB chunks so
/// gigabyte-sized model files don't blow up memory.
static func sha256(of url: URL) throws -> String {
Expand All @@ -69,12 +115,36 @@ internal enum ModelIntegrity {
try hash.write(to: sidecarURL(for: modelURL), atomically: true, encoding: .utf8)
}

/// Verify the sidecar hash matches; if missing, record it
/// (trust-on-first-use). Throws `ModelIntegrityError.mismatch` only
/// when a stored hash exists and the actual hash differs.
static func verify(at modelURL: URL) throws {
let sidecar = sidecarURL(for: modelURL)
/// Verify the integrity of a cached model's representative file.
///
/// If `modelIdentifier` is in `knownHashes` (an app-shipped model), the
/// file's hash is compared against that known-good value and any mismatch
/// is a HARD FAIL — even on the very first download — defeating a poisoned
/// first download. Otherwise falls back to trust-on-first-use against a
/// sidecar hash.
///
/// Throws `ModelIntegrityError.pinnedMismatch` when an app-shipped model
/// fails its known-good hash, or `.mismatch` when a TOFU sidecar differs.
static func verify(at modelURL: URL, modelIdentifier: String? = nil) throws {
let actual = try sha256(of: modelURL)

if let pinned = knownHash(for: modelIdentifier) {
// App-shipped model: verify against the hash baked into this build.
// Hard fail on mismatch — no trust-on-first-use escape hatch.
guard pinned.lowercased() == actual.lowercased() else {
throw ModelIntegrityError.pinnedMismatch(
model: modelIdentifier ?? "<unknown>",
expected: pinned,
actual: actual
)
}
// Keep the sidecar in sync so quick TOFU checks elsewhere agree.
try? actual.write(to: sidecarURL(for: modelURL), atomically: true, encoding: .utf8)
return
}

// User-added model: trust-on-first-use against a sidecar hash.
let sidecar = sidecarURL(for: modelURL)
if let stored = try? String(contentsOf: sidecar, encoding: .utf8)
.trimmingCharacters(in: .whitespacesAndNewlines), !stored.isEmpty {
guard stored.lowercased() == actual.lowercased() else {
Expand All @@ -86,12 +156,12 @@ internal enum ModelIntegrity {
}
}

/// Returns true if a sidecar exists and matches; false on any mismatch,
/// Returns true if integrity verification passes; false on any mismatch,
/// missing-file error, or unreadable file. Never throws — useful for
/// background verification where we don't want to surface noise.
static func quietVerify(at modelURL: URL) -> Bool {
static func quietVerify(at modelURL: URL, modelIdentifier: String? = nil) -> Bool {
do {
try verify(at: modelURL)
try verify(at: modelURL, modelIdentifier: modelIdentifier)
return true
} catch {
return false
Expand All @@ -105,11 +175,14 @@ internal enum ModelIntegrity {

internal enum ModelIntegrityError: LocalizedError {
case mismatch(expected: String, actual: String)
case pinnedMismatch(model: String, expected: String, actual: String)

var errorDescription: String? {
switch self {
case let .mismatch(expected, actual):
return "Model integrity check failed (expected \(expected.prefix(8))…, got \(actual.prefix(8))…). The cached model may be corrupted; re-download it from Settings."
case let .pinnedMismatch(model, expected, actual):
return "Integrity check failed for app-provided model \"\(model)\" (expected \(expected.prefix(8))…, got \(actual.prefix(8))…). The download does not match the version shipped with AudioWhisper and was rejected. Re-download it from Settings or reinstall AudioWhisper from a trusted source."
}
}
}
9 changes: 8 additions & 1 deletion Sources/Views/Components/Waveform/ClassicWaveformView.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import Combine

/// Classic level-based waveform visualization with animated bars.
/// Features gravity-based physics: bars rise quickly, fall with acceleration, and bounce.
Expand All @@ -20,6 +21,10 @@ struct ClassicWaveformView: View {
@State private var velocities: [CGFloat] = []
@State private var idlePhase: CGFloat = 0

// Stoppable per-frame timer: only runs while the view is on-screen so
// idle/off-screen tiles don't burn main-thread cycles.
@State private var frameTimer = FrameTimer(interval: 0.033)

var body: some View {
GeometryReader { geometry in
let totalSpacing = barSpacing * CGFloat(barCount - 1)
Expand All @@ -38,8 +43,10 @@ struct ClassicWaveformView: View {
.onAppear {
barHeights = Array(repeating: minHeight, count: barCount)
velocities = Array(repeating: 0, count: barCount)
frameTimer.start()
}
.onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in
.onDisappear { frameTimer.stop() }
.onReceive(frameTimer.publisher) { _ in
updatePhysics()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct ConstellationWaveformView: View {
let isActive: Bool

@State private var time: CGFloat = 0
@State private var isViewActive = false
@State private var frameTimer = FrameTimer(interval: 0.033)

private let count = 18
private let coral = Color(red: 0.85, green: 0.45, blue: 0.30)
Expand Down Expand Up @@ -65,10 +65,9 @@ struct ConstellationWaveformView: View {
)
}
}
.onAppear { isViewActive = true }
.onDisappear { isViewActive = false }
.onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in
guard isViewActive else { return }
.onAppear { frameTimer.start() }
.onDisappear { frameTimer.stop() }
.onReceive(frameTimer.publisher) { _ in
time += 0.033
}
}
Expand Down
9 changes: 4 additions & 5 deletions Sources/Views/Components/Waveform/DialWaveformView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ struct DialWaveformView: View {
let isActive: Bool

@State private var time: CGFloat = 0
@State private var isViewActive = false
@State private var frameTimer = FrameTimer(interval: 0.033)

private let dotCount = 36
private let coral = Color(red: 0.85, green: 0.45, blue: 0.30)
Expand Down Expand Up @@ -80,10 +80,9 @@ struct DialWaveformView: View {
}
.position(center)
}
.onAppear { isViewActive = true }
.onDisappear { isViewActive = false }
.onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in
guard isViewActive else { return }
.onAppear { frameTimer.start() }
.onDisappear { frameTimer.stop() }
.onReceive(frameTimer.publisher) { _ in
time += 0.033
}
}
Expand Down
39 changes: 39 additions & 0 deletions Sources/Views/Components/Waveform/FrameTimer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import SwiftUI
import Combine

/// A stoppable per-frame timer for waveform animations.
///
/// `Timer.publish(...).autoconnect()` runs forever the moment a view is
/// created — even for waveform tiles that are off-screen or in an idle state.
/// With the Visuals grid showing 8 styles at once that is hundreds of
/// main-thread state mutations per second while nothing is visible.
///
/// `FrameTimer` instead exposes a passthrough `publisher` that only emits
/// while `start()` has been called and `stop()` has not. Views drive it from
/// `onAppear`/`onDisappear`, so the timer is fully cancelled — not merely
/// ignored — whenever the view leaves the screen.
final class FrameTimer {
/// Per-frame tick stream. Emits only between `start()` and `stop()`.
let publisher = PassthroughSubject<Date, Never>()

private let interval: TimeInterval
private var cancellable: AnyCancellable?

init(interval: TimeInterval) {
self.interval = interval
}

/// Begin emitting ticks. No-op if already running.
func start() {
guard cancellable == nil else { return }
cancellable = Timer.publish(every: interval, on: .main, in: .common)
.autoconnect()
.sink { [publisher] date in publisher.send(date) }
}

/// Stop emitting ticks and cancel the underlying timer.
func stop() {
cancellable?.cancel()
cancellable = nil
}
Comment on lines +15 to +38
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# Find all usages of FrameTimer
rg "FrameTimer" --type swift -B 2 -A 2

Repository: jtn0123/AudioWhisper

Length of output: 4861


🏁 Script executed:

# Check file structure to understand if this is a UI component
fd -e swift Sources/Views/Components/Waveform/ | head -20

Repository: jtn0123/AudioWhisper

Length of output: 506


🏁 Script executed:

# Look for similar timer patterns in the codebase with `@MainActor`
rg "`@MainActor`" --type swift -A 3 -B 1

Repository: jtn0123/AudioWhisper

Length of output: 50377


🏁 Script executed:

# Check closure retention patterns in the codebase
rg "sink.*\[.*\].*in" --type swift | head -20

Repository: jtn0123/AudioWhisper

Length of output: 365


Consider adding @MainActor isolation to FrameTimer for consistency with similar utility classes.

While not strictly required by the guideline (which targets UI components), marking FrameTimer with @MainActor aligns with the pattern used by similar helper classes in the codebase like MicTestCapture and LivePreviewSampler, and makes explicit that the timer's mutable state is main-actor-isolated.

Suggested change
+@MainActor
 final class FrameTimer {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Views/Components/Waveform/FrameTimer.swift` around lines 15 - 38, Add
MainActor isolation to the FrameTimer utility by annotating the FrameTimer type
with `@MainActor` so its mutable state (interval, cancellable, publisher) and
methods start() / stop() are executed on the main actor; update any call sites
if needed to await or hop to the main actor when constructing or invoking
FrameTimer, and ensure references to publisher, cancellable, start(), stop(),
and init(interval:) remain consistent after the annotation.

}
9 changes: 4 additions & 5 deletions Sources/Views/Components/Waveform/HaloWaveformView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ struct HaloWaveformView: View {
let isActive: Bool

@State private var time: CGFloat = 0
@State private var isViewActive = false
@State private var frameTimer = FrameTimer(interval: 0.033)

private let barCount = 28
private let coral = Color(red: 0.85, green: 0.45, blue: 0.30)
Expand Down Expand Up @@ -108,10 +108,9 @@ struct HaloWaveformView: View {
}
.position(center)
}
.onAppear { isViewActive = true }
.onDisappear { isViewActive = false }
.onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in
guard isViewActive else { return }
.onAppear { frameTimer.start() }
.onDisappear { frameTimer.stop() }
.onReceive(frameTimer.publisher) { _ in
time += 0.033
}
}
Expand Down
Loading
Loading