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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -105,6 +106,7 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
5636CB2F77E91CE10C3EFE52 /* BackgroundSyncPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BackgroundSyncPlugin.swift; sourceTree = "<group>"; };
56C2868F946FC6AE7F918E2A /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
6A7467BA76B7322966D7EA03 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
7B09FFFEE67FC0E73BE167CD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -229,6 +231,7 @@
830C64B43794EBCBD9091276 /* WidgetBridgePlugin.swift */,
C10DF11F5A2B4C7D9E8F0A1C /* CloudFilesPlugin.swift */,
9115A46C405EC261610EC467 /* CloudFileStream.swift */,
5636CB2F77E91CE10C3EFE52 /* BackgroundSyncPlugin.swift */,
);
path = App;
sourceTree = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
46 changes: 46 additions & 0 deletions ios/App/App/BackgroundSyncPlugin.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
1 change: 1 addition & 0 deletions ios/App/App/ZNViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
209 changes: 209 additions & 0 deletions src/bridge/background-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>()
;(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<string, any>
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<Harness> {
const calls: string[] = []
const listeners = new Map<string, () => 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<void> } = {}) {
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<void>((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)
})
Loading
Loading