Skip to content

Commit 51de36c

Browse files
authored
fix(desktop): macOS bug audit cycle 3 follow-up — Bee audio, conversation cache, installer atomicity, floating bar, WAL sync (#10047)
## macOS bug audit — cycle 3 follow-up (deferred low-severity set) Follow-up to the merged cycle-3 audit (#10035). Cycle 3 fixed the high-severity crash/data-loss/leak/security findings and consciously deferred a set of lower-severity items. This PR completes the deferred findings that have a clean, hermetically-testable seam — each re-verified against current `main` before fixing. ### Fixes 1. **Bee audio frame drift (C6)** — `processAudioPacket` popped only the first ADTS frame per BLE notification; a notification completing two frames emitted the second one notification late (~64ms/frame, cumulative) and discarded surplus complete frames on stop. Now drains every complete frame per notification. Regression test feeds a two-frame packet and asserts both drain in order. 2. **Conversation optimistic-edit revert (C24)** — the cache-first `load()` published bare cached rows without overlaying pending optimistic mutations, so a filter change mid-mutation visually reverted an in-flight star/title/folder edit. Cache emit now overlays `pendingMutations` (mirrors the server merge path). Hermetic test suspends a star mutation and asserts the cache reload keeps it. 3. **Non-atomic app install (C14)** — the DMG install did `removeItem` then `copyItem`; a copy failure after the remove destroyed the user's working `/Applications/Omi.app`. `replaceInstalledApp` now stages then `replaceItemAt`-swaps, leaving the existing install untouched on failure. Real-FileManager temp-dir tests assert survival on copy failure + successful swap. 4. **Floating-bar pill drift (C10)** — `savePreChatCenterIfNeeded` snapped the draggable pill's restore origin (computed for the glow-inflated window) using the bare pill size, drifting the recorded center ~22pt left / 18pt down per rapid re-open cycle. Now stores + snaps the full restore frame; restore-origin math routes through pure `FloatingControlBarGeometry` helpers with a round-trip identity test. 5. **WAL sync stuck on auth failure (C1)** — `fetchSyncJobStatus` swallowed an auth-header failure with `try?` (sending an unauthenticated GET) and mapped 401 → `.transient`, parking the WAL in a forever-retry. Now returns a distinct `.unauthorized` (pure `forStatusCode` mapping) without an unauthenticated request; the reconciler reverts to `.miss` for re-upload via the authenticated path (poll stays session-preserving, INV-AUTH-1). 6. **Unbounded WAL retention (C19)** — `cleanupOldWals` was dead code, so synced audio + metadata grew without bound. Now invoked at the end of each `syncToCloud`, selecting reclaimable entries via a pure `WALCloudSyncLogic.cleanupCandidates` (synced-only, past the 7-day window) using the injected clock. ### Verification - Hermetic regression tests added for every fix (behavioral where a production seam exists; pure-function tests for the mapping/geometry/retention predicates). - Swift compiles/tests only in CI (no Swift toolchain in this sandbox). Local static gates green: `check_desktop_test_quality.py`, line-count ratchet (FloatingControlBarWindow baseline raised with justification), e2e flow-coverage (AppInstaller added to `desktop-update-relaunch` covers). ### Product invariants affected - INV-AUTH-1 - INV-CHAT-1 C1 names INV-AUTH-1 because the WAL status poll is a background, session-preserving request: on an auth failure it throws `.unauthorized` and reverts the WAL for re-upload through the authenticated path — it does not invalidate the Firebase session (that ownership stays with `AuthSessionCoordinator`). No session-death path changes. INV-CHAT-1 is named because the C10 pill-drift fix touches `FloatingControlBarWindow.swift` / `FloatingControlBarGeometry.swift` (path globs). The change is presentation geometry only — it stores and snaps the full restore *frame* instead of pairing an inflated origin with the bare pill size. It does not touch any chat write-path surface: no `ChatProvider`, kernel journal/projection, floating viewport cursor, turn identity, or pill projection code changes. The floating bar's chat continuity contract is unaffected — only where the collapsed pill window comes to rest on screen. ### Failure class (fixes) Failure-Class: none Independent instance fixes across unrelated subsystems (Bluetooth audio, conversations cache, installer, floating bar, WAL sync); none recurs against a registered failure class. ### Deferred (not in this PR) - **C27** BYOK keys → Keychain migration: security-sensitive migration, unverifiable without a macOS Keychain here; needs a dedicated PR + on-device proof + sign-off. - **C8** notch cursor-follow: the pure decision seam is testable, but the state-desync side effect has a residual on-device gap (verified by investigation); needs a notched Mac. - **C5** dormant `AudioSourceManager`: unreachable dead code; the correct resolution is delete + relocate the still-live `AudioSource` enum across many files, which needs a local Swift compile. - **S1** voice-turn watchdog: INV-VOICE-1 reducer logic; needs the macOS live continuity/voice gauntlet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Generated by [Claude Code](https://claude.ai/code/session_01RAxLsaxtwVNDTWAZFfJp9q)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/BasedHardware/omi/pull/10047?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
2 parents bb32b75 + be1150f commit 51de36c

24 files changed

Lines changed: 419 additions & 62 deletions

.github/scripts/product_file_line_count_ratchet_baseline/desktop-swift-floatingcontrolbar.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
"files": {
33
"desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": 2375,
44
"desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": 2832,
5-
"desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4797,
5+
"desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": 4806,
66
"desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": 2423,
77
"desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": 1621
88
},
99
"raise_justifications": {
1010
"desktop/macos/Desktop/Sources/FloatingControlBar/AgentPill.swift": "Agent completion presentation now follows the canonical terminal lifecycle used by PTT and chat.",
1111
"desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarView.swift": "Notch hover and voice presentation now share a private surface-state boundary; extracting it would broaden view-local ownership.",
12-
"desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.",
12+
"desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift": "savePreChatCenterIfNeeded now snaps to the full stored restore frame (origin + glow-inflated size) instead of pairing the inflated-origin with the bare pill size, so the draggable pill no longer drifts ~22pt left / 18pt down per rapid re-open cycle.",
1313
"desktop/macos/Desktop/Sources/FloatingControlBar/PushToTalkManager.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior.",
1414
"desktop/macos/Desktop/Sources/FloatingControlBar/RealtimeHubSession.swift": "Strict concurrency annotations (Ticket 14) add Sendable conformances and isolation qualifiers without changing runtime behavior."
1515
},

desktop/macos/Desktop/Sources/Bluetooth/Connections/BeeDeviceConnection.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ final class BeeDeviceConnection: BaseDeviceConnection {
105105
audioSubscription = Task { [weak self] in
106106
do {
107107
for try await data in stream {
108-
if let frame = self?.processAudioPacket(Array(data)) {
108+
for frame in self?.processAudioPacket(Array(data)) ?? [] {
109109
self?.audioStreamController.yield(Data(frame))
110110
}
111111
}
@@ -148,12 +148,24 @@ final class BeeDeviceConnection: BaseDeviceConnection {
148148

149149
// MARK: - Audio Processing
150150

151-
/// Process AAC audio packet with ADTS framing
152-
func processAudioPacket(_ data: [UInt8]) -> [UInt8]? {
153-
guard data.count >= 2 else { return nil }
151+
/// Process AAC audio packet with ADTS framing.
152+
///
153+
/// Drains **every** complete ADTS frame the notification completed, not just
154+
/// the first. A single notification can carry (or complete, after resync)
155+
/// more than one frame; returning only the first left the rest buffered until
156+
/// the next notification arrived — shifting all subsequent audio ~64ms/frame
157+
/// late and discarding any surplus complete frames on stop. A genuinely
158+
/// partial trailing frame still stays buffered (nextADTSFrame returns nil),
159+
/// so it is completed by the next notification as before.
160+
func processAudioPacket(_ data: [UInt8]) -> [[UInt8]] {
161+
guard data.count >= 2 else { return [] }
154162

155163
audioBuffer.append(contentsOf: data.dropFirst(2))
156-
return Self.nextADTSFrame(from: &audioBuffer)
164+
var frames: [[UInt8]] = []
165+
while let frame = Self.nextADTSFrame(from: &audioBuffer) {
166+
frames.append(frame)
167+
}
168+
return frames
157169
}
158170

159171
/// Pop the next complete ADTS/AAC frame from `buffer`, skipping leading

desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarGeometry.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,20 @@ enum FloatingControlBarGeometry {
2626
case preservingCurrentCenter
2727
}
2828

29+
/// Bottom-left origin that centers a window of `size` on `center`.
30+
static func restoreOrigin(center: NSPoint, size: NSSize) -> NSPoint {
31+
NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2)
32+
}
33+
34+
/// The center a window ends up reporting (`frame.midX/midY`) after it is
35+
/// snapped to `origin` with `size`. Round-trips with `restoreOrigin` ONLY when
36+
/// the same `size` is used for both: computing the origin with a glow-inflated
37+
/// size but snapping with the bare pill size shifts the recorded center by half
38+
/// the size difference — the pill-drift bug.
39+
static func recordedCenter(afterSnapOrigin origin: NSPoint, size: NSSize) -> NSPoint {
40+
NSPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2)
41+
}
42+
2943
static func centerAnchoredFrame(currentFrame: NSRect, targetSize: NSSize) -> NSRect {
3044
NSRect(
3145
x: currentFrame.midX - targetSize.width / 2,

desktop/macos/Desktop/Sources/FloatingControlBar/FloatingControlBarWindow.swift

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate {
202202
/// closeAIConversation() and cleared when the animation settles.
203203
/// Used by savePreChatCenterIfNeeded() to snap to the correct pill position
204204
/// if a new PTT query fires while the restore animation is still running.
205-
private var pendingRestoreOrigin: NSPoint?
205+
// Stores the FULL pending restore frame (origin AND size), not just the origin.
206+
// The restore origin is computed for the glow-inflated window size; snapping to
207+
// it with the bare collapsed size instead drifted the recorded center by one
208+
// glow outset (~22pt left / 18pt down) on every rapid re-open cycle.
209+
private var pendingRestoreFrame: NSRect?
206210
/// The idle pill frame captured just before morphing into the active island
207211
/// on a non-notch display, so the pill returns to the exact same spot.
208212
private var savedPillFrame: NSRect?
@@ -1301,9 +1305,10 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate {
13011305
if !ShortcutSettings.shared.draggableBarEnabled || notchModeEnabled {
13021306
restoreOrigin = defaultTopCenteredFrame(for: size).origin
13031307
} else if let center = preChatCenter {
1304-
restoreOrigin = NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2)
1308+
restoreOrigin = FloatingControlBarGeometry.restoreOrigin(center: center, size: size)
13051309
} else {
1306-
restoreOrigin = NSPoint(x: frame.midX - size.width / 2, y: frame.midY - size.height / 2)
1310+
restoreOrigin = FloatingControlBarGeometry.restoreOrigin(
1311+
center: NSPoint(x: frame.midX, y: frame.midY), size: size)
13071312
}
13081313

13091314
resizeWorkItem?.cancel()
@@ -1312,15 +1317,15 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate {
13121317
isResizingProgrammatically = true
13131318
// Record the animation target so savePreChatCenterIfNeeded() can snap to it
13141319
// if a new PTT query fires while this restore animation is still running.
1315-
pendingRestoreOrigin = restoreOrigin
1316-
animateFrame(to: NSRect(origin: restoreOrigin, size: size), duration: Self.askOmiAnimationDuration)
13171320
let targetFrame = NSRect(origin: restoreOrigin, size: size)
1321+
pendingRestoreFrame = targetFrame
1322+
animateFrame(to: targetFrame, duration: Self.askOmiAnimationDuration)
13181323
preChatCenter = nil
13191324
DispatchQueue.main.asyncAfter(deadline: .now() + Self.askOmiSettleDelay) { [weak self] in
13201325
guard let self = self else { return }
13211326
guard self.resignKeyAnimationToken == closeAnimationToken else { return }
13221327
self.isResizingProgrammatically = false
1323-
self.pendingRestoreOrigin = nil
1328+
self.pendingRestoreFrame = nil
13241329
// Safety net: only snap if no new AI session was opened while the close settled.
13251330
// Without this guard, a rapid PTT query that fires while close settles gets collapsed
13261331
// back to the pill position by this stale completion block.
@@ -2160,7 +2165,7 @@ class FloatingControlBarWindow: NSPanel, NSWindowDelegate {
21602165
)
21612166
return !state.showingAIConversation
21622167
&& !suppressHoverResize
2163-
&& pendingRestoreOrigin == nil
2168+
&& pendingRestoreFrame == nil
21642169
&& NSEqualSizes(frame.size, settledSize)
21652170
}
21662171

@@ -4738,7 +4743,7 @@ extension FloatingControlBarWindow {
47384743

47394744
/// Save the current center point so closeAIConversation can restore position.
47404745
/// Only saves if preChatCenter is not already set (avoids overwriting during follow-ups).
4741-
/// If a close/restore animation is in flight (pendingRestoreOrigin is set), snaps the
4746+
/// If a close/restore animation is in flight (pendingRestoreFrame is set), snaps the
47424747
/// window to that target first so the saved center reflects the true pill position,
47434748
/// not an intermediate animation frame.
47444749
/// In non-draggable mode, always snaps to the fixed default position so the saved
@@ -4763,14 +4768,18 @@ extension FloatingControlBarWindow {
47634768
isResizingProgrammatically = true
47644769
setFrame(snapFrame, display: true, animate: false)
47654770
isResizingProgrammatically = false
4766-
pendingRestoreOrigin = nil
4767-
} else if let restoreOrigin = pendingRestoreOrigin {
4768-
// Draggable: if a restore animation is running, snap to its target immediately
4769-
// so we record the correct pill position rather than a mid-animation frame.
4771+
pendingRestoreFrame = nil
4772+
} else if let restoreFrame = pendingRestoreFrame {
4773+
// Draggable: if a restore animation is running, snap to its target frame
4774+
// immediately so we record the correct pill center rather than a
4775+
// mid-animation frame. Snap to the STORED frame (origin + the glow-inflated
4776+
// size the origin was computed for) — reconstructing it with the bare
4777+
// collapsedBarSize is exactly what drifted preChatCenter one glow outset
4778+
// per cycle.
47704779
isResizingProgrammatically = true
4771-
setFrame(NSRect(origin: restoreOrigin, size: size), display: true, animate: false)
4780+
setFrame(restoreFrame, display: true, animate: false)
47724781
isResizingProgrammatically = false
4773-
pendingRestoreOrigin = nil
4782+
pendingRestoreFrame = nil
47744783
}
47754784
if !notchModeEnabled, state.isNotchHoverMenuVisible {
47764785
// Chat is opening from the taller pill agent list. The pill's true
@@ -4789,7 +4798,7 @@ extension FloatingControlBarWindow {
47894798
resignKeyAnimationToken += 1
47904799
frameAnimationToken += 1
47914800
if !ShortcutSettings.shared.draggableBarEnabled {
4792-
pendingRestoreOrigin = nil
4801+
pendingRestoreFrame = nil
47934802
}
47944803
suppressHoverResize = false
47954804
isResizingProgrammatically = false

desktop/macos/Desktop/Sources/MainWindow/Conversations/ConversationRepository.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,14 @@ final class ConversationRepository {
311311
if !cached.isEmpty {
312312
let cachedCount = try? await local.count(query: query)
313313
guard generation == requestGeneration else { return }
314-
conversations = cached
314+
// Overlay in-flight optimistic mutations before publishing, mirroring
315+
// the server merge path (mergeList → apply). Without this, a load()
316+
// that races a pending star/title/folder edit (e.g. the user toggles a
317+
// filter mid-mutation) paints the bare cached rows and visually reverts
318+
// the edit until the remote call lands.
319+
conversations = cached.map {
320+
ConversationReconciliationPolicy.apply(mutation: pendingMutations[$0.id], to: $0)
321+
}
315322
count = cachedCount
316323
emit(.cache)
317324
}

desktop/macos/Desktop/Sources/Services/APIClient/APIClient+Messages.swift

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -126,35 +126,40 @@ extension APIClient {
126126
}
127127
var request = URLRequest(url: url)
128128
request.httpMethod = "GET"
129-
request.allHTTPHeaderFields = try? await buildHeaders(requireAuth: true)
129+
130+
// Build the auth header explicitly. If token minting throws (e.g. an account
131+
// transition races the reconciler, or the Keychain is locked right after
132+
// login), do NOT fall through and send an UNAUTHENTICATED GET — that returns
133+
// 401, was misread as `.transient`, and parked the WAL in a forever-retry.
134+
// Surface `.unauthorized` so the reconciler reverts the WAL for re-upload via
135+
// the authenticated upload path, which owns the forced refresh. Per
136+
// INV-AUTH-1 the background poll is session-preserving: it never invalidates
137+
// the Firebase session itself.
138+
do {
139+
request.allHTTPHeaderFields = try await buildHeaders(requireAuth: true)
140+
} catch {
141+
return SyncJobFetch(outcome: .unauthorized)
142+
}
130143

131144
guard let (data, response) = try? await session.data(for: request),
132145
let http = response as? HTTPURLResponse
133146
else {
134147
return SyncJobFetch(outcome: .transient)
135148
}
136149

137-
if http.statusCode == 404 {
138-
return SyncJobFetch(outcome: .notFound)
139-
}
140-
// 403 means the caller is not permitted to access this sync job. Unlike a
141-
// transient transport failure, re-polling will not resolve it — the upload
142-
// path already refreshed auth on 401, so a 403 here is a durable permission
143-
// failure. Surface it as `.forbidden` so the reconciler reverts the WAL to
144-
// `.miss` for re-upload (the backend dedupes by conversation/timestamp)
145-
// instead of polling forever.
146-
if http.statusCode == 403 {
147-
return SyncJobFetch(outcome: .forbidden)
148-
}
149-
guard http.statusCode == 200 else {
150-
return SyncJobFetch(outcome: .transient)
151-
}
152-
153-
do {
154-
let status = try decoder.decode(SyncJobStatusResponse.self, from: data)
155-
return SyncJobFetch(outcome: .ok, status: status)
156-
} catch {
157-
return SyncJobFetch(outcome: .transient)
150+
// 401/403/404 are durable for this fetch: re-polling won't resolve them, so
151+
// the reconciler reverts the WAL to `.miss` for re-upload (the backend
152+
// dedupes by conversation/timestamp) instead of polling forever.
153+
switch SyncJobFetchOutcome.forStatusCode(http.statusCode) {
154+
case .ok:
155+
do {
156+
let status = try decoder.decode(SyncJobStatusResponse.self, from: data)
157+
return SyncJobFetch(outcome: .ok, status: status)
158+
} catch {
159+
return SyncJobFetch(outcome: .transient)
160+
}
161+
case let outcome:
162+
return SyncJobFetch(outcome: outcome)
158163
}
159164
}
160165

@@ -331,7 +336,22 @@ enum SyncJobFetchOutcome: Equatable {
331336
case ok
332337
case notFound
333338
case forbidden
339+
case unauthorized
334340
case transient
341+
342+
/// Pure HTTP-status → outcome mapping for a sync-job status GET. `.ok` means
343+
/// the 200 body still must be decoded by the caller (a decode failure is
344+
/// `.transient`); every other case is terminal for this fetch. Extracted so
345+
/// the mapping is unit-testable without a URLSession.
346+
static func forStatusCode(_ statusCode: Int) -> SyncJobFetchOutcome {
347+
switch statusCode {
348+
case 200: return .ok
349+
case 401: return .unauthorized
350+
case 403: return .forbidden
351+
case 404: return .notFound
352+
default: return .transient
353+
}
354+
}
335355
}
336356

337357
struct SyncJobFetch: Equatable {

desktop/macos/Desktop/Sources/Startup/AppInstaller.swift

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,19 +84,12 @@ enum AppInstaller {
8484
removeQuarantine(at: destination)
8585
return relaunch(destination)
8686
}
87-
do {
88-
try fileManager.removeItem(at: destination)
89-
} catch {
90-
logError("AppInstaller: could not remove existing installed copy", error: error)
91-
showManualInstallHint()
92-
return false
93-
}
9487
}
9588

9689
do {
97-
try fileManager.copyItem(at: bundleURL, to: destination)
90+
try replaceInstalledApp(source: bundleURL, destination: destination, fileManager: fileManager)
9891
} catch {
99-
logError("AppInstaller: copy to /Applications failed", error: error)
92+
logError("AppInstaller: install to /Applications failed (existing copy preserved)", error: error)
10093
showManualInstallHint()
10194
return false
10295
}
@@ -110,6 +103,36 @@ enum AppInstaller {
110103
return relaunch(destination)
111104
}
112105

106+
/// Atomically install `source` at `destination`, replacing any existing bundle.
107+
///
108+
/// The old code did `removeItem(destination)` then `copyItem(source →
109+
/// destination)`: a copy failure after the remove (e.g. ENOSPC copying off a
110+
/// nearly-full DMG) destroyed the user's working `/Applications` copy. Here the
111+
/// fresh bundle is copied to a staging path FIRST and only swapped in via
112+
/// `replaceItemAt` after the copy succeeds, so the existing install survives any
113+
/// copy failure untouched. Staging lives beside `destination` (same volume) so
114+
/// the swap is a rename, not a second cross-volume copy.
115+
static func replaceInstalledApp(
116+
source: URL,
117+
destination: URL,
118+
fileManager: FileManager = .default
119+
) throws {
120+
let staging = destination.deletingLastPathComponent()
121+
.appendingPathComponent(".\(destination.lastPathComponent).staging-\(getpid())")
122+
do {
123+
try? fileManager.removeItem(at: staging) // clear any stale staging from a prior crash
124+
try fileManager.copyItem(at: source, to: staging)
125+
if fileManager.fileExists(atPath: destination.path) {
126+
_ = try fileManager.replaceItemAt(destination, withItemAt: staging)
127+
} else {
128+
try fileManager.moveItem(at: staging, to: destination)
129+
}
130+
} catch {
131+
try? fileManager.removeItem(at: staging)
132+
throw error
133+
}
134+
}
135+
113136
private static func build(atBundleURL url: URL) -> String? {
114137
Bundle(url: url)?.object(forInfoDictionaryKey: "CFBundleVersion") as? String
115138
}

desktop/macos/Desktop/Sources/WAL/WALCloudSyncLogic.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,11 @@ enum WALCloudSyncLogic {
7777
case .transient:
7878
return false
7979

80-
case .notFound, .forbidden:
80+
case .notFound, .forbidden, .unauthorized:
81+
// `.unauthorized` = the status poll could not be authenticated (token mint
82+
// failed, or a stale 401). Revert to `.miss` like the other durable
83+
// failures so the authenticated upload path re-uploads and refreshes auth;
84+
// the poll itself must not invalidate the session (INV-AUTH-1).
8185
var changed = false
8286
for walId in memberWalIds {
8387
guard let index = wals.firstIndex(where: { $0.id == walId }) else { continue }
@@ -124,4 +128,13 @@ enum WALCloudSyncLogic {
124128
return changed
125129
}
126130
}
131+
132+
/// Synced WALs whose recording start predates the retention cutoff — safe to
133+
/// delete. Gated on `.synced` (a confirmed backend ack; never `.miss`/
134+
/// `.uploaded`/`.corrupted`, which still need upload) AND older than the
135+
/// cutoff, so local audio is only reclaimed after the cloud has it and a
136+
/// retention buffer has elapsed. Pure so the delete/keep boundary is testable.
137+
static func cleanupCandidates(wals: [WALEntry], cutoffTimestamp: Int) -> [WALEntry] {
138+
wals.filter { $0.status == .synced && $0.timerStart < cutoffTimestamp }
139+
}
127140
}

desktop/macos/Desktop/Sources/WAL/WALService.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1085,6 +1085,11 @@ final class WALService: ObservableObject {
10851085
saveWals()
10861086
}
10871087

1088+
// Reclaim already-synced audio past the retention window. Runs every sync
1089+
// pass (not just launch) so the wals directory + metadata don't grow without
1090+
// bound — cleanupOldWals was previously never invoked.
1091+
cleanupOldWals()
1092+
10881093
// If jobs are still in-flight (.uploaded) after the immediate reconcile,
10891094
// schedule a follow-up poll so queued/processing jobs eventually resolve.
10901095
// Without this, a 202 + still-queued GET leaves WALs stuck in .uploaded.
@@ -1171,12 +1176,14 @@ final class WALService: ObservableObject {
11711176

11721177
// MARK: - Cleanup
11731178

1174-
/// Remove synced WALs older than specified days
1179+
/// Remove synced WALs older than `olderThanDays` and delete their on-disk audio.
1180+
/// Runs at the end of each `syncToCloud` pass; uses the injected clock so the
1181+
/// retention window is deterministic in tests.
11751182
func cleanupOldWals(olderThanDays: Int = 7) {
1176-
let cutoff = Date().addingTimeInterval(-Double(olderThanDays * 24 * 60 * 60))
1177-
let cutoffTimestamp = Int(cutoff.timeIntervalSince1970)
1183+
let cutoffTimestamp = timestampProvider() - olderThanDays * 24 * 60 * 60
11781184

1179-
let toRemove = wals.filter { $0.status == .synced && $0.timerStart < cutoffTimestamp }
1185+
let toRemove = WALCloudSyncLogic.cleanupCandidates(wals: wals, cutoffTimestamp: cutoffTimestamp)
1186+
guard !toRemove.isEmpty else { return }
11801187

11811188
for wal in toRemove {
11821189
// Delete file

0 commit comments

Comments
 (0)