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