diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1b378..7485eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,10 @@ All notable changes to VaultSync are documented here. ### Fixed - **A share can no longer sync into a folder that already contains other files without asking** ([#54](https://github.com/psimaker/vaultsync/issues/54)) — if a folder under your Obsidian directory happened to have the same name as an incoming share (or the folder selected for VaultSync was itself a vault), the share was accepted straight into it — silently combining two sets of files and syncing the result to your other devices. VaultSync now stops before that happens: the share waits under "Pending Shares" with an explanation, and only you can approve the merge — in a dialog that spells out exactly what will be combined and where it will be synced. Approving is right when the folder holds this same vault's earlier notes (for example after removing the vault and accepting it again); for anything else, "Choose Vault…" picks a safe location. Enforced in the sync engine itself as well, so no code path can merge silently. Localized in English, German, Spanish, and Simplified Chinese. +- **The merge confirmation shows a visible Cancel button on iOS 26 again** ([#64](https://github.com/psimaker/vaultsync/issues/64)) — on iOS 26 the dialog asking whether a share may sync into a folder that already contains files rendered with the red "Merge and Sync" as its only visible button; canceling was possible only by tapping outside the dialog. The vault-removal question was affected the same way, as were the device-removal confirmation (which offered no Cancel at all on iOS 26) and the conflict-resolution confirmation. All four are now standard alerts whose full explanation, destructive action, and Cancel button render on every iOS version (verified on iOS 18 and iOS 26); the wording is unchanged. - **Shares are no longer accepted while vault locations are still being checked after launch** ([#56](https://github.com/psimaker/vaultsync/issues/56)) — right after starting, VaultSync briefly re-checks where every vault lives on this iPhone (that is what repairs sync when iOS has moved the app's storage). A share arriving in exactly that window could be judged against the not-yet-repaired locations — in the worst case it took the very folder an existing vault was about to be restored to, mixing the two. All accept decisions — automatic and manual, including the merge confirmation — now wait until that check has finished: automatic accepts simply continue on their own a moment later, and tapping an accept button during the window asks you to try again in a moment. Nothing is ever moved automatically. Localized in English, German, Spanish, and Simplified Chinese. - **Waiting shares are picked up right after reconnecting the Obsidian folder** ([#53](https://github.com/psimaker/vaultsync/issues/53)) — after re-selecting the Obsidian folder, a share that had been waiting (for example because the folder connection was broken, or because there was no safe location under the previously selected folder) was not retried until some unrelated event — often not before an app restart. Reconnecting now retries waiting shares immediately — and only after the vault locations have settled against the newly selected folder, so the safety checks judge against current locations, never stale ones. +- **The "vault folder was moved or deleted" error now explains itself — in your language** ([#65](https://github.com/psimaker/vaultsync/issues/65)) — when a vault's folder was moved, renamed, replaced, or deleted outside VaultSync, the app showed Syncthing's raw English developer text ("folder marker missing …") in every language, paired with a rescan suggestion that cannot help in that situation. The error now explains what happened and how to recover, in plain language: move the folder back, or remove the vault and accept its share again — VaultSync never moves, recreates, or deletes folders on its own. The technical engine text remains available in the details, the "Rescan Failed Vaults" button is no longer offered when no rescan can help, and the troubleshooting guide gained a dedicated section. Localized in English, German, Spanish, and Simplified Chinese. - **Opening the app during a background sync no longer shows a stopped engine — or silently stops it** ([#60](https://github.com/psimaker/vaultsync/issues/60)) — when iOS woke VaultSync in the background (scheduled refresh, overnight catch-up, or a Cloud Relay wake-up) and you opened the app while that run was still going, the app failed to attach to the already-running sync engine: the vault list stayed empty and the status looked stopped — and when the background run then finished, it shut the engine down right under the open app, leaving sync dead until the next reopen. The app now adopts the running engine the moment it comes to the foreground: status and vaults load normally, the engine keeps running, and share accepts keep waiting until vault locations have settled, exactly as after a normal start. - **The welcome setup no longer shows a "sync already running" error** ([#61](https://github.com/psimaker/vaultsync/issues/61)) — opening or completing setup while a background wake-up had already started the sync engine could surface a confusing error in the very first moments of use. The app now attaches to the running engine instead — at setup start, at setup completion, and on every foreground return, all through the same logic. - **The status no longer claims "Ready" while the sync engine is actually stopped** ([#61](https://github.com/psimaker/vaultsync/issues/61)) — in a rare timing window, a finishing background run could stop the engine right under the open app; the header then kept showing a healthy "Ready" although nothing was syncing (your notes were never at risk — sync simply wasn't running). The app now notices the stopped engine within seconds and restarts it once automatically; should it stop again, the status says so honestly and explains how to restart. Localized in English, German, Spanish, and Simplified Chinese. diff --git a/docs/decisions/011-consent-dialogs-alert-not-confirmation-dialog.md b/docs/decisions/011-consent-dialogs-alert-not-confirmation-dialog.md new file mode 100644 index 0000000..7480ebb --- /dev/null +++ b/docs/decisions/011-consent-dialogs-alert-not-confirmation-dialog.md @@ -0,0 +1,7 @@ +# 011 — Consent dialogs use .alert, never .confirmationDialog + +- **Context:** On iOS 26, SwiftUI's `.confirmationDialog` renders as a centered popover WITHOUT its cancel-role button (dismissal only by tapping outside); iOS 18 renders an action sheet with a visible Cancel. The merge-consent dialog (the #54 consent gate) and the vault-removal dialog therefore showed the destructive action as their only visible choice; the device-removal dialog declared no Cancel at all (1.8.0 UI audit, #64, verified empirically on both OS versions). +- **Decision:** Safety- and consent-relevant decisions are presented as `.alert` (or a purpose-built sheet with an explicit Cancel action) — never `.confirmationDialog`. All four existing instances converted (ContentView ×2, DeviceDetailView, ConflictDiffView). +- **Why:** A consent gate whose only visible button is the destructive action funnels users into exactly the action the gate exists to slow down. +- **Rejected alternative:** Keeping `.confirmationDialog` with shortened text so the popover renders smaller — the missing Cancel is independent of text length, and the audit rated the consequence texts as exemplary; shortening trades away clarity for nothing. +- **Links:** #64. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 314c644..5b87755 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -10,6 +10,7 @@ Find your symptom, jump to the fix. After each fix, retry from the app to confir | Stuck on "Confirm your first share" | [No Pending Shares Appear](#no-pending-shares-appear) | | Subscribed, but no wake-ups arrive | [APNs Not Registered](#apns-not-registered) | | Vault list empty, "folder not connected" | [Obsidian Folder Not Found](#obsidian-folder-not-found) | +| "Vault Folder Was Moved or Deleted" on a vault | [Vault Folder Was Moved or Deleted](#vault-folder-was-moved-or-deleted) | | "Cannot access this folder" / reconnect needed | [Bookmark Access Expired](#bookmark-access-expired) | | Syncs in foreground but not when closed | [Background Sync Not Working](#background-sync-not-working) | | "Required device disconnected" | [Required Device Disconnected](#required-device-disconnected) | @@ -113,6 +114,19 @@ Silent push wake-ups use a background push that **does not need notification per --- +## Vault Folder Was Moved or Deleted + +**Looks like:** a vault shows "Vault Folder Was Moved or Deleted" and its syncing has stopped. The vault's folder was moved, renamed, replaced, or deleted outside VaultSync (in the Files app, by Obsidian, or by another app), so VaultSync can no longer verify that the folder still holds this vault's data. + +**Fix:** +1. If you moved or renamed the folder: move it back to its original place and name — syncing resumes on its own. +2. If the folder is gone or was replaced: remove the vault in VaultSync on this iPhone, then accept its share again under **Pending Shares** — it syncs into a fresh folder. +3. If notes are missing on this iPhone, they are still on your other synced devices — re-accepting the share in step 2 brings them back. + +VaultSync never moves, recreates, or deletes folders on its own, and a rescan cannot fix this — recovery here is always your manual decision. + +--- + ## Bookmark Access Expired VaultSync re-derives every vault's location from your Obsidian folder on launch, so most storage moves self-heal. When a vault still can't be reached, the security-scoped bookmark needs renewing. diff --git a/ios/VaultSync/App/UIAuditFixture.swift b/ios/VaultSync/App/UIAuditFixture.swift new file mode 100644 index 0000000..699859f --- /dev/null +++ b/ios/VaultSync/App/UIAuditFixture.swift @@ -0,0 +1,27 @@ +import Foundation + +#if DEBUG +/// LAB: launch-argument fixtures for verifying safety-relevant UI empirically +/// on a simulator — consent dialogs and error rows that otherwise need a +/// paired peer or damaged on-disk state (#64/#65 audit evidence). Pass +/// `-uiaudit-fixture ` plus `-hasCompletedOnboarding YES` at launch to +/// seed the state. While a fixture is active the app skips all engine +/// management: the seeded folder/status state must not be overwritten by live +/// bridge polling (same reasoning as TestHost). Compiled out of release +/// builds, so it can never affect shipping behaviour. +enum UIAuditFixture { + static let mergeConsent = "merge-consent" + static let removalConsent = "removal-consent" + static let markerError = "marker-error" + static let deviceRemovalConsent = "device-removal-consent" + static let conflictResolveConsent = "conflict-resolve-consent" + + /// The fixture named by `-uiaudit-fixture `, read via the argument + /// domain UserDefaults overlay; nil in any normal run. + static var active: String? { + UserDefaults.standard.string(forKey: "uiaudit-fixture") + } + + static var isActive: Bool { active != nil } +} +#endif diff --git a/ios/VaultSync/App/VaultSyncApp.swift b/ios/VaultSync/App/VaultSyncApp.swift index 452ff1f..01e7857 100644 --- a/ios/VaultSync/App/VaultSyncApp.swift +++ b/ios/VaultSync/App/VaultSyncApp.swift @@ -65,6 +65,11 @@ struct VaultSyncApp: App { // The unit-test host must never manage the process-global engine // lifecycle — see TestHost. guard !TestHost.isActive else { return } + #if DEBUG + // A UI-audit fixture run seeds manager state directly; starting + // the engine would overwrite it — see UIAuditFixture. + guard !UIAuditFixture.isActive else { return } + #endif switch newPhase { case .active: BackgroundSyncService.setSceneActive(true) @@ -116,6 +121,9 @@ struct VaultSyncApp: App { } .onChange(of: hasCompletedOnboarding) { _, completed in guard !TestHost.isActive else { return } + #if DEBUG + guard !UIAuditFixture.isActive else { return } + #endif if completed { // Second consumer of the #60 state: guarding on the manager // alone made this call start() against an engine a background diff --git a/ios/VaultSync/Models/SyncUserError.swift b/ios/VaultSync/Models/SyncUserError.swift index 59cd4bd..12e3e70 100644 --- a/ios/VaultSync/Models/SyncUserError.swift +++ b/ios/VaultSync/Models/SyncUserError.swift @@ -10,6 +10,7 @@ enum SyncUserErrorCategory: String, Sendable { case relayUnreachable = "relay_unreachable" case relayProvision = "relay_provision" case fileAccess = "file_access" + case folderMarkerMissing = "folder_marker_missing" case unknown } @@ -45,6 +46,13 @@ struct SyncUserError: Identifiable, Equatable, Sendable { ) } + // Syncthing's marker-missing text mentions potential data loss and + // must never surface raw; matched here too so a failed rescan of an + // affected folder maps the same way as its folder status (#65). + if normalized.contains("marker missing") { + return folderMarkerMissing(detail: rawMessage, path: nil) + } + if isRelayConnectivityError(normalized) { return SyncUserError( category: .relayUnreachable, @@ -127,6 +135,13 @@ struct SyncUserError: Identifiable, Equatable, Sendable { let detail = message ?? L10n.tr("Folder is currently in an error state.") let pathHint = path.map { L10n.fmt(" (%@)", $0) } ?? "" + // The bridge classifies marker loss as "unknown_error", so it has to + // be matched on the message — before the reason switch, or the raw + // English engine text becomes the user-facing message (#65). + if detail.lowercased().contains("marker missing") { + return folderMarkerMissing(detail: detail, path: path) + } + switch normalizedReason { case "permission_denied": return SyncUserError( @@ -163,6 +178,26 @@ struct SyncUserError: Identifiable, Equatable, Sendable { } } + /// Syncthing's marker-missing error means the folder was moved, renamed, + /// replaced, or deleted outside the app while still configured to sync. + /// Doctrine-002 mapping: explain, stay stopped, let the user act — a + /// rescan cannot recreate the marker (Syncthing refuses to scan without + /// it, by design), and recreating or re-pointing anything automatically + /// risks propagating the loss to every peer (safety rule 3). + private static func folderMarkerMissing(detail: String, path: String?) -> SyncUserError { + let pathHint = path.map { L10n.fmt(" (%@)", $0) } ?? "" + return SyncUserError( + category: .folderMarkerMissing, + title: L10n.tr("Vault Folder Was Moved or Deleted"), + message: L10n.fmt( + "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes.", + pathHint + ), + remediation: L10n.tr("If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own."), + technicalDetails: detail + ) + } + static func relayProvisionFailed(reason: String) -> SyncUserError { let normalized = reason.lowercased() if isRelayConnectivityError(normalized) { @@ -223,6 +258,8 @@ struct SyncUserError: Identifiable, Equatable, Sendable { } else { anchor = "bookmark-access-expired" } + case .folderMarkerMissing: + anchor = "vault-folder-was-moved-or-deleted" case .config, .validation: if details.contains("pending") || details.contains("share") { anchor = "no-pending-shares-appear" diff --git a/ios/VaultSync/Services/SyncthingManager.swift b/ios/VaultSync/Services/SyncthingManager.swift index 6736ba8..ffba11e 100644 --- a/ios/VaultSync/Services/SyncthingManager.swift +++ b/ios/VaultSync/Services/SyncthingManager.swift @@ -532,7 +532,12 @@ final class SyncthingManager { kind: .folderErrors, title: count == 1 ? L10n.tr("1 Vault Has Sync Errors") : L10n.fmt("%d Vaults Have Sync Errors", count), message: L10n.tr("At least one folder is currently in an error state."), - remediation: L10n.tr("Rescan failed vaults, then verify folder access and permissions."), + // A rescan cannot recreate a missing folder marker — when + // marker loss is the only error, pointing at it would + // misdirect the recovery (#65). + remediation: hasRescanableFolderErrors + ? L10n.tr("Rescan failed vaults, then verify folder access and permissions.") + : L10n.tr("Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted."), severity: .critical, count: count, folderID: erroredFolderIDs.first, @@ -1911,6 +1916,17 @@ final class SyncthingManager { ) } + /// True when at least one errored folder could plausibly be helped by a + /// rescan. A folder whose sync marker is gone cannot — Syncthing refuses + /// to scan without the marker, by design — so when marker loss is the + /// only error, the rescan CTA is hidden and the doctrine-002 prose is the + /// guidance (#65). + var hasRescanableFolderErrors: Bool { + folderIDsWithErrors.contains { id in + folderUserError(folderID: id)?.category != .folderMarkerMissing + } + } + /// A folder stuck in a path-related error that the launch-time path /// reconcile could not auto-heal — typically a legacy folder pointing at a /// since-removed app-container location (issue #25). Surfaced to the user as @@ -2263,5 +2279,9 @@ final class SyncthingManager { func _testSetConflictFiles(_ newConflicts: [String: [ConflictInfo]]) { conflictFiles = newConflicts } + + func _testSetFolderStatuses(_ newStatuses: [String: FolderStatusInfo]) { + folderStatuses = newStatuses + } #endif } diff --git a/ios/VaultSync/Views/ConflictDiffView.swift b/ios/VaultSync/Views/ConflictDiffView.swift index 961a844..01a5deb 100644 --- a/ios/VaultSync/Views/ConflictDiffView.swift +++ b/ios/VaultSync/Views/ConflictDiffView.swift @@ -75,6 +75,16 @@ struct ConflictDiffView: View { } .navigationTitle("Resolve Conflict") .navigationBarTitleDisplayMode(.inline) + #if DEBUG + .onAppear { + // LAB: present the resolve consent immediately for the UI-audit + // fixture run (#64); reachable only via launch argument. + if UIAuditFixture.active == UIAuditFixture.conflictResolveConsent { + actionToConfirm = .keepThis + showConfirmAlert = true + } + } + #endif .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { @@ -100,10 +110,13 @@ struct ConflictDiffView: View { } message: { Text(alertMessage ?? "") } - .confirmationDialog( + // Same iOS-26 rendering defect as the consent dialogs: a + // .confirmationDialog hides its cancel-role button there, and one of + // these actions discards a version of a note — .alert keeps Cancel + // visible on every OS version (#64, decision 011). + .alert( "Resolve Conflict", isPresented: $showConfirmAlert, - titleVisibility: .visible, presenting: actionToConfirm ) { action in Button(confirmButtonTitle(for: action), role: action == .keepBoth ? nil : .destructive) { diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 7b664ed..331fbee 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -21,6 +21,9 @@ struct ContentView: View { @State private var vaultPendingRemoval: VaultRemovalTarget? @State private var pendingMergeConfirmation: MergeConfirmationRequest? @State private var showRelayUpsellCard = false + #if DEBUG + @State private var uiAuditDetailFixture: UIAuditDetailFixture? + #endif /// A vault the user has asked to remove, pending confirmation. Drives the /// shared removal confirmation dialog used by both the "needs attention" @@ -152,10 +155,14 @@ struct ContentView: View { } } } - .confirmationDialog( + // Consent decisions are presented as .alert, never .confirmationDialog: + // on iOS 26 a confirmation dialog renders as a centered popover WITHOUT + // a visible Cancel button, so the destructive action was the only + // visible choice on the very dialogs that exist to slow it down + // (#64, decision 011). + .alert( L10n.tr("Remove this vault from this iPhone?"), isPresented: removalBinding, - titleVisibility: .visible, presenting: vaultPendingRemoval ) { target in Button(L10n.tr("Remove Vault"), role: .destructive) { @@ -165,10 +172,9 @@ struct ContentView: View { } message: { target in Text(L10n.fmt("“%@” will stop syncing on this iPhone. Files already on your other devices are not deleted.", target.label)) } - .confirmationDialog( + .alert( L10n.tr("Sync into a folder that already contains files?"), isPresented: mergeConfirmationBinding, - titleVisibility: .visible, presenting: pendingMergeConfirmation ) { request in Button(L10n.tr("Merge and Sync"), role: .destructive) { @@ -210,6 +216,26 @@ struct ContentView: View { .onChange(of: subscriptionManager.isRelaySubscribed) { _, _ in maybePresentRelayUpsell() } + #if DEBUG + .onAppear(perform: applyUIAuditFixture) + .sheet(item: $uiAuditDetailFixture) { fixture in + NavigationStack { + switch fixture { + case .deviceRemoval: + DeviceDetailView( + device: Self.uiAuditFixtureDevice, + syncthingManager: syncthingManager + ) + case .conflictResolve: + ConflictDiffView( + folderID: "uiaudit-vault", + conflict: Self.uiAuditFixtureConflict, + syncthingManager: syncthingManager + ) + } + } + } + #endif } /// The Sync tab — the vault's live-status story: the pinned status header, @@ -334,6 +360,88 @@ struct ContentView: View { } } + // MARK: - UI-Audit Fixtures (#64/#65) + + #if DEBUG + private enum UIAuditDetailFixture: String, Identifiable { + case deviceRemoval + case conflictResolve + var id: String { rawValue } + } + + /// LAB: seed the state behind a consent dialog or error row from the + /// `-uiaudit-fixture` launch argument so it renders on a simulator + /// without a paired peer or damaged on-disk state (#64/#65 audit + /// evidence). Engine management is skipped for the whole fixture run — + /// see UIAuditFixture. Compiled out of release builds. + private func applyUIAuditFixture() { + switch UIAuditFixture.active { + case UIAuditFixture.mergeConsent: + pendingMergeConfirmation = MergeConfirmationRequest( + folder: SyncthingManager.PendingFolderInfo( + id: "uiaudit-vault", + label: "Life Notes", + offeredBy: [] + ), + targetName: "Life Notes" + ) + case UIAuditFixture.removalConsent: + vaultPendingRemoval = VaultRemovalTarget(id: "uiaudit-vault", label: "Life Notes") + case UIAuditFixture.markerError: + syncthingManager._testSetFolders([ + SyncthingManager.FolderInfo( + id: "uiaudit-vault", + label: "Life Notes", + path: "/var/mobile/Obsidian/Life Notes", + type: "sendreceive", + paused: false, + deviceIDs: [] + ), + ]) + syncthingManager._testSetFolderStatuses([ + "uiaudit-vault": 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: "folder marker missing (this indicates potential data loss, search docs/forum to get information about how to proceed)", + errorPath: "/var/mobile/Obsidian/Life Notes", + errorChanged: nil + )), + ]) + case UIAuditFixture.deviceRemovalConsent: + uiAuditDetailFixture = .deviceRemoval + case UIAuditFixture.conflictResolveConsent: + uiAuditDetailFixture = .conflictResolve + default: + break + } + } + + private static let uiAuditFixtureDevice: SyncthingManager.DeviceInfo = { + // DeviceInfo has a custom Decodable init and no memberwise init — + // decoding a literal is the fixture's only construction path. + try! JSONDecoder().decode( + SyncthingManager.DeviceInfo.self, + from: Data(#"{"deviceID":"UIAUDIT-DEVICE","name":"Desktop","connected":true}"#.utf8) + ) + }() + + private static let uiAuditFixtureConflict = SyncthingManager.ConflictInfo( + originalPath: "Notes/daily.md", + conflictPath: "Notes/daily.sync-conflict-20260707-101010-UIAUDIT.md", + conflictDate: "2026-07-07T10:10:10Z", + deviceShortID: "UIAUDIT" + ) + #endif + // MARK: - Auto-Accept Pending Shares private func autoAcceptPendingShares(_ pending: [SyncthingManager.PendingFolderInfo]) { diff --git a/ios/VaultSync/Views/DeviceDetailView.swift b/ios/VaultSync/Views/DeviceDetailView.swift index f658243..082bd4c 100644 --- a/ios/VaultSync/Views/DeviceDetailView.swift +++ b/ios/VaultSync/Views/DeviceDetailView.swift @@ -91,18 +91,29 @@ struct DeviceDetailView: View { .navigationBarTitleDisplayMode(.inline) .onAppear { editedName = device.name + #if DEBUG + // LAB: present the removal consent immediately for the UI-audit + // fixture run (#64); reachable only via launch argument. + if UIAuditFixture.active == UIAuditFixture.deviceRemovalConsent { + showRemoveConfirm = true + } + #endif } .onDisappear { saveName() } - .confirmationDialog( + // Consent decisions are presented as .alert, never .confirmationDialog: + // on iOS 26 the latter renders without a visible Cancel — and this + // dialog declared none at all, offering "Remove" as the only choice + // (#64, decision 011). + .alert( "Remove this device?", - isPresented: $showRemoveConfirm, - titleVisibility: .visible + isPresented: $showRemoveConfirm ) { Button("Remove", role: .destructive) { removeDevice() } + Button("Cancel", role: .cancel) { } } message: { Text("The device will be disconnected and removed from all shared folders.") } diff --git a/ios/VaultSync/Views/SyncIssuesView.swift b/ios/VaultSync/Views/SyncIssuesView.swift index 87778d0..0f89fd7 100644 --- a/ios/VaultSync/Views/SyncIssuesView.swift +++ b/ios/VaultSync/Views/SyncIssuesView.swift @@ -81,11 +81,17 @@ struct SyncIssuesView: View { EmptyView() case .folderErrors: - Button("Rescan Failed Vaults") { - onRescanFailedFolders() + // A rescan cannot recreate a missing folder marker — when marker + // loss is the only error, hide the button and let the prose + // remediation guide the manual recovery (#65, same stance as + // .pathCollision). + if syncthingManager.hasRescanableFolderErrors { + Button("Rescan Failed Vaults") { + onRescanFailedFolders() + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) } - .buttonStyle(.borderedProminent) - .controlSize(.regular) case .disconnectedPeers: Button("Add or Reconnect Device") { diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 6b627a6..809d886 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -654,3 +654,7 @@ "Folder Already Contains Files" = "Ordner enthält bereits Dateien"; "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "Der Ordner „%@“ enthält bereits Dateien. Die Freigabe anzunehmen würde seinen Inhalt mit dem geteilten Vault zusammenführen und das Ergebnis mit den anderen Geräten synchronisieren."; "Tap \"Retry Accept\" to review and confirm, or \"Choose Vault…\" to pick a different location." = "Tippe auf „Annahme erneut versuchen“, um zu prüfen und zu bestätigen, oder wähle über „Vault wählen…“ einen anderen Ort."; +"Vault Folder Was Moved or Deleted" = "Vault-Ordner wurde verschoben oder gelöscht"; +"VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync kann nicht mehr überprüfen, ob dieser Ordner noch die Daten dieses Vaults enthält%@ — der Ordner wurde vermutlich außerhalb von VaultSync verschoben, umbenannt, ersetzt oder gelöscht. Die Synchronisierung wurde gestoppt, um deine Notizen zu schützen."; +"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "Hast du den Ordner verschoben oder umbenannt, verschiebe ihn an seinen ursprünglichen Ort zurück. Ist er nicht mehr vorhanden, entferne diesen Vault auf diesem iPhone und nimm ihn unter „Ausstehende Freigaben“ erneut an. VaultSync verschiebt, erstellt oder löscht Ordner nie von selbst."; +"Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "Folge den Wiederherstellungsschritten beim betroffenen Vault — ein erneuter Scan kann einen verschobenen oder gelöschten Vault-Ordner nicht reparieren."; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index a7978f6..e9512a9 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -654,3 +654,7 @@ "Folder Already Contains Files" = "Folder Already Contains Files"; "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices."; "Tap \"Retry Accept\" to review and confirm, or \"Choose Vault…\" to pick a different location." = "Tap \"Retry Accept\" to review and confirm, or \"Choose Vault…\" to pick a different location."; +"Vault Folder Was Moved or Deleted" = "Vault Folder Was Moved or Deleted"; +"VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes."; +"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own."; +"Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 3a3ad6b..6599626 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -654,3 +654,7 @@ "Folder Already Contains Files" = "La carpeta ya contiene archivos"; "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "La carpeta \"%@\" ya contiene archivos. Aceptar esta compartición combinaría su contenido con el Vault compartido y sincronizaría el resultado con los demás dispositivos."; "Tap \"Retry Accept\" to review and confirm, or \"Choose Vault…\" to pick a different location." = "Toca \"Reintentar aceptar\" para revisar y confirmar, o \"Elegir Vault…\" para elegir otra ubicación."; +"Vault Folder Was Moved or Deleted" = "La carpeta del Vault se movió o se eliminó"; +"VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync ya no puede verificar que esta carpeta siga conteniendo los datos de este Vault%@: probablemente se movió, se renombró, se reemplazó o se eliminó fuera de VaultSync. La sincronización se ha detenido para proteger tus notas."; +"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "Si moviste o renombraste la carpeta, devuélvela a su ubicación original. Si ya no existe, elimina este Vault en este iPhone y acéptalo de nuevo en \"Comparticiones pendientes\". VaultSync nunca mueve, recrea ni elimina carpetas por su cuenta."; +"Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "Sigue los pasos de recuperación que se muestran junto al Vault afectado: un nuevo escaneo no puede reparar una carpeta de Vault movida o eliminada."; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index b455b1c..5df3679 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -654,3 +654,7 @@ "Folder Already Contains Files" = "文件夹已包含文件"; "The folder \"%@\" already contains files. Accepting this share would combine its contents with the shared vault and sync the result to the other devices." = "文件夹“%@”已包含文件。接受此共享会将其内容与共享的 Vault 合并,并将结果同步到其他设备。"; "Tap \"Retry Accept\" to review and confirm, or \"Choose Vault…\" to pick a different location." = "点按“重试接受”以查看并确认,或使用“选择 Vault…”另选位置。"; +"Vault Folder Was Moved or Deleted" = "Vault 文件夹已被移动或删除"; +"VaultSync can no longer verify that this folder still holds this vault's data%@ — the folder was likely moved, renamed, replaced, or deleted outside VaultSync. Syncing has stopped to protect your notes." = "VaultSync 已无法确认此文件夹仍包含该 Vault 的数据%@——该文件夹可能在 VaultSync 之外被移动、重命名、替换或删除。同步已停止,以保护你的笔记。"; +"If you moved or renamed the folder, move it back to its original place. If it is gone, remove this vault on this iPhone and accept it again under Pending Shares. VaultSync never moves, recreates, or deletes folders on its own." = "如果你移动或重命名了该文件夹,请将它移回原来的位置。如果它已不存在,请在此 iPhone 上移除该 Vault,然后在“待处理共享”中重新接受它。VaultSync 绝不会自行移动、重新创建或删除文件夹。"; +"Follow the recovery steps shown with the affected vault — rescanning cannot fix a vault folder that was moved or deleted." = "请按照受影响 Vault 旁显示的恢复步骤操作——重新扫描无法修复已被移动或删除的 Vault 文件夹。"; diff --git a/ios/VaultSyncTests/SyncUserErrorTests.swift b/ios/VaultSyncTests/SyncUserErrorTests.swift index 6393cf3..cab83c5 100644 --- a/ios/VaultSyncTests/SyncUserErrorTests.swift +++ b/ios/VaultSyncTests/SyncUserErrorTests.swift @@ -61,3 +61,119 @@ struct SyncUserErrorTests { #expect(error.remediation == L10n.tr("Retry the action. If it keeps failing, restart the app and check Settings diagnostics.")) } } + +@Suite("Marker-missing folder error mapping (#65)") +struct FolderMarkerMissingMappingTests { + /// Syncthing's literal engine text (lib/config/folderconfiguration.go) — + /// the exact string users saw raw and untranslated. + static let rawEngineText = + "folder marker missing (this indicates potential data loss, search docs/forum to get information about how to proceed)" + + @Test("Folder status maps the raw engine text to localized guidance") + func mapsFolderStatusMarkerMissing() { + let error = SyncUserError.fromFolderStatus( + reason: "unknown_error", + message: Self.rawEngineText, + path: "/vaults/Life" + ) + + #expect(error.category == .folderMarkerMissing) + #expect(error.title == L10n.tr("Vault Folder Was Moved or Deleted")) + #expect(!error.message.contains("search docs/forum")) + #expect(error.message.contains("/vaults/Life")) + #expect(error.technicalDetails == Self.rawEngineText) + } + + @Test("Remediation follows the manual-recovery doctrine — no rescan, no retry") + func remediationAvoidsRescanMisdirection() { + let error = SyncUserError.fromFolderStatus( + reason: "unknown_error", + message: Self.rawEngineText, + path: nil + ) + + let guidance = error.remediation.lowercased() + #expect(!guidance.isEmpty) + #expect(!guidance.contains("rescan")) + #expect(!guidance.contains("retry")) + } + + @Test("Raw-message path (e.g. a failed rescan) maps the same way") + func mapsRawMessageMarkerMissing() { + let error = SyncUserError.from(rawMessage: Self.rawEngineText) + + #expect(error.category == .folderMarkerMissing) + #expect(error.title == L10n.tr("Vault Folder Was Moved or Deleted")) + #expect(error.technicalDetails == Self.rawEngineText) + #expect(!error.remediation.lowercased().contains("retry")) + } + + @Test("Marker-missing errors route to their own troubleshooting anchor") + func routesToDedicatedTroubleshootingAnchor() { + let error = SyncUserError.fromFolderStatus( + reason: "unknown_error", + message: Self.rawEngineText, + path: nil + ) + + let url = SyncUserError.troubleshootingURL(for: error) + #expect(url?.absoluteString.hasSuffix("#vault-folder-was-moved-or-deleted") == true) + } + + @Test("Other unknown folder errors keep the generic mapping") + func keepsGenericMappingForOtherErrors() { + let error = SyncUserError.fromFolderStatus( + reason: "unknown_error", + message: "database is locked", + path: nil + ) + + #expect(error.title == L10n.tr("Folder Sync Error")) + #expect(error.message == "database is locked") + } +} + +@Suite("Rescan CTA availability under marker loss (#65)") +struct RescanCTAAvailabilityTests { + private func errorStatus(message: String) -> 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: message, + errorPath: nil, + errorChanged: nil + )) + } + + @Test("Marker loss as the only error hides the rescan path") + @MainActor + func markerOnlyErrorsAreNotRescanable() { + let manager = SyncthingManager() + manager._testSetFolderStatuses([ + "vault-a": errorStatus(message: FolderMarkerMissingMappingTests.rawEngineText), + ]) + + #expect(manager.hasRescanableFolderErrors == false) + } + + @Test("A non-marker folder error keeps the rescan path available") + @MainActor + func otherErrorsStayRescanable() { + let manager = SyncthingManager() + manager._testSetFolderStatuses([ + "vault-a": errorStatus(message: FolderMarkerMissingMappingTests.rawEngineText), + "vault-b": errorStatus(message: "database is locked"), + ]) + + #expect(manager.hasRescanableFolderErrors) + } +}