Commit 0867083
authored
v1.4.0 — conflict-notification controls, relay reliability, contributions (#11)
* feat: stable conflict-notification id + unchanged-count suppression (#10)
The conflict banner was posted with a fresh UUID identifier on every silent
push that reached idle, so iOS never coalesced them — each was a new banner
that lit the screen even when the count was identical ("28 conflicts" over
and over), draining battery.
- Stable "sync-conflict" identifier: a re-post replaces the existing banner
instead of stacking a new one.
- Persist the last-notified count; only alert (with sound) when the count
rises, suppress entirely when unchanged, refresh quietly when it falls,
clear when it hits zero.
- Re-baseline suppression from the foreground poll so a banner the user has
already seen is not re-alerted, and a brand-new conflict after a full
resolve still alerts instead of being read as a decrease.
- Localize the previously hard-coded English notification body (en/de/zh-Hans).
Decision logic extracted to a pure conflictNotificationAction(...) with tests.
* feat: in-app "Conflict Notifications" toggle (#10)
Adds a dedicated Settings → Notifications toggle that gates only the conflict
banner. Cloud Relay silent-push wake-ups are untouched, so users can silence
conflict spam without disabling iOS notifications (which the app otherwise
misreads as the relay being broken — addressed separately).
- Gate read at the top of notifyConflictsIfAny(), before the per-folder disk
scan, so turning banners off also skips that recurring I/O.
- Default ON via `object(forKey:) as? Bool ?? true` so existing installs (key
absent after upgrade) are NOT silently muted — never `bool(forKey:)`.
- Toggle lives in its own Notifications section, independent of the Cloud
Relay IAP block; @AppStorage-backed, default registered at launch.
- Strings localized for en/de/zh-Hans.
* fix: decouple Cloud Relay health from alert authorization (#10)
Turning off iOS notifications to silence the conflict banner made the app
report Cloud Relay as broken: refreshNotificationAuthorizationState() forced
APNs status to .failed purely because alert authorization was .denied. But
silent (content-available) pushes — the relay's wake mechanism — are delivered
regardless of alert authorization, so this was a false negative that scared
users off the exact workaround they needed, and cascaded a red "relay broken"
narrative across diagnostics, provisioning hints, and the APNs retry button.
- AppDelegate: stop marking APNs/relay failed on alert-denied entirely. Genuine
APNs problems still surface via didFailToRegisterForRemoteNotifications.
- SubscriptionManager: expose alertAuthorizationDenied (informational) and a
composite relayDeliveryLikelyWorking signal built from subscription + token +
provisioned + (recent silent-push trigger OR healthy endpoint) — independent
of alert authorization.
- RelayDiagnostics: show alert-banner permission as an informational row with a
clarifying caption, a positive "delivering wake-ups" line, and reworded hints
that no longer imply silent push needs notification banners.
- Strings localized for en/de/zh-Hans.
* fix: background-sync reliability — single-flight, deadline budget, error-idle
Three independent reliability fixes surfaced while auditing #10:
- Single-flight guard around performBackgroundSync: two concurrent background
wake-ups (silent push + BGAppRefresh, or two pushes) could have one task's
expiration/cleanup stop the bridge mid-sync of the other, silently aborting a
transfer. The second caller now just nudges a rescan and returns.
- Absolute deadline from sync start instead of "now": the silent-push setup
waits (folder availability, wake-evidence, optional forced restart) already
consume part of iOS's ~30s content-available budget. Budgeting from start
keeps total wall-clock under budget so overruns don't throttle future wakes.
- Error-state folders no longer spin the full deadline: a folder stuck in
"error" can never reach idle, so the wait loop used to burn the whole budget
and raise a misleading "Background Sync Timed Out". allFoldersSettledOrErrored
breaks early and the outcome is classified as settled, not timed out.
Idle/settlement logic extracted to a pure folderSettlement(...) with tests.
Note: cross-mechanism races with BGContinuedProcessing are out of scope here
(tracked for a follow-up); this covers the performBackgroundSync re-entry path.
* fix: address self-review findings (#10)
Adversarial review of the branch surfaced three real bugs and three worth-fixing
nits, all corrected here:
- L1 (medium): the foreground 2s poll keeps running during the ~30s post-
background grace window and was silently re-baselining the suppression count,
so a conflict arriving in that window would be read as "unchanged" by the next
silent push and never alert. Added a scene-active flag; the poll only
re-baselines while the scene is genuinely active, freezing the baseline once
backgrounded so such conflicts stay a genuine rise.
- L2 (medium): classifying an error-settled run as .synced made the widget show
green "idle" while a folder was actually errored. Added a dedicated
SyncResult.settledWithFolderError (isSuccessful=false so the widget shows
error, shouldSurfaceIssue=false so it still does NOT raise a misleading
"Timed Out" issue).
- L5 (low): the fast already-idle path skipped notifyConflictsIfAny entirely;
now it notifies before cleanup (a quick conflict could otherwise slip through).
- R1: reconcile now skips the write/IPC when the count is unchanged (was hitting
usernotificationsd every 2s tick in the zero-conflict steady state).
- R2: removed the now-dead `import UserNotifications` from AppDelegate.
- R3: split the relay signal into relayDeliveryConfirmed (recent trigger proves
end-to-end delivery → "delivering wake-ups") vs relayDeliveryLikelyWorking
(endpoint reachable only → "looks reachable").
Also gates notifyConflictsIfAny on !sceneActive so a silent push arriving while
the app is open doesn't post a banner over the in-app conflict UI (also removes
the foreground-reconcile vs background-notify race, L4).
Build/test: fixed a ShapeStyle ternary type error in the new alert-banner row
(Color.secondary/Color.green) caught by xcodebuild. App + widget compile clean
under Swift 6 strict concurrency; all unit tests pass (23 XCTest + 46 Swift
Testing across 10 suites, including the new suppression and folder-settlement
suites).
* feat: one-time contributions + region-correct Cloud Relay pricing
Adds a "Support VaultSync" section to Settings with two one-time, repeatable
contributions (StoreKit consumables) that unlock nothing — they only let users
support development, and can be given as often as they like.
- TipJarManager: loads the two consumables, sorts cheapest→most expensive,
purchases and finishes the transaction (consumable = nothing to unlock, so
finishing is the fulfillment). SubscriptionManager's existing updates loop
finishes any contribution that arrives out-of-band (e.g. approved Ask to Buy).
- SettingsView: own section, per-row localized displayPrice, spinner while
purchasing, a thank-you alert, and graceful unavailable/loading states.
Also fixes the inconsistent Cloud Relay price display: the subscribe button and
the App-Store-Review price line previously mixed StoreKit's localized
displayPrice with a hard-coded "$0.99/month", so non-US storefronts showed two
different currencies. Both now use a single StoreKit-derived
`relayPriceText` ("0,99 € / month", "A$1.99 / month", …) — never a hard-coded
amount.
New product IDs to create in App Store Connect (Consumable):
eu.vaultsync.app.contribution.small (base US $2.99)
eu.vaultsync.app.contribution.big (base US $9.99)
Adds VaultSync.storekit (test-only config, wired via project.yml) so IAP is
actually testable in local Debug builds — the project had none. Strings
localized for en/de/zh-Hans.
App + widget compile under Swift 6; all tests pass (23 XCTest + 46 Swift Testing).
* chore: release v1.4.0 (build 25)
Bump app + widget marketing version to 1.4.0 and build to 25. Add the 1.4.0
CHANGELOG entry (conflict-notification controls + reliability, region-correct
Cloud Relay pricing, one-time contributions) and refresh the README "What's New"
highlight.
* chore: set DEVELOPMENT_TEAM in project.yml for regenerate-safe signing
The .xcodeproj is gitignored and regenerated by xcodegen, so signing set in the
Xcode UI is wiped on every `xcodegen generate`. Pin the team (QWTAK63B7C) and
automatic signing in project.yml so it persists across regenerations for the
app, widget, and test targets.
* fix: address CodeRabbit review (conflict snapshot, alert tri-state, zh wording)
- Unreadable conflict snapshot no longer mistaken for "no conflicts":
currentConflictCount() returns Int? and a folder-list or per-folder decode
failure now suppresses instead of collapsing to 0. The old behaviour removed
the delivered banner and reset the baseline on a transient read failure, then
re-alerted the still-present conflicts as new on the next successful read.
- "Alert Banners" diagnostics row reflects real capability via a tri-state
(allowed/denied/unknown) read from UNNotificationSettings — authorized alone
isn't enough; a user can keep authorization but turn banners off
(alertSetting == .disabled). Replaces the previous denied/allowed Bool.
- zh-Hans: use "Vault" instead of the generic "库" in the conflict-notifications
toggle footer, matching the product terminology used elsewhere.
Skipped the .inactive scene-phase suggestion: .inactive is a transient
foreground sub-state (app switcher, Control Center, incoming call) and
.background reliably clears the scene-active latch, so clearing it on .inactive
would instead post conflict banners over the on-screen app.
* refactor: remove dead code and polish UI consistency
Dead code (all verified unreferenced across app, widget and tests):
- drop unused bridge wrappers getConfigJSON / setDiscoveryEnabled
- drop unused isPresetActive, isFolderSyncing and two folderLabel props
- drop never-read APNsRegistrationStore.Snapshot.status/failureReason
UI / visual polish (clear-improvement only, no v1.4.0 logic change):
- shared Color.vaultTeal/.vaultSlate palette (app + widget); unify the
syncing-state colour across dashboard, vault rows and widget
- ConflictListView reads live conflictFiles so a resolved conflict vanishes,
with an "all resolved" empty state; shared locale-aware conflict-date
formatter so the diff header no longer shows the raw Syncthing timestamp
- line-diff legend (+/- symbols, colourblind/VoiceOver-safe) and
equal-width row backgrounds; ASCII "..." -> "…"
- Subscribe spinner, Copy Device ID haptic + confirmation, Restore Purchases
progress, pending-contribution notice
- consistent APNs buttons (>=44pt), neutral "Unknown" alert-banner colour,
drop the misdirecting conflict "Learn how to fix" link
- device status "Offline" -> "Disconnected" to match the detail screen
- localize the background continued-processing title/subtitle and both
conflict-diff pane titles
* chore(l10n): add missing keys, drop orphans, keep en/de/zh in sync
- add 46 keys that were silently falling back to English in de/zh
(conflict resolution, device removal, background-sync diagnostics,
relay errors, VoiceOver hints and the new UI feedback strings)
- remove 76 orphaned keys left behind by the onboarding/checklist/relay
rewrites, incl. the stale hard-coded relay price and the unused QR Code key
- de-duplicate three keys whose de/zh copies had silently diverged,
keeping the currently-shipping wording (last-wins)
- fix German in-progress tense (Synchronisiert -> Wird synchronisiert) and
the zh notification-banner term for consistency
- result: en/de/zh at 510 keys each, no duplicates, no orphans,
matching format specifiers
* fix(l10n): localize the dashboard "Last sync" relative time
The bare Text(lastSync, style: .relative) renders only the magnitude
("2 hr", no "ago") and the appended L10n.tr("ago") leaked an untranslated
English "ago" into de/zh. Replace it with a fully localized relative phrase
from a cached RelativeDateTimeFormatter (unitsStyle .full) — "2 hours ago" /
"vor 2 Stunden" / "2 小时前" — rendered through one "Last sync: %@" key.
- new key reuses the existing "Last sync:" translations; the old key is dropped
- scope is this line only; the six other bare .relative usages are left as-is
- trade-off: the phrase is static (no live tick), fine for a last-sync label
- en/de/zh stay at 510 keys each, no orphans/dups, plutil OK; tests green
* feat(l10n): add Spanish (es) as a fully supported language
Add complete Spanish localization for the app (510 keys) and the
home-screen widget (16 keys), mirroring the English source exactly,
plus per-locale InfoPlist.strings for both targets.
- es.lproj/Localizable.strings + InfoPlist.strings for app and widget
- Informal tú register; brand terms (Vault, VaultSync, Cloud Relay,
Syncthing, Obsidian, APNs) kept untranslated; every format specifier
preserved and order-checked against en
- Wire es into knownRegions via the project.yml postGenCommand
(Base, de, en, es, "zh-Hans")
- CHANGELOG 1.4.0 + README note that Spanish is now supported
Verified: plutil -lint clean; es-vs-en parity 510/510 app, 16/16 widget
(0 missing/extra/duplicate; format specifiers match); xcodebuild test
green (23 XCTest + 46 Swift Testing).
* refactor(l10n): refresh Support/Cloud Relay copy and polish de/es/zh
Part B — marketing copy (en + de/es/zh; English-key renames + Swift literals):
- Support footer: reframe around VaultSync being an independent, open-source
(MPL-2.0), ad-free project; keep the "unlocks nothing / fully functional /
give as often as you like" honesty verbatim.
- Cloud Relay footer: drop the over-stated "instant sync" claim for the honest
silent-push framing — changes wake the app the moment they happen so incoming
sync feels instant, and the relay only sends a wake-up signal, never sees your
notes.
- Onboarding relay line: same concrete, honest framing.
- Untouched by design: the StoreKit price line "Cloud Relay — %@", the
auto-renew/cancel terms line, and the Subscribe button.
Part A — consistency/quality (translated values only; English keys unchanged):
- de: "ein Banner" gender fix; unify "rate-limitiert"; standardize prose on
"Hintergrundsynchronisation"; "Das Relay" (neuter).
- es: "Vault syncing" -> "Vault sincronizándose" (state, matches the checklist
siblings; disambiguates it from the "Syncing Vault" action).
- zh: unify 限流 and 支持 terminology; full-width parens; parallel "%@ 中…"
activity-log phrasing.
4-language parity intact (510 app keys each, 0 dups/orphans, format specifiers
matched), plutil -lint OK on all 8 strings files, xcodebuild test green (46 tests).
* fix(l10n): positional args in zh "additional files synced" string
The zh-Hans translation of "%d additional files synced in %@" reordered the
folder before the count without positional specifiers, so String(format:) bound
the Int count to %@ (treated as an object pointer → crash) and the folder String
to %d. Switched to "%2$@ … %1$d" so the args map to the correct positions; en/de/es
already follow en's order. Reachable on Chinese devices when one poll syncs more
than 6 files in a single folder (rate-limited activity summary).
* fix: address CodeRabbit (locale-aware conflict date, local signing override)
- ConflictListView.conflictDateDisplay hard-coded "yyyy-MM-dd HH:mm" despite its
"locale-aware" doc comment, ignoring the user's locale and 12/24-hour setting.
Now uses localized dateStyle/timeStyle with an .autoupdatingCurrent locale.
- Moved DEVELOPMENT_TEAM out of the committed project.yml so a public-repo
contributor's `xcodegen generate` is no longer forced onto the maintainer's
team. Signing flows from Signing.xcconfig (committed, no team) which optionally
includes a gitignored Signing.local.xcconfig (your DEVELOPMENT_TEAM) via
`#include?`. That include is skipped when the file is absent, so fresh clones /
CI generate and build for the Simulator unchanged. Copy
Signing.local.xcconfig.example to set yours; documented in docs/setup.md.
Verified: xcodegen generate succeeds with AND without the local override; no team
in the generated pbxproj; build + tests green (23 XCTest + 46 Swift Testing).
---------
Co-authored-by: psimaker <psimaker@users.noreply.github.com>1 parent 9c36fbd commit 0867083
37 files changed
Lines changed: 1897 additions & 428 deletions
File tree
- docs
- ios
- VaultSyncTests
- VaultSyncWidget
- de.lproj
- es.lproj
- VaultSync
- App
- Models
- Resources
- Services
- Views
- de.lproj
- en.lproj
- es.lproj
- zh-Hans.lproj
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
7 | 29 | | |
8 | 30 | | |
9 | 31 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
99 | 99 | | |
100 | 100 | | |
101 | 101 | | |
102 | | - | |
| 102 | + | |
103 | 103 | | |
104 | | - | |
| 104 | + | |
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
44 | 44 | | |
45 | 45 | | |
46 | 46 | | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
47 | 54 | | |
48 | 55 | | |
49 | 56 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | | - | |
3 | 2 | | |
4 | 3 | | |
5 | 4 | | |
| |||
13 | 12 | | |
14 | 13 | | |
15 | 14 | | |
16 | | - | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
17 | 20 | | |
18 | 21 | | |
19 | 22 | | |
| |||
60 | 63 | | |
61 | 64 | | |
62 | 65 | | |
63 | | - | |
| 66 | + | |
64 | 67 | | |
65 | 68 | | |
66 | 69 | | |
| |||
81 | 84 | | |
82 | 85 | | |
83 | 86 | | |
84 | | - | |
85 | | - | |
86 | | - | |
87 | | - | |
88 | | - | |
89 | | - | |
90 | | - | |
91 | | - | |
92 | 87 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
19 | 26 | | |
20 | 27 | | |
21 | 28 | | |
| |||
50 | 57 | | |
51 | 58 | | |
52 | 59 | | |
| 60 | + | |
53 | 61 | | |
54 | 62 | | |
55 | 63 | | |
| |||
75 | 83 | | |
76 | 84 | | |
77 | 85 | | |
| 86 | + | |
78 | 87 | | |
79 | 88 | | |
80 | 89 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
69 | | - | |
70 | | - | |
71 | 69 | | |
72 | 70 | | |
73 | 71 | | |
| |||
89 | 87 | | |
90 | 88 | | |
91 | 89 | | |
92 | | - | |
93 | | - | |
94 | | - | |
95 | | - | |
96 | | - | |
97 | | - | |
98 | | - | |
99 | 90 | | |
100 | | - | |
101 | | - | |
102 | 91 | | |
103 | 92 | | |
104 | 93 | | |
| |||
0 commit comments