From 9e58fa90e4c247267958b58469da8252d74428c0 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 11:20:42 -0500 Subject: [PATCH 1/3] fix: adopt Cloud 2.50.0 fixes and probe incoming changes --- .zennotes-commit | 2 +- src/bridge/mobile-bridge.ts | 5 ++ .../mobile-cloud-sync.integration.test.ts | 47 +++++++++++++++++-- src/bridge/mobile-cloud-sync.ts | 15 ++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/.zennotes-commit b/.zennotes-commit index 92ac9f2..3119a4f 100644 --- a/.zennotes-commit +++ b/.zennotes-commit @@ -1 +1 @@ -a3e638fc93852e602501d6cbbed6e0c0a60d8cd4 +104416f560bb9e709620cabe9e1f225306f7a4ea diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 7e7aa6d..5368e4a 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -102,6 +102,7 @@ import { restoreMobileCloudBackup, restoreMobileCloudBackupNote, syncMobileCloudVault, + hasMobileCloudVaultChanges, updateMobileCloudBackupSchedule, unlinkMobileCloudVault, deleteMobileCloudVault, @@ -852,6 +853,10 @@ export const mobileBridge: ZenBridge = { resolveCloudSettingsConflict: (choice) => resolveMobileCloudSettingsConflict(activeMobileVault(), choice), syncCloudVault: () => syncMobileCloudVault(activeMobileVault()), + hasCloudVaultChanges: () => { + const vault = activeVault() + return vault instanceof MobileVault ? hasMobileCloudVaultChanges(vault) : Promise.resolve(false) + }, getCloudBootstrapConflict: (conflict) => getMobileCloudBootstrapConflict(activeMobileVault(), conflict), resolveCloudBootstrapConflict: (resolution) => diff --git a/src/bridge/mobile-cloud-sync.integration.test.ts b/src/bridge/mobile-cloud-sync.integration.test.ts index f1f1118..8c42507 100644 --- a/src/bridge/mobile-cloud-sync.integration.test.ts +++ b/src/bridge/mobile-cloud-sync.integration.test.ts @@ -27,6 +27,8 @@ async function fixture(initial: Record = { 'note.md': 'Original' const reads: string[] = [] const refreshes: Record[] = [] const uploaded: CloudSyncMutation[] = [] + const manifestRequests: unknown[] = [] + let accountStatus = { state: 'connected', account: { base_url: 'https://sync.example.test' } } let cursor = 0 let clock = 1000 let failWritePath: string | null = null @@ -54,7 +56,10 @@ async function fixture(initial: Record = { 'note.md': 'Original' } const remote = { listVaults: async () => ({ data: [{ id: 'vault-1', name: 'Test vault' }] }), - manifest: async () => ({ data: [...remoteItems.values()], cursor, next_page: null }), + manifest: async (_vaultId: string, options?: unknown) => { + manifestRequests.push(options) + return { data: [...remoteItems.values()], cursor, next_page: null } + }, changes: async (_vaultId: string, after: number) => { beforeChanges?.() return { data: feed.filter((change) => change.sequence > after), cursor, has_more: false } @@ -164,15 +169,14 @@ async function fixture(initial: Record = { 'note.md': 'Original' './mobile-cloud-auth': { authenticatedCredential: async () => ({ base_url: 'https://sync.example.test', token: 'test-only' }), authenticatedClient: async () => remote, - getMobileCloudAccountStatus: async () => ({ - state: 'connected', account: { base_url: 'https://sync.example.test' } - }) + getMobileCloudAccountStatus: async () => accountStatus } }) await api.linkMobileCloudVault(vault, 'vault-1') const stateKey = () => [...persisted.keys()].find((path) => path.includes('/states/')) return { - api, vault, files, reads, uploaded, refreshes, remoteText, put, + api, vault, files, reads, uploaded, refreshes, remoteText, put, manifestRequests, + setAccountStatus: (value: typeof accountStatus) => { accountStatus = value }, sync: () => api.syncMobileCloudVault(vault), setFailWrite: (path: string | null) => { failWritePath = path }, setBeforeChanges: (callback: typeof beforeChanges) => { beforeChanges = callback }, @@ -191,6 +195,39 @@ async function fixture(initial: Record = { 'note.md': 'Original' } describe('mobile Cloud adapter wiring', () => { + it('detects remote changes from a one-item metadata manifest without scanning local files', async () => { + const h = await fixture() + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) + assert.deepEqual(h.manifestRequests, []) + await h.sync() + h.manifestRequests.length = 0 + h.reads.length = 0 + h.refreshes.length = 0 + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + h.remoteText('note.md', 'Incoming edit') + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), true) + assert.deepEqual(h.manifestRequests, [ + { includeContent: false, perPage: 1 }, { includeContent: false, perPage: 1 } + ]) + assert.deepEqual(h.reads, []) + assert.deepEqual(h.refreshes, []) + await h.sync() + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + }) + + it('does not probe an unlinked, disconnected, or different-origin account', async () => { + const h = await fixture() + await h.sync() + h.manifestRequests.length = 0 + h.setAccountStatus({ state: 'disconnected', account: { base_url: 'https://sync.example.test' } }) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + h.setAccountStatus({ state: 'connected', account: { base_url: 'https://another.example.test' } }) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + await h.api.unlinkMobileCloudVault(h.vault) + assert.equal(await h.api.hasMobileCloudVaultChanges(h.vault), false) + assert.deepEqual(h.manifestRequests, []) + }) + it('does not rescan the vault or reread acknowledged bytes during a no-op sync', async () => { const h = await fixture() await h.sync() diff --git a/src/bridge/mobile-cloud-sync.ts b/src/bridge/mobile-cloud-sync.ts index 5748b74..c630270 100644 --- a/src/bridge/mobile-cloud-sync.ts +++ b/src/bridge/mobile-cloud-sync.ts @@ -132,6 +132,21 @@ export async function resolveMobileCloudSettingsConflict( await vault.fs.deleteFile(CLOUD_SYNC_SETTINGS_CONFLICT_PATH).catch(() => {}) } +/** Check the server cursor without scanning the vault or downloading attachment bytes. */ +export async function hasMobileCloudVaultChanges(vault: MobileVault): Promise { + const link = await getMobileCloudVaultLink(vault) + if (!link) return false + const status = await getMobileCloudAccountStatus() + if (status.state !== 'connected' || status.account?.base_url !== link.base_url) return false + const value = await persistence.loadState(vault.fs.rootPath, link.base_url, link.vault_id) + const state = value as Partial | null + if (!state || state.version !== 1 || state.vault_id !== link.vault_id || + typeof state.cursor !== 'number' || !Number.isInteger(state.cursor) || state.cursor < 0) return true + const client = await authenticatedClient() + const manifest = await client.manifest(link.vault_id, { includeContent: false, perPage: 1 }) + return manifest.cursor !== state.cursor +} + export async function syncMobileCloudVault(vault: MobileVault): Promise { // No emit here: the host service runs vault.rescan() after every sync // (cloud-sync-host-service run()'s finally), and rescan emits the one From 42911bfbd927839908aae9ee7f5fa9f4a9ef19ac Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 11:20:42 -0500 Subject: [PATCH 2/3] fix: send iOS Cloud upload bodies and honor request timeouts --- src/bridge/cloud-sync-client.test.ts | 27 +++++++++++++++++++++++++ src/bridge/cloud-sync-client.ts | 11 +++++----- src/bridge/mobile-direct-upload.test.ts | 22 +++++++++++++++++++- src/bridge/mobile-direct-upload.ts | 11 ++++++++-- 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 src/bridge/cloud-sync-client.test.ts diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts new file mode 100644 index 0000000..b4928a5 --- /dev/null +++ b/src/bridge/cloud-sync-client.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +it('allows the full publishing timeout through the native iOS transport', async () => { + const requests: Array<{ connectTimeout?: number; readTimeout?: number }> = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + CapacitorHttp: { + request: async (options: { connectTimeout?: number; readTimeout?: number }) => { + requests.push(options) + return { status: 200, data: { id: 1, slug: 'test', url: 'https://example.test/s/test' } } + } + } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only') + const note = { note_path: 'Test.md', title: 'Test', markdown: 'Latest content' } + await client.publishNote(note) + await client.updatePublishedNote(1, note) + // Capacitor 7 iOS applies connectTimeout ?? readTimeout to the entire + // URLRequest, so a shorter connection value silently wins over readTimeout. + assert.deepEqual(requests.map(({ connectTimeout, readTimeout }) => ({ connectTimeout, readTimeout })), [ + { connectTimeout: 300_000, readTimeout: 300_000 }, + { connectTimeout: 300_000, readTimeout: 300_000 } + ]) +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index 665cf24..91114ed 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -39,6 +39,7 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync const transport: CloudSyncHttpTransport = { async request(request: CloudSyncHttpRequest): Promise { const multipart = request.body instanceof FormData + const timeoutMs = request.timeoutMs ?? 300_000 const response = await CapacitorHttp.request({ method: request.method, url: `${normalizedBaseUrl}${request.path}`, @@ -55,12 +56,10 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync ? await serializeFormData(request.body) : request.body, ...(multipart ? { dataType: 'formData' as const } : {}), - connectTimeout: 30_000, - // Generous on purpose: a first sync of an attachment-heavy vault - // legitimately pushes 100-item base64 batches over cellular, and a - // timeout here retries into the same wall forever. Desktop's fetch - // transport has no read timeout at all. - readTimeout: request.timeoutMs ?? 300_000 + // Capacitor iOS uses connectTimeout ahead of readTimeout for the + // entire request. Keep them equal so long publications can finish. + connectTimeout: timeoutMs, + readTimeout: timeoutMs }) if (response.status < 200 || response.status >= 300) { diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index 2a99f5b..cc80f7a 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -15,6 +15,26 @@ import { } from './mobile-direct-upload.ts' describe('mutateWithMobileDirectUploads', () => { + it('supplies a content type when production signs only the host so iOS sends the file body', () => { + const headers = { Host: 'objects.example.test' } + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', headers, base64: 'AQID', byteLength: 3 + }) + assert.equal(options.headers['Content-Type'], 'application/octet-stream') + assert.equal(options.headers.Host, headers.Host) + assert.deepEqual(headers, { Host: 'objects.example.test' }) + }) + + it('preserves a signed content type regardless of header casing', () => { + const options = mobileObjectUploadOptions({ + url: 'https://objects.example.test/upload?signature=signed', + method: 'PUT', headers: { 'content-type': 'image/jpeg' }, base64: 'AQID', byteLength: 3 + }) + const contentTypes = Object.entries(options.headers).filter(([key]) => key.toLowerCase() === 'content-type') + assert.deepEqual(contentTypes, [['content-type', 'image/jpeg']]) + }) + it('builds a native binary PUT without an account bearer token or redirects', () => { const options = mobileObjectUploadOptions({ url: 'https://objects.example.test/upload?signature=signed', @@ -36,7 +56,7 @@ describe('mutateWithMobileDirectUploads', () => { }, data: 'AQID', dataType: 'file', - connectTimeout: 30_000, + connectTimeout: 300_000, readTimeout: 300_000, disableRedirects: true }) diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 22aa87b..2c3ec09 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -57,13 +57,20 @@ export interface MobileObjectUploadOptions { export function mobileObjectUploadOptions( request: MobileObjectUploadRequest ): MobileObjectUploadOptions { + // Capacitor iOS only attaches the binary body when Content-Type exists. + // Production presigned URLs may return just Host; preserve signed headers. + const headers = { ...request.headers } + if (!Object.keys(headers).some((key) => key.toLowerCase() === 'content-type')) { + headers['Content-Type'] = 'application/octet-stream' + } return { url: request.url, method: request.method, - headers: request.headers, + headers, data: request.base64, dataType: 'file', - connectTimeout: 30_000, + // iOS uses connectTimeout ahead of readTimeout for the whole request. + connectTimeout: 300_000, readTimeout: 300_000, disableRedirects: true } From 9971018286cd371f90d7afb882e46d8be3afde50 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 11:20:42 -0500 Subject: [PATCH 3/3] fix: preserve Cloud sign-in across native app restarts --- src/bridge/mobile-cloud-auth.test.ts | 86 ++++++++++++++++++++++++++++ src/bridge/mobile-cloud-auth.ts | 47 ++++++++++++--- tooling/load-mobile-module.ts | 1 + 3 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 src/bridge/mobile-cloud-auth.test.ts diff --git a/src/bridge/mobile-cloud-auth.test.ts b/src/bridge/mobile-cloud-auth.test.ts new file mode 100644 index 0000000..8787175 --- /dev/null +++ b/src/bridge/mobile-cloud-auth.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { it } from 'node:test' +import { registerPlugin } from '@capacitor/core' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const account = { + base_url: 'https://zennotes.org', connected_at: '2026-09-14T12:00:00Z', + user: { name: 'Test', email: 'test@example.test' }, + device: { id: 'test-device', name: 'Test phone', platform: 'ios' } +} +const credential = JSON.stringify({ base_url: account.base_url, token: 'test-only', account }) + +async function coldLaunch(saved: Map) { + // Exercise the real Capacitor lazy proxy: concurrent first calls can + // instantiate separate implementations, each with its own key prefix. + const secureStorage = registerPlugin(`TestCloudStorage${randomUUID()}`, { + web: async () => { + await Promise.resolve() + return new class { + prefix = 'capacitor-storage_' + async setKeyPrefix(prefix: string) { this.prefix = prefix } + async setSynchronize(_value: boolean) {} + async setDefaultKeychainAccess(_value: unknown) {} + async getItem(key: string) { return saved.get(this.prefix + key) ?? null } + async setItem(key: string, value: string) { saved.set(this.prefix + key, value) } + async removeItem(key: string) { saved.delete(this.prefix + key) } + }() + } + }) + const api = await loadMobileModule('./src/bridge/mobile-cloud-auth.ts', { + '@capacitor/core': { Capacitor: { isNativePlatform: () => true }, CapacitorHttp: {} }, + '@capacitor/app': { App: { addListener: async () => ({}), getLaunchUrl: async () => null } }, + '@aparajita/capacitor-secure-storage': { + SecureStorage: secureStorage, + KeychainAccess: { whenUnlockedThisDeviceOnly: 'device-only' } + }, + './cloud-sync-client': { createCloudSyncClient: () => assert.fail('status must only read storage') } + }) + await api.configureMobileCloudAuth('test-version') + return api +} + +for (const prefix of ['zennotes.cloud.', 'capacitor-storage_']) { + it(`loads a saved account from ${prefix} on every cold launch`, async () => { + const saved = new Map([[prefix + 'credential', credential]]) + for (let launch = 0; launch < 2; launch++) { + const api = await coldLaunch(saved) + const statuses = await Promise.all([api.getMobileCloudAccountStatus(), api.getMobileCloudAccountStatus()]) + assert.deepEqual(statuses, [{ state: 'connected', account }, { state: 'connected', account }]) + assert.deepEqual([...saved.keys()], ['zennotes.cloud.credential']) + } + }) +} + +it('preserves the canonical account and prevents a legacy credential from returning after logout', async () => { + const saved = new Map([ + ['zennotes.cloud.credential', credential], + ['capacitor-storage_credential', JSON.stringify({ base_url: account.base_url, token: 'old-test-token', account })] + ]) + const api = await coldLaunch(saved) + assert.equal((await api.authenticatedCredential()).token, 'test-only') + await api.logoutMobileCloudAccount() + assert.equal(saved.size, 0) + assert.deepEqual(await (await coldLaunch(saved)).getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) +}) + +it('rejects invalid recovered credentials through the shared auth validator', async () => { + const invalid = JSON.stringify({ base_url: 'https://wrong.example.test', token: 'test-only', account }) + const saved = new Map([['capacitor-storage_credential', invalid]]) + const api = await coldLaunch(saved) + assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'disconnected', account: null }) + assert.equal(saved.size, 0) +}) + +it('recovers pending sign-in state across cold launches', async () => { + const pending = JSON.stringify({ + base_url: account.base_url, state: 'test-state', code_verifier: 'a'.repeat(43), expires_at: '2099-01-01T00:00:00Z' + }) + const saved = new Map([['capacitor-storage_pending-auth', pending]]) + for (let launch = 0; launch < 2; launch++) { + const api = await coldLaunch(saved) + assert.deepEqual(await api.getMobileCloudAccountStatus(), { state: 'connecting', account: null }) + assert.deepEqual([...saved.entries()], [['zennotes.cloud.pending-auth', pending]]) + } +}) diff --git a/src/bridge/mobile-cloud-auth.ts b/src/bridge/mobile-cloud-auth.ts index 8b83038..6bc2712 100644 --- a/src/bridge/mobile-cloud-auth.ts +++ b/src/bridge/mobile-cloud-auth.ts @@ -36,17 +36,13 @@ const accountListeners = new Set<(status: CloudAccountStatus) => void>() let authFlow: CloudAuthFlow | null = null let callbackQueue = Promise.resolve() -// Lazy and retryable: a module-level Promise.all that rejected once would -// poison every later storage call for the whole session. +// Lazy and retryable so a transient native storage failure does not poison +// every later account read for the session. let secureStorageSetup: Promise | null = null function secureStorageReady(): Promise { if (!Capacitor.isNativePlatform()) return Promise.resolve() if (!secureStorageSetup) { - secureStorageSetup = Promise.all([ - SecureStorage.setKeyPrefix('zennotes.cloud.'), - SecureStorage.setSynchronize(false), - SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly) - ]).then(() => undefined) + secureStorageSetup = configureSecureStorage() secureStorageSetup.catch(() => { secureStorageSetup = null }) @@ -54,6 +50,43 @@ function secureStorageReady(): Promise { return secureStorageSetup } +async function configureSecureStorage(): Promise { + // The Capacitor proxy loads its implementation lazily. Parallel first + // calls can initialize separate instances and lose the configured prefix. + await SecureStorage.setKeyPrefix('zennotes.cloud.') + await SecureStorage.setSynchronize(false) + await SecureStorage.setDefaultKeychainAccess(KeychainAccess.whenUnlockedThisDeviceOnly) + await migrateAuthStoragePrefix() +} + +async function migrateAuthStoragePrefix(): Promise { + const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY] + const canonical = new Map() + for (const key of keys) canonical.set(key, await SecureStorage.getItem(key)) + + // Affected builds could persist auth under the plugin's default prefix. + // All storage callers await setup, so none can see this temporary prefix. + // CloudAuthFlow still validates every recovered record before using it. + try { + await SecureStorage.setKeyPrefix('capacitor-storage_') + const legacy = new Map() + for (const key of keys) { + const value = await SecureStorage.getItem(key) + if (value !== null) legacy.set(key, value) + } + await SecureStorage.setKeyPrefix('zennotes.cloud.') + for (const [key, value] of legacy) { + if (canonical.get(key) === null) await SecureStorage.setItem(key, value) + } + // Remove superseded records as well, so logout cannot resurrect an older + // credential on the next launch. Copying must finish before removal. + await SecureStorage.setKeyPrefix('capacitor-storage_') + for (const key of legacy.keys()) await SecureStorage.removeItem(key) + } finally { + await SecureStorage.setKeyPrefix('zennotes.cloud.') + } +} + const storage: CloudAuthStorage = { async loadPending(): Promise { if (!Capacitor.isNativePlatform()) return null diff --git a/tooling/load-mobile-module.ts b/tooling/load-mobile-module.ts index 98ee3e0..0f91a50 100644 --- a/tooling/load-mobile-module.ts +++ b/tooling/load-mobile-module.ts @@ -23,6 +23,7 @@ export async function loadMobileModule( platform: 'node', format: 'cjs', write: false, + define: { 'import.meta.env': '{}' }, plugins: [{ name: 'mobile-test-boundaries', setup(plugin: any) {