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
2 changes: 1 addition & 1 deletion .zennotes-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
a3e638fc93852e602501d6cbbed6e0c0a60d8cd4
104416f560bb9e709620cabe9e1f225306f7a4ea
27 changes: 27 additions & 0 deletions src/bridge/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
@@ -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 }
])
})
11 changes: 5 additions & 6 deletions src/bridge/cloud-sync-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function createCloudSyncClient(baseUrl: string, token: string): CloudSync
const transport: CloudSyncHttpTransport = {
async request<Response>(request: CloudSyncHttpRequest): Promise<Response> {
const multipart = request.body instanceof FormData
const timeoutMs = request.timeoutMs ?? 300_000
const response = await CapacitorHttp.request({
method: request.method,
url: `${normalizedBaseUrl}${request.path}`,
Expand All @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions src/bridge/mobile-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
restoreMobileCloudBackup,
restoreMobileCloudBackupNote,
syncMobileCloudVault,
hasMobileCloudVaultChanges,
updateMobileCloudBackupSchedule,
unlinkMobileCloudVault,
deleteMobileCloudVault,
Expand Down Expand Up @@ -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) =>
Expand Down
86 changes: 86 additions & 0 deletions src/bridge/mobile-cloud-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
// 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]])
}
})
47 changes: 40 additions & 7 deletions src/bridge/mobile-cloud-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,57 @@ 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<void> | null = null
function secureStorageReady(): Promise<void> {
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
})
}
return secureStorageSetup
}

async function configureSecureStorage(): Promise<void> {
// 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<void> {
const keys = [CREDENTIAL_KEY, PENDING_AUTH_KEY]
const canonical = new Map<string, string | null>()
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<string, string>()
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<unknown> {
if (!Capacitor.isNativePlatform()) return null
Expand Down
47 changes: 42 additions & 5 deletions src/bridge/mobile-cloud-sync.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ async function fixture(initial: Record<string, string> = { 'note.md': 'Original'
const reads: string[] = []
const refreshes: Record<string, string>[] = []
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
Expand Down Expand Up @@ -54,7 +56,10 @@ async function fixture(initial: Record<string, string> = { '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 }
Expand Down Expand Up @@ -164,15 +169,14 @@ async function fixture(initial: Record<string, string> = { '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 },
Expand All @@ -191,6 +195,39 @@ async function fixture(initial: Record<string, string> = { '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()
Expand Down
15 changes: 15 additions & 0 deletions src/bridge/mobile-cloud-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<CloudSyncState> | 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<CloudSyncRunSummary> {
// No emit here: the host service runs vault.rescan() after every sync
// (cloud-sync-host-service run()'s finally), and rescan emits the one
Expand Down
22 changes: 21 additions & 1 deletion src/bridge/mobile-direct-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -36,7 +56,7 @@ describe('mutateWithMobileDirectUploads', () => {
},
data: 'AQID',
dataType: 'file',
connectTimeout: 30_000,
connectTimeout: 300_000,
readTimeout: 300_000,
disableRedirects: true
})
Expand Down
11 changes: 9 additions & 2 deletions src/bridge/mobile-direct-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions tooling/load-mobile-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down