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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions ios/VaultSync/App/UIAuditFixture.swift
Original file line number Diff line number Diff line change
@@ -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 <name>` 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 <name>`, 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
8 changes: 8 additions & 0 deletions ios/VaultSync/App/VaultSyncApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions ios/VaultSync/Models/SyncUserError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"
Expand Down
22 changes: 21 additions & 1 deletion ios/VaultSync/Services/SyncthingManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2263,5 +2279,9 @@ final class SyncthingManager {
func _testSetConflictFiles(_ newConflicts: [String: [ConflictInfo]]) {
conflictFiles = newConflicts
}

func _testSetFolderStatuses(_ newStatuses: [String: FolderStatusInfo]) {
folderStatuses = newStatuses
}
#endif
}
17 changes: 15 additions & 2 deletions ios/VaultSync/Views/ConflictDiffView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
Loading
Loading