diff --git a/CHANGELOG.md b/CHANGELOG.md index 36fcb1d..0f1eac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ All notable changes to VaultSync are documented here. ### Fixed - **The home-screen widget no longer shows a green check while something needs your attention** ([#73](https://github.com/psimaker/vaultsync/issues/73)) — the widget only knew "all good", "syncing", and "error", so a share waiting for your decision or a required device offline beyond its grace period left the green check standing. The widget status now derives from the same issue list as the in-app header ([#66](https://github.com/psimaker/vaultsync/issues/66)) and shows "Needs Attention" the moment something waits for you. +- **The widget stays honest after background syncs too** ([#76](https://github.com/psimaker/vaultsync/issues/76)) — a sync running in the background (scheduled refresh, overnight catch-up, or a Cloud Relay wake-up) wrote its result to the widget directly as "all good" or "error", bypassing the issue-derived status above: a successful background run could replace an honest "Needs Attention" — a share still waiting for your decision, a required device still offline — with a false green check until the app was next opened. Background results now run through the same status logic as the app and keep the issues a background sync cannot resolve on its own. A failed background sync also shows "Needs Attention" now, matching what the app itself shows for it, instead of a red "Sync Error". +- **The widget no longer flashes "Syncing" for a finished sync** ([#77](https://github.com/psimaker/vaultsync/issues/77)) — the moment a sync completed, the widget first received a stale "Syncing" snapshot that a second write corrected an instant later. The completed status is now written once, correctly. +- **The widget's numbers survive background runs** ([#76](https://github.com/psimaker/vaultsync/issues/76) follow-up) — after a background sync the widget could show "Vaults: 0" and lose the synced-file count, because the numbers were read only after the sync engine had already been shut down; and a failed background run stamped its own time as "last synced" as if it had succeeded. The numbers are now read while the engine is still up, and a failed run keeps the last real sync time instead of its own. - **Warning colors are readable in light mode** ([#68](https://github.com/psimaker/vaultsync/issues/68)) — the amber used for warnings and failure explanations was too faint on white backgrounds (below the WCAG contrast minimum); it is now a darker amber that clears it, and all status colors gained dedicated high-contrast variants, so the iOS "Increase Contrast" setting now actually increases their contrast. - **The green status color is readable in light mode too** ([#74](https://github.com/psimaker/vaultsync/issues/74)) — same class as the amber above: the light-mode green coloring the "devices connected" line was below the WCAG contrast minimum (3.38:1); it is now a darker green (5.47:1) with a deeper high-contrast variant, so "Increase Contrast" keeps having an effect. Dark mode is unchanged. - **Onboarding titles no longer truncate at large accessibility text sizes** ([#67](https://github.com/psimaker/vaultsync/issues/67)) — with very large Dynamic Type (German and Spanish especially), step titles cut off mid-word ("Deinen Obsidian-O…") instead of wrapping. diff --git a/docs/decisions/013-background-widget-write-issue-floor.md b/docs/decisions/013-background-widget-write-issue-floor.md new file mode 100644 index 0000000..f538cf8 --- /dev/null +++ b/docs/decisions/013-background-widget-write-issue-floor.md @@ -0,0 +1,11 @@ +# 013 — Background widget writes fold in a persisted issue floor + +**Context:** `BackgroundSyncService.completeSync` wrote "idle"/"error" straight to the widget snapshot; the issue-derived tier (decision 012, #73) existed only in the foreground manager, so a successful background run overwrote an honest attention snapshot with a green idle (#76). The static background context cannot recompute `unresolvedIssues`: the bridge is already stopped on the background-owned path, and grace-window state (disconnected peers) lives only in the manager. + +**Decision:** The foreground persists a severity floor (app-group key `vaultsync.widget.issue-floor`) on every widget write: the max severity of `unresolvedIssues` plus unreachable folders, **excluding** the kinds a background run itself invalidates (`.staleSync` — a successful sync resolves staleness by definition; `.backgroundSync` — the completion write knows the fresher outcome). `completeSync` maps its result to the same severities `backgroundSyncIssueItem` surfaces in-app, folds the floor in, and derives the tier through `SyncHeaderModel.deriveWidgetStatus` — one cascade for every status surface. An unknown persisted floor decodes as `.warning`, never `.none`. The snapshot's metrics follow the same direction: the completion write runs before cleanup stops the bridge, a stopped bridge's empty folder list never zeroes the count (the previous snapshot stands in), and only a successful run stamps the last-sync triple — a failure carries the previous one forward. + +**Why:** A stale floor errs amber (cleared on the next foreground open); dropping it errs green — the exact lie-class #73 closed. Excluding the self-resolving kinds keeps the amber from sticking when background syncs are the only thing running for days. + +**Rejected alternatives:** Recomputing the issue list in the background at completion time (bridge stopped, manager-only state — cannot work); carrying the previous snapshot's status forward wholesale (sticks a false amber for stale-sync/background-outcome issues that only a foreground open could clear). + +**Links:** issue #76; `BackgroundSyncService.backgroundCompletionWidgetStatus`; `SyncthingManager.durableIssueFloor`; `BackgroundWidgetStatusTests`. diff --git a/ios/VaultSync/Services/BackgroundSyncService.swift b/ios/VaultSync/Services/BackgroundSyncService.swift index 36098a4..2ab8fb7 100644 --- a/ios/VaultSync/Services/BackgroundSyncService.swift +++ b/ios/VaultSync/Services/BackgroundSyncService.swift @@ -609,16 +609,17 @@ enum BackgroundSyncService { trace("Folder availability check completed: hasFolders=\(hasFolders).") traceFolderStatuses(label: "post-folder-availability") guard hasFolders else { - if ownsLifecycle { - cleanupBackgroundManaged(managedURLs) - } - return completeSync( + let completion = completeSync( reason: reason, result: .noFoldersConfigured, detail: L10n.tr("No folders were available for background sync."), startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor ) + if ownsLifecycle { + cleanupBackgroundManaged(managedURLs) + } + return completion } if ownsLifecycle { @@ -650,16 +651,17 @@ enum BackgroundSyncService { let restart = await forceRestartForSilentPush(managedURLs: managedURLs) guard restart.success else { trace("Forced restart failed: \(restart.errorDetail ?? "unknown error").") - if restart.ownsLifecycle { - cleanupBackgroundManaged(restart.managedURLs) - } - return completeSync( + let completion = completeSync( reason: reason, result: .bridgeStartFailed, detail: restart.errorDetail ?? L10n.tr("Forced silent-push restart failed."), startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor ) + if restart.ownsLifecycle { + cleanupBackgroundManaged(restart.managedURLs) + } + return completion } managedURLs = restart.managedURLs @@ -678,16 +680,17 @@ enum BackgroundSyncService { traceVaultSnapshot(label: "post-forced-restart") traceFolderStatuses(label: "post-forced-restart") guard recoveredFolders else { - if ownsLifecycle { - cleanupBackgroundManaged(managedURLs) - } - return completeSync( + let completion = completeSync( reason: reason, result: .noFoldersConfigured, detail: L10n.tr("No folders were available after forced silent-push restart."), startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor ) + if ownsLifecycle { + cleanupBackgroundManaged(managedURLs) + } + return completion } if ownsLifecycle { @@ -713,16 +716,17 @@ enum BackgroundSyncService { // the conflict scan reads through it. notifyConflictsIfAny is // internally gated (foreground/toggle/suppression), so it's cheap. await notifyConflictsIfAny() - if ownsLifecycle { - cleanupBackgroundManaged(managedURLs) - } - return completeSync( + let completion = completeSync( reason: reason, result: .alreadyIdle, detail: nil, startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor ) + if ownsLifecycle { + cleanupBackgroundManaged(managedURLs) + } + return completion } // Budget the wait from sync START, not from "now": the silent-push setup @@ -766,11 +770,6 @@ enum BackgroundSyncService { await notifyConflictsIfAny() } - // Only stop if we started it and foreground hasn't taken over. - if ownsLifecycle { - cleanupBackgroundManaged(managedURLs) - } - let result: SyncResult let detail: String? if let progressSnapshot, forcedRestartPerformed, progressSnapshot.requiresMeaningfulProgress { @@ -791,13 +790,18 @@ enum BackgroundSyncService { result = .notIdleBeforeDeadline detail = L10n.fmt("Sync did not reach idle before %ds deadline.", Int(maxDuration)) } - return completeSync( + let completion = completeSync( reason: reason, result: result, detail: detail, startedAt: syncStartedAt, initialEventCursor: syncStartEventCursor ) + // Only stop if we started it and foreground hasn't taken over. + if ownsLifecycle { + cleanupBackgroundManaged(managedURLs) + } + return completion } // MARK: - BGAppRefreshTask Handler @@ -928,6 +932,90 @@ enum BackgroundSyncService { releaseAccess(managedURLs) } + /// Widget tier for a background completion (#76). Pure so the matrix is + /// unit-testable. Routes through the SAME header cascade as the + /// foreground write (decision 012): the run's own result maps to the + /// severities `backgroundSyncIssueItem` will surface in-app, combined + /// with the foreground-persisted floor of issues a background run cannot + /// resolve — so a successful background sync can never overwrite an + /// honest attention snapshot with a green idle. Engine/vault tiers are + /// pinned: a stopped bridge is the normal end state of a background-owned + /// run, not the "Starting" anomaly the foreground tier expresses. + static func backgroundCompletionWidgetStatus( + result: SyncResult, + issueFloor: WidgetSnapshotStore.IssueFloor + ) -> SyncStatus { + var severities: [SyncthingManager.SyncIssueSeverity] = [] + switch issueFloor { + case .none: break + case .warning: severities.append(.warning) + case .critical: severities.append(.critical) + } + switch result { + case .synced, .alreadyIdle: + break + case .noFoldersConfigured, .notIdleBeforeDeadline: + severities.append(.warning) + case .noBookmarkAccess, .bridgeStartFailed, .failed, .settledWithFolderError: + // settledWithFolderError surfaces in-app via the folderErrors + // issue (critical), not via backgroundSyncIssueItem — same tier. + severities.append(.critical) + } + return SyncHeaderModel.deriveWidgetStatus( + hasEngineError: false, + engineRunning: true, + issueSeverities: severities, + hasUnreachableFolders: false, + isSyncing: false, + hasSyncFolders: true + ) + } + + /// Snapshot assembly for a background completion (#76 follow-up). Pure + /// so the fallback matrix is unit-testable. Two rules keep the widget's + /// numbers honest when the run cannot supply fresh values: + /// - The last-sync triple (time, duration, files) is stamped only by a + /// successful run; a failure carries the previous snapshot's triple + /// forward so "last synced" keeps naming the last real sync, never the + /// failure moment. + /// - The folder count comes from the bridge only while it is readable; + /// once a background-owned run stopped it, the bridge reports an empty + /// folder list — writing that through rendered "Vaults: 0" on the + /// widget, so the previous snapshot's count stands in instead. + static func backgroundCompletionSnapshot( + result: SyncResult, + issueFloor: WidgetSnapshotStore.IssueFloor, + completedAt: Date, + startedAt: Date, + bridgeRunning: Bool, + liveFolderCount: Int, + liveSyncedFiles: Int, + previous: WidgetSnapshotStore.Snapshot? + ) -> WidgetSnapshotStore.Snapshot { + let status = backgroundCompletionWidgetStatus(result: result, issueFloor: issueFloor) + let folderCount = bridgeRunning ? liveFolderCount : (previous?.folderCount ?? 0) + if result.isSuccessful { + return WidgetSnapshotStore.Snapshot( + lastSyncTime: WidgetSnapshotStore.iso8601String(from: completedAt), + lastSyncDuration: max(0, completedAt.timeIntervalSince(startedAt)), + status: status.wireValue, + filesSynced: liveSyncedFiles, + folderCount: folderCount + ) + } + return WidgetSnapshotStore.Snapshot( + lastSyncTime: previous?.lastSyncTime ?? "", + lastSyncDuration: previous?.lastSyncDuration ?? 0, + status: status.wireValue, + filesSynced: previous?.filesSynced ?? 0, + folderCount: folderCount + ) + } + + /// Persist the outcome and the widget snapshot. Call BEFORE + /// `cleanupBackgroundManaged` — the snapshot reads the folder count and + /// the synced-file events through the bridge, and cleanup may stop it + /// (#76 follow-up). @discardableResult private static func completeSync( reason: String, @@ -944,12 +1032,15 @@ enum BackgroundSyncService { ) persistSyncOutcome(outcome) WidgetSnapshotStore.write( - snapshot: WidgetSnapshotStore.Snapshot( - lastSyncTime: WidgetSnapshotStore.iso8601String(from: outcome.timestamp), - lastSyncDuration: max(0, outcome.timestamp.timeIntervalSince(startedAt)), - status: result.isSuccessful ? "idle" : "error", - filesSynced: syncedFileCount(since: initialEventCursor), - folderCount: currentFolderCount() + snapshot: backgroundCompletionSnapshot( + result: result, + issueFloor: WidgetSnapshotStore.readIssueFloor(), + completedAt: outcome.timestamp, + startedAt: startedAt, + bridgeRunning: SyncBridgeService.isRunning(), + liveFolderCount: currentFolderCount(), + liveSyncedFiles: syncedFileCount(since: initialEventCursor), + previous: WidgetSnapshotStore.read() ) ) if let detail, !detail.isEmpty { diff --git a/ios/VaultSync/Services/SyncthingManager.swift b/ios/VaultSync/Services/SyncthingManager.swift index da3340b..9cf8222 100644 --- a/ios/VaultSync/Services/SyncthingManager.swift +++ b/ios/VaultSync/Services/SyncthingManager.swift @@ -27,6 +27,55 @@ enum WidgetSnapshotStore { WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) } + /// Read back the last persisted snapshot (app side). The background + /// completion write carries values forward from it that a finished run + /// cannot re-read honestly (#76 follow-up): the bridge reports an empty + /// folder list once stopped, and a failed run has no sync completion of + /// its own to stamp. + static func read() -> Snapshot? { + guard let defaults = UserDefaults(suiteName: appGroupSuiteName), + let json = defaults.string(forKey: snapshotDefaultsKey), + let data = json.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(Snapshot.self, from: data) + } + + /// Issue-severity floor persisted for the background completion write + /// (#76). The foreground manager derives it from the same issue list the + /// header and widget render (decision 012); `BackgroundSyncService + /// .completeSync` — a static context with no manager and usually a + /// stopped bridge — reads it so a successful background run cannot + /// overwrite an honest attention snapshot with a green idle. Only issue + /// kinds a background sync cannot resolve on its own are recorded (see + /// `SyncthingManager.durableIssueFloor`). + enum IssueFloor: String, Sendable { + case none + case warning + case critical + + /// Decode the persisted floor. Absent reads as `.none` (fresh install + /// or first run after update — matches the pre-#76 behavior until the + /// foreground writes one); an unknown value maps to `.warning`, never + /// silently to `.none` — same doctrine as `SyncStatus.fromWire`. + static func decode(_ raw: String?) -> IssueFloor { + guard let raw else { return .none } + return IssueFloor(rawValue: raw) ?? .warning + } + } + + static let issueFloorDefaultsKey = "vaultsync.widget.issue-floor" + + static func writeIssueFloor(_ floor: IssueFloor) { + UserDefaults(suiteName: appGroupSuiteName)?.set(floor.rawValue, forKey: issueFloorDefaultsKey) + } + + static func readIssueFloor() -> IssueFloor { + IssueFloor.decode( + UserDefaults(suiteName: appGroupSuiteName)?.string(forKey: issueFloorDefaultsKey) + ) + } + static func iso8601String(from date: Date?) -> String { guard let date else { return "" } let formatter = ISO8601DateFormatter() @@ -164,6 +213,7 @@ final class SyncthingManager { private var lastWidgetSyncDuration: TimeInterval = 0 private var lastWidgetSyncFilesSynced = 0 private var lastWrittenWidgetSnapshot: WidgetSnapshotStore.Snapshot? + private var lastWrittenIssueFloor: WidgetSnapshotStore.IssueFloor? private static let bridgeDateParser: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() @@ -1844,9 +1894,13 @@ final class SyncthingManager { if isSyncingNow { beginWidgetSyncSessionIfNeeded(startDate: Date()) } else if wasSyncing || activeWidgetSyncStart != nil { - completeWidgetSyncSession( - status: currentWidgetSnapshotStatus(using: newStatuses), - completedAt: Date() + // Close the session BEFORE deriving the tier: with the session + // still open, the cascade reports .syncing and the completion + // write persists a stale snapshot the poll-end write immediately + // replaces — two writes and widget reloads per completion (#77). + finalizeWidgetSyncSession(completedAt: Date()) + writeWidgetSnapshotIfNeeded( + statusOverride: currentWidgetSnapshotStatus(using: newStatuses) ) } } @@ -1857,16 +1911,22 @@ final class SyncthingManager { activeWidgetSyncFilesSynced = 0 } - private func completeWidgetSyncSession( - status: SyncStatus, - completedAt: Date - ) { + /// Roll the just-ended session into the last-sync metrics and clear it. + /// Must run before the completion tier is derived (#77). + private func finalizeWidgetSyncSession(completedAt: Date) { let startedAt = activeWidgetSyncStart ?? completedAt lastWidgetSyncCompletionTime = completedAt lastWidgetSyncDuration = max(0, completedAt.timeIntervalSince(startedAt)) lastWidgetSyncFilesSynced = activeWidgetSyncFilesSynced activeWidgetSyncStart = nil activeWidgetSyncFilesSynced = 0 + } + + private func completeWidgetSyncSession( + status: SyncStatus, + completedAt: Date + ) { + finalizeWidgetSyncSession(completedAt: completedAt) writeWidgetSnapshotIfNeeded(statusOverride: status) } @@ -1898,7 +1958,43 @@ final class SyncthingManager { ) } + /// Max severity among issues that persist across a background sync — the + /// floor `BackgroundSyncService.completeSync` folds into its widget write + /// (#76). Excluded kinds are the ones a background run itself + /// invalidates: `.staleSync` (a successful sync resolves staleness by + /// definition) and `.backgroundSync` (the completion write knows the + /// fresh outcome, which supersedes the persisted one) — including either + /// would stick a false amber on the widget that only a foreground open + /// could clear. Unreachable folders count as critical, mirroring the + /// header cascade. + nonisolated static func durableIssueFloor( + issues: [(kind: SyncIssueItem.Kind, severity: SyncIssueSeverity)], + hasUnreachableFolders: Bool + ) -> WidgetSnapshotStore.IssueFloor { + if hasUnreachableFolders { return .critical } + var floor = WidgetSnapshotStore.IssueFloor.none + for issue in issues where issue.kind != .staleSync && issue.kind != .backgroundSync { + switch issue.severity { + case .critical: return .critical + case .warning: floor = .warning + } + } + return floor + } + private func writeWidgetSnapshotIfNeeded(statusOverride: SyncStatus? = nil) { + // Keep the persisted issue floor current on every write attempt, even + // a deduped one — the floor can change (e.g. stale-sync warning joins + // an existing attention state) without the snapshot changing. + let issueFloor = Self.durableIssueFloor( + issues: unresolvedIssues.map { ($0.kind, $0.severity) }, + hasUnreachableFolders: !unreachableFolders.isEmpty + ) + if issueFloor != lastWrittenIssueFloor { + lastWrittenIssueFloor = issueFloor + WidgetSnapshotStore.writeIssueFloor(issueFloor) + } + let snapshot = WidgetSnapshotStore.Snapshot( lastSyncTime: WidgetSnapshotStore.iso8601String(from: lastWidgetSyncCompletionTime ?? lastSyncTime), lastSyncDuration: activeWidgetSyncStart == nil ? lastWidgetSyncDuration : 0, @@ -2288,5 +2384,35 @@ final class SyncthingManager { func _testSetFolderStatuses(_ newStatuses: [String: FolderStatusInfo]) { folderStatuses = newStatuses } + + func _testSetRunning(_ running: Bool) { + isRunning = running + } + + func _testUpdateWidgetSyncMetrics( + previousStatuses: [String: FolderStatusInfo], + newStatuses: [String: FolderStatusInfo] + ) { + updateWidgetSyncMetrics(previousStatuses: previousStatuses, newStatuses: newStatuses) + } + + func _testWriteWidgetSnapshot() { + writeWidgetSnapshotIfNeeded() + } + + func _testLastWrittenWidgetSnapshot() -> WidgetSnapshotStore.Snapshot? { + lastWrittenWidgetSnapshot + } + + func _testLastWrittenIssueFloor() -> WidgetSnapshotStore.IssueFloor? { + lastWrittenIssueFloor + } + + /// Init restores the last background outcome from `UserDefaults.standard`, + /// which parallel suites share — tests that assert on the issue cascade + /// clear it to stay hermetic. + func _testSetLastBackgroundSyncOutcome(_ outcome: BackgroundSyncService.SyncOutcome?) { + lastBackgroundSyncOutcome = outcome + } #endif } diff --git a/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift b/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift new file mode 100644 index 0000000..8dd63c8 --- /dev/null +++ b/ios/VaultSyncTests/BackgroundWidgetStatusTests.swift @@ -0,0 +1,300 @@ +import Foundation +import Testing +@testable import VaultSync + +/// `BackgroundSyncService.completeSync` used to persist "idle"/"error" +/// directly — the attention tier from #73 existed only on the foreground +/// path, so a successful background run overwrote an honest attention +/// snapshot (parked share, disconnected required peer, collision) with a +/// green idle (#76). +@Suite("Background completion widget status derives from result + issue floor (#76)") +struct BackgroundCompletionWidgetStatusTests { + private static let allResults: [BackgroundSyncService.SyncResult] = [ + .synced, .alreadyIdle, .noBookmarkAccess, .noFoldersConfigured, + .bridgeStartFailed, .notIdleBeforeDeadline, .failed, .settledWithFolderError, + ] + + @Test("A clean success with no recorded issues stays green") + func cleanSuccessIsSynced() { + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .synced, issueFloor: .none + ) == .synced) + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .alreadyIdle, issueFloor: .none + ) == .synced) + } + + // The reported lie: a successful background run must not clear an + // attention state it cannot have resolved (parked share, disconnected + // required peer, collision — all foreground-recorded). + @Test("A success never overwrites a recorded issue floor with green") + func successRespectsIssueFloor() { + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .synced, issueFloor: .warning + ) == .attention) + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .synced, issueFloor: .critical + ) == .attention) + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .alreadyIdle, issueFloor: .warning + ) == .attention) + } + + // Deliberate tier change, matching the header cascade (decision 012): + // a failed background run surfaces in-app as an attention-tier issue, + // so the widget shows the same amber, no longer a red "Sync Error". + @Test("Every non-successful result maps to attention") + func failuresMapToAttention() { + for result in Self.allResults where !result.isSuccessful { + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: result, issueFloor: .none + ) == .attention) + } + } + + @Test("No result can reach green once a floor is recorded") + func noGreenAboveFloor() { + for result in Self.allResults { + for floor in [WidgetSnapshotStore.IssueFloor.warning, .critical] { + #expect(BackgroundSyncService.backgroundCompletionWidgetStatus( + result: result, issueFloor: floor + ) != .synced) + } + } + } + + // End-to-end wire trip, same guarantee #73 pinned for the foreground + // write: the persisted value can never decode into the green branch. + @Test("Persisted wire value decodes back to the same tier in the widget") + func wireRoundTrip() { + let attention = BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .synced, issueFloor: .warning + ) + #expect(SyncStatus.fromWire(attention.wireValue) == .attention) + + let clean = BackgroundSyncService.backgroundCompletionWidgetStatus( + result: .synced, issueFloor: .none + ) + #expect(SyncStatus.fromWire(clean.wireValue) == .synced) + } +} + +/// `completeSync` used to read the folder count and synced-file events after +/// `cleanupBackgroundManaged` had already stopped the bridge (it reports an +/// empty folder list once stopped → the widget rendered "Vaults: 0"), and it +/// stamped the outcome timestamp as "last sync" for failed runs too. +@Suite("Background completion snapshot carries honest metrics (#76 follow-up)") +struct BackgroundCompletionSnapshotTests { + private let previous = WidgetSnapshotStore.Snapshot( + lastSyncTime: "2026-07-06T21:00:00Z", + lastSyncDuration: 42, + status: SyncStatus.synced.wireValue, + filesSynced: 17, + folderCount: 3 + ) + private let startedAt = Date(timeIntervalSince1970: 1_783_000_000) + private let completedAt = Date(timeIntervalSince1970: 1_783_000_012) + + private func snapshot( + result: BackgroundSyncService.SyncResult, + bridgeRunning: Bool, + previous: WidgetSnapshotStore.Snapshot? + ) -> WidgetSnapshotStore.Snapshot { + BackgroundSyncService.backgroundCompletionSnapshot( + result: result, + issueFloor: .none, + completedAt: completedAt, + startedAt: startedAt, + bridgeRunning: bridgeRunning, + liveFolderCount: 2, + liveSyncedFiles: 5, + previous: previous + ) + } + + @Test("A successful run stamps its own last-sync triple and live counts") + func successStampsFreshValues() { + let snap = snapshot(result: .synced, bridgeRunning: true, previous: previous) + #expect(snap.lastSyncTime == WidgetSnapshotStore.iso8601String(from: completedAt)) + #expect(snap.lastSyncDuration == 12) + #expect(snap.filesSynced == 5) + #expect(snap.folderCount == 2) + } + + @Test("A failed run keeps the last real sync's triple, never its own timestamp") + func failureCarriesPreviousTriple() { + for result in [BackgroundSyncService.SyncResult.bridgeStartFailed, .notIdleBeforeDeadline, .settledWithFolderError] { + let snap = snapshot(result: result, bridgeRunning: false, previous: previous) + #expect(snap.lastSyncTime == previous.lastSyncTime) + #expect(snap.lastSyncDuration == previous.lastSyncDuration) + #expect(snap.filesSynced == previous.filesSynced) + } + } + + @Test("A failed run with no previous snapshot reports no sync, not a fake one") + func failureWithoutPreviousIsEmpty() { + let snap = snapshot(result: .failed, bridgeRunning: false, previous: nil) + #expect(snap.lastSyncTime.isEmpty) + #expect(snap.lastSyncDuration == 0) + #expect(snap.filesSynced == 0) + } + + // The reported symptom: a background-owned run stops the bridge, the + // bridge then reports zero folders, and the widget showed "Vaults: 0". + @Test("A stopped bridge never zeroes the folder count") + func stoppedBridgeKeepsPreviousFolderCount() { + let failed = snapshot(result: .failed, bridgeRunning: false, previous: previous) + #expect(failed.folderCount == 3) + let synced = snapshot(result: .synced, bridgeRunning: false, previous: previous) + #expect(synced.folderCount == 3) + let running = snapshot(result: .synced, bridgeRunning: true, previous: previous) + #expect(running.folderCount == 2) + } + + @Test("The status tier still derives from result and floor") + func statusStillDerived() { + let snap = BackgroundSyncService.backgroundCompletionSnapshot( + result: .synced, + issueFloor: .warning, + completedAt: completedAt, + startedAt: startedAt, + bridgeRunning: true, + liveFolderCount: 2, + liveSyncedFiles: 5, + previous: previous + ) + #expect(snap.status == SyncStatus.attention.wireValue) + #expect(snapshot(result: .failed, bridgeRunning: false, previous: previous).status == SyncStatus.attention.wireValue) + #expect(snapshot(result: .synced, bridgeRunning: true, previous: previous).status == SyncStatus.synced.wireValue) + } +} + +@Suite("Durable widget issue floor (#76)") +struct DurableIssueFloorTests { + private func floor( + _ issues: [(kind: SyncthingManager.SyncIssueItem.Kind, severity: SyncthingManager.SyncIssueSeverity)], + hasUnreachableFolders: Bool = false + ) -> WidgetSnapshotStore.IssueFloor { + SyncthingManager.durableIssueFloor( + issues: issues, + hasUnreachableFolders: hasUnreachableFolders + ) + } + + @Test("No issues, no unreachable folders → none") + func emptyIsNone() { + #expect(floor([]) == .none) + } + + @Test("Durable warning and critical kinds carry their severity") + func durableKindsCarrySeverity() { + #expect(floor([(.pendingShares, .warning)]) == .warning) + #expect(floor([(.disconnectedPeers, .warning)]) == .warning) + #expect(floor([(.conflicts, .warning)]) == .warning) + #expect(floor([(.pathCollision, .critical)]) == .critical) + #expect(floor([(.nestedFolders, .critical)]) == .critical) + #expect(floor([(.folderErrors, .critical)]) == .critical) + #expect(floor([(.pendingShares, .warning), (.pathCollision, .critical)]) == .critical) + } + + // A successful background run resolves staleness by definition, and the + // completion write knows the fresh background outcome — recording either + // would stick a false amber only a foreground open could clear. + @Test("Stale-sync and background-outcome issues never enter the floor") + func selfResolvingKindsAreExcluded() { + #expect(floor([(.staleSync, .warning)]) == .none) + #expect(floor([(.backgroundSync, .critical)]) == .none) + #expect(floor([(.staleSync, .warning), (.backgroundSync, .critical)]) == .none) + #expect(floor([(.staleSync, .warning), (.pendingShares, .warning)]) == .warning) + } + + @Test("Unreachable folders count as critical, mirroring the header") + func unreachableFoldersAreCritical() { + #expect(floor([], hasUnreachableFolders: true) == .critical) + } + + @Test("Persisted floor decodes conservatively") + func decodeIsConservative() { + #expect(WidgetSnapshotStore.IssueFloor.decode(nil) == .none) + #expect(WidgetSnapshotStore.IssueFloor.decode("none") == .none) + #expect(WidgetSnapshotStore.IssueFloor.decode("warning") == .warning) + #expect(WidgetSnapshotStore.IssueFloor.decode("critical") == .critical) + // Unknown must never silently read as "no issues". + #expect(WidgetSnapshotStore.IssueFloor.decode("future-tier") == .warning) + } +} + +/// The manager persists the floor alongside every snapshot write, so the +/// static background context always reads the foreground's latest view. +@Suite("Manager persists the issue floor with the snapshot write (#76)") +struct IssueFloorWiringTests { + @MainActor + private func makeManager(lastSync: Date) -> SyncthingManager { + let defaults = TestSupport.makeIsolatedDefaults(label: "IssueFloorWiring") + let store = SyncHistoryStore(defaults: defaults, storageKey: "history") + store.save(globalLastSync: lastSync, lastSyncByFolder: [:]) + let manager = SyncthingManager(syncHistoryStore: store) + manager._testSetLastBackgroundSyncOutcome(nil) + manager._testSetRunning(true) + manager._testSetFolders([ + SyncthingManager.FolderInfo( + id: "vault-a", + label: "Vault A", + path: "/tmp/issuefloor/vault-a", + type: "sendreceive", + paused: false, + deviceIDs: [] + ), + ]) + return manager + } + + private func errorStatus() -> SyncthingManager.FolderStatusInfo { + SyncthingManager.FolderStatusInfo(payload: .init( + state: "error", + stateChanged: "2026-07-07T10:00:00Z", + completionPct: 0, + globalBytes: 0, + globalFiles: 0, + localBytes: 0, + localFiles: 0, + needBytes: 0, + needFiles: 0, + inProgressBytes: 0, + errorReason: "unknown_error", + errorMessage: "database is locked", + errorPath: nil, + errorChanged: nil + )) + } + + @MainActor + @Test("A folder error is recorded as a critical floor") + func folderErrorRecordsCriticalFloor() { + let manager = makeManager(lastSync: Date()) + manager._testSetFolderStatuses(["vault-a": errorStatus()]) + manager._testWriteWidgetSnapshot() + #expect(manager._testLastWrittenIssueFloor() == .critical) + } + + @MainActor + @Test("A stale-sync warning colors the snapshot but never the floor") + func staleSyncStaysOutOfTheFloor() { + let manager = makeManager(lastSync: Date(timeIntervalSinceNow: -13 * 3600)) + manager._testWriteWidgetSnapshot() + // The foreground write is honest about staleness… + #expect(manager._testLastWrittenWidgetSnapshot()?.status == SyncStatus.attention.wireValue) + // …but a successful background sync resolves it, so it must not + // survive into the floor the background write folds in. + #expect(manager._testLastWrittenIssueFloor() == WidgetSnapshotStore.IssueFloor.none) + } + + @MainActor + @Test("A clean state records an empty floor") + func cleanStateRecordsNoFloor() { + let manager = makeManager(lastSync: Date()) + manager._testWriteWidgetSnapshot() + #expect(manager._testLastWrittenIssueFloor() == WidgetSnapshotStore.IssueFloor.none) + #expect(manager._testLastWrittenWidgetSnapshot()?.status == SyncStatus.synced.wireValue) + } +} diff --git a/ios/VaultSyncTests/WidgetCompletionWriteTests.swift b/ios/VaultSyncTests/WidgetCompletionWriteTests.swift new file mode 100644 index 0000000..f5f890d --- /dev/null +++ b/ios/VaultSyncTests/WidgetCompletionWriteTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import VaultSync + +/// The completion branch of `updateWidgetSyncMetrics` used to derive its tier +/// while the widget sync session was still open (`activeWidgetSyncStart` +/// non-nil), so it persisted a stale `.syncing` snapshot that the poll-end +/// write immediately replaced — two snapshot writes and widget reloads per +/// sync completion (#77). +@Suite("Widget completion write persists the settled tier (#77)") +struct WidgetCompletionWriteTests { + @MainActor + private func makeManager() -> SyncthingManager { + let defaults = TestSupport.makeIsolatedDefaults(label: "WidgetCompletionWrite") + let store = SyncHistoryStore(defaults: defaults, storageKey: "history") + // A recent last sync keeps the stale-sync warning out of the cascade — + // these tests pin the syncing → settled transition, nothing else. + store.save(globalLastSync: Date(), lastSyncByFolder: [:]) + let manager = SyncthingManager(syncHistoryStore: store) + manager._testSetLastBackgroundSyncOutcome(nil) + manager._testSetRunning(true) + manager._testSetFolders([ + SyncthingManager.FolderInfo( + id: "vault-a", + label: "Vault A", + path: "/tmp/widget77/vault-a", + type: "sendreceive", + paused: false, + deviceIDs: [] + ), + ]) + return manager + } + + private func status(state: String) -> SyncthingManager.FolderStatusInfo { + SyncthingManager.FolderStatusInfo(payload: .init( + state: state, + stateChanged: "2026-07-07T10:00:00Z", + completionPct: 100, + globalBytes: 0, + globalFiles: 0, + localBytes: 0, + localFiles: 0, + needBytes: 0, + needFiles: 0, + inProgressBytes: 0, + errorReason: nil, + errorMessage: nil, + errorPath: nil, + errorChanged: nil + )) + } + + @MainActor + @Test("Completion persists the settled tier, never a stale .syncing") + func completionPersistsSettledTier() { + let manager = makeManager() + let syncing = ["vault-a": status(state: "syncing")] + let idle = ["vault-a": status(state: "idle")] + + manager._testUpdateWidgetSyncMetrics(previousStatuses: [:], newStatuses: syncing) + manager._testUpdateWidgetSyncMetrics(previousStatuses: syncing, newStatuses: idle) + + #expect(manager._testLastWrittenWidgetSnapshot()?.status == SyncStatus.synced.wireValue) + } + + @MainActor + @Test("The poll-end write after a completion dedupes to a no-op") + func pollEndWriteDedupes() { + let manager = makeManager() + let syncing = ["vault-a": status(state: "syncing")] + let idle = ["vault-a": status(state: "idle")] + + manager._testUpdateWidgetSyncMetrics(previousStatuses: [:], newStatuses: syncing) + manager._testUpdateWidgetSyncMetrics(previousStatuses: syncing, newStatuses: idle) + let afterCompletion = manager._testLastWrittenWidgetSnapshot() + #expect(afterCompletion != nil) + + // The poll publishes the fresh statuses right after the metrics + // update, then writes without an override — the write that used to + // correct the stale .syncing. It must now find an identical snapshot + // and skip (one write, one widget reload per completion). + manager._testSetFolderStatuses(idle) + manager._testWriteWidgetSnapshot() + #expect(manager._testLastWrittenWidgetSnapshot() == afterCompletion) + } +}