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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions docs/decisions/013-background-widget-write-issue-floor.md
Original file line number Diff line number Diff line change
@@ -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`.
147 changes: 119 additions & 28 deletions ios/VaultSync/Services/BackgroundSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
Loading
Loading