From d4d81bb84864ff8c8d67107eaf5118c0c26529ba Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Sat, 15 Aug 2026 00:58:24 +0100 Subject: [PATCH] feat(settings): Advanced section with safeguards, fully-automatic sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Local Sync and Clear Cache both need a real connection or a real cache to be safe against — clearing while offline, or letting sync think it should run with nothing to talk to, can leave the app with no local copy and no way to rebuild one. Both are now disabled (Toggle/button, with a tooltip explaining why) whenever the app isn't effectively online (real network down OR the manual Offline Mode toggle). Clear Cache moves out of Offline & Sync entirely into a new Advanced section (sidebar, above About) — the one deliberate escape hatch that works even offline, gated behind an off-by-default "Enable Advanced Options" master toggle that only turns on after a confirmation dialog warning about data loss. A few not-yet-implemented settings sit under it as permanently-dimmed "Coming Soon" rows. Nothing destructive is reachable outside this one screen, so there's no path to breaking Full Local Sync's cache by accident. Full Local Sync itself is now fully automatic: RootView's background task syncs immediately whenever it (re)starts — covers both "just turned on" and "was already on at a fresh launch" — then every 20 minutes, and again immediately on reconnect. No more needing to press Sync Now by hand. --- Outpost/Features/Account/SettingsView.swift | 145 +++++++++++++++++--- Outpost/Root/AppNavigation.swift | 4 +- Outpost/Root/RootView.swift | 22 ++- 3 files changed, 142 insertions(+), 29 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 6a9df19..07fac73 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -14,7 +14,9 @@ struct SettingsView: View { @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false + @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false @State private var isShowingLogoutConfirmation = false + @State private var isShowingAdvancedWarning = false @State private var storageSummary: CacheStorageSummary? @State private var pendingOperations: [PendingOperationSummary] = [] @State private var isSyncing = false @@ -22,6 +24,16 @@ struct SettingsView: View { @State private var lastFullSyncSummary: FullSyncSummary? @State private var lastFlushSummary: SyncFlushSummary? + /// Full Local Sync and cache-clearing both need a real connection to be + /// safe — clearing while offline (or letting Full Local Sync think it + /// should be running) can leave the app with nothing local to show and + /// no way to refetch it. "Offline" here means either a real dropped + /// connection or the user's own manual toggle — both leave the app with + /// no server to talk to. + private var isEffectivelyOnline: Bool { + session.networkMonitor.isOnline && !isOfflineModeEnabled + } + var body: some View { ScrollView { sectionDetail @@ -40,6 +52,7 @@ struct SettingsView: View { case .appearance: appearanceDetail case .account: accountDetail case .offlineSync: offlineSyncDetail + case .advanced: advancedDetail case .about: aboutDetail } } @@ -103,14 +116,18 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 6) { Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled) - .onChange(of: isFullLocalSyncEnabled) { _, enabled in - if enabled { Task { await runFullSync() } } - } - Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline.") + .disabled(!isEffectivelyOnline) + Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.") .font(.caption) .foregroundStyle(.secondary) + if !isEffectivelyOnline { + Text("Requires an internet connection to turn on or off.") + .font(.caption2) + .foregroundStyle(.orange) + } } .frame(maxWidth: 480, alignment: .leading) + .help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.") if isFullLocalSyncEnabled { VStack(alignment: .leading, spacing: 6) { @@ -123,6 +140,7 @@ struct SettingsView: View { } else { Button("Sync Now") { Task { await runFullSync() } } .controlSize(.small) + .disabled(!isEffectivelyOnline) if let lastFullSyncSummary { Text(fullSyncSummaryText(lastFullSyncSummary)) .font(.caption) @@ -156,24 +174,8 @@ struct SettingsView: View { private var storageRow: some View { VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Cache Storage") - .font(.subheadline.weight(.medium)) - Spacer() - Button(role: .destructive) { - Task { await clearCache() } - } label: { - if isClearingCache { - ProgressView().controlSize(.small) - } else { - Text("Clear Cache") - } - } - .buttonStyle(.plain) - .foregroundStyle(.red) - .font(.caption) - .disabled(isClearingCache || (storageSummary?.itemCount ?? 0) == 0) - } + Text("Cache Storage") + .font(.subheadline.weight(.medium)) if let storageSummary { Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))") .font(.caption) @@ -183,6 +185,9 @@ struct SettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + Text("Clearing the cache is in Advanced Options.") + .font(.caption2) + .foregroundStyle(.secondary) } } @@ -248,6 +253,102 @@ struct SettingsView: View { } } + // MARK: - Advanced + + /// Everything here either does something destructive (clearing the + /// cache Full Local Sync just spent minutes building) or doesn't exist + /// yet — gating all of it behind an off-by-default master toggle plus a + /// confirmation to turn that toggle on is the safeguard: nothing here + /// can be reached by accident, and nothing outside this section can + /// touch the cache at all, so there's no path to "messed up Full Local + /// Sync" that doesn't go through this screen on purpose. + private var advancedDetail: some View { + VStack(alignment: .leading, spacing: 20) { + sectionHeader + + VStack(alignment: .leading, spacing: 6) { + Toggle("Enable Advanced Options", isOn: advancedOptionsBinding) + Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 12) { + comingSoonRow("Export All Data") + comingSoonRow("Developer Diagnostics") + comingSoonRow("Reset Local Database") + } + .frame(maxWidth: 480, alignment: .leading) + + Divider() + .frame(maxWidth: 480) + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Clear All Cache") + Spacer() + Button(role: .destructive) { + Task { await clearCache() } + } label: { + if isClearingCache { + ProgressView().controlSize(.small) + } else { + Text("Clear") + } + } + } + Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.") + .font(.caption2) + .foregroundStyle(.secondary) + } + .disabled(!isAdvancedOptionsEnabled || isClearingCache) + .opacity(isAdvancedOptionsEnabled ? 1 : 0.4) + .frame(maxWidth: 480, alignment: .leading) + } + .confirmationDialog( + "Enable Advanced Options?", + isPresented: $isShowingAdvancedWarning, + titleVisibility: .visible + ) { + Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true } + Button("Cancel", role: .cancel) {} + } message: { + Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.") + } + } + + /// Never writes `true` directly — turning the toggle on only opens the + /// warning dialog; only that dialog's own "Enable" button actually sets + /// it. Turning off doesn't need confirmation. + private var advancedOptionsBinding: Binding { + Binding( + get: { isAdvancedOptionsEnabled }, + set: { newValue in + if newValue { + isShowingAdvancedWarning = true + } else { + isAdvancedOptionsEnabled = false + } + } + ) + } + + private func comingSoonRow(_ title: String) -> some View { + HStack { + Text(title) + Spacer() + Text("Coming Soon") + .font(.caption) + .foregroundStyle(.secondary) + } + .disabled(true) + .opacity(0.5) + } + // MARK: - About private var aboutDetail: some View { diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 8d31750..6804aef 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -1,7 +1,7 @@ import Observation enum SettingsSection: String, CaseIterable, Identifiable, Hashable { - case appearance, account, offlineSync, about + case appearance, account, offlineSync, advanced, about var id: String { rawValue } @@ -10,6 +10,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .appearance: return "Appearance" case .account: return "Account" case .offlineSync: return "Offline & Sync" + case .advanced: return "Advanced" case .about: return "About" } } @@ -19,6 +20,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { case .appearance: return "paintbrush" case .account: return "person.crop.circle" case .offlineSync: return "arrow.triangle.2.circlepath" + case .advanced: return "wrench.and.screwdriver" case .about: return "info.circle" } } diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index ab32307..4df0d61 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -35,20 +35,30 @@ struct RootView: View { } // Replay whatever queued up while offline the moment the network's // back — no need to wait for the user to open Settings and hit Retry. + // Also catches Full Local Sync back up immediately on reconnect, + // rather than leaving it to wait out the rest of the periodic loop + // below. .task(id: session.networkMonitor.isOnline) { guard session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { return } _ = await cachingClient.flushPendingOperations() + if isFullLocalSyncEnabled { + _ = await cachingClient.performFullSync() + } } - // Full Local Sync re-walks the whole workspace periodically while - // enabled, in addition to the immediate sync Settings kicks off when - // the toggle is first switched on — keeps a long-running session from - // slowly drifting stale. + // Fully automatic — this is the only place Full Local Sync actually + // runs from (Settings' "Sync Now" is just an on-demand nudge at the + // same call). Syncs immediately whenever this task (re)starts, which + // covers both "just switched on" and "was already on at launch" — + // `.task(id:)` restarts on either, an `@AppStorage`-backed toggle + // changing anywhere updates every view reading that key — then every + // 20 minutes after, for as long as it stays enabled. .task(id: isFullLocalSyncEnabled) { guard isFullLocalSyncEnabled else { return } while !Task.isCancelled { + if session.networkMonitor.isOnline, let cachingClient = session.cachingClient { + _ = await cachingClient.performFullSync() + } try? await Task.sleep(for: .seconds(1200)) - guard !Task.isCancelled, session.networkMonitor.isOnline, let cachingClient = session.cachingClient else { continue } - _ = await cachingClient.performFullSync() } } }