diff --git a/CHANGELOG.md b/CHANGELOG.md index ec63dac..16af8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to VaultSync are documented here. --- +## [1.6.0] — 2026-06-10 + +### Added + +- **Missed wake-ups now catch up on their own** — A device that misses a Cloud Relay wake-up (offline too long, push expired) no longer stays stale until the next vault change: the server helper (`vaultsync-notify`) now re-sends a wake-up on a slow cadence while any of your devices still needs data (default every 6 hours; `STALE_RETRIGGER_SECONDS`, `0` disables). Fully synced devices never cause a push, and a wake-up that is already on its way is never duplicated. +- **Overnight catch-up sync** — VaultSync now also schedules a long-running background task that iOS runs while the iPhone is charging with a network connection — typically overnight. It gets a multi-minute budget instead of the ~30 seconds of a normal background refresh, so large catch-ups complete on the charger instead of timing out. +- **See what Cloud Relay actually delivers** — Relay Diagnostics now counts the wake-ups received in the last 7 days (stored only on your device, never reported anywhere), warns live when Low Power Mode is deferring silent pushes, and explains the most common silent killer: force-quitting VaultSync from the app switcher stops all wake-ups until the next manual launch. Localized in English, German, Spanish, and Simplified Chinese. +- **Instant iPhone → server uploads, automated** — A new guide ([docs/instant-upload.md](docs/instant-upload.md)) shows the one-time Shortcuts automation that opens VaultSync every time you leave Obsidian, so your edits reach the server seconds after you close the app. The home-screen and lock-screen widgets are already tap-to-sync. + +### Changed + +- **A calmer, more consistent home screen** — the dashboard's Cloud Relay hints, sync errors, and the Obsidian connection prompt now share the app's standard card and row styles, and every error card carries a real action button instead of prose directions. Vaults get the same full-size status treatment as devices, with conflict counts as a clear badge. +- **Friendlier first run** — "no vaults yet" and "no devices yet" are now proper empty-state screens with guidance and an **Add Device** button; adding a device moved to the navigation bar of the Devices tab. +- **The Cloud Relay offer no longer switches tabs on its own** — after your first successful sync it appears as a dismissable card on the Sync tab (**View Cloud Relay** / **Not now**) instead of pulling you out of what you were doing. +- **Tappable setup checklist** — checklist steps with an in-app fix (connect your Obsidian folder, add a device, open the Relay tab) now carry a button that takes you straight there. +- **Design and accessibility polish** — onboarding, badges, spacing, and monospaced text now come from the shared design tokens (correct in light, dark, and increased-contrast mode), action buttons meet the 44pt touch-target minimum, renaming a device shows a saved confirmation, and the widget's last-sync time is labeled for VoiceOver. Localized in English, German, Spanish, and Simplified Chinese. +- **Honest self-hosted relay documentation** — The relay specification no longer advertises a free self-hosted relay tier as roadmap. It now explains the real constraint: Apple's push service only accepts wake-ups for the App Store app signed with VaultSync's own key, which can never be distributed. Building the entire stack from source with your own Apple Developer account remains possible under MPL-2.0. + ## [1.5.1] — 2026-06-01 ### Added diff --git a/README.md b/README.md index 75c65f1..eaf0164 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,7 @@ Full build, signing, and test steps: [docs/setup.md](docs/setup.md). | [docs/troubleshooting.md](docs/troubleshooting.md) | Common failures and exact fixes | | [docs/architecture.md](docs/architecture.md) | Codebase structure and sync strategy | | [docs/relay-spec.md](docs/relay-spec.md) | Cloud Relay protocol reference | +| [docs/instant-upload.md](docs/instant-upload.md) | Instant iPhone → server uploads via a Shortcuts automation | | [notify/README.md](notify/README.md) | Notify sidecar setup and diagnostics | | [PRIVACY.md](PRIVACY.md) · [TERMS.md](TERMS.md) | Privacy policy and license terms | diff --git a/docs/architecture.md b/docs/architecture.md index e549a1f..8f405b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,7 +20,7 @@ VaultSync embeds Syncthing's Go reference implementation as an iOS library via g ## 🔄 Sync strategy - **Foreground** — Syncthing runs unrestricted: immediate, continuous sync. -- **Background** — `BGAppRefreshTask` (requested ~15 min out; iOS decides the actual timing) + `BGContinuedProcessingTask` (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets in-flight work finish. +- **Background** — `BGAppRefreshTask` (requested ~15 min out; iOS decides the actual timing) + `BGProcessingTask` (overnight catch-up: multi-minute budget while charging with network) + `BGContinuedProcessingTask` (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets in-flight work finish. - **Push (Cloud Relay)** — optional. Near-realtime `server → iPhone` wake-ups via APNs silent push. See [relay-spec.md](relay-spec.md). VaultSync is intentionally **asymmetric**: @@ -28,7 +28,7 @@ VaultSync is intentionally **asymmetric**: | Direction | Path | |---|---| | **Server → iPhone** | `vaultsync-notify` spots outgoing changes → Cloud Relay silent push → VaultSync wakes and pulls. | -| **iPhone → Server** | iOS doesn't guarantee timely background execution for local edits. The reliable path is to open VaultSync and let embedded Syncthing run in the foreground. | +| **iPhone → Server** | iOS doesn't guarantee timely background execution for local edits. The reliable path is to open VaultSync and let embedded Syncthing run in the foreground — a [Shortcuts automation](instant-upload.md) can do that automatically whenever you leave Obsidian. | Cloud Relay is a `server → iPhone` *acceleration* path, not a guarantee of symmetric real-time background sync. diff --git a/docs/instant-upload.md b/docs/instant-upload.md new file mode 100644 index 0000000..78882c8 --- /dev/null +++ b/docs/instant-upload.md @@ -0,0 +1,31 @@ +# Instant iPhone → server uploads (Shortcuts automation) + +VaultSync's architecture is [intentionally asymmetric](architecture.md): Cloud Relay makes `server → iPhone` near-realtime, but iOS gives no app a reliable way to *upload* in the background. Notes you write in Obsidian on the iPhone reach your server when VaultSync next runs in the foreground — or during a system-scheduled background window. + +A one-time **Shortcuts personal automation** closes that gap: every time you leave Obsidian, iOS opens VaultSync for a moment, which triggers an immediate rescan and sync. Your edits are on the server seconds after you close Obsidian. + +## Set it up (about a minute) + +1. Open the **Shortcuts** app → **Automation** tab → **+** (New Automation). +2. Choose **App**. +3. Tap **App** and select **Obsidian**. Check **Is Closed**, and choose **Run Immediately** (on older iOS versions: turn off "Ask Before Running"). +4. Tap **Next**, then create a new shortcut with a single action: **Open URLs** with this URL: + + ``` + vaultsync://sync + ``` + +5. Done. Close Obsidian once to test — VaultSync should open and start a scan right away. + +To sync only a specific vault, use `vaultsync://sync?folder=` (the folder ID is shown in the vault's detail view). + +## What to expect + +- **"Is Closed" means "you switched away"** — the automation fires whenever you leave Obsidian, not only when you force-quit it. That is exactly what you want: every editing session ends with a sync. +- **VaultSync comes to the foreground briefly.** That is how iOS automations work; there is no silent variant. Switch back to whatever you were doing — the [~30s grace window](architecture.md) after backgrounding is enough to finish a typical note upload. +- **No automation needed for the other direction.** Changes made on your server reach the iPhone via Cloud Relay wake-ups (or on the next app open without it). + +## Manual alternatives + +- **Widget**: the home screen and lock screen widgets are tap-to-sync — one tap opens VaultSync and triggers the same `vaultsync://sync` action. +- **Pull-to-refresh** inside VaultSync rescans on demand. diff --git a/docs/relay-spec.md b/docs/relay-spec.md index 7bfcac4..01efa9b 100644 --- a/docs/relay-spec.md +++ b/docs/relay-spec.md @@ -1,6 +1,6 @@ # VaultSync Cloud Relay — Specification -> **Status:** Shipped in v1.4.0; signed-transaction (JWS) verification and server-side expiry enforcement added in v1.5.0; key-free auto-detection and self-activation (startup-announce) added in v1.5.1. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client. Sections marked _Roadmap_ (self-hosted relay) are not yet built. +> **Status:** Shipped in v1.4.0; signed-transaction (JWS) verification and server-side expiry enforcement added in v1.5.0; key-free auto-detection and self-activation (startup-announce) added in v1.5.1. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client. ## Overview @@ -56,6 +56,7 @@ In the app, the **Cloud Relay** tab → **Relay health & diagnostics** is the li - Auto-detects the Syncthing API key and URL from `config.xml` — no key to copy (override via env if needed) - Reads its Syncthing Device ID from `/rest/system/status` at startup - **Startup-announce** (`STARTUP_ANNOUNCE`, default on): sends one wake-up the moment it starts, so a freshly-subscribed device flips to "Cloud Relay active" without waiting for the next change +- **Stale-peer sweep** (`STALE_RETRIGGER_SECONDS`, default 6 h): while any unpaused peer still reports outstanding `need{Items,Bytes,Deletes}` via `/rest/db/completion`, re-sends a wake-up on a slow cadence. This recovers a phone that missed its push (APNs expiry is ~1 h) without waiting for the next vault change - No persistent storage required — stateless except for config **Central Relay (relay.vaultsync.eu)** @@ -193,6 +194,7 @@ Configuration via environment variables. `RELAY_URL` is the only required value | `SYNCTHING_CONFIG_WAIT_SECONDS` | No | First-boot wait for `config.xml` (default `60`). | | `DEBOUNCE_SECONDS` | No | Debounce interval for batching events (default `5`). | | `WATCHED_FOLDERS` | No | Comma-separated folder IDs to watch (default: all). | +| `STALE_RETRIGGER_SECONDS` | No | Re-send a wake-up on this cadence while a peer still needs data (default `21600` = 6 h; `0` disables). Recovers devices that missed a push — APNs silent pushes expire after ~1 h, and the change-driven path never fires twice for the same change. | The container reads its own Syncthing Device ID automatically from `/rest/system/status` at startup — no manual Device ID configuration. It consumes the Syncthing event stream and pushes outbound to the relay. Full operator reference: [../notify/README.md](../notify/README.md). @@ -235,11 +237,9 @@ The container reads its own Syncthing Device ID automatically from `/rest/system |---|---|---| | **VaultSync App** | Free | Full sync, background refresh, all features | | **Cloud Relay** | Monthly or yearly subscription | Push notifications via relay.vaultsync.eu | -| **Self-hosted Relay** | Free (roadmap) | User runs everything — no central relay needed | - Cloud Relay subscription managed via App Store (StoreKit 2, auto-renewable; monthly or yearly). The price is set in App Store Connect and shown in the user's local currency at runtime via StoreKit — never hard-coded (USD reference: ~$1.99/month or ~$14.99/year). No free trial. - App provisions the relay after successful purchase using the StoreKit signed transaction (JWS) -- Homeserver container works identically regardless of cloud vs self-hosted relay - No feature gates in the container itself — the gate is the central relay accepting provisioned Device IDs --- @@ -258,48 +258,24 @@ Cloud Relay is configured from its own **Cloud Relay** tab, not onboarding. --- -## Self-Hosted Variant +## Why there is no self-hosted relay -> **Roadmap — not yet built.** The components below are design exploration, not shipping code. Today `vaultsync-notify` points at the central `relay.vaultsync.eu` by default. +The relay's only job is sending APNs pushes — and APNs only accepts pushes for `eu.vaultsync.app` that are signed with VaultSync's own APNs key (a `p8` key bound to the app's bundle ID and developer team). That key cannot be distributed: anyone holding it could push to every VaultSync install, and sharing it violates Apple's terms. This is an APNs constraint, not a pricing decision — a self-hosted relay that wakes the App Store build is technically impossible no matter who runs the server. -For users who don't want to use the central relay or pay for the subscription. +What IS open: -### What the User Deploys +- **`RELAY_URL` is configurable** in `vaultsync-notify` — used for development and testing against a mock relay. It exists so the helper never wakes a relay the operator didn't choose, not as a self-hosting path for the shipped app. +- **VaultSync is open source (MPL-2.0).** A developer building the app from source with their own bundle ID and Apple Developer account can run the entire stack themselves — own APNs key, own relay, own build. The architecture supports it; it is not a supported product configuration. -**1. vaultsync-notify container** (same Docker image as cloud variant) -- Configured with `RELAY_URL` pointing to their own relay server instead of relay.vaultsync.eu - -**2. vaultsync-relay-server** (additional component) -- Minimal server that receives triggers and sends APNs pushes -- Requires the user's own Apple Developer Account ($99/year) for APNs credentials -- OR: uses the VaultSync APNs credentials bundled in a self-hosted-friendly way (TBD — licensing/security implications) -- Docker image provided, single binary - -### Self-Hosted Limitations - -- User must manage their own APNs credentials (p8 key from Apple Developer Portal) -- No automatic token rotation handling — user manages the database -- No SLA or uptime guarantees -- User responsible for TLS termination (reverse proxy like Caddy/nginx) - -### Self-Hosted Alternative: Direct Push (No Relay Server) - -A simplified variant where the homeserver container sends APNs pushes directly: - -- vaultsync-relay container configured with APNs credentials directly -- No intermediate relay server needed -- Simplest setup but requires Apple Developer Account -- Single container, no database -- Config: `APNS_KEY_FILE`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `DEVICE_TOKEN` (hardcoded for single device) +Everything else about the system is already self-hosted: notes sync peer-to-peer between the user's own devices, and the only thing that ever touches VaultSync infrastructure is an anonymous wake-up signal. --- ## Open Questions -1. **APNs credentials for self-hosted:** Can we distribute our p8 key in a way that allows self-hosted users to send pushes without their own Developer Account? Likely no — security and ToS implications. -2. **Multi-vault routing:** Should the push signal include which vault changed, so the app can prioritize? Currently: wake up and sync everything. Tradeoff: more metadata leaves the homeserver. -3. **Fallback behavior:** If push delivery fails (APNs errors, network issues), should the container retry? Or rely on the existing BGAppRefreshTask polling as fallback? -4. **GDPR — data processing agreement:** Device tokens are personal data. The privacy policy now ships ([../PRIVACY.md](../PRIVACY.md), surfaced in Settings → About); a data processing agreement for EU users is still open. +1. **Multi-vault routing:** Should the push signal include which vault changed, so the app can prioritize? Currently: wake up and sync everything. Tradeoff: more metadata leaves the homeserver. +2. **Fallback behavior:** The sidecar's stale-peer sweep (`STALE_RETRIGGER_SECONDS`) now re-sends a wake-up while a peer still needs data, recovering missed/expired pushes. Remaining question: should the central relay also retry failed APNs sends itself, or is the sweep + BGAppRefreshTask polling enough? +3. **GDPR — data processing agreement:** Device tokens are personal data. The privacy policy now ships ([../PRIVACY.md](../PRIVACY.md), surfaced in Settings → About); a data processing agreement for EU users is still open. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index baf9bc9..db942d4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -139,7 +139,7 @@ iOS controls background time and may delay or skip it. Cloud Relay makes `server 4. For relay users, confirm `vaultsync-notify --doctor` is green and **Last Trigger Received** is recent. 5. Re-check the **Last sync** timestamp after the next background window. -> For `iPhone → server`, open VaultSync and let it sync in the foreground. iOS background time is system-controlled and not guaranteed. +> For `iPhone → server`, open VaultSync and let it sync in the foreground. iOS background time is system-controlled and not guaranteed. A one-time Shortcuts automation can do the opening for you every time you leave Obsidian — see [instant-upload.md](instant-upload.md). --- diff --git a/ios/VaultSync/App/VaultSyncApp.swift b/ios/VaultSync/App/VaultSyncApp.swift index 27258e0..45b0272 100644 --- a/ios/VaultSync/App/VaultSyncApp.swift +++ b/ios/VaultSync/App/VaultSyncApp.swift @@ -105,6 +105,7 @@ struct VaultSyncApp: App { BackgroundSyncService.beginBackgroundAssertion() BackgroundSyncService.scheduleAppRefresh() + BackgroundSyncService.scheduleProcessing() if syncthingManager.isAnySyncing { if #available(iOS 26.0, *) { BackgroundSyncService.submitContinuedProcessing() diff --git a/ios/VaultSync/Models/RelayProvisionStatus.swift b/ios/VaultSync/Models/RelayProvisionStatus.swift index eaf0705..19bec70 100644 --- a/ios/VaultSync/Models/RelayProvisionStatus.swift +++ b/ios/VaultSync/Models/RelayProvisionStatus.swift @@ -133,6 +133,10 @@ enum APNsRegistrationStore { enum RelayTriggerStore { private static let lastReceivedAtKey = "relay-last-trigger-received-at" + private static let receivedHistoryKey = "relay-trigger-received-history-v1" + /// Rolling cap on stored arrival timestamps. 200 entries cover weeks of + /// realistic delivery volume while keeping the UserDefaults blob tiny. + private static let historyLimit = 200 static let triggerDidChangeNotification = Notification.Name("RelayTriggerDidChange") /// A real wake-up reached this device — the silent push the relay delivers @@ -143,10 +147,30 @@ enum RelayTriggerStore { /// push is a genuine delivery and needs no attribution heuristic. static func markReceived(date: Date = Date()) { UserDefaults.standard.set(date, forKey: lastReceivedAtKey) + var history = receivedHistory() + history.append(date) + if history.count > historyLimit { + history.removeFirst(history.count - historyLimit) + } + UserDefaults.standard.set(history, forKey: receivedHistoryKey) NotificationCenter.default.post(name: triggerDidChangeNotification, object: nil) } static func lastReceivedAt() -> Date? { UserDefaults.standard.object(forKey: lastReceivedAtKey) as? Date } + + /// All recorded wake-up arrivals, oldest first, capped at `historyLimit`. + /// Purely local diagnostics — nothing is reported anywhere. + static func receivedHistory() -> [Date] { + UserDefaults.standard.array(forKey: receivedHistoryKey) as? [Date] ?? [] + } + + /// Number of wake-ups that arrived within the trailing interval. Drives the + /// diagnostics counter that makes delivery (or its absence) visible — + /// "Never"/"3 days ago" alone hides how much iOS is actually letting through. + static func receivedCount(within interval: TimeInterval, now: Date = Date()) -> Int { + let cutoff = now.addingTimeInterval(-interval) + return receivedHistory().filter { $0 >= cutoff }.count + } } diff --git a/ios/VaultSync/Models/SyncUserError.swift b/ios/VaultSync/Models/SyncUserError.swift index d4a169a..59cd4bd 100644 --- a/ios/VaultSync/Models/SyncUserError.swift +++ b/ios/VaultSync/Models/SyncUserError.swift @@ -70,7 +70,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { category: .permission, title: L10n.tr("Permission Required"), message: L10n.tr("VaultSync does not have the required permission for this action."), - remediation: L10n.tr("Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry."), + remediation: L10n.tr("Open iOS Settings → VaultSync and check that all permissions are enabled, then retry."), technicalDetails: rawMessage ) } @@ -197,7 +197,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { category: .permission, title: L10n.tr("Push Registration Failed"), message: L10n.tr("iOS did not provide a push token required for instant sync."), - remediation: L10n.tr("Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app."), + remediation: L10n.tr("Enable notifications for VaultSync in iOS Settings → Notifications → VaultSync, then restart the app."), technicalDetails: reason ) } diff --git a/ios/VaultSync/Resources/Theme.swift b/ios/VaultSync/Resources/Theme.swift index 78daac7..fa194f1 100644 --- a/ios/VaultSync/Resources/Theme.swift +++ b/ios/VaultSync/Resources/Theme.swift @@ -19,11 +19,15 @@ import UIKit #if canImport(UIKit) /// A Display-P3 color that resolves light/dark and optional increased-contrast /// variants from the active trait collection. Channels are 0–255 for legibility. +/// The optional alphas produce muted *fills* (icon chips, decorative washes) +/// without per-call-site `colorScheme == .dark ? … : …` opacity math. private func vaultColor( light: (CGFloat, CGFloat, CGFloat), dark: (CGFloat, CGFloat, CGFloat), lightHC: (CGFloat, CGFloat, CGFloat)? = nil, - darkHC: (CGFloat, CGFloat, CGFloat)? = nil + darkHC: (CGFloat, CGFloat, CGFloat)? = nil, + lightAlpha: CGFloat = 1, + darkAlpha: CGFloat = 1 ) -> Color { Color(uiColor: UIColor { traits in let highContrast = traits.accessibilityContrast == .high @@ -38,7 +42,7 @@ private func vaultColor( displayP3Red: channels.0 / 255, green: channels.1 / 255, blue: channels.2 / 255, - alpha: 1 + alpha: traits.userInterfaceStyle == .dark ? darkAlpha : lightAlpha ) }) } @@ -47,9 +51,12 @@ private func vaultColor( light: (CGFloat, CGFloat, CGFloat), dark: (CGFloat, CGFloat, CGFloat), lightHC: (CGFloat, CGFloat, CGFloat)? = nil, - darkHC: (CGFloat, CGFloat, CGFloat)? = nil + darkHC: (CGFloat, CGFloat, CGFloat)? = nil, + lightAlpha: CGFloat = 1, + darkAlpha: CGFloat = 1 ) -> Color { Color(red: light.0 / 255, green: light.1 / 255, blue: light.2 / 255) + .opacity(lightAlpha) } #endif @@ -76,6 +83,56 @@ extension Color { light: (38, 50, 56), // #263238 dark: (176, 190, 197) // #B0BEC5 — readable as a muted accent in dark ) + + // Muted decorative fills. These retire the last of the hand-rolled + // `colorScheme == .dark ? 0.22 : 0.14` opacity math (onboarding chips, page + // dots, blurred background circles). Two emphasis levels per hue are enough. + + /// Brand-accent wash for icon chips and selected decorations. + static let vaultAccentFill = vaultColor( + light: (0, 137, 123), dark: (38, 196, 176), + lightAlpha: 0.14, darkAlpha: 0.22 + ) + /// Fainter accent wash for large background decorations. + static let vaultAccentFillSubtle = vaultColor( + light: (0, 137, 123), dark: (38, 196, 176), + lightAlpha: 0.08, darkAlpha: 0.14 + ) + /// Neutral slate wash for inactive decorations (page dots, accent bars). + static let vaultSlateFill = vaultColor( + light: (38, 50, 56), dark: (176, 190, 197), + lightAlpha: 0.20, darkAlpha: 0.34 + ) + /// Fainter slate wash for large background decorations. + static let vaultSlateFillSubtle = vaultColor( + light: (38, 50, 56), dark: (176, 190, 197), + lightAlpha: 0.05, darkAlpha: 0.10 + ) + + /// Hairline stroke for card borders — the system separator, tuned per scheme + /// so it reads on elevated cards in dark without dominating in light. + static let vaultHairline: Color = { + #if canImport(UIKit) + Color(uiColor: UIColor { traits in + UIColor.separator.withAlphaComponent( + traits.userInterfaceStyle == .dark ? 0.45 : 0.18 + ) + }) + #else + Color.gray.opacity(0.18) + #endif + }() +} + +// MARK: - Typography + +extension Font { + /// Monospaced rendering for genuine machine strings — Device IDs, paths, + /// `.stignore` globs, file contents. One call site to change instead of the + /// `.system(_, design: .monospaced)` literals that were scattered per view. + static func vaultMono(_ style: Font.TextStyle = .footnote, weight: Font.Weight = .regular) -> Font { + .system(style, design: .monospaced).weight(weight) + } } // MARK: - Semantic status palette @@ -106,6 +163,7 @@ extension Color { /// 8pt soft grid. Replaces the 14-value padding literal soup. enum VaultSpacing { + static let xxs: CGFloat = 2 static let xs: CGFloat = 4 static let s: CGFloat = 8 static let m: CGFloat = 12 diff --git a/ios/VaultSync/Services/BackgroundSyncService.swift b/ios/VaultSync/Services/BackgroundSyncService.swift index e1a62cf..0ea2579 100644 --- a/ios/VaultSync/Services/BackgroundSyncService.swift +++ b/ios/VaultSync/Services/BackgroundSyncService.swift @@ -24,6 +24,7 @@ enum BackgroundSyncService { static let appRefreshIdentifier = "eu.vaultsync.app.sync-refresh" static let continuedProcessingIdentifier = "eu.vaultsync.app.sync-continued" + static let processingIdentifier = "eu.vaultsync.app.sync-processing" /// Shared lock coordinating Syncthing bridge start/stop across foreground and background. static let lifecycleLock = OSAllocatedUnfairLock(initialState: SyncLifecycleState()) @@ -61,6 +62,7 @@ enum BackgroundSyncService { /// Written once during app launch, read-only afterwards — safe without synchronization. nonisolated(unsafe) private(set) static var appRefreshRegistered = false nonisolated(unsafe) private(set) static var continuedProcessingRegistered = false + nonisolated(unsafe) private(set) static var processingRegistered = false private static let lastSyncOutcomeStorageKey = "background-sync-last-outcome-v1" static let lastSyncOutcomeDidChangeNotification = Notification.Name("BackgroundSyncLastOutcomeDidChange") @@ -106,6 +108,21 @@ enum BackgroundSyncService { logger.warning("Failed to register app refresh task — background refresh unavailable") } + let processingHandler: @Sendable (BGTask) -> Void = { task in + guard let processingTask = task as? BGProcessingTask else { return } + let wrapped = UnsafeSendable(value: processingTask) + Task { await handleProcessing(task: wrapped.value) } + } + processingRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: processingIdentifier, + using: nil, + launchHandler: processingHandler + ) + + if !processingRegistered { + logger.warning("Failed to register processing task — overnight catch-up sync unavailable") + } + if #available(iOS 26.0, *) { let continuedHandler: @Sendable (BGTask) -> Void = { task in guard let processingTask = task as? BGContinuedProcessingTask else { return } @@ -125,7 +142,7 @@ enum BackgroundSyncService { logger.warning("Failed to register continued processing task — continued processing unavailable (expected in Simulator)") } - logger.info("Background task registration complete (refresh=\(appRefreshRegistered), continued=\(continuedProcessingRegistered))") + logger.info("Background task registration complete (refresh=\(appRefreshRegistered), processing=\(processingRegistered), continued=\(continuedProcessingRegistered))") } // MARK: - Lifecycle lock / background assertion @@ -209,6 +226,34 @@ enum BackgroundSyncService { } } + /// Schedule the overnight catch-up sync. iOS runs it when conditions are + /// right — typically while charging at night — with a multi-minute budget, + /// unlike the ~30s BGAppRefreshTask. This is the safety net for catch-ups + /// too large for the refresh/silent-push budget. + @MainActor + static func scheduleProcessing() { + guard processingRegistered else { return } + + let hasAccess = BookmarkService.resolveBookmark(identifier: "obsidian-root") != nil + guard hasAccess else { + logger.info("No Obsidian directory configured, skipping processing schedule") + return + } + + let request = BGProcessingTaskRequest(identifier: processingIdentifier) + request.requiresNetworkConnectivity = true + // Charger-time only: the long catch-up must never cost battery. + request.requiresExternalPower = true + request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) + + do { + try BGTaskScheduler.shared.submit(request) + logger.info("Processing task scheduled") + } catch { + logger.error("Could not schedule processing task: \(error)") + } + } + /// Submit continued processing for active sync. Call from foreground only. @MainActor static func submitContinuedProcessing() { @@ -218,7 +263,7 @@ enum BackgroundSyncService { let request = BGContinuedProcessingTaskRequest( identifier: continuedProcessingIdentifier, title: L10n.tr("Syncing Vault"), - subtitle: L10n.tr("Synchronizing your Obsidian vault...") + subtitle: L10n.tr("Synchronizing your Obsidian vault…") ) do { @@ -745,6 +790,33 @@ enum BackgroundSyncService { task.setTaskCompleted(success: result.isSuccessful) } + // MARK: - BGProcessingTask Handler + + /// Overnight catch-up: the same sync cycle as app refresh, but with the + /// multi-minute budget BGProcessingTask grants (charging + network + /// required). If iOS expires the task before our own deadline, the + /// expiration handler stops the bridge and the wait loop exits on + /// isRunning() == false. + private static func handleProcessing(task: BGProcessingTask) async { + logger.info("Background processing starting") + await MainActor.run { + scheduleProcessing() + } + + task.expirationHandler = { + let shouldStop = lifecycleLock.withLock { !$0.foregroundActive } + if shouldStop { + SyncBridgeService.stopSyncthing() + logger.info("Background processing expired — Syncthing stopped") + } else { + logger.info("Background processing expired — skipped stop (foreground active)") + } + } + + let result = await performBackgroundSync(reason: "processing", maxDuration: 180) + task.setTaskCompleted(success: result.isSuccessful) + } + // MARK: - BGContinuedProcessingTask Handler @available(iOS 26.0, *) diff --git a/ios/VaultSync/Services/SubscriptionManager.swift b/ios/VaultSync/Services/SubscriptionManager.swift index 1450637..117bf6c 100644 --- a/ios/VaultSync/Services/SubscriptionManager.swift +++ b/ios/VaultSync/Services/SubscriptionManager.swift @@ -30,6 +30,9 @@ final class SubscriptionManager { private(set) var relayHealthResult: RelayService.HealthCheckResult? private(set) var relayHealthCheckInFlight = false private(set) var lastRelayTriggerReceivedAt: Date? + /// Wake-ups that actually arrived in the trailing 7 days (local count, see + /// `RelayTriggerStore.receivedCount`). Surfaces how much iOS lets through. + private(set) var relayWakeupsLast7Days: Int = 0 private(set) var lastRelayError: RelayService.RecordedRelayError? /// Whether iOS will actually present an alert banner (authorized + banners /// enabled), denied, or unknown. Informational only — silent pushes (Cloud @@ -584,6 +587,7 @@ final class SubscriptionManager { private func refreshStoredRelayDiagnostics() { hasAPNsToken = KeychainService.hasAPNsDeviceToken() lastRelayTriggerReceivedAt = RelayTriggerStore.lastReceivedAt() + relayWakeupsLast7Days = RelayTriggerStore.receivedCount(within: 7 * 24 * 60 * 60) lastRelayError = RelayService.lastRecordedError() } diff --git a/ios/VaultSync/Services/VaultManager.swift b/ios/VaultSync/Services/VaultManager.swift index 996bdbb..dfecf30 100644 --- a/ios/VaultSync/Services/VaultManager.swift +++ b/ios/VaultSync/Services/VaultManager.swift @@ -248,7 +248,7 @@ final class VaultManager { let fm = FileManager.default var isDirectory: ObjCBool = false guard fm.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue else { - return L10n.tr("Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\".") + return L10n.tr("Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\".") } let isNamedObsidian = url.lastPathComponent.compare("Obsidian", options: .caseInsensitive) == .orderedSame @@ -263,7 +263,7 @@ final class VaultManager { includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) else { - return L10n.tr("VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\".") + return L10n.tr("VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" → \"Obsidian\".") } let containsVaultSubfolders = contents.contains { itemURL in @@ -276,7 +276,7 @@ final class VaultManager { return nil } - return L10n.tr("This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\".") + return L10n.tr("This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" → \"Obsidian\".") } private func markReconnectRequired(reason: String) { diff --git a/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift b/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift index 3e7fc83..148ceda 100644 --- a/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift +++ b/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift @@ -14,6 +14,15 @@ final class SetupChecklistViewModel { var id: String { rawValue } } + /// An in-app action that directly advances a checklist item, so remediations + /// are a tappable button instead of prose-only "go to the home screen and…" + /// directions. Only items with a real in-app entry point carry one. + enum ChecklistAction: Hashable { + case connectObsidian + case addDevice + case openRelayTab + } + struct ChecklistItem: Identifiable, Hashable { let requirement: Requirement let title: String @@ -21,6 +30,7 @@ final class SetupChecklistViewModel { let remediation: String let isOptional: Bool let isComplete: Bool + var action: ChecklistAction? = nil var id: Requirement { requirement } } @@ -115,7 +125,8 @@ final class SetupChecklistViewModel { description: L10n.tr("Your iPhone is not paired with a Syncthing device yet."), remediation: L10n.tr("Add your computer or server from the Devices section on the home screen."), isOptional: false, - isComplete: false + isComplete: false, + action: .addDevice ) } @@ -137,7 +148,8 @@ final class SetupChecklistViewModel { description: L10n.tr("VaultSync cannot access your local Obsidian folder."), remediation: L10n.tr("Connect your Obsidian folder from the VaultSync home screen."), isOptional: false, - isComplete: false + isComplete: false, + action: .connectObsidian ) } @@ -213,7 +225,8 @@ final class SetupChecklistViewModel { description: L10n.tr("Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync."), remediation: L10n.tr("Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen."), isOptional: true, - isComplete: false + isComplete: false, + action: .openRelayTab ) case .awaitingDelivery: return ChecklistItem( @@ -222,7 +235,8 @@ final class SetupChecklistViewModel { description: L10n.tr("You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server."), remediation: L10n.tr("Set up the server helper from the Relay tab → Set Up Your Server."), isOptional: true, - isComplete: false + isComplete: false, + action: .openRelayTab ) case .delivering: return ChecklistItem( diff --git a/ios/VaultSync/Views/ConflictDiffView.swift b/ios/VaultSync/Views/ConflictDiffView.swift index dc5e9c0..961a844 100644 --- a/ios/VaultSync/Views/ConflictDiffView.swift +++ b/ios/VaultSync/Views/ConflictDiffView.swift @@ -266,12 +266,12 @@ struct ConflictDiffView: View { ScrollView(.horizontal, showsIndicators: false) { Text(content.isEmpty ? L10n.tr("(empty or unreadable)") : content) - .font(.system(.caption, design: .monospaced)) - .padding(12) + .font(.vaultMono(.caption)) + .padding(VaultSpacing.m) .frame(maxWidth: .infinity, alignment: .leading) } .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .clipShape(RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous)) .padding(.horizontal) } } diff --git a/ios/VaultSync/Views/ConflictListView.swift b/ios/VaultSync/Views/ConflictListView.swift index 8bf0857..932243d 100644 --- a/ios/VaultSync/Views/ConflictListView.swift +++ b/ios/VaultSync/Views/ConflictListView.swift @@ -20,14 +20,14 @@ struct ConflictListView: View { var body: some View { List { Section { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: VaultSpacing.s) { Text("What is a conflict?") .font(.headline) Text("A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss.") .font(.subheadline) .foregroundStyle(.secondary) } - .padding(.vertical, 4) + .padding(.vertical, VaultSpacing.xs) } Section { @@ -43,17 +43,17 @@ struct ConflictListView: View { syncthingManager: syncthingManager ) } label: { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { Text(conflict.originalPath) .font(.body) - HStack(spacing: 8) { + HStack(spacing: VaultSpacing.s) { Label(conflict.formattedConflictDate, systemImage: "clock") Label(conflict.deviceShortID, systemImage: "laptopcomputer") } .font(.caption) .foregroundStyle(.secondary) } - .padding(.vertical, 2) + .padding(.vertical, VaultSpacing.xxs) .accessibilityElement(children: .combine) } } diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 7a37693..c09b629 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -7,12 +7,14 @@ struct ContentView: View { @State private var showAddDevice = false @State private var showSettings = false @State private var showObsidianPicker = false + @State private var pendingChecklistAction: SetupChecklistViewModel.ChecklistAction? @State private var alertMessage: String? @State private var showAlert = false @State private var pendingShareFailures: [String: SyncUserError] = [:] @State private var pendingShareInFlight: Set = [] @State private var pendingFilterSheetFolder: SyncthingManager.FolderInfo? @State private var vaultPendingRemoval: VaultRemovalTarget? + @State private var showRelayUpsellCard = false /// A vault the user has asked to remove, pending confirmation. Drives the /// shared removal confirmation dialog used by both the "needs attention" @@ -78,8 +80,13 @@ struct ContentView: View { showAlert = true } } - .sheet(isPresented: $showSettings) { - SettingsView(syncthingManager: syncthingManager, vaultManager: vaultManager, subscriptionManager: subscriptionManager) + .sheet(isPresented: $showSettings, onDismiss: runPendingChecklistAction) { + SettingsView( + syncthingManager: syncthingManager, + vaultManager: vaultManager, + subscriptionManager: subscriptionManager, + onChecklistAction: handleChecklistAction + ) } .sheet(isPresented: $showObsidianPicker) { FolderPicker(initialDirectoryURL: vaultManager.obsidianDirectoryURL, onCancel: { @@ -161,6 +168,7 @@ struct ContentView: View { } /// The Devices tab — paired Syncthing peers and the add-device entry point. + /// "Add" lives in the toolbar (the idiomatic spot), not in a section header. private var devicesTab: some View { NavigationStack { List { @@ -168,6 +176,18 @@ struct ContentView: View { } .navigationTitle(L10n.tr("Devices")) .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showAddDevice = true + } label: { + Image(systemName: "plus") + } + .disabled(!syncthingManager.isRunning) + .accessibilityLabel("Add Device") + .accessibilityHint("Opens the form to add a Syncthing device.") + } + } } } @@ -184,16 +204,50 @@ struct ContentView: View { // MARK: - Cloud Relay Upsell - /// Presents the Cloud Relay offer once, at the "aha moment": the first time - /// a real sync has completed while the user has at least one vault and is not - /// subscribed. Only auto-shown once; the dashboard affordance stays available. + /// Presents the Cloud Relay offer at the "aha moment": the first time a real + /// sync has completed while the user has at least one vault and is not + /// subscribed. Shown as a dismissable dashboard card — never by silently + /// switching tabs out from under the user. Acting on it (either way) retires + /// it for good; the permanent dashboard affordance stays available. private func maybePresentRelayUpsell() { - guard !subscriptionManager.isRelaySubscribed else { return } + guard !subscriptionManager.isRelaySubscribed else { + showRelayUpsellCard = false + return + } guard !syncthingManager.folders.isEmpty else { return } guard syncthingManager.lastSyncTime != nil else { return } guard !UserDefaults.standard.bool(forKey: Self.relayUpsellShownKey) else { return } + showRelayUpsellCard = true + } + + private func dismissRelayUpsell(openRelay: Bool) { UserDefaults.standard.set(true, forKey: Self.relayUpsellShownKey) - selectedTab = .relay + withAnimation(.snappy) { showRelayUpsellCard = false } + if openRelay { selectedTab = .relay } + } + + // MARK: - Checklist Actions + + /// Remember a tapped checklist remediation. The settings sheet is + /// dismissing when this fires, and presenting the next sheet during that + /// transition gets silently dropped — so the action runs from the sheet's + /// `onDismiss`, which fires only after the transition has fully completed + /// (no timing guess). + private func handleChecklistAction(_ action: SetupChecklistViewModel.ChecklistAction) { + pendingChecklistAction = action + } + + private func runPendingChecklistAction() { + guard let action = pendingChecklistAction else { return } + pendingChecklistAction = nil + switch action { + case .connectObsidian: + showObsidianPicker = true + case .addDevice: + showAddDevice = true + case .openRelayTab: + selectedTab = .relay + } } // MARK: - Auto-Accept Pending Shares @@ -257,6 +311,9 @@ struct ContentView: View { private var dashboardSection: some View { Section { + if showRelayUpsellCard { + relayUpsellCard + } if let staleWarning = syncthingManager.staleSyncWarning { Label(staleWarning, systemImage: "clock.badge.exclamationmark") .font(.caption) @@ -273,74 +330,49 @@ struct ContentView: View { if subscriptionManager.isRelaySubscribed { if subscriptionManager.needsRelayReactivation { - reactivationCard + // A1 — paid-but-never-activated relay subscription (the "dead + // sub" cohort: subscribed, never woken, past the grace period). + // A self-test does NOT clear this — only a real server wake-up + // does (it keys on the real trigger timestamp). + relayNavRow( + title: L10n.tr("Finish activating Cloud Relay"), + subtitle: L10n.tr("You’re subscribed, but your server has never woken this iPhone. One step finishes setup."), + status: .attention, + systemImage: "antenna.radiowaves.left.and.right.slash" + ) + .accessibilityHint(L10n.tr("Opens Cloud Relay setup.")) } else if subscriptionManager.relayDeliveryConfirmed { // "active" means a REAL wake-up has actually reached this - // device — not merely "provisioned + reachable" (K1). - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(accent) - .accessibilityHidden(true) - Text("Cloud Relay active") - .font(.subheadline) - .foregroundStyle(accent) - } - .accessibilityElement(children: .combine) + // device — not merely "provisioned + reachable" (K1). Same + // badge as the Relay tab's steady state, so the two screens + // can never disagree about what "active" looks like. + StatusBadge(.synced, text: L10n.tr("Cloud Relay active")) } else if subscriptionManager.lastRelayTriggerReceivedAt != nil { // Delivered before, but no recent wake-up — setup IS done; the // helper just went quiet. Don't tell them to "set up" again. - relayRecoveryRow + relayNavRow( + title: L10n.tr("Cloud Relay went quiet"), + subtitle: L10n.tr("No wake-up in a while — is your server still on?"), + status: .attention, + systemImage: "antenna.radiowaves.left.and.right" + ) } else { // Subscribed but never delivered yet (within grace, so not the // reactivation card) — finish the one missing setup step. - Button { - selectedTab = .relay - } label: { - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(Color.statusAttention) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - Text(L10n.tr("One step left to activate")) - .font(.subheadline.weight(.medium)) - Text(L10n.tr("Set up the server helper")) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } - } - .tint(.primary) - .accessibilityElement(children: .combine) - } - } else if !syncthingManager.folders.isEmpty { - Button { - selectedTab = .relay - } label: { - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(accent) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - Text(L10n.tr("Get instant updates")) - .font(.subheadline.weight(.medium)) - Text(L10n.tr("Turn on Cloud Relay")) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - } + relayNavRow( + title: L10n.tr("One step left to activate"), + subtitle: L10n.tr("Set up the server helper"), + status: .attention, + systemImage: "antenna.radiowaves.left.and.right" + ) } - .tint(.primary) - .accessibilityElement(children: .combine) + } else if !syncthingManager.folders.isEmpty, !showRelayUpsellCard { + relayNavRow( + title: L10n.tr("Get instant updates"), + subtitle: L10n.tr("Turn on Cloud Relay"), + status: nil, + systemImage: "antenna.radiowaves.left.and.right" + ) } if syncthingManager.isRunning { @@ -363,122 +395,102 @@ struct ContentView: View { } if let error = currentSyncError { - HStack(spacing: 8) { - Image(systemName: SyncStatus.error.symbolName) - .foregroundStyle(Color.statusError) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(error.title) - .font(.subheadline.weight(.semibold)) - Text(error.message) - .font(.caption) - Text(error.remediation) - .font(.footnote) - .foregroundStyle(.secondary) - } - .foregroundStyle(Color.statusError) - } - .accessibilityElement(children: .combine) - if let url = troubleshootingURL(for: error) { - ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.footnote) - } + ActionCard( + status: .error, + title: error.title, + message: joinedErrorMessage(error.message, error.remediation), + secondary: troubleshootingSecondary(for: error) + ) } - let errorFolders = foldersWithErrors - if !errorFolders.isEmpty { - ForEach(errorFolders, id: \.self) { folderID in - let folder = syncthingManager.folders.first { $0.id == folderID } - let folderError = syncthingManager.folderUserError(folderID: folderID) - HStack(spacing: 8) { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(Color.statusAttention) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(folder?.label ?? folderID) - .font(.subheadline.weight(.semibold)) - Text(folderError?.message ?? L10n.tr("Folder is currently in an error state.")) - .font(.caption) - if let remediation = folderError?.remediation { - Text(remediation) - .font(.footnote) - .foregroundStyle(.secondary) - } - if let folderError, - let url = troubleshootingURL(for: folderError) { - ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.footnote) - } - } - } - .accessibilityElement(children: .combine) - } + ForEach(foldersWithErrors, id: \.self) { folderID in + let folder = syncthingManager.folders.first { $0.id == folderID } + let folderError = syncthingManager.folderUserError(folderID: folderID) + ActionCard( + status: .attention, + title: folder?.label ?? folderID, + message: joinedErrorMessage( + folderError?.message ?? L10n.tr("Folder is currently in an error state."), + folderError?.remediation ?? "" + ), + secondary: folderError.flatMap { troubleshootingSecondary(for: $0) } + ) } } } - /// A1 — prominent, non-dismissable card for a paid-but-never-activated relay - /// subscription (the "dead sub" cohort: subscribed, never woken, past the - /// grace period). Routes into the Relay tab to finish setup. Only shown while - /// `needsRelayReactivation` holds; a self-test does NOT clear it — only a real - /// server wake-up does (it keys on the real trigger timestamp). - private var reactivationCard: some View { + /// Message + remediation as one ActionCard body, skipping empty parts. + private func joinedErrorMessage(_ message: String, _ remediation: String) -> String { + [message, remediation].filter { !$0.isEmpty }.joined(separator: "\n\n") + } + + /// The "Learn how to fix" link as an ActionCard secondary slot, when the + /// error maps to a troubleshooting anchor. + private func troubleshootingSecondary(for error: SyncUserError) -> (() -> AnyView)? { + guard let url = troubleshootingURL(for: error) else { return nil } + return { + AnyView( + ExternalLinkButton(titleKey: "Learn how to fix", url: url) + .font(.footnote) + ) + } + } + + /// One dashboard row that routes into the Relay tab — shared by the upsell, + /// "finish setup", recovery, and reactivation states so all four read as the + /// same kind of row instead of four hand-built HStacks. + private func relayNavRow( + title: String, + subtitle: String, + status: SyncStatus?, + systemImage: String + ) -> some View { Button { selectedTab = .relay } label: { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "antenna.radiowaves.left.and.right.slash") - .font(.title3) - .foregroundStyle(Color.statusAttention) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 3) { - Text(L10n.tr("Finish activating Cloud Relay")) - .font(.subheadline.weight(.semibold)) - Text(L10n.tr("You’re subscribed, but your server has never woken this iPhone. One step finishes setup.")) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - Spacer(minLength: 4) + StatusRow(title, subtitle: subtitle, status: status, systemImage: systemImage) { Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) .accessibilityHidden(true) } - .padding(.vertical, 4) } .tint(.primary) - .accessibilityElement(children: .combine) - .accessibilityHint(L10n.tr("Opens Cloud Relay setup.")) } - /// Subscribed and previously delivering, but no wake-up has arrived in a while - /// — the helper likely stopped. A recovery nudge, NOT a "set up" prompt (setup - /// is already done). Taps through to the Relay tab. - private var relayRecoveryRow: some View { - Button { - selectedTab = .relay - } label: { - HStack { + /// The one-time Cloud Relay offer, shown as a dismissable dashboard card the + /// first time a real sync completes (the "aha moment"). Replaces the old + /// behavior of silently switching the selected tab, which yanked users out + /// of whatever they were doing mid-celebration. + private var relayUpsellCard: some View { + VStack(alignment: .leading, spacing: VaultSpacing.s) { + HStack(spacing: VaultSpacing.s) { Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(Color.statusAttention) + .foregroundStyle(accent) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - Text(L10n.tr("Cloud Relay went quiet")) - .font(.subheadline.weight(.medium)) - Text(L10n.tr("No wake-up in a while — is your server still on?")) - .font(.caption) - .foregroundStyle(.secondary) + Text(L10n.tr("Get instant updates")) + .font(.headline) + } + Text(L10n.tr("Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: VaultSpacing.m) { + Button(L10n.tr("View Cloud Relay")) { + dismissRelayUpsell(openRelay: true) } - Spacer() - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.secondary) - .accessibilityHidden(true) + .buttonStyle(.borderedProminent) + Button(L10n.tr("Not now")) { + dismissRelayUpsell(openRelay: false) + } + .buttonStyle(.bordered) } + .padding(.top, VaultSpacing.xxs) } - .tint(.primary) - .accessibilityElement(children: .combine) + .padding(.vertical, VaultSpacing.xs) + // `.contain`, not `.combine`: the card holds two buttons that must stay + // independently focusable for VoiceOver. + .accessibilityElement(children: .contain) } private var isSyncing: Bool { @@ -558,59 +570,52 @@ struct ContentView: View { private var obsidianStatusSection: some View { if !vaultManager.isAccessible { Section { - VStack(alignment: .leading, spacing: 14) { - Label( - vaultManager.needsReconnect ? "Obsidian access expired" : "Obsidian folder not connected", - systemImage: "folder.badge.questionmark" - ) - .foregroundStyle(Color.statusAttention) + ActionCard( + status: .attention, + title: vaultManager.needsReconnect + ? L10n.tr("Obsidian access expired") + : L10n.tr("Obsidian folder not connected"), + message: obsidianAccessMessage, + actionTitle: vaultManager.needsReconnect + ? L10n.tr("Reconnect Obsidian Folder") + : L10n.tr("Connect Obsidian Folder"), + action: { showObsidianPicker = true }, + secondary: { AnyView(obsidianAccessFooter) } + ) + } + } + } - if let issue = vaultManager.accessIssue { - Text(issue.message) - .font(.caption) - .foregroundStyle(Color.statusAttention) - Text(issue.remediation) - .font(.caption2) - .foregroundStyle(.secondary) - if let url = SyncUserError.troubleshootingURL(anchor: vaultManager.needsReconnect ? "bookmark-access-expired" : "obsidian-folder-not-found") { - ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.caption2) - } - } else { - Text("VaultSync needs one-time access to your Obsidian folder before it can accept shares.") - .font(.caption) - .foregroundStyle(.secondary) - } + private var obsidianAccessMessage: String { + if let issue = vaultManager.accessIssue { + return joinedErrorMessage(issue.message, issue.remediation) + } + return L10n.tr("VaultSync needs one-time access to your Obsidian folder before it can accept shares.") + } - Button { - showObsidianPicker = true - } label: { - HStack(spacing: 8) { - Image(systemName: vaultManager.needsReconnect ? "arrow.clockwise" : "folder.badge.plus") - Text(vaultManager.needsReconnect ? "Reconnect Obsidian Folder" : "Connect Obsidian Folder") - } - .frame(maxWidth: .infinity, alignment: .center) - } - .buttonStyle(.borderedProminent) - .tint(accent) - .frame(maxWidth: .infinity) + /// Picker guidance + first-install help below the connect button. + private var obsidianAccessFooter: some View { + VStack(alignment: .leading, spacing: VaultSpacing.s) { + if vaultManager.accessIssue != nil, + let url = SyncUserError.troubleshootingURL(anchor: vaultManager.needsReconnect ? "bookmark-access-expired" : "obsidian-folder-not-found") { + ExternalLinkButton(titleKey: "Learn how to fix", url: url) + .font(.caption) + } - Text("In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open.") - .font(.caption) - .foregroundStyle(.tertiary) + Text("In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open.") + .font(.caption) + .foregroundStyle(.tertiary) - DisclosureGroup("Can't find the Obsidian folder?") { - Text("Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it.") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.top, 4) - } + DisclosureGroup("Can't find the Obsidian folder?") { + Text("Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it.") .font(.caption) .foregroundStyle(.secondary) - } - .padding(.vertical, 6) + .padding(.top, VaultSpacing.xs) } + .font(.caption) + .foregroundStyle(.secondary) } + .padding(.top, VaultSpacing.xs) } // MARK: - Pending Shares Section @@ -764,27 +769,7 @@ struct ContentView: View { private var vaultsSection: some View { Section("Obsidian Vaults") { if syncthingManager.folders.isEmpty { - VStack(alignment: .leading, spacing: 8) { - if vaultManager.isAccessible { - if vaultManager.detectedVaults.isEmpty { - Label("No vaults found", systemImage: "folder.badge.questionmark") - .foregroundStyle(.secondary) - Text("Create a vault in Obsidian first. VaultSync will detect it automatically.") - .font(.caption) - .foregroundStyle(.tertiary) - } else { - Label("No folders syncing yet", systemImage: "arrow.triangle.2.circlepath") - .foregroundStyle(.secondary) - Text("Share a folder from your desktop Syncthing — it will be accepted automatically.") - .font(.caption) - .foregroundStyle(.tertiary) - } - } else { - Label("Connect to Obsidian first", systemImage: "folder.badge.gearshape") - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) + vaultsEmptyState } else { ForEach(vaultRows) { item in NavigationLink { @@ -797,6 +782,32 @@ struct ContentView: View { } } + /// A designed first-run state instead of a degenerate caption row — this is + /// the screen a brand-new user stares at the longest. The "connect" case + /// carries no button of its own: the ActionCard above already owns that CTA. + @ViewBuilder + private var vaultsEmptyState: some View { + if !vaultManager.isAccessible { + ContentUnavailableView { + Label(L10n.tr("Connect to Obsidian first"), systemImage: "folder.badge.gearshape") + } description: { + Text("VaultSync needs one-time access to your Obsidian folder before it can accept shares.") + } + } else if vaultManager.detectedVaults.isEmpty { + ContentUnavailableView { + Label(L10n.tr("No vaults found"), systemImage: "folder.badge.questionmark") + } description: { + Text("Create a vault in Obsidian first. VaultSync will detect it automatically.") + } + } else { + ContentUnavailableView { + Label(L10n.tr("No folders syncing yet"), systemImage: "arrow.triangle.2.circlepath") + } description: { + Text("Share a folder from your desktop Syncthing — it will be accepted automatically.") + } + } + } + /// A single row in the "Obsidian Vaults" list. The list is keyed on the /// *vaults* the user actually has — matching the section title and what /// Obsidian itself shows — not on raw Syncthing sync folders. When one sync @@ -860,43 +871,34 @@ struct ContentView: View { return all.filter { $0.belongs(toVault: vault) } } + /// Vaults are the app's hero object — give them the same StatusRow treatment + /// as devices (full-size status glyph, headline title) instead of the old + /// plain-text row with a tiny trailing caption icon. private func vaultRow(_ item: VaultRowItem) -> some View { let status = syncthingManager.folderStatuses[item.folder.id] let conflictCount = conflicts(for: item).count - return HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(item.name) - .font(.body) - if conflictCount > 0 { - Text("\(conflictCount)") - .font(.caption2.bold()) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 1) - .background(Color.statusAttention, in: Capsule()) - .accessibilityLabel(L10n.fmt("%d conflicts", conflictCount)) - } - } - if let status { - HStack(spacing: 4) { - Text(localizedState(status.state)) - .font(.caption2) - if status.completionPct < 100, status.completionPct > 0 { - Text(L10n.fmt("(%d%%)", Int(status.completionPct))) - .font(.caption2) - } - } - .foregroundStyle(stateColor(status.state)) - } + let syncStatus = folderSyncStatus(status?.state ?? "unknown") + + var subtitle: String? + if let status { + subtitle = localizedState(status.state) + if status.completionPct < 100, status.completionPct > 0 { + subtitle! += " " + L10n.fmt("(%d%%)", Int(status.completionPct)) + } + } + + return StatusRow( + item.name, + subtitle: subtitle, + status: syncStatus, + systemImage: syncStatus == nil ? "questionmark.circle" : nil, + glyphTint: syncStatus == nil ? Color.statusInactive : nil + ) { + if conflictCount > 0 { + StatusTag(text: "\(conflictCount)", filled: true) + .accessibilityLabel(L10n.fmt("%d conflicts", conflictCount)) } - Spacer() - Image(systemName: stateIcon(status?.state ?? "unknown")) - .foregroundStyle(stateColor(status?.state ?? "unknown")) - .font(.caption2) - .accessibilityHidden(true) } - .accessibilityElement(children: .combine) } /// Map a folder's raw engine state onto the canonical `SyncStatus` registry so @@ -1013,7 +1015,7 @@ struct ContentView: View { Text(device.name.isEmpty ? L10n.tr("Unnamed") : device.name) .font(.body) Text(device.deviceID) - .font(.system(.caption2, design: .monospaced)) + .font(.vaultMono(.caption2)) .lineLimit(1) .truncationMode(.middle) } @@ -1112,14 +1114,17 @@ struct ContentView: View { private var devicesSection: some View { Section { if syncthingManager.devices.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Label("No devices configured", systemImage: "laptopcomputer.and.iphone") - .foregroundStyle(.secondary) + ContentUnavailableView { + Label(L10n.tr("No devices configured"), systemImage: "laptopcomputer.and.iphone") + } description: { Text("Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID.") - .font(.caption) - .foregroundStyle(.tertiary) + } actions: { + Button(L10n.tr("Add Device")) { + showAddDevice = true + } + .buttonStyle(.borderedProminent) + .disabled(!syncthingManager.isRunning) } - .padding(.vertical, 4) } else { ForEach(syncthingManager.devices) { device in NavigationLink { @@ -1140,20 +1145,6 @@ struct ContentView: View { } } } - } header: { - HStack { - Text("Devices") - Spacer() - if syncthingManager.isRunning { - Button { - showAddDevice = true - } label: { - Image(systemName: "plus") - } - .accessibilityLabel("Add Device") - .accessibilityHint("Opens the form to add a Syncthing device.") - } - } } } diff --git a/ios/VaultSync/Views/DesignSystem.swift b/ios/VaultSync/Views/DesignSystem.swift index b11e0ff..b0040ad 100644 --- a/ios/VaultSync/Views/DesignSystem.swift +++ b/ios/VaultSync/Views/DesignSystem.swift @@ -43,6 +43,46 @@ struct StatusBadge: View { } } +/// A compact capsule tag for counts and short state words — conflict counts, +/// "Save N%", "Ready"/"Needs Attention". One look for the three capsule badges +/// that were hand-rolled with divergent paddings and opacities. +struct StatusTag: View { + let text: String + var tint: Color = .statusAttention + /// High emphasis: solid tint capsule. The text picks black or white by the + /// resolved fill's luminance — a scheme-flipped color (`systemBackground`) + /// gave white-on-amber ~2.5:1 in light mode, while the lifted dark-mode + /// tints genuinely need dark text. + var filled: Bool = false + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Text(text) + .font(.caption2.weight(.bold)) + .foregroundStyle(filled ? filledForeground : tint) + .padding(.horizontal, VaultSpacing.s) + .padding(.vertical, VaultSpacing.xxs) + .background(filled ? tint : tint.opacity(0.15), in: Capsule()) + } + + /// Black or white — whichever has more WCAG contrast against the tint as + /// resolved for the current scheme. Break-even is relative luminance + /// ~0.179: above it black always yields the higher contrast ratio. + private var filledForeground: Color { + let resolved = UIColor(tint).resolvedColor( + with: UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light) + ) + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0 + guard resolved.getRed(&red, green: &green, blue: &blue, alpha: nil) else { return .black } + func linear(_ channel: CGFloat) -> CGFloat { + channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4) + } + let luminance = 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue) + return luminance > 0.179 ? .black : .white + } +} + /// A list row: leading status glyph, primary title, optional secondary line, and /// optional trailing content. Replaces the title+caption `VStack(spacing: 2)` that /// was copy-pasted across five views. @@ -51,6 +91,9 @@ struct StatusRow: View { var subtitle: String? var status: SyncStatus? var systemImage: String? + /// Optional glyph-color override for the rare row whose color isn't carried + /// by a `SyncStatus` (e.g. a neutral "unknown" placeholder). + var glyphTint: Color? @ViewBuilder var trailing: () -> Trailing init( @@ -58,12 +101,14 @@ struct StatusRow: View { subtitle: String? = nil, status: SyncStatus? = nil, systemImage: String? = nil, + glyphTint: Color? = nil, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() } ) { self.title = title self.subtitle = subtitle self.status = status self.systemImage = systemImage + self.glyphTint = glyphTint self.trailing = trailing } @@ -74,7 +119,7 @@ struct StatusRow: View { if let glyph { Image(systemName: glyph) .font(.title3) - .foregroundStyle(status?.tint ?? Color.vaultAccent) + .foregroundStyle(glyphTint ?? status?.tint ?? Color.vaultAccent) .frame(width: 28) .accessibilityHidden(true) } @@ -107,7 +152,7 @@ struct DetailRow: View { .foregroundStyle(.secondary) Spacer(minLength: VaultSpacing.s) Text(value) - .font(monospacedValue ? .system(.body, design: .monospaced) : .body) + .font(monospacedValue ? .vaultMono(.body) : .body) .multilineTextAlignment(.trailing) } .font(.subheadline) @@ -177,7 +222,7 @@ struct MonoField: View { } label: { HStack(alignment: .top, spacing: VaultSpacing.s) { Text(text) - .font(.system(.footnote, design: .monospaced)) + .font(.vaultMono(.footnote)) .lineLimit(3) .truncationMode(.middle) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/ios/VaultSync/Views/DeviceDetailView.swift b/ios/VaultSync/Views/DeviceDetailView.swift index 475daea..bab94f9 100644 --- a/ios/VaultSync/Views/DeviceDetailView.swift +++ b/ios/VaultSync/Views/DeviceDetailView.swift @@ -5,6 +5,7 @@ struct DeviceDetailView: View { let syncthingManager: SyncthingManager @State private var editedName = "" + @State private var nameSaved = false @State private var showRemoveConfirm = false @State private var alertMessage: String? @State private var showAlert = false @@ -29,6 +30,14 @@ struct DeviceDetailView: View { .onSubmit { saveName() } + // Transient saved confirmation, mirroring MonoField's + // copy feedback — the rename otherwise commits invisibly. + if nameSaved { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.statusSuccess) + .transition(.scale.combined(with: .opacity)) + .accessibilityLabel(L10n.tr("Name saved")) + } } LabeledContent("Status") { @@ -84,6 +93,12 @@ struct DeviceDetailView: View { alertMessage = mapped.userVisibleDescription showAlert = true editedName = device.name + return + } + withAnimation(.snappy) { nameSaved = true } + Task { + try? await Task.sleep(for: .seconds(1.5)) + withAnimation(.snappy) { nameSaved = false } } } diff --git a/ios/VaultSync/Views/IgnorePatternsView.swift b/ios/VaultSync/Views/IgnorePatternsView.swift index 8ebe2a3..028e29c 100644 --- a/ios/VaultSync/Views/IgnorePatternsView.swift +++ b/ios/VaultSync/Views/IgnorePatternsView.swift @@ -77,9 +77,9 @@ struct IgnorePatternsView: View { private var customSection: some View { Section(header: Text(L10n.tr("Custom patterns"))) { ForEach(customEntries, id: \.self) { entry in - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(entry.original) - .font(.system(.body, design: .monospaced)) + .font(.vaultMono(.body)) if entry.hasConflictGlob { Text(L10n.tr("+ conflict copies")) .font(.caption) @@ -91,7 +91,7 @@ struct IgnorePatternsView: View { HStack { TextField(L10n.tr("Add pattern (e.g. *.tmp)"), text: $newPattern) - .font(.system(.body, design: .monospaced)) + .font(.vaultMono(.body)) .autocorrectionDisabled() .textInputAutocapitalization(.never) Button(L10n.tr("Add")) { addCustom() } diff --git a/ios/VaultSync/Views/LineDiffView.swift b/ios/VaultSync/Views/LineDiffView.swift index 054589e..18057a1 100644 --- a/ios/VaultSync/Views/LineDiffView.swift +++ b/ios/VaultSync/Views/LineDiffView.swift @@ -30,15 +30,15 @@ struct LineDiffView: View { ForEach(diffLines) { line in HStack(alignment: .top, spacing: 6) { Text(linePrefix(for: line.type)) - .font(.system(.caption, design: .monospaced).weight(.semibold)) + .font(.vaultMono(.caption, weight: .semibold)) .foregroundStyle(foregroundColor(for: line.type)) .accessibilityHidden(true) Text(line.text.isEmpty ? " " : line.text) - .font(.system(.caption, design: .monospaced)) + .font(.vaultMono(.caption)) .foregroundStyle(foregroundColor(for: line.type)) } - .padding(.horizontal, 4) - .padding(.vertical, 2) + .padding(.horizontal, VaultSpacing.xs) + .padding(.vertical, VaultSpacing.xxs) .frame(maxWidth: .infinity, alignment: .leading) .background(backgroundColor(for: line.type)) .accessibilityElement(children: .ignore) diff --git a/ios/VaultSync/Views/OnboardingView.swift b/ios/VaultSync/Views/OnboardingView.swift index fd055c3..90c1ae5 100644 --- a/ios/VaultSync/Views/OnboardingView.swift +++ b/ios/VaultSync/Views/OnboardingView.swift @@ -37,9 +37,9 @@ struct OnboardingView: View { .tabViewStyle(.page(indexDisplayMode: .never)) bottomBar - .padding(.horizontal, 20) - .padding(.top, 12) - .padding(.bottom, 8) + .padding(.horizontal, VaultSpacing.l) + .padding(.top, VaultSpacing.m) + .padding(.bottom, VaultSpacing.s) .background(.bar) } .toolbar(.hidden, for: .navigationBar) @@ -74,11 +74,11 @@ struct OnboardingView: View { private func page(_ content: Content) -> some View { ScrollView { - VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: VaultSpacing.xl) { content } - .padding(.horizontal, 20) - .padding(.vertical, 28) + .padding(.horizontal, VaultSpacing.l) + .padding(.vertical, VaultSpacing.xl) .frame(maxWidth: .infinity, alignment: .topLeading) } } @@ -86,8 +86,8 @@ struct OnboardingView: View { // MARK: - Welcome private var welcomeScreen: some View { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: VaultSpacing.l) { + VStack(alignment: .leading, spacing: VaultSpacing.m) { topAccentBar Text(L10n.tr("onboarding.welcome.title")) @@ -100,17 +100,17 @@ struct OnboardingView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - .padding(24) + .padding(VaultSpacing.xl) .frame(maxWidth: .infinity, alignment: .leading) .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous)) .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous))) - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: VaultSpacing.l) { benefitRow(icon: "lock.shield.fill", textKey: "onboarding.welcome.benefit.private") benefitRow(icon: "books.vertical.fill", textKey: "onboarding.welcome.benefit.obsidian") benefitRow(icon: "icloud.slash.fill", textKey: "onboarding.welcome.benefit.noCloud") } - .padding(20) + .padding(VaultSpacing.l) .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) } @@ -119,8 +119,8 @@ struct OnboardingView: View { // MARK: - Setup (live, actionable) private var setupScreen: some View { - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: VaultSpacing.l) { + VStack(alignment: .leading, spacing: VaultSpacing.s) { Text(L10n.tr("Let’s get your vault synced")) .font(titleFont) .foregroundStyle(primaryHeadingColor) @@ -130,7 +130,7 @@ struct OnboardingView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - .padding(24) + .padding(VaultSpacing.xl) .frame(maxWidth: .infinity, alignment: .leading) .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous)) .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous))) @@ -162,7 +162,7 @@ struct OnboardingView: View { action: nil ) - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: VaultSpacing.m) { Image(systemName: "antenna.radiowaves.left.and.right") .font(.body.weight(.semibold)) .foregroundStyle(teal) @@ -172,7 +172,7 @@ struct OnboardingView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - .padding(18) + .padding(VaultSpacing.l) .frame(maxWidth: .infinity, alignment: .leading) .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) @@ -188,10 +188,10 @@ struct OnboardingView: View { actionTitle: String?, action: (() -> Void)? ) -> some View { - HStack(alignment: .top, spacing: 14) { + HStack(alignment: .top, spacing: VaultSpacing.m) { ZStack { Circle() - .fill(isComplete ? Color.statusSuccess : teal.opacity(colorScheme == .dark ? 0.22 : 0.14)) + .fill(isComplete ? Color.statusSuccess : Color.vaultAccentFill) .frame(width: 34, height: 34) Image(systemName: isComplete ? "checkmark" : icon) .font(.subheadline.weight(.semibold)) @@ -221,12 +221,12 @@ struct OnboardingView: View { .buttonStyle(.borderedProminent) .controlSize(.regular) .tint(teal) - .padding(.top, 4) + .padding(.top, VaultSpacing.xs) } } Spacer(minLength: 0) } - .padding(18) + .padding(VaultSpacing.l) .frame(maxWidth: .infinity, alignment: .leading) .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) @@ -242,7 +242,7 @@ struct OnboardingView: View { // MARK: - Bottom bar private var bottomBar: some View { - VStack(spacing: 14) { + VStack(spacing: VaultSpacing.m) { pageDots Button { @@ -259,10 +259,10 @@ struct OnboardingView: View { } private var pageDots: some View { - HStack(spacing: 10) { + HStack(spacing: VaultSpacing.s) { ForEach(0..<2, id: \.self) { index in Circle() - .fill(index == page ? teal : slate.opacity(colorScheme == .dark ? 0.34 : 0.20)) + .fill(index == page ? teal : Color.vaultSlateFill) .frame(width: 8, height: 8) } } @@ -286,13 +286,13 @@ struct OnboardingView: View { .ignoresSafeArea() Circle() - .fill(teal.opacity(colorScheme == .dark ? 0.14 : 0.08)) + .fill(Color.vaultAccentFillSubtle) .frame(width: 220, height: 220) .blur(radius: 18) .offset(x: -120, y: -70) Circle() - .fill(slate.opacity(colorScheme == .dark ? 0.10 : 0.05)) + .fill(Color.vaultSlateFillSubtle) .frame(width: 240, height: 240) .blur(radius: 22) .offset(x: 130, y: -110) @@ -301,10 +301,10 @@ struct OnboardingView: View { } private func benefitRow(icon: String, textKey: String) -> some View { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: VaultSpacing.m) { ZStack { - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(teal.opacity(colorScheme == .dark ? 0.22 : 0.14)) + RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous) + .fill(Color.vaultAccentFill) .frame(width: 36, height: 36) Image(systemName: icon) .font(.subheadline.weight(.semibold)) @@ -320,12 +320,12 @@ struct OnboardingView: View { } private var topAccentBar: some View { - HStack(spacing: 10) { + HStack(spacing: VaultSpacing.s) { Capsule() .fill(teal) .frame(width: 64, height: 8) Capsule() - .fill(slate.opacity(colorScheme == .dark ? 0.42 : 0.20)) + .fill(Color.vaultSlateFill) .frame(width: 24, height: 8) } .accessibilityHidden(true) @@ -344,9 +344,6 @@ struct OnboardingView: View { } private func cardStroke(in shape: S) -> some View { - shape.stroke( - Color(uiColor: .separator).opacity(colorScheme == .dark ? 0.45 : 0.18), - lineWidth: 1 - ) + shape.stroke(Color.vaultHairline, lineWidth: 1) } } diff --git a/ios/VaultSync/Views/PendingSharesView.swift b/ios/VaultSync/Views/PendingSharesView.swift index 43bf823..1eaf430 100644 --- a/ios/VaultSync/Views/PendingSharesView.swift +++ b/ios/VaultSync/Views/PendingSharesView.swift @@ -13,7 +13,7 @@ struct PendingSharesView: View { var onReconnectObsidian: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: VaultSpacing.l) { if !obsidianAccessible { ActionCard( status: .attention, @@ -36,10 +36,10 @@ struct PendingSharesView: View { if !ignoredFolders.isEmpty { DisclosureGroup(L10n.fmt("Ignored shares (%d)", ignoredFolders.count)) { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: VaultSpacing.s) { ForEach(ignoredFolders) { folder in HStack { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(displayName(for: folder)) .font(.subheadline.weight(.semibold)) Text(offeredByDescription(for: folder)) @@ -51,39 +51,37 @@ struct PendingSharesView: View { onRestoreIgnored(folder) } .buttonStyle(.bordered) - .controlSize(.small) + .controlSize(.regular) } } } - .padding(.top, 8) + .padding(.top, VaultSpacing.s) } .font(.subheadline) .foregroundStyle(.secondary) } } - .padding(.vertical, 4) + .padding(.vertical, VaultSpacing.xs) } @ViewBuilder private func pendingRow(_ folder: SyncthingManager.PendingFolderInfo) -> some View { let failure = failureByFolderID[folder.id] let hasFailure = failure != nil - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: VaultSpacing.s) { + HStack(alignment: .top, spacing: VaultSpacing.s) { Image(systemName: hasFailure ? "exclamationmark.circle.fill" : "tray.and.arrow.down.fill") .foregroundStyle(hasFailure ? Color.statusAttention : Color.statusInfo) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { + HStack(spacing: VaultSpacing.xs) { Text(displayName(for: folder)) .font(.body.weight(.semibold)) - Text(hasFailure ? L10n.tr("Needs Attention") : L10n.tr("Ready")) - .font(.caption.weight(.semibold)) - .foregroundStyle(hasFailure ? Color.statusAttention : Color.statusInfo) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background((hasFailure ? Color.statusAttention : Color.statusInfo).opacity(0.12), in: Capsule()) + StatusTag( + text: hasFailure ? L10n.tr("Needs Attention") : L10n.tr("Ready"), + tint: hasFailure ? Color.statusAttention : Color.statusInfo + ) } Text(offeredByDescription(for: folder)) @@ -105,7 +103,7 @@ struct PendingSharesView: View { } .accessibilityElement(children: .combine) - HStack(spacing: 8) { + HStack(spacing: VaultSpacing.s) { if inFlightFolderIDs.contains(folder.id) { ProgressView() .controlSize(.small) diff --git a/ios/VaultSync/Views/QRScannerView.swift b/ios/VaultSync/Views/QRScannerView.swift index 7bb4478..0b33b48 100644 --- a/ios/VaultSync/Views/QRScannerView.swift +++ b/ios/VaultSync/Views/QRScannerView.swift @@ -40,9 +40,10 @@ struct QRScannerView: View { } private var permissionDeniedView: some View { - VStack(spacing: 16) { + VStack(spacing: VaultSpacing.l) { Image(systemName: "camera.fill") - .font(.system(size: 48)) + .font(.largeTitle) + .imageScale(.large) .foregroundStyle(.secondary) .accessibilityHidden(true) @@ -53,7 +54,7 @@ struct QRScannerView: View { .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal, 32) + .padding(.horizontal, VaultSpacing.xl) Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { diff --git a/ios/VaultSync/Views/RelayDiagnosticsView.swift b/ios/VaultSync/Views/RelayDiagnosticsView.swift index 30ab6ba..ff1e441 100644 --- a/ios/VaultSync/Views/RelayDiagnosticsView.swift +++ b/ios/VaultSync/Views/RelayDiagnosticsView.swift @@ -7,6 +7,7 @@ struct RelayDiagnosticsView: View { @State private var diagnosticsInFlight = false @State private var retryProvisioningInFlight = false + @State private var lowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled var body: some View { List { relayHealthSection @@ -23,6 +24,9 @@ struct RelayDiagnosticsView: View { await runDiagnostics() } } + .onReceive(NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)) { _ in + lowPowerModeEnabled = ProcessInfo.processInfo.isLowPowerModeEnabled + } } private var relayHealthSection: some View { @@ -92,7 +96,7 @@ struct RelayDiagnosticsView: View { if let message = subscriptionManager.relayHealthResult?.message, !(subscriptionManager.relayHealthResult?.isHealthy ?? true) { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(message) .font(.caption) .foregroundStyle(.secondary) @@ -104,7 +108,7 @@ struct RelayDiagnosticsView: View { } if let relayError = subscriptionManager.lastRelayError { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { Text("Last Relay Error") .font(.caption.weight(.semibold)) .foregroundStyle(Color.statusError) @@ -124,7 +128,7 @@ struct RelayDiagnosticsView: View { .font(.caption2) } } - .padding(.vertical, 2) + .padding(.vertical, VaultSpacing.xxs) } else { Text("No relay errors recorded.") .font(.caption) @@ -184,7 +188,7 @@ struct RelayDiagnosticsView: View { } if case .failed(let reason) = subscriptionManager.apnsRegistrationStatus { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(reason) .font(.caption) .foregroundStyle(Color.statusError) @@ -214,9 +218,9 @@ struct RelayDiagnosticsView: View { } else { ForEach(syncthingManager.devices) { device in let status = subscriptionManager.relayProvisionStatuses[device.deviceID] ?? .notAttempted - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { HStack { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(device.name.isEmpty ? device.deviceID : device.name) .font(.subheadline) Text(device.deviceID) @@ -242,16 +246,16 @@ struct RelayDiagnosticsView: View { } } } - .padding(.vertical, 2) + .padding(.vertical, VaultSpacing.xxs) } } } } private var triggerSection: some View { - Section("Trigger Delivery") { + Section("Wake-up Delivery") { HStack { - Label("Last Trigger Received", systemImage: "bolt.badge.clock") + Label("Last Wake-up Received", systemImage: "bolt.badge.clock") Spacer() if let lastTrigger = subscriptionManager.lastRelayTriggerReceivedAt { Text(lastTrigger, style: .relative) @@ -263,10 +267,28 @@ struct RelayDiagnosticsView: View { } .accessibilityElement(children: .combine) + HStack { + Label(L10n.tr("Wake-ups (Last 7 Days)"), systemImage: "bolt.circle") + Spacer() + Text("\(subscriptionManager.relayWakeupsLast7Days)") + .foregroundStyle(subscriptionManager.relayWakeupsLast7Days > 0 ? .primary : .secondary) + } + .accessibilityElement(children: .combine) + Text("This timestamp is updated when VaultSync receives a silent push from Cloud Relay.") .font(.caption) .foregroundStyle(.secondary) + if lowPowerModeEnabled { + Label(L10n.tr("Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging."), systemImage: "battery.25percent") + .font(.caption) + .foregroundStyle(Color.statusAttention) + } + + Text(L10n.tr("Keep VaultSync in the background: if you force-quit it from the app switcher, iOS stops delivering wake-ups until you open the app again.")) + .font(.caption) + .foregroundStyle(.secondary) + #if DEBUG // LAB: the Simulator can't deliver real silent pushes, so this drives // the REAL receive→UI path (markReceived → freshness → "active" / @@ -372,7 +394,20 @@ struct RelayDiagnosticsView: View { } if subscriptionManager.isRelaySubscribed, subscriptionManager.lastRelayTriggerReceivedAt == nil { - hints.append(L10n.tr("No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu.")) + hints.append(L10n.tr("No wake-up has been received yet. Check that the vaultsync-notify helper is running on your server and can reach relay.vaultsync.eu.")) + } + + if lowPowerModeEnabled { + hints.append(L10n.tr("Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging.")) + } + + // Delivered before, but nothing within the freshness window: the most + // common cause by far is a force-quit (iOS then drops silent pushes on + // the floor until the next manual launch), not a broken helper. + if subscriptionManager.isRelaySubscribed, + subscriptionManager.lastRelayTriggerReceivedAt != nil, + !subscriptionManager.relayDeliveryConfirmed { + hints.append(L10n.tr("Wake-ups went quiet. The most common cause is force-quitting VaultSync from the app switcher — iOS then blocks wake-ups until the next manual launch. Also check that the helper on your server is still running.")) } return hints diff --git a/ios/VaultSync/Views/RelayHomeView.swift b/ios/VaultSync/Views/RelayHomeView.swift index 0a5dcf7..5135a62 100644 --- a/ios/VaultSync/Views/RelayHomeView.swift +++ b/ios/VaultSync/Views/RelayHomeView.swift @@ -48,7 +48,7 @@ struct RelayHomeView: View { Section { VStack(alignment: .leading, spacing: VaultSpacing.m) { Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 34)) + .font(.largeTitle) .foregroundStyle(Color.vaultAccent) .accessibilityHidden(true) @@ -146,7 +146,7 @@ struct RelayHomeView: View { // doesn't poll). Updates by itself once the first wake-up lands. StatusBadge(.attention, text: L10n.tr("Not active yet")) .font(.headline) - Text(L10n.tr("You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below. This screen updates automatically the moment the first wake-up arrives.")) + Text(L10n.tr("You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/ios/VaultSync/Views/RelayServerSetupView.swift b/ios/VaultSync/Views/RelayServerSetupView.swift index e39e895..44ee6e6 100644 --- a/ios/VaultSync/Views/RelayServerSetupView.swift +++ b/ios/VaultSync/Views/RelayServerSetupView.swift @@ -66,11 +66,11 @@ struct RelayServerSetupView: View { } header: { Text(L10n.tr("Step 1 — Run this on your server")) } footer: { - Text(L10n.tr("No API key to copy — the helper reads it straight from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). If you get a permission error, add -u : for the user that owns config.xml.")) + Text(L10n.tr("No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml.")) } Section { - Text(L10n.tr("That’s it. As soon as the helper starts, it wakes this iPhone once on its own — VaultSync flips to “Cloud Relay active” the moment that first wake-up arrives, with no change needed. After that, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (your server or NAS).")) + Text(L10n.tr("That’s it — the helper wakes this iPhone once on its own, and VaultSync flips to “Cloud Relay active” as soon as that first wake-up arrives. From then on, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (server or NAS).")) .font(.subheadline) } header: { Text(L10n.tr("Step 2 — It activates itself")) @@ -90,9 +90,9 @@ struct RelayServerSetupView: View { private var commandBox: some View { ScrollView(.horizontal, showsIndicators: true) { Text(dockerCommand) - .font(.system(.caption, design: .monospaced)) + .font(.vaultMono(.caption)) .textSelection(.enabled) - .padding(10) + .padding(VaultSpacing.m) } .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous)) .accessibilityLabel(L10n.tr("Server setup command")) diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index 32182b2..f185c78 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -6,6 +6,10 @@ struct SettingsView: View { let syncthingManager: SyncthingManager var vaultManager: VaultManager var subscriptionManager: SubscriptionManager + /// Routes a tapped checklist remediation to its in-app action. The host + /// (ContentView) owns the folder picker, the add-device sheet, and the tab + /// selection, so the action must travel up through this sheet. + var onChecklistAction: ((SetupChecklistViewModel.ChecklistAction) -> Void)? = nil @State private var showSetupStatus = false @State private var tipJar = TipJarManager() @@ -80,9 +84,19 @@ struct SettingsView: View { syncthingManager: syncthingManager, vaultManager: vaultManager, subscriptionManager: subscriptionManager - ) + ), + onAction: onChecklistAction.map { handler in + { action in + // Collapse both sheets first; the host delays + // its own presentation until the dismissal + // transition has finished. + showSetupStatus = false + dismiss() + handler(action) + } + } ) - .padding(16) + .padding(VaultSpacing.l) } .navigationTitle(L10n.tr("Setup Status")) .navigationBarTitleDisplayMode(.inline) @@ -175,7 +189,7 @@ struct SettingsView: View { } header: { Text(L10n.tr("Support VaultSync")) } footer: { - Text(L10n.tr("VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like.")) + Text(L10n.tr("VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward — it unlocks nothing, and the app stays fully functional without it.")) } } diff --git a/ios/VaultSync/Views/SetupChecklistView.swift b/ios/VaultSync/Views/SetupChecklistView.swift index 1783ddb..d90caaa 100644 --- a/ios/VaultSync/Views/SetupChecklistView.swift +++ b/ios/VaultSync/Views/SetupChecklistView.swift @@ -2,6 +2,10 @@ import SwiftUI struct SetupChecklistView: View { var viewModel: SetupChecklistViewModel + /// When set, incomplete items with an in-app entry point render a real action + /// button below their remediation text — instead of leaving the user to + /// navigate there by prose directions. + var onAction: ((SetupChecklistViewModel.ChecklistAction) -> Void)? = nil @Environment(\.dynamicTypeSize) private var dynamicTypeSize var body: some View { @@ -32,8 +36,8 @@ struct SetupChecklistView: View { Text(L10n.fmt("%d of %d essentials ready", viewModel.completedRequiredCount, viewModel.totalRequiredCount)) .font(.caption.weight(.semibold).monospacedDigit()) .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 4) + .padding(.horizontal, VaultSpacing.s) + .padding(.vertical, VaultSpacing.xs) .background(Color(.tertiarySystemBackground), in: Capsule()) } } else { @@ -45,8 +49,8 @@ struct SetupChecklistView: View { Text(L10n.fmt("%d of %d essentials ready", viewModel.completedRequiredCount, viewModel.totalRequiredCount)) .font(.caption.weight(.semibold).monospacedDigit()) .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 4) + .padding(.horizontal, VaultSpacing.s) + .padding(.vertical, VaultSpacing.xs) .background(Color(.tertiarySystemBackground), in: Capsule()) } } @@ -100,6 +104,15 @@ struct SetupChecklistView: View { .accessibilityElement(children: .combine) } + if !item.isComplete, let action = item.action, let onAction { + Button(actionTitle(for: action)) { + onAction(action) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .padding(.top, VaultSpacing.xxs) + } + } .padding(VaultSpacing.m) .frame(maxWidth: .infinity, alignment: .leading) @@ -127,15 +140,21 @@ struct SetupChecklistView: View { return .statusAttention } + private func actionTitle(for action: SetupChecklistViewModel.ChecklistAction) -> String { + switch action { + case .connectObsidian: + return L10n.tr("Connect Obsidian Folder") + case .addDevice: + return L10n.tr("Add Device") + case .openRelayTab: + return L10n.tr("Open the Relay tab") + } + } + @ViewBuilder private func optionalBadge(for item: SetupChecklistViewModel.ChecklistItem) -> some View { if item.isOptional { - Text(L10n.tr("Optional")) - .font(.caption2.weight(.semibold)) - .foregroundStyle(statusColor(for: item)) - .padding(.horizontal, VaultSpacing.s) - .padding(.vertical, 3) - .background(statusColor(for: item).opacity(0.15), in: Capsule()) + StatusTag(text: L10n.tr("Optional"), tint: statusColor(for: item)) } } diff --git a/ios/VaultSync/Views/SubscribePlanPicker.swift b/ios/VaultSync/Views/SubscribePlanPicker.swift index 7f72e9e..3b2c656 100644 --- a/ios/VaultSync/Views/SubscribePlanPicker.swift +++ b/ios/VaultSync/Views/SubscribePlanPicker.swift @@ -78,7 +78,7 @@ struct SubscribePlanPicker: View { .font(.title3) .foregroundStyle(Color.statusAttention) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: VaultSpacing.xxs) { Text(L10n.tr("Add your server first")) .font(.headline) Text(L10n.tr("Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe.")) @@ -95,17 +95,12 @@ struct SubscribePlanPicker: View { purchase(product) } label: { HStack(spacing: VaultSpacing.m) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { HStack(spacing: VaultSpacing.s) { Text(title) .font(.headline) if recommended, let savings = yearlySavingsText { - Text(savings) - .font(.caption2.weight(.bold)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.vaultAccent, in: Capsule()) - .foregroundStyle(.white) + StatusTag(text: savings, tint: .vaultAccent, filled: true) } } Text(subscriptionManager.priceText(for: product)) diff --git a/ios/VaultSync/Views/SyncActivityView.swift b/ios/VaultSync/Views/SyncActivityView.swift index 2781626..022ae66 100644 --- a/ios/VaultSync/Views/SyncActivityView.swift +++ b/ios/VaultSync/Views/SyncActivityView.swift @@ -13,7 +13,7 @@ struct SyncActivityView: View { ) } else { ForEach(events) { event in - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: VaultSpacing.m) { Image(systemName: event.symbolName) .font(.body.weight(.semibold)) .foregroundStyle(event.isError ? Color.statusError : Color.statusInfo) @@ -34,7 +34,7 @@ struct SyncActivityView: View { Spacer(minLength: 0) } - .padding(.vertical, 2) + .padding(.vertical, VaultSpacing.xxs) .accessibilityElement(children: .combine) } } diff --git a/ios/VaultSync/Views/SyncIssuesView.swift b/ios/VaultSync/Views/SyncIssuesView.swift index ca55c6e..0d8f6ce 100644 --- a/ios/VaultSync/Views/SyncIssuesView.swift +++ b/ios/VaultSync/Views/SyncIssuesView.swift @@ -66,6 +66,9 @@ struct SyncIssuesView: View { } } + // Buttons are `.regular` (the 44pt minimum touch target — `.small` violated + // it), and a button that could do nothing is hidden instead of disabled with + // no explanation: the prose remediation above it remains the guidance. @ViewBuilder private func actionView(for issue: SyncthingManager.SyncIssueItem) -> some View { switch issue.kind { @@ -74,22 +77,23 @@ struct SyncIssuesView: View { onRescanFailedFolders() } .buttonStyle(.borderedProminent) - .controlSize(.small) + .controlSize(.regular) case .disconnectedPeers: Button("Add or Reconnect Device") { onOpenAddDevice() } .buttonStyle(.bordered) - .controlSize(.small) + .controlSize(.regular) case .pendingShares: - Button("Accept First Pending Share") { - onAcceptFirstPendingShare() + if !syncthingManager.actionablePendingFolders.isEmpty { + Button("Accept First Pending Share") { + onAcceptFirstPendingShare() + } + .buttonStyle(.bordered) + .controlSize(.regular) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(syncthingManager.actionablePendingFolders.isEmpty) case .conflicts: if let destination = firstConflictDestination(preferredFolderID: issue.folderID) { @@ -100,24 +104,26 @@ struct SyncIssuesView: View { ) } .buttonStyle(.bordered) - .controlSize(.small) + .controlSize(.regular) } case .staleSync: - Button("Rescan All Vaults") { - onRescanAllVaults() + if !syncthingManager.folders.isEmpty { + Button("Rescan All Vaults") { + onRescanAllVaults() + } + .buttonStyle(.bordered) + .controlSize(.regular) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(syncthingManager.folders.isEmpty) case .backgroundSync: - Button("Run Foreground Rescan") { - onRescanAllVaults() + if !syncthingManager.folders.isEmpty { + Button("Run Foreground Rescan") { + onRescanAllVaults() + } + .buttonStyle(.bordered) + .controlSize(.regular) } - .buttonStyle(.bordered) - .controlSize(.small) - .disabled(syncthingManager.folders.isEmpty) } } diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index d0b978b..884d9cd 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -7,19 +7,19 @@ "Add" = "Hinzufügen"; "Add Device" = "Gerät hinzufügen"; "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "Füge ein Gerät mit seiner Syncthing-Geräte-ID hinzu. Du findest sie in der Syncthing-Weboberfläche unter Actions > Show ID."; -"Add or Reconnect Device" = "Gerät verbinden"; +"Add or Reconnect Device" = "Gerät hinzufügen oder neu verbinden"; "Added line. %@" = "Hinzugefügte Zeile. %@"; "All Synced" = "Alles synchron"; "App Refresh" = "App-Aktualisierung"; "Applying…" = "Wird angewendet…"; "Authentication Error" = "Authentifizierungsfehler"; -"Background Sync Completed" = "Hintergrundsynchronisation abgeschlossen"; -"Background Sync Could Not Access Obsidian" = "Hintergrundsynchronisation konnte nicht auf Obsidian zugreifen"; -"Background Sync Could Not Start" = "Hintergrundsynchronisation konnte nicht starten"; -"Background Sync Failed" = "Hintergrundsynchronisation fehlgeschlagen"; -"Background Sync Found No Vaults" = "Hintergrundsynchronisation fand keine Vaults"; -"Background Sync Timed Out" = "Zeitüberschreitung bei Hintergrundsynchronisation"; -"Background sync: %@" = "Hintergrundsync: %@"; +"Background Sync Completed" = "Hintergrund-Sync abgeschlossen"; +"Background Sync Could Not Access Obsidian" = "Hintergrund-Sync konnte nicht auf Obsidian zugreifen"; +"Background Sync Could Not Start" = "Hintergrund-Sync konnte nicht starten"; +"Background Sync Failed" = "Hintergrund-Sync fehlgeschlagen"; +"Background Sync Found No Vaults" = "Hintergrund-Sync fand keine Vaults"; +"Background Sync Timed Out" = "Zeitüberschreitung beim Hintergrund-Sync"; +"Background sync: %@" = "Hintergrund-Sync: %@"; "Camera Access Required" = "Kamerazugriff erforderlich"; "Can't find the Obsidian folder?" = "Den Obsidian-Ordner nicht gefunden?"; "Cancel" = "Abbrechen"; @@ -51,7 +51,7 @@ "Copy Device ID" = "Geräte-ID kopieren"; "Could Not Accept Share" = "Freigabe konnte nicht angenommen werden"; "Could Not Add Device" = "Gerät konnte nicht hinzugefügt werden"; -"Could Not Start Sync" = "Synchronisation konnte nicht gestartet werden"; +"Could Not Start Sync" = "Sync konnte nicht gestartet werden"; "Could not access the selected folder." = "Auf den ausgewählten Ordner konnte nicht zugegriffen werden."; "Could not read conflict file.\n\n%@" = "Konfliktdatei konnte nicht gelesen werden.\n\n%@"; "Could not read files.\n\n%@\n%@" = "Dateien konnten nicht gelesen werden.\n\n%@\n%@"; @@ -67,7 +67,7 @@ "Disconnected" = "Getrennt"; "Done" = "Fertig"; "Empty line" = "Leere Zeile"; -"Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "Aktiviere Mitteilungen für VaultSync unter iOS Einstellungen -> Mitteilungen -> VaultSync und starte die App dann neu."; +"Enable notifications for VaultSync in iOS Settings → Notifications → VaultSync, then restart the app." = "Aktiviere Mitteilungen für VaultSync unter iOS Einstellungen → Mitteilungen → VaultSync und starte die App dann neu."; "Error" = "Fehler"; "Failed" = "Fehlgeschlagen"; "Failed to save access permission: %@" = "Zugriffsberechtigung konnte nicht gespeichert werden: %@"; @@ -78,7 +78,7 @@ "Folder Not Configured" = "Ordner nicht konfiguriert"; "Folder Path Missing" = "Ordnerpfad fehlt"; "Folder Permission Error" = "Ordnerberechtigungsfehler"; -"Folder Sync Error" = "Ordnersynchronisationsfehler"; +"Folder Sync Error" = "Ordner-Sync-Fehler"; "Folder entered an error state." = "Der Ordner ist in einen Fehlerzustand gewechselt."; "Folder error in %@" = "Ordnerfehler in %@"; "Folder is currently in an error state." = "Der Ordner befindet sich derzeit in einem Fehlerzustand."; @@ -94,7 +94,7 @@ "In progress" = "Läuft"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Installiere Obsidian aus dem App Store und öffne es einmal. Der Ordner erscheint, nachdem Obsidian ihn erstellt hat."; "Invalid Input" = "Ungültige Eingabe"; -"Invalid folder name: '%@'" = "Ungültiger Ordnername: '%@'"; +"Invalid folder name: '%@'" = "Ungültiger Ordnername: „%@“"; "Keep Both" = "Beide behalten"; "Keep Other" = "Andere behalten"; "Keep Other Device's Version" = "Version des anderen Geräts behalten"; @@ -107,19 +107,20 @@ "Last Failure" = "Letzter Fehler"; "Last Relay Error" = "Letzter Relay-Fehler"; "Last Success" = "Letzter Erfolg"; -"Last Trigger Received" = "Letzter empfangener Trigger"; +"Last Wake-up Received" = "Letztes empfangenes Weck-Signal"; "Last Update" = "Letzte Aktualisierung"; "Last successful sync was about %d %@ ago." = "Die letzte erfolgreiche Synchronisation war vor etwa %d %@."; "Last successful sync was more than %d %@ ago." = "Die letzte erfolgreiche Synchronisation ist mehr als %d %@ her."; -"Last sync: %@" = "Letzte Synchronisation: %@"; +"Last sync: %@" = "Letzter Sync: %@"; "Latency" = "Latenz"; -"Learn how to fix" = "So behebst du es"; +"Learn how to fix" = "Lösung anzeigen"; "Loading…" = "Wird geladen…"; "Local Files" = "Lokale Dateien"; "Log" = "Protokoll"; "Manage Subscription" = "Abo verwalten"; "Name" = "Name"; "Name (optional)" = "Name (optional)"; +"Name saved" = "Name gespeichert"; "Needs Attention" = "Aktion nötig"; "Network Error" = "Netzwerkfehler"; "Never" = "Nie"; @@ -131,10 +132,11 @@ "No home server devices available for relay provisioning." = "Keine Home-Server-Geräte für Relay-Provisioning verfügbar."; "No immediate relay problems detected." = "Keine unmittelbaren Relay-Probleme erkannt."; "No relay errors recorded." = "Keine Relay-Fehler aufgezeichnet."; -"No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu." = "Es wurde noch kein Relay-Trigger empfangen. Prüfe, ob dein Homeserver-Container `vaultsync-notify` läuft und relay.vaultsync.eu erreichen kann."; +"No wake-up has been received yet. Check that the vaultsync-notify helper is running on your server and can reach relay.vaultsync.eu." = "Es ist noch kein Weck-Signal angekommen. Prüfe, ob der Helfer vaultsync-notify auf deinem Server läuft und relay.vaultsync.eu erreichen kann."; "No successful sync has been recorded for your vaults yet." = "Für deine Vaults wurde noch keine erfolgreiche Synchronisation aufgezeichnet."; "No vaults found" = "Keine Vaults gefunden"; "Not Configured" = "Nicht konfiguriert"; +"Not now" = "Nicht jetzt"; "Not Shared" = "Nicht geteilt"; "Not attempted" = "Nicht versucht"; "Not checked" = "Nicht geprüft"; @@ -146,13 +148,14 @@ "Obsidian directory not accessible." = "Auf das Obsidian-Verzeichnis kann nicht zugegriffen werden."; "Obsidian folder not connected" = "Obsidian-Ordner nicht verbunden"; "Open Settings" = "Einstellungen öffnen"; +"Open the Relay tab" = "Relay-Tab öffnen"; "Open VaultSync" = "VaultSync öffnen"; "Open VaultSync once to restart Syncthing, then retry." = "Öffne VaultSync einmal, um Syncthing neu zu starten, und versuche es dann erneut."; -"Open VaultSync to allow a longer foreground sync session." = "Öffne VaultSync, um eine längere Synchronisationssitzung im Vordergrund zu erlauben."; +"Open VaultSync to allow a longer foreground sync session." = "Öffne VaultSync, um eine längere Sync-Sitzung im Vordergrund zu erlauben."; "Open conflicts and choose which version to keep." = "Öffne Konflikte und wähle aus, welche Version behalten werden soll."; "Open full relay troubleshooting" = "Vollständige Relay-Fehlerbehebung öffnen"; "Open iOS Notification Settings" = "iOS-Mitteilungseinstellungen öffnen"; -"Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry." = "Öffne iOS Einstellungen -> VaultSync, prüfe, ob alle Berechtigungen aktiviert sind, und versuche es dann erneut."; +"Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "Öffne iOS Einstellungen → VaultSync, prüfe, ob alle Berechtigungen aktiviert sind, und versuche es dann erneut."; "Open the folder picker again and select your Obsidian directory." = "Öffne den Ordner-Picker erneut und wähle dein Obsidian-Verzeichnis aus."; "Opens discovery, relay, and notification settings." = "Öffnet Erkennungs-, Relay- und Mitteilungseinstellungen."; "Opens the form to add a Syncthing device." = "Öffnet das Formular zum Hinzufügen eines Syncthing-Geräts."; @@ -164,14 +167,14 @@ "Pending shares are waiting to be accepted before sync can start." = "Ausstehende Freigaben warten auf Annahme, bevor die Synchronisation starten kann."; "Per-Device Provisioning" = "Provisioning pro Gerät"; "Permission Required" = "Berechtigung erforderlich"; -"Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Bitte wähle einen Ordner aus. Wähle im Picker „Auf meinem iPhone“ -> „Obsidian“."; +"Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Bitte wähle einen Ordner aus. Wähle im Picker „Auf meinem iPhone“ → „Obsidian“."; "Privacy Policy" = "Datenschutzrichtlinie"; "Provisioned" = "Provisioniert"; "Push Registration" = "Push-Registrierung"; "Push Registration Failed" = "Push-Registrierung fehlgeschlagen"; "Push registration is unavailable in Simulator. Test APNs on a physical iPhone in Settings > Notifications for VaultSync." = "Die Push-Registrierung ist im Simulator nicht verfügbar. Teste APNs auf einem echten iPhone unter Einstellungen > Mitteilungen für VaultSync."; "Ready" = "Bereit"; -"Recent scan, sync, connection, and error events will appear here." = "Aktuelle Scan-, Synchronisations-, Verbindungs- und Fehlerereignisse erscheinen hier."; +"Recent scan, sync, connection, and error events will appear here." = "Aktuelle Scan-, Sync-, Verbindungs- und Fehlerereignisse erscheinen hier."; "Reconnect Obsidian Directory" = "Obsidian-Verzeichnis erneut verbinden"; "Reconnect Obsidian Folder" = "Obsidian-Ordner erneut verbinden"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "Verbinde den Obsidian-Zugriff erneut oder passe die Ordnerberechtigungen auf dem Host-Gerät an."; @@ -225,6 +228,7 @@ "Purchase Failed" = "Kauf fehlgeschlagen"; "Rescan All Vaults" = "Alle Vaults erneut scannen"; "Sync Conflicts" = "Sync-Konflikte"; +"View Cloud Relay" = "Cloud Relay ansehen"; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; "Retry APNs Registration" = "APNs-Registrierung erneut versuchen"; "Retry Accept" = "Annahme erneut versuchen"; @@ -234,12 +238,12 @@ "Review the affected device/folder setup in the app and retry." = "Prüfe die betroffene Geräte-/Ordnerkonfiguration in der App und versuche es erneut."; "Review the value and try again." = "Prüfe den Wert und versuche es erneut."; "Run Foreground Rescan" = "Im Vordergrund scannen"; -"Scan QR Code" = "QR Code scannen"; +"Scan QR Code" = "QR-Code scannen"; "Scanning" = "Scannen"; "Scanning completed in %@" = "Scan in %@ abgeschlossen"; "Scanning started in %@" = "Scan in %@ gestartet"; "Settings" = "Einstellungen"; -"Share a folder from your desktop Syncthing — it will be accepted automatically." = "Teile einen Ordner aus Syncthing auf deinem Desktop — er wird automatisch angenommen."; +"Share a folder from your desktop Syncthing — it will be accepted automatically." = "Teile einen Ordner aus Syncthing auf deinem Desktop – er wird automatisch angenommen."; "Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Freigabeanfragen werden unten angezeigt, aber Annehmen und Erneut versuchen sind deaktiviert, bis dein Obsidian-Ordner verbunden ist."; "Shared" = "Geteilt"; "Shared With" = "Geteilt mit"; @@ -253,47 +257,47 @@ "Some entered data is invalid or incomplete." = "Einige eingegebene Daten sind ungültig oder unvollständig."; "Some shared peers are offline or unreachable right now." = "Einige geteilte Peers sind derzeit offline oder nicht erreichbar."; "Starting…" = "Startet…"; -"Starting" = "Wird gestartet"; +"Starting" = "Startet"; "Paused" = "Pausiert"; "State" = "Status"; "Status" = "Status"; "Subscribe" = "Abonnieren"; "Subscription unavailable" = "Abo nicht verfügbar"; -"Sync Activity" = "Synchronisationsaktivität"; -"Sync Activity Looks Stale" = "Synchronisationsaktivität wirkt veraltet"; -"Sync Engine Not Running" = "Synchronisations-Engine läuft nicht"; +"Sync Activity" = "Sync-Aktivität"; +"Sync Activity Looks Stale" = "Sync-Aktivität wirkt veraltet"; +"Sync Engine Not Running" = "Sync-Engine läuft nicht"; "Sync Error" = "Sync-Fehler"; "Sync Issue" = "Sync-Problem"; -"Sync Issues" = "Synchronisationsprobleme"; -"Sync Status" = "Synchronisationsstatus"; -"Sync completed in %@" = "Synchronisation in %@ abgeschlossen"; -"Sync error in %@" = "Synchronisationsfehler in %@"; -"Sync started in %@" = "Synchronisation in %@ gestartet"; -"Synchronizing your Obsidian vault..." = "Dein Obsidian-Vault wird synchronisiert..."; +"Sync Issues" = "Sync-Probleme"; +"Sync Status" = "Sync-Status"; +"Sync completed in %@" = "Sync in %@ abgeschlossen"; +"Sync error in %@" = "Sync-Fehler in %@"; +"Sync started in %@" = "Sync in %@ gestartet"; +"Synchronizing your Obsidian vault…" = "Dein Obsidian-Vault wird synchronisiert…"; "Syncing" = "Wird synchronisiert"; "Syncing Vault" = "Vault wird synchronisiert"; "Syncing…" = "Synchronisiert…"; -"Reconnecting…" = "Verbinde…"; +"Reconnecting…" = "Verbindet neu…"; "Restoring connection to 1 device" = "Verbindung zu 1 Gerät wird wiederhergestellt"; "Restoring connection to %d devices" = "Verbindung zu %d Geräten wird wiederhergestellt"; "Syncthing is scanning local changes." = "Syncthing scannt lokale Änderungen."; "Syncthing no longer has this folder configured." = "Syncthing hat diesen Ordner nicht mehr konfiguriert."; "Terms of Use" = "Nutzungsbedingungen"; "The device will be disconnected and removed from all shared folders." = "Das Gerät wird getrennt und aus allen geteilten Ordnern entfernt."; -"The embedded Syncthing bridge did not start for a background sync." = "Die eingebettete Syncthing-Bridge ist für eine Hintergrundsynchronisation nicht gestartet."; -"The file '%@' was kept as your local version. The other device's version was discarded." = "Die Datei '%@' wurde als lokale Version behalten. Die Version des anderen Geräts wurde verworfen."; -"The file '%@' was overwritten with the version from the other device." = "Die Datei '%@' wurde mit der Version des anderen Geräts überschrieben."; +"The embedded Syncthing bridge did not start for a background sync." = "Die eingebettete Syncthing-Bridge ist für einen Hintergrund-Sync nicht gestartet."; +"The file '%@' was kept as your local version. The other device's version was discarded." = "Die Datei „%@“ wurde als lokale Version behalten. Die Version des anderen Geräts wurde verworfen."; +"The file '%@' was overwritten with the version from the other device." = "Die Datei „%@“ wurde mit der Version des anderen Geräts überschrieben."; "The folder path no longer exists%@." = "Der Ordnerpfad existiert nicht mehr%@."; "The folder scan finished successfully." = "Der Ordnerscan wurde erfolgreich abgeschlossen."; "This Device" = "Dieses Gerät"; -"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Dieser Ordner sieht noch nicht wie dein Obsidian-Verzeichnis aus. Wähle im Picker „Auf meinem iPhone“ -> „Obsidian“."; +"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Dieser Ordner sieht noch nicht wie dein Obsidian-Verzeichnis aus. Wähle im Picker „Auf meinem iPhone“ → „Obsidian“."; "This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "Dieser Zeitstempel wird aktualisiert, wenn VaultSync einen stillen Push von Cloud Relay empfängt."; "This will permanently discard the version from the other device." = "Dadurch wird die Version des anderen Geräts dauerhaft verworfen."; "This will permanently discard your local version." = "Dadurch wird deine lokale Version dauerhaft verworfen."; "Timed out" = "Zeitüberschreitung"; "Timeline updates were rate-limited to keep activity readable." = "Zeitachsen-Updates wurden rate-limitiert, damit die Aktivität lesbar bleibt."; -"Trigger Delivery" = "Trigger-Zustellung"; -"Trigger a vault rescan to refresh sync state." = "Starte einen erneuten Vault-Scan, um den Synchronisationsstatus zu aktualisieren."; +"Wake-up Delivery" = "Weck-Signal-Zustellung"; +"Trigger a vault rescan to refresh sync state." = "Starte einen erneuten Vault-Scan, um den Sync-Status zu aktualisieren."; "Troubleshooting" = "Fehlerbehebung"; "Unchanged line. %@" = "Unveränderte Zeile. %@"; "Unhealthy" = "Nicht funktionsfähig"; @@ -307,11 +311,11 @@ "Unreachable" = "Nicht erreichbar"; "Vault" = "Vault"; "VaultSync" = "VaultSync"; -"VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access." = "VaultSync kann dein Obsidian-Verzeichnis nicht mehr lesen. Verbinde den Ordner erneut, um den Synchronisationszugriff wiederherzustellen."; +"VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access." = "VaultSync kann dein Obsidian-Verzeichnis nicht mehr lesen. Verbinde den Ordner erneut, um den Sync-Zugriff wiederherzustellen."; "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync kann die gespeicherte Berechtigung für den Obsidian-Ordner nicht mehr auflösen. Verbinde das Obsidian-Verzeichnis erneut, um weiter zu synchronisieren."; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync kann nicht mehr auf den gespeicherten Obsidian-Ordner zugreifen. Verbinde das Obsidian-Verzeichnis erneut, um weiter zu synchronisieren."; "VaultSync cannot access this folder%@." = "VaultSync kann auf diesen Ordner nicht zugreifen%@."; -"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\"." = "VaultSync kann diesen Ordner nicht lesen. Öffne den Picker erneut und wähle „Auf meinem iPhone“ -> „Obsidian“."; +"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" → \"Obsidian\"." = "VaultSync kann diesen Ordner nicht lesen. Öffne den Picker erneut und wähle „Auf meinem iPhone“ → „Obsidian“."; "VaultSync cannot talk to Syncthing right now." = "VaultSync kann derzeit nicht mit Syncthing kommunizieren."; "VaultSync could not complete the request due to a network issue." = "VaultSync konnte die Anfrage wegen eines Netzwerkproblems nicht abschließen."; "VaultSync could not reach the Cloud Relay service." = "VaultSync konnte den Cloud Relay-Dienst nicht erreichen."; @@ -332,7 +336,7 @@ "No Activity Yet" = "Noch keine Aktivität"; "Sync engine running" = "Sync-Engine läuft"; "onboarding.welcome.title" = "Deine Obsidian-Notizen. Privat synchronisiert."; -"onboarding.welcome.subtitle" = "Halte deinen Vault zwischen deinen eigenen Geräten synchron — ohne fremden Cloud-Speicher."; +"onboarding.welcome.subtitle" = "Halte deinen Vault zwischen deinen eigenen Geräten synchron – ohne fremden Cloud-Speicher."; "onboarding.welcome.benefit.private" = "Privat zwischen deinen Geräten"; "onboarding.welcome.benefit.obsidian" = "Für Obsidian Vaults gemacht"; "onboarding.welcome.benefit.noCloud" = "Kein Cloud-Konto erforderlich"; @@ -349,6 +353,7 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync kann nicht auf deinen lokalen Obsidian-Ordner zugreifen."; "Connect your Obsidian folder from the VaultSync home screen." = "Verbinde deinen Obsidian-Ordner auf dem VaultSync-Startbildschirm."; "Computer or server added" = "Computer oder Server hinzugefügt"; +"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "Dein erster Sync ist geschafft. Cloud Relay weckt dieses iPhone mit einem Weck-Signal, sobald sich deine Notizen ändern — auch bei geschlossener App."; "Your iPhone is paired with at least one Syncthing device." = "Dein iPhone ist mit mindestens einem Syncthing-Gerät gekoppelt."; "Your iPhone is not paired with a Syncthing device yet." = "Dein iPhone ist noch nicht mit einem Syncthing-Gerät gekoppelt."; "Add your computer or server from the Devices section on the home screen." = "Füge deinen Computer oder Server im Bereich „Geräte“ auf dem Startbildschirm hinzu."; @@ -385,7 +390,7 @@ "Could not add filter" = "Filter konnte nicht hinzugefügt werden"; "Could not save filters" = "Filter konnten nicht gespeichert werden"; "Could not read current sync filters. Please try again." = "Aktuelle Sync-Filter konnten nicht gelesen werden. Bitte erneut versuchen."; -"%@ — %d files" = "%@ — %d Dateien"; +"%@ — %d files" = "%@ – %d Dateien"; /* IgnorePreset labels and descriptions */ "Workspace state" = "Arbeitsbereich-Zustand"; @@ -394,7 +399,7 @@ "Files already deleted on other devices." = "Bereits auf anderen Geräten gelöschte Dateien."; "Git repository" = "Git-Repository"; "Node modules" = "Node-Module"; -"Version history — rarely useful on iPhone." = "Versionsverlauf — auf dem iPhone selten nützlich."; +"Version history — rarely useful on iPhone." = "Versionsverlauf – auf dem iPhone selten nützlich."; "macOS metadata" = "macOS-Metadaten"; "Finder metadata files like .DS_Store." = "Finder-Metadatendateien wie .DS_Store."; "Copilot index" = "Copilot-Index"; @@ -403,8 +408,8 @@ "Auto-regenerated Obsidian internal cache." = "Automatisch regenerierter interner Obsidian-Cache."; // Sync Issue titles & messages -"1 Vault Has Sync Errors" = "1 Vault hat Synchronisationsfehler"; -"%d Vaults Have Sync Errors" = "%d Vaults haben Synchronisationsfehler"; +"1 Vault Has Sync Errors" = "1 Vault hat Sync-Fehler"; +"%d Vaults Have Sync Errors" = "%d Vaults haben Sync-Fehler"; "At least one folder is currently in an error state." = "Mindestens ein Ordner befindet sich derzeit in einem Fehlerzustand."; "1 Required Device Is Disconnected" = "1 erforderliches Gerät ist getrennt"; "%d Required Devices Are Disconnected" = "%d erforderliche Geräte sind getrennt"; @@ -418,21 +423,21 @@ "%d files have sync conflicts. Open VaultSync to resolve them." = "%d Dateien haben Sync-Konflikte. Öffne VaultSync, um sie zu lösen."; /* Issue #10 — conflict-notifications toggle (Settings) */ -"Notifications" = "Benachrichtigungen"; -"Conflict Notifications" = "Konflikt-Benachrichtigungen"; -"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Zeigt ein Banner an, wenn Sync-Konflikte erkannt werden. Das Ausschalten betrifft weder Cloud Relay noch die Hintergrundsynchronisation — dein Vault synchronisiert weiter."; +"Notifications" = "Mitteilungen"; +"Conflict Notifications" = "Konflikt-Mitteilungen"; +"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Zeigt ein Banner an, wenn Sync-Konflikte erkannt werden. Das Ausschalten betrifft weder Cloud Relay noch den Hintergrund-Sync – dein Vault synchronisiert weiter."; /* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ "Alert Banners" = "Hinweis-Banner"; "Allowed" = "Erlaubt"; "Denied" = "Verweigert"; "Cloud Relay is delivering wake-ups" = "Cloud Relay liefert Weck-Signale"; -"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Hinweis-Banner sind auf iOS-Ebene aus. Cloud-Relay-Weck-Signale funktionieren weiterhin — sie nutzen Silent Push, der keine Benachrichtigungsberechtigung benötigt."; +"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Hinweis-Banner sind auf iOS-Ebene aus. Cloud-Relay-Weck-Signale funktionieren weiterhin – sie nutzen Silent Push, der keine Mitteilungsberechtigung benötigt."; "APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)" = "APNs-Token fehlt. Wiederhole die APNs-Registrierung; falls es weiter fehlschlägt, prüfe deine Internetverbindung. (Silent Push benötigt keine Hinweis-Banner.)"; "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs-Registrierung fehlgeschlagen. Prüfe deine Internetverbindung und wiederhole die Registrierung. Silent Push benötigt keine aktivierten Hinweis-Banner."; /* Issue #10 — background-sync reliability (error-settled outcome) */ -"Background sync settled with at least one folder in an error state." = "Hintergrundsynchronisation beendet — mindestens ein Ordner ist im Fehlerzustand."; +"Background sync settled with at least one folder in an error state." = "Hintergrund-Sync beendet – mindestens ein Ordner ist im Fehlerzustand."; /* Issue #10 — relay reachable (vs delivering) */ "Cloud Relay looks reachable" = "Cloud Relay scheint erreichbar"; @@ -455,9 +460,9 @@ "Contributions are currently unavailable." = "Unterstützungen sind derzeit nicht verfügbar."; "Thank you!" = "Danke!"; "Your contribution means a lot and directly supports VaultSync development. Thank you!" = "Deine Unterstützung bedeutet viel und fließt direkt in die Entwicklung von VaultSync. Danke!"; -"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync ist eine unabhängige Open-Source-App (MPL-2.0). Eine einmalige Unterstützung hält sie unabhängig, werbefrei und in Entwicklung. Sie schaltet nichts frei — VaultSync bleibt auch ohne sie voll funktionsfähig — und du kannst so oft geben, wie du möchtest."; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward — it unlocks nothing, and the app stays fully functional without it." = "VaultSync ist eine unabhängige Open-Source-App (MPL-2.0). Eine einmalige Unterstützung hält sie unabhängig, werbefrei und in Entwicklung – sie schaltet nichts frei, und die App bleibt auch ohne sie voll funktionsfähig."; /* Cloud Relay price (localized via StoreKit) */ -"Cloud Relay — %@" = "Cloud Relay — %@"; +"Cloud Relay — %@" = "Cloud Relay – %@"; "Cloud Relay subscription" = "Cloud-Relay-Abo"; /* Issue #10 pre-merge polish — added missing localization keys */ @@ -476,7 +481,7 @@ "Added lines come from the other device; removed lines are your version on this device." = "Hinzugefügte Zeilen stammen vom anderen Gerät; entfernte Zeilen sind deine Version auf diesem Gerät."; "(empty or unreadable)" = "(leer oder nicht lesbar)"; "a new name" = "einen neuen Namen"; -"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Beide Versionen wurden behalten.\n\nDeine lokale Version bleibt als '%@'.\nDie Version des anderen Geräts wurde in '%@' umbenannt."; +"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Beide Versionen wurden behalten.\n\nDeine lokale Version bleibt als „%@“.\nDie Version des anderen Geräts wurde in „%@“ umbenannt."; "%d conflicts" = "%d Konflikte"; "Your contribution is pending approval." = "Deine Unterstützung wartet auf Freigabe."; "iOS did not provide a push token required for instant sync." = "iOS hat kein Push-Token bereitgestellt, das für Sofort-Sync erforderlich ist."; @@ -486,14 +491,14 @@ "%@ connected" = "%@ verbunden"; "%@ disconnected" = "%@ getrennt"; "%d%% complete" = "%d %% abgeschlossen"; -"Background sync completed and reached idle." = "Hintergrundsynchronisation abgeschlossen und Leerlauf erreicht."; -"Background sync did not reach an idle folder state before the iOS deadline." = "Hintergrundsynchronisation hat vor dem iOS-Zeitlimit keinen Leerlauf-Zustand der Ordner erreicht."; -"Background sync ended with an unexpected failure." = "Hintergrundsynchronisation endete mit einem unerwarteten Fehler."; -"Background sync ran, but folders were already idle." = "Hintergrundsynchronisation lief, aber die Ordner waren bereits im Leerlauf."; +"Background sync completed and reached idle." = "Hintergrund-Sync abgeschlossen und Leerlauf erreicht."; +"Background sync did not reach an idle folder state before the iOS deadline." = "Hintergrund-Sync hat vor dem iOS-Zeitlimit keinen Leerlauf-Zustand der Ordner erreicht."; +"Background sync ended with an unexpected failure." = "Hintergrund-Sync endete mit einem unerwarteten Fehler."; +"Background sync ran, but folders were already idle." = "Hintergrund-Sync lief, aber die Ordner waren bereits im Leerlauf."; "Forced silent-push restart failed." = "Erzwungener Neustart per Silent Push fehlgeschlagen."; -"No Syncthing folders were available to sync in the background." = "Es waren keine Syncthing-Ordner für die Hintergrundsynchronisation verfügbar."; +"No Syncthing folders were available to sync in the background." = "Es waren keine Syncthing-Ordner für den Hintergrund-Sync verfügbar."; "No folders were available after forced silent-push restart." = "Nach dem erzwungenen Silent-Push-Neustart waren keine Ordner verfügbar."; -"No folders were available for background sync." = "Für die Hintergrundsynchronisation waren keine Ordner verfügbar."; +"No folders were available for background sync." = "Für den Hintergrund-Sync waren keine Ordner verfügbar."; "No security-scoped bookmark access was available." = "Es war kein Security-Scoped-Bookmark-Zugriff verfügbar."; "Accept or create a shared vault before relying on background sync." = "Akzeptiere oder erstelle einen geteilten Vault, bevor du dich auf Hintergrund-Sync verlässt."; "Retry from the app and review relay/background diagnostics in Settings." = "Versuche es erneut in der App und prüfe die Relay-/Hintergrund-Diagnose in den Einstellungen."; @@ -510,16 +515,16 @@ /* Cloud Relay activation (2026-05-31): honest server-setup + conversion */ "Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync." = "Cloud Relay ist nicht aktiviert. Ohne Cloud Relay kommen eingehende Änderungen an, wenn du VaultSync öffnest."; "Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "Aktiviere Cloud Relay im Relay-Tab, wenn Änderungen im selben Moment gepusht werden sollen."; -"Cloud Relay — finish server setup" = "Cloud Relay — Server-Einrichtung abschließen"; +"Cloud Relay — finish server setup" = "Cloud Relay – Server-Einrichtung abschließen"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "Du hast ein Abo, aber es ist noch kein aktuelles Weck-Signal angekommen. Stelle sicher, dass der Helfer „vaultsync-notify“ auf deinem Server läuft."; -"Set up the server helper from the Relay tab → Set Up Your Server." = "Richte den Server-Helfer ein über Relay-Tab → Server einrichten."; -"Wake-ups are being delivered — incoming changes sync the moment they happen." = "Weck-Signale werden zugestellt — eingehende Änderungen synchronisieren im selben Moment."; -"Your server helper is running — wake-ups are being delivered." = "Dein Server-Helfer läuft — Weck-Signale werden zugestellt."; +"Set up the server helper from the Relay tab → Set Up Your Server." = "Richte den Server-Helfer unter Relay-Tab → „Server einrichten“ ein."; +"Wake-ups are being delivered — incoming changes sync the moment they happen." = "Weck-Signale werden zugestellt – eingehende Änderungen synchronisieren im selben Moment."; +"Your server helper is running — wake-ups are being delivered." = "Dein Server-Helfer läuft – Weck-Signale werden zugestellt."; "Why this step" = "Warum dieser Schritt"; -"Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with." = "Cloud Relay braucht einen kleinen Helfer auf deinem Server. Er beobachtet Syncthing auf Änderungen und sendet VaultSync ein Weck-Signal — deine Notizen sieht er nie. Ohne ihn hat das Abo nichts, womit es die App wecken könnte."; +"Cloud Relay needs a small helper on your server. It watches Syncthing for changes and sends VaultSync a wake-up signal — it never sees your notes. Without it, the subscription has nothing to wake the app with." = "Cloud Relay braucht einen kleinen Helfer auf deinem Server. Er beobachtet Syncthing auf Änderungen und sendet VaultSync ein Weck-Signal – deine Notizen sieht er nie. Ohne ihn hat das Abo nichts, womit es die App wecken könnte."; "Copy Command" = "Befehl kopieren"; -"Step 3 — Confirm it works" = "Schritt 3 — Bestätige, dass es funktioniert"; -"Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works." = "Komm zurück zu VaultSync. Sobald sich auf deinem Server etwas ändert, bestätigt die Relay-Diagnose, dass Weck-Signale zugestellt werden — dann funktioniert alles."; +"Step 3 — Confirm it works" = "Schritt 3 – Bestätige, dass es funktioniert"; +"Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works." = "Komm zurück zu VaultSync. Sobald sich auf deinem Server etwas ändert, bestätigt die Relay-Diagnose, dass Weck-Signale zugestellt werden – dann funktioniert alles."; "Full setup guide (Docker Compose or guided setup script)" = "Vollständige Anleitung (Docker Compose oder geführtes Setup-Skript)"; "Prefer Docker Compose or a guided setup script? The full guide covers both." = "Lieber Docker Compose oder ein geführtes Setup-Skript? Die vollständige Anleitung deckt beides ab."; "Set Up Your Server" = "Server einrichten"; @@ -531,7 +536,7 @@ "Turn on Cloud Relay" = "Cloud Relay aktivieren"; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ -"%1$@ — %2$@" = "%1$@ — %2$@"; +"%1$@ — %2$@" = "%1$@ – %2$@"; "%@: %@" = "%@: %@"; "(%d%%)" = "(%d %%)"; "Add your computer or server" = "Computer oder Server hinzufügen"; @@ -575,9 +580,9 @@ "Could Not Remove Vault" = "Vault konnte nicht entfernt werden"; "Remove this vault from this iPhone?" = "Diesen Vault von diesem iPhone entfernen?"; "“%@” will stop syncing on this iPhone. Files already on your other devices are not deleted." = "„%@“ wird auf diesem iPhone nicht mehr synchronisiert. Dateien auf deinen anderen Geräten werden nicht gelöscht."; -"Removing a vault only stops syncing it on this iPhone. The notes on your other devices are not affected." = "Das Entfernen eines Vaults stoppt nur die Synchronisierung auf diesem iPhone. Die Notizen auf deinen anderen Geräten sind nicht betroffen."; -"This vault points to storage that no longer exists on this iPhone, so it can no longer sync." = "Dieser Vault verweist auf einen Speicherort, den es auf diesem iPhone nicht mehr gibt – Synchronisierung ist daher nicht möglich."; -"Stops syncing this vault on this iPhone. The notes on your other devices are not affected." = "Stoppt die Synchronisierung dieses Vaults auf diesem iPhone. Die Notizen auf deinen anderen Geräten sind nicht betroffen."; +"Removing a vault only stops syncing it on this iPhone. The notes on your other devices are not affected." = "Das Entfernen eines Vaults stoppt nur die Synchronisation auf diesem iPhone. Die Notizen auf deinen anderen Geräten sind nicht betroffen."; +"This vault points to storage that no longer exists on this iPhone, so it can no longer sync." = "Dieser Vault verweist auf einen Speicherort, den es auf diesem iPhone nicht mehr gibt – Synchronisation ist daher nicht möglich."; +"Stops syncing this vault on this iPhone. The notes on your other devices are not affected." = "Stoppt die Synchronisation dieses Vaults auf diesem iPhone. Die Notizen auf deinen anderen Geräten sind nicht betroffen."; /* Cloud Relay activation (lab → wave 1; es/zh-Hans need a native review) */ "Could not accept share '%@'.\n\n%@" = "Freigabe „%@“ konnte nicht angenommen werden.\n\n%@"; @@ -585,17 +590,23 @@ "You’re subscribed, but your server has never woken this iPhone. One step finishes setup." = "Du hast ein Abo, aber dein Server hat dieses iPhone noch nie geweckt. Ein Schritt schließt die Einrichtung ab."; "Opens Cloud Relay setup." = "Öffnet die Cloud-Relay-Einrichtung."; "Cloud Relay went quiet" = "Cloud Relay ist verstummt"; -"No wake-up in a while — is your server still on?" = "Seit einer Weile kein Weck-Signal — läuft dein Server noch?"; +"No wake-up in a while — is your server still on?" = "Seit einer Weile kein Weck-Signal – läuft dein Server noch?"; "Your server just reached this iPhone. Incoming changes now sync the moment they happen." = "Dein Server hat dieses iPhone gerade erreicht. Eingehende Änderungen synchronisieren jetzt im selben Moment."; "Great" = "Super"; "No wake-up has arrived in a while. Make sure the helper is still running on the computer or server you keep on." = "Seit einer Weile ist kein Weck-Signal angekommen. Stelle sicher, dass der Helfer auf dem dauerhaft laufenden Computer oder Server noch läuft."; "Not active yet" = "Noch nicht aktiv"; -"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below. This screen updates automatically the moment the first wake-up arrives." = "Du hast ein Abo. Weck-Signale beginnen, sobald der Helfer auf dem dauerhaft laufenden Computer oder Server läuft — schließe die Einrichtung unten ab. Diese Ansicht aktualisiert sich automatisch, sobald das erste Weck-Signal eintrifft."; -"Relay reachable — but no wake-up has arrived recently. Check the helper is still running on your server." = "Relay erreichbar — aber in letzter Zeit kein Weck-Signal. Prüfe, ob der Helfer auf deinem Server noch läuft."; -"Relay reachable — but no wake-up delivered yet. Run the helper on your server." = "Relay erreichbar — aber noch kein Weck-Signal zugestellt. Starte den Helfer auf deinem Server."; +"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "Du hast ein Abo. Weck-Signale beginnen, sobald der Helfer auf dem dauerhaft laufenden Computer oder Server läuft – schließe die Einrichtung unten ab."; +"Relay reachable — but no wake-up has arrived recently. Check the helper is still running on your server." = "Relay erreichbar – aber in letzter Zeit kein Weck-Signal. Prüfe, ob der Helfer auf deinem Server noch läuft."; +"Relay reachable — but no wake-up delivered yet. Run the helper on your server." = "Relay erreichbar – aber noch kein Weck-Signal zugestellt. Starte den Helfer auf deinem Server."; /* Cloud Relay setup — key-free server command (Phase 3 A+C) */ -"Step 1 — Run this on your server" = "Schritt 1 — Führe dies auf deinem Server aus"; -"No API key to copy — the helper reads it straight from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). If you get a permission error, add -u : for the user that owns config.xml." = "Kein API-Schlüssel zum Kopieren — der Helfer liest ihn direkt aus Syncthings config.xml. Ersetze /PATH/TO/syncthing durch deinen Syncthing-Konfigurationsordner (oft ~/.local/state/syncthing oder ~/.config/syncthing). Bei einem Berechtigungsfehler füge -u : für den Benutzer hinzu, dem config.xml gehört."; -"Step 2 — It activates itself" = "Schritt 2 — Es aktiviert sich selbst"; -"That’s it. As soon as the helper starts, it wakes this iPhone once on its own — VaultSync flips to “Cloud Relay active” the moment that first wake-up arrives, with no change needed. After that, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (your server or NAS)." = "Das war’s. Sobald der Helfer startet, weckt er dieses iPhone einmal von selbst — VaultSync wechselt zu „Cloud Relay aktiv“, sobald das erste Weck-Signal eintrifft, ganz ohne weiteres Zutun. Danach wecken Änderungen von deinen anderen Geräten die App im Hintergrund. Lass den Helfer auf einem dauerhaft laufenden Gerät (Server oder NAS) laufen."; +"Step 1 — Run this on your server" = "Schritt 1 – Führe dies auf deinem Server aus"; +"No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml." = "Kein API-Schlüssel nötig – der Helfer liest ihn direkt aus Syncthings config.xml. Ersetze /PATH/TO/syncthing durch deinen Syncthing-Konfigurationsordner (oft ~/.local/state/syncthing oder ~/.config/syncthing). Berechtigungsfehler? Ergänze -u : für den Benutzer, dem config.xml gehört."; +"Step 2 — It activates itself" = "Schritt 2 – Es aktiviert sich selbst"; +"That’s it — the helper wakes this iPhone once on its own, and VaultSync flips to “Cloud Relay active” as soon as that first wake-up arrives. From then on, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (server or NAS)." = "Das war’s – der Helfer weckt dieses iPhone einmal von selbst, und VaultSync wechselt zu „Cloud Relay aktiv“, sobald dieses erste Weck-Signal eintrifft. Ab dann wecken Änderungen von deinen anderen Geräten die App im Hintergrund. Lass den Helfer auf einem dauerhaft laufenden Gerät (Server oder NAS) laufen."; + +/* Relay diagnostics — delivery visibility (force-quit / Low Power education, 7-day counter) */ +"Wake-ups (Last 7 Days)" = "Weck-Signale (letzte 7 Tage)"; +"Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging." = "Der Stromsparmodus ist aktiv – iOS verzögert stille Weck-Signale, bis er ausgeschaltet ist oder das iPhone lädt."; +"Keep VaultSync in the background: if you force-quit it from the app switcher, iOS stops delivering wake-ups until you open the app again." = "Lass VaultSync im Hintergrund: Wenn du die App im App-Umschalter wegwischst, stellt iOS keine Weck-Signale mehr zu, bis du die App wieder öffnest."; +"Wake-ups went quiet. The most common cause is force-quitting VaultSync from the app switcher — iOS then blocks wake-ups until the next manual launch. Also check that the helper on your server is still running." = "Die Weck-Signale sind verstummt. Häufigste Ursache: VaultSync wurde im App-Umschalter weggewischt – iOS blockiert Weck-Signale dann bis zum nächsten manuellen Start. Prüfe auch, ob der Helfer auf deinem Server noch läuft."; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 90a2b0c..518fae9 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -67,7 +67,7 @@ "Disconnected" = "Disconnected"; "Done" = "Done"; "Empty line" = "Empty line"; -"Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app."; +"Enable notifications for VaultSync in iOS Settings → Notifications → VaultSync, then restart the app." = "Enable notifications for VaultSync in iOS Settings → Notifications → VaultSync, then restart the app."; "Error" = "Error"; "Failed" = "Failed"; "Failed to save access permission: %@" = "Failed to save access permission: %@"; @@ -107,7 +107,7 @@ "Last Failure" = "Last Failure"; "Last Relay Error" = "Last Relay Error"; "Last Success" = "Last Success"; -"Last Trigger Received" = "Last Trigger Received"; +"Last Wake-up Received" = "Last Wake-up Received"; "Last Update" = "Last Update"; "Last successful sync was about %d %@ ago." = "Last successful sync was about %d %@ ago."; "Last successful sync was more than %d %@ ago." = "Last successful sync was more than %d %@ ago."; @@ -120,6 +120,7 @@ "Manage Subscription" = "Manage Subscription"; "Name" = "Name"; "Name (optional)" = "Name (optional)"; +"Name saved" = "Name saved"; "Needs Attention" = "Needs Attention"; "Network Error" = "Network Error"; "Never" = "Never"; @@ -131,10 +132,11 @@ "No home server devices available for relay provisioning." = "No home server devices available for relay provisioning."; "No immediate relay problems detected." = "No immediate relay problems detected."; "No relay errors recorded." = "No relay errors recorded."; -"No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu." = "No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu."; +"No wake-up has been received yet. Check that the vaultsync-notify helper is running on your server and can reach relay.vaultsync.eu." = "No wake-up has been received yet. Check that the vaultsync-notify helper is running on your server and can reach relay.vaultsync.eu."; "No successful sync has been recorded for your vaults yet." = "No successful sync has been recorded for your vaults yet."; "No vaults found" = "No vaults found"; "Not Configured" = "Not Configured"; +"Not now" = "Not now"; "Not Shared" = "Not Shared"; "Not attempted" = "Not attempted"; "Not checked" = "Not checked"; @@ -146,13 +148,14 @@ "Obsidian directory not accessible." = "Obsidian directory not accessible."; "Obsidian folder not connected" = "Obsidian folder not connected"; "Open Settings" = "Open Settings"; +"Open the Relay tab" = "Open the Relay tab"; "Open VaultSync" = "Open VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "Open VaultSync once to restart Syncthing, then retry."; "Open VaultSync to allow a longer foreground sync session." = "Open VaultSync to allow a longer foreground sync session."; "Open conflicts and choose which version to keep." = "Open conflicts and choose which version to keep."; "Open full relay troubleshooting" = "Open full relay troubleshooting"; "Open iOS Notification Settings" = "Open iOS Notification Settings"; -"Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry." = "Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry."; +"Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "Open iOS Settings → VaultSync and check that all permissions are enabled, then retry."; "Open the folder picker again and select your Obsidian directory." = "Open the folder picker again and select your Obsidian directory."; "Opens discovery, relay, and notification settings." = "Opens discovery, relay, and notification settings."; "Opens the form to add a Syncthing device." = "Opens the form to add a Syncthing device."; @@ -164,7 +167,7 @@ "Pending shares are waiting to be accepted before sync can start." = "Pending shares are waiting to be accepted before sync can start."; "Per-Device Provisioning" = "Per-Device Provisioning"; "Permission Required" = "Permission Required"; -"Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\"."; +"Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"."; "Privacy Policy" = "Privacy Policy"; "Provisioned" = "Provisioned"; "Push Registration" = "Push Registration"; @@ -225,6 +228,7 @@ "Purchase Failed" = "Purchase Failed"; "Rescan All Vaults" = "Rescan All Vaults"; "Sync Conflicts" = "Sync Conflicts"; +"View Cloud Relay" = "View Cloud Relay"; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; "Retry APNs Registration" = "Retry APNs Registration"; "Retry Accept" = "Retry Accept"; @@ -269,7 +273,7 @@ "Sync completed in %@" = "Sync completed in %@"; "Sync error in %@" = "Sync error in %@"; "Sync started in %@" = "Sync started in %@"; -"Synchronizing your Obsidian vault..." = "Synchronizing your Obsidian vault..."; +"Synchronizing your Obsidian vault…" = "Synchronizing your Obsidian vault…"; "Syncing" = "Syncing"; "Syncing Vault" = "Syncing Vault"; "Syncing…" = "Syncing…"; @@ -286,13 +290,13 @@ "The folder path no longer exists%@." = "The folder path no longer exists%@."; "The folder scan finished successfully." = "The folder scan finished successfully."; "This Device" = "This Device"; -"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\"."; +"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" → \"Obsidian\"."; "This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "This timestamp is updated when VaultSync receives a silent push from Cloud Relay."; "This will permanently discard the version from the other device." = "This will permanently discard the version from the other device."; "This will permanently discard your local version." = "This will permanently discard your local version."; "Timed out" = "Timed out"; "Timeline updates were rate-limited to keep activity readable." = "Timeline updates were rate-limited to keep activity readable."; -"Trigger Delivery" = "Trigger Delivery"; +"Wake-up Delivery" = "Wake-up Delivery"; "Trigger a vault rescan to refresh sync state." = "Trigger a vault rescan to refresh sync state."; "Troubleshooting" = "Troubleshooting"; "Unchanged line. %@" = "Unchanged line. %@"; @@ -311,7 +315,7 @@ "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing."; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing."; "VaultSync cannot access this folder%@." = "VaultSync cannot access this folder%@."; -"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\"." = "VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\"."; +"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" → \"Obsidian\"." = "VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" → \"Obsidian\"."; "VaultSync cannot talk to Syncthing right now." = "VaultSync cannot talk to Syncthing right now."; "VaultSync could not complete the request due to a network issue." = "VaultSync could not complete the request due to a network issue."; "VaultSync could not reach the Cloud Relay service." = "VaultSync could not reach the Cloud Relay service."; @@ -349,6 +353,7 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync cannot access your local Obsidian folder."; "Connect your Obsidian folder from the VaultSync home screen." = "Connect your Obsidian folder from the VaultSync home screen."; "Computer or server added" = "Computer or server added"; +"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed."; "Your iPhone is paired with at least one Syncthing device." = "Your iPhone is paired with at least one Syncthing device."; "Your iPhone is not paired with a Syncthing device yet." = "Your iPhone is not paired with a Syncthing device yet."; "Add your computer or server from the Devices section on the home screen." = "Add your computer or server from the Devices section on the home screen."; @@ -455,7 +460,7 @@ "Contributions are currently unavailable." = "Contributions are currently unavailable."; "Thank you!" = "Thank you!"; "Your contribution means a lot and directly supports VaultSync development. Thank you!" = "Your contribution means a lot and directly supports VaultSync development. Thank you!"; -"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like."; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward — it unlocks nothing, and the app stays fully functional without it." = "VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward — it unlocks nothing, and the app stays fully functional without it."; /* Cloud Relay price (localized via StoreKit) */ "Cloud Relay — %@" = "Cloud Relay — %@"; "Cloud Relay subscription" = "Cloud Relay subscription"; @@ -590,12 +595,18 @@ "Great" = "Great"; "No wake-up has arrived in a while. Make sure the helper is still running on the computer or server you keep on." = "No wake-up has arrived in a while. Make sure the helper is still running on the computer or server you keep on."; "Not active yet" = "Not active yet"; -"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below. This screen updates automatically the moment the first wake-up arrives." = "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below. This screen updates automatically the moment the first wake-up arrives."; +"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below."; "Relay reachable — but no wake-up has arrived recently. Check the helper is still running on your server." = "Relay reachable — but no wake-up has arrived recently. Check the helper is still running on your server."; "Relay reachable — but no wake-up delivered yet. Run the helper on your server." = "Relay reachable — but no wake-up delivered yet. Run the helper on your server."; /* Cloud Relay setup — key-free server command (Phase 3 A+C) */ "Step 1 — Run this on your server" = "Step 1 — Run this on your server"; -"No API key to copy — the helper reads it straight from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). If you get a permission error, add -u : for the user that owns config.xml." = "No API key to copy — the helper reads it straight from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). If you get a permission error, add -u : for the user that owns config.xml."; +"No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml." = "No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml."; "Step 2 — It activates itself" = "Step 2 — It activates itself"; -"That’s it. As soon as the helper starts, it wakes this iPhone once on its own — VaultSync flips to “Cloud Relay active” the moment that first wake-up arrives, with no change needed. After that, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (your server or NAS)." = "That’s it. As soon as the helper starts, it wakes this iPhone once on its own — VaultSync flips to “Cloud Relay active” the moment that first wake-up arrives, with no change needed. After that, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (your server or NAS)."; +"That’s it — the helper wakes this iPhone once on its own, and VaultSync flips to “Cloud Relay active” as soon as that first wake-up arrives. From then on, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (server or NAS)." = "That’s it — the helper wakes this iPhone once on its own, and VaultSync flips to “Cloud Relay active” as soon as that first wake-up arrives. From then on, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (server or NAS)."; + +/* Relay diagnostics — delivery visibility (force-quit / Low Power education, 7-day counter) */ +"Wake-ups (Last 7 Days)" = "Wake-ups (Last 7 Days)"; +"Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging." = "Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging."; +"Keep VaultSync in the background: if you force-quit it from the app switcher, iOS stops delivering wake-ups until you open the app again." = "Keep VaultSync in the background: if you force-quit it from the app switcher, iOS stops delivering wake-ups until you open the app again."; +"Wake-ups went quiet. The most common cause is force-quitting VaultSync from the app switcher — iOS then blocks wake-ups until the next manual launch. Also check that the helper on your server is still running." = "Wake-ups went quiet. The most common cause is force-quitting VaultSync from the app switcher — iOS then blocks wake-ups until the next manual launch. Also check that the helper on your server is still running."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 1b2d3ba..f969103 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -67,7 +67,7 @@ "Disconnected" = "Desconectado"; "Done" = "Listo"; "Empty line" = "Línea vacía"; -"Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "Activa las notificaciones de VaultSync en Ajustes de iOS -> Notificaciones -> VaultSync y luego reinicia la app."; +"Enable notifications for VaultSync in iOS Settings → Notifications → VaultSync, then restart the app." = "Activa las notificaciones de VaultSync en Ajustes de iOS → Notificaciones → VaultSync y luego reinicia la app."; "Error" = "Error"; "Failed" = "Fallido"; "Failed to save access permission: %@" = "No se pudo guardar el permiso de acceso: %@"; @@ -94,7 +94,7 @@ "In progress" = "En curso"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Instala Obsidian desde la App Store y ábrelo una vez. La carpeta aparece después de que Obsidian la cree."; "Invalid Input" = "Entrada no válida"; -"Invalid folder name: '%@'" = "Nombre de carpeta no válido: '%@'"; +"Invalid folder name: '%@'" = "Nombre de carpeta no válido: «%@»"; "Keep Both" = "Conservar ambas"; "Keep Other" = "Conservar la otra"; "Keep Other Device's Version" = "Conservar la versión del otro dispositivo"; @@ -107,7 +107,7 @@ "Last Failure" = "Último fallo"; "Last Relay Error" = "Último error del Relay"; "Last Success" = "Último éxito"; -"Last Trigger Received" = "Último activador recibido"; +"Last Wake-up Received" = "Última señal de activación recibida"; "Last Update" = "Última actualización"; "Last successful sync was about %d %@ ago." = "La última sincronización correcta fue hace unos %d %@."; "Last successful sync was more than %d %@ ago." = "La última sincronización correcta fue hace más de %d %@."; @@ -120,6 +120,7 @@ "Manage Subscription" = "Gestionar suscripción"; "Name" = "Nombre"; "Name (optional)" = "Nombre (opcional)"; +"Name saved" = "Nombre guardado"; "Needs Attention" = "Requiere atención"; "Network Error" = "Error de red"; "Never" = "Nunca"; @@ -131,10 +132,11 @@ "No home server devices available for relay provisioning." = "No hay dispositivos de servidor doméstico disponibles para el aprovisionamiento del relay."; "No immediate relay problems detected." = "No se detectaron problemas inmediatos del relay."; "No relay errors recorded." = "No se registraron errores del relay."; -"No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu." = "Aún no se ha recibido ningún activador del relay. Verifica que el contenedor `vaultsync-notify` de tu servidor doméstico esté en ejecución y pueda acceder a relay.vaultsync.eu."; +"No wake-up has been received yet. Check that the vaultsync-notify helper is running on your server and can reach relay.vaultsync.eu." = "Aún no ha llegado ninguna señal de activación. Comprueba que el asistente vaultsync-notify se esté ejecutando en tu servidor y pueda acceder a relay.vaultsync.eu."; "No successful sync has been recorded for your vaults yet." = "Aún no se ha registrado ninguna sincronización correcta para tus Vaults."; "No vaults found" = "No se encontraron Vaults"; "Not Configured" = "No configurado"; +"Not now" = "Ahora no"; "Not Shared" = "No compartido"; "Not attempted" = "No intentado"; "Not checked" = "No comprobado"; @@ -146,13 +148,14 @@ "Obsidian directory not accessible." = "No se puede acceder al directorio de Obsidian."; "Obsidian folder not connected" = "Carpeta de Obsidian no conectada"; "Open Settings" = "Abrir Ajustes"; +"Open the Relay tab" = "Abrir la pestaña Relay"; "Open VaultSync" = "Abrir VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "Abre VaultSync una vez para reiniciar Syncthing y vuelve a intentarlo."; "Open VaultSync to allow a longer foreground sync session." = "Abre VaultSync para permitir una sesión de sincronización más larga en primer plano."; "Open conflicts and choose which version to keep." = "Abre los conflictos y elige qué versión conservar."; "Open full relay troubleshooting" = "Abrir la resolución de problemas completa del relay"; "Open iOS Notification Settings" = "Abrir los ajustes de notificaciones de iOS"; -"Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry." = "Abre Ajustes de iOS -> VaultSync y comprueba que todos los permisos estén activados, luego vuelve a intentarlo."; +"Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "Abre Ajustes de iOS → VaultSync y comprueba que todos los permisos estén activados, luego vuelve a intentarlo."; "Open the folder picker again and select your Obsidian directory." = "Vuelve a abrir el selector de carpetas y elige tu directorio de Obsidian."; "Opens discovery, relay, and notification settings." = "Abre los ajustes de detección, relay y notificaciones."; "Opens the form to add a Syncthing device." = "Abre el formulario para añadir un dispositivo de Syncthing."; @@ -164,15 +167,15 @@ "Pending shares are waiting to be accepted before sync can start." = "Las comparticiones pendientes esperan a ser aceptadas antes de que pueda comenzar la sincronización."; "Per-Device Provisioning" = "Aprovisionamiento por dispositivo"; "Permission Required" = "Permiso requerido"; -"Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Selecciona una carpeta. En el selector elige \"En mi iPhone\" -> \"Obsidian\"."; +"Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Selecciona una carpeta. En el selector elige \"En mi iPhone\" → \"Obsidian\"."; "Privacy Policy" = "Política de privacidad"; "Provisioned" = "Aprovisionado"; "Push Registration" = "Registro de push"; "Push Registration Failed" = "Error en el registro de push"; "Push registration is unavailable in Simulator. Test APNs on a physical iPhone in Settings > Notifications for VaultSync." = "El registro de push no está disponible en el Simulador. Prueba APNs en un iPhone físico en Ajustes > Notificaciones para VaultSync."; -"Ready" = "Listo"; +"Ready" = "Preparado"; "Recent scan, sync, connection, and error events will appear here." = "Aquí aparecerán los eventos recientes de escaneo, sincronización, conexión y error."; -"Reconnect Obsidian Directory" = "Reconectar Obsidian"; +"Reconnect Obsidian Directory" = "Volver a conectar Obsidian"; "Reconnect Obsidian Folder" = "Volver a conectar la carpeta de Obsidian"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "Vuelve a conectar el acceso a Obsidian o ajusta los permisos de la carpeta en el dispositivo anfitrión."; "Reconnect devices or add missing peers to restore continuous sync." = "Vuelve a conectar dispositivos o añade los pares que falten para restaurar la sincronización continua."; @@ -225,6 +228,7 @@ "Purchase Failed" = "La compra falló"; "Rescan All Vaults" = "Reescanear todos los Vaults"; "Sync Conflicts" = "Conflictos de sincronización"; +"View Cloud Relay" = "Ver Cloud Relay"; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; "Retry APNs Registration" = "Reintentar registro de APNs"; "Retry Accept" = "Reintentar aceptar"; @@ -269,7 +273,7 @@ "Sync completed in %@" = "Sincronización completada en %@"; "Sync error in %@" = "Error de sincronización en %@"; "Sync started in %@" = "Sincronización iniciada en %@"; -"Synchronizing your Obsidian vault..." = "Sincronizando tu Vault de Obsidian..."; +"Synchronizing your Obsidian vault…" = "Sincronizando tu Vault de Obsidian…"; "Syncing" = "Sincronizando"; "Syncing Vault" = "Sincronizando Vault"; "Syncing…" = "Sincronizando…"; @@ -281,18 +285,18 @@ "Terms of Use" = "Términos de uso"; "The device will be disconnected and removed from all shared folders." = "El dispositivo se desconectará y se eliminará de todas las carpetas compartidas."; "The embedded Syncthing bridge did not start for a background sync." = "El puente integrado de Syncthing no se inició para una sincronización en segundo plano."; -"The file '%@' was kept as your local version. The other device's version was discarded." = "El archivo '%@' se conservó como tu versión local. La versión del otro dispositivo se descartó."; -"The file '%@' was overwritten with the version from the other device." = "El archivo '%@' se sobrescribió con la versión del otro dispositivo."; +"The file '%@' was kept as your local version. The other device's version was discarded." = "El archivo «%@» se conservó como tu versión local. La versión del otro dispositivo se descartó."; +"The file '%@' was overwritten with the version from the other device." = "El archivo «%@» se sobrescribió con la versión del otro dispositivo."; "The folder path no longer exists%@." = "La ruta de la carpeta ya no existe%@."; "The folder scan finished successfully." = "El escaneo de la carpeta finalizó correctamente."; "This Device" = "Este dispositivo"; -"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Esta carpeta todavía no parece tu directorio de Obsidian. En el selector elige \"En mi iPhone\" -> \"Obsidian\"."; +"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "Esta carpeta todavía no parece tu directorio de Obsidian. En el selector elige \"En mi iPhone\" → \"Obsidian\"."; "This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "Esta marca de tiempo se actualiza cuando VaultSync recibe un push silencioso de Cloud Relay."; "This will permanently discard the version from the other device." = "Esto descartará permanentemente la versión del otro dispositivo."; "This will permanently discard your local version." = "Esto descartará permanentemente tu versión local."; -"Timed out" = "Sin respuesta"; +"Timed out" = "Tiempo agotado"; "Timeline updates were rate-limited to keep activity readable." = "Las actualizaciones de la cronología se limitaron por frecuencia para mantener la actividad legible."; -"Trigger Delivery" = "Entrega de activadores"; +"Wake-up Delivery" = "Entrega de señales de activación"; "Trigger a vault rescan to refresh sync state." = "Inicia un reescaneo del Vault para actualizar el estado de sincronización."; "Troubleshooting" = "Resolución de problemas"; "Unchanged line. %@" = "Línea sin cambios. %@"; @@ -311,7 +315,7 @@ "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync ya no puede resolver el permiso guardado de la carpeta de Obsidian. Vuelve a conectar el directorio de Obsidian para seguir sincronizando."; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync ya no puede acceder a la carpeta de Obsidian guardada. Vuelve a conectar el directorio de Obsidian para seguir sincronizando."; "VaultSync cannot access this folder%@." = "VaultSync no puede acceder a esta carpeta%@."; -"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\"." = "VaultSync no puede leer esta carpeta. Vuelve a abrir el selector y elige \"En mi iPhone\" -> \"Obsidian\"."; +"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" → \"Obsidian\"." = "VaultSync no puede leer esta carpeta. Vuelve a abrir el selector y elige \"En mi iPhone\" → \"Obsidian\"."; "VaultSync cannot talk to Syncthing right now." = "VaultSync no puede comunicarse con Syncthing en este momento."; "VaultSync could not complete the request due to a network issue." = "VaultSync no pudo completar la solicitud debido a un problema de red."; "VaultSync could not reach the Cloud Relay service." = "VaultSync no pudo acceder al servicio de Cloud Relay."; @@ -349,6 +353,7 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync no puede acceder a tu carpeta local de Obsidian."; "Connect your Obsidian folder from the VaultSync home screen." = "Conecta tu carpeta de Obsidian desde la pantalla de inicio de VaultSync."; "Computer or server added" = "Ordenador o servidor añadido"; +"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "Tu primera sincronización está lista. Cloud Relay envía una señal de activación a este iPhone en cuanto cambian tus notas, incluso con la app cerrada."; "Your iPhone is paired with at least one Syncthing device." = "Tu iPhone está emparejado con al menos un dispositivo de Syncthing."; "Your iPhone is not paired with a Syncthing device yet." = "Tu iPhone aún no está emparejado con ningún dispositivo de Syncthing."; "Add your computer or server from the Devices section on the home screen." = "Añade tu ordenador o servidor desde la sección Dispositivos en la pantalla de inicio."; @@ -378,7 +383,7 @@ "Always skip on this iPhone" = "Omitir siempre en este iPhone"; "More actions" = "Más acciones"; "Skipping enabled" = "Omisión activada"; -"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "'%@' y sus copias de conflicto dejarán de sincronizarse en este iPhone. Puedes deshacerlo en Filtros de sincronización."; +"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "«%@» y sus copias de conflicto dejarán de sincronizarse en este iPhone. Puedes deshacerlo en Filtros de sincronización."; "1 existing conflict copy was removed." = "Se eliminó 1 copia de conflicto existente."; "%d existing conflict copies were removed." = "Se eliminaron %d copias de conflicto existentes."; "+ conflict copies" = "+ copias de conflicto"; @@ -455,7 +460,7 @@ "Contributions are currently unavailable." = "Las contribuciones no están disponibles actualmente."; "Thank you!" = "¡Gracias!"; "Your contribution means a lot and directly supports VaultSync development. Thank you!" = "Tu contribución significa mucho y apoya directamente el desarrollo de VaultSync. ¡Gracias!"; -"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync es una app independiente y de código abierto (MPL-2.0). Una contribución única la mantiene independiente, sin anuncios y en marcha. No desbloquea nada — VaultSync sigue siendo totalmente funcional sin ella — y puedes contribuir tantas veces como quieras."; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward — it unlocks nothing, and the app stays fully functional without it." = "VaultSync es una app independiente y de código abierto (MPL-2.0). Una contribución única la mantiene independiente, sin anuncios y en marcha: no desbloquea nada y la app sigue siendo totalmente funcional sin ella."; /* Cloud Relay price (localized via StoreKit) */ "Cloud Relay — %@" = "Cloud Relay — %@"; "Cloud Relay subscription" = "Suscripción a Cloud Relay"; @@ -476,7 +481,7 @@ "Added lines come from the other device; removed lines are your version on this device." = "Las líneas añadidas provienen del otro dispositivo; las líneas eliminadas son tu versión en este dispositivo."; "(empty or unreadable)" = "(vacío o ilegible)"; "a new name" = "un nombre nuevo"; -"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Se conservaron ambas versiones.\n\nTu versión local permanece como '%@'.\nLa versión del otro dispositivo se renombró a '%@'."; +"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Se conservaron ambas versiones.\n\nTu versión local permanece como «%@».\nLa versión del otro dispositivo se renombró a «%@»."; "%d conflicts" = "%d conflictos"; "Your contribution is pending approval." = "Tu contribución está pendiente de aprobación."; "iOS did not provide a push token required for instant sync." = "iOS no proporcionó un token de push necesario para la sincronización instantánea."; @@ -536,7 +541,7 @@ "(%d%%)" = "(%d%%)"; "Add your computer or server" = "Añade tu ordenador o servidor"; "Add your server first" = "Añade primero tu servidor"; -"Best value" = "Mejor valor"; +"Best value" = "La mejor opción"; "Cloud Relay wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe." = "Cloud Relay despierta un dispositivo concreto. Vincula en la pestaña «Dispositivos» el ordenador o servidor que aloja tu Vault y luego vuelve para suscribirte."; "Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "Completa estos pasos aquí mismo. Se iluminan en verde a medida que avanzas, y siempre puedes terminarlos más tarde desde la pantalla de inicio."; "Connect your Obsidian folder" = "Conecta tu carpeta de Obsidian"; @@ -580,7 +585,7 @@ "Stops syncing this vault on this iPhone. The notes on your other devices are not affected." = "Detiene la sincronización de este Vault en este iPhone. Las notas en tus otros dispositivos no se ven afectadas."; /* Cloud Relay activation (lab → wave 1; es/zh-Hans need a native review) */ -"Could not accept share '%@'.\n\n%@" = "No se pudo aceptar el recurso compartido «%@».\n\n%@"; +"Could not accept share '%@'.\n\n%@" = "No se pudo aceptar la compartición «%@».\n\n%@"; "Finish activating Cloud Relay" = "Termina de activar Cloud Relay"; "You’re subscribed, but your server has never woken this iPhone. One step finishes setup." = "Estás suscrito, pero tu servidor nunca ha despertado este iPhone. Un paso completa la configuración."; "Opens Cloud Relay setup." = "Abre la configuración de Cloud Relay."; @@ -590,12 +595,18 @@ "Great" = "Genial"; "No wake-up has arrived in a while. Make sure the helper is still running on the computer or server you keep on." = "Hace tiempo que no llega ninguna señal de activación. Asegúrate de que el asistente siga ejecutándose en el ordenador o servidor que mantienes encendido."; "Not active yet" = "Aún no activo"; -"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below. This screen updates automatically the moment the first wake-up arrives." = "Estás suscrito. Las señales de activación comienzan cuando el asistente esté en marcha en el ordenador o servidor que mantienes encendido: completa la configuración abajo. Esta pantalla se actualiza automáticamente en cuanto llega la primera señal."; +"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "Estás suscrito. Las señales de activación comienzan cuando el asistente esté en marcha en el ordenador o servidor que mantienes encendido: completa la configuración abajo."; "Relay reachable — but no wake-up has arrived recently. Check the helper is still running on your server." = "Relay accesible, pero últimamente no ha llegado ninguna señal de activación. Comprueba que el asistente siga ejecutándose en tu servidor."; "Relay reachable — but no wake-up delivered yet. Run the helper on your server." = "Relay accesible, pero aún no se ha entregado ninguna señal de activación. Ejecuta el asistente en tu servidor."; /* Cloud Relay setup — key-free server command (Phase 3 A+C) */ "Step 1 — Run this on your server" = "Paso 1: Ejecuta esto en tu servidor"; -"No API key to copy — the helper reads it straight from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). If you get a permission error, add -u : for the user that owns config.xml." = "No hay que copiar ninguna clave de API: el asistente la lee directamente del config.xml de Syncthing. Sustituye /PATH/TO/syncthing por tu carpeta de configuración de Syncthing (a menudo ~/.local/state/syncthing o ~/.config/syncthing). Si aparece un error de permisos, añade -u : para el usuario propietario de config.xml."; +"No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml." = "No necesitas ninguna clave de API: el asistente la lee directamente del config.xml de Syncthing. Sustituye /PATH/TO/syncthing por tu carpeta de configuración de Syncthing (a menudo ~/.local/state/syncthing o ~/.config/syncthing). ¿Error de permisos? Añade -u : para el usuario propietario de config.xml."; "Step 2 — It activates itself" = "Paso 2: Se activa solo"; -"That’s it. As soon as the helper starts, it wakes this iPhone once on its own — VaultSync flips to “Cloud Relay active” the moment that first wake-up arrives, with no change needed. After that, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (your server or NAS)." = "Eso es todo. En cuanto el asistente arranca, despierta este iPhone una vez por sí solo: VaultSync cambia a «Cloud Relay activo» en cuanto llega esa primera señal de activación, sin necesidad de ningún cambio. A partir de ahí, los cambios de tus otros dispositivos despiertan la app en segundo plano. Mantén el asistente en funcionamiento en un equipo que permanezca encendido (tu servidor o NAS)."; +"That’s it — the helper wakes this iPhone once on its own, and VaultSync flips to “Cloud Relay active” as soon as that first wake-up arrives. From then on, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (server or NAS)." = "Eso es todo: el asistente despierta este iPhone una vez por sí solo y VaultSync cambia a «Cloud Relay activo» en cuanto llega esa primera señal de activación. A partir de ahí, los cambios de tus otros dispositivos despiertan la app en segundo plano. Mantén el asistente en un equipo que permanezca encendido (servidor o NAS)."; + +/* Relay diagnostics — delivery visibility (force-quit / Low Power education, 7-day counter) */ +"Wake-ups (Last 7 Days)" = "Señales de activación (últimos 7 días)"; +"Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging." = "El modo de bajo consumo está activado: iOS aplaza las señales de activación silenciosas hasta que se desactive o el iPhone se cargue."; +"Keep VaultSync in the background: if you force-quit it from the app switcher, iOS stops delivering wake-ups until you open the app again." = "Mantén VaultSync en segundo plano: si la cierras desde el selector de aplicaciones, iOS dejará de entregar señales de activación hasta que vuelvas a abrir la app."; +"Wake-ups went quiet. The most common cause is force-quitting VaultSync from the app switcher — iOS then blocks wake-ups until the next manual launch. Also check that the helper on your server is still running." = "Las señales de activación han dejado de llegar. La causa más común es cerrar VaultSync desde el selector de aplicaciones: iOS bloquea entonces las señales de activación hasta el siguiente inicio manual. Comprueba también que el asistente sigue ejecutándose en tu servidor."; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index d3f3ae6..65f98a5 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -1,5 +1,5 @@ "About" = "关于"; -"Accept First Pending Share" = "接受待处理共享"; +"Accept First Pending Share" = "接受第一个待处理共享"; "Accept Share" = "接受共享"; "Accept a share to activate syncing for that vault." = "接受一个共享以激活该 Vault 的同步。"; "Actions" = "操作"; @@ -67,7 +67,7 @@ "Disconnected" = "已断开"; "Done" = "完成"; "Empty line" = "空行"; -"Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "请在 iOS 设置 -> 通知 -> VaultSync 中启用通知,然后重新启动应用。"; +"Enable notifications for VaultSync in iOS Settings → Notifications → VaultSync, then restart the app." = "请在 iOS 设置 → 通知 → VaultSync 中启用通知,然后重新启动应用。"; "Error" = "错误"; "Failed" = "失败"; "Failed to save access permission: %@" = "保存访问权限失败:%@"; @@ -107,7 +107,7 @@ "Last Failure" = "上次失败"; "Last Relay Error" = "上次 Relay 错误"; "Last Success" = "上次成功"; -"Last Trigger Received" = "上次收到触发"; +"Last Wake-up Received" = "上次收到唤醒信号"; "Last Update" = "上次更新"; "Last successful sync was about %d %@ ago." = "上次成功同步约在 %d %@ 前。"; "Last successful sync was more than %d %@ ago." = "距离上次成功同步已超过 %d %@。"; @@ -120,6 +120,7 @@ "Manage Subscription" = "管理订阅"; "Name" = "名称"; "Name (optional)" = "名称(可选)"; +"Name saved" = "名称已保存"; "Needs Attention" = "需要处理"; "Network Error" = "网络错误"; "Never" = "从未"; @@ -131,10 +132,11 @@ "No home server devices available for relay provisioning." = "没有可用于 Relay 配置的家庭服务器设备。"; "No immediate relay problems detected." = "目前未检测到 Relay 问题。"; "No relay errors recorded." = "未记录到 Relay 错误。"; -"No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu." = "尚未收到 Relay 触发。请确认你的 homeserver `vaultsync-notify` 容器正在运行并且能够访问 relay.vaultsync.eu。"; +"No wake-up has been received yet. Check that the vaultsync-notify helper is running on your server and can reach relay.vaultsync.eu." = "尚未收到唤醒信号。请确认 vaultsync-notify 助手正在你的服务器上运行,并且能够访问 relay.vaultsync.eu。"; "No successful sync has been recorded for your vaults yet." = "你的 Vault 尚未记录到成功同步。"; "No vaults found" = "未找到 Vault"; "Not Configured" = "未配置"; +"Not now" = "暂不"; "Not Shared" = "未共享"; "Not attempted" = "未尝试"; "Not checked" = "未检查"; @@ -146,13 +148,14 @@ "Obsidian directory not accessible." = "无法访问 Obsidian 目录。"; "Obsidian folder not connected" = "Obsidian 文件夹未连接"; "Open Settings" = "打开设置"; +"Open the Relay tab" = "打开 Relay 标签页"; "Open VaultSync" = "打开 VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "打开 VaultSync 一次以重启 Syncthing,然后重试。"; "Open VaultSync to allow a longer foreground sync session." = "打开 VaultSync,以允许更长的前台同步会话。"; "Open conflicts and choose which version to keep." = "打开冲突并选择要保留的版本。"; "Open full relay troubleshooting" = "打开完整 Relay 故障排查"; "Open iOS Notification Settings" = "打开 iOS 通知设置"; -"Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry." = "打开 iOS 设置 -> VaultSync,检查所有权限都已启用,然后重试。"; +"Open iOS Settings → VaultSync and check that all permissions are enabled, then retry." = "打开 iOS 设置 → VaultSync,检查所有权限都已启用,然后重试。"; "Open the folder picker again and select your Obsidian directory." = "重新打开文件夹选择器并选择你的 Obsidian 目录。"; "Opens discovery, relay, and notification settings." = "打开发现、Relay 和通知设置。"; "Opens the form to add a Syncthing device." = "打开添加 Syncthing 设备的表单。"; @@ -164,7 +167,7 @@ "Pending shares are waiting to be accepted before sync can start." = "待处理共享需要先被接受,同步才能开始。"; "Per-Device Provisioning" = "按设备配置"; "Permission Required" = "需要权限"; -"Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "请选择一个文件夹。在选择器中选择“在我的 iPhone 上” -> “Obsidian”。"; +"Please select a folder. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "请选择一个文件夹。在选择器中选择“在我的 iPhone 上” → “Obsidian”。"; "Privacy Policy" = "隐私政策"; "Provisioned" = "已配置"; "Push Registration" = "推送注册"; @@ -225,6 +228,7 @@ "Purchase Failed" = "购买失败"; "Rescan All Vaults" = "重新扫描所有 Vault"; "Sync Conflicts" = "同步冲突"; +"View Cloud Relay" = "查看 Cloud Relay"; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; "Retry APNs Registration" = "重试 APNs 注册"; "Retry Accept" = "重试接受"; @@ -269,7 +273,7 @@ "Sync completed in %@" = "%@ 中的同步已完成"; "Sync error in %@" = "%@ 中发生同步错误"; "Sync started in %@" = "%@ 中开始同步"; -"Synchronizing your Obsidian vault..." = "正在同步你的 Obsidian Vault..."; +"Synchronizing your Obsidian vault…" = "正在同步你的 Obsidian Vault…"; "Syncing" = "同步中"; "Syncing Vault" = "正在同步 Vault"; "Syncing…" = "同步中…"; @@ -286,13 +290,13 @@ "The folder path no longer exists%@." = "该文件夹路径已不存在%@。"; "The folder scan finished successfully." = "文件夹扫描已成功完成。"; "This Device" = "此设备"; -"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "这个文件夹目前看起来还不像你的 Obsidian 目录。请在选择器中选择“在我的 iPhone 上” -> “Obsidian”。"; +"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" → \"Obsidian\"." = "这个文件夹目前看起来还不像你的 Obsidian 目录。请在选择器中选择“在我的 iPhone 上” → “Obsidian”。"; "This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "当 VaultSync 收到来自 Cloud Relay 的静默推送时,此时间戳会更新。"; "This will permanently discard the version from the other device." = "这将永久丢弃另一台设备上的版本。"; "This will permanently discard your local version." = "这将永久丢弃你的本地版本。"; "Timed out" = "超时"; "Timeline updates were rate-limited to keep activity readable." = "为了保持活动记录可读,时间线更新已被限流。"; -"Trigger Delivery" = "触发投递"; +"Wake-up Delivery" = "唤醒信号投递"; "Trigger a vault rescan to refresh sync state." = "触发一次 Vault 重新扫描以刷新同步状态。"; "Troubleshooting" = "故障排查"; "Unchanged line. %@" = "未更改行。%@"; @@ -311,7 +315,7 @@ "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync 已无法解析已保存的 Obsidian 文件夹权限。请重新连接 Obsidian 目录以继续同步。"; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync 已无法访问已保存的 Obsidian 文件夹。请重新连接 Obsidian 目录以继续同步。"; "VaultSync cannot access this folder%@." = "VaultSync 无法访问此文件夹%@。"; -"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\"." = "VaultSync 无法读取此文件夹。请重新打开选择器并选择“在我的 iPhone 上” -> “Obsidian”。"; +"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" → \"Obsidian\"." = "VaultSync 无法读取此文件夹。请重新打开选择器并选择“在我的 iPhone 上” → “Obsidian”。"; "VaultSync cannot talk to Syncthing right now." = "VaultSync 当前无法与 Syncthing 通信。"; "VaultSync could not complete the request due to a network issue." = "由于网络问题,VaultSync 无法完成该请求。"; "VaultSync could not reach the Cloud Relay service." = "VaultSync 无法连接到 Cloud Relay 服务。"; @@ -349,6 +353,7 @@ "VaultSync cannot access your local Obsidian folder." = "VaultSync 无法访问你的本地 Obsidian 文件夹。"; "Connect your Obsidian folder from the VaultSync home screen." = "请在 VaultSync 主屏幕连接你的 Obsidian 文件夹。"; "Computer or server added" = "已添加电脑或服务器"; +"Your first sync is done. Cloud Relay wakes this iPhone the moment your notes change — even while the app is closed." = "首次同步已完成。Cloud Relay 会在笔记发生变化时立即发送唤醒信号唤醒这部 iPhone——即使应用已关闭。"; "Your iPhone is paired with at least one Syncthing device." = "你的 iPhone 已与至少一台 Syncthing 设备配对。"; "Your iPhone is not paired with a Syncthing device yet." = "你的 iPhone 尚未与 Syncthing 设备配对。"; "Add your computer or server from the Devices section on the home screen." = "请在主屏幕的“设备”部分添加你的电脑或服务器。"; @@ -444,7 +449,7 @@ "weeks" = "周"; "year" = "年"; "years" = "年"; -"%@ / %@" = "%@ / %@"; +"%@ / %@" = "每%2$@ %1$@"; "%1$@ / %2$d %3$@" = "%1$@ / %2$d %3$@"; /* Contributions / tip jar */ "Support VaultSync" = "支持 VaultSync"; @@ -455,7 +460,7 @@ "Contributions are currently unavailable." = "目前无法进行支持。"; "Thank you!" = "谢谢!"; "Your contribution means a lot and directly supports VaultSync development. Thank you!" = "你的支持意义重大,将直接用于 VaultSync 的开发。谢谢!"; -"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync 是一款独立的开源应用(MPL-2.0)。一次性的支持就能让它保持独立、无广告并持续更新。它不会解锁任何功能——没有它 VaultSync 也完全可用——你可以随时多次支持。"; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward — it unlocks nothing, and the app stays fully functional without it." = "VaultSync 是一款独立的开源应用(MPL-2.0)。一次性的支持能让它保持独立、无广告并持续更新——它不解锁任何功能,没有它应用也完全可用。"; /* Cloud Relay price (localized via StoreKit) */ "Cloud Relay — %@" = "Cloud Relay — %@"; "Cloud Relay subscription" = "Cloud Relay 订阅"; @@ -481,8 +486,8 @@ "Your contribution is pending approval." = "你的支持正在等待批准。"; "iOS did not provide a push token required for instant sync." = "iOS 未提供即时同步所需的推送令牌。"; "%@ (Trigger: %@)" = "%@(触发:%@)"; -"1 additional file synced in %@" = "在 %@ 内同步了 1 个额外文件"; -"%d additional files synced in %@" = "在 %2$@ 内同步了 %1$d 个额外文件"; +"1 additional file synced in %@" = "在 %@ 中同步了 1 个额外文件"; +"%d additional files synced in %@" = "在 %2$@ 中同步了 %1$d 个额外文件"; "%@ connected" = "%@ 已连接"; "%@ disconnected" = "%@ 已断开连接"; "%d%% complete" = "已完成 %d%%"; @@ -587,15 +592,21 @@ "Cloud Relay went quiet" = "Cloud Relay 暂无动静"; "No wake-up in a while — is your server still on?" = "已有一段时间没有唤醒信号了——你的服务器还开着吗?"; "Your server just reached this iPhone. Incoming changes now sync the moment they happen." = "你的服务器刚刚联系上了此 iPhone。现在,传入的改动会在发生的那一刻同步。"; -"Great" = "好的"; +"Great" = "太好了"; "No wake-up has arrived in a while. Make sure the helper is still running on the computer or server you keep on." = "已有一段时间没有收到唤醒信号。请确认助手仍在你常开的电脑或服务器上运行。"; "Not active yet" = "尚未激活"; -"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below. This screen updates automatically the moment the first wake-up arrives." = "你已订阅。当助手在你常开的电脑或服务器上运行后,唤醒信号即会开始送达——请在下方完成设置。首个唤醒信号到达时,此界面会自动更新。"; +"You’re subscribed. Wake-ups start once the helper is running on the computer or server you keep on — finish setup below." = "你已订阅。当助手在你常开的电脑或服务器上运行后,唤醒信号即会开始送达——请在下方完成设置。"; "Relay reachable — but no wake-up has arrived recently. Check the helper is still running on your server." = "Relay 可达——但最近没有收到唤醒信号。请检查助手是否仍在你的服务器上运行。"; "Relay reachable — but no wake-up delivered yet. Run the helper on your server." = "Relay 可达——但尚未送达唤醒信号。请在你的服务器上运行助手。"; /* Cloud Relay setup — key-free server command (Phase 3 A+C) */ "Step 1 — Run this on your server" = "第 1 步 — 在你的服务器上运行此命令"; -"No API key to copy — the helper reads it straight from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). If you get a permission error, add -u : for the user that owns config.xml." = "无需复制 API 密钥——助手会直接从 Syncthing 的 config.xml 读取。请将 /PATH/TO/syncthing 替换为你的 Syncthing 配置文件夹(通常是 ~/.local/state/syncthing 或 ~/.config/syncthing)。如果出现权限错误,请为拥有 config.xml 的用户添加 -u :。"; +"No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml." = "无需 API 密钥——助手会直接从 Syncthing 的 config.xml 读取。请将 /PATH/TO/syncthing 替换为你的 Syncthing 配置文件夹(通常是 ~/.local/state/syncthing 或 ~/.config/syncthing)。遇到权限错误?请为拥有 config.xml 的用户添加 -u :。"; "Step 2 — It activates itself" = "第 2 步 — 它会自行激活"; -"That’s it. As soon as the helper starts, it wakes this iPhone once on its own — VaultSync flips to “Cloud Relay active” the moment that first wake-up arrives, with no change needed. After that, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (your server or NAS)." = "就这样。助手一启动,就会自行唤醒此 iPhone 一次——一旦首次唤醒送达,VaultSync 就会切换为“Cloud Relay 已启用”,无需任何更改。此后,你其他设备上的更改会在后台唤醒此应用。请让助手在一台保持常开的设备(你的服务器或 NAS)上持续运行。"; +"That’s it — the helper wakes this iPhone once on its own, and VaultSync flips to “Cloud Relay active” as soon as that first wake-up arrives. From then on, changes from your other devices wake the app in the background. Keep the helper running on a machine that stays on (server or NAS)." = "就这样——助手会自行唤醒此 iPhone 一次,首个唤醒信号一到,VaultSync 就会切换为“Cloud Relay 已启用”。此后,你其他设备上的更改会在后台唤醒应用。请让助手在一台常开的设备(服务器或 NAS)上持续运行。"; + +/* Relay diagnostics — delivery visibility (force-quit / Low Power education, 7-day counter) */ +"Wake-ups (Last 7 Days)" = "唤醒信号(最近 7 天)"; +"Low Power Mode is on — iOS defers silent wake-ups until it is off or the iPhone is charging." = "低电量模式已开启——iOS 会推迟静默唤醒信号,直到关闭该模式或 iPhone 充电。"; +"Keep VaultSync in the background: if you force-quit it from the app switcher, iOS stops delivering wake-ups until you open the app again." = "请让 VaultSync 保留在后台:如果在应用切换器中强制退出,iOS 将停止递送唤醒信号,直到你再次打开应用。"; +"Wake-ups went quiet. The most common cause is force-quitting VaultSync from the app switcher — iOS then blocks wake-ups until the next manual launch. Also check that the helper on your server is still running." = "唤醒信号已中断。最常见的原因是在应用切换器中强制退出了 VaultSync——iOS 会在下次手动启动前阻止唤醒信号。同时请检查服务器上的助手是否仍在运行。"; diff --git a/ios/VaultSyncTests/RelayTriggerStoreTests.swift b/ios/VaultSyncTests/RelayTriggerStoreTests.swift new file mode 100644 index 0000000..b066daf --- /dev/null +++ b/ios/VaultSyncTests/RelayTriggerStoreTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import VaultSync + +@Suite("Relay Trigger History", .serialized) +struct RelayTriggerStoreTests { + @Test("markReceived records history and receivedCount windows it") + func historyAndWindowedCount() { + TestSupport.resetRelayState() + + let now = Date() + RelayTriggerStore.markReceived(date: now.addingTimeInterval(-8 * 24 * 60 * 60)) + RelayTriggerStore.markReceived(date: now.addingTimeInterval(-3 * 24 * 60 * 60)) + RelayTriggerStore.markReceived(date: now.addingTimeInterval(-60)) + + #expect(RelayTriggerStore.receivedHistory().count == 3) + // The 8-day-old arrival falls outside the 7-day window. + #expect(RelayTriggerStore.receivedCount(within: 7 * 24 * 60 * 60, now: now) == 2) + #expect(RelayTriggerStore.lastReceivedAt() != nil) + + TestSupport.resetRelayState() + #expect(RelayTriggerStore.receivedHistory().isEmpty) + #expect(RelayTriggerStore.receivedCount(within: 7 * 24 * 60 * 60, now: now) == 0) + } + + @Test("history is capped to the rolling limit, dropping oldest entries") + func historyCap() { + TestSupport.resetRelayState() + + let base = Date(timeIntervalSince1970: 1_000_000) + for offset in 0..<205 { + RelayTriggerStore.markReceived(date: base.addingTimeInterval(TimeInterval(offset))) + } + + let history = RelayTriggerStore.receivedHistory() + #expect(history.count == 200) + #expect(history.first == base.addingTimeInterval(5)) + #expect(history.last == base.addingTimeInterval(204)) + + TestSupport.resetRelayState() + } +} diff --git a/ios/VaultSyncTests/TestSupport.swift b/ios/VaultSyncTests/TestSupport.swift index 8b3db2b..5934b16 100644 --- a/ios/VaultSyncTests/TestSupport.swift +++ b/ios/VaultSyncTests/TestSupport.swift @@ -28,6 +28,7 @@ enum TestSupport { "apns-registration-last-success-at", "apns-registration-last-failure-at", "relay-last-trigger-received-at", + "relay-trigger-received-history-v1", "relay-diagnostics-last-error", ] { defaults.removeObject(forKey: key) diff --git a/ios/VaultSyncWidget/VaultSyncWidget.swift b/ios/VaultSyncWidget/VaultSyncWidget.swift index 8d12de5..b6399f0 100644 --- a/ios/VaultSyncWidget/VaultSyncWidget.swift +++ b/ios/VaultSyncWidget/VaultSyncWidget.swift @@ -173,6 +173,10 @@ private struct VaultSyncWidgetEntryView: View { if let lastSyncDate = entry.snapshot.lastSyncDate { Text(lastSyncDate, style: .time) .font(.title3.monospacedDigit()) + // Without a label VoiceOver reads a bare clock time with + // no hint of what it refers to. + .accessibilityLabel(VaultSyncWidgetL10n.tr("widget_last_sync")) + .accessibilityValue(Text(lastSyncDate, style: .time)) } else { Text(VaultSyncWidgetL10n.tr("widget_waiting")) .font(.title3.weight(.semibold)) diff --git a/ios/VaultSyncWidget/de.lproj/Localizable.strings b/ios/VaultSyncWidget/de.lproj/Localizable.strings index 5f172b8..72d7307 100644 --- a/ios/VaultSyncWidget/de.lproj/Localizable.strings +++ b/ios/VaultSyncWidget/de.lproj/Localizable.strings @@ -12,13 +12,13 @@ // Fallback labels "widget_no_sync_yet" = "Noch kein Sync"; -"widget_waiting" = "Warten"; +"widget_waiting" = "Wartet"; "widget_duration_unavailable" = "Dauer unbekannt"; "widget_last_run_seconds" = "Letzter Lauf: %ds"; "widget_files_tap_to_sync" = "%d Dateien • Zum Sync tippen"; // Actions -"widget_sync_now" = "Jetzt syncen"; +"widget_sync_now" = "Jetzt synchronisieren"; // Widget gallery "VaultSync" = "VaultSync"; diff --git a/ios/project.yml b/ios/project.yml index 2b9d116..063f58f 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -59,8 +59,8 @@ targets: info: path: VaultSync/Info.plist properties: - CFBundleShortVersionString: "1.5.1" - CFBundleVersion: "27" + CFBundleShortVersionString: "1.6.0" + CFBundleVersion: "28" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -72,6 +72,7 @@ targets: BGTaskSchedulerPermittedIdentifiers: - eu.vaultsync.app.sync-refresh - eu.vaultsync.app.sync-continued + - eu.vaultsync.app.sync-processing NSCameraUsageDescription: "VaultSync uses the camera to scan Syncthing device QR codes for easy setup." # NOTE: NSAppTransportSecurity is deliberately NOT set, so shipping (Release) # builds keep the strictest ATS posture. The production app only talks HTTPS to @@ -122,8 +123,8 @@ targets: info: path: VaultSyncWidget/Info.plist properties: - CFBundleShortVersionString: "1.5.1" - CFBundleVersion: "27" + CFBundleShortVersionString: "1.6.0" + CFBundleVersion: "28" CFBundleDisplayName: VaultSync Widget NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension diff --git a/notify/README.md b/notify/README.md index 633086f..08c2797 100644 --- a/notify/README.md +++ b/notify/README.md @@ -61,6 +61,7 @@ If detection fails, set `SYNCTHING_CONFIG=/path/to/config.xml` and rerun. | `SYNCTHING_CONFIG_WAIT_SECONDS` | No | `60` | First boot: wait up to this many seconds for Syncthing to write `config.xml` before exiting. `0` disables the wait (fail fast). | | `DEBOUNCE_SECONDS` | No | `5` | Wait after the last event before triggering. Batches rapid changes into one push. | | `WATCHED_FOLDERS` | No | all | Comma-separated Syncthing folder IDs to watch. Empty = all. | +| `STALE_RETRIGGER_SECONDS` | No | `21600` (6 h) | While a peer still needs data, re-send a wake-up on this cadence. Recovers phones that missed a push (APNs silent pushes expire after ~1 h) without waiting for the next vault change. `0` disables. | > **NAS users (the common footgun).** `config.xml` is mode `0600`, so the helper must run as the uid that owns it or it can't read the key (you'll get a clear permission error). The `1000` default fits the official `syncthing/syncthing` image; set `PUID`/`PGID` for others — linuxserver = `911`, Unraid = `99:100`, Synology runs as the `syncthing` user. Synology/QNAP also need `SYNCTHING_CONFIG` pointed at the real `config.xml`. diff --git a/notify/main.go b/notify/main.go index e7015de..7027e51 100644 --- a/notify/main.go +++ b/notify/main.go @@ -31,6 +31,15 @@ type Config struct { // false, so Config literals that don't set it (e.g. focused runService tests) // keep announce off; only loadConfig turns it on for the real service. StartupAnnounce bool + // StaleRetriggerSeconds re-sends a wake-up on a slow cadence while any + // unpaused remote device still needs data from this instance. This closes + // the missed-push gap: APNs silent pushes expire after ~1h, and a phone + // that was offline for the only change of the day would otherwise stay + // stale until the next change or a manual app open — the change-driven + // dedup markers never fire twice for the same change. 0 disables the + // sweep. NOTE: like StartupAnnounce, the zero value keeps the feature off + // for focused Config-literal tests; only loadConfig applies the default. + StaleRetriggerSeconds int } type appMode int @@ -131,6 +140,16 @@ func loadConfig() (Config, error) { debounce = d } + // Stale re-trigger defaults to every 6 hours; 0 disables. + staleRetrigger := 6 * 60 * 60 + if v := os.Getenv("STALE_RETRIGGER_SECONDS"); v != "" { + s, err := strconv.Atoi(v) + if err != nil || s < 0 { + return Config{}, fmt.Errorf("STALE_RETRIGGER_SECONDS must be a non-negative integer (got %q)", v) + } + staleRetrigger = s + } + // Startup announce (B1) defaults ON; opt out with a falsey STARTUP_ANNOUNCE. startupAnnounce := true if v := strings.TrimSpace(strings.ToLower(os.Getenv("STARTUP_ANNOUNCE"))); v != "" { @@ -199,12 +218,13 @@ func loadConfig() (Config, error) { // the app's setup command both supply it explicitly, so this costs the // operator nothing in practice; only the Syncthing key/URL are auto-detected. cfg := Config{ - SyncthingAPIURL: apiURL, - SyncthingAPIKey: apiKey, - RelayURL: required("RELAY_URL"), - DebounceSeconds: debounce, - WatchedFolders: watched, - StartupAnnounce: startupAnnounce, + SyncthingAPIURL: apiURL, + SyncthingAPIKey: apiKey, + RelayURL: required("RELAY_URL"), + DebounceSeconds: debounce, + WatchedFolders: watched, + StartupAnnounce: startupAnnounce, + StaleRetriggerSeconds: staleRetrigger, } missing := make([]string, 0, 3) @@ -355,6 +375,7 @@ func runService(ctx context.Context, cfg Config) int { "debounce_seconds", cfg.DebounceSeconds, "watched_folders", formatWatched(cfg.WatchedFolders), "startup_announce", cfg.StartupAnnounce, + "stale_retrigger_seconds", cfg.StaleRetriggerSeconds, ) // B1 — startup announce. Fire one wake-up now so a freshly (re)started helper @@ -387,6 +408,18 @@ func runService(ctx context.Context, cfg Config) int { pending := make(map[string]string) lastTriggered := make(map[string]string) + // Stale-peer sweep: while a peer still needs data, re-send a wake-up on a + // slow cadence. The change-driven path dedups by marker and APNs pushes + // expire after ~1h, so a phone that misses its one push would otherwise + // stay stale until the next change. The first sweep runs a full interval + // after startup — the startup announce already covers boot-time liveness. + var staleCh <-chan time.Time + if cfg.StaleRetriggerSeconds > 0 { + staleTicker := time.NewTicker(time.Duration(cfg.StaleRetriggerSeconds) * time.Second) + defer staleTicker.Stop() + staleCh = staleTicker.C + } + for { select { case ev, ok := <-events: @@ -477,6 +510,38 @@ func runService(ctx context.Context, cfg Config) int { debounceCh = debounceTimer.C } + case <-staleCh: + if len(pending) > 0 { + // A change-driven trigger is already debouncing; it will wake + // the phone anyway. + continue + } + sweepCtx, sweepCancel := context.WithTimeout(ctx, 30*time.Second) + behind, err := anyPeerNeedsData(sweepCtx, st, deviceID, cfg.WatchedFolders) + sweepCancel() + if err != nil { + slog.Warn("stale-peer sweep failed; retrying next interval", + "classification", "recoverable", + "component", "syncthing", + "error", err, + "action", "continue", + ) + continue + } + if !behind { + continue + } + slog.Info("peer still needs data; re-sending wake-up", + "component", "relay", + "reason", "stale-retrigger", + ) + // Delivered, transient, and inactive outcomes all just wait for + // the next sweep — it re-evaluates from live completion data, so + // nothing needs to be retained or marked here. + if fireTrigger(ctx, relay, "stale-retrigger") == outcomeFatal { + return 1 + } + case <-ctx.Done(): slog.Info("shutdown signal received") if len(pending) > 0 { @@ -495,6 +560,66 @@ func runService(ctx context.Context, cfg Config) int { } } +// completionNeedsWakeup reports whether a completion snapshot shows work a +// wake-up could help deliver. Deletes count: they need to propagate too. +func completionNeedsWakeup(c DeviceCompletion) bool { + return c.NeedItems > 0 || c.NeedBytes > 0 || c.NeedDeletes > 0 +} + +// anyPeerNeedsData reports whether at least one unpaused remote device still +// needs items, bytes, or deletes from this instance. With watched folders +// configured the check is per watched folder; otherwise it uses Syncthing's +// aggregate completion across all folders shared with the device. +// +// A per-device HTTP status error (e.g. a watched folder not shared with that +// device) skips just that check — it is configuration-shaped, not a sweep +// failure. Transport errors fail the sweep so the caller logs and retries. +func anyPeerNeedsData(ctx context.Context, st *SyncthingClient, selfID string, watchedFolders map[string]bool) (bool, error) { + devices, err := st.ListDevices(ctx) + if err != nil { + return false, err + } + + var firstErr error + for _, dev := range devices { + if dev.DeviceID == selfID || dev.Paused { + continue + } + + folders := []string{""} + if watchedFolders != nil { + folders = folders[:0] + for folder := range watchedFolders { + folders = append(folders, folder) + } + } + + for _, folder := range folders { + completion, err := st.Completion(ctx, dev.DeviceID, folder) + if err != nil { + var statusErr *HTTPStatusError + if errors.As(err, &statusErr) { + slog.Debug("skipping completion check", + "device", dev.DeviceID, + "folder", folder, + "error", err, + ) + continue + } + if firstErr == nil { + firstErr = err + } + continue + } + if completionNeedsWakeup(completion) { + return true, nil + } + } + } + + return false, firstErr +} + func waitForDeviceID(ctx context.Context, st *SyncthingClient) (string, error) { backoff := time.Second for attempt := 1; ; attempt++ { diff --git a/notify/main_test.go b/notify/main_test.go index 856c527..8e17eb3 100644 --- a/notify/main_test.go +++ b/notify/main_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync/atomic" "testing" @@ -961,6 +962,229 @@ func TestLoadConfigStartupAnnounceDefaultAndOverride(t *testing.T) { } } +func TestLoadConfigStaleRetriggerDefaultAndOverride(t *testing.T) { + set := func(value string) (Config, error) { + t.Setenv("SYNCTHING_API_URL", "http://localhost:8384") + t.Setenv("SYNCTHING_API_KEY", "test-key") + t.Setenv("RELAY_URL", "https://relay.example.com") + t.Setenv("DEBOUNCE_SECONDS", "") + t.Setenv("WATCHED_FOLDERS", "") + t.Setenv("STALE_RETRIGGER_SECONDS", value) + return loadConfig() + } + + cfg, err := set("") + if err != nil { + t.Fatalf("default loadConfig error: %v", err) + } + if cfg.StaleRetriggerSeconds != 6*60*60 { + t.Fatalf("StaleRetriggerSeconds default = %d, want %d", cfg.StaleRetriggerSeconds, 6*60*60) + } + + cfg, err = set("0") + if err != nil { + t.Fatalf("loadConfig(0) error: %v", err) + } + if cfg.StaleRetriggerSeconds != 0 { + t.Fatalf("STALE_RETRIGGER_SECONDS=0 should disable the sweep, got %d", cfg.StaleRetriggerSeconds) + } + + cfg, err = set("900") + if err != nil || cfg.StaleRetriggerSeconds != 900 { + t.Fatalf("STALE_RETRIGGER_SECONDS=900 parsed as %d (err=%v), want 900", cfg.StaleRetriggerSeconds, err) + } + + for _, invalid := range []string{"-1", "soon"} { + if _, err := set(invalid); err == nil || !strings.Contains(err.Error(), "STALE_RETRIGGER_SECONDS") { + t.Fatalf("STALE_RETRIGGER_SECONDS=%q should be rejected, got err=%v", invalid, err) + } + } +} + +func TestCompletionNeedsWakeup(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + completion DeviceCompletion + want bool + }{ + {"fully synced", DeviceCompletion{}, false}, + {"needs bytes", DeviceCompletion{NeedBytes: 1}, true}, + {"needs items", DeviceCompletion{NeedItems: 1}, true}, + {"needs only deletes", DeviceCompletion{NeedDeletes: 1}, true}, + } + for _, tc := range cases { + if got := completionNeedsWakeup(tc.completion); got != tc.want { + t.Errorf("%s: completionNeedsWakeup = %v, want %v", tc.name, got, tc.want) + } + } +} + +// newStaleSyncthingStub reports a fixed Device ID, never emits change events, +// and serves a device list plus per-device completion — the surface the +// stale-peer sweep reads. The peer's outstanding needBytes is adjustable so +// tests can flip between a behind and a fully-synced peer. +func newStaleSyncthingStub(t *testing.T, needBytes *atomic.Int64) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/rest/system/status": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"myID":"DEVICE-STALE"}`)) + case "/rest/events": + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + case "/rest/config/devices": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[ + {"deviceID":"DEVICE-STALE","paused":false}, + {"deviceID":"PHONE-PEER","paused":false}, + {"deviceID":"PAUSED-PEER","paused":true} + ]`)) + case "/rest/db/completion": + switch r.URL.Query().Get("device") { + case "PAUSED-PEER": + t.Error("stale sweep queried completion for a paused device") + case "DEVICE-STALE": + t.Error("stale sweep queried completion for the local device") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"completion":99,"needBytes":` + strconv.FormatInt(needBytes.Load(), 10) + `,"needItems":0,"needDeletes":0}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// TestRunServiceStaleRetriggerWakesBehindPeer proves the missed-push recovery: +// with NO Syncthing change at all, a peer that still needs data causes a +// wake-up on the stale-retrigger cadence. +func TestRunServiceStaleRetriggerWakesBehindPeer(t *testing.T) { + var needBytes atomic.Int64 + needBytes.Store(4096) + syncthing := newStaleSyncthingStub(t, &needBytes) + + triggered := make(chan struct{}, 1) + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/health": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + case "/api/v1/trigger": + select { + case triggered <- struct{}{}: + default: + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"accepted","devices_notified":1}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(relay.Close) + + cfg := Config{ + SyncthingAPIURL: syncthing.URL, + SyncthingAPIKey: "test-key", + RelayURL: relay.URL, + DebounceSeconds: 1, + // Announce disabled so the only possible trigger is the stale sweep. + StartupAnnounce: false, + StaleRetriggerSeconds: 1, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + codeCh := make(chan int, 1) + go func() { codeCh <- runService(ctx, cfg) }() + + select { + case <-triggered: + case code := <-codeCh: + t.Fatalf("runService exited with code %d before the stale sweep fired", code) + case <-time.After(10 * time.Second): + t.Fatal("stale sweep did not re-trigger for a behind peer") + } + + cancel() + select { + case code := <-codeCh: + if code != 0 { + t.Fatalf("runService exit code = %d after graceful shutdown, want 0", code) + } + case <-time.After(10 * time.Second): + t.Fatal("runService did not shut down after context cancel") + } +} + +// TestRunServiceStaleRetriggerQuietWhenPeersCurrent is the anti-spam half: a +// fully-synced peer must NOT cause any wake-up, sweep after sweep. +func TestRunServiceStaleRetriggerQuietWhenPeersCurrent(t *testing.T) { + var needBytes atomic.Int64 // zero: peer fully synced + syncthing := newStaleSyncthingStub(t, &needBytes) + + var triggerCalls atomic.Int32 + relay := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/health": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) + case "/api/v1/trigger": + triggerCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"accepted","devices_notified":1}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(relay.Close) + + cfg := Config{ + SyncthingAPIURL: syncthing.URL, + SyncthingAPIKey: "test-key", + RelayURL: relay.URL, + DebounceSeconds: 1, + StartupAnnounce: false, + StaleRetriggerSeconds: 1, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + codeCh := make(chan int, 1) + go func() { codeCh <- runService(ctx, cfg) }() + + // Let several sweeps run; none may trigger. + select { + case code := <-codeCh: + t.Fatalf("runService exited early with code %d", code) + case <-time.After(2500 * time.Millisecond): + } + if got := triggerCalls.Load(); got != 0 { + t.Fatalf("stale sweep sent %d wake-ups for a fully-synced peer, want 0", got) + } + + cancel() + select { + case code := <-codeCh: + if code != 0 { + t.Fatalf("runService exit code = %d after graceful shutdown, want 0", code) + } + case <-time.After(10 * time.Second): + t.Fatal("runService did not shut down after context cancel") + } +} + func TestMarkTriggeredCopiesPendingMarkers(t *testing.T) { t.Parallel() diff --git a/notify/syncthing.go b/notify/syncthing.go index 6296d3c..0962569 100644 --- a/notify/syncthing.go +++ b/notify/syncthing.go @@ -8,6 +8,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "time" ) @@ -174,6 +175,74 @@ func (c *SyncthingClient) getSystemStatus(ctx context.Context, withAPIKey bool) return status, resp.StatusCode, nil } +// remoteDeviceConfig is the subset of /rest/config/devices needed to decide +// which peers to include in the stale-peer sweep. +type remoteDeviceConfig struct { + DeviceID string `json:"deviceID"` + Paused bool `json:"paused"` +} + +// DeviceCompletion is the subset of /rest/db/completion used to decide whether +// a peer still needs data from this instance. +type DeviceCompletion struct { + NeedBytes int64 `json:"needBytes"` + NeedItems int `json:"needItems"` + NeedDeletes int `json:"needDeletes"` +} + +// ListDevices returns all configured devices, including this one. +func (c *SyncthingClient) ListDevices(ctx context.Context) ([]remoteDeviceConfig, error) { + var devices []remoteDeviceConfig + if err := c.getJSON(ctx, c.apiURL+"/rest/config/devices", &devices); err != nil { + return nil, err + } + return devices, nil +} + +// Completion reads sync completion for a device. An empty folder aggregates +// across all folders shared with that device. +func (c *SyncthingClient) Completion(ctx context.Context, deviceID, folder string) (DeviceCompletion, error) { + query := url.Values{"device": {deviceID}} + if folder != "" { + query.Set("folder", folder) + } + + var completion DeviceCompletion + if err := c.getJSON(ctx, c.apiURL+"/rest/db/completion?"+query.Encode(), &completion); err != nil { + return DeviceCompletion{}, err + } + return completion, nil +} + +func (c *SyncthingClient) getJSON(ctx context.Context, url string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + req.Header.Set("X-API-Key", c.apiKey) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return &HTTPStatusError{ + Component: "syncthing", + URL: url, + StatusCode: resp.StatusCode, + Body: string(body), + } + } + + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} + func (c *SyncthingClient) poll(ctx context.Context, since int) ([]Event, error) { url := fmt.Sprintf("%s/rest/events?since=%d&limit=100", c.apiURL, since)