From 4b2b2ab3c5d1d2a7e4d58cb32a10aa317eee26c4 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Wed, 14 Oct 2026 09:24:19 -0500 Subject: [PATCH] feat(mobile): sync in the background finishes a sync after you leave the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Cloud → Sync in the background, on by default and per device. Leaving the app used to stop every Cloud request at once (the request lifetime in cloud-sync-client.ts), so an edit made just before switching away waited on the phone until the app was opened again, and a sync in progress was abandoned. Asked on Discord: does phone sync run in the background? It did not. Now, when the app leaves the screen with the setting on and the vault linked, it asks iOS for its grace period (UIApplication beginBackgroundTask, in BackgroundSyncPlugin.swift), waits half a second for the editor's 350 ms save, runs up to two sync passes, and stops the Cloud requests when they end or when iOS ends the time. With the setting off, leaving stops them at once, as before; signing out still does either way. What it deliberately does not do: sync on a timer while the app is away. The sync engine runs in the WebView, and iOS freezes a backgrounded WebView within seconds, so BGAppRefreshTask would wake the app but not the engine; a native engine would mean two more copies of the sync and encryption code. Changes from other devices still arrive when the app opens. background-sync.ts owns leaving and returning. mobile-cloud-auth.ts's appStateChange listener calls it, and until it is installed leaving stops requests at once, so the existing lifecycle test stands unchanged. The setting is an On/Off row under the linked-vault card, stored as zn:background-sync (absent = on). tooling/add-background-sync.rb adds the plugin to the Xcode project. Checked: npm run upstream, npm test (264, 11 new), a simulator build and launch. On an iPhone 16 Pro Max through iPhone Mirroring, an edit made just before View → Home Screen went up between ZenBackgroundSync begin and end, three seconds after leaving; with the setting off nothing ran until the app reopened, and the upload followed at once. --- README.md | 10 ++ ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/BackgroundSyncPlugin.swift | 46 ++++++ ios/App/App/ZNViewController.swift | 1 + src/bridge/background-sync.test.ts | 209 +++++++++++++++++++++++++ src/bridge/background-sync.ts | 165 +++++++++++++++++++ src/bridge/mobile-bridge.ts | 23 +++ src/bridge/mobile-cloud-auth.ts | 7 +- src/main.tsx | 5 +- src/ui-mobile/MobileShell.tsx | 90 +++++++++++ src/viewport.ts | 4 + tooling/add-background-sync.rb | 15 ++ 12 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 ios/App/App/BackgroundSyncPlugin.swift create mode 100644 src/bridge/background-sync.test.ts create mode 100644 src/bridge/background-sync.ts create mode 100644 tooling/add-background-sync.rb diff --git a/README.md b/README.md index ca7c423..7c9dc38 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,16 @@ work without an account or subscription. Files larger than the 5 MiB inline limit use Cloud's signed object-storage upload flow, with the account token kept off the object-storage request and a five-minute mobile transfer timeout. +Settings → Cloud → Sync in the background (on by default, per device) lets a +sync finish after you leave the app. The sync engine runs in the WebView, +which iOS suspends seconds after the app leaves the screen, so the app asks +for UIKit's background task (about 30 seconds) as it leaves, finishes a sync +in it, and stops its Cloud requests once that sync ends or iOS ends the time +(`src/bridge/background-sync.ts`, `ios/App/App/BackgroundSyncPlugin.swift`). +Nothing runs on a timer while the app is away: changes from other devices +arrive when it opens. With the setting off, leaving stops Cloud requests at +once, as before. + ## Release verification Pull requests and `main` run bridge tests, an installed-package typecheck, diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 45447a3..72644d7 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 283DF469CD667CDB34C0DE25 /* SearchCreateUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30A5D0EA5991955FBC0360 /* SearchCreateUITests.swift */; }; 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; 3322BE59AC62263A587F4D6B /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85847153B31EF561562A9281 /* SwiftUI.framework */; }; + 34CD033D6469BD27C1F5F22B /* BackgroundSyncPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5636CB2F77E91CE10C3EFE52 /* BackgroundSyncPlugin.swift */; }; 4444E1477FBE380A04E0BFE0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A7702CA7CC6312CAD599CD7 /* Foundation.framework */; }; 46516E19362E75EC2801386B /* ShareInboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80582C35DDDDF14031E4EA94 /* ShareInboxPlugin.swift */; }; 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; @@ -105,6 +106,7 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 5636CB2F77E91CE10C3EFE52 /* BackgroundSyncPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundSyncPlugin.swift; sourceTree = ""; }; 56C2868F946FC6AE7F918E2A /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 7B09FFFEE67FC0E73BE167CD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -229,6 +231,7 @@ 830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */, C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */, 9115A46C405EC261610EC467 /* CloudFileStream.swift */, + 5636CB2F77E91CE10C3EFE52 /* BackgroundSyncPlugin.swift */, ); path = App; sourceTree = ""; @@ -523,6 +526,7 @@ 66714DDFC6EB0A9732DE176A /* WidgetBridgePlugin.swift in Sources */, C10DF11E5A2B4C7D9E8F0A1B /* CloudFilesPlugin.swift in Sources */, FD65529207121AA61FF892C5 /* CloudFileStream.swift in Sources */, + 34CD033D6469BD27C1F5F22B /* BackgroundSyncPlugin.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/BackgroundSyncPlugin.swift b/ios/App/App/BackgroundSyncPlugin.swift new file mode 100644 index 0000000..8384112 --- /dev/null +++ b/ios/App/App/BackgroundSyncPlugin.swift @@ -0,0 +1,46 @@ +import Capacitor +import UIKit + +/// The grace period background sync runs in (src/bridge/background-sync.ts). +/// When the app leaves the screen with a sync to finish, JS calls `begin`, +/// which keeps the app running for about 30 seconds, and `end` once the sync +/// is done. If iOS ends the time first, the task is ended here (an app that +/// holds one past its expiry is killed) and JS hears `expired`, which stops +/// its Cloud requests as leaving used to. +@objc(BackgroundSyncPlugin) +public class BackgroundSyncPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "BackgroundSyncPlugin" + public let jsName = "ZenBackgroundSync" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "begin", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "end", returnType: CAPPluginReturnPromise) + ] + + /// Read and written on the main thread only. + private var task: UIBackgroundTaskIdentifier = .invalid + + @objc func begin(_ call: CAPPluginCall) { + DispatchQueue.main.async { + if self.task == .invalid { + self.task = UIApplication.shared.beginBackgroundTask(withName: "ZenNotes Cloud sync") { [weak self] in + self?.notifyListeners("expired", data: [:]) + self?.finish() + } + } + call.resolve() + } + } + + @objc func end(_ call: CAPPluginCall) { + DispatchQueue.main.async { + self.finish() + call.resolve() + } + } + + private func finish() { + guard task != .invalid else { return } + UIApplication.shared.endBackgroundTask(task) + task = .invalid + } +} diff --git a/ios/App/App/ZNViewController.swift b/ios/App/App/ZNViewController.swift index 98afe17..a14cb3d 100644 --- a/ios/App/App/ZNViewController.swift +++ b/ios/App/App/ZNViewController.swift @@ -17,6 +17,7 @@ class ZNViewController: CAPBridgeViewController { bridge?.registerPluginInstance(KeyboardBackdropPlugin()) bridge?.registerPluginInstance(WidgetBridgePlugin()) bridge?.registerPluginInstance(CloudFilesPlugin()) + bridge?.registerPluginInstance(BackgroundSyncPlugin()) } override open func viewDidAppear(_ animated: Bool) { diff --git a/src/bridge/background-sync.test.ts b/src/bridge/background-sync.test.ts new file mode 100644 index 0000000..89d6871 --- /dev/null +++ b/src/bridge/background-sync.test.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const store = new Map() +;(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key) +} + +interface Harness { + api: Record + calls: string[] + vaultListeners: Set<() => void> + /** Fires the native `expired` event, as the plugin does when iOS ends the time. */ + expire(): void + /** Emits a vault change, as an in-app write or the sync's rescan does. */ + change(): void +} + +async function load(): Promise { + const calls: string[] = [] + const listeners = new Map void>() + const vaultListeners = new Set<() => void>() + const plugin = { + begin: async () => void calls.push('begin'), + end: async () => void calls.push('end'), + addListener: async (event: string, listener: () => void) => { + listeners.set(event, listener) + return { remove: async () => {} } + } + } + const api = await loadMobileModule('./src/bridge/background-sync.ts', { + '@capacitor/core': { registerPlugin: () => plugin }, + './cloud-sync-client': { + stopMobileCloudRequests: () => void calls.push('stop'), + resumeMobileCloudRequests: () => void calls.push('resume') + } + }) + return { + api, + calls, + vaultListeners, + expire: () => listeners.get('expired')?.(), + change: () => { + for (const listener of vaultListeners) listener() + } + } +} + +function host(harness: Harness, options: { ready?: boolean; sync?: () => Promise } = {}) { + const listeners = harness.vaultListeners + return { + ready: async () => { + harness.calls.push('ready') + return options.ready ?? true + }, + sync: options.sync ?? (async () => void harness.calls.push('sync')), + onVaultChange: (listener: () => void) => { + listeners.add(listener) + return () => void listeners.delete(listener) + } + } +} + +it('is on by default, and turning it on again removes the stored choice', async () => { + store.clear() + const { api } = await load() + assert.equal(api.backgroundSyncEnabled(), true) + api.setBackgroundSyncEnabled(false) + assert.equal(store.get(api.BACKGROUND_SYNC_KEY), 'off') + assert.equal(api.backgroundSyncEnabled(), false) + api.setBackgroundSyncEnabled(true) + assert.equal(store.has(api.BACKGROUND_SYNC_KEY), false) + assert.equal(api.backgroundSyncEnabled(), true) +}) + +it('stops Cloud requests at once on leaving before it is installed, as leaving always did', async () => { + store.clear() + const harness = await load() + harness.api.leaveApp() + harness.api.returnToApp() + assert.deepEqual(harness.calls, ['stop', 'resume']) +}) + +it('stops at once when the setting is off, without asking whether the vault is ready', async () => { + store.clear() + store.set('zn:background-sync', 'off') + const harness = await load() + harness.api.installBackgroundSync(host(harness)) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['stop']) +}) + +it('finishes a sync in the grace period, then stops requests', async () => { + store.clear() + const harness = await load() + harness.api.installBackgroundSync(host(harness)) + harness.api.leaveApp() + assert.equal(harness.calls.includes('stop'), false, 'requests keep running while the final sync is pending') + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['ready', 'begin', 'sync', 'end', 'stop']) +}) + +it('stops without a grace period when the vault is not linked to Cloud', async () => { + store.clear() + const harness = await load() + harness.api.installBackgroundSync(host(harness, { ready: false })) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['ready', 'stop']) +}) + +it('keeps requests running when the app comes back before the final sync ends', async () => { + store.clear() + const harness = await load() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + harness.calls.push('sync') + await gate + } + })) + harness.api.leaveApp() + while (!harness.calls.includes('sync')) await new Promise((resolve) => setTimeout(resolve, 10)) + harness.api.returnToApp() + release() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['ready', 'begin', 'sync', 'resume', 'end']) +}) + +it('runs a second pass when the vault changed during the first, and never a third', async () => { + store.clear() + const harness = await load() + let passes = 0 + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + passes += 1 + harness.calls.push('sync') + // Every pass reports a change, as a sync that pulled notes does. + harness.change() + } + })) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 2) + assert.deepEqual(harness.calls, ['ready', 'begin', 'sync', 'sync', 'end', 'stop']) +}) + +it('includes an edit saved while it waits for the editor', async () => { + store.clear() + const harness = await load() + let passes = 0 + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + passes += 1 + harness.calls.push('sync') + } + })) + harness.api.leaveApp() + // The editor's save lands during the wait, before the first pass. + while (!harness.calls.includes('begin')) await new Promise((resolve) => setTimeout(resolve, 5)) + harness.change() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 1, 'one pass reads the saved edit; the change came before it') +}) + +it('stops requests when iOS ends the grace period first, and still ends the task', async () => { + store.clear() + const harness = await load() + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + harness.calls.push('sync') + harness.expire() + throw new DOMException('The operation was aborted.', 'AbortError') + } + })) + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.deepEqual(harness.calls, ['ready', 'begin', 'sync', 'stop', 'end', 'stop']) +}) + +it('ignores an expiry that arrives after the app is back on screen', async () => { + store.clear() + const harness = await load() + harness.api.installBackgroundSync(host(harness)) + harness.api.returnToApp() + harness.expire() + assert.deepEqual(harness.calls, ['resume']) +}) + +it('starts one final sync however often the app leaves while it runs', async () => { + store.clear() + const harness = await load() + let passes = 0 + harness.api.installBackgroundSync(host(harness, { + sync: async () => { + passes += 1 + harness.calls.push('sync') + } + })) + harness.api.leaveApp() + harness.api.leaveApp() + await harness.api.backgroundSyncSettled() + assert.equal(passes, 1) +}) diff --git a/src/bridge/background-sync.ts b/src/bridge/background-sync.ts new file mode 100644 index 0000000..6b1469e --- /dev/null +++ b/src/bridge/background-sync.ts @@ -0,0 +1,165 @@ +/** + * Background sync: Settings → Cloud → Sync in the background, on by default. + * + * The sync engine runs in this WebView, and iOS stops running a WebView a few + * seconds after the app leaves the screen, so a sync cannot run on a timer + * while the app is away. What this does instead: when you leave ZenNotes, it + * asks iOS for its grace period (`beginBackgroundTask`, about 30 seconds) and + * finishes a sync in it, so an edit made just before leaving reaches your + * other devices without opening the app again. Changes from those devices + * still arrive when the app comes back. + * + * Leaving used to stop every Cloud request at once (cloud-sync-client.ts + * binds them to one lifetime), which abandoned a sync in progress. With the + * setting on, the requests stop after that final sync instead, or when iOS + * ends the grace period; with it off, or before this module is installed, + * leaving stops them at once, as before. Signing out stops them at once + * either way. + */ +import { registerPlugin, type PluginListenerHandle } from '@capacitor/core' +import type { VaultChangeEvent } from '@zennotes/bridge-contract/ipc' +import { BACKGROUND_SYNC_KEY } from '../viewport' +import { resumeMobileCloudRequests, stopMobileCloudRequests } from './cloud-sync-client' + +export { BACKGROUND_SYNC_KEY } + +/** The editor writes a note 350 ms after the last keystroke (app-core's + * PATH_SAVE_DEBOUNCE_MS). Waiting a little longer lets an edit made just + * before leaving reach the disk before the sync reads the vault. */ +const SAVE_SETTLE_MS = 500 + +/** A second pass runs only when the vault changed during the first. Never + * more: the sync's own rescan reports changes too. */ +const MAX_PASSES = 2 + +export interface BackgroundSyncPlugin { + /** Starts the grace period (UIApplication.beginBackgroundTask). */ + begin(): Promise + end(): Promise + /** iOS is ending the grace period; the native side has ended the task. */ + addListener(event: 'expired', listener: () => void): Promise +} + +/** What this module needs from the open vault (mobile-bridge.ts). */ +export interface BackgroundSyncHost { + /** Signed in, and the open vault is linked to this account's Cloud. */ + ready(): Promise + /** One sync pass of the open vault; joins a pass that is already running. */ + sync(): Promise + onVaultChange(listener: (event: VaultChangeEvent) => void): () => void +} + +let host: BackgroundSyncHost | null = null +let plugin: BackgroundSyncPlugin | null = null +let active = true +let leaving: Promise | null = null +let runs = 0 + +/** Registered on first use, so loading this module never touches native code. */ +function native(): BackgroundSyncPlugin { + plugin ??= registerPlugin('ZenBackgroundSync') + return plugin +} + +export function backgroundSyncEnabled(): boolean { + try { + return localStorage.getItem(BACKGROUND_SYNC_KEY) !== 'off' + } catch { + return true + } +} + +export function setBackgroundSyncEnabled(enabled: boolean): void { + try { + // On is the default, so it removes the key: a fresh install and a reset + // look the same. + if (enabled) localStorage.removeItem(BACKGROUND_SYNC_KEY) + else localStorage.setItem(BACKGROUND_SYNC_KEY, 'off') + } catch { + // Storage unavailable: the choice applies to this session only. + } +} + +export function installBackgroundSync(next: BackgroundSyncHost): void { + if (host) return + host = next + void native() + .addListener('expired', () => { + // The grace period is over: stop as leaving used to, unless the app is + // already back on screen. + if (!active) stopMobileCloudRequests() + }) + .catch(() => {}) +} + +/** The app left the screen (Capacitor's appStateChange, isActive false). */ +export function leaveApp(): void { + active = false + const current = host + if (!current || !backgroundSyncEnabled()) { + stopMobileCloudRequests() + return + } + leaving ??= holdRequests(() => finishSync(current)).finally(() => { + leaving = null + }) +} + +/** The app is back on screen. */ +export function returnToApp(): void { + active = true + resumeMobileCloudRequests() +} + +/** Settles once the final sync started by leaving has ended (tests). */ +export function backgroundSyncSettled(): Promise { + return leaving ?? Promise.resolve() +} + +/** Background work keeps Cloud requests open; the last run to end stops them + * if the app is still away. */ +async function holdRequests(work: () => Promise): Promise { + runs += 1 + try { + await work() + } finally { + runs -= 1 + if (runs === 0 && !active) stopMobileCloudRequests() + } +} + +async function finishSync(current: BackgroundSyncHost): Promise { + let began = false + try { + if (!(await current.ready())) return + await native().begin() + began = true + await delay(SAVE_SETTLE_MS) + await syncPasses(current) + } catch (error) { + // The next foreground sync picks it up; nothing here can be shown. + console.warn('Background sync did not finish', error) + } finally { + if (began) await native().end().catch(() => {}) + } +} + +async function syncPasses(current: BackgroundSyncHost): Promise { + let changed = false + const stopWatching = current.onVaultChange(() => { + changed = true + }) + try { + for (let pass = 0; pass < MAX_PASSES; pass += 1) { + changed = false + await current.sync() + if (!changed) break + } + } finally { + stopWatching() + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index daf1d90..a9c32f7 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -80,6 +80,7 @@ import { selectExternalVault } from './folder-picker' import { emitVaultChange, onVaultChange, onOpenNoteRequested, requestOpenNote } from './events' +import type { BackgroundSyncHost } from './background-sync' import { openAssetExternally } from './open-asset' import { renderTikzOnDevice } from './tikz' import { fetchLinkMetadataOnDevice } from './link-metadata' @@ -422,6 +423,28 @@ function activeMobileVault(): MobileVault { return current } +/** The open vault as background sync sees it: ready when it is an on-device + * vault linked to the signed-in account, the check app-core's automatic + * sync makes before each run. */ +export const mobileBackgroundSyncHost: BackgroundSyncHost = { + async ready() { + let current: MobileVault + try { + current = activeMobileVault() + } catch { + return false + } + const status = await getMobileCloudAccountStatus() + if (status.state !== 'connected' || !status.account) return false + const link = await getMobileCloudVaultLink(current) + return link !== null && link.base_url === status.account.base_url + }, + async sync() { + await syncMobileCloudVault(activeMobileVault()) + }, + onVaultChange +} + function vaultNameFromRoot(root: string): string { const raw = root.startsWith(VAULT_ROOT_PREFIX) ? root.slice(VAULT_ROOT_PREFIX.length) : root const name = decodeURIComponent(raw).split('/').filter(Boolean).pop() ?? '' diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index ab060b4..9341583 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -28,6 +28,7 @@ import { } from '@zennotes/shared-domain/cloud-e2ee-account' import { createCloudSyncClient, firstValidationMessage, stopMobileCloudRequests, resumeMobileCloudRequests, mobileCloudRequestSignal } from './cloud-sync-client' import { publishWithStagedUploads, type CloudPublishAssetPlatform } from '@zennotes/shared-domain/cloud-publish-uploads' +import { leaveApp, returnToApp } from './background-sync' // The trailing-slash strip matters: allowedInsecureOrigins and the exchange // URL are compared/joined against an origin with no trailing slash. @@ -248,8 +249,10 @@ export async function configureMobileCloudAuth(appVersion: string): Promise { appActive = isActive - if (isActive) resumeMobileCloudRequests() - else stopMobileCloudRequests() + // Leaving stops Cloud requests, after a final sync when background sync + // is on (background-sync.ts). + if (isActive) returnToApp() + else leaveApp() }) const launch = await CapApp.getLaunchUrl() if (launch?.url && isCloudAuthUrl(launch.url)) scheduleAuthCallback(launch.url) diff --git a/src/main.tsx b/src/main.tsx index f17d5bb..0c7d8c1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,8 +17,10 @@ import { installMobileBridge, loadNativeAppVersion, bootVault, - importPendingShares + importPendingShares, + mobileBackgroundSyncHost } from './bridge/mobile-bridge' +import { installBackgroundSync } from './bridge/background-sync' import { syncKeyboardBackdrop } from './bridge/keyboard-backdrop' import { configureMobileCloudAuth } from './bridge/mobile-cloud-auth' import { maybeRunFirstRunOnboarding } from './ui-mobile/Onboarding' @@ -95,6 +97,7 @@ async function boot(): Promise { const appVersion = await loadNativeAppVersion() await configureMobileCloudAuth(appVersion) installMobileBridge() + installBackgroundSync(mobileBackgroundSyncHost) // FIRST store subscriber, ahead of everything React mounts: the guard // undoes app-core's null-active-tab fallback inside the same notification // pass, and zustand notifies in subscription order — installed any later, diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 1737dfa..8e6c86c 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -48,6 +48,7 @@ import { WELCOME_PENDING_KEY, FAB_HINT_KEY } from './Onboarding' import { WELCOME_NOTE_PATH } from '../bridge/welcome-note' import ensoUrl from '../assets/enso.png' import { getStoragePref } from '../bridge/icloud' +import { backgroundSyncEnabled, setBackgroundSyncEnabled } from '../bridge/background-sync' import { getPinnedNotes, loadPins } from './pins' import { isSwipeRowGestureActive } from './SwipeRow' import { installNoteRowGestures, NOTE_ROW_SELECTOR } from './note-row-gestures' @@ -2593,6 +2594,94 @@ function useLayoutSettingsRow(): void { }, []) } +/** + * Settings → Cloud → Sync in the background (island under the linked-vault + * card, on every width: the iPad leaves the app the same way). What the + * setting does is bridge/background-sync.ts's. + */ +function SettingsBackgroundSyncRow(): React.JSX.Element { + const [enabled, setEnabled] = useState(() => backgroundSyncEnabled()) + const choose = (next: boolean): void => { + setBackgroundSyncEnabled(next) + setEnabled(next) + } + return ( +
+
+
Sync in the background
+
+ {enabled + ? 'When you leave ZenNotes, it keeps going for a few seconds to send your latest changes. iOS pauses it after that, so changes from your other devices arrive when you open it again.' + : 'Leaving ZenNotes stops syncing at once. Anything not sent yet goes up when you open it again.'} +
+
+
+ {BACKGROUND_SYNC_CHOICES.map((choice) => ( + + ))} +
+
+ ) +} + +const BACKGROUND_SYNC_CHOICES: Array<{ value: boolean; label: string }> = [ + { value: true, label: 'On' }, + { value: false, label: 'Off' } +] + +function useCloudSettingsRow(): void { + useEffect(() => { + let container: HTMLElement | null = null + let root: ReturnType | null = null + const sync = (): void => { + // Anchor on the vault card (app-core's cloud-vault section, walked up + // to the Cloud page's direct child) and sit right below it, with the + // keep-alive discipline of the Layout island: the container moves or + // detaches, the React tree survives. + const vaultCard = document.querySelector( + '[data-settings-search-id="cloud-vault"]' + ) + let anchor: HTMLElement | null = vaultCard + while ( + anchor && + !(anchor.parentElement?.className ?? '').split(/\s+/).includes('space-y-6') + ) { + anchor = anchor.parentElement + } + const parent = anchor?.parentElement + if (!anchor || !parent) { + container?.remove() + return + } + if (container?.parentElement === parent && container.previousElementSibling === anchor) return + if (!container) { + container = document.createElement('div') + container.className = 'zn-settings-layout-host' + root = ReactDOM.createRoot(container) + root.render() + } + parent.insertBefore(container, anchor.nextSibling) + } + const observer = new MutationObserver(() => sync()) + observer.observe(document.body, { childList: true, subtree: true }) + sync() + return () => { + observer.disconnect() + root?.unmount() + container?.remove() + } + }, []) +} + // --------------------------------------------------------------------------- // Settings → About → GitHub links (Discord feedback, 2026-08-20: "About // section has no link to Github"). App-core's About pane links only to @@ -2708,6 +2797,7 @@ function MobileShellRoot(): React.JSX.Element { useYouTubeLiteEmbeds() useVaultSettingsRows() useLayoutSettingsRow() + useCloudSettingsRow() useAboutGitHubLinks() useAtlasTouchGestures() const sheet = useMobileSheet() diff --git a/src/viewport.ts b/src/viewport.ts index 27d4c48..9406718 100644 --- a/src/viewport.ts +++ b/src/viewport.ts @@ -127,6 +127,10 @@ export const GESTURES_KEY = 'zn:gestures' * the user left off), see ui-mobile/start-screen.ts. */ export const START_SCREEN_KEY = 'zn:start-screen' +/** localStorage key for Settings → Cloud → Sync in the background ('off'; + * absent = on), see bridge/background-sync.ts. */ +export const BACKGROUND_SYNC_KEY = 'zn:background-sync' + export function getLayoutMode(): LayoutMode { try { const raw = localStorage.getItem(LAYOUT_MODE_KEY) diff --git a/tooling/add-background-sync.rb b/tooling/add-background-sync.rb new file mode 100644 index 0000000..e1b88bf --- /dev/null +++ b/tooling/add-background-sync.rb @@ -0,0 +1,15 @@ +require 'xcodeproj' + +# BackgroundSyncPlugin.swift (src/bridge/background-sync.ts's grace period) +# into the App target. Idempotent, like the other wiring scripts. +project = Xcodeproj::Project.open(File.expand_path('../ios/App/App.xcodeproj', __dir__)) +target = project.targets.find { |candidate| candidate.name == 'App' } +group = project.main_group['App'] +raise 'App target or group not found' unless target && group + +%w[BackgroundSyncPlugin.swift].each do |name| + reference = group.files.find { |file| file.display_name == name } || group.new_reference(name) + target.add_file_references([reference]) unless target.source_build_phase.files_references.include?(reference) +end + +project.save