Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ All notable changes to VaultSync are documented here.

---

## [1.6.0] — 2026-06-10

### Added

- **Missed wake-ups now catch up on their own** — A device that misses a Cloud Relay wake-up (offline too long, push expired) no longer stays stale until the next vault change: the server helper (`vaultsync-notify`) now re-sends a wake-up on a slow cadence while any of your devices still needs data (default every 6 hours; `STALE_RETRIGGER_SECONDS`, `0` disables). Fully synced devices never cause a push, and a wake-up that is already on its way is never duplicated.
- **Overnight catch-up sync** — VaultSync now also schedules a long-running background task that iOS runs while the iPhone is charging with a network connection — typically overnight. It gets a multi-minute budget instead of the ~30 seconds of a normal background refresh, so large catch-ups complete on the charger instead of timing out.
- **See what Cloud Relay actually delivers** — Relay Diagnostics now counts the wake-ups received in the last 7 days (stored only on your device, never reported anywhere), warns live when Low Power Mode is deferring silent pushes, and explains the most common silent killer: force-quitting VaultSync from the app switcher stops all wake-ups until the next manual launch. Localized in English, German, Spanish, and Simplified Chinese.
- **Instant iPhone → server uploads, automated** — A new guide ([docs/instant-upload.md](docs/instant-upload.md)) shows the one-time Shortcuts automation that opens VaultSync every time you leave Obsidian, so your edits reach the server seconds after you close the app. The home-screen and lock-screen widgets are already tap-to-sync.

### Changed

- **A calmer, more consistent home screen** — the dashboard's Cloud Relay hints, sync errors, and the Obsidian connection prompt now share the app's standard card and row styles, and every error card carries a real action button instead of prose directions. Vaults get the same full-size status treatment as devices, with conflict counts as a clear badge.
- **Friendlier first run** — "no vaults yet" and "no devices yet" are now proper empty-state screens with guidance and an **Add Device** button; adding a device moved to the navigation bar of the Devices tab.
- **The Cloud Relay offer no longer switches tabs on its own** — after your first successful sync it appears as a dismissable card on the Sync tab (**View Cloud Relay** / **Not now**) instead of pulling you out of what you were doing.
- **Tappable setup checklist** — checklist steps with an in-app fix (connect your Obsidian folder, add a device, open the Relay tab) now carry a button that takes you straight there.
- **Design and accessibility polish** — onboarding, badges, spacing, and monospaced text now come from the shared design tokens (correct in light, dark, and increased-contrast mode), action buttons meet the 44pt touch-target minimum, renaming a device shows a saved confirmation, and the widget's last-sync time is labeled for VoiceOver. Localized in English, German, Spanish, and Simplified Chinese.
- **Honest self-hosted relay documentation** — The relay specification no longer advertises a free self-hosted relay tier as roadmap. It now explains the real constraint: Apple's push service only accepts wake-ups for the App Store app signed with VaultSync's own key, which can never be distributed. Building the entire stack from source with your own Apple Developer account remains possible under MPL-2.0.

## [1.5.1] — 2026-06-01

### Added
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ Full build, signing, and test steps: [docs/setup.md](docs/setup.md).
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common failures and exact fixes |
| [docs/architecture.md](docs/architecture.md) | Codebase structure and sync strategy |
| [docs/relay-spec.md](docs/relay-spec.md) | Cloud Relay protocol reference |
| [docs/instant-upload.md](docs/instant-upload.md) | Instant iPhone → server uploads via a Shortcuts automation |
| [notify/README.md](notify/README.md) | Notify sidecar setup and diagnostics |
| [PRIVACY.md](PRIVACY.md) · [TERMS.md](TERMS.md) | Privacy policy and license terms |

Expand Down
4 changes: 2 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ VaultSync embeds Syncthing's Go reference implementation as an iOS library via g
## 🔄 Sync strategy

- **Foreground** — Syncthing runs unrestricted: immediate, continuous sync.
- **Background** — `BGAppRefreshTask` (requested ~15 min out; iOS decides the actual timing) + `BGContinuedProcessingTask` (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets in-flight work finish.
- **Background** — `BGAppRefreshTask` (requested ~15 min out; iOS decides the actual timing) + `BGProcessingTask` (overnight catch-up: multi-minute budget while charging with network) + `BGContinuedProcessingTask` (iOS 26+, longer runtime for user-initiated tasks). A ~30s grace window after backgrounding lets in-flight work finish.
- **Push (Cloud Relay)** — optional. Near-realtime `server → iPhone` wake-ups via APNs silent push. See [relay-spec.md](relay-spec.md).

VaultSync is intentionally **asymmetric**:

| Direction | Path |
|---|---|
| **Server → iPhone** | `vaultsync-notify` spots outgoing changes → Cloud Relay silent push → VaultSync wakes and pulls. |
| **iPhone → Server** | iOS doesn't guarantee timely background execution for local edits. The reliable path is to open VaultSync and let embedded Syncthing run in the foreground. |
| **iPhone → Server** | iOS doesn't guarantee timely background execution for local edits. The reliable path is to open VaultSync and let embedded Syncthing run in the foreground — a [Shortcuts automation](instant-upload.md) can do that automatically whenever you leave Obsidian. |

Cloud Relay is a `server → iPhone` *acceleration* path, not a guarantee of symmetric real-time background sync.

Expand Down
31 changes: 31 additions & 0 deletions docs/instant-upload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Instant iPhone → server uploads (Shortcuts automation)

VaultSync's architecture is [intentionally asymmetric](architecture.md): Cloud Relay makes `server → iPhone` near-realtime, but iOS gives no app a reliable way to *upload* in the background. Notes you write in Obsidian on the iPhone reach your server when VaultSync next runs in the foreground — or during a system-scheduled background window.

A one-time **Shortcuts personal automation** closes that gap: every time you leave Obsidian, iOS opens VaultSync for a moment, which triggers an immediate rescan and sync. Your edits are on the server seconds after you close Obsidian.

## Set it up (about a minute)

1. Open the **Shortcuts** app → **Automation** tab → **+** (New Automation).
2. Choose **App**.
3. Tap **App** and select **Obsidian**. Check **Is Closed**, and choose **Run Immediately** (on older iOS versions: turn off "Ask Before Running").
4. Tap **Next**, then create a new shortcut with a single action: **Open URLs** with this URL:

```
vaultsync://sync
```

5. Done. Close Obsidian once to test — VaultSync should open and start a scan right away.

To sync only a specific vault, use `vaultsync://sync?folder=<folder-id>` (the folder ID is shown in the vault's detail view).

## What to expect

- **"Is Closed" means "you switched away"** — the automation fires whenever you leave Obsidian, not only when you force-quit it. That is exactly what you want: every editing session ends with a sync.
- **VaultSync comes to the foreground briefly.** That is how iOS automations work; there is no silent variant. Switch back to whatever you were doing — the [~30s grace window](architecture.md) after backgrounding is enough to finish a typical note upload.
- **No automation needed for the other direction.** Changes made on your server reach the iPhone via Cloud Relay wake-ups (or on the next app open without it).

## Manual alternatives

- **Widget**: the home screen and lock screen widgets are tap-to-sync — one tap opens VaultSync and triggers the same `vaultsync://sync` action.
- **Pull-to-refresh** inside VaultSync rescans on demand.
48 changes: 12 additions & 36 deletions docs/relay-spec.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# VaultSync Cloud Relay — Specification

> **Status:** Shipped in v1.4.0; signed-transaction (JWS) verification and server-side expiry enforcement added in v1.5.0; key-free auto-detection and self-activation (startup-announce) added in v1.5.1. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client. Sections marked _Roadmap_ (self-hosted relay) are not yet built.
> **Status:** Shipped in v1.4.0; signed-transaction (JWS) verification and server-side expiry enforcement added in v1.5.0; key-free auto-detection and self-activation (startup-announce) added in v1.5.1. This document is the protocol and architecture reference for the relay, the `vaultsync-notify` sidecar, and the iOS client.

## Overview

Expand Down Expand Up @@ -56,6 +56,7 @@ In the app, the **Cloud Relay** tab → **Relay health & diagnostics** is the li
- Auto-detects the Syncthing API key and URL from `config.xml` — no key to copy (override via env if needed)
- Reads its Syncthing Device ID from `/rest/system/status` at startup
- **Startup-announce** (`STARTUP_ANNOUNCE`, default on): sends one wake-up the moment it starts, so a freshly-subscribed device flips to "Cloud Relay active" without waiting for the next change
- **Stale-peer sweep** (`STALE_RETRIGGER_SECONDS`, default 6 h): while any unpaused peer still reports outstanding `need{Items,Bytes,Deletes}` via `/rest/db/completion`, re-sends a wake-up on a slow cadence. This recovers a phone that missed its push (APNs expiry is ~1 h) without waiting for the next vault change
- No persistent storage required — stateless except for config

**Central Relay (relay.vaultsync.eu)**
Expand Down Expand Up @@ -193,6 +194,7 @@ Configuration via environment variables. `RELAY_URL` is the only required value
| `SYNCTHING_CONFIG_WAIT_SECONDS` | No | First-boot wait for `config.xml` (default `60`). |
| `DEBOUNCE_SECONDS` | No | Debounce interval for batching events (default `5`). |
| `WATCHED_FOLDERS` | No | Comma-separated folder IDs to watch (default: all). |
| `STALE_RETRIGGER_SECONDS` | No | Re-send a wake-up on this cadence while a peer still needs data (default `21600` = 6 h; `0` disables). Recovers devices that missed a push — APNs silent pushes expire after ~1 h, and the change-driven path never fires twice for the same change. |

The container reads its own Syncthing Device ID automatically from `/rest/system/status` at startup — no manual Device ID configuration. It consumes the Syncthing event stream and pushes outbound to the relay. Full operator reference: [../notify/README.md](../notify/README.md).

Expand Down Expand Up @@ -235,11 +237,9 @@ The container reads its own Syncthing Device ID automatically from `/rest/system
|---|---|---|
| **VaultSync App** | Free | Full sync, background refresh, all features |
| **Cloud Relay** | Monthly or yearly subscription | Push notifications via relay.vaultsync.eu |
| **Self-hosted Relay** | Free (roadmap) | User runs everything — no central relay needed |

- Cloud Relay subscription managed via App Store (StoreKit 2, auto-renewable; monthly or yearly). The price is set in App Store Connect and shown in the user's local currency at runtime via StoreKit — never hard-coded (USD reference: ~$1.99/month or ~$14.99/year). No free trial.
- App provisions the relay after successful purchase using the StoreKit signed transaction (JWS)
- Homeserver container works identically regardless of cloud vs self-hosted relay
- No feature gates in the container itself — the gate is the central relay accepting provisioned Device IDs

---
Expand All @@ -258,48 +258,24 @@ Cloud Relay is configured from its own **Cloud Relay** tab, not onboarding.

---

## Self-Hosted Variant
## Why there is no self-hosted relay

> **Roadmap — not yet built.** The components below are design exploration, not shipping code. Today `vaultsync-notify` points at the central `relay.vaultsync.eu` by default.
The relay's only job is sending APNs pushes — and APNs only accepts pushes for `eu.vaultsync.app` that are signed with VaultSync's own APNs key (a `p8` key bound to the app's bundle ID and developer team). That key cannot be distributed: anyone holding it could push to every VaultSync install, and sharing it violates Apple's terms. This is an APNs constraint, not a pricing decision — a self-hosted relay that wakes the App Store build is technically impossible no matter who runs the server.

For users who don't want to use the central relay or pay for the subscription.
What IS open:

### What the User Deploys
- **`RELAY_URL` is configurable** in `vaultsync-notify` — used for development and testing against a mock relay. It exists so the helper never wakes a relay the operator didn't choose, not as a self-hosting path for the shipped app.
- **VaultSync is open source (MPL-2.0).** A developer building the app from source with their own bundle ID and Apple Developer account can run the entire stack themselves — own APNs key, own relay, own build. The architecture supports it; it is not a supported product configuration.

**1. vaultsync-notify container** (same Docker image as cloud variant)
- Configured with `RELAY_URL` pointing to their own relay server instead of relay.vaultsync.eu

**2. vaultsync-relay-server** (additional component)
- Minimal server that receives triggers and sends APNs pushes
- Requires the user's own Apple Developer Account ($99/year) for APNs credentials
- OR: uses the VaultSync APNs credentials bundled in a self-hosted-friendly way (TBD — licensing/security implications)
- Docker image provided, single binary

### Self-Hosted Limitations

- User must manage their own APNs credentials (p8 key from Apple Developer Portal)
- No automatic token rotation handling — user manages the database
- No SLA or uptime guarantees
- User responsible for TLS termination (reverse proxy like Caddy/nginx)

### Self-Hosted Alternative: Direct Push (No Relay Server)

A simplified variant where the homeserver container sends APNs pushes directly:

- vaultsync-relay container configured with APNs credentials directly
- No intermediate relay server needed
- Simplest setup but requires Apple Developer Account
- Single container, no database
- Config: `APNS_KEY_FILE`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `DEVICE_TOKEN` (hardcoded for single device)
Everything else about the system is already self-hosted: notes sync peer-to-peer between the user's own devices, and the only thing that ever touches VaultSync infrastructure is an anonymous wake-up signal.

---

## Open Questions

1. **APNs credentials for self-hosted:** Can we distribute our p8 key in a way that allows self-hosted users to send pushes without their own Developer Account? Likely no — security and ToS implications.
2. **Multi-vault routing:** Should the push signal include which vault changed, so the app can prioritize? Currently: wake up and sync everything. Tradeoff: more metadata leaves the homeserver.
3. **Fallback behavior:** If push delivery fails (APNs errors, network issues), should the container retry? Or rely on the existing BGAppRefreshTask polling as fallback?
4. **GDPR — data processing agreement:** Device tokens are personal data. The privacy policy now ships ([../PRIVACY.md](../PRIVACY.md), surfaced in Settings → About); a data processing agreement for EU users is still open.
1. **Multi-vault routing:** Should the push signal include which vault changed, so the app can prioritize? Currently: wake up and sync everything. Tradeoff: more metadata leaves the homeserver.
2. **Fallback behavior:** The sidecar's stale-peer sweep (`STALE_RETRIGGER_SECONDS`) now re-sends a wake-up while a peer still needs data, recovering missed/expired pushes. Remaining question: should the central relay also retry failed APNs sends itself, or is the sweep + BGAppRefreshTask polling enough?
3. **GDPR — data processing agreement:** Device tokens are personal data. The privacy policy now ships ([../PRIVACY.md](../PRIVACY.md), surfaced in Settings → About); a data processing agreement for EU users is still open.

---

Expand Down
2 changes: 1 addition & 1 deletion docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ iOS controls background time and may delay or skip it. Cloud Relay makes `server
4. For relay users, confirm `vaultsync-notify --doctor` is green and **Last Trigger Received** is recent.
5. Re-check the **Last sync** timestamp after the next background window.

> For `iPhone → server`, open VaultSync and let it sync in the foreground. iOS background time is system-controlled and not guaranteed.
> For `iPhone → server`, open VaultSync and let it sync in the foreground. iOS background time is system-controlled and not guaranteed. A one-time Shortcuts automation can do the opening for you every time you leave Obsidian — see [instant-upload.md](instant-upload.md).

---

Expand Down
1 change: 1 addition & 0 deletions ios/VaultSync/App/VaultSyncApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ struct VaultSyncApp: App {
BackgroundSyncService.beginBackgroundAssertion()

BackgroundSyncService.scheduleAppRefresh()
BackgroundSyncService.scheduleProcessing()
if syncthingManager.isAnySyncing {
if #available(iOS 26.0, *) {
BackgroundSyncService.submitContinuedProcessing()
Expand Down
24 changes: 24 additions & 0 deletions ios/VaultSync/Models/RelayProvisionStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ enum APNsRegistrationStore {

enum RelayTriggerStore {
private static let lastReceivedAtKey = "relay-last-trigger-received-at"
private static let receivedHistoryKey = "relay-trigger-received-history-v1"
/// Rolling cap on stored arrival timestamps. 200 entries cover weeks of
/// realistic delivery volume while keeping the UserDefaults blob tiny.
private static let historyLimit = 200
static let triggerDidChangeNotification = Notification.Name("RelayTriggerDidChange")

/// A real wake-up reached this device — the silent push the relay delivers
Expand All @@ -143,10 +147,30 @@ enum RelayTriggerStore {
/// push is a genuine delivery and needs no attribution heuristic.
static func markReceived(date: Date = Date()) {
UserDefaults.standard.set(date, forKey: lastReceivedAtKey)
var history = receivedHistory()
history.append(date)
if history.count > historyLimit {
history.removeFirst(history.count - historyLimit)
}
UserDefaults.standard.set(history, forKey: receivedHistoryKey)
NotificationCenter.default.post(name: triggerDidChangeNotification, object: nil)
}

static func lastReceivedAt() -> Date? {
UserDefaults.standard.object(forKey: lastReceivedAtKey) as? Date
}

/// All recorded wake-up arrivals, oldest first, capped at `historyLimit`.
/// Purely local diagnostics — nothing is reported anywhere.
static func receivedHistory() -> [Date] {
UserDefaults.standard.array(forKey: receivedHistoryKey) as? [Date] ?? []
}

/// Number of wake-ups that arrived within the trailing interval. Drives the
/// diagnostics counter that makes delivery (or its absence) visible —
/// "Never"/"3 days ago" alone hides how much iOS is actually letting through.
static func receivedCount(within interval: TimeInterval, now: Date = Date()) -> Int {
let cutoff = now.addingTimeInterval(-interval)
return receivedHistory().filter { $0 >= cutoff }.count
}
}
Loading
Loading