From cff431caec4ab9a6a1687a754f773e960c60ee83 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 16:41:51 +0200 Subject: [PATCH 01/22] feat(ui): token foundation, SyncStatus registry, global accent & widget parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the radical redesign (Vault-OS identity on a Calm-Native foundation). Foundation only — no navigation/structure changes yet. - Theme.swift becomes the shared design-token layer (compiled into app + widget): P3 / dark-aware brand accent, a six-meaning semantic status palette, and an 8pt spacing + continuous-radius scale, replacing the previous two literals. - Add a canonical SyncStatus registry (symbol + color + localized label) keyed by genuine sync state, incl. a distinct "starting" state. Uses String(localized:) so it resolves in both the app and the widget bundle (no L10n dependency). - Fill AccentColor with the refined teal so the app-wide tint is the brand instead of the leaked system blue; also set an explicit .tint on the WindowGroup. - Widget: decode the snapshot status through SyncStatus.fromWire — unknown values now resolve to "attention", never silently to the green "all good" branch (closes the documented widget-lies bug). CTA retinted off system blue. - Add SyncStatus.fromWire contract tests (unknown -> attention regression guard). Build + full test suite green on iOS 26.5 simulator. --- ios/VaultSync/App/VaultSyncApp.swift | 1 + .../AccentColor.colorset/Contents.json | 27 +++ ios/VaultSync/Resources/Theme.swift | 202 +++++++++++++++++- ios/VaultSyncTests/SyncStatusTests.swift | 63 ++++++ ios/VaultSyncWidget/VaultSyncWidget.swift | 44 +--- 5 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 ios/VaultSyncTests/SyncStatusTests.swift diff --git a/ios/VaultSync/App/VaultSyncApp.swift b/ios/VaultSync/App/VaultSyncApp.swift index dde020f..751a925 100644 --- a/ios/VaultSync/App/VaultSyncApp.swift +++ b/ios/VaultSync/App/VaultSyncApp.swift @@ -53,6 +53,7 @@ struct VaultSyncApp: App { .onOpenURL { url in handleIncomingURL(url) } + .tint(.vaultAccent) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { diff --git a/ios/VaultSync/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/VaultSync/Resources/Assets.xcassets/AccentColor.colorset/Contents.json index eb87897..b923b56 100644 --- a/ios/VaultSync/Resources/Assets.xcassets/AccentColor.colorset/Contents.json +++ b/ios/VaultSync/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -1,6 +1,33 @@ { "colors" : [ { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.482", + "green" : "0.537", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.690", + "green" : "0.769", + "red" : "0.149" + } + }, "idiom" : "universal" } ], diff --git a/ios/VaultSync/Resources/Theme.swift b/ios/VaultSync/Resources/Theme.swift index ef5aecc..e35a944 100644 --- a/ios/VaultSync/Resources/Theme.swift +++ b/ios/VaultSync/Resources/Theme.swift @@ -1,10 +1,200 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Design Tokens +// +// Single source of truth for the VaultSync visual language, compiled into BOTH +// the app and the widget target (see project.yml). Because it is shared with the +// widget extension it must NOT reference app-only symbols such as `L10n`; use +// `String(localized:)` for any user-facing text so each target resolves strings +// from its own bundle. +// +// Colors are built as dynamic Display-P3 `UIColor`s so light/dark (and the +// Increase-Contrast accessibility setting) resolve automatically — this retires +// the hand-rolled `colorScheme == .dark ? … : …` opacity math that used to live +// in the views. + +#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. +private func vaultColor( + light: (CGFloat, CGFloat, CGFloat), + dark: (CGFloat, CGFloat, CGFloat), + lightHC: (CGFloat, CGFloat, CGFloat)? = nil, + darkHC: (CGFloat, CGFloat, CGFloat)? = nil +) -> Color { + Color(uiColor: UIColor { traits in + let highContrast = traits.accessibilityContrast == .high + let channels: (CGFloat, CGFloat, CGFloat) + switch (traits.userInterfaceStyle, highContrast) { + case (.dark, true): channels = darkHC ?? dark + case (.dark, false): channels = dark + case (_, true): channels = lightHC ?? light + default: channels = light + } + return UIColor( + displayP3Red: channels.0 / 255, + green: channels.1 / 255, + blue: channels.2 / 255, + alpha: 1 + ) + }) +} +#else +private func vaultColor( + light: (CGFloat, CGFloat, CGFloat), + dark: (CGFloat, CGFloat, CGFloat), + lightHC: (CGFloat, CGFloat, CGFloat)? = nil, + darkHC: (CGFloat, CGFloat, CGFloat)? = nil +) -> Color { + Color(red: light.0 / 255, green: light.1 / 255, blue: light.2 / 255) +} +#endif + +// MARK: - Brand palette -/// Shared brand palette. Single source of truth so the app and the widget -/// render the same accent colors instead of redefining the RGB per file. extension Color { - /// Brand teal — active / in-progress sync accent. - static let vaultTeal = Color(red: 0 / 255, green: 137 / 255, blue: 123 / 255) - /// Brand slate — muted/inactive accent. - static let vaultSlate = Color(red: 38 / 255, green: 50 / 255, blue: 56 / 255) + /// Primary interactive / affirmative-active brand accent. This is the single + /// app-wide tint (also mirrored in `AccentColor` so the asset-catalog global + /// accent matches). Used for links, selection, primary buttons, "syncing". + static let vaultAccent = vaultColor( + light: (0, 137, 123), // #00897B — the established brand teal, P3-tuned + dark: (38, 196, 176), // lifted so it stays vivid on a dark canvas + lightHC: (0, 110, 99), + darkHC: (74, 222, 202) + ) + + /// Brand teal — kept as the historical name so existing call sites keep + /// working, now dark-aware. Identical to `vaultAccent`. + static let vaultTeal = Color.vaultAccent + + /// Deep neutral slate, used for muted fills/surfaces. Dark-aware so fills no + /// longer need per-call-site opacity math. + static let vaultSlate = vaultColor( + light: (38, 50, 56), // #263238 + dark: (176, 190, 197) // #B0BEC5 — readable as a muted accent in dark + ) + + /// A violet token reserved for the Vault OS identity layer (a later phase can + /// promote it to the primary accent). Defined now so the dial exists; not yet + /// wired as the global tint. + static let vaultViolet = vaultColor( + light: (124, 92, 255), // #7C5CFF + dark: (167, 139, 250) // #A78BFA + ) +} + +// MARK: - Semantic status palette +// +// Six pinned meanings, each ALWAYS paired with a symbol + text label by the +// `SyncStatus` registry so status is never conveyed by color alone. + +extension Color { + /// Idle / all-synced / connected. + static let statusSuccess = vaultColor(light: (46, 158, 107), dark: (52, 199, 127)) + /// Active transfer in progress (alias of the brand accent). + static let statusSyncing = Color.vaultAccent + /// Transient "starting/preparing" — a calm blue so it is never mistaken for + /// an error (today it is wrongly conflated with attention/orange). + static let statusStarting = vaultColor(light: (78, 124, 168), dark: (127, 168, 208)) + /// Warning / action-needed (conflicts, pending shares, setup gaps). + static let statusAttention = vaultColor(light: (224, 146, 47), dark: (242, 169, 59)) + /// Error / unreachable — reserved for genuine failures. + static let statusError = vaultColor(light: (210, 69, 59), dark: (232, 92, 82)) + /// Informational / shared-with — replaces the off-brand system blue used for + /// "Shared With" checkmarks. + static let statusInfo = vaultColor(light: (78, 111, 181), dark: (110, 143, 216)) + /// Paused / offline / inactive. + static let statusInactive = Color.secondary +} + +// MARK: - Spacing & radius scale + +/// 8pt soft grid. Replaces the 14-value padding literal soup. +enum VaultSpacing { + static let xs: CGFloat = 4 + static let s: CGFloat = 8 + static let m: CGFloat = 12 + static let l: CGFloat = 16 + static let xl: CGFloat = 24 + static let xxl: CGFloat = 32 +} + +/// Continuous corner radii. Replaces the 8/10/11/12/14/22/24/28 spread. +enum VaultRadius { + static let control: CGFloat = 12 + static let card: CGFloat = 16 + static let hero: CGFloat = 28 +} + +// MARK: - Sync status registry +// +// One canonical status type keyed by genuine sync state. Maps to a symbol, a +// semantic color, and a localized label. The widget decodes its stringly-typed +// snapshot through `fromWire(_:)` so an unknown value maps to `.attention` +// (NEVER silently to "all good"), closing the documented widget-lies bug. + +enum SyncStatus: String, Sendable, CaseIterable { + case synced + case syncing + case starting + case attention + case error + case paused + + /// Decode the app↔widget wire-format status string. Unknown → `.attention`. + static func fromWire(_ raw: String) -> SyncStatus { + switch raw.lowercased() { + case "idle", "synced", "ok": return .synced + case "syncing", "scanning": return .syncing + case "starting", "preparing": return .starting + case "attention", "warning", "warn": return .attention + case "error", "failed": return .error + case "paused", "inactive", "offline": return .paused + default: return .attention + } + } + + /// Stable wire string for persisting into the shared snapshot. + var wireValue: String { rawValue } + + var symbolName: String { + switch self { + case .synced: return "checkmark.circle.fill" + case .syncing: return "arrow.triangle.2.circlepath" + case .starting: return "hourglass" + case .attention: return "exclamationmark.triangle.fill" + case .error: return "xmark.octagon.fill" + case .paused: return "pause.circle.fill" + } + } + + var tint: Color { + switch self { + case .synced: return .statusSuccess + case .syncing: return .statusSyncing + case .starting: return .statusStarting + case .attention: return .statusAttention + case .error: return .statusError + case .paused: return .statusInactive + } + } + + /// Localized one-word/short label. Resolved from each target's own bundle. + var label: String { + switch self { + case .synced: return String(localized: "All Synced") + case .syncing: return String(localized: "Syncing") + case .starting: return String(localized: "Starting") + case .attention: return String(localized: "Needs Attention") + case .error: return String(localized: "Sync Error") + case .paused: return String(localized: "Paused") + } + } + + /// True for states that should draw the user's attention (used for ordering + /// and for animating the symbol). + var isUrgent: Bool { self == .attention || self == .error } } diff --git a/ios/VaultSyncTests/SyncStatusTests.swift b/ios/VaultSyncTests/SyncStatusTests.swift new file mode 100644 index 0000000..81c8fa7 --- /dev/null +++ b/ios/VaultSyncTests/SyncStatusTests.swift @@ -0,0 +1,63 @@ +import Testing +@testable import VaultSync + +@Suite("SyncStatus registry & wire decoding") +struct SyncStatusTests { + @Test("Known wire strings decode to their canonical status") + func decodesKnownStrings() { + #expect(SyncStatus.fromWire("idle") == .synced) + #expect(SyncStatus.fromWire("synced") == .synced) + #expect(SyncStatus.fromWire("syncing") == .syncing) + #expect(SyncStatus.fromWire("scanning") == .syncing) + #expect(SyncStatus.fromWire("starting") == .starting) + #expect(SyncStatus.fromWire("attention") == .attention) + #expect(SyncStatus.fromWire("warning") == .attention) + #expect(SyncStatus.fromWire("error") == .error) + #expect(SyncStatus.fromWire("paused") == .paused) + } + + @Test("Decoding is case-insensitive") + func decodingIsCaseInsensitive() { + #expect(SyncStatus.fromWire("IDLE") == .synced) + #expect(SyncStatus.fromWire("Syncing") == .syncing) + #expect(SyncStatus.fromWire("ERROR") == .error) + } + + // The load-bearing contract: a status the app never taught the widget about + // must surface as "needs attention", NEVER silently as the green "all good" + // branch. This is the regression guard for the documented widget-lies bug. + @Test("Unknown wire strings resolve to .attention, never .synced") + func unknownResolvesToAttention() { + #expect(SyncStatus.fromWire("garbage") == .attention) + #expect(SyncStatus.fromWire("") == .attention) + #expect(SyncStatus.fromWire("some-future-state") == .attention) + for raw in ["garbage", "", "???", "newstate"] { + #expect(SyncStatus.fromWire(raw) != .synced) + } + } + + @Test("Wire value round-trips through fromWire for every case") + func wireValueRoundTrips() { + for status in SyncStatus.allCases { + #expect(SyncStatus.fromWire(status.wireValue) == status) + } + } + + @Test("Every status carries a symbol and a non-empty label") + func everyStatusHasSymbolAndLabel() { + for status in SyncStatus.allCases { + #expect(!status.symbolName.isEmpty) + #expect(!status.label.isEmpty) + } + } + + @Test("Only attention and error are urgent") + func urgencyFlags() { + #expect(SyncStatus.attention.isUrgent) + #expect(SyncStatus.error.isUrgent) + #expect(!SyncStatus.synced.isUrgent) + #expect(!SyncStatus.syncing.isUrgent) + #expect(!SyncStatus.starting.isUrgent) + #expect(!SyncStatus.paused.isUrgent) + } +} diff --git a/ios/VaultSyncWidget/VaultSyncWidget.swift b/ios/VaultSyncWidget/VaultSyncWidget.swift index 68f9e62..42567c4 100644 --- a/ios/VaultSyncWidget/VaultSyncWidget.swift +++ b/ios/VaultSyncWidget/VaultSyncWidget.swift @@ -39,38 +39,16 @@ private struct VaultSyncWidgetSnapshot: Codable, Equatable { return VaultSyncWidgetDateFormatters.parseISO8601(lastSyncTime) } - var statusLabel: String { - switch status { - case "syncing": - return VaultSyncWidgetL10n.tr("Syncing") - case "error": - return VaultSyncWidgetL10n.tr("Needs Attention") - default: - return VaultSyncWidgetL10n.tr("Idle") - } - } + /// Canonical status decoded from the wire string through the shared registry. + /// Unknown values resolve to `.attention` — never silently to "all good" — + /// so the widget can no longer lie when the status model drifts. + var syncStatus: SyncStatus { SyncStatus.fromWire(status) } - var statusSymbol: String { - switch status { - case "syncing": - return "arrow.triangle.2.circlepath" - case "error": - return "exclamationmark.triangle.fill" - default: - return "checkmark.circle.fill" - } - } + var statusLabel: String { syncStatus.label } - var statusColor: Color { - switch status { - case "syncing": - return .vaultTeal - case "error": - return .orange - default: - return .green - } - } + var statusSymbol: String { syncStatus.symbolName } + + var statusColor: Color { syncStatus.tint } var lastSyncDescription: String { guard let lastSyncDate else { return VaultSyncWidgetL10n.tr("widget_no_sync_yet") } @@ -168,10 +146,10 @@ private struct VaultSyncWidgetEntryView: View { .font(.subheadline.weight(.semibold)) Text(buttonLabel) .font(.caption.weight(.semibold)) - .foregroundStyle(.blue) + .foregroundStyle(Color.vaultAccent) .padding(.horizontal, 10) .padding(.vertical, 6) - .background(.blue.opacity(0.12), in: Capsule()) + .background(Color.vaultAccent.opacity(0.12), in: Capsule()) } Spacer(minLength: 0) } @@ -210,7 +188,7 @@ private struct VaultSyncWidgetEntryView: View { .padding(.horizontal, 12) .padding(.vertical, 8) .frame(maxWidth: .infinity) - .background(.blue, in: Capsule()) + .background(Color.vaultAccent, in: Capsule()) .foregroundStyle(.white) } .buttonStyle(.plain) From 15cd407d2f3988377d173718dc7d0c555dc0ec8d Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 16:44:18 +0200 Subject: [PATCH 02/22] feat(ui): shared component kit (StatusBadge, StatusRow, DetailRow, ActionCard, MonoField) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a — the reusable building blocks every later phase consumes, each replacing a pattern the audit found hand-rebuilt across many views: - StatusBadge / StatusRow: status as icon + text label (never color alone), driven by the SyncStatus registry. - DetailRow: the title+value row duplicated across five views. - ActionCard: icon + title + plain-language message + a real >=44pt primary action, replacing the prose "go to Settings" remediation blocks. - MonoField: copyable monospaced field (Device IDs, .stignore globs, docker run) with haptic + confirmation — the Vault OS "monospace-as-identity" signature. - vaultCard(): one card surface (continuous radius + optional status-accent bar) replacing the three divergent card looks. Additive and app-only. Build green; not yet wired into screens. --- ios/VaultSync/Views/DesignSystem.swift | 228 +++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 ios/VaultSync/Views/DesignSystem.swift diff --git a/ios/VaultSync/Views/DesignSystem.swift b/ios/VaultSync/Views/DesignSystem.swift new file mode 100644 index 0000000..bf54334 --- /dev/null +++ b/ios/VaultSync/Views/DesignSystem.swift @@ -0,0 +1,228 @@ +import SwiftUI +#if canImport(UIKit) +import UIKit +#endif + +// MARK: - Shared UI component kit +// +// The reusable building blocks of the redesign. Each replaces a pattern the +// audit found hand-rebuilt across many views, so "what a status / card / row +// looks like" is edited once here instead of at dozens of call sites. All consume +// the tokens in Theme.swift (colors, spacing, radius, the SyncStatus registry). +// +// App-only (not in the widget target), so these may use `L10n`. + +// MARK: Status primitives + +/// Icon + text label for a sync state, driven by the `SyncStatus` registry so +/// status is NEVER conveyed by color alone — the symbol and the text carry the +/// meaning for VoiceOver and color-blind users; the color is redundant emphasis. +struct StatusBadge: View { + let status: SyncStatus + /// Optional override for the registry's default label. + var text: String? + + init(_ status: SyncStatus, text: String? = nil) { + self.status = status + self.text = text + } + + var body: some View { + HStack(spacing: VaultSpacing.xs) { + Image(systemName: status.symbolName) + .foregroundStyle(status.tint) + .accessibilityHidden(true) + Text(text ?? status.label) + .font(.subheadline.weight(.medium)) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(text ?? status.label) + } +} + +/// 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. +struct StatusRow: View { + let title: String + var subtitle: String? + var status: SyncStatus? + var systemImage: String? + @ViewBuilder var trailing: () -> Trailing + + init( + _ title: String, + subtitle: String? = nil, + status: SyncStatus? = nil, + systemImage: String? = nil, + @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() } + ) { + self.title = title + self.subtitle = subtitle + self.status = status + self.systemImage = systemImage + self.trailing = trailing + } + + private var glyph: String? { systemImage ?? status?.symbolName } + + var body: some View { + HStack(spacing: VaultSpacing.m) { + if let glyph { + Image(systemName: glyph) + .font(.title3) + .foregroundStyle(status?.tint ?? Color.vaultAccent) + .frame(width: 28) + .accessibilityHidden(true) + } + VStack(alignment: .leading, spacing: VaultSpacing.xs / 2) { + Text(title) + .font(.headline) + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: VaultSpacing.s) + trailing() + } + .accessibilityElement(children: .combine) + } +} + +/// Title + value row (e.g. a key/value detail). The de-facto `DetailRow` the +/// audit found duplicated across views. +struct DetailRow: View { + let title: String + let value: String + var monospacedValue: Bool = false + + var body: some View { + HStack(spacing: VaultSpacing.m) { + Text(title) + .foregroundStyle(.secondary) + Spacer(minLength: VaultSpacing.s) + Text(value) + .font(monospacedValue ? .system(.body, design: .monospaced) : .body) + .multilineTextAlignment(.trailing) + } + .font(.subheadline) + .accessibilityElement(children: .combine) + } +} + +/// An attention/error card: status glyph + title + plain-language message + a real +/// primary action button (≥44pt) and optional secondary link. Replaces the +/// icon+title+message+remediation block that was hand-rebuilt at least three times, +/// and turns prose "go to Settings" remediations into a tappable action. +struct ActionCard: View { + let status: SyncStatus + let title: String + var message: String? + var actionTitle: String? + var action: (() -> Void)? + var secondary: (() -> AnyView)? + + var body: some View { + VStack(alignment: .leading, spacing: VaultSpacing.s) { + StatusBadge(status, text: title) + .font(.headline) + if let message { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + if let secondary { + secondary() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(VaultSpacing.l) + .vaultCard(tint: status.isUrgent ? status.tint : nil) + } +} + +/// A copyable monospaced field for genuine machine strings — Device IDs, `.stignore` +/// globs, `docker run` commands. Tap to copy with a haptic + visual confirmation. +/// This "monospace-as-identity, tap-to-copy" treatment is the Vault OS signature: +/// the domain reality is a first-class citizen, not a leak to apologize for. +struct MonoField: View { + let text: String + var accessibilityName: String? + + @State private var copied = false + + var body: some View { + Button { + #if canImport(UIKit) + UIPasteboard.general.string = text + UIImpactFeedbackGenerator(style: .light).impactOccurred() + #endif + withAnimation(.snappy) { copied = true } + } label: { + HStack(alignment: .top, spacing: VaultSpacing.s) { + Text(text) + .font(.system(.footnote, design: .monospaced)) + .lineLimit(3) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .foregroundStyle(copied ? Color.statusSuccess : Color.vaultAccent) + .accessibilityHidden(true) + } + .padding(VaultSpacing.m) + .background( + Color(.secondarySystemBackground), + in: RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous) + ) + } + .buttonStyle(.plain) + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityName ?? text) + .accessibilityValue(copied ? L10n.tr("Copied") : "") + .accessibilityHint(L10n.tr("Double tap to copy")) + .accessibilityAddTraits(.isButton) + } +} + +// MARK: - Card surface + +private struct VaultCardModifier: ViewModifier { + var tint: Color? + + func body(content: Content) -> some View { + content + .background( + Color(.secondarySystemBackground), + in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous) + ) + .overlay(alignment: .leading) { + if let tint { + Rectangle() + .fill(tint) + .frame(width: 4) + .clipShape( + RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous) + ) + .accessibilityHidden(true) + } + } + } +} + +extension View { + /// Standard card surface: continuous-radius `card` corner, secondary system + /// background, and an optional leading status-accent bar. Replaces the three + /// divergent card looks (custom fills / system grouped / hand-rolled + /// RoundedRect + 0.5pt stroke) with one. + func vaultCard(tint: Color? = nil) -> some View { + modifier(VaultCardModifier(tint: tint)) + } +} From 210c0c85b7e86ddb711c68db29a19f956245d38f Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 16:46:41 +0200 Subject: [PATCH 03/22] refactor(ui): retint standalone views onto semantic status tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2b (partial) — replace ad-hoc .green/.orange/.blue status colors with the semantic tokens so status reads consistently and resolves correctly in dark mode: - PendingSharesView: attention/info tokens for the failure vs "Ready" states; bumped the status badge off .caption2. - SetupChecklistView: success/attention tokens for the progress tint and the per-item status color. - RelayServerSetupView: success token for the "helper running" banner; removed the dead `private let teal` the audit flagged. ContentView and OnboardingView are intentionally left for their Phase 3/4 rebuilds. Build green. --- ios/VaultSync/Views/PendingSharesView.swift | 14 +++++++------- ios/VaultSync/Views/RelayServerSetupView.swift | 3 +-- ios/VaultSync/Views/SetupChecklistView.swift | 8 ++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/ios/VaultSync/Views/PendingSharesView.swift b/ios/VaultSync/Views/PendingSharesView.swift index 4abdaae..c2c8872 100644 --- a/ios/VaultSync/Views/PendingSharesView.swift +++ b/ios/VaultSync/Views/PendingSharesView.swift @@ -17,7 +17,7 @@ struct PendingSharesView: View { if !obsidianAccessible { VStack(alignment: .leading, spacing: 8) { Label("Connect Obsidian to accept shares", systemImage: "folder.badge.questionmark") - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) Text("Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected.") .font(.caption) .foregroundStyle(.secondary) @@ -29,7 +29,7 @@ struct PendingSharesView: View { .buttonStyle(.borderedProminent) } .padding(12) - .background(Color.orange.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(Color.statusAttention.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .accessibilityElement(children: .combine) } @@ -80,7 +80,7 @@ struct PendingSharesView: View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .top, spacing: 8) { Image(systemName: hasFailure ? "exclamationmark.circle.fill" : "tray.and.arrow.down.fill") - .foregroundStyle(hasFailure ? .orange : .blue) + .foregroundStyle(hasFailure ? Color.statusAttention : Color.statusInfo) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { @@ -88,11 +88,11 @@ struct PendingSharesView: View { Text(displayName(for: folder)) .font(.body.weight(.semibold)) Text(hasFailure ? L10n.tr("Needs Attention") : L10n.tr("Ready")) - .font(.caption2.weight(.semibold)) - .foregroundStyle(hasFailure ? .orange : .blue) + .font(.caption.weight(.semibold)) + .foregroundStyle(hasFailure ? Color.statusAttention : Color.statusInfo) .padding(.horizontal, 6) .padding(.vertical, 2) - .background((hasFailure ? Color.orange : Color.blue).opacity(0.12), in: Capsule()) + .background((hasFailure ? Color.statusAttention : Color.statusInfo).opacity(0.12), in: Capsule()) } Text(offeredByDescription(for: folder)) @@ -102,7 +102,7 @@ struct PendingSharesView: View { if let failure { Text(failure.message) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) if !failure.remediation.isEmpty { Text(failure.remediation) .font(.caption2) diff --git a/ios/VaultSync/Views/RelayServerSetupView.swift b/ios/VaultSync/Views/RelayServerSetupView.swift index aef360c..4786473 100644 --- a/ios/VaultSync/Views/RelayServerSetupView.swift +++ b/ios/VaultSync/Views/RelayServerSetupView.swift @@ -11,7 +11,6 @@ struct RelayServerSetupView: View { var isDelivering: Bool = false @State private var commandCopied = false - private let teal = Color.vaultTeal /// Self-contained command a user can paste into their server shell. The /// relay URL is pre-filled; only the Syncthing API key is left as a @@ -32,7 +31,7 @@ struct RelayServerSetupView: View { if isDelivering { Section { Label(L10n.tr("Your server helper is running — wake-ups are being delivered."), systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) + .foregroundStyle(Color.statusSuccess) .font(.subheadline) } } diff --git a/ios/VaultSync/Views/SetupChecklistView.swift b/ios/VaultSync/Views/SetupChecklistView.swift index 5c58012..03faf49 100644 --- a/ios/VaultSync/Views/SetupChecklistView.swift +++ b/ios/VaultSync/Views/SetupChecklistView.swift @@ -9,7 +9,7 @@ struct SetupChecklistView: View { headerSection ProgressView(value: viewModel.completionProgress) - .tint(viewModel.isReadyToFinish ? .green : .orange) + .tint(viewModel.isReadyToFinish ? Color.statusSuccess : Color.statusAttention) .accessibilityLabel(L10n.tr("Setup status progress")) .accessibilityValue(L10n.fmt("%d of %d essentials ready", viewModel.completedRequiredCount, viewModel.totalRequiredCount)) @@ -138,10 +138,10 @@ struct SetupChecklistView: View { private func statusColor(for item: SetupChecklistViewModel.ChecklistItem) -> Color { if item.isOptional { - return item.isComplete ? .green : .secondary + return item.isComplete ? .statusSuccess : .secondary } - if item.isComplete { return .green } - return .orange + if item.isComplete { return .statusSuccess } + return .statusAttention } private func statusText(for item: SetupChecklistViewModel.ChecklistItem) -> String { From 573a967ddb29a4424f3bd960280cd1c930c7a121 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:03:47 +0200 Subject: [PATCH 04/22] feat(ui): persistent sync-status header + hub retint (Phase 3a) - Add SyncStatusHeader: a pinned material header (safeAreaInset) that states one glanceable truth, driven by a new canonical `overallStatus` (SyncStatus) for glyph + color while keeping the contextual wording in syncStatusText. Adopts Liquid Glass automatically on iOS 26. - Give the hub a real navigation title ("VaultSync") instead of the empty one. - Remove the now-redundant in-list status row (folded into the header) and its duplicate syncStatusIcon/syncStatusColor; relocate stale/background warnings. - Retint the whole hub onto semantic status tokens (devices connected, errors, folder errors, conflict badge, vault state, "Shared With", device connectivity), killing the .green/.orange/.red/.blue + hand-rolled slate-opacity usage. - Raise actionable/remediation text off .caption2 to the .footnote floor. Build green. No navigation restructure yet (TabView is 3b). --- ios/VaultSync/Views/ContentView.swift | 145 +++++++++++-------------- ios/VaultSync/Views/DesignSystem.swift | 57 ++++++++++ 2 files changed, 121 insertions(+), 81 deletions(-) diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index efa8034..5f86bed 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -18,7 +18,6 @@ struct ContentView: View { private static let relayUpsellShownKey = "relay-upsell-shown" - private let slate = Color.vaultSlate private let teal = Color.vaultTeal /// Cached formatter for the dashboard "Last sync" line. Produces a fully @@ -44,7 +43,15 @@ struct ContentView: View { .refreshable { await syncthingManager.performForegroundSync() } - .navigationTitle("") + .safeAreaInset(edge: .top, spacing: 0) { + SyncStatusHeader( + status: overallStatus, + title: syncStatusText, + subtitle: headerSubtitle, + busy: shouldShowReconnectingUI + ) + } + .navigationTitle(L10n.tr("VaultSync")) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { @@ -174,52 +181,19 @@ struct ContentView: View { private var dashboardSection: some View { Section { - HStack(spacing: 12) { - if shouldShowReconnectingUI { - ProgressView() - .tint(teal) - .frame(width: 28, height: 28) - .accessibilityHidden(true) - } else { - Image(systemName: syncStatusIcon) - .font(.title2) - .foregroundStyle(syncStatusColor) - .symbolEffect(.pulse, isActive: isSyncing) - .accessibilityHidden(true) - } - - VStack(alignment: .leading, spacing: 2) { - Text(syncStatusText) - .font(.headline) - if shouldShowReconnectingUI { - let count = syncthingManager.reconnectingRequiredDeviceIDs.count - Text(count == 1 - ? L10n.tr("Restoring connection to 1 device") - : L10n.fmt("Restoring connection to %d devices", count)) - .font(.caption) - .foregroundStyle(.secondary) - } - if let lastSync = syncthingManager.lastSyncTime { - Text(L10n.fmt("Last sync: %@", Self.lastSyncFormatter.localizedString(for: lastSync, relativeTo: Date()))) - .font(.caption) - .foregroundStyle(.secondary) - } - if let staleWarning = syncthingManager.staleSyncWarning { - Text(staleWarning) - .font(.caption2) - .foregroundStyle(.orange) - } - if let backgroundOutcome = syncthingManager.lastBackgroundSyncOutcome, - backgroundOutcome.result.shouldSurfaceIssue { - Text(L10n.fmt("Background sync: %@", backgroundOutcome.result.issueTitle)) - .font(.caption2) - .foregroundStyle(.orange) - } - } - - Spacer(minLength: 0) + if let staleWarning = syncthingManager.staleSyncWarning { + Label(staleWarning, systemImage: "clock.badge.exclamationmark") + .font(.caption) + .foregroundStyle(Color.statusAttention) + .accessibilityElement(children: .combine) + } + if let backgroundOutcome = syncthingManager.lastBackgroundSyncOutcome, + backgroundOutcome.result.shouldSurfaceIssue { + Label(L10n.fmt("Background sync: %@", backgroundOutcome.result.issueTitle), systemImage: "moon.zzz") + .font(.caption) + .foregroundStyle(Color.statusAttention) + .accessibilityElement(children: .combine) } - .accessibilityElement(children: .combine) if subscriptionManager.isRelaySubscribed { HStack { @@ -262,14 +236,14 @@ struct ContentView: View { let total = syncthingManager.devices.count HStack { Image(systemName: "network") - .foregroundStyle(connected > 0 ? teal : slate.opacity(colorScheme == .dark ? 0.75 : 0.65)) + .foregroundStyle(connected > 0 ? Color.statusSuccess : Color.statusInactive) .accessibilityHidden(true) if total == 0 { Text("No devices configured") .foregroundStyle(.secondary) } else { Text(L10n.fmt("%d of %d devices connected", connected, total)) - .foregroundStyle(connected > 0 ? teal : Color.orange) + .foregroundStyle(connected > 0 ? Color.statusSuccess : Color.statusAttention) } } .font(.subheadline) @@ -278,8 +252,8 @@ struct ContentView: View { if let error = currentSyncError { HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) + Image(systemName: SyncStatus.error.symbolName) + .foregroundStyle(Color.statusError) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(error.title) @@ -287,15 +261,15 @@ struct ContentView: View { Text(error.message) .font(.caption) Text(error.remediation) - .font(.caption2) + .font(.footnote) .foregroundStyle(.secondary) } - .foregroundStyle(.red) + .foregroundStyle(Color.statusError) } .accessibilityElement(children: .combine) if let url = troubleshootingURL(for: error) { ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.caption2) + .font(.footnote) } } @@ -306,7 +280,7 @@ struct ContentView: View { let folderError = syncthingManager.folderUserError(folderID: folderID) HStack(spacing: 8) { Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(folder?.label ?? folderID) @@ -315,13 +289,13 @@ struct ContentView: View { .font(.caption) if let remediation = folderError?.remediation { Text(remediation) - .font(.caption2) + .font(.footnote) .foregroundStyle(.secondary) } if let folderError, let url = troubleshootingURL(for: folderError) { ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.caption2) + .font(.footnote) } } } @@ -351,22 +325,31 @@ struct ContentView: View { && isReconnecting } - private var syncStatusIcon: String { - if currentSyncError != nil { return "exclamationmark.triangle.fill" } - if !syncthingManager.isRunning { return "arrow.triangle.2.circlepath" } - if !foldersWithErrors.isEmpty { return "exclamationmark.circle" } - if isReconnecting { return "arrow.triangle.2.circlepath" } - if isSyncing { return "arrow.triangle.2.circlepath" } - return "checkmark.circle.fill" - } - - private var syncStatusColor: Color { - if currentSyncError != nil { return .red } - if !syncthingManager.isRunning { return slate.opacity(colorScheme == .dark ? 0.78 : 0.68) } - if !foldersWithErrors.isEmpty { return .orange } - if isReconnecting { return teal } - if isSyncing { return teal } - return teal + /// Canonical overall status for the header, mirroring the precedence cascade + /// of `syncStatusText`. Drives the header glyph + color through the SyncStatus + /// registry; the contextual wording stays in `syncStatusText`. + private var overallStatus: SyncStatus { + if currentSyncError != nil { return .error } + if !syncthingManager.isRunning { return .starting } + if !foldersWithErrors.isEmpty { return .attention } + if isReconnecting { return .starting } + if isSyncing { return .syncing } + return .synced + } + + /// Secondary line for the status header — the reconnecting progress or the + /// last-sync relative time. + private var headerSubtitle: String? { + if shouldShowReconnectingUI { + let count = syncthingManager.reconnectingRequiredDeviceIDs.count + return count == 1 + ? L10n.tr("Restoring connection to 1 device") + : L10n.fmt("Restoring connection to %d devices", count) + } + if let lastSync = syncthingManager.lastSyncTime { + return L10n.fmt("Last sync: %@", Self.lastSyncFormatter.localizedString(for: lastSync, relativeTo: Date())) + } + return nil } private var syncStatusText: String { @@ -591,7 +574,7 @@ struct ContentView: View { .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 1) - .background(.orange, in: Capsule()) + .background(Color.statusAttention, in: Capsule()) .accessibilityLabel(L10n.fmt("%d conflicts", conflicts.count)) } } @@ -627,10 +610,10 @@ struct ContentView: View { private func stateColor(_ state: String) -> Color { switch state { - case "idle": .green - case "scanning", "syncing": teal - case "error": .red - default: .gray + case "idle": .statusSuccess + case "scanning", "syncing": .vaultAccent + case "error": .statusError + default: .statusInactive } } @@ -687,7 +670,7 @@ struct ContentView: View { } label: { HStack { Label("Conflicts", systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) Spacer() Text("\(conflicts.count)") .foregroundStyle(.secondary) @@ -726,7 +709,7 @@ struct ContentView: View { Spacer() Label(isShared ? L10n.tr("Shared") : L10n.tr("Not Shared"), systemImage: isShared ? "checkmark.circle.fill" : "circle") .font(.caption.weight(.semibold)) - .foregroundStyle(isShared ? .blue : .secondary) + .foregroundStyle(isShared ? Color.vaultAccent : Color.statusInactive) .accessibilityHidden(true) } } @@ -826,7 +809,7 @@ struct ContentView: View { Spacer() HStack(spacing: 4) { Image(systemName: device.connected ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(device.connected ? .green : .secondary) + .foregroundStyle(device.connected ? Color.statusSuccess : Color.statusInactive) .accessibilityHidden(true) Text(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) .font(.caption2.weight(.semibold)) diff --git a/ios/VaultSync/Views/DesignSystem.swift b/ios/VaultSync/Views/DesignSystem.swift index bf54334..ab2c335 100644 --- a/ios/VaultSync/Views/DesignSystem.swift +++ b/ios/VaultSync/Views/DesignSystem.swift @@ -192,6 +192,63 @@ struct MonoField: View { } } +// MARK: - Persistent sync-status header + +/// The "answer at a glance" header: a floating material bar that states one +/// unambiguous truth about the vault — the canonical `SyncStatus` drives the +/// glyph + color, and the title/subtitle carry the contextual detail. Pinned +/// above the content via `.safeAreaInset(edge: .top)`. On iOS 26 the material +/// adopts the Liquid Glass look automatically. +struct SyncStatusHeader: View { + let status: SyncStatus + let title: String + var subtitle: String? + /// When true, show an indeterminate spinner instead of the status glyph + /// (used while reconnecting to peers). + var busy: Bool = false + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + HStack(spacing: VaultSpacing.m) { + ZStack { + if busy { + ProgressView() + .tint(status.tint) + } else { + Image(systemName: status.symbolName) + .font(.title2) + .foregroundStyle(status.tint) + .symbolEffect(.pulse, isActive: status == .syncing && !reduceMotion) + } + } + .frame(width: 30, height: 30) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + if let subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, VaultSpacing.l) + .padding(.vertical, VaultSpacing.m) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial) + .overlay(alignment: .bottom) { + Divider() + } + .accessibilityElement(children: .combine) + .accessibilityLabel(title) + .accessibilityValue(subtitle ?? "") + } +} + // MARK: - Card surface private struct VaultCardModifier: ViewModifier { From 0c39ee088f06680cdb60dca16111454f31622867 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:06:57 +0200 Subject: [PATCH 05/22] =?UTF-8?q?feat(ui):=20TabView=20shell=20=E2=80=94?= =?UTF-8?q?=20split=20the=20overloaded=20hub=20into=20Sync=20+=20Devices?= =?UTF-8?q?=20(Phase=203b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the single overloaded scrolling hub with a TabView: a "Sync" tab (status header + issues + Obsidian + pending shares + vaults) and a "Devices" tab (paired peers + Add Device), giving the app the HIG-correct bottom-tab shell and thumb-zone navigation it lacked. - Keep all @State, sheets, alerts and onChange handlers in ContentView, attached at the shell level so cross-tab triggers (e.g. "Add Device" from a Sync-tab issue) still present correctly. The scenePhase-coupled Syncthing lifecycle in VaultSyncApp is untouched. - Add-Device sheet grows with Dynamic Type ([.medium, .large] instead of medium-only). The dedicated Relay tab + cross-linked funnel is Phase 4. Build + tests green. --- ios/VaultSync/Views/ContentView.swift | 118 +++++++++++++++++--------- 1 file changed, 78 insertions(+), 40 deletions(-) diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 5f86bed..d8cf707 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -30,7 +30,74 @@ struct ContentView: View { return f }() + private enum Tab: Hashable { + case sync + case devices + } + + @State private var selectedTab: Tab = .sync + var body: some View { + TabView(selection: $selectedTab) { + syncTab + .tabItem { + Label(L10n.tr("Sync"), systemImage: "arrow.triangle.2.circlepath") + } + .tag(Tab.sync) + + devicesTab + .tabItem { + Label(L10n.tr("Devices"), systemImage: "laptopcomputer.and.iphone") + } + .tag(Tab.devices) + } + // Sheets/alerts live at the shell level so cross-tab triggers (e.g. an + // "Add Device" remediation tapped from a Sync-tab issue) present + // regardless of which tab is active. + .alert("Error", isPresented: $showAlert) { + Button("OK") { } + } message: { + Text(alertMessage ?? "") + } + .sheet(isPresented: $showAddDevice) { + addDeviceSheet + } + .sheet(isPresented: $showSettings) { + SettingsView(syncthingManager: syncthingManager, vaultManager: vaultManager, subscriptionManager: subscriptionManager) + } + .sheet(isPresented: $showObsidianPicker) { + FolderPicker(initialDirectoryURL: vaultManager.obsidianDirectoryURL, onCancel: { + showObsidianPicker = false + }) { url in + showObsidianPicker = false + if let err = vaultManager.grantAccess(url: url) { + alertMessage = mappedError(err, fallbackTitle: L10n.tr("Obsidian Folder Connection Failed")).userVisibleDescription + showAlert = true + } + } + } + .sheet(isPresented: $showRelayUpsell) { + NavigationStack { + CloudRelayUpsellView( + syncthingManager: syncthingManager, + subscriptionManager: subscriptionManager + ) + } + } + .onChange(of: syncthingManager.pendingFolders, initial: true) { _, pending in + autoAcceptPendingShares(pending) + } + .onChange(of: syncthingManager.lastSyncTime, initial: true) { _, _ in + maybePresentRelayUpsell() + } + .onChange(of: subscriptionManager.isRelaySubscribed) { _, _ in + maybePresentRelayUpsell() + } + } + + /// The Sync tab — the vault's live-status story: the pinned status header, + /// sync issues, Obsidian connection, pending shares, and the vault list. + private var syncTab: some View { NavigationStack { List { dashboardSection @@ -38,7 +105,6 @@ struct ContentView: View { obsidianStatusSection pendingSharesSection vaultsSection - devicesSection } .refreshable { await syncthingManager.performForegroundSync() @@ -64,45 +130,17 @@ struct ContentView: View { .accessibilityHint("Opens discovery, relay, and notification settings.") } } - .alert("Error", isPresented: $showAlert) { - Button("OK") { } - } message: { - Text(alertMessage ?? "") - } - .sheet(isPresented: $showAddDevice) { - addDeviceSheet - } - .sheet(isPresented: $showSettings) { - SettingsView(syncthingManager: syncthingManager, vaultManager: vaultManager, subscriptionManager: subscriptionManager) - } - .sheet(isPresented: $showObsidianPicker) { - FolderPicker(initialDirectoryURL: vaultManager.obsidianDirectoryURL, onCancel: { - showObsidianPicker = false - }) { url in - showObsidianPicker = false - if let err = vaultManager.grantAccess(url: url) { - alertMessage = mappedError(err, fallbackTitle: L10n.tr("Obsidian Folder Connection Failed")).userVisibleDescription - showAlert = true - } - } - } - .sheet(isPresented: $showRelayUpsell) { - NavigationStack { - CloudRelayUpsellView( - syncthingManager: syncthingManager, - subscriptionManager: subscriptionManager - ) - } - } - .onChange(of: syncthingManager.pendingFolders, initial: true) { _, pending in - autoAcceptPendingShares(pending) - } - .onChange(of: syncthingManager.lastSyncTime, initial: true) { _, _ in - maybePresentRelayUpsell() - } - .onChange(of: subscriptionManager.isRelaySubscribed) { _, _ in - maybePresentRelayUpsell() + } + } + + /// The Devices tab — paired Syncthing peers and the add-device entry point. + private var devicesTab: some View { + NavigationStack { + List { + devicesSection } + .navigationTitle(L10n.tr("Devices")) + .navigationBarTitleDisplayMode(.inline) } } @@ -883,7 +921,7 @@ struct ContentView: View { } } } - .presentationDetents([.medium]) + .presentationDetents([.medium, .large]) } private func addDevice() { From c09790b72504847a44541be11a33bafef78ed896 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:15:11 +0200 Subject: [PATCH 06/22] refactor(ui): finish app-wide retint onto semantic status tokens (Phase 2b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retint the remaining views so status reads consistently and resolves correctly in dark mode app-wide: - SyncActivityView: error/info tokens for event severity. - DeviceDetailView: success/inactive for connectivity. - LineDiffView: unify the diff colors onto status tokens — fixes the mixed-hue bug (Color.green background vs systemGreen foreground) the audit flagged, and gives dark-mode-correct, contrast-validated add/remove highlights. - SettingsView + RelayDiagnosticsView + CloudRelayUpsellView: success/attention/ error tokens for relay subscription, delivery, health, APNs, and error states. Only OnboardingView remains on raw colors; it is rebuilt in Phase 4. Build green. --- ios/VaultSync/Views/CloudRelayUpsellView.swift | 2 +- ios/VaultSync/Views/DeviceDetailView.swift | 2 +- ios/VaultSync/Views/LineDiffView.swift | 8 ++++---- ios/VaultSync/Views/RelayDiagnosticsView.swift | 12 ++++++------ ios/VaultSync/Views/SettingsView.swift | 10 +++++----- ios/VaultSync/Views/SyncActivityView.swift | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/ios/VaultSync/Views/CloudRelayUpsellView.swift b/ios/VaultSync/Views/CloudRelayUpsellView.swift index 21326d9..1434f10 100644 --- a/ios/VaultSync/Views/CloudRelayUpsellView.swift +++ b/ios/VaultSync/Views/CloudRelayUpsellView.swift @@ -102,7 +102,7 @@ struct CloudRelayUpsellView: View { private var subscribedContent: some View { Section { Label(L10n.tr("You’re subscribed to Cloud Relay"), systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) + .foregroundStyle(Color.statusSuccess) .font(.headline) } Section { diff --git a/ios/VaultSync/Views/DeviceDetailView.swift b/ios/VaultSync/Views/DeviceDetailView.swift index 12ee85e..c9a4247 100644 --- a/ios/VaultSync/Views/DeviceDetailView.swift +++ b/ios/VaultSync/Views/DeviceDetailView.swift @@ -34,7 +34,7 @@ struct DeviceDetailView: View { LabeledContent("Status") { HStack(spacing: 6) { Image(systemName: device.connected ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(device.connected ? .green : .secondary) + .foregroundStyle(device.connected ? Color.statusSuccess : Color.statusInactive) .accessibilityHidden(true) Text(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) } diff --git a/ios/VaultSync/Views/LineDiffView.swift b/ios/VaultSync/Views/LineDiffView.swift index 8045609..eb1bf36 100644 --- a/ios/VaultSync/Views/LineDiffView.swift +++ b/ios/VaultSync/Views/LineDiffView.swift @@ -114,16 +114,16 @@ struct LineDiffView: View { private func backgroundColor(for type: DiffLine.LineType) -> Color { switch type { case .unchanged: return Color.clear - case .added: return Color.green.opacity(0.2) - case .removed: return Color.red.opacity(0.2) + case .added: return Color.statusSuccess.opacity(0.18) + case .removed: return Color.statusError.opacity(0.18) } } private func foregroundColor(for type: DiffLine.LineType) -> Color { switch type { case .unchanged: return Color.primary - case .added: return Color(uiColor: .systemGreen) - case .removed: return Color(uiColor: .systemRed) + case .added: return Color.statusSuccess + case .removed: return Color.statusError } } diff --git a/ios/VaultSync/Views/RelayDiagnosticsView.swift b/ios/VaultSync/Views/RelayDiagnosticsView.swift index 2d6aed1..b623702 100644 --- a/ios/VaultSync/Views/RelayDiagnosticsView.swift +++ b/ios/VaultSync/Views/RelayDiagnosticsView.swift @@ -30,11 +30,11 @@ struct RelayDiagnosticsView: View { Section("Relay Backend") { if subscriptionManager.relayDeliveryConfirmed { Label(L10n.tr("Cloud Relay is delivering wake-ups"), systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) + .foregroundStyle(Color.statusSuccess) .font(.subheadline) } else if subscriptionManager.relayDeliveryLikelyWorking { Label(L10n.tr("Cloud Relay looks reachable"), systemImage: "checkmark.circle") - .foregroundStyle(.green) + .foregroundStyle(Color.statusSuccess) .font(.subheadline) } HStack { @@ -45,7 +45,7 @@ struct RelayDiagnosticsView: View { .controlSize(.small) } else if let result = subscriptionManager.relayHealthResult { Text(result.summary) - .foregroundStyle(result.isHealthy ? .green : .red) + .foregroundStyle(result.isHealthy ? Color.statusSuccess : Color.statusError) } else { Text("Not checked") .foregroundStyle(.secondary) @@ -82,7 +82,7 @@ struct RelayDiagnosticsView: View { VStack(alignment: .leading, spacing: 4) { Text("Last Relay Error") .font(.caption.weight(.semibold)) - .foregroundStyle(.red) + .foregroundStyle(Color.statusError) Text(relayError.message) .font(.caption) Text( @@ -122,7 +122,7 @@ struct RelayDiagnosticsView: View { Label("APNs Token", systemImage: "key.fill") Spacer() Text(subscriptionManager.hasAPNsToken ? L10n.tr("Present") : L10n.tr("Missing")) - .foregroundStyle(subscriptionManager.hasAPNsToken ? .green : .orange) + .foregroundStyle(subscriptionManager.hasAPNsToken ? Color.statusSuccess : Color.statusAttention) } .accessibilityElement(children: .combine) @@ -162,7 +162,7 @@ struct RelayDiagnosticsView: View { VStack(alignment: .leading, spacing: 2) { Text(reason) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(Color.statusError) if let url = SyncUserError.troubleshootingURL(anchor: "apns-not-registered") { ExternalLinkButton(titleKey: "Learn how to fix", url: url) .font(.caption2) diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index 8abeb14..ea47284 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -143,7 +143,7 @@ struct SettingsView: View { Label("Status", systemImage: subscriptionManager.isRelaySubscribed ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") Spacer() Text(relayStatusText) - .foregroundStyle(subscriptionManager.isRelaySubscribed ? .green : .secondary) + .foregroundStyle(subscriptionManager.isRelaySubscribed ? Color.statusSuccess : Color.statusInactive) } .accessibilityElement(children: .combine) @@ -255,7 +255,7 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 2) { Text(relayError) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(Color.statusError) if let url = SyncUserError.troubleshootingURL(forRawError: relayError) { ExternalLinkButton(titleKey: "Learn how to fix", url: url) .font(.caption2) @@ -294,7 +294,7 @@ struct SettingsView: View { private var relayDeliveryRow: some View { if subscriptionManager.relayDeliveryConfirmed { Label(L10n.tr("Delivering wake-ups"), systemImage: "checkmark.seal.fill") - .foregroundStyle(.green) + .foregroundStyle(Color.statusSuccess) .font(.subheadline) .accessibilityElement(children: .combine) } else if let last = subscriptionManager.lastRelayTriggerReceivedAt { @@ -304,7 +304,7 @@ struct SettingsView: View { } else { VStack(alignment: .leading, spacing: 6) { Label(L10n.tr("Waiting for your server"), systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) .font(.subheadline) Text(L10n.tr("Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates.")) .font(.caption) @@ -414,7 +414,7 @@ struct SettingsView: View { if let error = tipJar.errorMessage, !error.isEmpty { Text(error) .font(.caption) - .foregroundStyle(.red) + .foregroundStyle(Color.statusError) } if let pending = tipJar.pendingMessage, !pending.isEmpty { diff --git a/ios/VaultSync/Views/SyncActivityView.swift b/ios/VaultSync/Views/SyncActivityView.swift index d385666..2781626 100644 --- a/ios/VaultSync/Views/SyncActivityView.swift +++ b/ios/VaultSync/Views/SyncActivityView.swift @@ -16,7 +16,7 @@ struct SyncActivityView: View { HStack(alignment: .top, spacing: 12) { Image(systemName: event.symbolName) .font(.body.weight(.semibold)) - .foregroundStyle(event.isError ? .red : .blue) + .foregroundStyle(event.isError ? Color.statusError : Color.statusInfo) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { From a412f0f38d3acf272a4c939ddb42bf4fa91c1be2 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:25:43 +0200 Subject: [PATCH 07/22] feat(ui): actionable onboarding pager (Phase 4a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild onboarding from prose-that-points-elsewhere into a real pager whose steps launch the actual task — the audit's highest-leverage single change ("setup is described but not doable" / "the checklist lies"). - TabView(.page) pager: a Welcome page (value pitch) + a live "Let's get your vault synced" page with three actionable step cards. - Each step runs the real task inline and self-verifies against live manager state, turning green when its prerequisite is met: - Connect Obsidian folder -> FolderPicker - Add your computer/server -> Device ID form + QR scanner - Sync your first vault -> info + auto-green when a folder arrives - Page dots + a persistent bottom bar (Continue -> Open VaultSync); tokens/radii throughout; Add-Device sheet grows with Dynamic Type. - Animate the onboarding->hub swap in VaultSyncApp (was an abrupt no-transition flip). Welcome page visually verified in the simulator; build green. --- ios/VaultSync/App/VaultSyncApp.swift | 1 + ios/VaultSync/Views/OnboardingView.swift | 437 +++++++++++++---------- 2 files changed, 258 insertions(+), 180 deletions(-) diff --git a/ios/VaultSync/App/VaultSyncApp.swift b/ios/VaultSync/App/VaultSyncApp.swift index 751a925..16111be 100644 --- a/ios/VaultSync/App/VaultSyncApp.swift +++ b/ios/VaultSync/App/VaultSyncApp.swift @@ -54,6 +54,7 @@ struct VaultSyncApp: App { handleIncomingURL(url) } .tint(.vaultAccent) + .animation(.easeInOut(duration: 0.3), value: hasCompletedOnboarding) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { diff --git a/ios/VaultSync/Views/OnboardingView.swift b/ios/VaultSync/Views/OnboardingView.swift index 5a39754..8148b7f 100644 --- a/ios/VaultSync/Views/OnboardingView.swift +++ b/ios/VaultSync/Views/OnboardingView.swift @@ -8,76 +8,63 @@ struct OnboardingView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @State private var currentScreen: Screen = .welcome - private enum Screen: Int { - case welcome = 0 - case overview = 1 + @State private var page = 0 - var pageIndex: Int { rawValue } - } - - private struct OverviewStep: Identifiable { - let number: Int - let titleKey: String - let descriptionKey: String - - var id: Int { number } - } + // Live setup actions — each step launches the real task instead of describing it. + @State private var showObsidianPicker = false + @State private var showAddDevice = false + @State private var showQRScanner = false + @State private var newDeviceID = "" + @State private var newDeviceName = "" + @State private var alertMessage: String? + @State private var showAlert = false private let slate = Color.vaultSlate private let teal = Color.vaultTeal - private var overviewSteps: [OverviewStep] { - [ - OverviewStep( - number: 1, - titleKey: "onboarding.overview.step1.title", - descriptionKey: "onboarding.overview.step1.description" - ), - OverviewStep( - number: 2, - titleKey: "onboarding.overview.step2.title", - descriptionKey: "onboarding.overview.step2.description" - ), - OverviewStep( - number: 3, - titleKey: "onboarding.overview.step3.title", - descriptionKey: "onboarding.overview.step3.description" - ), - OverviewStep( - number: 4, - titleKey: "onboarding.overview.step4.title", - descriptionKey: "onboarding.overview.step4.description" - ), - ] - } + private var obsidianConnected: Bool { vaultManager.isAccessible } + private var deviceAdded: Bool { !syncthingManager.devices.isEmpty } + private var vaultSyncing: Bool { !syncthingManager.folders.isEmpty } var body: some View { NavigationStack { - GeometryReader { geometry in - ZStack { - backgroundView - - ScrollView { - VStack(alignment: .leading, spacing: 28) { - if currentScreen == .welcome { - welcomeScreen - } else { - overviewScreen - } - - Spacer(minLength: currentScreen == .welcome ? 32 : 12) - - primaryActionSection - } - .padding(.horizontal, 20) - .padding(.vertical, 28) - .frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading) + ZStack { + backgroundView + + VStack(spacing: 0) { + TabView(selection: $page) { + page(welcomeScreen).tag(0) + page(setupScreen).tag(1) } + .tabViewStyle(.page(indexDisplayMode: .never)) + + bottomBar + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 8) + .background(.bar) } .toolbar(.hidden, for: .navigationBar) } + .sheet(isPresented: $showObsidianPicker) { + FolderPicker(initialDirectoryURL: vaultManager.obsidianDirectoryURL, onCancel: { + showObsidianPicker = false + }) { url in + showObsidianPicker = false + if let err = vaultManager.grantAccess(url: url) { + present(error: err, fallbackTitle: L10n.tr("Obsidian Folder Connection Failed")) + } + } + } + .sheet(isPresented: $showAddDevice) { + addDeviceSheet + } + .alert("Error", isPresented: $showAlert) { + Button("OK") { } + } message: { + Text(alertMessage ?? "") + } } .onAppear { vaultManager.restoreAccess() @@ -85,25 +72,19 @@ struct OnboardingView: View { } } - private var backgroundView: some View { - ZStack(alignment: .top) { - Color(uiColor: .systemGroupedBackground) - .ignoresSafeArea() - - Circle() - .fill(teal.opacity(colorScheme == .dark ? 0.14 : 0.08)) - .frame(width: 220, height: 220) - .blur(radius: 18) - .offset(x: -120, y: -70) - - Circle() - .fill(slate.opacity(colorScheme == .dark ? 0.10 : 0.05)) - .frame(width: 240, height: 240) - .blur(radius: 22) - .offset(x: 130, y: -110) + private func page(_ content: Content) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + content + } + .padding(.horizontal, 20) + .padding(.vertical, 28) + .frame(maxWidth: .infinity, alignment: .topLeading) } } + // MARK: - Welcome + private var welcomeScreen: some View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 12) { @@ -121,8 +102,8 @@ struct OnboardingView: View { } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) - .background(cardBackground, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) - .overlay(cardStroke(in: RoundedRectangle(cornerRadius: 28, style: .continuous))) + .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous)) + .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous))) VStack(alignment: .leading, spacing: 14) { benefitRow(icon: "lock.shield.fill", textKey: "onboarding.welcome.benefit.private") @@ -130,162 +111,273 @@ struct OnboardingView: View { benefitRow(icon: "icloud.slash.fill", textKey: "onboarding.welcome.benefit.noCloud") } .padding(20) - .background(cardBackground, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .overlay(cardStroke(in: RoundedRectangle(cornerRadius: 24, style: .continuous))) + .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) + .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) } } - private var overviewScreen: some View { + // MARK: - Setup (live, actionable) + + private var setupScreen: some View { VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 10) { - Text(L10n.tr("onboarding.overview.title")) + Text(L10n.tr("Let’s get your vault synced")) .font(titleFont) .foregroundStyle(primaryHeadingColor) - Text(L10n.tr("onboarding.overview.subtitle")) + Text(L10n.tr("Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen.")) .font(.body) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(24) .frame(maxWidth: .infinity, alignment: .leading) - .background(cardBackground, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) - .overlay(cardStroke(in: RoundedRectangle(cornerRadius: 28, style: .continuous))) + .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous)) + .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.hero, style: .continuous))) + + stepCard( + isComplete: obsidianConnected, + icon: "folder.badge.plus", + title: L10n.tr("Connect your Obsidian folder"), + description: L10n.tr("Give VaultSync one-time access to your local Obsidian folder so it can sync your notes."), + actionTitle: L10n.tr("Connect Obsidian Folder"), + action: { showObsidianPicker = true } + ) - VStack(alignment: .leading, spacing: 14) { - ForEach(overviewSteps) { step in - overviewStepRow(step) - } - } + stepCard( + isComplete: deviceAdded, + icon: "laptopcomputer.and.iphone", + title: L10n.tr("Add your computer or server"), + description: L10n.tr("Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code."), + actionTitle: L10n.tr("Add Device"), + action: { showAddDevice = true } + ) + + stepCard( + isComplete: vaultSyncing, + icon: "arrow.triangle.2.circlepath", + title: L10n.tr("Sync your first vault"), + description: L10n.tr("Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives."), + actionTitle: nil, + action: nil + ) HStack(alignment: .top, spacing: 12) { - Image(systemName: "gearshape.2.fill") + Image(systemName: "antenna.radiowaves.left.and.right") .font(.body.weight(.semibold)) .foregroundStyle(teal) .accessibilityHidden(true) - - Text(L10n.tr("onboarding.overview.cloudRelay")) + Text(L10n.tr("Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .padding(18) - .background(cardBackground, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay(cardStroke(in: RoundedRectangle(cornerRadius: 22, style: .continuous))) + .frame(maxWidth: .infinity, alignment: .leading) + .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) + .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) .accessibilityElement(children: .combine) } } - private func benefitRow(icon: String, textKey: String) -> some View { - HStack(alignment: .top, spacing: 12) { + private func stepCard( + isComplete: Bool, + icon: String, + title: String, + description: String, + actionTitle: String?, + action: (() -> Void)? + ) -> some View { + HStack(alignment: .top, spacing: 14) { ZStack { - RoundedRectangle(cornerRadius: 11, style: .continuous) - .fill(teal.opacity(colorScheme == .dark ? 0.22 : 0.14)) - .frame(width: 36, height: 36) - - Image(systemName: icon) + Circle() + .fill(isComplete ? Color.statusSuccess : teal.opacity(colorScheme == .dark ? 0.22 : 0.14)) + .frame(width: 34, height: 34) + Image(systemName: isComplete ? "checkmark" : icon) .font(.subheadline.weight(.semibold)) - .foregroundStyle(teal) - .accessibilityHidden(true) + .foregroundStyle(isComplete ? .white : teal) } - - Text(L10n.tr(textKey)) - .font(.body.weight(.semibold)) - .foregroundStyle(primaryHeadingColor) - .fixedSize(horizontal: false, vertical: true) - } - .accessibilityElement(children: .combine) - } - - private var topAccentBar: some View { - HStack(spacing: 10) { - Capsule() - .fill(teal) - .frame(width: 64, height: 8) - - Capsule() - .fill(slate.opacity(colorScheme == .dark ? 0.42 : 0.20)) - .frame(width: 24, height: 8) - } - .accessibilityHidden(true) - } - - private func overviewStepRow(_ step: OverviewStep) -> some View { - HStack(alignment: .top, spacing: 14) { - Text("\(step.number)") - .font(.headline.weight(.bold).monospacedDigit()) - .foregroundStyle(.white) - .frame(width: 34, height: 34) - .background(teal, in: Circle()) - .accessibilityHidden(true) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 6) { - Text(L10n.tr(step.titleKey)) + Text(title) .font(.body.weight(.semibold)) .foregroundStyle(primaryHeadingColor) - - Text(L10n.tr(step.descriptionKey)) + Text(description) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + if !isComplete, let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .tint(teal) + .padding(.top, 4) + } } + Spacer(minLength: 0) } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background(cardBackground, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) - .overlay(cardStroke(in: RoundedRectangle(cornerRadius: 22, style: .continuous))) + .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) + .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) .accessibilityElement(children: .combine) + .accessibilityLabel(title) + .accessibilityValue(isComplete ? L10n.tr("Done") : "") } - private var pageIndicator: some View { - HStack(spacing: 10) { - pageDot(isActive: currentScreen == .welcome) - pageDot(isActive: currentScreen == .overview) + // MARK: - Add Device sheet (onboarding) + + private var addDeviceSheet: some View { + NavigationStack { + Form { + Section("Device ID") { + TextField("XXXXXXX-XXXXXXX-...", text: $newDeviceID) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + Button { + showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + } + Section("Name (optional)") { + TextField("e.g. My Laptop", text: $newDeviceName) + } + } + .sheet(isPresented: $showQRScanner) { + QRScannerView { scannedCode in + newDeviceID = scannedCode + } + } + .navigationTitle("Add Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { resetAddDeviceForm() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { addDevice() } + .disabled(newDeviceID.isEmpty) + } + } } - .padding(.vertical, 4) - .accessibilityElement(children: .ignore) - .accessibilityLabel(L10n.fmt("onboarding.accessibility.page", currentScreen.pageIndex + 1)) + .presentationDetents([.medium, .large]) } - private func pageDot(isActive: Bool) -> some View { - Circle() - .fill(isActive ? teal : slate.opacity(colorScheme == .dark ? 0.34 : 0.20)) - .frame(width: 10, height: 10) - .overlay( - Circle() - .stroke(teal.opacity(isActive ? 0 : 0.35), lineWidth: 1) - ) + private func addDevice() { + let id = newDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) + let name = newDeviceName.trimmingCharacters(in: .whitespacesAndNewlines) + if let err = syncthingManager.addDevice(id: id, name: name) { + present(error: err, fallbackTitle: L10n.tr("Could Not Add Device")) + } else { + resetAddDeviceForm() + } + } + + private func resetAddDeviceForm() { + newDeviceID = "" + newDeviceName = "" + showAddDevice = false + } + + private func present(error: String, fallbackTitle: String) { + alertMessage = SyncUserError.from(rawMessage: error, fallbackTitle: fallbackTitle).userVisibleDescription + showAlert = true } - private var primaryActionSection: some View { - VStack(spacing: 16) { - pageIndicator - .frame(maxWidth: .infinity) + // MARK: - Bottom bar + + private var bottomBar: some View { + VStack(spacing: 14) { + pageDots Button { handlePrimaryAction() } label: { - Text(actionButtonTitle) + Text(page == 0 ? L10n.tr("onboarding.cta.continue") : L10n.tr("onboarding.cta.openVaultSync")) .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .controlSize(.large) .tint(teal) .frame(maxWidth: .infinity) - .accessibilityHint(actionButtonHint) } } - private var actionButtonTitle: String { - currentScreen == .welcome - ? L10n.tr("onboarding.cta.continue") - : L10n.tr("onboarding.cta.openVaultSync") + private var pageDots: some View { + HStack(spacing: 10) { + ForEach(0..<2, id: \.self) { index in + Circle() + .fill(index == page ? teal : slate.opacity(colorScheme == .dark ? 0.34 : 0.20)) + .frame(width: 8, height: 8) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.fmt("onboarding.accessibility.page", page + 1)) } - private var actionButtonHint: String { - currentScreen == .welcome - ? L10n.tr("onboarding.accessibility.continueHint") - : L10n.tr("onboarding.accessibility.openVaultSyncHint") + private func handlePrimaryAction() { + if page == 0 { + withAnimation(.easeInOut(duration: 0.25)) { page = 1 } + } else { + hasCompletedOnboarding = true + } + } + + // MARK: - Shared chrome + + private var backgroundView: some View { + ZStack(alignment: .top) { + Color(uiColor: .systemGroupedBackground) + .ignoresSafeArea() + + Circle() + .fill(teal.opacity(colorScheme == .dark ? 0.14 : 0.08)) + .frame(width: 220, height: 220) + .blur(radius: 18) + .offset(x: -120, y: -70) + + Circle() + .fill(slate.opacity(colorScheme == .dark ? 0.10 : 0.05)) + .frame(width: 240, height: 240) + .blur(radius: 22) + .offset(x: 130, y: -110) + } + .accessibilityHidden(true) + } + + private func benefitRow(icon: String, textKey: String) -> some View { + HStack(alignment: .top, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(teal.opacity(colorScheme == .dark ? 0.22 : 0.14)) + .frame(width: 36, height: 36) + Image(systemName: icon) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(teal) + .accessibilityHidden(true) + } + Text(L10n.tr(textKey)) + .font(.body.weight(.semibold)) + .foregroundStyle(primaryHeadingColor) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + } + + private var topAccentBar: some View { + HStack(spacing: 10) { + Capsule() + .fill(teal) + .frame(width: 64, height: 8) + Capsule() + .fill(slate.opacity(colorScheme == .dark ? 0.42 : 0.20)) + .frame(width: 24, height: 8) + } + .accessibilityHidden(true) } private var titleFont: Font { @@ -306,19 +398,4 @@ struct OnboardingView: View { lineWidth: 1 ) } - - private func handlePrimaryAction() { - switch currentScreen { - case .welcome: - withAnimation(.easeInOut(duration: 0.25)) { - currentScreen = .overview - } - case .overview: - completeOnboarding() - } - } - - private func completeOnboarding() { - hasCompletedOnboarding = true - } } From ad943ada8f546d7961efd75c74f52bf451128ecb Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:33:08 +0200 Subject: [PATCH 08/22] feat(ui): dedicated Relay tab + canonical SubscribePlanPicker (Phase 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the three disconnected relay altitudes (paywall / Docker wall / engineer dump) with one progressively-disclosed Relay tab, and unify the subscribe UI. - SubscribePlanPicker: the single canonical subscription picker — yearly first and highlighted as "Best value", monthly below, restore, and prominent price / term / auto-renew disclosure with Terms of Use + Privacy Policy links (App Store 3.1.2(a)). Replaces the two previously duplicated, opposite-ordered blocks. - RelayHomeView: the new Relay tab. Not subscribed -> pitch + SubscribePlanPicker. Subscribed -> a status badge + a three-step spine (Subscribe -> run the server helper -> verify delivery) that links into RelayServerSetupView and RelayDiagnosticsView, finally cross-linking the funnel that had no links. - ContentView: add the Relay tab (Sync / Devices / Relay); the dashboard relay row and the one-time aha-moment now select the tab instead of presenting a sheet. - Delete CloudRelayUpsellView (superseded by the Relay tab). - SettingsView: list yearly before monthly to match the canonical order. Build + tests green. --- .../Views/CloudRelayUpsellView.swift | 179 ------------------ ios/VaultSync/Views/ContentView.swift | 31 +-- ios/VaultSync/Views/RelayHomeView.swift | 151 +++++++++++++++ ios/VaultSync/Views/SettingsView.swift | 8 +- ios/VaultSync/Views/SubscribePlanPicker.swift | 146 ++++++++++++++ 5 files changed, 321 insertions(+), 194 deletions(-) delete mode 100644 ios/VaultSync/Views/CloudRelayUpsellView.swift create mode 100644 ios/VaultSync/Views/RelayHomeView.swift create mode 100644 ios/VaultSync/Views/SubscribePlanPicker.swift diff --git a/ios/VaultSync/Views/CloudRelayUpsellView.swift b/ios/VaultSync/Views/CloudRelayUpsellView.swift deleted file mode 100644 index 1434f10..0000000 --- a/ios/VaultSync/Views/CloudRelayUpsellView.swift +++ /dev/null @@ -1,179 +0,0 @@ -import StoreKit -import SwiftUI - -/// In-context Cloud Relay offer, presented at the "aha moment" (right after the -/// first successful sync) and from a dashboard upgrade affordance. Lets the user -/// subscribe in place, then flows straight into the mandatory server setup so the -/// subscription actually delivers value. -struct CloudRelayUpsellView: View { - let syncthingManager: SyncthingManager - var subscriptionManager: SubscriptionManager - - @Environment(\.dismiss) private var dismiss - @State private var alertMessage: String? - @State private var showAlert = false - @State private var isRestoring = false - private let teal = Color.vaultTeal - - var body: some View { - List { - if subscriptionManager.isRelaySubscribed { - subscribedContent - } else { - pitchContent - } - } - .navigationTitle(L10n.tr("Cloud Relay")) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(subscriptionManager.isRelaySubscribed ? L10n.tr("Done") : L10n.tr("Not Now")) { dismiss() } - } - } - .alert("Error", isPresented: $showAlert) { - Button("OK") { } - } message: { - Text(alertMessage ?? "") - } - } - - // MARK: - Pitch (not yet subscribed) - - @ViewBuilder - private var pitchContent: some View { - Section { - VStack(alignment: .leading, spacing: 10) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.largeTitle) - .foregroundStyle(teal) - .accessibilityHidden(true) - Text(L10n.tr("Make incoming sync instant")) - .font(.title3.weight(.bold)) - Text(L10n.tr("Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first.")) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .padding(.vertical, 4) - } - - Section { - benefitRow(icon: "bolt.fill", L10n.tr("Near-instant server → iPhone updates")) - benefitRow(icon: "lock.shield.fill", L10n.tr("The relay only sends a wake-up — it never sees your notes")) - benefitRow(icon: "xmark.circle.fill", L10n.tr("Cancel anytime in Settings → Subscriptions")) - } - - Section { - if subscriptionManager.monthlyProduct != nil || subscriptionManager.yearlyProduct != nil { - if let yearly = subscriptionManager.yearlyProduct { - subscribeButton(for: yearly, label: L10n.tr("Subscribe Yearly"), accent: true) - } - if let monthly = subscriptionManager.monthlyProduct { - subscribeButton(for: monthly, label: L10n.tr("Subscribe Monthly")) - } - } else { - Text("Subscription unavailable") - .foregroundStyle(.secondary) - } - - Button { - Task { - isRestoring = true - await subscriptionManager.restorePurchases() - isRestoring = false - } - } label: { - HStack { - Text("Restore Purchases") - if isRestoring { - Spacer() - ProgressView().controlSize(.small) - } - } - } - .disabled(isRestoring) - } footer: { - subscriptionDetailsFooter - } - } - - // MARK: - Subscribed (flow into server setup) - - @ViewBuilder - private var subscribedContent: some View { - Section { - Label(L10n.tr("You’re subscribed to Cloud Relay"), systemImage: "checkmark.seal.fill") - .foregroundStyle(Color.statusSuccess) - .font(.headline) - } - Section { - Text(L10n.tr("One step left: Cloud Relay only delivers wake-ups once a small helper is running on your server.")) - .font(.subheadline) - NavigationLink { - RelayServerSetupView(isDelivering: subscriptionManager.relayDeliveryConfirmed) - } label: { - Label(L10n.tr("Set Up Your Server"), systemImage: "server.rack") - } - } header: { - Text(L10n.tr("Finish setup")) - } - } - - // MARK: - Helpers - - private func benefitRow(icon: String, _ text: String) -> some View { - Label { - Text(text).font(.subheadline) - } icon: { - Image(systemName: icon).foregroundStyle(teal) - } - .accessibilityElement(children: .combine) - } - - @ViewBuilder - private func subscribeButton(for product: Product, label: String, accent: Bool = false) -> some View { - Button { - Task { - do { - let deviceIDs = syncthingManager.devices.map(\.deviceID) - try await subscriptionManager.purchase(product, homeserverDeviceIDs: deviceIDs) - // On success the view switches to subscribedContent, which - // surfaces the server-setup step — no extra navigation needed. - } catch { - alertMessage = SyncUserError.from( - error: error, - fallbackTitle: L10n.tr("Purchase Failed") - ).userVisibleDescription - showAlert = true - } - } - } label: { - HStack { - Text(label) - .fontWeight(accent ? .semibold : .regular) - Spacer() - if subscriptionManager.purchaseInProgress { - ProgressView().controlSize(.small) - } else { - Text(subscriptionManager.priceText(for: product)) - .foregroundStyle(accent ? Color.vaultTeal : Color.secondary) - } - } - } - .disabled(subscriptionManager.purchaseInProgress) - } - - @ViewBuilder - private var subscriptionDetailsFooter: some View { - VStack(alignment: .leading, spacing: 2) { - if let monthly = subscriptionManager.monthlyProduct { - Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: monthly))) - } - if let yearly = subscriptionManager.yearlyProduct { - Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: yearly))) - } - Text("Auto-renews until canceled. Cancel anytime in Settings → Subscriptions.") - Text(L10n.tr("Cloud Relay needs a one-time helper on your server, shown right after you subscribe.")) - } - .font(.caption) - } -} diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index d8cf707..64d27d2 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -14,7 +14,6 @@ struct ContentView: View { @State private var pendingShareInFlight: Set = [] @State private var isRescanning = false @State private var pendingFilterSheetFolder: SyncthingManager.FolderInfo? - @State private var showRelayUpsell = false private static let relayUpsellShownKey = "relay-upsell-shown" @@ -33,6 +32,7 @@ struct ContentView: View { private enum Tab: Hashable { case sync case devices + case relay } @State private var selectedTab: Tab = .sync @@ -50,6 +50,12 @@ struct ContentView: View { Label(L10n.tr("Devices"), systemImage: "laptopcomputer.and.iphone") } .tag(Tab.devices) + + relayTab + .tabItem { + Label(L10n.tr("Relay"), systemImage: "antenna.radiowaves.left.and.right") + } + .tag(Tab.relay) } // Sheets/alerts live at the shell level so cross-tab triggers (e.g. an // "Add Device" remediation tapped from a Sync-tab issue) present @@ -76,14 +82,6 @@ struct ContentView: View { } } } - .sheet(isPresented: $showRelayUpsell) { - NavigationStack { - CloudRelayUpsellView( - syncthingManager: syncthingManager, - subscriptionManager: subscriptionManager - ) - } - } .onChange(of: syncthingManager.pendingFolders, initial: true) { _, pending in autoAcceptPendingShares(pending) } @@ -144,6 +142,17 @@ struct ContentView: View { } } + /// The Relay tab — the unified Cloud Relay home (pitch + subscribe, or the + /// cross-linked setup/verify funnel once subscribed). + private var relayTab: some View { + NavigationStack { + RelayHomeView( + syncthingManager: syncthingManager, + subscriptionManager: subscriptionManager + ) + } + } + // MARK: - Cloud Relay Upsell /// Presents the Cloud Relay offer once, at the "aha moment": the first time @@ -155,7 +164,7 @@ struct ContentView: View { guard syncthingManager.lastSyncTime != nil else { return } guard !UserDefaults.standard.bool(forKey: Self.relayUpsellShownKey) else { return } UserDefaults.standard.set(true, forKey: Self.relayUpsellShownKey) - showRelayUpsell = true + selectedTab = .relay } // MARK: - Auto-Accept Pending Shares @@ -245,7 +254,7 @@ struct ContentView: View { .accessibilityElement(children: .combine) } else if !syncthingManager.folders.isEmpty { Button { - showRelayUpsell = true + selectedTab = .relay } label: { HStack { Image(systemName: "antenna.radiowaves.left.and.right") diff --git a/ios/VaultSync/Views/RelayHomeView.swift b/ios/VaultSync/Views/RelayHomeView.swift new file mode 100644 index 0000000..0cb0ba7 --- /dev/null +++ b/ios/VaultSync/Views/RelayHomeView.swift @@ -0,0 +1,151 @@ +import SwiftUI + +/// The Relay tab — the single home for the paid Cloud Relay feature. Replaces the +/// three disconnected altitudes (marketing paywall, Docker setup wall, engineer +/// diagnostics dump) with one progressively-disclosed flow that cross-links them: +/// +/// - Not subscribed: plain-language pitch + the canonical `SubscribePlanPicker`. +/// - Subscribed: a status header + a three-step spine (Subscribe → run the +/// server helper → verify delivery) whose steps link into Server Setup and +/// Diagnostics, finally closing the funnel that previously had no links. +struct RelayHomeView: View { + let syncthingManager: SyncthingManager + var subscriptionManager: SubscriptionManager + + private var deviceIDs: [String] { syncthingManager.devices.map(\.deviceID) } + private var isDelivering: Bool { subscriptionManager.relayDeliveryConfirmed } + + var body: some View { + List { + if subscriptionManager.isRelaySubscribed { + subscribedSections + } else { + pitchSections + } + } + .navigationTitle(L10n.tr("Cloud Relay")) + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Not subscribed + + @ViewBuilder + private var pitchSections: some View { + Section { + VStack(alignment: .leading, spacing: VaultSpacing.s) { + Image(systemName: "antenna.radiowaves.left.and.right") + .font(.largeTitle) + .foregroundStyle(Color.vaultAccent) + .accessibilityHidden(true) + Text(L10n.tr("Make incoming sync instant")) + .font(.title3.weight(.bold)) + Text(L10n.tr("Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first.")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, VaultSpacing.xs) + } + + Section { + benefitRow(icon: "bolt.fill", L10n.tr("Near-instant server → iPhone updates")) + benefitRow(icon: "lock.shield.fill", L10n.tr("The relay only sends a wake-up — it never sees your notes")) + benefitRow(icon: "xmark.circle.fill", L10n.tr("Cancel anytime in Settings → Subscriptions")) + } + + Section { + SubscribePlanPicker( + subscriptionManager: subscriptionManager, + homeserverDeviceIDs: deviceIDs + ) + } footer: { + Text(L10n.tr("Cloud Relay needs a one-time helper on your server, shown right after you subscribe.")) + } + } + + // MARK: - Subscribed + + @ViewBuilder + private var subscribedSections: some View { + Section { + StatusBadge( + isDelivering ? .synced : .attention, + text: isDelivering + ? L10n.tr("Cloud Relay active") + : L10n.tr("Finish server setup") + ) + .font(.headline) + Text(isDelivering + ? L10n.tr("Wake-ups are being delivered — incoming changes sync the moment they happen.") + : L10n.tr("You’re subscribed. Cloud Relay only delivers wake-ups once the helper is running on your server.")) + .font(.subheadline) + .foregroundStyle(.secondary) + if let expiry = subscriptionManager.subscriptionExpiryDate { + DetailRow(title: L10n.tr("Renews"), value: expiry.formatted(date: .abbreviated, time: .omitted)) + } + } + + Section { + spineRow(done: true, title: L10n.tr("Subscribe"), detail: L10n.tr("Your Cloud Relay subscription is active.")) + + NavigationLink { + RelayServerSetupView(isDelivering: isDelivering) + } label: { + spineLabel( + done: isDelivering, + title: L10n.tr("Run the server helper"), + detail: L10n.tr("Start vaultsync-notify on your server — copyable command inside.") + ) + } + + NavigationLink { + RelayDiagnosticsView( + syncthingManager: syncthingManager, + subscriptionManager: subscriptionManager + ) + } label: { + spineLabel( + done: isDelivering, + title: L10n.tr("Verify delivery"), + detail: L10n.tr("Check relay health, push token, and per-device provisioning.") + ) + } + } header: { + Text(L10n.tr("Finish setup")) + } footer: { + Text(L10n.tr("Manage or cancel your subscription anytime in Settings → Subscriptions.")) + } + } + + // MARK: - Helpers + + private func benefitRow(icon: String, _ text: String) -> some View { + Label { + Text(text).font(.subheadline) + } icon: { + Image(systemName: icon).foregroundStyle(Color.vaultAccent) + } + .accessibilityElement(children: .combine) + } + + private func spineRow(done: Bool, title: String, detail: String) -> some View { + spineLabel(done: done, title: title, detail: detail) + } + + private func spineLabel(done: Bool, title: String, detail: String) -> some View { + HStack(spacing: VaultSpacing.m) { + Image(systemName: done ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(done ? Color.statusSuccess : Color.statusInactive) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.body) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + .accessibilityValue(done ? L10n.tr("Done") : "") + } +} diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index ea47284..99c47dd 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -266,14 +266,14 @@ struct SettingsView: View { // Subscription details (required by App Store Review). Price comes // from StoreKit so it is correct per storefront — never hard-coded. VStack(alignment: .leading, spacing: 2) { - if let monthly = subscriptionManager.monthlyProduct { - Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: monthly))) - .font(.caption) - } if let yearly = subscriptionManager.yearlyProduct { Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: yearly))) .font(.caption) } + if let monthly = subscriptionManager.monthlyProduct { + Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: monthly))) + .font(.caption) + } if subscriptionManager.monthlyProduct == nil, subscriptionManager.yearlyProduct == nil { Text(L10n.tr("Cloud Relay subscription")) .font(.caption) diff --git a/ios/VaultSync/Views/SubscribePlanPicker.swift b/ios/VaultSync/Views/SubscribePlanPicker.swift new file mode 100644 index 0000000..4e6d1b0 --- /dev/null +++ b/ios/VaultSync/Views/SubscribePlanPicker.swift @@ -0,0 +1,146 @@ +import StoreKit +import SwiftUI + +/// The single canonical Cloud Relay subscription picker. Yearly is listed first +/// and highlighted as the recommended plan; monthly sits below; restore and a +/// prominent, compliant price / term / auto-renew disclosure with Terms & Privacy +/// links follow (App Store guideline 3.1.2(a)). +/// +/// Used by both the Relay tab and the in-context upsell so the two can never +/// drift in ordering or copy again — replacing the previously duplicated blocks +/// that listed the plans in opposite orders. +struct SubscribePlanPicker: View { + var subscriptionManager: SubscriptionManager + let homeserverDeviceIDs: [String] + + @State private var alertMessage: String? + @State private var showAlert = false + @State private var isRestoring = false + + var body: some View { + VStack(alignment: .leading, spacing: VaultSpacing.m) { + if subscriptionManager.yearlyProduct == nil && subscriptionManager.monthlyProduct == nil { + if subscriptionManager.isLoadingProduct { + HStack(spacing: VaultSpacing.s) { + ProgressView() + Text(L10n.tr("Loading plans…")).foregroundStyle(.secondary) + } + } else { + Text(L10n.tr("Subscription unavailable")).foregroundStyle(.secondary) + } + } else { + if let yearly = subscriptionManager.yearlyProduct { + planCard(product: yearly, title: L10n.tr("Yearly"), recommended: true) + } + if let monthly = subscriptionManager.monthlyProduct { + planCard(product: monthly, title: L10n.tr("Monthly"), recommended: false) + } + } + + Button { + Task { + isRestoring = true + await subscriptionManager.restorePurchases() + isRestoring = false + } + } label: { + HStack { + Text(L10n.tr("Restore Purchases")) + if isRestoring { + Spacer() + ProgressView().controlSize(.small) + } + } + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + .foregroundStyle(Color.vaultAccent) + .disabled(isRestoring) + + complianceFooter + } + .frame(maxWidth: .infinity, alignment: .leading) + .alert("Error", isPresented: $showAlert) { + Button("OK") { } + } message: { + Text(alertMessage ?? "") + } + } + + private func planCard(product: Product, title: String, recommended: Bool) -> some View { + Button { + purchase(product) + } label: { + HStack(spacing: VaultSpacing.m) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: VaultSpacing.s) { + Text(title) + .font(.headline) + if recommended { + Text(L10n.tr("Best value")) + .font(.caption2.weight(.bold)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.vaultAccent, in: Capsule()) + .foregroundStyle(.white) + } + } + Text(subscriptionManager.priceText(for: product)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: VaultSpacing.s) + if subscriptionManager.purchaseInProgress { + ProgressView() + } else { + Image(systemName: "chevron.right") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } + } + .padding(VaultSpacing.l) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color(.secondarySystemBackground), + in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous) + .stroke(recommended ? Color.vaultAccent : Color.clear, lineWidth: 2) + } + } + .buttonStyle(.plain) + .disabled(subscriptionManager.purchaseInProgress) + .accessibilityElement(children: .combine) + .accessibilityLabel(L10n.fmt("%1$@ — %2$@", title, subscriptionManager.priceText(for: product))) + .accessibilityHint(L10n.tr("Starts a subscription purchase.")) + } + + private var complianceFooter: some View { + VStack(alignment: .leading, spacing: VaultSpacing.xs) { + Text(L10n.tr("Auto-renews until canceled. Cancel anytime in Settings → Subscriptions.")) + HStack(spacing: VaultSpacing.l) { + ExternalLinkButton(titleKey: "Terms of Use", url: DocURL.termsOfUse) + ExternalLinkButton(titleKey: "Privacy Policy", url: DocURL.privacyPolicy) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + + private func purchase(_ product: Product) { + Task { + do { + try await subscriptionManager.purchase(product, homeserverDeviceIDs: homeserverDeviceIDs) + } catch { + alertMessage = SyncUserError.from( + error: error, + fallbackTitle: L10n.tr("Purchase Failed") + ).userVisibleDescription + showAlert = true + } + } + } +} From b01e83daa57857a7ca601eafb79cc4c618d4e2ba Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:38:54 +0200 Subject: [PATCH 09/22] feat(ui): conflict resolution affordance, honest rescan progress, status motion (Phase 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConflictDiffView: replace the tiny .caption2 bottom-bar icons (which looked like a fake tab bar) with a real bottom action bar of full-width >=44pt buttons. ALL three actions now confirm before mutating files — "Keep Both" previously mutated with no confirmation. Keep This / Keep Other are destructive-styled and confirmed; Keep Both confirms with a non-destructive message. Labels are now localized; the diff legend uses the semantic status tokens. - ContentView: the Vault Detail rescan no longer fakes completion with a fixed 2s Task.sleep — the busy state now reflects the folder's real "scanning" engine state. - SyncStatusHeader: add contentTransition(.symbolEffect(.replace)) so the status glyph morphs (e.g. syncing arrows -> checkmark) on completion; .pulse while syncing stays, both reduce-motion-safe. Build + tests green. --- ios/VaultSync/Views/ConflictDiffView.swift | 96 +++++++++++----------- ios/VaultSync/Views/ContentView.swift | 20 ++--- ios/VaultSync/Views/DesignSystem.swift | 1 + 3 files changed, 56 insertions(+), 61 deletions(-) diff --git a/ios/VaultSync/Views/ConflictDiffView.swift b/ios/VaultSync/Views/ConflictDiffView.swift index bde71a1..1232f14 100644 --- a/ios/VaultSync/Views/ConflictDiffView.swift +++ b/ios/VaultSync/Views/ConflictDiffView.swift @@ -89,49 +89,10 @@ struct ConflictDiffView: View { .accessibilityLabel(L10n.tr("More actions")) } } - ToolbarItemGroup(placement: .bottomBar) { - Button { - confirmAction(.keepThis) - } label: { - VStack(spacing: 2) { - Image(systemName: "iphone") - .accessibilityHidden(true) - Text("Keep This") - .font(.caption2) - } - } - .accessibilityLabel("Keep this device version") - .accessibilityHint("Discards the version from the other device.") - - Spacer() - - Button { - executeAction(.keepBoth) - } label: { - VStack(spacing: 2) { - Image(systemName: "doc.on.doc") - .accessibilityHidden(true) - Text("Keep Both") - .font(.caption2) - } - } - .accessibilityLabel("Keep both versions") - .accessibilityHint("Keeps your local file and renames the other device's file.") - - Spacer() - - Button { - confirmAction(.keepOther) - } label: { - VStack(spacing: 2) { - Image(systemName: "laptopcomputer") - .accessibilityHidden(true) - Text("Keep Other") - .font(.caption2) - } - } - .accessibilityLabel("Keep other device version") - .accessibilityHint("Overwrites your local file with the version from the other device.") + } + .safeAreaInset(edge: .bottom) { + if !isLoading && loadError == nil { + resolutionBar } } .alert("Error", isPresented: $showAlert) { @@ -145,7 +106,7 @@ struct ConflictDiffView: View { titleVisibility: .visible, presenting: actionToConfirm ) { action in - Button(confirmButtonTitle(for: action), role: .destructive) { + Button(confirmButtonTitle(for: action), role: action == .keepBoth ? nil : .destructive) { executeAction(action) } Button("Cancel", role: .cancel) { } @@ -187,6 +148,47 @@ struct ConflictDiffView: View { } } + /// The bottom resolution bar: full-width, ≥44pt buttons (replacing the tiny + /// caption2 tab-bar-style icons). Every action routes through confirmAction so + /// all three confirm before mutating files — including Keep Both, which used + /// to mutate with no confirmation. + private var resolutionBar: some View { + VStack(spacing: VaultSpacing.s) { + Button { + confirmAction(.keepThis) + } label: { + Label(L10n.tr("Keep This Device’s Version"), systemImage: "iphone") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityHint(L10n.tr("Discards the version from the other device.")) + + HStack(spacing: VaultSpacing.s) { + Button { + confirmAction(.keepBoth) + } label: { + Text(L10n.tr("Keep Both")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityHint(L10n.tr("Keeps your local file and renames the other device's file.")) + + Button(role: .destructive) { + confirmAction(.keepOther) + } label: { + Text(L10n.tr("Keep Other")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .accessibilityHint(L10n.tr("Overwrites your local file with the version from the other device.")) + } + } + .controlSize(.large) + .tint(.vaultAccent) + .padding(VaultSpacing.l) + .background(.bar) + } + /// The body of the comparison — line-by-line diff (with a colour/sign legend) /// or the two side-by-side file panes. Extracted from `body` to keep each /// view expression small enough for the Swift type-checker. @@ -221,9 +223,9 @@ struct ConflictDiffView: View { private var diffLegend: some View { HStack(spacing: 12) { Label(L10n.tr("Other Device"), systemImage: "plus") - .foregroundStyle(Color(uiColor: .systemGreen)) + .foregroundStyle(Color.statusSuccess) Label(L10n.tr("This Device"), systemImage: "minus") - .foregroundStyle(Color(uiColor: .systemRed)) + .foregroundStyle(Color.statusError) } .font(.caption2) .padding(.horizontal) @@ -317,7 +319,7 @@ struct ConflictDiffView: View { case .keepOther: return L10n.tr("This will permanently discard your local version.") case .keepBoth: - return "" + return L10n.tr("Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded.") } } diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 64d27d2..8a1e72e 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -12,7 +12,6 @@ struct ContentView: View { @State private var showAlert = false @State private var pendingShareFailures: [String: SyncUserError] = [:] @State private var pendingShareInFlight: Set = [] - @State private var isRescanning = false @State private var pendingFilterSheetFolder: SyncthingManager.FolderInfo? private static let relayUpsellShownKey = "relay-upsell-shown" @@ -773,34 +772,27 @@ struct ContentView: View { } Section { + // Honest progress: the busy state reflects the folder's REAL + // scan state from the engine, not a fixed timer. + let isScanning = status?.state == "scanning" Button { - isRescanning = true if let err = syncthingManager.rescanFolder(id: folder.id) { alertMessage = mappedError(err, fallbackTitle: L10n.tr("Rescan Failed")).userVisibleDescription showAlert = true - isRescanning = false - } else { - Task { - try? await Task.sleep(for: .seconds(2)) - isRescanning = false - } } } label: { HStack { - Text(isRescanning ? "Rescanning…" : "Rescan Vault") + Text(isScanning ? "Rescanning…" : "Rescan Vault") Spacer() - if isRescanning { + if isScanning { ProgressView() .controlSize(.small) } } } - .disabled(isRescanning) + .disabled(isScanning) } } - .onDisappear { - isRescanning = false - } .navigationTitle(folder.label.isEmpty ? folder.id : folder.label) .navigationBarTitleDisplayMode(.inline) .onAppear { diff --git a/ios/VaultSync/Views/DesignSystem.swift b/ios/VaultSync/Views/DesignSystem.swift index ab2c335..09528ce 100644 --- a/ios/VaultSync/Views/DesignSystem.swift +++ b/ios/VaultSync/Views/DesignSystem.swift @@ -219,6 +219,7 @@ struct SyncStatusHeader: View { Image(systemName: status.symbolName) .font(.title2) .foregroundStyle(status.tint) + .contentTransition(.symbolEffect(.replace)) .symbolEffect(.pulse, isActive: status == .syncing && !reduceMotion) } } From e1cb82eb0b987c8f08bbcb84515cfd5d5c3eb51b Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 17:42:54 +0200 Subject: [PATCH 10/22] feat(ui): widget VoiceOver, L10n fallback, design-token lint guardrail (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Widget: add VoiceOver support (it had zero) — decorative status glyphs are hidden and each widget family is exposed as one combined, readable element (status + last sync + files + action). - L10n.tr: add a `value: key` fallback so a missing translation renders readable English instead of the bare key, matching the widget's helper. - Add ios/scripts/design-token-lint.sh — fails if views reintroduce raw status colors (.red/.green/.orange/.blue) or hardcoded literals instead of Theme.swift tokens — and wire it as a fast CI gate before the macOS build. The guardrail immediately caught two missed .orange usages in ContentView's Obsidian section, now retinted to the attention token. Lint passes; build green. Note: the bulk de/es/zh translation of the newly added (and the pre-existing 44 unlocalized) strings is a separate translation pass — they are now all extractable keys and fall back to English meanwhile. --- .github/workflows/ci.yml | 13 +++++++++ ios/VaultSync/Resources/L10n.swift | 6 ++-- ios/VaultSync/Views/ContentView.swift | 4 +-- ios/VaultSyncWidget/VaultSyncWidget.swift | 5 ++++ ios/scripts/design-token-lint.sh | 35 +++++++++++++++++++++++ 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100755 ios/scripts/design-token-lint.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f316b5c..13cb335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,18 @@ jobs: working-directory: notify run: go test ./... -count=1 + design-lint: + name: Design Token Lint + runs-on: ubuntu-latest # pure bash/grep guardrail; no Xcode needed + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - name: Check design tokens + # Fails if views reintroduce raw status colors instead of Theme.swift + # tokens — keeps the redesign's single source of truth from eroding. + run: ios/scripts/design-token-lint.sh + build: name: Build & Test runs-on: macos-26 # Xcode 26.2 default, iOS 26 SDK required for BGContinuedProcessingTask @@ -71,6 +83,7 @@ jobs: needs: - go-tests - notify-tests + - design-lint steps: - uses: actions/checkout@v6 diff --git a/ios/VaultSync/Resources/L10n.swift b/ios/VaultSync/Resources/L10n.swift index e33b20a..991ba33 100644 --- a/ios/VaultSync/Resources/L10n.swift +++ b/ios/VaultSync/Resources/L10n.swift @@ -1,11 +1,13 @@ import Foundation enum L10n { + /// `value: key` means a missing translation falls back to the (English) key + /// text instead of rendering the bare key — matching the widget's helper. static func tr(_ key: String) -> String { - NSLocalizedString(key, comment: "") + NSLocalizedString(key, tableName: nil, bundle: .main, value: key, comment: "") } static func fmt(_ key: String, _ args: CVarArg...) -> String { - String(format: NSLocalizedString(key, comment: ""), locale: Locale.current, arguments: args) + String(format: tr(key), locale: Locale.current, arguments: args) } } diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 8a1e72e..25d600e 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -433,12 +433,12 @@ struct ContentView: View { vaultManager.needsReconnect ? "Obsidian access expired" : "Obsidian folder not connected", systemImage: "folder.badge.questionmark" ) - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) if let issue = vaultManager.accessIssue { Text(issue.message) .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(Color.statusAttention) Text(issue.remediation) .font(.caption2) .foregroundStyle(.secondary) diff --git a/ios/VaultSyncWidget/VaultSyncWidget.swift b/ios/VaultSyncWidget/VaultSyncWidget.swift index 42567c4..06406a0 100644 --- a/ios/VaultSyncWidget/VaultSyncWidget.swift +++ b/ios/VaultSyncWidget/VaultSyncWidget.swift @@ -155,6 +155,7 @@ private struct VaultSyncWidgetEntryView: View { } .widgetURL(VaultSyncWidgetConstants.syncURL) .containerBackground(.fill.tertiary, for: .widget) + .accessibilityElement(children: .combine) } private var mediumWidget: some View { @@ -197,6 +198,7 @@ private struct VaultSyncWidgetEntryView: View { } .widgetURL(VaultSyncWidgetConstants.syncURL) .containerBackground(.fill.tertiary, for: .widget) + .accessibilityElement(children: .combine) } private var accessoryWidget: some View { @@ -204,6 +206,7 @@ private struct VaultSyncWidgetEntryView: View { HStack(spacing: 6) { Image(systemName: entry.snapshot.statusSymbol) .foregroundStyle(entry.snapshot.statusColor) + .accessibilityHidden(true) Text(entry.snapshot.statusLabel) .font(.caption.weight(.semibold)) .lineLimit(1) @@ -218,12 +221,14 @@ private struct VaultSyncWidgetEntryView: View { } .widgetURL(VaultSyncWidgetConstants.syncURL) .containerBackground(.fill.tertiary, for: .widget) + .accessibilityElement(children: .combine) } private var statusRow: some View { HStack(spacing: 8) { Image(systemName: entry.snapshot.statusSymbol) .foregroundStyle(entry.snapshot.statusColor) + .accessibilityHidden(true) Text(entry.snapshot.statusLabel) .font(.headline) .lineLimit(1) diff --git a/ios/scripts/design-token-lint.sh b/ios/scripts/design-token-lint.sh new file mode 100755 index 0000000..c05fa1c --- /dev/null +++ b/ios/scripts/design-token-lint.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Design-token lint guardrail. +# +# Fails if SwiftUI views use raw system status colors (.red/.green/.orange/.blue) +# or hardcoded color literals (Color(red:…), Color(.systemGreen), …) instead of +# the semantic tokens defined in Theme.swift. This keeps the redesign's single +# source of truth — one accent + the .statusSuccess/.statusAttention/.statusError/ +# .statusInfo/.statusInactive palette — from eroding back into per-file literals. +# +# Allowed: .white/.black/.clear/.primary/.secondary/.tertiary and the vault* / +# status* tokens. Theme.swift itself is excluded (it defines the tokens). +# +# Usage: ios/scripts/design-token-lint.sh (exit 0 = clean, 1 = violations) + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DIRS=("$ROOT/VaultSync/Views" "$ROOT/VaultSync/App" "$ROOT/VaultSyncWidget") + +# Raw status colors inside a color modifier, bare Color., hardcoded RGB, +# and UIColor.system bridges. +PATTERN='(foregroundStyle|foregroundColor|tint|fill|background)\(\s*\.(red|green|orange|blue)\b|Color\.(red|green|orange|blue)\b|Color\(red:|Color\(uiColor: ?\.system(Red|Green|Orange|Blue)|Color\(\.system(Red|Green|Orange|Blue)' + +hits="$(grep -rnE "$PATTERN" "${DIRS[@]}" --include='*.swift' 2>/dev/null || true)" + +if [ -n "$hits" ]; then + echo "❌ Design-token lint failed — use Theme.swift tokens instead of raw colors:" + echo " (.statusSuccess / .statusAttention / .statusError / .statusInfo / .statusInactive / .vaultAccent)" + echo "" + echo "$hits" + exit 1 +fi + +echo "✅ Design-token lint passed — no raw status/hardcoded colors in views." From 7a74165642d490eada013f0a73b2016c4bf679e9 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 18:51:48 +0200 Subject: [PATCH 11/22] =?UTF-8?q?refactor(ui):=20rework=20Relay=20tab=20?= =?UTF-8?q?=E2=80=94=20distinct=20states,=20reframed=20pitch,=20Settings?= =?UTF-8?q?=20relay=20removed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the Relay tab content: - The Relay tab now has two deliberately different shapes: - Not subscribed: a decluttered, focused pitch (single hero + one honest framing line) instead of the busy icon-title-paragraph + three benefit rows. Framing repositions Relay as a tiny private wake-up on top of the already-free P2P sync — explicitly "not cloud storage" — so it doesn't read as an Obsidian Sync competitor. - Subscribed: an operational control center. Activation is the real lever, so when wake-ups aren't arriving the server-setup step is front and center; otherwise a calm "active" status. Diagnostics, server setup, and Manage Subscription all live here now. - SubscribePlanPicker: Monthly is listed first (low-commitment entry, avoids annual sticker shock); Yearly follows, framed as "Save N%" (computed from StoreKit prices) rather than a scary annual total. Adds a no-device guard — with no paired homeserver it guides to the Devices tab instead of letting the user pay for nothing to wake. - Delete the entire Cloud Relay section from Settings; the subscribe guard, manage subscription, server setup, and diagnostics it held are absorbed by the Relay tab. The tab refreshes relay diagnostics on appear. Build + full test suite + design-token lint green. --- ios/VaultSync/Views/RelayHomeView.swift | 192 ++++++------ ios/VaultSync/Views/SettingsView.swift | 276 +----------------- ios/VaultSync/Views/SubscribePlanPicker.swift | 60 +++- 3 files changed, 158 insertions(+), 370 deletions(-) diff --git a/ios/VaultSync/Views/RelayHomeView.swift b/ios/VaultSync/Views/RelayHomeView.swift index 0cb0ba7..a1c12de 100644 --- a/ios/VaultSync/Views/RelayHomeView.swift +++ b/ios/VaultSync/Views/RelayHomeView.swift @@ -1,13 +1,18 @@ +import StoreKit import SwiftUI +import UIKit -/// The Relay tab — the single home for the paid Cloud Relay feature. Replaces the -/// three disconnected altitudes (marketing paywall, Docker setup wall, engineer -/// diagnostics dump) with one progressively-disclosed flow that cross-links them: +/// The Relay tab — the single home for the paid Cloud Relay feature. It has two +/// deliberately different shapes: /// -/// - Not subscribed: plain-language pitch + the canonical `SubscribePlanPicker`. -/// - Subscribed: a status header + a three-step spine (Subscribe → run the -/// server helper → verify delivery) whose steps link into Server Setup and -/// Diagnostics, finally closing the funnel that previously had no links. +/// - **Not subscribed:** a focused, decluttered pitch that frames Relay honestly +/// (a tiny private wake-up on top of the already-free P2P sync — NOT cloud +/// storage), then the canonical `SubscribePlanPicker`. +/// - **Subscribed:** an operational control center. Activation is the real lever +/// (most subscriptions never finish the server setup), so when wake-ups aren't +/// arriving yet the setup step is front and center; otherwise it's a calm +/// "active" status plus manage / diagnostics. Relay diagnostics live here now — +/// the old Settings → Cloud Relay section has been removed. struct RelayHomeView: View { let syncthingManager: SyncthingManager var subscriptionManager: SubscriptionManager @@ -18,38 +23,49 @@ struct RelayHomeView: View { var body: some View { List { if subscriptionManager.isRelaySubscribed { - subscribedSections + subscribedContent } else { - pitchSections + pitchContent } } .navigationTitle(L10n.tr("Cloud Relay")) .navigationBarTitleDisplayMode(.inline) + .task { + await subscriptionManager.refreshRelayDiagnostics(homeserverDeviceIDs: deviceIDs) + } } // MARK: - Not subscribed @ViewBuilder - private var pitchSections: some View { + private var pitchContent: some View { Section { - VStack(alignment: .leading, spacing: VaultSpacing.s) { + VStack(alignment: .leading, spacing: VaultSpacing.m) { Image(systemName: "antenna.radiowaves.left.and.right") - .font(.largeTitle) + .font(.system(size: 34)) .foregroundStyle(Color.vaultAccent) .accessibilityHidden(true) - Text(L10n.tr("Make incoming sync instant")) - .font(.title3.weight(.bold)) - Text(L10n.tr("Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first.")) + + Text(L10n.tr("Instant sync, still private")) + .font(.title2.weight(.bold)) + + Text(L10n.tr("Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed.")) .font(.subheadline) .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Label { + Text(L10n.tr("Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage.")) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "lock.shield.fill").foregroundStyle(Color.statusSuccess) + } + .padding(.top, VaultSpacing.xs) } - .padding(.vertical, VaultSpacing.xs) - } - - Section { - benefitRow(icon: "bolt.fill", L10n.tr("Near-instant server → iPhone updates")) - benefitRow(icon: "lock.shield.fill", L10n.tr("The relay only sends a wake-up — it never sees your notes")) - benefitRow(icon: "xmark.circle.fill", L10n.tr("Cancel anytime in Settings → Subscriptions")) + .padding(.vertical, VaultSpacing.s) + .accessibilityElement(children: .combine) } Section { @@ -57,44 +73,62 @@ struct RelayHomeView: View { subscriptionManager: subscriptionManager, homeserverDeviceIDs: deviceIDs ) - } footer: { - Text(L10n.tr("Cloud Relay needs a one-time helper on your server, shown right after you subscribe.")) } } // MARK: - Subscribed @ViewBuilder - private var subscribedSections: some View { + private var subscribedContent: some View { Section { - StatusBadge( - isDelivering ? .synced : .attention, - text: isDelivering - ? L10n.tr("Cloud Relay active") - : L10n.tr("Finish server setup") - ) - .font(.headline) - Text(isDelivering - ? L10n.tr("Wake-ups are being delivered — incoming changes sync the moment they happen.") - : L10n.tr("You’re subscribed. Cloud Relay only delivers wake-ups once the helper is running on your server.")) - .font(.subheadline) - .foregroundStyle(.secondary) - if let expiry = subscriptionManager.subscriptionExpiryDate { - DetailRow(title: L10n.tr("Renews"), value: expiry.formatted(date: .abbreviated, time: .omitted)) + VStack(alignment: .leading, spacing: VaultSpacing.s) { + StatusBadge( + isDelivering ? .synced : .attention, + text: isDelivering + ? L10n.tr("Cloud Relay active") + : L10n.tr("One step left to activate") + ) + .font(.headline) + + Text(isDelivering + ? L10n.tr("Wake-ups are being delivered — changes from your other devices arrive instantly.") + : L10n.tr("You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if isDelivering, let last = subscriptionManager.lastRelayTriggerReceivedAt { + LabeledContent(L10n.tr("Last wake-up")) { + Text(last, style: .relative) + } + .font(.subheadline) + } } + .padding(.vertical, VaultSpacing.xs) + .accessibilityElement(children: .combine) } - Section { - spineRow(done: true, title: L10n.tr("Subscribe"), detail: L10n.tr("Your Cloud Relay subscription is active.")) + // Activation lever — prominent while wake-ups aren't arriving yet. + if !isDelivering { + Section { + NavigationLink { + RelayServerSetupView(isDelivering: isDelivering) + } label: { + Label(L10n.tr("Set up the server helper"), systemImage: "server.rack") + .font(.headline) + } + } footer: { + Text(L10n.tr("Run the one-time vaultsync-notify helper on your server to start receiving instant updates. It only sends a wake-up — it never sees your notes.")) + } + } - NavigationLink { - RelayServerSetupView(isDelivering: isDelivering) - } label: { - spineLabel( - done: isDelivering, - title: L10n.tr("Run the server helper"), - detail: L10n.tr("Start vaultsync-notify on your server — copyable command inside.") - ) + Section { + if isDelivering { + NavigationLink { + RelayServerSetupView(isDelivering: isDelivering) + } label: { + Label(L10n.tr("Server helper setup"), systemImage: "server.rack") + } } NavigationLink { @@ -103,49 +137,37 @@ struct RelayHomeView: View { subscriptionManager: subscriptionManager ) } label: { - spineLabel( - done: isDelivering, - title: L10n.tr("Verify delivery"), - detail: L10n.tr("Check relay health, push token, and per-device provisioning.") - ) + Label(L10n.tr("Relay health & diagnostics"), systemImage: "stethoscope") } - } header: { - Text(L10n.tr("Finish setup")) - } footer: { - Text(L10n.tr("Manage or cancel your subscription anytime in Settings → Subscriptions.")) - } - } - // MARK: - Helpers + Button { + Task { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } + try? await AppStore.showManageSubscriptions(in: scene) + } + } label: { + Label(L10n.tr("Manage Subscription"), systemImage: "creditcard") + } - private func benefitRow(icon: String, _ text: String) -> some View { - Label { - Text(text).font(.subheadline) - } icon: { - Image(systemName: icon).foregroundStyle(Color.vaultAccent) + if let expiry = subscriptionManager.subscriptionExpiryDate { + LabeledContent(L10n.tr("Renews")) { + Text(expiry, style: .date) + } + } + } header: { + Text(L10n.tr("Manage")) } - .accessibilityElement(children: .combine) - } - - private func spineRow(done: Bool, title: String, detail: String) -> some View { - spineLabel(done: done, title: title, detail: detail) - } - private func spineLabel(done: Bool, title: String, detail: String) -> some View { - HStack(spacing: VaultSpacing.m) { - Image(systemName: done ? "checkmark.circle.fill" : "circle") - .font(.title3) - .foregroundStyle(done ? Color.statusSuccess : Color.statusInactive) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.body) - Text(detail) + if let relayError = subscriptionManager.errorMessage, !relayError.isEmpty { + Section { + Text(relayError) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(Color.statusError) + if let url = SyncUserError.troubleshootingURL(forRawError: relayError) { + ExternalLinkButton(titleKey: "Learn how to fix", url: url) + .font(.footnote) + } } } - .accessibilityElement(children: .combine) - .accessibilityValue(done ? L10n.tr("Done") : "") } } diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index 99c47dd..f9ee353 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -13,15 +13,16 @@ struct SettingsView: View { @State private var tipJar = TipJarManager() @State private var showThankYou = false @State private var deviceIDCopied = false - @State private var isRestoring = false - @State private var showServerSetup = false @AppStorage(BackgroundSyncService.conflictNotificationsEnabledKey) private var conflictNotificationsEnabled = true @Environment(\.dismiss) private var dismiss + // Cloud Relay now lives entirely in its own tab (RelayHomeView) — subscribe, + // server setup, diagnostics, and manage-subscription. Settings no longer + // duplicates it. + var body: some View { NavigationStack { List { - cloudRelaySection supportSection notificationsSection aboutSection @@ -101,25 +102,6 @@ struct SettingsView: View { } } } - .sheet(isPresented: $showServerSetup) { - NavigationStack { - RelayServerSetupView(isDelivering: subscriptionManager.relayDeliveryConfirmed) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - showServerSetup = false - } - } - } - } - } - .onAppear { - Task { - await subscriptionManager.refreshRelayDiagnostics( - homeserverDeviceIDs: syncthingManager.devices.map(\.deviceID) - ) - } - } .onChange(of: tipJar.didContribute) { _, contributed in if contributed { showThankYou = true @@ -134,231 +116,6 @@ struct SettingsView: View { } } - // MARK: - Cloud Relay Section - - private var cloudRelaySection: some View { - Section { - // Status row - HStack { - Label("Status", systemImage: subscriptionManager.isRelaySubscribed ? "antenna.radiowaves.left.and.right" : "antenna.radiowaves.left.and.right.slash") - Spacer() - Text(relayStatusText) - .foregroundStyle(subscriptionManager.isRelaySubscribed ? Color.statusSuccess : Color.statusInactive) - } - .accessibilityElement(children: .combine) - - // Expiry date - if let expiry = subscriptionManager.subscriptionExpiryDate, subscriptionManager.isRelaySubscribed { - LabeledContent("Renews") { - Text(expiry, style: .date) - } - } - - if subscriptionManager.isRelaySubscribed { - relayDeliveryRow - } - - if !syncthingManager.devices.isEmpty { - ForEach(syncthingManager.devices) { device in - let status = relayProvisionStatus(for: device.deviceID) - if status != .provisioned { - VStack(alignment: .leading, spacing: 4) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(device.name.isEmpty ? device.deviceID : device.name) - .font(.subheadline) - Text(device.deviceID) - .font(.system(.caption2, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - .foregroundStyle(.secondary) - } - Spacer() - Text(status.summary) - .font(.caption.weight(.semibold)) - .foregroundStyle(relayProvisionColor(status)) - } - .accessibilityElement(children: .combine) - if let reason = status.failureReason { - Text(reason) - .font(.caption2) - .foregroundStyle(.secondary) - if let url = SyncUserError.troubleshootingURL(forRawError: reason) { - ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.caption2) - } - } - } - .padding(.vertical, 2) - } - } - } - - // Subscribe / Manage - if subscriptionManager.isRelaySubscribed { - Button("Manage Subscription") { - Task { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } - try? await AppStore.showManageSubscriptions(in: windowScene) - } - } - } else { - if subscriptionManager.monthlyProduct != nil || subscriptionManager.yearlyProduct != nil { - if let monthly = subscriptionManager.monthlyProduct { - subscribeButton(for: monthly, label: L10n.tr("Subscribe Monthly")) - } - if let yearly = subscriptionManager.yearlyProduct { - subscribeButton(for: yearly, label: L10n.tr("Subscribe Yearly"), accent: true) - } - } else { - Text("Subscription unavailable") - .foregroundStyle(.secondary) - } - - Button { - Task { - isRestoring = true - await subscriptionManager.restorePurchases() - isRestoring = false - } - } label: { - HStack { - Text("Restore Purchases") - if isRestoring { - Spacer() - ProgressView() - .controlSize(.small) - } - } - } - .disabled(isRestoring) - } - - if subscriptionManager.isRelaySubscribed { - NavigationLink { - RelayServerSetupView(isDelivering: subscriptionManager.relayDeliveryConfirmed) - } label: { - Label(L10n.tr("Set Up Your Server"), systemImage: "server.rack") - } - } - - NavigationLink { - RelayDiagnosticsView( - syncthingManager: syncthingManager, - subscriptionManager: subscriptionManager - ) - } label: { - Label("Open Relay Diagnostics", systemImage: "stethoscope") - } - - if let relayError = subscriptionManager.errorMessage, !relayError.isEmpty { - VStack(alignment: .leading, spacing: 2) { - Text(relayError) - .font(.caption) - .foregroundStyle(Color.statusError) - if let url = SyncUserError.troubleshootingURL(forRawError: relayError) { - ExternalLinkButton(titleKey: "Learn how to fix", url: url) - .font(.caption2) - } - } - } - - // Subscription details (required by App Store Review). Price comes - // from StoreKit so it is correct per storefront — never hard-coded. - VStack(alignment: .leading, spacing: 2) { - if let yearly = subscriptionManager.yearlyProduct { - Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: yearly))) - .font(.caption) - } - if let monthly = subscriptionManager.monthlyProduct { - Text(L10n.fmt("Cloud Relay — %@", subscriptionManager.priceText(for: monthly))) - .font(.caption) - } - if subscriptionManager.monthlyProduct == nil, subscriptionManager.yearlyProduct == nil { - Text(L10n.tr("Cloud Relay subscription")) - .font(.caption) - } - Text("Auto-renews until canceled. Cancel anytime in Settings → Subscriptions.") - .font(.caption) - .foregroundStyle(.secondary) - } - .padding(.vertical, 2) - } header: { - Text("Cloud Relay") - } footer: { - Text("When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing.") - } - } - - @ViewBuilder - private var relayDeliveryRow: some View { - if subscriptionManager.relayDeliveryConfirmed { - Label(L10n.tr("Delivering wake-ups"), systemImage: "checkmark.seal.fill") - .foregroundStyle(Color.statusSuccess) - .font(.subheadline) - .accessibilityElement(children: .combine) - } else if let last = subscriptionManager.lastRelayTriggerReceivedAt { - LabeledContent(L10n.tr("Last wake-up")) { - Text(last, style: .relative) - } - } else { - VStack(alignment: .leading, spacing: 6) { - Label(L10n.tr("Waiting for your server"), systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(Color.statusAttention) - .font(.subheadline) - Text(L10n.tr("Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates.")) - .font(.caption) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .combine) - } - } - - @ViewBuilder - private func subscribeButton(for product: Product, label: String, accent: Bool = false) -> some View { - Button { - // Cloud Relay can only be provisioned for a paired homeserver. Block - // the purchase until at least one Syncthing peer exists, otherwise the - // user would pay and the relay would have no Device ID to wake. - guard !syncthingManager.devices.isEmpty else { - alertMessage = L10n.tr("Add your server as a Syncthing device before subscribing to Cloud Relay.") - showAlert = true - return - } - Task { - do { - let deviceIDs = syncthingManager.devices.map(\.deviceID) - try await subscriptionManager.purchase(product, homeserverDeviceIDs: deviceIDs) - // A subscription alone delivers nothing until the server-side - // helper runs — guide the buyer there immediately. - if subscriptionManager.isRelaySubscribed { - showServerSetup = true - } - } catch { - alertMessage = SyncUserError.from( - error: error, - fallbackTitle: L10n.tr("Purchase Failed") - ).userVisibleDescription - showAlert = true - } - } - } label: { - HStack { - Text(label) - .fontWeight(accent ? .semibold : .regular) - Spacer() - if subscriptionManager.purchaseInProgress { - ProgressView() - .controlSize(.small) - } else { - Text(subscriptionManager.priceText(for: product)) - .foregroundStyle(accent ? Color.vaultTeal : Color.secondary) - } - } - } - .disabled(subscriptionManager.purchaseInProgress) - } - // MARK: - Notifications Section private var notificationsSection: some View { @@ -472,29 +229,4 @@ struct SettingsView: View { } } } - - private var relayStatusText: String { - if subscriptionManager.isRelaySubscribed { return L10n.tr("Active") } - if subscriptionManager.isLoadingProduct { return L10n.tr("Loading…") } - if subscriptionManager.availableProduct != nil { return L10n.tr("Not Subscribed") } - return L10n.tr("Not Configured") - } - - private func relayProvisionStatus(for deviceID: String) -> RelayProvisionStatus { - subscriptionManager.relayProvisionStatuses[deviceID] ?? .notAttempted - } - - private func relayProvisionColor(_ status: RelayProvisionStatus) -> Color { - switch status { - case .provisioned: - return .green - case .failed: - return .red - case .inProgress: - return .blue - case .notAttempted: - return .secondary - } - } - } diff --git a/ios/VaultSync/Views/SubscribePlanPicker.swift b/ios/VaultSync/Views/SubscribePlanPicker.swift index 4e6d1b0..c700013 100644 --- a/ios/VaultSync/Views/SubscribePlanPicker.swift +++ b/ios/VaultSync/Views/SubscribePlanPicker.swift @@ -1,14 +1,15 @@ import StoreKit import SwiftUI -/// The single canonical Cloud Relay subscription picker. Yearly is listed first -/// and highlighted as the recommended plan; monthly sits below; restore and a -/// prominent, compliant price / term / auto-renew disclosure with Terms & Privacy -/// links follow (App Store guideline 3.1.2(a)). +/// The single canonical Cloud Relay subscription picker. Monthly is listed first +/// (the low-commitment entry point — leading with the small price avoids the +/// "annual sticker shock" that makes people reach for a full cloud-sync product +/// instead); yearly follows, framed as savings and marked as the best value. +/// Prominent, compliant price / term / auto-renew disclosure with Terms & Privacy +/// links follows (App Store guideline 3.1.2(a)). /// -/// Used by both the Relay tab and the in-context upsell so the two can never -/// drift in ordering or copy again — replacing the previously duplicated blocks -/// that listed the plans in opposite orders. +/// Relay can only be provisioned for a paired homeserver, so with no devices the +/// picker shows a guide instead of letting the user pay for nothing to wake. struct SubscribePlanPicker: View { var subscriptionManager: SubscriptionManager let homeserverDeviceIDs: [String] @@ -19,7 +20,9 @@ struct SubscribePlanPicker: View { var body: some View { VStack(alignment: .leading, spacing: VaultSpacing.m) { - if subscriptionManager.yearlyProduct == nil && subscriptionManager.monthlyProduct == nil { + if homeserverDeviceIDs.isEmpty { + noDeviceNotice + } else if subscriptionManager.yearlyProduct == nil && subscriptionManager.monthlyProduct == nil { if subscriptionManager.isLoadingProduct { HStack(spacing: VaultSpacing.s) { ProgressView() @@ -29,12 +32,12 @@ struct SubscribePlanPicker: View { Text(L10n.tr("Subscription unavailable")).foregroundStyle(.secondary) } } else { - if let yearly = subscriptionManager.yearlyProduct { - planCard(product: yearly, title: L10n.tr("Yearly"), recommended: true) - } if let monthly = subscriptionManager.monthlyProduct { planCard(product: monthly, title: L10n.tr("Monthly"), recommended: false) } + if let yearly = subscriptionManager.yearlyProduct { + planCard(product: yearly, title: L10n.tr("Yearly"), recommended: true) + } } Button { @@ -68,6 +71,24 @@ struct SubscribePlanPicker: View { } } + private var noDeviceNotice: some View { + HStack(alignment: .top, spacing: VaultSpacing.m) { + Image(systemName: "laptopcomputer.slash") + .font(.title3) + .foregroundStyle(Color.statusAttention) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + 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.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + } + private func planCard(product: Product, title: String, recommended: Bool) -> some View { Button { purchase(product) @@ -77,8 +98,8 @@ struct SubscribePlanPicker: View { HStack(spacing: VaultSpacing.s) { Text(title) .font(.headline) - if recommended { - Text(L10n.tr("Best value")) + if recommended, let savings = yearlySavingsText { + Text(savings) .font(.caption2.weight(.bold)) .padding(.horizontal, 8) .padding(.vertical, 2) @@ -118,6 +139,19 @@ struct SubscribePlanPicker: View { .accessibilityHint(L10n.tr("Starts a subscription purchase.")) } + /// "Save N%" for the yearly plan vs. paying monthly for a year. Derived from + /// StoreKit prices so it is correct per storefront; nil if not computable. + private var yearlySavingsText: String? { + guard let monthly = subscriptionManager.monthlyProduct, + let yearly = subscriptionManager.yearlyProduct else { return nil } + let monthlyAnnual = monthly.price * 12 + guard monthlyAnnual > 0 else { return nil } + let fraction = (monthlyAnnual - yearly.price) / monthlyAnnual + let percent = NSDecimalNumber(decimal: fraction * 100).intValue + guard percent > 0 else { return L10n.tr("Best value") } + return L10n.fmt("Save %d%%", percent) + } + private var complianceFooter: some View { VStack(alignment: .leading, spacing: VaultSpacing.xs) { Text(L10n.tr("Auto-renews until canceled. Cancel anytime in Settings → Subscriptions.")) From 3088d4288fa81f6c9115e4adb44332391b4be438 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 19:00:04 +0200 Subject: [PATCH 12/22] refactor(ui): move Relay privacy framing behind an info button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "already syncs free / not cloud storage" reassurance line is no longer always-on in the pitch — it's now a subtle "How is this private?" info button that reveals the text in a popover, decluttering the hero further. Dropped the hero's accessibility-combine so the button stays independently actionable for VoiceOver. Build + design-token lint green. --- ios/VaultSync/Views/RelayHomeView.swift | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/ios/VaultSync/Views/RelayHomeView.swift b/ios/VaultSync/Views/RelayHomeView.swift index a1c12de..29248ba 100644 --- a/ios/VaultSync/Views/RelayHomeView.swift +++ b/ios/VaultSync/Views/RelayHomeView.swift @@ -17,6 +17,8 @@ struct RelayHomeView: View { let syncthingManager: SyncthingManager var subscriptionManager: SubscriptionManager + @State private var showPrivacyInfo = false + private var deviceIDs: [String] { syncthingManager.devices.map(\.deviceID) } private var isDelivering: Bool { subscriptionManager.relayDeliveryConfirmed } @@ -54,18 +56,25 @@ struct RelayHomeView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - Label { - Text(L10n.tr("Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage.")) + Button { + showPrivacyInfo = true + } label: { + Label(L10n.tr("How is this private?"), systemImage: "info.circle") .font(.footnote) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } icon: { - Image(systemName: "lock.shield.fill").foregroundStyle(Color.statusSuccess) } + .buttonStyle(.plain) + .foregroundStyle(Color.vaultAccent) .padding(.top, VaultSpacing.xs) + .popover(isPresented: $showPrivacyInfo) { + Text(L10n.tr("Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .padding() + .frame(maxWidth: 320) + .presentationCompactAdaptation(.popover) + } } .padding(.vertical, VaultSpacing.s) - .accessibilityElement(children: .combine) } Section { From 9c7f21e8701e380eb9c3a725324ce93ed533f94a Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 19:33:07 +0200 Subject: [PATCH 13/22] fix(l10n): translate redesign strings to de/es/zh, unify relay terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add the 36 redesign keys that were English-only (tab labels Sync/Relay, Relay-tab pitch + subscribed states, onboarding setup steps, plan picker, conflict "keep both" copy) to all four catalogs with real de/es/zh translations. Parity restored: 583 keys each. - Fix ConflictDiffView's primary "Keep This Device's Version" button: it used a curly apostrophe so it never matched the straight-apostrophe catalog key and fell back to English in every language despite existing translations. - Unify "wake-up" terminology: de -> "Weck-Signal" (was Wecksignal/Aufweck- vorgänge/Weckrufe), es -> "señal de activación" (was señal de aviso/ activaciones). Fix es "silent push" (push silencioso), "Health Endpoint" (Endpoint de salud), and de Synchronisierung/Synchronisation drift. - zh: normalize quote marks to full-width "" (were 「」 / straight ASCII). --- ios/VaultSync/Views/ConflictDiffView.swift | 2 +- ios/VaultSync/de.lproj/Localizable.strings | 50 ++++++++++++++++--- ios/VaultSync/en.lproj/Localizable.strings | 38 ++++++++++++++ ios/VaultSync/es.lproj/Localizable.strings | 50 ++++++++++++++++--- .../zh-Hans.lproj/Localizable.strings | 42 +++++++++++++++- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/ios/VaultSync/Views/ConflictDiffView.swift b/ios/VaultSync/Views/ConflictDiffView.swift index 1232f14..dc5e9c0 100644 --- a/ios/VaultSync/Views/ConflictDiffView.swift +++ b/ios/VaultSync/Views/ConflictDiffView.swift @@ -157,7 +157,7 @@ struct ConflictDiffView: View { Button { confirmAction(.keepThis) } label: { - Label(L10n.tr("Keep This Device’s Version"), systemImage: "iphone") + Label(L10n.tr("Keep This Device's Version"), systemImage: "iphone") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index f2650d4..1d3f8d9 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -33,8 +33,8 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Prüfe deinen Abostatus in den Einstellungen und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay aktiv"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Ändern sich Dateien auf deinem Server, weckt ein stiller Push VaultSync im selben Moment — Synchronisation wirkt sofort, ohne die App zu öffnen. Das Relay sendet nur ein Wecksignal und sieht deine Notizen nie."; -"Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay ist derzeit nicht abonniert. Push-ausgelöste Aufweckvorgänge sind deaktiviert, bis das Abo aktiv ist."; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Ändern sich Dateien auf deinem Server, weckt ein stiller Push VaultSync im selben Moment — Synchronisation wirkt sofort, ohne die App zu öffnen. Das Relay sendet nur ein Weck-Signal und sieht deine Notizen nie."; +"Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay ist derzeit nicht abonniert. Push-ausgelöste Weck-Signale sind deaktiviert, bis das Abo aktiv ist."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay ist nicht abonniert. Starte zuerst ein Abo."; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay-Provisioning konnte das Relay-Backend nicht erreichen."; "Cloud Relay provisioning did not complete." = "Cloud Relay-Provisioning wurde nicht abgeschlossen."; @@ -444,8 +444,8 @@ "Alert Banners" = "Hinweis-Banner"; "Allowed" = "Erlaubt"; "Denied" = "Verweigert"; -"Cloud Relay is delivering wake-ups" = "Cloud Relay liefert Weckrufe"; -"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-Weckrufe funktionieren weiterhin — sie nutzen Silent Push, der keine Benachrichtigungsberechtigung benötigt."; +"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."; "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."; @@ -553,9 +553,9 @@ "Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "Cloud Relay ist abonniert, aber es ist noch kein Weck-Signal angekommen. Schließe die einmalige Einrichtung auf deinem Server ab, um sofortige Updates zu erhalten."; "Subscribe Monthly" = "Monatlich abonnieren"; "Subscribe Yearly" = "Jährlich abonnieren"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "Wenn sich Dateien auf deinem Server ändern, weckt ein stiller Push VaultSync im selben Moment — die Synchronisierung fühlt sich sofort an, ohne die App zu öffnen. Das Relay sendet nur ein Weck-Signal — deine Notizen sieht es nie. Dafür ist ein einmaliger Helfer auf deinem Server nötig — tippe nach dem Abschluss des Abos auf „Server einrichten“."; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "Wenn sich Dateien auf deinem Server ändern, weckt ein stiller Push VaultSync im selben Moment — die Synchronisation wirkt sofort, ohne die App zu öffnen. Das Relay sendet nur ein Weck-Signal — deine Notizen sieht es nie. Dafür ist ein einmaliger Helfer auf deinem Server nötig — tippe nach dem Abschluss des Abos auf „Server einrichten“."; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Verlängert sich automatisch bis zur Kündigung. Jederzeit kündbar unter Einstellungen → Abonnements."; -"Make incoming sync instant" = "Eingehende Synchronisierung sofort machen"; +"Make incoming sync instant" = "Eingehende Änderungen sofort synchronisieren"; "Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first." = "Dein Vault synchronisiert bereits, wenn du VaultSync öffnest. Cloud Relay fügt einen stillen Push hinzu, damit Änderungen auf deinem Server dieses iPhone im selben Moment erreichen — ganz ohne die App vorher zu öffnen."; "Near-instant server → iPhone updates" = "Nahezu sofortige Updates Server → iPhone"; "The relay only sends a wake-up — it never sees your notes" = "Das Relay sendet nur ein Weck-Signal — deine Notizen sieht es nie"; @@ -568,3 +568,41 @@ "Get instant updates" = "Sofortige Updates erhalten"; "Turn on Cloud Relay" = "Cloud Relay aktivieren"; "Add your server as a Syncthing device before subscribing to Cloud Relay." = "Füge deinen Server als Syncthing-Gerät hinzu, bevor du Cloud Relay abonnierst."; + +/* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ +"%1$@ — %2$@" = "%1$@ — %2$@"; +"%@: %@" = "%@: %@"; +"(%d%%)" = "(%d%%)"; +"Add your computer or server" = "Computer oder Server hinzufügen"; +"Add your server first" = "Füge zuerst deinen Server hinzu"; +"Best value" = "Bestes Angebot"; +"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 weckt ein bestimmtes Gerät. Verbinde im Tab „Geräte“ den Computer oder Server, der deinen Vault bereitstellt, und komm dann zum Abonnieren zurück."; +"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "Erledige diese Schritte direkt hier. Sie leuchten grün auf, während du vorankommst – und du kannst sie jederzeit später vom Startbildschirm aus abschließen."; +"Connect your Obsidian folder" = "Deinen Obsidian-Ordner verbinden"; +"Double tap to copy" = "Zum Kopieren doppeltippen"; +"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "Gib VaultSync einmaligen Zugriff auf deinen lokalen Obsidian-Ordner, damit es deine Notizen synchronisieren kann."; +"How is this private?" = "Wie ist das privat?"; +"Instant sync, still private" = "Sofort synchron, weiterhin privat"; +"Let’s get your vault synced" = "Bringen wir deinen Vault zum Synchronisieren"; +"Loading plans…" = "Tarife werden geladen …"; +"Manage" = "Verwalten"; +"Monthly" = "Monatlich"; +"One step left to activate" = "Noch ein Schritt bis zur Aktivierung"; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "Optional: Aktiviere Cloud Relay später für sofortige Updates – du findest es in den Einstellungen."; +"Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Verbinde dieses iPhone per Geräte-ID oder QR-Code mit dem Syncthing-Gerät, das deinen Vault bereitstellt."; +"Relay" = "Relay"; +"Relay health & diagnostics" = "Relay-Status & Diagnose"; +"Run the one-time vaultsync-notify helper on your server to start receiving instant updates. It only sends a wake-up — it never sees your notes." = "Führe den einmaligen Helfer vaultsync-notify auf deinem Server aus, um sofortige Updates zu erhalten. Er sendet nur ein Weck-Signal – deine Notizen sieht er nie."; +"Save %d%%" = "%d %% sparen"; +"Server helper setup" = "Server-Helfer-Einrichtung"; +"Set up the server helper" = "Server-Helfer einrichten"; +"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "Teile deinen Obsidian-Vault über Syncthing auf deinem Computer. VaultSync nimmt ihn automatisch an – das wird grün, sobald er ankommt."; +"Starts a subscription purchase." = "Startet einen Abo-Kauf."; +"Sync" = "Sync"; +"Sync your first vault" = "Ersten Vault synchronisieren"; +"Wake-ups are being delivered — changes from your other devices arrive instantly." = "Weck-Signale werden zugestellt – Änderungen von deinen anderen Geräten kommen sofort an."; +"Yearly" = "Jährlich"; +"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "Deine lokale Version bleibt erhalten, und die Version des anderen Geräts wird unter einem neuen Namen hinzugefügt. Es wird nichts verworfen."; +"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "Deine Notizen erreichen nie unsere Server. Cloud Relay sendet nur ein winziges Weck-Signal, damit Änderungen von deinen anderen Geräten im selben Moment ankommen – sogar bei geschlossener App."; +"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "Dein Vault synchronisiert bereits kostenlos und Peer-to-Peer. Relay beseitigt nur die Wartezeit „zum Synchronisieren die App öffnen“ – es ist kein Cloud-Speicher."; +"You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "Du hast abonniert, aber es ist noch kein Weck-Signal angekommen. Cloud Relay liefert erst, wenn der Helfer auf deinem Server läuft."; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index a0c6aab..572f7e5 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -568,3 +568,41 @@ "Get instant updates" = "Get instant updates"; "Turn on Cloud Relay" = "Turn on Cloud Relay"; "Add your server as a Syncthing device before subscribing to Cloud Relay." = "Add your server as a Syncthing device before subscribing to Cloud Relay."; + +/* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ +"%1$@ — %2$@" = "%1$@ — %2$@"; +"%@: %@" = "%@: %@"; +"(%d%%)" = "(%d%%)"; +"Add your computer or server" = "Add your computer or server"; +"Add your server first" = "Add your server first"; +"Best value" = "Best value"; +"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 wakes a specific device. Pair the computer or server that hosts your vault on the Devices tab, then come back to subscribe."; +"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen."; +"Connect your Obsidian folder" = "Connect your Obsidian folder"; +"Double tap to copy" = "Double tap to copy"; +"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "Give VaultSync one-time access to your local Obsidian folder so it can sync your notes."; +"How is this private?" = "How is this private?"; +"Instant sync, still private" = "Instant sync, still private"; +"Let’s get your vault synced" = "Let’s get your vault synced"; +"Loading plans…" = "Loading plans…"; +"Manage" = "Manage"; +"Monthly" = "Monthly"; +"One step left to activate" = "One step left to activate"; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings."; +"Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code."; +"Relay" = "Relay"; +"Relay health & diagnostics" = "Relay health & diagnostics"; +"Run the one-time vaultsync-notify helper on your server to start receiving instant updates. It only sends a wake-up — it never sees your notes." = "Run the one-time vaultsync-notify helper on your server to start receiving instant updates. It only sends a wake-up — it never sees your notes."; +"Save %d%%" = "Save %d%%"; +"Server helper setup" = "Server helper setup"; +"Set up the server helper" = "Set up the server helper"; +"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives."; +"Starts a subscription purchase." = "Starts a subscription purchase."; +"Sync" = "Sync"; +"Sync your first vault" = "Sync your first vault"; +"Wake-ups are being delivered — changes from your other devices arrive instantly." = "Wake-ups are being delivered — changes from your other devices arrive instantly."; +"Yearly" = "Yearly"; +"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded."; +"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed."; +"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage."; +"You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index eb3a731..0db8b74 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -33,8 +33,8 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Comprueba el estado de tu suscripción en Ajustes y vuelve a intentarlo. Si continúa, reinicia VaultSync."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay activo"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Cuando cambian archivos en tu servidor, un push silencioso despierta VaultSync en ese mismo momento, así la sincronización se siente inmediata sin abrir la app. El relay solo envía una señal de aviso: nunca ve tus notas."; -"Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay no está suscrito actualmente. Las activaciones por push están desactivadas hasta que la suscripción esté activa."; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Cuando cambian archivos en tu servidor, un push silencioso despierta VaultSync en ese mismo momento, así la sincronización se siente inmediata sin abrir la app. El relay solo envía una señal de activación: nunca ve tus notas."; +"Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay no está suscrito actualmente. Las señales de activación por push están desactivadas hasta que la suscripción esté activa."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay no está suscrito. Inicia primero una suscripción."; "Cloud Relay provisioning could not contact the relay backend." = "El aprovisionamiento de Cloud Relay no pudo contactar con el backend del relay."; "Cloud Relay provisioning did not complete." = "El aprovisionamiento de Cloud Relay no se completó."; @@ -88,7 +88,7 @@ "Folder reached idle state after syncing." = "La carpeta alcanzó el estado inactivo tras la sincronización."; "Folder reported an error." = "La carpeta informó de un error."; "Global Files" = "Archivos globales"; -"Health Endpoint" = "Endpoint de estado"; +"Health Endpoint" = "Endpoint de salud"; "Healthy" = "En buen estado"; "How to fix: %@" = "Cómo solucionarlo: %@"; "Idle" = "Inactivo"; @@ -444,8 +444,8 @@ "Alert Banners" = "Avisos"; "Allowed" = "Permitido"; "Denied" = "Denegado"; -"Cloud Relay is delivering wake-ups" = "Cloud Relay está entregando activaciones"; -"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Los avisos están desactivados a nivel de iOS. Las activaciones de Cloud Relay siguen funcionando: usan push silencioso, que no necesita permiso de notificaciones."; +"Cloud Relay is delivering wake-ups" = "Cloud Relay está entregando señales de activación"; +"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Los avisos están desactivados a nivel de iOS. Las señales de activación de Cloud Relay siguen funcionando: usan push silencioso, que no necesita permiso de notificaciones."; "APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)" = "Falta el token de APNs. Reintenta el registro de APNs; si sigue fallando, comprueba tu conexión a internet. (El push silencioso no requiere avisos de notificación.)"; "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "El registro de APNs falló. Comprueba tu conexión a internet y reintenta el registro. El push silencioso no requiere que los avisos de notificación estén activados."; @@ -553,7 +553,7 @@ "Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "Cloud Relay está suscrito, pero aún no ha llegado ninguna señal de activación. Completa la configuración única en tu servidor para empezar a recibir actualizaciones instantáneas."; "Subscribe Monthly" = "Suscripción mensual"; "Subscribe Yearly" = "Suscripción anual"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "Cuando los archivos cambian en tu servidor, una notificación silenciosa despierta VaultSync en ese mismo momento, de modo que la sincronización se siente instantánea sin abrir la app. El relay solo envía una señal de activación; nunca ve tus notas. Necesita un asistente único en tu servidor: toca «Configura tu servidor» después de suscribirte."; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "Cuando los archivos cambian en tu servidor, un push silencioso despierta VaultSync en ese mismo momento, de modo que la sincronización se siente instantánea sin abrir la app. El relay solo envía una señal de activación; nunca ve tus notas. Necesita un asistente único en tu servidor: toca «Configura tu servidor» después de suscribirte."; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Se renueva automáticamente hasta que se cancele. Cancela cuando quieras en Ajustes → Suscripciones."; "Make incoming sync instant" = "Haz que la sincronización entrante sea instantánea"; "Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first." = "Tu Vault ya se sincroniza cuando abres VaultSync. Cloud Relay añade una notificación silenciosa para que los cambios en tu servidor lleguen a este iPhone en el momento en que ocurren, sin necesidad de abrir la app primero."; @@ -568,3 +568,41 @@ "Get instant updates" = "Recibe actualizaciones instantáneas"; "Turn on Cloud Relay" = "Activar Cloud Relay"; "Add your server as a Syncthing device before subscribing to Cloud Relay." = "Añade tu servidor como dispositivo de Syncthing antes de suscribirte a Cloud Relay."; + +/* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ +"%1$@ — %2$@" = "%1$@ — %2$@"; +"%@: %@" = "%@: %@"; +"(%d%%)" = "(%d%%)"; +"Add your computer or server" = "Añade tu ordenador o servidor"; +"Add your server first" = "Añade primero tu servidor"; +"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"; +"Double tap to copy" = "Toca dos veces para copiar"; +"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "Concede a VaultSync acceso único a tu carpeta local de Obsidian para que pueda sincronizar tus notas."; +"How is this private?" = "¿Cómo es esto privado?"; +"Instant sync, still private" = "Sincronización instantánea, sigue siendo privado"; +"Let’s get your vault synced" = "Vamos a sincronizar tu Vault"; +"Loading plans…" = "Cargando planes…"; +"Manage" = "Gestionar"; +"Monthly" = "Mensual"; +"One step left to activate" = "Falta un paso para activar"; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "Opcional: activa Cloud Relay más tarde para recibir actualizaciones instantáneas; lo encontrarás en Ajustes."; +"Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Vincula este iPhone con el dispositivo de Syncthing que aloja tu Vault, mediante el ID de dispositivo o un código QR."; +"Relay" = "Relay"; +"Relay health & diagnostics" = "Estado y diagnóstico del Relay"; +"Run the one-time vaultsync-notify helper on your server to start receiving instant updates. It only sends a wake-up — it never sees your notes." = "Ejecuta el asistente único vaultsync-notify en tu servidor para empezar a recibir actualizaciones instantáneas. Solo envía una señal de activación: nunca ve tus notas."; +"Save %d%%" = "Ahorra un %d %%"; +"Server helper setup" = "Configuración del asistente del servidor"; +"Set up the server helper" = "Configurar el asistente del servidor"; +"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "Comparte tu Vault de Obsidian desde Syncthing en tu ordenador. VaultSync lo acepta automáticamente: se pondrá verde en cuanto llegue."; +"Starts a subscription purchase." = "Inicia la compra de una suscripción."; +"Sync" = "Sincronizar"; +"Sync your first vault" = "Sincroniza tu primer Vault"; +"Wake-ups are being delivered — changes from your other devices arrive instantly." = "Se están entregando las señales de activación: los cambios de tus otros dispositivos llegan al instante."; +"Yearly" = "Anual"; +"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "Se conserva tu versión local y la versión del otro dispositivo se añade con un nombre nuevo. No se descarta nada."; +"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "Tus notas nunca pasan por nuestros servidores. Cloud Relay envía una pequeña señal de activación para que los cambios de tus otros dispositivos lleguen en el momento en que ocurren, incluso con la app cerrada."; +"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "Tu Vault ya se sincroniza gratis y de igual a igual. Relay solo elimina la espera de «abrir la app para sincronizar»; no es almacenamiento en la nube."; +"You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "Estás suscrito, pero aún no ha llegado ninguna señal de activación. Cloud Relay solo entrega una vez que el asistente se está ejecutando en tu servidor."; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index d964665..7528bc0 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -97,7 +97,7 @@ "In progress" = "进行中"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "从 App Store 安装 Obsidian 并打开一次。文件夹会在 Obsidian 创建后出现。"; "Invalid Input" = "输入无效"; -"Invalid folder name: '%@'" = "无效的文件夹名称:'%@'"; +"Invalid folder name: '%@'" = "无效的文件夹名称:“%@”"; "Keep Both" = "两者都保留"; "Keep Other" = "保留另一方"; "Keep Other Device's Version" = "保留另一台设备的版本"; @@ -397,7 +397,7 @@ "Always skip on this iPhone" = "在此 iPhone 上始终跳过"; "More actions" = "更多操作"; "Skipping enabled" = "跳过已启用"; -"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "「%@」及其冲突副本将不再同步到此 iPhone。你可以在同步过滤器中撤销。"; +"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "“%@”及其冲突副本将不再同步到此 iPhone。你可以在同步过滤器中撤销。"; "1 existing conflict copy was removed." = "已移除 1 个现有冲突副本。"; "%d existing conflict copies were removed." = "已移除 %d 个现有冲突副本。"; "+ conflict copies" = "+ 冲突副本"; @@ -568,3 +568,41 @@ "Get instant updates" = "获取即时更新"; "Turn on Cloud Relay" = "启用 Cloud Relay"; "Add your server as a Syncthing device before subscribing to Cloud Relay." = "在订阅 Cloud Relay 之前,请先将你的服务器添加为 Syncthing 设备。"; + +/* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ +"%1$@ — %2$@" = "%1$@ — %2$@"; +"%@: %@" = "%@: %@"; +"(%d%%)" = "(%d%%)"; +"Add your computer or server" = "添加你的电脑或服务器"; +"Add your server first" = "请先添加你的服务器"; +"Best value" = "最划算"; +"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 会唤醒指定的设备。请先在“设备”标签页中配对承载你 Vault 的电脑或服务器,然后再回来订阅。"; +"Complete these steps right here. They light up green as you go — and you can always finish them later from the home screen." = "直接在这里完成这些步骤。随着你的进展,它们会亮起绿色——你也可以稍后随时从主屏幕完成它们。"; +"Connect your Obsidian folder" = "连接你的 Obsidian 文件夹"; +"Double tap to copy" = "双击以复制"; +"Give VaultSync one-time access to your local Obsidian folder so it can sync your notes." = "授予 VaultSync 对本地 Obsidian 文件夹的一次性访问权限,以便同步你的笔记。"; +"How is this private?" = "这如何保护隐私?"; +"Instant sync, still private" = "即时同步,依然私密"; +"Let’s get your vault synced" = "来同步你的 Vault 吧"; +"Loading plans…" = "正在加载方案…"; +"Manage" = "管理"; +"Monthly" = "按月"; +"One step left to activate" = "还差一步即可激活"; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "可选:之后可启用 Cloud Relay 以获得即时更新——你可以在“设置”中找到它。"; +"Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "通过设备 ID 或二维码,将此 iPhone 与承载你 Vault 的 Syncthing 设备配对。"; +"Relay" = "Relay"; +"Relay health & diagnostics" = "Relay 健康状况与诊断"; +"Run the one-time vaultsync-notify helper on your server to start receiving instant updates. It only sends a wake-up — it never sees your notes." = "在你的服务器上运行一次性的 vaultsync-notify 助手,即可开始接收即时更新。它只发送唤醒信号——绝不会看到你的笔记。"; +"Save %d%%" = "节省 %d%%"; +"Server helper setup" = "服务器助手设置"; +"Set up the server helper" = "设置服务器助手"; +"Share your Obsidian vault from Syncthing on your computer. VaultSync accepts it automatically — this turns green the moment it arrives." = "在你的电脑上通过 Syncthing 共享你的 Obsidian Vault。VaultSync 会自动接受——它一到达就会变绿。"; +"Starts a subscription purchase." = "开始订阅购买。"; +"Sync" = "同步"; +"Sync your first vault" = "同步你的第一个 Vault"; +"Wake-ups are being delivered — changes from your other devices arrive instantly." = "唤醒信号正在送达——来自你其他设备的更改会即时到达。"; +"Yearly" = "按年"; +"Your local version is kept, and the other device’s version is added under a new name. Nothing is discarded." = "保留你的本地版本,另一台设备的版本会以新名称添加。不会丢弃任何内容。"; +"Your notes never touch our servers. Cloud Relay sends a tiny wake-up so changes from your other devices land the moment they happen — even with the app closed." = "你的笔记绝不会经过我们的服务器。Cloud Relay 只发送一个微小的唤醒信号,让来自你其他设备的更改在发生的那一刻就送达——即使应用已关闭。"; +"Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage." = "你的 Vault 本身就已经免费、点对点地同步。Relay 只是免去了“打开应用才能同步”的等待——它不是云存储。"; +"You’re subscribed, but no wake-up has arrived yet. Cloud Relay only delivers once the helper is running on your server." = "你已订阅,但尚未收到唤醒信号。只有当助手在你的服务器上运行时,Cloud Relay 才会送达。"; From ce19e7455d805af7db63347ddbf7fb8a2aec4a2b Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 19:50:04 +0200 Subject: [PATCH 14/22] =?UTF-8?q?fix(ui):=20resolve=20redesign=20review=20?= =?UTF-8?q?findings=20=E2=80=94=20honesty,=20a11y,=20dead=20code,=20kit=20?= =?UTF-8?q?adoption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness - ContentView: the dashboard "Cloud Relay active" claim was driven solely by isRelaySubscribed (a StoreKit entitlement check), so a subscribed-but-unprovisioned device showed green "active" while nothing was delivered. Gate it on relayDeliveryLikelyWorking; otherwise route the user to finish server setup. - ContentView: fold the folder-state glyph/color mapping onto the SyncStatus registry (one source of truth) — fixes the divergent error glyph (exclamationmark.circle vs the canonical xmark.octagon) and the non-semantic .vaultAccent fill; unknown states stay neutral rather than a false "attention". Dead code / duplication - Extract the byte-for-byte-duplicated Add-Device form (ContentView + Onboarding) into a shared AddDeviceSheet. - ContentView: drop the unused @Environment colorScheme; collapse the teal/vaultTeal alias onto vaultAccent; unify the empty-device strings. - SettingsView: remove the dead "Error" alert and its unused alertMessage/showAlert state (left over from the relay-section removal). - SetupChecklistView: dedup the optional-badge across both Dynamic-Type branches and remove the unreachable statusText("") branch. Design-system adoption (the kit was 0-call-site dead code) - Wire StatusRow (device rows), DetailRow (vault detail), ActionCard (pending-shares banner), MonoField (Device ID), and .vaultCard() (pending-shares + checklist surfaces) into the views that had hand-rebuilt them. - DesignSystem: MonoField "copied" state now reverts after 1.5s instead of latching forever; vaultCard's accent strip is clipped to the card shape so it follows the leading corners instead of being rounded on all four of its own. Tokens / color - RelayDiagnosticsView: the .green/.red/.blue returned from the status-color helpers evaded the design-token lint (return-position blind spot) — map them onto statusSuccess/statusError/statusInfo. - Tokenize stray radii/spacing (checklist, pending shares, relay command box); LineDiffView uses .foregroundStyle over the deprecated .foregroundColor. Accessibility - OnboardingView: the step card combined its children AND overrode the label, hiding both the description and the action button from VoiceOver — combine only the text, keep the button as its own element. - DeviceDetailView: stop overriding the Status row's label so LabeledContent keeps the "Status" field name. - SubscribePlanPicker: fold the "best value / save N%" badge into the recommended card's VoiceOver label; hide the in-progress spinner. - PendingSharesView: drop the redundant "Applying share" spinner label. --- ios/VaultSync/Views/AddDeviceSheet.swift | 65 +++++++ ios/VaultSync/Views/ContentView.swift | 176 +++++++----------- ios/VaultSync/Views/DesignSystem.swift | 17 +- ios/VaultSync/Views/DeviceDetailView.swift | 14 +- ios/VaultSync/Views/LineDiffView.swift | 4 +- ios/VaultSync/Views/OnboardingView.swift | 89 ++------- ios/VaultSync/Views/PendingSharesView.swift | 34 +--- .../Views/RelayDiagnosticsView.swift | 12 +- .../Views/RelayServerSetupView.swift | 2 +- ios/VaultSync/Views/SettingsView.swift | 7 - ios/VaultSync/Views/SetupChecklistView.swift | 54 +++--- ios/VaultSync/Views/SubscribePlanPicker.swift | 13 +- 12 files changed, 227 insertions(+), 260 deletions(-) create mode 100644 ios/VaultSync/Views/AddDeviceSheet.swift diff --git a/ios/VaultSync/Views/AddDeviceSheet.swift b/ios/VaultSync/Views/AddDeviceSheet.swift new file mode 100644 index 0000000..aa69bdf --- /dev/null +++ b/ios/VaultSync/Views/AddDeviceSheet.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// The "add a Syncthing device" form. Shared by the main app (Devices tab) and +/// onboarding so the two stay in lockstep instead of drifting as two copies. +/// Owns its own field state, dismisses itself on success, and surfaces add +/// failures through the provided `onError` handler. +struct AddDeviceSheet: View { + let syncthingManager: SyncthingManager + /// Called with a user-visible message when adding the device fails. + var onError: (String) -> Void + + @State private var deviceID = "" + @State private var name = "" + @State private var showQRScanner = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Device ID") { + TextField("XXXXXXX-XXXXXXX-...", text: $deviceID) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.characters) + .autocorrectionDisabled() + + Button { + showQRScanner = true + } label: { + Label("Scan QR Code", systemImage: "qrcode.viewfinder") + } + } + Section("Name (optional)") { + TextField("e.g. My Laptop", text: $name) + } + } + .sheet(isPresented: $showQRScanner) { + QRScannerView { scannedCode in + deviceID = scannedCode + } + } + .navigationTitle("Add Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { addDevice() } + .disabled(deviceID.isEmpty) + } + } + } + .presentationDetents([.medium, .large]) + } + + private func addDevice() { + let id = deviceID.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + if let err = syncthingManager.addDevice(id: id, name: trimmedName) { + onError(SyncUserError.from(rawMessage: err, fallbackTitle: L10n.tr("Could Not Add Device")).userVisibleDescription) + } else { + dismiss() + } + } +} diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 25d600e..11c13d6 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -4,7 +4,6 @@ struct ContentView: View { var syncthingManager: SyncthingManager var vaultManager: VaultManager var subscriptionManager: SubscriptionManager - @Environment(\.colorScheme) private var colorScheme @State private var showAddDevice = false @State private var showSettings = false @State private var showObsidianPicker = false @@ -16,7 +15,7 @@ struct ContentView: View { private static let relayUpsellShownKey = "relay-upsell-shown" - private let teal = Color.vaultTeal + private let accent = Color.vaultAccent /// Cached formatter for the dashboard "Last sync" line. Produces a fully /// localized relative phrase ("2 hours ago" / "vor 2 Stunden" / "2 小时前"). @@ -65,7 +64,10 @@ struct ContentView: View { Text(alertMessage ?? "") } .sheet(isPresented: $showAddDevice) { - addDeviceSheet + AddDeviceSheet(syncthingManager: syncthingManager) { message in + alertMessage = message + showAlert = true + } } .sheet(isPresented: $showSettings) { SettingsView(syncthingManager: syncthingManager, vaultManager: vaultManager, subscriptionManager: subscriptionManager) @@ -242,22 +244,51 @@ struct ContentView: View { } if subscriptionManager.isRelaySubscribed { - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(teal) - .accessibilityHidden(true) - Text("Cloud Relay active") - .font(.subheadline) - .foregroundStyle(teal) + if subscriptionManager.relayDeliveryLikelyWorking { + HStack { + Image(systemName: "antenna.radiowaves.left.and.right") + .foregroundStyle(accent) + .accessibilityHidden(true) + Text("Cloud Relay active") + .font(.subheadline) + .foregroundStyle(accent) + } + .accessibilityElement(children: .combine) + } else { + // Subscribed, but wake-ups aren't actually arriving yet (device + // not provisioned / relay unhealthy). Don't claim "active" — send + // the user to finish the one missing 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) } - .accessibilityElement(children: .combine) } else if !syncthingManager.folders.isEmpty { Button { selectedTab = .relay } label: { HStack { Image(systemName: "antenna.radiowaves.left.and.right") - .foregroundStyle(teal) + .foregroundStyle(accent) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 1) { Text(L10n.tr("Get instant updates")) @@ -462,7 +493,7 @@ struct ContentView: View { .frame(maxWidth: .infinity, alignment: .center) } .buttonStyle(.borderedProminent) - .tint(teal) + .tint(accent) .frame(maxWidth: .infinity) Text("In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open.") @@ -645,22 +676,25 @@ struct ContentView: View { .accessibilityElement(children: .combine) } - private func stateIcon(_ state: String) -> String { + /// Map a folder's raw engine state onto the canonical `SyncStatus` registry so + /// the folder row's glyph + color stay identical to the rest of the app (one + /// source of truth). Unknown states stay neutral rather than being forced to a + /// misleading "attention". + private func folderSyncStatus(_ state: String) -> SyncStatus? { switch state { - case "idle": "checkmark.circle.fill" - case "scanning", "syncing": "arrow.triangle.2.circlepath" - case "error": "exclamationmark.circle.fill" - default: "questionmark.circle" + case "idle": return .synced + case "scanning", "syncing": return .syncing + case "error": return .error + default: return nil } } + private func stateIcon(_ state: String) -> String { + folderSyncStatus(state)?.symbolName ?? "questionmark.circle" + } + private func stateColor(_ state: String) -> Color { - switch state { - case "idle": .statusSuccess - case "scanning", "syncing": .vaultAccent - case "error": .statusError - default: .statusInactive - } + folderSyncStatus(state)?.tint ?? .statusInactive } // MARK: - Vault Detail @@ -670,8 +704,8 @@ struct ContentView: View { let conflicts = syncthingManager.conflictFiles[folder.id] ?? [] return List { Section("Vault") { - LabeledContent("Name", value: folder.label.isEmpty ? folder.id : folder.label) - LabeledContent("Path", value: folder.path) + DetailRow(title: L10n.tr("Name"), value: folder.label.isEmpty ? folder.id : folder.label) + DetailRow(title: L10n.tr("Path"), value: folder.path, monospacedValue: true) } Section("Sync Status") { @@ -827,7 +861,7 @@ struct ContentView: View { Section { if syncthingManager.devices.isEmpty { VStack(alignment: .leading, spacing: 8) { - Label("No devices connected", systemImage: "laptopcomputer.and.iphone") + Label("No devices configured", systemImage: "laptopcomputer.and.iphone") .foregroundStyle(.secondary) Text("Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID.") .font(.caption) @@ -842,19 +876,14 @@ struct ContentView: View { syncthingManager: syncthingManager ) } label: { - HStack { - Text(device.name.isEmpty ? L10n.tr("Unnamed") : device.name) - .font(.body) - Spacer() - HStack(spacing: 4) { - Image(systemName: device.connected ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(device.connected ? Color.statusSuccess : Color.statusInactive) - .accessibilityHidden(true) - Text(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.secondary) - } - .accessibilityElement(children: .combine) + StatusRow( + device.name.isEmpty ? L10n.tr("Unnamed") : device.name, + status: device.connected ? .synced : .paused, + systemImage: device.connected ? "checkmark.circle.fill" : "xmark.circle.fill" + ) { + Text(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) } } } @@ -876,73 +905,6 @@ struct ContentView: View { } } - // MARK: - Add Device Sheet - - @State private var newDeviceID = "" - @State private var newDeviceName = "" - @State private var showQRScanner = false - - private var addDeviceSheet: some View { - NavigationStack { - Form { - Section("Device ID") { - TextField("XXXXXXX-XXXXXXX-...", text: $newDeviceID) - .font(.system(.body, design: .monospaced)) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - - Button { - showQRScanner = true - } label: { - Label("Scan QR Code", systemImage: "qrcode.viewfinder") - } - } - Section("Name (optional)") { - TextField("e.g. My Laptop", text: $newDeviceName) - } - } - .sheet(isPresented: $showQRScanner) { - QRScannerView { scannedCode in - newDeviceID = scannedCode - } - } - .navigationTitle("Add Device") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - resetAddDeviceForm() - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Add") { - addDevice() - } - .disabled(newDeviceID.isEmpty) - } - } - } - .presentationDetents([.medium, .large]) - } - - private func addDevice() { - let id = newDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) - let name = newDeviceName.trimmingCharacters(in: .whitespacesAndNewlines) - - if let err = syncthingManager.addDevice(id: id, name: name) { - alertMessage = mappedError(err, fallbackTitle: L10n.tr("Could Not Add Device")).userVisibleDescription - showAlert = true - } else { - resetAddDeviceForm() - } - } - - private func resetAddDeviceForm() { - newDeviceID = "" - newDeviceName = "" - showAddDevice = false - } - // MARK: - Error Helpers private func mappedError(_ error: String, fallbackTitle: String = L10n.tr("Sync Error")) -> SyncUserError { diff --git a/ios/VaultSync/Views/DesignSystem.swift b/ios/VaultSync/Views/DesignSystem.swift index 09528ce..6d11aa7 100644 --- a/ios/VaultSync/Views/DesignSystem.swift +++ b/ios/VaultSync/Views/DesignSystem.swift @@ -166,6 +166,11 @@ struct MonoField: View { UIImpactFeedbackGenerator(style: .light).impactOccurred() #endif withAnimation(.snappy) { copied = true } + // Revert the affordance so the field doesn't latch on "copied" forever. + Task { + try? await Task.sleep(for: .seconds(1.5)) + withAnimation(.snappy) { copied = false } + } } label: { HStack(alignment: .top, spacing: VaultSpacing.s) { Text(text) @@ -256,22 +261,20 @@ private struct VaultCardModifier: ViewModifier { var tint: Color? func body(content: Content) -> some View { + // Clip the whole surface (background + the 4pt accent strip) to the card + // shape, so the strip follows the card's leading rounded corners instead + // of being individually rounded on all four of its own corners. content - .background( - Color(.secondarySystemBackground), - in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous) - ) + .background(Color(.secondarySystemBackground)) .overlay(alignment: .leading) { if let tint { Rectangle() .fill(tint) .frame(width: 4) - .clipShape( - RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous) - ) .accessibilityHidden(true) } } + .clipShape(RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) } } diff --git a/ios/VaultSync/Views/DeviceDetailView.swift b/ios/VaultSync/Views/DeviceDetailView.swift index c9a4247..475daea 100644 --- a/ios/VaultSync/Views/DeviceDetailView.swift +++ b/ios/VaultSync/Views/DeviceDetailView.swift @@ -13,13 +13,13 @@ struct DeviceDetailView: View { var body: some View { List { Section("Device") { - LabeledContent("Device ID") { - Text(device.deviceID) - .font(.system(.caption2, design: .monospaced)) - .textSelection(.enabled) - .lineLimit(2) - .multilineTextAlignment(.trailing) + VStack(alignment: .leading, spacing: VaultSpacing.xs) { + Text("Device ID") + .font(.caption) + .foregroundStyle(.secondary) + MonoField(text: device.deviceID, accessibilityName: L10n.tr("Device ID")) } + .padding(.vertical, VaultSpacing.xs) HStack { Text("Name") @@ -38,8 +38,6 @@ struct DeviceDetailView: View { .accessibilityHidden(true) Text(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) } - .accessibilityElement(children: .combine) - .accessibilityLabel(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) .accessibilityHint("Shows whether this Syncthing device is currently reachable.") } } diff --git a/ios/VaultSync/Views/LineDiffView.swift b/ios/VaultSync/Views/LineDiffView.swift index eb1bf36..054589e 100644 --- a/ios/VaultSync/Views/LineDiffView.swift +++ b/ios/VaultSync/Views/LineDiffView.swift @@ -31,11 +31,11 @@ struct LineDiffView: View { HStack(alignment: .top, spacing: 6) { Text(linePrefix(for: line.type)) .font(.system(.caption, design: .monospaced).weight(.semibold)) - .foregroundColor(foregroundColor(for: line.type)) + .foregroundStyle(foregroundColor(for: line.type)) .accessibilityHidden(true) Text(line.text.isEmpty ? " " : line.text) .font(.system(.caption, design: .monospaced)) - .foregroundColor(foregroundColor(for: line.type)) + .foregroundStyle(foregroundColor(for: line.type)) } .padding(.horizontal, 4) .padding(.vertical, 2) diff --git a/ios/VaultSync/Views/OnboardingView.swift b/ios/VaultSync/Views/OnboardingView.swift index 8148b7f..b2cbbd9 100644 --- a/ios/VaultSync/Views/OnboardingView.swift +++ b/ios/VaultSync/Views/OnboardingView.swift @@ -14,9 +14,6 @@ struct OnboardingView: View { // Live setup actions — each step launches the real task instead of describing it. @State private var showObsidianPicker = false @State private var showAddDevice = false - @State private var showQRScanner = false - @State private var newDeviceID = "" - @State private var newDeviceName = "" @State private var alertMessage: String? @State private var showAlert = false @@ -58,7 +55,10 @@ struct OnboardingView: View { } } .sheet(isPresented: $showAddDevice) { - addDeviceSheet + AddDeviceSheet(syncthingManager: syncthingManager) { message in + alertMessage = message + showAlert = true + } } .alert("Error", isPresented: $showAlert) { Button("OK") { } @@ -200,13 +200,21 @@ struct OnboardingView: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.body.weight(.semibold)) - .foregroundStyle(primaryHeadingColor) - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + // Group only the text into one VoiceOver element (so title + + // description are read together with the completion status) while + // leaving the action button as its own focusable, activatable + // element — combining the whole card would swallow the button. + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.body.weight(.semibold)) + .foregroundStyle(primaryHeadingColor) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .accessibilityElement(children: .combine) + .accessibilityValue(isComplete ? L10n.tr("Done") : "") if !isComplete, let actionTitle, let action { Button(actionTitle, action: action) @@ -222,66 +230,9 @@ struct OnboardingView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(cardBackground, in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous)) .overlay(cardStroke(in: RoundedRectangle(cornerRadius: VaultRadius.card, style: .continuous))) - .accessibilityElement(children: .combine) - .accessibilityLabel(title) - .accessibilityValue(isComplete ? L10n.tr("Done") : "") } - // MARK: - Add Device sheet (onboarding) - - private var addDeviceSheet: some View { - NavigationStack { - Form { - Section("Device ID") { - TextField("XXXXXXX-XXXXXXX-...", text: $newDeviceID) - .font(.system(.body, design: .monospaced)) - .textInputAutocapitalization(.characters) - .autocorrectionDisabled() - Button { - showQRScanner = true - } label: { - Label("Scan QR Code", systemImage: "qrcode.viewfinder") - } - } - Section("Name (optional)") { - TextField("e.g. My Laptop", text: $newDeviceName) - } - } - .sheet(isPresented: $showQRScanner) { - QRScannerView { scannedCode in - newDeviceID = scannedCode - } - } - .navigationTitle("Add Device") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { resetAddDeviceForm() } - } - ToolbarItem(placement: .confirmationAction) { - Button("Add") { addDevice() } - .disabled(newDeviceID.isEmpty) - } - } - } - .presentationDetents([.medium, .large]) - } - - private func addDevice() { - let id = newDeviceID.trimmingCharacters(in: .whitespacesAndNewlines) - let name = newDeviceName.trimmingCharacters(in: .whitespacesAndNewlines) - if let err = syncthingManager.addDevice(id: id, name: name) { - present(error: err, fallbackTitle: L10n.tr("Could Not Add Device")) - } else { - resetAddDeviceForm() - } - } - - private func resetAddDeviceForm() { - newDeviceID = "" - newDeviceName = "" - showAddDevice = false - } + // MARK: - Error presentation private func present(error: String, fallbackTitle: String) { alertMessage = SyncUserError.from(rawMessage: error, fallbackTitle: fallbackTitle).userVisibleDescription diff --git a/ios/VaultSync/Views/PendingSharesView.swift b/ios/VaultSync/Views/PendingSharesView.swift index c2c8872..43bf823 100644 --- a/ios/VaultSync/Views/PendingSharesView.swift +++ b/ios/VaultSync/Views/PendingSharesView.swift @@ -15,22 +15,13 @@ struct PendingSharesView: View { var body: some View { VStack(alignment: .leading, spacing: 14) { if !obsidianAccessible { - VStack(alignment: .leading, spacing: 8) { - Label("Connect Obsidian to accept shares", systemImage: "folder.badge.questionmark") - .foregroundStyle(Color.statusAttention) - Text("Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected.") - .font(.caption) - .foregroundStyle(.secondary) - Button { - onReconnectObsidian() - } label: { - Label("Reconnect Obsidian Folder", systemImage: "folder.badge.gearshape") - } - .buttonStyle(.borderedProminent) - } - .padding(12) - .background(Color.statusAttention.opacity(0.12), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .accessibilityElement(children: .combine) + ActionCard( + status: .attention, + title: L10n.tr("Connect Obsidian to accept shares"), + message: L10n.tr("Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected."), + actionTitle: L10n.tr("Reconnect Obsidian Folder"), + action: onReconnectObsidian + ) } if pendingFolders.isEmpty { @@ -118,7 +109,7 @@ struct PendingSharesView: View { if inFlightFolderIDs.contains(folder.id) { ProgressView() .controlSize(.small) - .accessibilityLabel("Applying share") + .accessibilityHidden(true) Text("Applying…") .font(.caption) .foregroundStyle(.secondary) @@ -143,13 +134,8 @@ struct PendingSharesView: View { .disabled(inFlightFolderIDs.contains(folder.id)) } } - .padding(12) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color(.separator), lineWidth: 0.5) - ) + .padding(VaultSpacing.m) + .vaultCard() } private func displayName(for folder: SyncthingManager.PendingFolderInfo) -> String { diff --git a/ios/VaultSync/Views/RelayDiagnosticsView.swift b/ios/VaultSync/Views/RelayDiagnosticsView.swift index b623702..e20629a 100644 --- a/ios/VaultSync/Views/RelayDiagnosticsView.swift +++ b/ios/VaultSync/Views/RelayDiagnosticsView.swift @@ -348,7 +348,7 @@ struct RelayDiagnosticsView: View { private var alertBannerColor: Color { switch subscriptionManager.alertBannerStatus { - case .allowed: return .green + case .allowed: return .statusSuccess case .denied: return .secondary // "Not determined" is not an error — keep it neutral rather than a // warning yellow that implies something is wrong. @@ -359,9 +359,9 @@ struct RelayDiagnosticsView: View { private var apnsStatusColor: Color { switch subscriptionManager.apnsRegistrationStatus { case .registered: - return .green + return .statusSuccess case .failed: - return .red + return .statusError case .notAttempted: return .secondary } @@ -370,11 +370,11 @@ struct RelayDiagnosticsView: View { private func relayProvisionColor(_ status: RelayProvisionStatus) -> Color { switch status { case .provisioned: - return .green + return .statusSuccess case .failed: - return .red + return .statusError case .inProgress: - return .blue + return .statusInfo case .notAttempted: return .secondary } diff --git a/ios/VaultSync/Views/RelayServerSetupView.swift b/ios/VaultSync/Views/RelayServerSetupView.swift index 4786473..4788dee 100644 --- a/ios/VaultSync/Views/RelayServerSetupView.swift +++ b/ios/VaultSync/Views/RelayServerSetupView.swift @@ -97,7 +97,7 @@ struct RelayServerSetupView: View { .textSelection(.enabled) .padding(10) } - .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous)) .accessibilityLabel(L10n.tr("Server setup command")) .accessibilityValue(dockerCommand) } diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index f9ee353..32182b2 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -7,8 +7,6 @@ struct SettingsView: View { var vaultManager: VaultManager var subscriptionManager: SubscriptionManager - @State private var alertMessage: String? - @State private var showAlert = false @State private var showSetupStatus = false @State private var tipJar = TipJarManager() @State private var showThankYou = false @@ -74,11 +72,6 @@ struct SettingsView: View { } } } - .alert("Error", isPresented: $showAlert) { - Button("OK") { } - } message: { - Text(alertMessage ?? "") - } .sheet(isPresented: $showSetupStatus) { NavigationStack { ScrollView { diff --git a/ios/VaultSync/Views/SetupChecklistView.swift b/ios/VaultSync/Views/SetupChecklistView.swift index 03faf49..1783ddb 100644 --- a/ios/VaultSync/Views/SetupChecklistView.swift +++ b/ios/VaultSync/Views/SetupChecklistView.swift @@ -18,13 +18,8 @@ struct SetupChecklistView: View { } } - .padding(16) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color(.separator), lineWidth: 0.5) - ) + .padding(VaultSpacing.l) + .vaultCard() } @ViewBuilder @@ -70,16 +65,10 @@ struct SetupChecklistView: View { Text(item.title) .font(.body.weight(.semibold)) } - if item.isOptional { - Text(statusText(for: item)) - .font(.caption2.weight(.semibold)) - .foregroundStyle(statusColor(for: item)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(statusColor(for: item).opacity(0.15), in: Capsule()) - } + optionalBadge(for: item) } .accessibilityElement(children: .combine) + .accessibilityValue(statusAccessibilityValue(for: item)) } else { HStack(spacing: 8) { Image(systemName: statusIcon(for: item)) @@ -89,16 +78,10 @@ struct SetupChecklistView: View { Text(item.title) .font(.body.weight(.semibold)) Spacer() - if item.isOptional { - Text(statusText(for: item)) - .font(.caption2.weight(.semibold)) - .foregroundStyle(statusColor(for: item)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(statusColor(for: item).opacity(0.15), in: Capsule()) - } + optionalBadge(for: item) } .accessibilityElement(children: .combine) + .accessibilityValue(statusAccessibilityValue(for: item)) } Text(item.description) @@ -118,12 +101,12 @@ struct SetupChecklistView: View { } } - .padding(12) + .padding(VaultSpacing.m) .frame(maxWidth: .infinity, alignment: .leading) .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) + RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous) .stroke(Color(.separator), lineWidth: 0.5) ) } @@ -144,8 +127,23 @@ struct SetupChecklistView: View { return .statusAttention } - private func statusText(for item: SetupChecklistViewModel.ChecklistItem) -> String { + @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()) + } + } + + /// VoiceOver status for a checklist item — required items previously exposed no + /// completion state at all (their only signal was a decorative, a11y-hidden icon). + private func statusAccessibilityValue(for item: SetupChecklistViewModel.ChecklistItem) -> String { + if item.isComplete { return L10n.tr("Done") } if item.isOptional { return L10n.tr("Optional") } - return "" + return L10n.tr("Needs Attention") } } diff --git a/ios/VaultSync/Views/SubscribePlanPicker.swift b/ios/VaultSync/Views/SubscribePlanPicker.swift index c700013..4a9d73b 100644 --- a/ios/VaultSync/Views/SubscribePlanPicker.swift +++ b/ios/VaultSync/Views/SubscribePlanPicker.swift @@ -114,6 +114,7 @@ struct SubscribePlanPicker: View { Spacer(minLength: VaultSpacing.s) if subscriptionManager.purchaseInProgress { ProgressView() + .accessibilityHidden(true) } else { Image(systemName: "chevron.right") .font(.footnote.weight(.semibold)) @@ -135,10 +136,20 @@ struct SubscribePlanPicker: View { .buttonStyle(.plain) .disabled(subscriptionManager.purchaseInProgress) .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.fmt("%1$@ — %2$@", title, subscriptionManager.priceText(for: product))) + .accessibilityLabel(planAccessibilityLabel(title: title, product: product, recommended: recommended)) .accessibilityHint(L10n.tr("Starts a subscription purchase.")) } + /// VoiceOver label for a plan card — folds the "best value / save N%" badge into + /// the spoken label, which the combined element would otherwise drop. + private func planAccessibilityLabel(title: String, product: Product, recommended: Bool) -> String { + let base = L10n.fmt("%1$@ — %2$@", title, subscriptionManager.priceText(for: product)) + if recommended, let savings = yearlySavingsText { + return base + ". " + savings + } + return base + } + /// "Save N%" for the yearly plan vs. paying monthly for a year. Derived from /// StoreKit prices so it is correct per storefront; nil if not computable. private var yearlySavingsText: String? { From 6446c7f5cbf151321cff828859ff0418b95193e5 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 20:11:25 +0200 Subject: [PATCH 15/22] fix(ui): relay privacy popover truncation + close the token-lint return blind spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RelayHomeView: the "How is this private?" popover showed only a truncated first line ("Dein Vault synchronisiert bereits kostenl…"). It sized the text to a single line; force multi-line wrapping at a fixed width with fixedSize(vertical) so the popover grows to the full text. - design-token-lint.sh: also flag return-position / ternary / switch-expression status colors (return .red, cond ? .green : .red, case .x: .orange) — the blind spot that let helper functions evade the lint. Tightening it immediately caught two more missed colors in SyncIssuesView's severity helper, now retinted to statusError / statusAttention. Build + full test suite + (tightened) design-token lint green. --- ios/VaultSync/Views/RelayHomeView.swift | 7 ++++++- ios/VaultSync/Views/SyncIssuesView.swift | 4 ++-- ios/scripts/design-token-lint.sh | 6 ++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ios/VaultSync/Views/RelayHomeView.swift b/ios/VaultSync/Views/RelayHomeView.swift index 29248ba..9bee89c 100644 --- a/ios/VaultSync/Views/RelayHomeView.swift +++ b/ios/VaultSync/Views/RelayHomeView.swift @@ -69,8 +69,13 @@ struct RelayHomeView: View { Text(L10n.tr("Your vault already syncs free and peer-to-peer. Relay only removes the “open the app to sync” wait — it isn’t cloud storage.")) .font(.subheadline) .foregroundStyle(.secondary) + .multilineTextAlignment(.leading) + // Force multi-line wrapping at a fixed width and let the + // popover grow to the full text height — without this the + // popover sizes the text to a single line and truncates it. + .fixedSize(horizontal: false, vertical: true) + .frame(width: 280, alignment: .leading) .padding() - .frame(maxWidth: 320) .presentationCompactAdaptation(.popover) } } diff --git a/ios/VaultSync/Views/SyncIssuesView.swift b/ios/VaultSync/Views/SyncIssuesView.swift index 2e64f06..ca55c6e 100644 --- a/ios/VaultSync/Views/SyncIssuesView.swift +++ b/ios/VaultSync/Views/SyncIssuesView.swift @@ -60,9 +60,9 @@ struct SyncIssuesView: View { private func color(for issue: SyncthingManager.SyncIssueItem) -> Color { switch issue.severity { case .critical: - return .red + return .statusError case .warning: - return .orange + return .statusAttention } } diff --git a/ios/scripts/design-token-lint.sh b/ios/scripts/design-token-lint.sh index c05fa1c..15f1bd2 100755 --- a/ios/scripts/design-token-lint.sh +++ b/ios/scripts/design-token-lint.sh @@ -19,8 +19,10 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)" DIRS=("$ROOT/VaultSync/Views" "$ROOT/VaultSync/App" "$ROOT/VaultSyncWidget") # Raw status colors inside a color modifier, bare Color., hardcoded RGB, -# and UIColor.system bridges. -PATTERN='(foregroundStyle|foregroundColor|tint|fill|background)\(\s*\.(red|green|orange|blue)\b|Color\.(red|green|orange|blue)\b|Color\(red:|Color\(uiColor: ?\.system(Red|Green|Orange|Blue)|Color\(\.system(Red|Green|Orange|Blue)' +# UIColor.system bridges, AND return-position / ternary / switch-expression +# status colors (e.g. `return .red`, `cond ? .green : .red`, `case .x: .orange`) — +# the return-position blind spot that previously let helper functions slip through. +PATTERN='(foregroundStyle|foregroundColor|tint|fill|background)\(\s*\.(red|green|orange|blue)\b|Color\.(red|green|orange|blue)\b|Color\(red:|Color\(uiColor: ?\.system(Red|Green|Orange|Blue)|Color\(\.system(Red|Green|Orange|Blue)|return +\.(red|green|orange|blue)\b|[:?] +\.(red|green|orange|blue)\b' hits="$(grep -rnE "$PATTERN" "${DIRS[@]}" --include='*.swift' 2>/dev/null || true)" From e24536fb7d861f939bd3e8f1bbda978fa6a1ade7 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 20:50:17 +0200 Subject: [PATCH 16/22] fix(notify): keep the sidecar alive on inactive-subscription relay 4xx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay declines triggers with HTTP 400/401/403 when a device's subscription is expired, cancelled, or not yet provisioned. doTrigger classified those as fatal, so fireTrigger exited the process — and with `restart: unless-stopped` that turned any normal subscription lapse into a crash-restart loop. Treat the relay's subscription-gating codes (400/401/402/403) as a recoverable subscriptionInactiveError: log and keep running, and re-check on a slow cadence so delivery resumes automatically once the subscription is active again, without hammering the relay. 404 stays fatal (wrong RELAY_URL); other 4xx and 5xx remain transient retries. fireTrigger now returns a triggerOutcome enum instead of (delivered, fatal) bools. Add unit and end-to-end runService tests (survival, auto-resume, fatal exit) and document the trigger error contract in docs/relay-spec.md. --- docs/relay-spec.md | 13 +++ notify/main.go | 118 +++++++++++++++------ notify/main_test.go | 237 ++++++++++++++++++++++++++++++++++++++++++ notify/relay.go | 81 ++++++++++++--- notify/relay_test.go | 240 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 641 insertions(+), 48 deletions(-) create mode 100644 notify/relay_test.go diff --git a/docs/relay-spec.md b/docs/relay-spec.md index 25751a4..3919cd2 100644 --- a/docs/relay-spec.md +++ b/docs/relay-spec.md @@ -148,6 +148,19 @@ Wake-up signal from homeserver container. Sends silent push to all devices regis - No file content, no folder names, no metadata — just a wake-up signal - Rate limited server-side (separate from the client `DEBOUNCE_SECONDS`): roughly 1 push per Device ID per ~30s window +#### Error responses and how `vaultsync-notify` reacts + +The trigger endpoint distinguishes a *subscription state* from a *misconfiguration*, and the sidecar reacts accordingly so a normal lapse never turns into a crash-restart loop: + +| Status | Meaning | Sidecar behaviour | +|---|---|---| +| `400`/`401`/`402`/`403` | No active subscription for this Device ID — expired, cancelled, or not yet provisioned | **Recoverable.** Log and keep running; re-check on a slow cadence so delivery resumes automatically once the subscription is active again. | +| `404` | Endpoint missing — wrong `RELAY_URL` or a broken relay deployment | **Fatal.** Exit so the operator fixes the configuration (normally caught earlier by the startup `/health` check). | +| `429` | Server-side rate limit | Recoverable. Retry honouring `Retry-After`. | +| `5xx` / other | Transient relay/network fault | Recoverable. Retry with exponential backoff. | + +The sidecar never exits on a subscription-state response; only a genuine misconfiguration (`404`) is fatal. + ### GET /health No authentication required. diff --git a/notify/main.go b/notify/main.go index 87556d2..6f7a933 100644 --- a/notify/main.go +++ b/notify/main.go @@ -80,7 +80,10 @@ func main() { } return default: - os.Exit(runService(cfg)) + runCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) + code := runService(runCtx, cfg) + stop() + os.Exit(code) } } @@ -167,10 +170,15 @@ var relevantEventTypes = map[string]bool{ "FolderCompletion": true, } -func runService(cfg Config) int { - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) - defer cancel() +// inactiveRecheckInterval is how long the run loop waits before re-attempting a +// trigger the relay declined for an inactive subscription. It is deliberately +// much slower than the debounce cadence: it resumes delivery on its own soon +// after the subscription is reactivated — even if no new Syncthing change +// arrives — without hammering the relay (which allows ~10 triggers/min/device) +// while the subscription stays inactive. Overridable in tests. +var inactiveRecheckInterval = 60 * time.Second +func runService(ctx context.Context, cfg Config) int { st := NewSyncthingClient(cfg.SyncthingAPIURL, cfg.SyncthingAPIKey) deviceID, err := waitForDeviceID(ctx, st) if err != nil { @@ -219,15 +227,14 @@ func runService(cfg Config) int { if !ok { if len(pending) > 0 { flushCtx, flushCancel := context.WithTimeout(context.Background(), 5*time.Second) - delivered, fatal := fireTrigger(flushCtx, relay, "stream-flush") - if delivered { + outcome := fireTrigger(flushCtx, relay, "stream-flush") + flushCancel() + if outcome == outcomeDelivered { markTriggered(lastTriggered, pending) } - if fatal { - flushCancel() + if outcome == outcomeFatal { return 1 } - flushCancel() } if ctx.Err() == nil { slog.Error("syncthing event stream closed unexpectedly", @@ -282,23 +289,33 @@ func runService(cfg Config) int { if len(pending) == 0 { continue } - delivered, fatal := fireTrigger(ctx, relay, "change-detected") - if delivered { + switch fireTrigger(ctx, relay, "change-detected") { + case outcomeDelivered: markTriggered(lastTriggered, pending) clear(pending) - continue - } - if fatal { + case outcomeFatal: return 1 + case outcomeRetry: + // Transient failure: keep the pending work and retry promptly on + // the debounce cadence. + debounceTimer = time.NewTimer(debounceDur) + debounceCh = debounceTimer.C + case outcomeSubscriptionInactive: + // No active subscription: keep the pending work and re-check on a + // slow cadence (not the fast debounce cadence) so delivery + // resumes automatically once the subscription is active again — + // even with no further changes — without hammering the relay + // while it stays inactive. A new change still re-arms the faster + // debounce timer in the event branch. + debounceTimer = time.NewTimer(inactiveRecheckInterval) + debounceCh = debounceTimer.C } - debounceTimer = time.NewTimer(debounceDur) - debounceCh = debounceTimer.C case <-ctx.Done(): slog.Info("shutdown signal received") if len(pending) > 0 { shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) - if delivered, _ := fireTrigger(shutdownCtx, relay, "shutdown-flush"); delivered { + if fireTrigger(shutdownCtx, relay, "shutdown-flush") == outcomeDelivered { markTriggered(lastTriggered, pending) } shutdownCancel() @@ -466,22 +483,56 @@ func markTriggered(lastTriggered, pending map[string]string) { } } -// fireTrigger sends a relay trigger. -// Returns `(delivered, fatal)` so the caller can retain pending work on -// transient relay failures without collapsing the process on recoverable errors. -func fireTrigger(ctx context.Context, relay *RelayClient, reason string) (bool, bool) { +// triggerOutcome classifies the result of a relay trigger so the run loop can +// react without collapsing the process on recoverable conditions. +type triggerOutcome int + +const ( + // outcomeDelivered: the relay accepted the wake-up signal. + outcomeDelivered triggerOutcome = iota + // outcomeRetry: a transient failure (network blip, 5xx, relay rate limit). + // Keep the pending work and retry promptly on the debounce cadence. + outcomeRetry + // outcomeSubscriptionInactive: the relay declined because the device has no + // active subscription (expired, cancelled, or not yet provisioned). Keep the + // process alive; delivery resumes automatically once the subscription is + // active again. This must never bring the sidecar down. + outcomeSubscriptionInactive + // outcomeFatal: a genuine misconfiguration (wrong RELAY_URL / missing + // endpoint) that a runtime retry cannot fix. + outcomeFatal +) + +// fireTrigger sends a relay trigger and classifies the result so the caller can +// retain pending work on transient failures, keep running through inactive +// subscriptions, and exit only on a real misconfiguration. +func fireTrigger(ctx context.Context, relay *RelayClient, reason string) triggerOutcome { slog.Info("sending relay trigger", "reason", reason) - if err := relay.Trigger(ctx); err != nil { - if isFatal(err) { - slog.Error("relay trigger failed with fatal configuration error", - "classification", "fatal", - "component", "relay", - "reason", reason, - "error", err, - "action", "exit", - ) - return false, true - } + err := relay.Trigger(ctx) + if err == nil { + return outcomeDelivered + } + + switch { + case isFatal(err): + slog.Error("relay trigger failed with fatal configuration error", + "classification", "fatal", + "component", "relay", + "reason", reason, + "error", err, + "action", "exit", + ) + return outcomeFatal + case isSubscriptionInactive(err): + slog.Warn("relay reports no active subscription for this device; keeping notify running", + "classification", "recoverable", + "component", "relay", + "reason", reason, + "error", err, + "action", "await_active_subscription", + ) + return outcomeSubscriptionInactive + default: slog.Error("relay trigger failed", "classification", "recoverable", "component", "relay", @@ -489,9 +540,8 @@ func fireTrigger(ctx context.Context, relay *RelayClient, reason string) (bool, "error", err, "action", "continue", ) - return false, false + return outcomeRetry } - return true, false } func formatWatched(folders map[string]bool) string { diff --git a/notify/main_test.go b/notify/main_test.go index 5cf6f39..6a279ce 100644 --- a/notify/main_test.go +++ b/notify/main_test.go @@ -264,6 +264,243 @@ func TestTriggerCandidateForEventHonorsWatchedFolders(t *testing.T) { } } +// newSyncthingStub returns a Syncthing test server that reports a fixed Device +// ID and emits exactly one relevant change event (LocalIndexUpdated, folder +// vault-a) on the first poll, then long-polls with no further events — letting +// the run loop's own timers, not a stream of events, drive the test. +func newSyncthingStub(t *testing.T) *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-INT"}`)) + case "/rest/events": + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("since") == "0" { + _, _ = w.Write([]byte(`[{"id":1,"type":"LocalIndexUpdated","data":{"folder":"vault-a","sequence":42}}]`)) + return + } + // Emulate Syncthing's long-poll: hold the connection until the + // client disconnects (ctx cancel), then return no new events. + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + _, _ = w.Write([]byte(`[]`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// TestRunServiceSurvivesInactiveSubscription is the regression guard for the +// crash-restart loop: when the relay declines triggers with HTTP 400 (an +// expired/cancelled/not-yet-provisioned subscription), runService must keep +// running and shut down cleanly (exit 0) on signal — never exit 1, which under +// `restart: unless-stopped` would loop forever. It also asserts the inactive +// verdict does not re-hammer the relay on the fast debounce cadence. +func TestRunServiceSurvivesInactiveSubscription(t *testing.T) { + // Push the recheck far beyond the test window so any extra trigger would + // have to come from the (wrong) fast debounce cadence, not the recheck. + restore := inactiveRecheckInterval + inactiveRecheckInterval = time.Hour + t.Cleanup(func() { inactiveRecheckInterval = restore }) + + syncthing := newSyncthingStub(t) + + var triggerCalls atomic.Int32 + firstTrigger := 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": + triggerCalls.Add(1) + select { + case firstTrigger <- struct{}{}: + default: + } + http.Error(w, `{"error":"subscription expired"}`, http.StatusBadRequest) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(relay.Close) + + cfg := Config{ + SyncthingAPIURL: syncthing.URL, + SyncthingAPIKey: "test-key", + RelayURL: relay.URL, + DebounceSeconds: 1, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + codeCh := make(chan int, 1) + go func() { codeCh <- runService(ctx, cfg) }() + + // Wait for the relay to decline the first trigger with 400. + select { + case <-firstTrigger: + case code := <-codeCh: + t.Fatalf("runService exited early with code %d before processing a trigger", code) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the relay trigger") + } + + // Across several debounce periods with no new events, the 400 must neither + // bring the process down nor cause a fast-cadence re-hammer of the relay. + select { + case code := <-codeCh: + t.Fatalf("runService exited with code %d on a 400 trigger; expected it to keep running", code) + case <-time.After(2500 * time.Millisecond): + } + if got := triggerCalls.Load(); got != 1 { + t.Fatalf("relay was triggered %d times while inactive; want 1 (no fast-cadence re-hammer)", got) + } + + // Graceful shutdown must report success, not a fatal exit. + 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") + } +} + +// TestRunServiceResumesAfterSubscriptionActivates proves the second half of the +// requirement: once the subscription is active again, delivery resumes +// automatically — here even with NO further Syncthing change, driven solely by +// the slow recheck timer. +func TestRunServiceResumesAfterSubscriptionActivates(t *testing.T) { + restore := inactiveRecheckInterval + inactiveRecheckInterval = 150 * time.Millisecond + t.Cleanup(func() { inactiveRecheckInterval = restore }) + + syncthing := newSyncthingStub(t) + + var active atomic.Bool + declined := make(chan struct{}, 1) + accepted := 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": + if active.Load() { + select { + case accepted <- struct{}{}: + default: + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"accepted","devices_notified":1}`)) + return + } + select { + case declined <- struct{}{}: + default: + } + http.Error(w, `{"error":"subscription expired"}`, http.StatusBadRequest) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(relay.Close) + + cfg := Config{ + SyncthingAPIURL: syncthing.URL, + SyncthingAPIKey: "test-key", + RelayURL: relay.URL, + DebounceSeconds: 1, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + codeCh := make(chan int, 1) + go func() { codeCh <- runService(ctx, cfg) }() + + // The relay first declines the trigger (inactive subscription). + select { + case <-declined: + case code := <-codeCh: + t.Fatalf("runService exited with code %d before the relay declined", code) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the first (declined) trigger") + } + + // Subscription is reactivated. With no further Syncthing events, the slow + // recheck must resume delivery on its own. + active.Store(true) + select { + case <-accepted: + case code := <-codeCh: + t.Fatalf("runService exited with code %d instead of resuming delivery", code) + case <-time.After(10 * time.Second): + t.Fatal("delivery did not resume automatically after the subscription became active") + } + + 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") + } +} + +// TestRunServiceExitsOnFatalTrigger asserts the other half stays fatal: a 404 +// (wrong RELAY_URL / missing endpoint) at the trigger stage must exit 1, even +// though the startup health check passed. +func TestRunServiceExitsOnFatalTrigger(t *testing.T) { + syncthing := newSyncthingStub(t) + 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"}`)) + default: + // Trigger (and everything else) is 404 -> fatal misconfiguration. + http.NotFound(w, r) + } + })) + t.Cleanup(relay.Close) + + cfg := Config{ + SyncthingAPIURL: syncthing.URL, + SyncthingAPIKey: "test-key", + RelayURL: relay.URL, + DebounceSeconds: 1, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + codeCh := make(chan int, 1) + go func() { codeCh <- runService(ctx, cfg) }() + + select { + case code := <-codeCh: + if code != 1 { + t.Fatalf("runService exit code = %d for a fatal 404 trigger, want 1", code) + } + case <-time.After(10 * time.Second): + t.Fatal("runService did not exit on a fatal trigger") + } +} + func TestMarkTriggeredCopiesPendingMarkers(t *testing.T) { t.Parallel() diff --git a/notify/relay.go b/notify/relay.go index 169f6a3..9c9d958 100644 --- a/notify/relay.go +++ b/notify/relay.go @@ -80,8 +80,17 @@ func (c *RelayClient) CheckHealth(ctx context.Context) error { } // Trigger sends a wake-up signal to the relay. Retries transient errors with -// exponential backoff (max 5 attempts). Returns fatal errors for invalid -// request/endpoint configuration. +// exponential backoff (max 5 attempts). +// +// Two non-transient outcomes short-circuit the retry loop and are surfaced to +// the caller unchanged: +// - *fatalError for a genuine misconfiguration (endpoint missing / wrong +// RELAY_URL), which a runtime retry cannot fix. +// - *subscriptionInactiveError when the relay declines the trigger because +// the device has no active subscription (expired, cancelled, or not yet +// provisioned). This is a normal, self-resolving runtime state: the caller +// keeps the process running and resumes delivery once the subscription is +// active again — it must never bring the sidecar down. func (c *RelayClient) Trigger(ctx context.Context) error { body, err := json.Marshal(triggerRequest{DeviceID: c.deviceID}) if err != nil { @@ -102,7 +111,10 @@ func (c *RelayClient) Trigger(ctx context.Context) error { return nil } - if isFatal(err) { + // A misconfiguration or an inactive-subscription verdict is stable; + // retrying the same request within seconds cannot change it, so return + // immediately and let the caller decide how to react. + if isFatal(err) || isSubscriptionInactive(err) { return err } @@ -144,6 +156,13 @@ func (c *RelayClient) ProbeTrigger(ctx context.Context) error { if errors.As(err, &rateLimited) { return nil } + // A rate-limit or subscription verdict both prove the trigger endpoint + // is reachable and behaving — which is all the doctor probe checks. + // Subscription state is managed in the iOS app, not by the operator, so + // it must not fail the connectivity diagnostic. + if isSubscriptionInactive(err) { + return nil + } } return err } @@ -171,29 +190,41 @@ func (c *RelayClient) doTrigger(ctx context.Context, url string, body []byte) er slog.Info("relay trigger accepted", "devices_notified", tr.DevicesNotified) return nil - case http.StatusBadRequest: - return &fatalError{msg: "relay rejected request (400 Bad Request): check device ID"} - case http.StatusNotFound: + // The endpoint is missing: wrong RELAY_URL or a broken relay deployment. + // A runtime retry cannot fix this, so it stays fatal. (A wrong RELAY_URL + // is normally caught earlier by the startup health check.) return &fatalError{msg: "relay endpoint not found (404): check RELAY_URL"} case http.StatusTooManyRequests: ra := parseRetryAfter(resp.Header.Get("Retry-After")) return &rateLimitError{retryAfter: ra} - case http.StatusUnauthorized, http.StatusForbidden: - return &fatalError{msg: fmt.Sprintf("relay rejected request (%d): check RELAY_URL or relay auth policy", resp.StatusCode)} + case http.StatusBadRequest, http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden: + // The relay reached us but declined this device. The trigger endpoint + // has no auth and the device ID is read straight from Syncthing (always + // well-formed), so these codes mean the subscription is expired, + // cancelled, or not yet provisioned — the relay gates pushes on the + // verified StoreKit expiry. That is a self-resolving runtime state, not + // a misconfiguration — never fatal. + body := strings.TrimSpace(string(readBodySnippet(resp.Body))) + return &subscriptionInactiveError{statusCode: resp.StatusCode, body: body} default: - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return &fatalError{msg: fmt.Sprintf("relay rejected request (%d): %s", resp.StatusCode, respBody)} - } - respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return &transientError{err: fmt.Errorf("unexpected status %d: %s", resp.StatusCode, respBody)} + // Any other status (5xx, or an undocumented 4xx such as a proxy-level + // 405/422) is treated as transient: keep retrying and stay alive rather + // than crash, and log the raw code honestly instead of guessing it is a + // subscription issue. + body := strings.TrimSpace(string(readBodySnippet(resp.Body))) + return &transientError{err: fmt.Errorf("unexpected status %d: %s", resp.StatusCode, body)} } } +func readBodySnippet(r io.Reader) []byte { + snippet, _ := io.ReadAll(io.LimitReader(r, 512)) + return snippet +} + type fatalError struct{ msg string } func (e *fatalError) Error() string { return e.msg } @@ -208,11 +239,33 @@ func (e *rateLimitError) Error() string { return fmt.Sprintf("rate limited (retry after %s)", e.retryAfter) } +// subscriptionInactiveError indicates the relay declined a trigger because the +// device's subscription is not active: expired, cancelled, or not yet +// provisioned. It is a normal runtime condition, not a misconfiguration, so the +// notify sidecar keeps running and resumes delivery automatically once the +// subscription is active again. +type subscriptionInactiveError struct { + statusCode int + body string +} + +func (e *subscriptionInactiveError) Error() string { + if e.body == "" { + return fmt.Sprintf("relay declined trigger (HTTP %d): no active subscription for this device (expired, cancelled, or not yet provisioned)", e.statusCode) + } + return fmt.Sprintf("relay declined trigger (HTTP %d): no active subscription for this device (expired, cancelled, or not yet provisioned): %s", e.statusCode, e.body) +} + func isFatal(err error) bool { _, ok := err.(*fatalError) return ok } +func isSubscriptionInactive(err error) bool { + var e *subscriptionInactiveError + return errors.As(err, &e) +} + func retryAfter(err error) (time.Duration, bool) { if e, ok := err.(*rateLimitError); ok && e.retryAfter > 0 { return e.retryAfter, true diff --git a/notify/relay_test.go b/notify/relay_test.go new file mode 100644 index 0000000..dc79105 --- /dev/null +++ b/notify/relay_test.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +// triggerStub is a relay test double that returns a fixed status for +// POST /api/v1/trigger and counts how many times it was hit. +type triggerStub struct { + server *httptest.Server + calls atomic.Int32 +} + +func newTriggerStub(t *testing.T, status int, retryAfter, body string) *triggerStub { + t.Helper() + stub := &triggerStub{} + stub.server = 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": + stub.calls.Add(1) + if retryAfter != "" { + w.Header().Set("Retry-After", retryAfter) + } + w.WriteHeader(status) + if body != "" { + _, _ = w.Write([]byte(body)) + } + default: + http.NotFound(w, r) + } + })) + t.Cleanup(stub.server.Close) + return stub +} + +func (s *triggerStub) client() *RelayClient { return NewRelayClient(s.server.URL, "DEVICE-TEST") } + +func (s *triggerStub) triggerURL() string { return s.server.URL + "/api/v1/trigger" } + +// doTrigger is the single source of truth for status-code classification, so we +// assert each branch directly — no retry/backoff timing involved. +func TestDoTriggerClassifiesStatusCodes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status int + assert func(t *testing.T, err error) + }{ + {"202 accepted", http.StatusAccepted, func(t *testing.T, err error) { + if err != nil { + t.Fatalf("202 should succeed, got %v", err) + } + }}, + {"400 bad request -> subscription inactive", http.StatusBadRequest, assertSubscriptionInactive}, + {"401 unauthorized -> subscription inactive", http.StatusUnauthorized, assertSubscriptionInactive}, + {"402 payment required -> subscription inactive", http.StatusPaymentRequired, assertSubscriptionInactive}, + {"403 forbidden -> subscription inactive", http.StatusForbidden, assertSubscriptionInactive}, + {"405 method not allowed -> transient", http.StatusMethodNotAllowed, assertTransient}, + {"409 conflict -> transient", http.StatusConflict, assertTransient}, + {"404 not found -> fatal", http.StatusNotFound, func(t *testing.T, err error) { + if !isFatal(err) { + t.Fatalf("404 should be fatal (wrong RELAY_URL), got %T: %v", err, err) + } + if isSubscriptionInactive(err) { + t.Fatal("404 must not be classified as a subscription state") + } + }}, + {"429 too many requests -> rate limit", http.StatusTooManyRequests, func(t *testing.T, err error) { + if isFatal(err) || isSubscriptionInactive(err) { + t.Fatalf("429 should be a transient rate limit, got %T", err) + } + if _, ok := retryAfter(err); !ok { + t.Fatalf("429 should carry a retry-after duration, got %T: %v", err, err) + } + }}, + {"500 server error -> transient", http.StatusInternalServerError, assertTransient}, + {"503 unavailable -> transient", http.StatusServiceUnavailable, assertTransient}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stub := newTriggerStub(t, tc.status, "", "") + err := stub.client().doTrigger(context.Background(), stub.triggerURL(), []byte(`{"device_id":"DEVICE-TEST"}`)) + tc.assert(t, err) + }) + } +} + +func assertSubscriptionInactive(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected a subscription-inactive error, got nil") + } + if !isSubscriptionInactive(err) { + t.Fatalf("expected subscription-inactive classification, got %T: %v", err, err) + } + if isFatal(err) { + t.Fatal("a subscription-inactive response must never be fatal") + } +} + +func assertTransient(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected a transient error, got nil") + } + if isFatal(err) || isSubscriptionInactive(err) { + t.Fatalf("5xx should be transient (retryable), got %T: %v", err, err) + } +} + +// A subscription-inactive verdict is stable: Trigger must surface it after a +// single request instead of burning the full retry/backoff budget. +func TestTriggerDoesNotRetrySubscriptionInactive(t *testing.T) { + t.Parallel() + + stub := newTriggerStub(t, http.StatusBadRequest, "", `{"error":"subscription expired"}`) + err := stub.client().Trigger(context.Background()) + + if !isSubscriptionInactive(err) { + t.Fatalf("expected subscription-inactive error, got %T: %v", err, err) + } + if got := stub.calls.Load(); got != 1 { + t.Fatalf("Trigger made %d requests for a stable subscription verdict, want 1", got) + } +} + +func TestTrigger404ReturnsFatalImmediately(t *testing.T) { + t.Parallel() + + stub := newTriggerStub(t, http.StatusNotFound, "", "") + err := stub.client().Trigger(context.Background()) + + if !isFatal(err) { + t.Fatalf("expected fatal error for 404, got %T: %v", err, err) + } + if got := stub.calls.Load(); got != 1 { + t.Fatalf("Trigger made %d requests for a fatal 404, want 1", got) + } +} + +// ProbeTrigger backs the doctor's connectivity diagnostic. A subscription-state +// response proves the endpoint is reachable, so the probe must pass — otherwise +// the doctor would falsely report a config failure for an unprovisioned device. +func TestProbeTriggerTreatsSubscriptionInactiveAsReachable(t *testing.T) { + t.Parallel() + + stub := newTriggerStub(t, http.StatusBadRequest, "", "") + if err := stub.client().ProbeTrigger(context.Background()); err != nil { + t.Fatalf("ProbeTrigger should treat an inactive subscription as reachable, got %v", err) + } +} + +func TestProbeTrigger404Fails(t *testing.T) { + t.Parallel() + + stub := newTriggerStub(t, http.StatusNotFound, "", "") + err := stub.client().ProbeTrigger(context.Background()) + if err == nil || !isFatal(err) { + t.Fatalf("ProbeTrigger should fail fatally for 404, got %T: %v", err, err) + } +} + +// fireTrigger is the run loop's decision point: it must never return +// outcomeFatal for a subscription-state response. +func TestFireTriggerClassifiesOutcomes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + status int + want triggerOutcome + }{ + {"accepted", http.StatusAccepted, outcomeDelivered}, + {"bad request", http.StatusBadRequest, outcomeSubscriptionInactive}, + {"forbidden", http.StatusForbidden, outcomeSubscriptionInactive}, + {"payment required", http.StatusPaymentRequired, outcomeSubscriptionInactive}, + {"not found", http.StatusNotFound, outcomeFatal}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + stub := newTriggerStub(t, tc.status, "", "") + if got := fireTrigger(context.Background(), stub.client(), "test"); got != tc.want { + t.Fatalf("fireTrigger for %d = %d, want %d", tc.status, got, tc.want) + } + }) + } +} + +// A transient relay failure (5xx) must classify as outcomeRetry, not fatal — a +// relay outage must never bring the sidecar down. A short deadline keeps the +// test fast instead of paying the real retry backoff. +func TestFireTriggerTransientReturnsRetry(t *testing.T) { + t.Parallel() + + stub := newTriggerStub(t, http.StatusInternalServerError, "", "") + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + if got := fireTrigger(ctx, stub.client(), "test"); got != outcomeRetry { + t.Fatalf("fireTrigger for a 5xx outage = %d, want outcomeRetry (%d)", got, outcomeRetry) + } +} + +// Trigger must actually retry a transient failure before giving up — the retry +// loop, not just the short-circuit branches, has to work. +func TestTriggerRetriesTransientThenSucceeds(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"accepted","devices_notified":1}`)) + })) + defer server.Close() + + if err := NewRelayClient(server.URL, "DEVICE-TEST").Trigger(context.Background()); err != nil { + t.Fatalf("expected success after one transient failure, got %v", err) + } + if got := calls.Load(); got != 2 { + t.Fatalf("Trigger made %d attempts, want 2 (one transient failure then success)", got) + } +} From 383c1d587f93820a5b73f1042ba81e6f3dfe5246 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 21:13:27 +0200 Subject: [PATCH 17/22] fix(ui): show real Obsidian vaults on the home screen, not the sync folder The "Obsidian Vaults" section rendered syncthingManager.folders directly, so a whole-directory sync (one folder rooted at the Obsidian directory, labelled "Obsidian") showed a single "Obsidian" row instead of the vaults inside it. VaultManager.detectedVaults already discovered the real vaults (subfolders with .obsidian/) but was only used to pick an empty-state message. Derive the displayed list from the detected vaults mapped onto whichever Syncthing folder syncs them: a folder rooted at the Obsidian directory expands into one row per vault; per-vault folders still map 1:1. Path comparison is symlink/trailing-slash tolerant. Conflicts are attributed per vault by folder-relative path so a shared folder's conflicts no longer appear duplicated across vault rows. The detail view is vault-centric (name + subpath) with a note that sync filters and devices apply to the whole directory. Adds VaultConflictAttributionTests for the path-prefix boundary logic. --- ios/VaultSync/Views/ConflictListView.swift | 17 ++- ios/VaultSync/Views/ContentView.swift | 103 +++++++++++++++--- ios/VaultSync/de.lproj/Localizable.strings | 1 + ios/VaultSync/en.lproj/Localizable.strings | 1 + ios/VaultSync/es.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../VaultConflictAttributionTests.swift | 43 ++++++++ 7 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 ios/VaultSyncTests/VaultConflictAttributionTests.swift diff --git a/ios/VaultSync/Views/ConflictListView.swift b/ios/VaultSync/Views/ConflictListView.swift index 8af3409..8bf0857 100644 --- a/ios/VaultSync/Views/ConflictListView.swift +++ b/ios/VaultSync/Views/ConflictListView.swift @@ -2,13 +2,19 @@ import SwiftUI struct ConflictListView: View { let folderID: String + /// When this folder syncs the whole Obsidian directory, scope the list to a + /// single vault's subdirectory (e.g. "brain"); nil shows the folder's + /// conflicts as a whole. + var pathPrefix: String? = nil let syncthingManager: SyncthingManager /// Read live from the manager so a conflict resolved in the detail view /// disappears immediately. The view previously held a by-value snapshot /// captured at push time, which left resolved files as tappable dead rows. private var conflicts: [SyncthingManager.ConflictInfo] { - syncthingManager.conflictFiles[folderID] ?? [] + let all = syncthingManager.conflictFiles[folderID] ?? [] + guard let prefix = pathPrefix else { return all } + return all.filter { $0.belongs(toVault: prefix) } } var body: some View { @@ -63,6 +69,15 @@ struct ConflictListView: View { } extension SyncthingManager.ConflictInfo { + /// True when this conflict's folder-relative path lives inside the named vault + /// subdirectory (exact `vault/…` match, tolerating a stray leading slash). + /// Used to attribute conflicts to a single vault when one Syncthing folder + /// covers the whole Obsidian directory. + func belongs(toVault vault: String) -> Bool { + let path = originalPath.hasPrefix("/") ? String(originalPath.dropFirst()) : originalPath + return path == vault || path.hasPrefix(vault + "/") + } + private static let conflictDateParser: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyyMMdd-HHmmss" diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index 11c13d6..2efc8ff 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -626,33 +626,96 @@ struct ContentView: View { } .padding(.vertical, 4) } else { - ForEach(syncthingManager.folders) { folder in + ForEach(vaultRows) { item in NavigationLink { - vaultDetailView(folder) + vaultDetailView(item) } label: { - folderRow(folder) + vaultRow(item) } } } } } - private func folderRow(_ folder: SyncthingManager.FolderInfo) -> some View { - let status = syncthingManager.folderStatuses[folder.id] - let conflicts = syncthingManager.conflictFiles[folder.id] ?? [] + /// 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 + /// folder covers the whole Obsidian directory (the common setup: pick + /// "On My iPhone/Obsidian"), it expands into one row per detected vault + /// inside it; a per-vault sync folder maps 1:1. `vaultSubpath`/`relativePrefix` + /// are non-nil only for the expanded directory case, where sync status, + /// filters and devices are shared by the whole directory. + private struct VaultRowItem: Identifiable { + let id: String + let name: String + let folder: SyncthingManager.FolderInfo + let vaultSubpath: String? + let relativePrefix: String? + } + + /// Build the displayed vault list from the detected vaults inside the synced + /// Obsidian directory, mapped onto whichever Syncthing folder actually syncs + /// them. Falls back to the folder itself when it isn't the Obsidian root + /// (per-vault sync, or the root is itself a single vault). + private var vaultRows: [VaultRowItem] { + let base = vaultManager.obsidianBasePath.map(Self.canonicalPath) + var rows: [VaultRowItem] = [] + for folder in syncthingManager.folders { + let isWholeDirectory = base != nil && Self.canonicalPath(folder.path) == base + if isWholeDirectory, !vaultManager.detectedVaults.isEmpty { + for vault in vaultManager.detectedVaults { + rows.append(VaultRowItem( + id: "\(folder.id)/\(vault)", + name: vault, + folder: folder, + vaultSubpath: (folder.path as NSString).appendingPathComponent(vault), + relativePrefix: vault + )) + } + } else { + rows.append(VaultRowItem( + id: folder.id, + name: folder.label.isEmpty ? folder.id : folder.label, + folder: folder, + vaultSubpath: nil, + relativePrefix: nil + )) + } + } + return rows + } + + /// Normalize a path so the Syncthing folder path (stored at accept time) and + /// the security-scoped bookmark path (resolved at launch) compare equal even + /// across `/var`↔`/private/var` symlinks or a trailing slash. + private static func canonicalPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().standardizedFileURL.path + } + + /// Conflicts attributed to one vault: inside the vault's subdirectory for a + /// directory-sync row, or all of the folder's conflicts for a 1:1 row. + private func conflicts(for item: VaultRowItem) -> [SyncthingManager.ConflictInfo] { + let all = syncthingManager.conflictFiles[item.folder.id] ?? [] + guard let vault = item.relativePrefix else { return all } + return all.filter { $0.belongs(toVault: vault) } + } + + 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(folder.label.isEmpty ? folder.id : folder.label) + Text(item.name) .font(.body) - if !conflicts.isEmpty { - Text("\(conflicts.count)") + 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", conflicts.count)) + .accessibilityLabel(L10n.fmt("%d conflicts", conflictCount)) } } if let status { @@ -699,13 +762,20 @@ struct ContentView: View { // MARK: - Vault Detail - private func vaultDetailView(_ folder: SyncthingManager.FolderInfo) -> some View { + private func vaultDetailView(_ item: VaultRowItem) -> some View { + let folder = item.folder let status = syncthingManager.folderStatuses[folder.id] - let conflicts = syncthingManager.conflictFiles[folder.id] ?? [] + let conflicts = self.conflicts(for: item) return List { - Section("Vault") { - DetailRow(title: L10n.tr("Name"), value: folder.label.isEmpty ? folder.id : folder.label) - DetailRow(title: L10n.tr("Path"), value: folder.path, monospacedValue: true) + Section { + DetailRow(title: L10n.tr("Name"), value: item.name) + DetailRow(title: L10n.tr("Path"), value: item.vaultSubpath ?? folder.path, monospacedValue: true) + } header: { + Text("Vault") + } footer: { + if item.relativePrefix != nil { + Text("Synced as part of your Obsidian directory. Sync filters and devices apply to the whole directory.") + } } Section("Sync Status") { @@ -745,6 +815,7 @@ struct ContentView: View { NavigationLink { ConflictListView( folderID: folder.id, + pathPrefix: item.relativePrefix, syncthingManager: syncthingManager ) } label: { @@ -827,7 +898,7 @@ struct ContentView: View { .disabled(isScanning) } } - .navigationTitle(folder.label.isEmpty ? folder.id : folder.label) + .navigationTitle(item.name) .navigationBarTitleDisplayMode(.inline) .onAppear { if !syncthingManager.hasShownRecommendationSheet(folderID: folder.id) { diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 1d3f8d9..b37aca8 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -254,6 +254,7 @@ "Shared With" = "Geteilt mit"; "Shared by an unknown device" = "Geteilt von einem unbekannten Gerät"; "Shared by: %@" = "Geteilt von: %@"; +"Synced as part of your Obsidian directory. Sync filters and devices apply to the whole directory." = "Wird als Teil deines Obsidian-Verzeichnisses synchronisiert. Sync-Filter und Geräte gelten für das gesamte Verzeichnis."; "Show Line-by-Line Diff" = "Zeilenweisen Diff anzeigen"; "Shows whether this Syncthing device is currently reachable." = "Zeigt an, ob dieses Syncthing-Gerät derzeit erreichbar ist."; "Silent Push" = "Stiller Push"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 572f7e5..9aab3e0 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -254,6 +254,7 @@ "Shared With" = "Shared With"; "Shared by an unknown device" = "Shared by an unknown device"; "Shared by: %@" = "Shared by: %@"; +"Synced as part of your Obsidian directory. Sync filters and devices apply to the whole directory." = "Synced as part of your Obsidian directory. Sync filters and devices apply to the whole directory."; "Show Line-by-Line Diff" = "Show Line-by-Line Diff"; "Shows whether this Syncthing device is currently reachable." = "Shows whether this Syncthing device is currently reachable."; "Silent Push" = "Silent Push"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 0db8b74..d67ffa7 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -254,6 +254,7 @@ "Shared With" = "Compartido con"; "Shared by an unknown device" = "Compartido por un dispositivo desconocido"; "Shared by: %@" = "Compartido por: %@"; +"Synced as part of your Obsidian directory. Sync filters and devices apply to the whole directory." = "Se sincroniza como parte de tu directorio de Obsidian. Los filtros de sincronización y los dispositivos se aplican a todo el directorio."; "Show Line-by-Line Diff" = "Mostrar diferencias línea por línea"; "Shows whether this Syncthing device is currently reachable." = "Indica si este dispositivo de Syncthing es accesible actualmente."; "Silent Push" = "Push silencioso"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 7528bc0..3ebbcb8 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -254,6 +254,7 @@ "Shared With" = "共享给"; "Shared by an unknown device" = "由未知设备共享"; "Shared by: %@" = "共享者:%@"; +"Synced as part of your Obsidian directory. Sync filters and devices apply to the whole directory." = "作为 Obsidian 目录的一部分进行同步。同步过滤器和设备适用于整个目录。"; "Show Line-by-Line Diff" = "显示逐行差异"; "Shows whether this Syncthing device is currently reachable." = "显示该 Syncthing 设备当前是否可达。"; "Silent Push" = "静默推送"; diff --git a/ios/VaultSyncTests/VaultConflictAttributionTests.swift b/ios/VaultSyncTests/VaultConflictAttributionTests.swift new file mode 100644 index 0000000..9ebeba3 --- /dev/null +++ b/ios/VaultSyncTests/VaultConflictAttributionTests.swift @@ -0,0 +1,43 @@ +import Testing +@testable import VaultSync + +/// When one Syncthing folder syncs the whole Obsidian directory, the home screen +/// expands it into one row per vault and must attribute each conflict to the +/// right vault by its folder-relative path. These tests pin that boundary logic. +@Suite("Vault conflict attribution") +struct VaultConflictAttributionTests { + + private func conflict(_ originalPath: String) -> SyncthingManager.ConflictInfo { + SyncthingManager.ConflictInfo( + originalPath: originalPath, + conflictPath: originalPath + ".sync-conflict", + conflictDate: "20260531-120000", + deviceShortID: "ABCDEFG" + ) + } + + @Test("A nested file is attributed to its own vault") + func nestedFileBelongsToItsVault() { + let c = conflict("brain/notes/today.md") + #expect(c.belongs(toVault: "brain")) + #expect(!c.belongs(toVault: "openclaw")) + } + + @Test("A vault-prefix is not a substring match") + func prefixIsNotSubstring() { + // "brain" must not swallow conflicts that live in "brainstorm". + let c = conflict("brainstorm/index.md") + #expect(!c.belongs(toVault: "brain")) + #expect(c.belongs(toVault: "brainstorm")) + } + + @Test("An exact vault-root path matches its vault") + func exactRootMatches() { + #expect(conflict("brain").belongs(toVault: "brain")) + } + + @Test("A stray leading slash is tolerated") + func leadingSlashTolerated() { + #expect(conflict("/brain/notes/a.md").belongs(toVault: "brain")) + } +} From b5aea065221f66b2049b55314b58dfc309544473 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 22:15:31 +0200 Subject: [PATCH 18/22] fix(l10n): correct translation errors and add missing status keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh: - Monthly subscription period rendered "1 月" (reads as "January"). Change month singular 月 -> 个月. - Standardize 重扫 -> 重新扫描 (9x; Apple-standard "rescan"). - QR Code -> 二维码 (Scan button, camera-permission body, InfoPlist). - "Present" 已提供 -> 存在 (APNs-token diagnostic; antonym of 缺失). - "immediate" 即时 -> 目前 in "No immediate relay problems detected". - Restore 前 ("ago") in "...more than %d %@ ago", parallel to the sibling. - Symmetric Keep This / Keep Other (both on 版本). - Full-width colon in "%@: %@". de: - Healthy/Unhealthy "Gesund/Ungesund" (medical sense) -> "Funktionsfähig/Nicht funktionsfähig" for a service-health status (incl. the "...is not healthy" body line). - "No folders syncing yet" calque -> "Es werden noch keine Ordner synchronisiert". - Unify contribution wording to "Unterstützung". - "Last Trigger Received" -> noun-phrase label "Letzter empfangener Trigger". es: - "Last wake-up" -> consistent "señal de activación". - "Best value" "La mejor opción" -> "Mejor valor". Add missing keys Starting / Paused (SyncStatus badge) and Node modules (Go-bridge detected-pattern label) to all four catalogs — these were rendering the English fallback in de/es/zh. Parity restored: 587 keys each. --- ios/VaultSync/de.lproj/Localizable.strings | 17 ++++---- ios/VaultSync/en.lproj/Localizable.strings | 3 ++ ios/VaultSync/es.lproj/Localizable.strings | 7 +++- ios/VaultSync/zh-Hans.lproj/InfoPlist.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 39 ++++++++++--------- 5 files changed, 40 insertions(+), 28 deletions(-) diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index b37aca8..843ab35 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -89,7 +89,7 @@ "Folder reported an error." = "Der Ordner hat einen Fehler gemeldet."; "Global Files" = "Globale Dateien"; "Health Endpoint" = "Health-Endpunkt"; -"Healthy" = "Gesund"; +"Healthy" = "Funktionsfähig"; "How to fix: %@" = "So behebst du es: %@"; "Idle" = "Leerlauf"; "Ignore for Now" = "Vorerst ignorieren"; @@ -112,7 +112,7 @@ "Last Failure" = "Letzter Fehler"; "Last Relay Error" = "Letzter Relay-Fehler"; "Last Success" = "Letzter Erfolg"; -"Last Trigger Received" = "Letzter Trigger empfangen"; +"Last Trigger Received" = "Letzter empfangener Trigger"; "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."; @@ -134,7 +134,7 @@ "No active pending shares" = "Keine aktiven ausstehenden Freigaben"; "No devices configured" = "Keine Geräte konfiguriert"; "No devices connected" = "Keine Geräte verbunden"; -"No folders syncing yet" = "Noch keine Ordner in Synchronisation"; +"No folders syncing yet" = "Es werden noch keine Ordner synchronisiert"; "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."; @@ -197,7 +197,7 @@ "Relay health check failed: %@" = "Relay-Health-Check fehlgeschlagen: %@"; "Relay health check returned a non-HTTP response." = "Der Relay-Health-Check lieferte keine HTTP-Antwort."; "Relay health check timed out after %ds." = "Zeitüberschreitung beim Relay-Health-Check nach %d s."; -"Relay health endpoint is not healthy. Check internet access, VPN/firewall rules, or relay availability." = "Der Relay-Health-Endpunkt ist nicht gesund. Prüfe Internetzugang, VPN-/Firewall-Regeln oder die Relay-Verfügbarkeit."; +"Relay health endpoint is not healthy. Check internet access, VPN/firewall rules, or relay availability." = "Der Relay-Health-Endpunkt ist nicht funktionsfähig. Prüfe Internetzugang, VPN-/Firewall-Regeln oder die Relay-Verfügbarkeit."; "Relay health endpoint returned HTTP %d." = "Der Relay-Health-Endpunkt lieferte HTTP %d."; "Relay network error: %@" = "Relay-Netzwerkfehler: %@"; "Relay provision failed with HTTP %d." = "Relay-Provisioning mit HTTP %d fehlgeschlagen."; @@ -262,6 +262,8 @@ "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"; +"Paused" = "Pausiert"; "State" = "Status"; "Status" = "Status"; "Subscribe" = "Abonnieren"; @@ -303,7 +305,7 @@ "Trigger a vault rescan to refresh sync state." = "Starte einen erneuten Vault-Scan, um den Synchronisationsstatus zu aktualisieren."; "Troubleshooting" = "Fehlerbehebung"; "Unchanged line. %@" = "Unveränderte Zeile. %@"; -"Unhealthy" = "Ungesund"; +"Unhealthy" = "Nicht funktionsfähig"; "Unknown" = "Unbekannt"; "Unknown APNs registration error" = "Unbekannter APNs-Registrierungsfehler"; "Unknown Device" = "Unbekanntes Gerät"; @@ -413,6 +415,7 @@ "Trash" = "Papierkorb"; "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."; "macOS metadata" = "macOS-Metadaten"; "Finder metadata files like .DS_Store." = "Finder-Metadatendateien wie .DS_Store."; @@ -497,7 +500,7 @@ "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."; "%d conflicts" = "%d Konflikte"; -"Your contribution is pending approval." = "Dein Beitrag wartet auf Freigabe."; +"Your contribution is pending approval." = "Deine Unterstützung wartet auf Freigabe."; "Could not accept share '%@'.\n\n%@" = "Freigabe '%@' konnte nicht angenommen werden.\n\n%@"; "iOS did not provide a push token required for instant sync." = "iOS hat kein Push-Token bereitgestellt, das für Sofort-Sync erforderlich ist."; "%@ (Trigger: %@)" = "%@ (Auslöser: %@)"; @@ -573,7 +576,7 @@ /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ "%1$@ — %2$@" = "%1$@ — %2$@"; "%@: %@" = "%@: %@"; -"(%d%%)" = "(%d%%)"; +"(%d%%)" = "(%d %%)"; "Add your computer or server" = "Computer oder Server hinzufügen"; "Add your server first" = "Füge zuerst deinen Server hinzu"; "Best value" = "Bestes Angebot"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 9aab3e0..c0d828f 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -262,6 +262,8 @@ "Some entered data is invalid or incomplete." = "Some entered data is invalid or incomplete."; "Some shared peers are offline or unreachable right now." = "Some shared peers are offline or unreachable right now."; "Starting…" = "Starting…"; +"Starting" = "Starting"; +"Paused" = "Paused"; "State" = "State"; "Status" = "Status"; "Subscribe" = "Subscribe"; @@ -413,6 +415,7 @@ "Trash" = "Trash"; "Files already deleted on other devices." = "Files already deleted on other devices."; "Git repository" = "Git repository"; +"Node modules" = "Node modules"; "Version history — rarely useful on iPhone." = "Version history — rarely useful on iPhone."; "macOS metadata" = "macOS metadata"; "Finder metadata files like .DS_Store." = "Finder metadata files like .DS_Store."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index d67ffa7..7cb6e7f 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -262,6 +262,8 @@ "Some entered data is invalid or incomplete." = "Algunos datos introducidos no son válidos o están incompletos."; "Some shared peers are offline or unreachable right now." = "Algunos pares compartidos están desconectados o inaccesibles en este momento."; "Starting…" = "Iniciando…"; +"Starting" = "Iniciando"; +"Paused" = "En pausa"; "State" = "Estado"; "Status" = "Estado"; "Subscribe" = "Suscribirse"; @@ -413,6 +415,7 @@ "Trash" = "Papelera"; "Files already deleted on other devices." = "Archivos ya eliminados en otros dispositivos."; "Git repository" = "Repositorio de Git"; +"Node modules" = "Módulos de Node"; "Version history — rarely useful on iPhone." = "Historial de versiones — rara vez útil en el iPhone."; "macOS metadata" = "Metadatos de macOS"; "Finder metadata files like .DS_Store." = "Archivos de metadatos de Finder como .DS_Store."; @@ -549,7 +552,7 @@ "Set Up Your Server" = "Configura tu servidor"; "Server setup command" = "Comando de configuración del servidor"; "Delivering wake-ups" = "Entregando señales de activación"; -"Last wake-up" = "Última activación"; +"Last wake-up" = "Última señal de activación"; "Waiting for your server" = "Esperando a tu servidor"; "Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "Cloud Relay está suscrito, pero aún no ha llegado ninguna señal de activación. Completa la configuración única en tu servidor para empezar a recibir actualizaciones instantáneas."; "Subscribe Monthly" = "Suscripción mensual"; @@ -576,7 +579,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" = "La mejor opción"; +"Best value" = "Mejor valor"; "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"; diff --git a/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings b/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings index 589c391..0d577c1 100644 --- a/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings +++ b/ios/VaultSync/zh-Hans.lproj/InfoPlist.strings @@ -1,2 +1,2 @@ "CFBundleDisplayName" = "VaultSync"; -"NSCameraUsageDescription" = "VaultSync 需要使用相机扫描 Syncthing 设备 QR Code,以便轻松完成设置。"; +"NSCameraUsageDescription" = "VaultSync 需要使用相机扫描 Syncthing 设备二维码,以便轻松完成设置。"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 3ebbcb8..f30c1ac 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -99,9 +99,9 @@ "Invalid Input" = "输入无效"; "Invalid folder name: '%@'" = "无效的文件夹名称:“%@”"; "Keep Both" = "两者都保留"; -"Keep Other" = "保留另一方"; +"Keep Other" = "保留对方版本"; "Keep Other Device's Version" = "保留另一台设备的版本"; -"Keep This" = "保留当前版本"; +"Keep This" = "保留此版本"; "Keep This Device's Version" = "保留此设备版本"; "Keep both versions" = "保留两个版本"; "Keep other device version" = "保留另一台设备版本"; @@ -115,7 +115,7 @@ "Last Trigger Received" = "上次收到触发"; "Last Update" = "上次更新"; "Last successful sync was about %d %@ ago." = "上次成功同步约在 %d %@ 前。"; -"Last successful sync was more than %d %@ ago." = "上次成功同步已超过 %d %@。"; +"Last successful sync was more than %d %@ ago." = "上次成功同步已超过 %d %@ 前。"; "Last sync: %@" = "上次同步:%@"; "Latency" = "延迟"; "Learn how to fix" = "查看修复方法"; @@ -136,7 +136,7 @@ "No devices connected" = "没有已连接的设备"; "No folders syncing yet" = "尚无正在同步的文件夹"; "No home server devices available for relay provisioning." = "没有可用于 Relay 配置的家庭服务器设备。"; -"No immediate relay problems detected." = "未检测到即时 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 successful sync has been recorded for your vaults yet." = "你的 Vault 尚未记录到成功同步。"; @@ -185,8 +185,8 @@ "Reconnect Obsidian Folder" = "重新连接 Obsidian 文件夹"; "Reconnect Obsidian access or adjust folder permissions on the host device." = "重新连接 Obsidian 访问权限,或在主机设备上调整文件夹权限。"; "Reconnect devices or add missing peers to restore continuous sync." = "重新连接设备或添加缺失的对端,以恢复持续同步。"; -"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "在 VaultSync 中重新连接 Obsidian 文件夹访问权限,然后执行一次前台重扫。"; -"Recreate or reselect the folder, then trigger a rescan." = "重新创建或重新选择文件夹,然后触发重扫。"; +"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "在 VaultSync 中重新连接 Obsidian 文件夹访问权限,然后执行一次前台重新扫描。"; +"Recreate or reselect the folder, then trigger a rescan." = "重新创建或重新选择文件夹,然后触发重新扫描。"; "Registered" = "已注册"; "Relay Backend" = "Relay 后端"; "Relay Diagnostics" = "Relay 诊断"; @@ -216,10 +216,10 @@ "Rename Failed" = "重命名失败"; "Renews" = "续订"; "Requesting camera access…" = "正在请求相机权限…"; -"Rescan Failed" = "重扫失败"; -"Rescan Failed Vaults" = "重扫失败的 Vault"; -"Rescan Vault" = "重扫 Vault"; -"Rescan failed vaults, then verify folder access and permissions." = "重扫失败的 Vault,然后检查文件夹访问和权限。"; +"Rescan Failed" = "重新扫描失败"; +"Rescan Failed Vaults" = "重新扫描失败的 Vault"; +"Rescan Vault" = "重新扫描 Vault"; +"Rescan failed vaults, then verify folder access and permissions." = "重新扫描失败的 Vault,然后检查文件夹访问和权限。"; "Resolve Conflict" = "解决冲突"; "Resolve Conflicts" = "解决冲突"; "Restore Purchases" = "恢复购买"; @@ -230,9 +230,9 @@ "Context: %@ · %@" = "上下文:%@ · %@"; "In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open." = "在选择器中选择“在我的 iPhone 上” → “Obsidian”,然后点按“打开”。"; "Missing" = "缺失"; -"Present" = "已提供"; +"Present" = "存在"; "Purchase Failed" = "购买失败"; -"Rescan All Vaults" = "重扫所有 Vault"; +"Rescan All Vaults" = "重新扫描所有 Vault"; "Sync Conflicts" = "同步冲突"; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; "Retry APNs Registration" = "重试 APNs 注册"; @@ -242,8 +242,8 @@ "Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "请重试该操作。如果仍然失败,请重启应用并检查设置中的诊断信息。"; "Review the affected device/folder setup in the app and retry." = "检查应用中相关设备/文件夹设置后再重试。"; "Review the value and try again." = "请检查该值后重试。"; -"Run Foreground Rescan" = "执行前台重扫"; -"Scan QR Code" = "扫描 QR Code"; +"Run Foreground Rescan" = "执行前台重新扫描"; +"Scan QR Code" = "扫描二维码"; "Scanning" = "扫描中"; "Scanning completed in %@" = "%@ 中的扫描已完成"; "Scanning started in %@" = "%@ 中开始扫描"; @@ -262,6 +262,8 @@ "Some entered data is invalid or incomplete." = "输入的数据无效或不完整。"; "Some shared peers are offline or unreachable right now." = "部分共享对端当前离线或无法访问。"; "Starting…" = "正在启动…"; +"Starting" = "正在启动"; +"Paused" = "已暂停"; "State" = "状态"; "Status" = "状态"; "Subscribe" = "订阅"; @@ -300,7 +302,7 @@ "Timed out" = "超时"; "Timeline updates were rate-limited to keep activity readable." = "为了保持活动记录可读,时间线更新已被限流。"; "Trigger Delivery" = "触发投递"; -"Trigger a vault rescan to refresh sync state." = "触发一次 Vault 重扫以刷新同步状态。"; +"Trigger a vault rescan to refresh sync state." = "触发一次 Vault 重新扫描以刷新同步状态。"; "Troubleshooting" = "故障排查"; "Unchanged line. %@" = "未更改行。%@"; "Unhealthy" = "异常"; @@ -326,7 +328,7 @@ "VaultSync could not verify this request." = "VaultSync 无法验证该请求。"; "VaultSync does not have the required permission for this action." = "VaultSync 没有所需的权限来执行此操作。"; "VaultSync found a configuration problem." = "VaultSync 检测到配置问题。"; -"VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync 需要相机权限来扫描 Syncthing 设备 ID 的 QR Code。请在设置中启用。"; +"VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync 需要相机权限来扫描 Syncthing 设备 ID 的二维码。请在设置中启用。"; "VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync 需要一次性访问你的 Obsidian 文件夹后,才能接受共享。"; "VaultSync reported an unexpected error." = "VaultSync 报告了一个意外错误。"; "Wait a moment and retry." = "请稍等片刻后重试。"; @@ -413,6 +415,7 @@ "Trash" = "废纸篓"; "Files already deleted on other devices." = "已在其他设备上删除的文件。"; "Git repository" = "Git 仓库"; +"Node modules" = "Node 模块"; "Version history — rarely useful on iPhone." = "版本历史 — 在 iPhone 上很少用到。"; "macOS metadata" = "macOS 元数据"; "Finder metadata files like .DS_Store." = "Finder 元数据文件,如 .DS_Store。"; @@ -457,7 +460,7 @@ "Cloud Relay looks reachable" = "Cloud Relay 似乎可达"; /* Subscription period units */ -"month" = "月"; +"month" = "个月"; "months" = "个月"; "week" = "周"; "weeks" = "周"; @@ -572,7 +575,7 @@ /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ "%1$@ — %2$@" = "%1$@ — %2$@"; -"%@: %@" = "%@: %@"; +"%@: %@" = "%@:%@"; "(%d%%)" = "(%d%%)"; "Add your computer or server" = "添加你的电脑或服务器"; "Add your server first" = "请先添加你的服务器"; From 146e5890e570eb66ff8976ad6fdd7d8e218f9625 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 22:20:44 +0200 Subject: [PATCH 19/22] chore: remove dead localization keys and unused Swift symbols Catalogs (all four, parity 587 -> 549): - Drop 38 unused string keys verified to have zero Swift references (neither L10n.tr/fmt nor bare SwiftUI string literals): the removed onboarding-overview page (11 onboarding.overview.* keys + 2 onboarding.accessibility hints) and ~25 superseded Cloud Relay redesign strings (Subscribe Monthly/Yearly, Make incoming sync instant, Not Now, Finish setup, the long pitch variants, etc.). Swift (verified no callers; test-only symbols left intact): - Theme: Color.vaultViolet (never wired), VaultSpacing.xxl (unused token). - SubscriptionManager: relayProductID + availableProduct (legacy single-product aliases; UI reads monthlyProduct/yearlyProduct directly). - VaultManager: stopAccess() (+ its now-orphaned // MARK: - Lifecycle). Builds clean for iphonesimulator. --- ios/VaultSync/Resources/Theme.swift | 9 ----- .../Services/SubscriptionManager.swift | 4 -- ios/VaultSync/Services/VaultManager.swift | 15 -------- ios/VaultSync/de.lproj/Localizable.strings | 38 ------------------- ios/VaultSync/en.lproj/Localizable.strings | 38 ------------------- ios/VaultSync/es.lproj/Localizable.strings | 38 ------------------- .../zh-Hans.lproj/Localizable.strings | 38 ------------------- 7 files changed, 180 deletions(-) diff --git a/ios/VaultSync/Resources/Theme.swift b/ios/VaultSync/Resources/Theme.swift index e35a944..78daac7 100644 --- a/ios/VaultSync/Resources/Theme.swift +++ b/ios/VaultSync/Resources/Theme.swift @@ -76,14 +76,6 @@ extension Color { light: (38, 50, 56), // #263238 dark: (176, 190, 197) // #B0BEC5 — readable as a muted accent in dark ) - - /// A violet token reserved for the Vault OS identity layer (a later phase can - /// promote it to the primary accent). Defined now so the dial exists; not yet - /// wired as the global tint. - static let vaultViolet = vaultColor( - light: (124, 92, 255), // #7C5CFF - dark: (167, 139, 250) // #A78BFA - ) } // MARK: - Semantic status palette @@ -119,7 +111,6 @@ enum VaultSpacing { static let m: CGFloat = 12 static let l: CGFloat = 16 static let xl: CGFloat = 24 - static let xxl: CGFloat = 32 } /// Continuous corner radii. Replaces the 8/10/11/12/14/22/24/28 spread. diff --git a/ios/VaultSync/Services/SubscriptionManager.swift b/ios/VaultSync/Services/SubscriptionManager.swift index d8eb247..696e176 100644 --- a/ios/VaultSync/Services/SubscriptionManager.swift +++ b/ios/VaultSync/Services/SubscriptionManager.swift @@ -12,15 +12,11 @@ final class SubscriptionManager { static let monthlyProductID = "eu.vaultsync.app.relay.monthly" static let yearlyProductID = "eu.vaultsync.app.relay.yearly" static let relayProductIDs: Set = [monthlyProductID, yearlyProductID] - /// Back-compat alias for call sites that need a single representative ID. - static let relayProductID = monthlyProductID private(set) var isRelaySubscribed = false private(set) var subscriptionExpiryDate: Date? private(set) var monthlyProduct: Product? private(set) var yearlyProduct: Product? - /// Primary product for legacy single-product call sites. - var availableProduct: Product? { monthlyProduct ?? yearlyProduct } private(set) var purchaseInProgress = false private(set) var isLoadingProduct = true private(set) var errorMessage: String? diff --git a/ios/VaultSync/Services/VaultManager.swift b/ios/VaultSync/Services/VaultManager.swift index de6153f..52ca491 100644 --- a/ios/VaultSync/Services/VaultManager.swift +++ b/ios/VaultSync/Services/VaultManager.swift @@ -219,21 +219,6 @@ final class VaultManager { .trimmingCharacters(in: .whitespacesAndNewlines) } - // MARK: - Lifecycle - - /// Stop security-scoped access. Call when Syncthing is stopped. - func stopAccess() { - if let url = obsidianDirectoryURL { - BookmarkService.stopAccessing(url: url) - logger.info("Stopped Obsidian directory access") - } - obsidianDirectoryURL = nil - isAccessible = false - detectedVaults = [] - accessIssue = nil - needsReconnect = false - } - // MARK: - Legacy Migration /// Remove old per-vault bookmarks after migration to obsidian-root. diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 843ab35..e88cf97 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -11,10 +11,8 @@ "Added line. %@" = "Hinzugefügte Zeile. %@"; "All Synced" = "Alles synchron"; "App Refresh" = "App-Aktualisierung"; -"Applying share" = "Freigabe wird angewendet"; "Applying…" = "Wird angewendet…"; "Authentication Error" = "Authentifizierungsfehler"; -"Auto-renews monthly. Cancel anytime in Settings → Subscriptions." = "Verlängert sich monatlich automatisch. Jederzeit unter Einstellungen → Abonnements kündbar."; "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"; @@ -33,7 +31,6 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Prüfe deinen Abostatus in den Einstellungen und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay aktiv"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Ändern sich Dateien auf deinem Server, weckt ein stiller Push VaultSync im selben Moment — Synchronisation wirkt sofort, ohne die App zu öffnen. Das Relay sendet nur ein Weck-Signal und sieht deine Notizen nie."; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay ist derzeit nicht abonniert. Push-ausgelöste Weck-Signale sind deaktiviert, bis das Abo aktiv ist."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay ist nicht abonniert. Starte zuerst ein Abo."; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay-Provisioning konnte das Relay-Backend nicht erreichen."; @@ -104,9 +101,7 @@ "Keep This" = "Diese behalten"; "Keep This Device's Version" = "Version dieses Geräts behalten"; "Keep both versions" = "Beide Versionen behalten"; -"Keep other device version" = "Version des anderen Geräts behalten"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Lass die App einen Moment geöffnet und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; -"Keep this device version" = "Version dieses Geräts behalten"; "Keeps your local file and renames the other device's file." = "Behält deine lokale Datei und benennt die Datei des anderen Geräts um."; "Last Check" = "Letzte Prüfung"; "Last Failure" = "Letzter Fehler"; @@ -128,12 +123,10 @@ "Needs Attention" = "Benötigt Aufmerksamkeit"; "Network Error" = "Netzwerkfehler"; "Never" = "Nie"; -"No Cloud Relay product is currently available." = "Derzeit ist kein Cloud Relay-Produkt verfügbar."; "No Syncthing peers available yet." = "Noch keine Syncthing-Peers verfügbar."; "No action needed." = "Keine Aktion erforderlich."; "No active pending shares" = "Keine aktiven ausstehenden Freigaben"; "No devices configured" = "Keine Geräte konfiguriert"; -"No devices connected" = "Keine Geräte verbunden"; "No folders syncing yet" = "Es werden noch keine Ordner synchronisiert"; "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."; @@ -143,7 +136,6 @@ "No vaults found" = "Keine Vaults gefunden"; "Not Configured" = "Nicht konfiguriert"; "Not Shared" = "Nicht geteilt"; -"Not Subscribed" = "Nicht abonniert"; "Not attempted" = "Nicht versucht"; "Not checked" = "Nicht geprüft"; "Not shared" = "Nicht geteilt"; @@ -153,7 +145,6 @@ "Obsidian access expired" = "Obsidian-Zugriff abgelaufen"; "Obsidian directory not accessible." = "Auf das Obsidian-Verzeichnis kann nicht zugegriffen werden."; "Obsidian folder not connected" = "Obsidian-Ordner nicht verbunden"; -"Open Relay Diagnostics" = "Relay-Diagnose öffnen"; "Open Settings" = "Einstellungen ö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."; @@ -346,21 +337,8 @@ "onboarding.welcome.benefit.obsidian" = "Für Obsidian Vaults gemacht"; "onboarding.welcome.benefit.noCloud" = "Kein Cloud-Konto erforderlich"; "onboarding.cta.continue" = "Weiter"; -"onboarding.overview.title" = "Was als Nächstes passiert"; -"onboarding.overview.subtitle" = "Die Einrichtung machst du gleich auf dem VaultSync-Startbildschirm. Diese Liste zeigt nur die Reihenfolge."; -"onboarding.overview.step1.title" = "Obsidian-Ordner verbinden"; -"onboarding.overview.step1.description" = "Erlaube VaultSync den Zugriff auf den Obsidian-Ordner auf diesem iPhone."; -"onboarding.overview.step2.title" = "Computer oder Server hinzufügen"; -"onboarding.overview.step2.description" = "Scanne einen QR-Code oder gib die Syncthing-Geräte-ID ein."; -"onboarding.overview.step3.title" = "Vault teilen"; -"onboarding.overview.step3.description" = "Teile deinen Obsidian Vault in Syncthing von deinem Computer mit diesem iPhone."; -"onboarding.overview.step4.title" = "Sync-Status verfolgen"; -"onboarding.overview.step4.description" = "VaultSync zeigt aktive Vaults, Probleme und Sync-Fortschritt auf dem Startbildschirm."; -"onboarding.overview.cloudRelay" = "Änderungen im selben Moment, ohne die App zu öffnen? Aktiviere Cloud Relay später in den Einstellungen — dazu kommt ein kleiner Helfer auf deinen Server."; "onboarding.cta.openVaultSync" = "VaultSync öffnen"; "onboarding.accessibility.page" = "Seite %d von 2"; -"onboarding.accessibility.continueHint" = "Öffnet eine kurze Setup-Übersicht."; -"onboarding.accessibility.openVaultSyncHint" = "Schließt das Onboarding und öffnet den VaultSync-Startbildschirm."; "Setup Status" = "Einrichtungsstatus"; "Check setup progress and troubleshooting tips." = "Prüfe Einrichtungsfortschritt und Hinweise zur Fehlerbehebung."; "Check the essentials for syncing. You can complete setup actions from the VaultSync home screen." = "Prüfe die wichtigsten Voraussetzungen für die Synchronisation. Die eigentliche Einrichtung erledigst du auf dem VaultSync-Startbildschirm."; @@ -551,27 +529,11 @@ "Prefer Docker Compose or a guided one-command installer? The full guide covers both." = "Lieber Docker Compose oder ein geführter Ein-Befehl-Installer? Die vollständige Anleitung deckt beides ab."; "Set Up Your Server" = "Server einrichten"; "Server setup command" = "Server-Einrichtungsbefehl"; -"Delivering wake-ups" = "Weck-Signale werden zugestellt"; "Last wake-up" = "Letztes Weck-Signal"; -"Waiting for your server" = "Warte auf deinen Server"; -"Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "Cloud Relay ist abonniert, aber es ist noch kein Weck-Signal angekommen. Schließe die einmalige Einrichtung auf deinem Server ab, um sofortige Updates zu erhalten."; -"Subscribe Monthly" = "Monatlich abonnieren"; -"Subscribe Yearly" = "Jährlich abonnieren"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "Wenn sich Dateien auf deinem Server ändern, weckt ein stiller Push VaultSync im selben Moment — die Synchronisation wirkt sofort, ohne die App zu öffnen. Das Relay sendet nur ein Weck-Signal — deine Notizen sieht es nie. Dafür ist ein einmaliger Helfer auf deinem Server nötig — tippe nach dem Abschluss des Abos auf „Server einrichten“."; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Verlängert sich automatisch bis zur Kündigung. Jederzeit kündbar unter Einstellungen → Abonnements."; -"Make incoming sync instant" = "Eingehende Änderungen sofort synchronisieren"; -"Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first." = "Dein Vault synchronisiert bereits, wenn du VaultSync öffnest. Cloud Relay fügt einen stillen Push hinzu, damit Änderungen auf deinem Server dieses iPhone im selben Moment erreichen — ganz ohne die App vorher zu öffnen."; -"Near-instant server → iPhone updates" = "Nahezu sofortige Updates Server → iPhone"; -"The relay only sends a wake-up — it never sees your notes" = "Das Relay sendet nur ein Weck-Signal — deine Notizen sieht es nie"; "Cancel anytime in Settings → Subscriptions" = "Jederzeit kündbar unter Einstellungen → Abonnements"; -"You’re subscribed to Cloud Relay" = "Du hast Cloud Relay abonniert"; -"One step left: Cloud Relay only delivers wake-ups once a small helper is running on your server." = "Noch ein Schritt: Cloud Relay liefert Weck-Signale erst, wenn ein kleiner Helfer auf deinem Server läuft."; -"Finish setup" = "Einrichtung abschließen"; -"Cloud Relay needs a one-time helper on your server, shown right after you subscribe." = "Cloud Relay braucht einen einmaligen Helfer auf deinem Server, der direkt nach dem Abo angezeigt wird."; -"Not Now" = "Jetzt nicht"; "Get instant updates" = "Sofortige Updates erhalten"; "Turn on Cloud Relay" = "Cloud Relay aktivieren"; -"Add your server as a Syncthing device before subscribing to Cloud Relay." = "Füge deinen Server als Syncthing-Gerät hinzu, bevor du Cloud Relay abonnierst."; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ "%1$@ — %2$@" = "%1$@ — %2$@"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index c0d828f..2814749 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -11,10 +11,8 @@ "Added line. %@" = "Added line. %@"; "All Synced" = "All Synced"; "App Refresh" = "App Refresh"; -"Applying share" = "Applying share"; "Applying…" = "Applying…"; "Authentication Error" = "Authentication Error"; -"Auto-renews monthly. Cancel anytime in Settings → Subscriptions." = "Auto-renews monthly. Cancel anytime in Settings → Subscriptions."; "Background Sync Completed" = "Background Sync Completed"; "Background Sync Could Not Access Obsidian" = "Background Sync Could Not Access Obsidian"; "Background Sync Could Not Start" = "Background Sync Could Not Start"; @@ -33,7 +31,6 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Check your subscription status in Settings and retry. If this persists, restart VaultSync."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay active"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes."; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay is not subscribed. Start a subscription first."; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay provisioning could not contact the relay backend."; @@ -104,9 +101,7 @@ "Keep This" = "Keep This"; "Keep This Device's Version" = "Keep This Device's Version"; "Keep both versions" = "Keep both versions"; -"Keep other device version" = "Keep other device version"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Keep the app open for a moment and retry. If this persists, restart VaultSync."; -"Keep this device version" = "Keep this device version"; "Keeps your local file and renames the other device's file." = "Keeps your local file and renames the other device's file."; "Last Check" = "Last Check"; "Last Failure" = "Last Failure"; @@ -128,12 +123,10 @@ "Needs Attention" = "Needs Attention"; "Network Error" = "Network Error"; "Never" = "Never"; -"No Cloud Relay product is currently available." = "No Cloud Relay product is currently available."; "No Syncthing peers available yet." = "No Syncthing peers available yet."; "No action needed." = "No action needed."; "No active pending shares" = "No active pending shares"; "No devices configured" = "No devices configured"; -"No devices connected" = "No devices connected"; "No folders syncing yet" = "No folders syncing yet"; "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."; @@ -143,7 +136,6 @@ "No vaults found" = "No vaults found"; "Not Configured" = "Not Configured"; "Not Shared" = "Not Shared"; -"Not Subscribed" = "Not Subscribed"; "Not attempted" = "Not attempted"; "Not checked" = "Not checked"; "Not shared" = "Not shared"; @@ -153,7 +145,6 @@ "Obsidian access expired" = "Obsidian access expired"; "Obsidian directory not accessible." = "Obsidian directory not accessible."; "Obsidian folder not connected" = "Obsidian folder not connected"; -"Open Relay Diagnostics" = "Open Relay Diagnostics"; "Open Settings" = "Open Settings"; "Open VaultSync" = "Open VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "Open VaultSync once to restart Syncthing, then retry."; @@ -346,21 +337,8 @@ "onboarding.welcome.benefit.obsidian" = "Built for Obsidian vaults"; "onboarding.welcome.benefit.noCloud" = "No cloud account required"; "onboarding.cta.continue" = "Continue"; -"onboarding.overview.title" = "What happens next"; -"onboarding.overview.subtitle" = "You’ll complete setup on the VaultSync home screen. This checklist only shows the order."; -"onboarding.overview.step1.title" = "Connect your Obsidian folder"; -"onboarding.overview.step1.description" = "Allow VaultSync to access the Obsidian folder on this iPhone."; -"onboarding.overview.step2.title" = "Add your computer or server"; -"onboarding.overview.step2.description" = "Scan a QR code or enter the Syncthing device ID."; -"onboarding.overview.step3.title" = "Share your vault"; -"onboarding.overview.step3.description" = "Share your Obsidian vault from Syncthing on your computer to this iPhone."; -"onboarding.overview.step4.title" = "Watch sync status"; -"onboarding.overview.step4.description" = "VaultSync shows active vaults, issues, and sync progress on the home screen."; -"onboarding.overview.cloudRelay" = "Want changes the moment they happen, without opening the app? Turn on Cloud Relay in Settings later — it adds a small helper on your server."; "onboarding.cta.openVaultSync" = "Open VaultSync"; "onboarding.accessibility.page" = "Page %d of 2"; -"onboarding.accessibility.continueHint" = "Opens a short setup overview."; -"onboarding.accessibility.openVaultSyncHint" = "Closes onboarding and opens the VaultSync home screen."; "Setup Status" = "Setup Status"; "Check setup progress and troubleshooting tips." = "Check setup progress and troubleshooting tips."; "Check the essentials for syncing. You can complete setup actions from the VaultSync home screen." = "Check the essentials for syncing. You can complete setup actions from the VaultSync home screen."; @@ -551,27 +529,11 @@ "Prefer Docker Compose or a guided one-command installer? The full guide covers both." = "Prefer Docker Compose or a guided one-command installer? The full guide covers both."; "Set Up Your Server" = "Set Up Your Server"; "Server setup command" = "Server setup command"; -"Delivering wake-ups" = "Delivering wake-ups"; "Last wake-up" = "Last wake-up"; -"Waiting for your server" = "Waiting for your server"; -"Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates."; -"Subscribe Monthly" = "Subscribe Monthly"; -"Subscribe Yearly" = "Subscribe Yearly"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing."; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions."; -"Make incoming sync instant" = "Make incoming sync instant"; -"Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first." = "Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first."; -"Near-instant server → iPhone updates" = "Near-instant server → iPhone updates"; -"The relay only sends a wake-up — it never sees your notes" = "The relay only sends a wake-up — it never sees your notes"; "Cancel anytime in Settings → Subscriptions" = "Cancel anytime in Settings → Subscriptions"; -"You’re subscribed to Cloud Relay" = "You’re subscribed to Cloud Relay"; -"One step left: Cloud Relay only delivers wake-ups once a small helper is running on your server." = "One step left: Cloud Relay only delivers wake-ups once a small helper is running on your server."; -"Finish setup" = "Finish setup"; -"Cloud Relay needs a one-time helper on your server, shown right after you subscribe." = "Cloud Relay needs a one-time helper on your server, shown right after you subscribe."; -"Not Now" = "Not Now"; "Get instant updates" = "Get instant updates"; "Turn on Cloud Relay" = "Turn on Cloud Relay"; -"Add your server as a Syncthing device before subscribing to Cloud Relay." = "Add your server as a Syncthing device before subscribing to Cloud Relay."; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ "%1$@ — %2$@" = "%1$@ — %2$@"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 7cb6e7f..6a7dc7e 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -11,10 +11,8 @@ "Added line. %@" = "Línea añadida. %@"; "All Synced" = "Todo sincronizado"; "App Refresh" = "Actualización de la app"; -"Applying share" = "Aplicando compartición"; "Applying…" = "Aplicando…"; "Authentication Error" = "Error de autenticación"; -"Auto-renews monthly. Cancel anytime in Settings → Subscriptions." = "Se renueva automáticamente cada mes. Cancela cuando quieras en Ajustes → Suscripciones."; "Background Sync Completed" = "Sincronización en segundo plano completada"; "Background Sync Could Not Access Obsidian" = "La sincronización en segundo plano no pudo acceder a Obsidian"; "Background Sync Could Not Start" = "La sincronización en segundo plano no pudo iniciarse"; @@ -33,7 +31,6 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Comprueba el estado de tu suscripción en Ajustes y vuelve a intentarlo. Si continúa, reinicia VaultSync."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay activo"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Cuando cambian archivos en tu servidor, un push silencioso despierta VaultSync en ese mismo momento, así la sincronización se siente inmediata sin abrir la app. El relay solo envía una señal de activación: nunca ve tus notas."; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay no está suscrito actualmente. Las señales de activación por push están desactivadas hasta que la suscripción esté activa."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay no está suscrito. Inicia primero una suscripción."; "Cloud Relay provisioning could not contact the relay backend." = "El aprovisionamiento de Cloud Relay no pudo contactar con el backend del relay."; @@ -104,9 +101,7 @@ "Keep This" = "Conservar esta"; "Keep This Device's Version" = "Conservar la versión de este dispositivo"; "Keep both versions" = "Conservar ambas versiones"; -"Keep other device version" = "Conservar la versión del otro dispositivo"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Mantén la app abierta un momento y vuelve a intentarlo. Si continúa, reinicia VaultSync."; -"Keep this device version" = "Conservar la versión de este dispositivo"; "Keeps your local file and renames the other device's file." = "Conserva tu archivo local y renombra el archivo del otro dispositivo."; "Last Check" = "Última comprobación"; "Last Failure" = "Último fallo"; @@ -128,12 +123,10 @@ "Needs Attention" = "Requiere atención"; "Network Error" = "Error de red"; "Never" = "Nunca"; -"No Cloud Relay product is currently available." = "Actualmente no hay ningún producto de Cloud Relay disponible."; "No Syncthing peers available yet." = "Aún no hay pares de Syncthing disponibles."; "No action needed." = "No se requiere ninguna acción."; "No active pending shares" = "No hay comparticiones pendientes activas"; "No devices configured" = "No hay dispositivos configurados"; -"No devices connected" = "No hay dispositivos conectados"; "No folders syncing yet" = "Aún no se sincroniza ninguna carpeta"; "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."; @@ -143,7 +136,6 @@ "No vaults found" = "No se encontraron Vaults"; "Not Configured" = "No configurado"; "Not Shared" = "No compartido"; -"Not Subscribed" = "No suscrito"; "Not attempted" = "No intentado"; "Not checked" = "No comprobado"; "Not shared" = "No compartido"; @@ -153,7 +145,6 @@ "Obsidian access expired" = "El acceso a Obsidian caducó"; "Obsidian directory not accessible." = "No se puede acceder al directorio de Obsidian."; "Obsidian folder not connected" = "Carpeta de Obsidian no conectada"; -"Open Relay Diagnostics" = "Abrir diagnóstico del Relay"; "Open Settings" = "Abrir Ajustes"; "Open VaultSync" = "Abrir VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "Abre VaultSync una vez para reiniciar Syncthing y vuelve a intentarlo."; @@ -346,21 +337,8 @@ "onboarding.welcome.benefit.obsidian" = "Diseñado para Vaults de Obsidian"; "onboarding.welcome.benefit.noCloud" = "No requiere cuenta en la nube"; "onboarding.cta.continue" = "Continuar"; -"onboarding.overview.title" = "Qué ocurre a continuación"; -"onboarding.overview.subtitle" = "Completarás la configuración en la pantalla de inicio de VaultSync. Esta lista solo muestra el orden."; -"onboarding.overview.step1.title" = "Conecta tu carpeta de Obsidian"; -"onboarding.overview.step1.description" = "Permite que VaultSync acceda a la carpeta de Obsidian en este iPhone."; -"onboarding.overview.step2.title" = "Añade tu ordenador o servidor"; -"onboarding.overview.step2.description" = "Escanea un código QR o introduce el ID de dispositivo de Syncthing."; -"onboarding.overview.step3.title" = "Comparte tu Vault"; -"onboarding.overview.step3.description" = "Comparte tu Vault de Obsidian desde Syncthing en tu ordenador con este iPhone."; -"onboarding.overview.step4.title" = "Sigue el estado de sincronización"; -"onboarding.overview.step4.description" = "VaultSync muestra los Vaults activos, los problemas y el progreso de sincronización en la pantalla de inicio."; -"onboarding.overview.cloudRelay" = "¿Quieres los cambios en el momento en que ocurren, sin abrir la app? Activa Cloud Relay más tarde en Ajustes: añade un pequeño asistente en tu servidor."; "onboarding.cta.openVaultSync" = "Abrir VaultSync"; "onboarding.accessibility.page" = "Página %d de 2"; -"onboarding.accessibility.continueHint" = "Abre una breve descripción general de la configuración."; -"onboarding.accessibility.openVaultSyncHint" = "Cierra la introducción y abre la pantalla de inicio de VaultSync."; "Setup Status" = "Estado de la configuración"; "Check setup progress and troubleshooting tips." = "Consulta el progreso de la configuración y los consejos para resolver problemas."; "Check the essentials for syncing. You can complete setup actions from the VaultSync home screen." = "Comprueba lo esencial para sincronizar. Puedes completar las acciones de configuración desde la pantalla de inicio de VaultSync."; @@ -551,27 +529,11 @@ "Prefer Docker Compose or a guided one-command installer? The full guide covers both." = "¿Prefieres Docker Compose o un instalador guiado de un solo comando? La guía completa cubre ambos."; "Set Up Your Server" = "Configura tu servidor"; "Server setup command" = "Comando de configuración del servidor"; -"Delivering wake-ups" = "Entregando señales de activación"; "Last wake-up" = "Última señal de activación"; -"Waiting for your server" = "Esperando a tu servidor"; -"Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "Cloud Relay está suscrito, pero aún no ha llegado ninguna señal de activación. Completa la configuración única en tu servidor para empezar a recibir actualizaciones instantáneas."; -"Subscribe Monthly" = "Suscripción mensual"; -"Subscribe Yearly" = "Suscripción anual"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "Cuando los archivos cambian en tu servidor, un push silencioso despierta VaultSync en ese mismo momento, de modo que la sincronización se siente instantánea sin abrir la app. El relay solo envía una señal de activación; nunca ve tus notas. Necesita un asistente único en tu servidor: toca «Configura tu servidor» después de suscribirte."; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Se renueva automáticamente hasta que se cancele. Cancela cuando quieras en Ajustes → Suscripciones."; -"Make incoming sync instant" = "Haz que la sincronización entrante sea instantánea"; -"Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first." = "Tu Vault ya se sincroniza cuando abres VaultSync. Cloud Relay añade una notificación silenciosa para que los cambios en tu servidor lleguen a este iPhone en el momento en que ocurren, sin necesidad de abrir la app primero."; -"Near-instant server → iPhone updates" = "Actualizaciones servidor → iPhone casi instantáneas"; -"The relay only sends a wake-up — it never sees your notes" = "El relay solo envía una señal de activación; nunca ve tus notas"; "Cancel anytime in Settings → Subscriptions" = "Cancela cuando quieras en Ajustes → Suscripciones"; -"You’re subscribed to Cloud Relay" = "Estás suscrito a Cloud Relay"; -"One step left: Cloud Relay only delivers wake-ups once a small helper is running on your server." = "Queda un paso: Cloud Relay solo entrega señales de activación cuando un pequeño asistente se ejecuta en tu servidor."; -"Finish setup" = "Completar configuración"; -"Cloud Relay needs a one-time helper on your server, shown right after you subscribe." = "Cloud Relay necesita un asistente único en tu servidor, que se muestra justo después de suscribirte."; -"Not Now" = "Ahora no"; "Get instant updates" = "Recibe actualizaciones instantáneas"; "Turn on Cloud Relay" = "Activar Cloud Relay"; -"Add your server as a Syncthing device before subscribing to Cloud Relay." = "Añade tu servidor como dispositivo de Syncthing antes de suscribirte a Cloud Relay."; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ "%1$@ — %2$@" = "%1$@ — %2$@"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index f30c1ac..a93ff94 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -11,10 +11,8 @@ "Added line. %@" = "已添加行。%@"; "All Synced" = "全部已同步"; "App Refresh" = "应用刷新"; -"Applying share" = "正在应用共享"; "Applying…" = "正在应用…"; "Authentication Error" = "认证错误"; -"Auto-renews monthly. Cancel anytime in Settings → Subscriptions." = "每月自动续费。可随时在“设置 → 订阅”中取消。"; "Background Sync Completed" = "后台同步已完成"; "Background Sync Could Not Access Obsidian" = "后台同步无法访问 Obsidian"; "Background Sync Could Not Start" = "后台同步无法启动"; @@ -33,7 +31,6 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "在设置中检查订阅状态后重试。如果问题持续存在,请重启 VaultSync。"; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay 已启用"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "服务器上的文件一发生变化,静默推送就会在第一时间唤醒 VaultSync,无需打开应用,同步仿佛即时完成。Relay 只发送唤醒信号,绝不会看到你的笔记。"; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "当前未订阅 Cloud Relay。在订阅激活前,基于推送的唤醒功能将被禁用。"; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay 尚未订阅。请先开始订阅。"; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay 配置无法连接到 relay 后端。"; @@ -104,9 +101,7 @@ "Keep This" = "保留此版本"; "Keep This Device's Version" = "保留此设备版本"; "Keep both versions" = "保留两个版本"; -"Keep other device version" = "保留另一台设备版本"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "请保持应用打开片刻后重试。如果问题持续存在,请重启 VaultSync。"; -"Keep this device version" = "保留此设备版本"; "Keeps your local file and renames the other device's file." = "保留你的本地文件,并重命名另一台设备的文件。"; "Last Check" = "上次检查"; "Last Failure" = "上次失败"; @@ -128,12 +123,10 @@ "Needs Attention" = "需要处理"; "Network Error" = "网络错误"; "Never" = "从未"; -"No Cloud Relay product is currently available." = "当前没有可用的 Cloud Relay 产品。"; "No Syncthing peers available yet." = "尚无可用的 Syncthing 对端。"; "No action needed." = "无需操作。"; "No active pending shares" = "没有活动的待处理共享"; "No devices configured" = "未配置任何设备"; -"No devices connected" = "没有已连接的设备"; "No folders syncing yet" = "尚无正在同步的文件夹"; "No home server devices available for relay provisioning." = "没有可用于 Relay 配置的家庭服务器设备。"; "No immediate relay problems detected." = "目前未检测到 Relay 问题。"; @@ -143,7 +136,6 @@ "No vaults found" = "未找到 Vault"; "Not Configured" = "未配置"; "Not Shared" = "未共享"; -"Not Subscribed" = "未订阅"; "Not attempted" = "未尝试"; "Not checked" = "未检查"; "Not shared" = "未共享"; @@ -153,7 +145,6 @@ "Obsidian access expired" = "Obsidian 访问已过期"; "Obsidian directory not accessible." = "无法访问 Obsidian 目录。"; "Obsidian folder not connected" = "Obsidian 文件夹未连接"; -"Open Relay Diagnostics" = "打开 Relay 诊断"; "Open Settings" = "打开设置"; "Open VaultSync" = "打开 VaultSync"; "Open VaultSync once to restart Syncthing, then retry." = "打开 VaultSync 一次以重启 Syncthing,然后重试。"; @@ -346,21 +337,8 @@ "onboarding.welcome.benefit.obsidian" = "专为 Obsidian Vault 打造"; "onboarding.welcome.benefit.noCloud" = "无需云账号"; "onboarding.cta.continue" = "继续"; -"onboarding.overview.title" = "接下来会发生什么"; -"onboarding.overview.subtitle" = "你将在 VaultSync 主屏幕完成设置。此清单只说明步骤顺序。"; -"onboarding.overview.step1.title" = "连接你的 Obsidian 文件夹"; -"onboarding.overview.step1.description" = "允许 VaultSync 访问此 iPhone 上的 Obsidian 文件夹。"; -"onboarding.overview.step2.title" = "添加你的电脑或服务器"; -"onboarding.overview.step2.description" = "扫描二维码或输入 Syncthing 设备 ID。"; -"onboarding.overview.step3.title" = "共享你的 Vault"; -"onboarding.overview.step3.description" = "在电脑上的 Syncthing 中将你的 Obsidian Vault 共享到这台 iPhone。"; -"onboarding.overview.step4.title" = "查看同步状态"; -"onboarding.overview.step4.description" = "VaultSync 会在主屏幕显示活动 Vault、问题和同步进度。"; -"onboarding.overview.cloudRelay" = "想让改动在发生的那一刻就送达、无需打开应用?稍后在“设置”中启用 Cloud Relay——它会在你的服务器上添加一个小助手。"; "onboarding.cta.openVaultSync" = "打开 VaultSync"; "onboarding.accessibility.page" = "第 %d 页,共 2 页"; -"onboarding.accessibility.continueHint" = "打开简短的设置概览。"; -"onboarding.accessibility.openVaultSyncHint" = "关闭引导并打开 VaultSync 主屏幕。"; "Setup Status" = "设置状态"; "Check setup progress and troubleshooting tips." = "检查设置进度和故障排查提示。"; "Check the essentials for syncing. You can complete setup actions from the VaultSync home screen." = "检查同步所需的关键项目。你可以在 VaultSync 主屏幕完成设置操作。"; @@ -551,27 +529,11 @@ "Prefer Docker Compose or a guided one-command installer? The full guide covers both." = "更喜欢 Docker Compose 或引导式的一条命令安装程序?完整指南两者都涵盖。"; "Set Up Your Server" = "设置你的服务器"; "Server setup command" = "服务器设置命令"; -"Delivering wake-ups" = "正在送达唤醒信号"; "Last wake-up" = "上次唤醒"; -"Waiting for your server" = "正在等待你的服务器"; -"Cloud Relay is subscribed, but no wake-up has arrived yet. Finish the one-time setup on your server to start receiving instant updates." = "已订阅 Cloud Relay,但尚未收到唤醒信号。请在你的服务器上完成一次性设置,即可开始接收即时更新。"; -"Subscribe Monthly" = "按月订阅"; -"Subscribe Yearly" = "按年订阅"; -"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes. It needs a one-time helper on your server — tap Set Up Your Server after subscribing." = "当你的服务器上的文件发生更改时,静默推送会在那一刻唤醒 VaultSync,因此无需打开应用,同步也会即时进行。Relay 只发送唤醒信号——它从不查看你的笔记。它需要在你的服务器上运行一个一次性助手——订阅后请点按“设置你的服务器”。"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "自动续订,直至取消。可随时在“设置 → 订阅”中取消。"; -"Make incoming sync instant" = "让传入的同步即时完成"; -"Your vault already syncs when you open VaultSync. Cloud Relay adds a silent push so changes on your server reach this iPhone the moment they happen — no need to open the app first." = "打开 VaultSync 时,你的 Vault 就已经在同步。Cloud Relay 增加了静默推送,让你服务器上的改动在发生的那一刻就送达这部 iPhone——无需先打开应用。"; -"Near-instant server → iPhone updates" = "近乎即时的服务器 → iPhone 更新"; -"The relay only sends a wake-up — it never sees your notes" = "Relay 只发送唤醒信号——它从不查看你的笔记"; "Cancel anytime in Settings → Subscriptions" = "可随时在“设置 → 订阅”中取消"; -"You’re subscribed to Cloud Relay" = "你已订阅 Cloud Relay"; -"One step left: Cloud Relay only delivers wake-ups once a small helper is running on your server." = "还差一步:只有当一个小助手在你的服务器上运行时,Cloud Relay 才会送达唤醒信号。"; -"Finish setup" = "完成设置"; -"Cloud Relay needs a one-time helper on your server, shown right after you subscribe." = "Cloud Relay 需要在你的服务器上运行一个一次性助手,订阅后会立即显示。"; -"Not Now" = "暂不"; "Get instant updates" = "获取即时更新"; "Turn on Cloud Relay" = "启用 Cloud Relay"; -"Add your server as a Syncthing device before subscribing to Cloud Relay." = "在订阅 Cloud Relay 之前,请先将你的服务器添加为 Syncthing 设备。"; /* Redesign (Vault OS) strings — added in branch redesign/vault-os-foundation */ "%1$@ — %2$@" = "%1$@ — %2$@"; From 75ccb26e7480c1255b579456e79f0440941dce15 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 22:22:10 +0200 Subject: [PATCH 20/22] fix(l10n): shorten overflowing button and status labels (de/es/zh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shorten translation values for width-constrained controls so they no longer truncate/wrap on iPhone (English keys unchanged): de — tight .controlSize(.small) SyncIssuesView buttons + SyncStatus pills: - "Run Foreground Rescan": "Erneuten Scan im Vordergrund ausführen" -> "Im Vordergrund scannen" - "Add or Reconnect Device": "Gerät hinzufügen oder neu verbinden" -> "Gerät verbinden" - "Rescan Failed Vaults": "Fehlgeschlagene Vaults erneut scannen" -> "Fehler-Vaults neu scannen" - "Sync Error" -> "Sync-Fehler", "Sync Issue" -> "Sync-Problem", "Needs Attention" -> "Aktion nötig" (status pills; also widget catalog) es: - "Add or Reconnect Device" -> "Añadir o reconectar" - "Run Foreground Rescan" -> "Reescanear ahora" - "Timed out" -> "Sin respuesta" - "Reconnect Obsidian Directory" (alert action) -> "Reconectar Obsidian" zh: - "Accept First Pending Share" -> "接受待处理共享" - "Add or Reconnect Device" -> "添加或重连设备" --- ios/VaultSync/de.lproj/Localizable.strings | 12 ++++++------ ios/VaultSync/es.lproj/Localizable.strings | 8 ++++---- ios/VaultSync/zh-Hans.lproj/Localizable.strings | 4 ++-- ios/VaultSyncWidget/de.lproj/Localizable.strings | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index e88cf97..b1de454 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -7,7 +7,7 @@ "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 hinzufügen oder neu verbinden"; +"Add or Reconnect Device" = "Gerät verbinden"; "Added line. %@" = "Hinzugefügte Zeile. %@"; "All Synced" = "Alles synchron"; "App Refresh" = "App-Aktualisierung"; @@ -120,7 +120,7 @@ "Manage Subscription" = "Abo verwalten"; "Name" = "Name"; "Name (optional)" = "Name (optional)"; -"Needs Attention" = "Benötigt Aufmerksamkeit"; +"Needs Attention" = "Aktion nötig"; "Network Error" = "Netzwerkfehler"; "Never" = "Nie"; "No Syncthing peers available yet." = "Noch keine Syncthing-Peers verfügbar."; @@ -208,7 +208,7 @@ "Renews" = "Verlängert sich"; "Requesting camera access…" = "Kamerazugriff wird angefordert…"; "Rescan Failed" = "Erneuter Scan fehlgeschlagen"; -"Rescan Failed Vaults" = "Fehlgeschlagene Vaults erneut scannen"; +"Rescan Failed Vaults" = "Fehler-Vaults neu scannen"; "Rescan Vault" = "Vault erneut scannen"; "Rescan failed vaults, then verify folder access and permissions." = "Scanne fehlgeschlagene Vaults erneut und prüfe dann Ordnerzugriff und Berechtigungen."; "Resolve Conflict" = "Konflikt lösen"; @@ -233,7 +233,7 @@ "Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "Versuche die Aktion erneut. Wenn sie weiter fehlschlägt, starte die App neu und prüfe die Diagnose in den Einstellungen."; "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" = "Erneuten Scan im Vordergrund ausführen"; +"Run Foreground Rescan" = "Im Vordergrund scannen"; "Scan QR Code" = "QR Code scannen"; "Scanning" = "Scannen"; "Scanning completed in %@" = "Scan in %@ abgeschlossen"; @@ -262,8 +262,8 @@ "Sync Activity" = "Synchronisationsaktivität"; "Sync Activity Looks Stale" = "Synchronisationsaktivität wirkt veraltet"; "Sync Engine Not Running" = "Synchronisations-Engine läuft nicht"; -"Sync Error" = "Synchronisationsfehler"; -"Sync Issue" = "Synchronisationsproblem"; +"Sync Error" = "Sync-Fehler"; +"Sync Issue" = "Sync-Problem"; "Sync Issues" = "Synchronisationsprobleme"; "Sync Status" = "Synchronisationsstatus"; "Sync completed in %@" = "Synchronisation in %@ abgeschlossen"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 6a7dc7e..947062e 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -7,7 +7,7 @@ "Add" = "Añadir"; "Add Device" = "Añadir dispositivo"; "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "Añade un dispositivo con su ID de dispositivo de Syncthing. Lo encontrarás en la interfaz web de Syncthing en Actions > Show ID."; -"Add or Reconnect Device" = "Añadir o volver a conectar dispositivo"; +"Add or Reconnect Device" = "Añadir o reconectar"; "Added line. %@" = "Línea añadida. %@"; "All Synced" = "Todo sincronizado"; "App Refresh" = "Actualización de la app"; @@ -172,7 +172,7 @@ "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"; "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" = "Volver a conectar el directorio de Obsidian"; +"Reconnect Obsidian Directory" = "Reconectar 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."; @@ -233,7 +233,7 @@ "Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "Reintenta la acción. Si sigue fallando, reinicia la app y revisa el diagnóstico en Ajustes."; "Review the affected device/folder setup in the app and retry." = "Revisa la configuración del dispositivo/carpeta afectado en la app y vuelve a intentarlo."; "Review the value and try again." = "Revisa el valor y vuelve a intentarlo."; -"Run Foreground Rescan" = "Ejecutar reescaneo en primer plano"; +"Run Foreground Rescan" = "Reescanear ahora"; "Scan QR Code" = "Escanear código QR"; "Scanning" = "Escaneando"; "Scanning completed in %@" = "Escaneo completado en %@"; @@ -290,7 +290,7 @@ "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" = "Tiempo de espera agotado"; +"Timed out" = "Sin respuesta"; "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"; "Trigger a vault rescan to refresh sync state." = "Inicia un reescaneo del Vault para actualizar el estado de sincronización."; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index a93ff94..394db65 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" = "操作"; @@ -7,7 +7,7 @@ "Add" = "添加"; "Add Device" = "添加设备"; "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "使用 Syncthing 设备 ID 添加设备。你可以在 Syncthing 网页界面的 Actions > Show ID 中找到它。"; -"Add or Reconnect Device" = "添加或重新连接设备"; +"Add or Reconnect Device" = "添加或重连设备"; "Added line. %@" = "已添加行。%@"; "All Synced" = "全部已同步"; "App Refresh" = "应用刷新"; diff --git a/ios/VaultSyncWidget/de.lproj/Localizable.strings b/ios/VaultSyncWidget/de.lproj/Localizable.strings index 3765649..5f172b8 100644 --- a/ios/VaultSyncWidget/de.lproj/Localizable.strings +++ b/ios/VaultSyncWidget/de.lproj/Localizable.strings @@ -1,6 +1,6 @@ // Status labels reused from the main app "Idle" = "Leerlauf"; -"Needs Attention" = "Benötigt Aufmerksamkeit"; +"Needs Attention" = "Aktion nötig"; "Open VaultSync" = "VaultSync öffnen"; "Syncing" = "Wird synchronisiert"; From 72d11c9e7dd5b3eeaed5d41b8b9d426298f001cb Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 22:45:00 +0200 Subject: [PATCH 21/22] docs: document the UI redesign in the unreleased 1.5.0 changelog + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.5.0 hasn't shipped yet, so the redesign folds into the existing entry rather than a new version (no bump). Added the redesign highlights (design system, tabbed home, do-it onboarding, dedicated Cloud Relay tab, never-color-only status, conflict affordance, honest progress), plus the notify sidecar crash-loop fix, the widget false-"all good" fix, and the de/es/zh translation pass. Updated the now-stale "Settings → Cloud Relay" references to the new Relay tab and refreshed the README "What's New" to lead with the redesign. --- CHANGELOG.md | 13 +++++++++++-- README.md | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d85d60..f764e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,18 +8,27 @@ All notable changes to VaultSync are documented here. ### Added -- **Guided Cloud Relay server setup** — Cloud Relay needs a small helper (`vaultsync-notify`) on your server, and the app now says so clearly. A new **Set Up Your Server** screen explains the step and offers a copyable one-line command (with the relay URL pre-filled); it appears right after you subscribe and from Settings → Cloud Relay. Localized in English, German, Spanish, and Simplified Chinese. +- **A complete visual redesign** — VaultSync moves to a coherent design system: a single brand accent (instead of stray system blue), a status palette that resolves correctly in light and dark mode, and a shared component kit used across the app and the home-screen widget. +- **Tabbed home screen** — the single overloaded screen is split into **Sync**, **Devices**, and **Cloud Relay** tabs, led by a persistent status header that states one glanceable truth ("All Synced" / "Syncing…" / "Needs Attention"). +- **Onboarding that does it for you** — onboarding steps now launch the real task (choose your Obsidian folder, pair a device, scan a QR code) and turn green as you complete them, instead of describing setup in prose. +- **Guided Cloud Relay server setup** — Cloud Relay needs a small helper (`vaultsync-notify`) on your server, and the app now says so clearly. A new **Set Up Your Server** screen explains the step and offers a copyable one-line command (with the relay URL pre-filled); it appears right after you subscribe and from the Cloud Relay tab. Localized in English, German, Spanish, and Simplified Chinese. - **Yearly Cloud Relay plan** — Cloud Relay is now available as a yearly subscription in addition to monthly, at a lower effective monthly price. Both prices are read from StoreKit and shown correctly per storefront. - **In-context Cloud Relay offer** — After your first successful sync, VaultSync offers Cloud Relay in context (with a one-tap path into server setup), and the home screen shows an unobtrusive upgrade row for non-subscribers. ### Changed -- **Honest Cloud Relay status** — The setup checklist and Settings no longer call Cloud Relay "ready" just because you subscribed. They now reflect real delivery: *waiting for your server* until a wake-up actually arrives, then *delivering wake-ups*. +- **Cloud Relay has its own tab** — Cloud Relay moved out of Settings into a dedicated tab that brings the subscribe offer, server-helper setup, delivery status, diagnostics, and manage-subscription together. When you're not subscribed it leads with a focused, privacy-first pitch — a tiny wake-up on top of your already-free peer-to-peer sync, not cloud storage — and lists the monthly plan first with the yearly plan shown as savings. +- **Status is never color-only** — every sync state pairs an icon and a text label with its color, so it is clear for VoiceOver and color-blind users and reads identically on the home screen, the activity log, and the widget. +- **Clearer conflict resolution and honest progress** — Keep This / Keep Both / Keep Other are full-width buttons that always confirm before changing any files (previously "Keep Both" applied with none); the home screen lists your actual Obsidian vaults instead of the raw sync folder; and the vault rescan reflects the real scan state instead of a fixed timer. +- **Honest Cloud Relay status** — The setup checklist and the Cloud Relay tab no longer call Cloud Relay "ready" just because you subscribed. They now reflect real delivery: *waiting for your server* until a wake-up actually arrives, then *delivering wake-ups*. - **Cloud Relay monthly price** — The monthly price was raised; the app always shows the live, storefront-correct price from StoreKit and never hard-codes an amount. - **Verified subscriptions** — The relay now verifies the App Store signed transaction against Apple's certificate chain and enforces the subscription expiry server-side, so an expired or cancelled subscription stops receiving wake-ups. ### Fixed +- **Cloud Relay server helper no longer crash-loops** — when a subscription is inactive the relay replies with a 4xx; the `vaultsync-notify` helper treated that as fatal and, under `restart: unless-stopped`, restarted in a loop. It now logs the response and keeps running. +- **The widget can't show a false "all good"** — an unrecognised sync status now surfaces as *needs attention* instead of silently falling back to the green idle state, and the widget gained VoiceOver labels. +- **Localization** — the redesign's new strings are translated to German, Spanish, and Simplified Chinese with full key parity across all four languages, and existing translation errors and relay terminology drift were corrected. - **iPhone and iPad both get wake-ups** — When an iPhone and iPad shared the same server, they could displace each other's push registration so only one received Cloud Relay wake-ups. Both are now kept, and tokens Apple reports as invalid are cleaned up automatically. ## [1.4.0] — 2026-05-30 diff --git a/README.md b/README.md index 207ed7f..16e1522 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ VaultSync is **not** a hosted note-sync service and **not** a magic always-on da ## What’s New — v1.5.0 -Cloud Relay now guides you through the one-time server helper it needs (right after you subscribe), adds a **yearly plan**, and shows honest delivery status instead of just “subscribed”. The relay verifies your subscription with Apple and enforces expiry, and an iPhone and iPad sharing one server both receive wake-ups. See [CHANGELOG.md](CHANGELOG.md) for details. +VaultSync gets a top-to-bottom **visual redesign**: a tabbed home screen (Sync · Devices · Cloud Relay) with a persistent, glanceable status header, a coherent design system that is correct in light and dark mode, status that never relies on color alone, and onboarding whose steps actually run the setup for you. **Cloud Relay** moves into its own tab with honest delivery status and a clearer, privacy-first pitch — and keeps its yearly plan and Apple-verified subscriptions, while its server helper no longer crash-loops on an inactive subscription. See [CHANGELOG.md](CHANGELOG.md) for details. --- From b8467ff6d47b2f238ba11b7a81413bbc59cb5728 Mon Sep 17 00:00:00 2001 From: psimaker Date: Sun, 31 May 2026 22:59:06 +0200 Subject: [PATCH 22/22] fix(ui,l10n): resolve actionable CodeRabbit findings (skip 3 nitpicks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stale activation route: in-app strings + all four catalogs no longer send users to "Settings → Cloud Relay" (removed in the redesign) — they now point to the Relay tab (checklist, onboarding, and the relay-unreachable / auth remediations). - SubscribePlanPicker: yearlySavingsText returns nil instead of a false "Best value" when the yearly plan has no real discount; Restore Purchases is disabled and guarded while a purchase is in progress. - AddDeviceSheet: disable Add and guard addDevice() against whitespace-only IDs. - StatusBadge: expose the status meaning as accessibilityValue when its label is overridden, so VoiceOver keeps the severity. - Widget: buttonLabel branches on the canonical decoded status (handles "scanning"). - zh: fix the ungrammatical "more than %d %@ ago" stale-sync phrasing. Deliberately skipped nitpicks: errors.As in notify isFatal (no wrapping today), StatusRow .combine->.contain (combine is correct for the current non-interactive trailing), and CI checkout SHA-pin/persist-credentials (matches the repo's existing unpinned convention — better as a separate repo-wide change). Key parity 549 across en/de/es/zh; build + full test suite + design-token lint green. --- ios/VaultSync/Models/SyncUserError.swift | 4 ++-- .../ViewModels/SetupChecklistViewModel.swift | 4 ++-- ios/VaultSync/Views/AddDeviceSheet.swift | 3 ++- ios/VaultSync/Views/DesignSystem.swift | 3 +++ ios/VaultSync/Views/OnboardingView.swift | 2 +- ios/VaultSync/Views/SubscribePlanPicker.swift | 5 +++-- ios/VaultSync/de.lproj/Localizable.strings | 10 +++++----- ios/VaultSync/en.lproj/Localizable.strings | 10 +++++----- ios/VaultSync/es.lproj/Localizable.strings | 10 +++++----- ios/VaultSync/zh-Hans.lproj/Localizable.strings | 12 ++++++------ ios/VaultSyncWidget/VaultSyncWidget.swift | 2 +- 11 files changed, 35 insertions(+), 30 deletions(-) diff --git a/ios/VaultSync/Models/SyncUserError.swift b/ios/VaultSync/Models/SyncUserError.swift index c723932..d4a169a 100644 --- a/ios/VaultSync/Models/SyncUserError.swift +++ b/ios/VaultSync/Models/SyncUserError.swift @@ -50,7 +50,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { category: .relayUnreachable, title: L10n.tr("Relay Unreachable"), message: L10n.tr("VaultSync could not reach the Cloud Relay service."), - remediation: L10n.tr("Check your internet connection and try the relay health check again in Settings."), + remediation: L10n.tr("Check your internet connection and try the relay health check again on the Relay tab."), technicalDetails: rawMessage ) } @@ -80,7 +80,7 @@ struct SyncUserError: Identifiable, Equatable, Sendable { category: .auth, title: L10n.tr("Authentication Error"), message: L10n.tr("VaultSync could not verify this request."), - remediation: L10n.tr("Check your subscription status in Settings and retry. If this persists, restart VaultSync."), + remediation: L10n.tr("Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync."), technicalDetails: rawMessage ) } diff --git a/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift b/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift index f6c3baf..3e7fc83 100644 --- a/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift +++ b/ios/VaultSync/ViewModels/SetupChecklistViewModel.swift @@ -211,7 +211,7 @@ final class SetupChecklistViewModel { requirement: .relayConfigured, title: L10n.tr("Cloud Relay"), description: L10n.tr("Cloud Relay is not enabled. Without it, incoming changes arrive when you open VaultSync."), - remediation: L10n.tr("Enable Cloud Relay in Settings if you want changes pushed the moment they happen."), + remediation: L10n.tr("Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen."), isOptional: true, isComplete: false ) @@ -220,7 +220,7 @@ final class SetupChecklistViewModel { requirement: .relayConfigured, title: L10n.tr("Cloud Relay — finish server setup"), 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 Settings → Cloud Relay → Set Up Your Server."), + remediation: L10n.tr("Set up the server helper from the Relay tab → Set Up Your Server."), isOptional: true, isComplete: false ) diff --git a/ios/VaultSync/Views/AddDeviceSheet.swift b/ios/VaultSync/Views/AddDeviceSheet.swift index aa69bdf..5c881de 100644 --- a/ios/VaultSync/Views/AddDeviceSheet.swift +++ b/ios/VaultSync/Views/AddDeviceSheet.swift @@ -46,7 +46,7 @@ struct AddDeviceSheet: View { } ToolbarItem(placement: .confirmationAction) { Button("Add") { addDevice() } - .disabled(deviceID.isEmpty) + .disabled(deviceID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } } } @@ -55,6 +55,7 @@ struct AddDeviceSheet: View { private func addDevice() { let id = deviceID.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty else { return } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) if let err = syncthingManager.addDevice(id: id, name: trimmedName) { onError(SyncUserError.from(rawMessage: err, fallbackTitle: L10n.tr("Could Not Add Device")).userVisibleDescription) diff --git a/ios/VaultSync/Views/DesignSystem.swift b/ios/VaultSync/Views/DesignSystem.swift index 6d11aa7..b11e0ff 100644 --- a/ios/VaultSync/Views/DesignSystem.swift +++ b/ios/VaultSync/Views/DesignSystem.swift @@ -37,6 +37,9 @@ struct StatusBadge: View { } .accessibilityElement(children: .combine) .accessibilityLabel(text ?? status.label) + // When `text` overrides the label, still expose the status meaning + // (e.g. "Needs Attention") as the value so VoiceOver doesn't lose it. + .accessibilityValue(text == nil ? "" : status.label) } } diff --git a/ios/VaultSync/Views/OnboardingView.swift b/ios/VaultSync/Views/OnboardingView.swift index b2cbbd9..fd055c3 100644 --- a/ios/VaultSync/Views/OnboardingView.swift +++ b/ios/VaultSync/Views/OnboardingView.swift @@ -167,7 +167,7 @@ struct OnboardingView: View { .font(.body.weight(.semibold)) .foregroundStyle(teal) .accessibilityHidden(true) - Text(L10n.tr("Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings.")) + Text(L10n.tr("Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab.")) .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/ios/VaultSync/Views/SubscribePlanPicker.swift b/ios/VaultSync/Views/SubscribePlanPicker.swift index 4a9d73b..7f72e9e 100644 --- a/ios/VaultSync/Views/SubscribePlanPicker.swift +++ b/ios/VaultSync/Views/SubscribePlanPicker.swift @@ -41,6 +41,7 @@ struct SubscribePlanPicker: View { } Button { + guard !subscriptionManager.purchaseInProgress, !isRestoring else { return } Task { isRestoring = true await subscriptionManager.restorePurchases() @@ -59,7 +60,7 @@ struct SubscribePlanPicker: View { } .buttonStyle(.plain) .foregroundStyle(Color.vaultAccent) - .disabled(isRestoring) + .disabled(isRestoring || subscriptionManager.purchaseInProgress) complianceFooter } @@ -159,7 +160,7 @@ struct SubscribePlanPicker: View { guard monthlyAnnual > 0 else { return nil } let fraction = (monthlyAnnual - yearly.price) / monthlyAnnual let percent = NSDecimalNumber(decimal: fraction * 100).intValue - guard percent > 0 else { return L10n.tr("Best value") } + guard percent > 0 else { return nil } return L10n.fmt("Save %d%%", percent) } diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index b1de454..11be21b 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -27,8 +27,8 @@ "Check folder sharing, connectivity, and permissions, then retry." = "Prüfe Ordnerfreigabe, Verbindung und Berechtigungen und versuche es dann erneut."; "Check internet connectivity and retry provisioning." = "Prüfe die Internetverbindung und versuche das Provisioning erneut."; "Check your connection and retry." = "Prüfe deine Verbindung und versuche es erneut."; -"Check your internet connection and try the relay health check again in Settings." = "Prüfe deine Internetverbindung und führe den Relay-Health-Check in den Einstellungen erneut aus."; -"Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Prüfe deinen Abostatus in den Einstellungen und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; +"Check your internet connection and try the relay health check again on the Relay tab." = "Prüfe deine Internetverbindung und führe den Relay-Health-Check im Relay-Tab erneut aus."; +"Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync." = "Prüfe deinen Abostatus im Relay-Tab und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay aktiv"; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay ist derzeit nicht abonniert. Push-ausgelöste Weck-Signale sind deaktiviert, bis das Abo aktiv ist."; @@ -510,10 +510,10 @@ /* 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 in Settings if you want changes pushed the moment they happen." = "Aktiviere Cloud Relay in den Einstellungen, wenn Änderungen im selben Moment gepusht werden sollen."; +"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"; "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 Settings → Cloud Relay → Set Up Your Server." = "Richte den Server-Helfer ein unter Einstellungen → Cloud Relay → Server einrichten."; +"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."; "Why this step" = "Warum dieser Schritt"; @@ -554,7 +554,7 @@ "Manage" = "Verwalten"; "Monthly" = "Monatlich"; "One step left to activate" = "Noch ein Schritt bis zur Aktivierung"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "Optional: Aktiviere Cloud Relay später für sofortige Updates – du findest es in den Einstellungen."; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "Optional: Aktiviere Cloud Relay später für sofortige Updates – du findest es im Relay-Tab."; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Verbinde dieses iPhone per Geräte-ID oder QR-Code mit dem Syncthing-Gerät, das deinen Vault bereitstellt."; "Relay" = "Relay"; "Relay health & diagnostics" = "Relay-Status & Diagnose"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 2814749..3a7d195 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -27,8 +27,8 @@ "Check folder sharing, connectivity, and permissions, then retry." = "Check folder sharing, connectivity, and permissions, then retry."; "Check internet connectivity and retry provisioning." = "Check internet connectivity and retry provisioning."; "Check your connection and retry." = "Check your connection and retry."; -"Check your internet connection and try the relay health check again in Settings." = "Check your internet connection and try the relay health check again in Settings."; -"Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Check your subscription status in Settings and retry. If this persists, restart VaultSync."; +"Check your internet connection and try the relay health check again on the Relay tab." = "Check your internet connection and try the relay health check again on the Relay tab."; +"Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync." = "Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay active"; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active."; @@ -510,10 +510,10 @@ /* 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 is not enabled. Without it, incoming changes arrive when you open VaultSync."; -"Enable Cloud Relay in Settings if you want changes pushed the moment they happen." = "Enable Cloud Relay in Settings if you want changes pushed the moment they happen."; +"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen."; "Cloud Relay — finish server setup" = "Cloud Relay — finish server setup"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server."; -"Set up the server helper from Settings → Cloud Relay → Set Up Your Server." = "Set up the server helper from Settings → Cloud Relay → Set Up Your Server."; +"Set up the server helper from the Relay tab → Set Up Your Server." = "Set up the server helper from the Relay tab → Set Up Your Server."; "Wake-ups are being delivered — incoming changes sync the moment they happen." = "Wake-ups are being delivered — incoming changes sync the moment they happen."; "Your server helper is running — wake-ups are being delivered." = "Your server helper is running — wake-ups are being delivered."; "Why this step" = "Why this step"; @@ -554,7 +554,7 @@ "Manage" = "Manage"; "Monthly" = "Monthly"; "One step left to activate" = "One step left to activate"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings."; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab."; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code."; "Relay" = "Relay"; "Relay health & diagnostics" = "Relay health & diagnostics"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index 947062e..35b3152 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -27,8 +27,8 @@ "Check folder sharing, connectivity, and permissions, then retry." = "Comprueba la compartición de la carpeta, la conectividad y los permisos, y vuelve a intentarlo."; "Check internet connectivity and retry provisioning." = "Comprueba la conexión a internet y vuelve a intentar el aprovisionamiento."; "Check your connection and retry." = "Comprueba tu conexión y vuelve a intentarlo."; -"Check your internet connection and try the relay health check again in Settings." = "Comprueba tu conexión a internet y vuelve a ejecutar la comprobación de estado del Relay en Ajustes."; -"Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Comprueba el estado de tu suscripción en Ajustes y vuelve a intentarlo. Si continúa, reinicia VaultSync."; +"Check your internet connection and try the relay health check again on the Relay tab." = "Comprueba tu conexión a internet y vuelve a ejecutar la comprobación de estado del Relay en la pestaña Relay."; +"Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync." = "Comprueba el estado de tu suscripción en la pestaña Relay y vuelve a intentarlo. Si continúa, reinicia VaultSync."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay activo"; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay no está suscrito actualmente. Las señales de activación por push están desactivadas hasta que la suscripción esté activa."; @@ -510,10 +510,10 @@ /* 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 no está activado. Sin él, los cambios entrantes llegan cuando abres VaultSync."; -"Enable Cloud Relay in Settings if you want changes pushed the moment they happen." = "Activa Cloud Relay en Ajustes si quieres que los cambios se envíen en el momento en que ocurren."; +"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "Activa Cloud Relay en la pestaña Relay si quieres que los cambios se envíen en el momento en que ocurren."; "Cloud Relay — finish server setup" = "Cloud Relay: completa la configuración del servidor"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "Tienes una suscripción, pero aún no ha llegado ninguna señal de activación reciente. Asegúrate de que el asistente vaultsync-notify se está ejecutando en tu servidor."; -"Set up the server helper from Settings → Cloud Relay → Set Up Your Server." = "Configura el asistente del servidor en Ajustes → Cloud Relay → Configura tu servidor."; +"Set up the server helper from the Relay tab → Set Up Your Server." = "Configura el asistente del servidor en la pestaña Relay → Configura tu servidor."; "Wake-ups are being delivered — incoming changes sync the moment they happen." = "Las señales de activación se están entregando: los cambios entrantes se sincronizan en el momento en que ocurren."; "Your server helper is running — wake-ups are being delivered." = "Tu asistente del servidor está en marcha: se están entregando las señales de activación."; "Why this step" = "Por qué este paso"; @@ -554,7 +554,7 @@ "Manage" = "Gestionar"; "Monthly" = "Mensual"; "One step left to activate" = "Falta un paso para activar"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "Opcional: activa Cloud Relay más tarde para recibir actualizaciones instantáneas; lo encontrarás en Ajustes."; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "Opcional: activa Cloud Relay más tarde para recibir actualizaciones instantáneas; lo encontrarás en la pestaña Relay."; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "Vincula este iPhone con el dispositivo de Syncthing que aloja tu Vault, mediante el ID de dispositivo o un código QR."; "Relay" = "Relay"; "Relay health & diagnostics" = "Estado y diagnóstico del Relay"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 394db65..66236bb 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -27,8 +27,8 @@ "Check folder sharing, connectivity, and permissions, then retry." = "检查文件夹共享、连接性和权限后再重试。"; "Check internet connectivity and retry provisioning." = "检查网络连接后重试配置。"; "Check your connection and retry." = "检查网络连接后重试。"; -"Check your internet connection and try the relay health check again in Settings." = "检查网络连接,然后在设置中重新执行 Relay 健康检查。"; -"Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "在设置中检查订阅状态后重试。如果问题持续存在,请重启 VaultSync。"; +"Check your internet connection and try the relay health check again on the Relay tab." = "检查网络连接,然后在 Relay 标签页中重新执行 Relay 健康检查。"; +"Check your subscription status on the Relay tab and retry. If this persists, restart VaultSync." = "在 Relay 标签页中检查订阅状态后重试。如果问题持续存在,请重启 VaultSync。"; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay 已启用"; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "当前未订阅 Cloud Relay。在订阅激活前,基于推送的唤醒功能将被禁用。"; @@ -110,7 +110,7 @@ "Last Trigger Received" = "上次收到触发"; "Last Update" = "上次更新"; "Last successful sync was about %d %@ ago." = "上次成功同步约在 %d %@ 前。"; -"Last successful sync was more than %d %@ ago." = "上次成功同步已超过 %d %@ 前。"; +"Last successful sync was more than %d %@ ago." = "距离上次成功同步已超过 %d %@。"; "Last sync: %@" = "上次同步:%@"; "Latency" = "延迟"; "Learn how to fix" = "查看修复方法"; @@ -510,10 +510,10 @@ /* 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 未启用。没有它,传入的改动会在你打开 VaultSync 时到达。"; -"Enable Cloud Relay in Settings if you want changes pushed the moment they happen." = "如果希望改动在发生的那一刻就推送过来,请在“设置”中启用 Cloud Relay。"; +"Enable Cloud Relay on the Relay tab if you want changes pushed the moment they happen." = "如果希望改动在发生的那一刻就推送过来,请在 Relay 标签页中启用 Cloud Relay。"; "Cloud Relay — finish server setup" = "Cloud Relay — 完成服务器设置"; "You’re subscribed, but no recent wake-up has arrived. Make sure the vaultsync-notify helper is running on your server." = "你已订阅,但尚未收到最近的唤醒信号。请确认 vaultsync-notify 助手正在你的服务器上运行。"; -"Set up the server helper from Settings → Cloud Relay → Set Up Your Server." = "在“设置”→ Cloud Relay →“设置你的服务器”中配置服务器助手。"; +"Set up the server helper from the Relay tab → Set Up Your Server." = "在 Relay 标签页 →“设置你的服务器”中配置服务器助手。"; "Wake-ups are being delivered — incoming changes sync the moment they happen." = "唤醒信号正在送达——传入的改动会在发生的那一刻同步。"; "Your server helper is running — wake-ups are being delivered." = "你的服务器助手正在运行——唤醒信号正在送达。"; "Why this step" = "为什么需要这一步"; @@ -554,7 +554,7 @@ "Manage" = "管理"; "Monthly" = "按月"; "One step left to activate" = "还差一步即可激活"; -"Optional: turn on Cloud Relay later for instant updates — you’ll find it in Settings." = "可选:之后可启用 Cloud Relay 以获得即时更新——你可以在“设置”中找到它。"; +"Optional: turn on Cloud Relay later for instant updates — you’ll find it on the Relay tab." = "可选:之后可启用 Cloud Relay 以获得即时更新——你可以在 Relay 标签页中找到它。"; "Pair this iPhone with the Syncthing device that hosts your vault, by Device ID or QR code." = "通过设备 ID 或二维码,将此 iPhone 与承载你 Vault 的 Syncthing 设备配对。"; "Relay" = "Relay"; "Relay health & diagnostics" = "Relay 健康状况与诊断"; diff --git a/ios/VaultSyncWidget/VaultSyncWidget.swift b/ios/VaultSyncWidget/VaultSyncWidget.swift index 06406a0..8d12de5 100644 --- a/ios/VaultSyncWidget/VaultSyncWidget.swift +++ b/ios/VaultSyncWidget/VaultSyncWidget.swift @@ -247,7 +247,7 @@ private struct VaultSyncWidgetEntryView: View { } private var buttonLabel: String { - entry.snapshot.status == "syncing" + entry.snapshot.syncStatus == .syncing ? VaultSyncWidgetL10n.tr("Open VaultSync") : VaultSyncWidgetL10n.tr("widget_sync_now") }