diff --git a/README.md b/README.md index da62317..63e4749 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. A clean checkout installs them with `npm ci`, without a source clone or sibling repository. The vendored set is the core release -[core-2.76.0-core.h076cc71aa1ce0bbe](https://github.com/ZenNotes/zennotes/releases/tag/core-2.76.0-core.h076cc71aa1ce0bbe) -(desktop commit `61628218`, clean tree). +[core-2.77.0-core.hfe38f5ee2a70bd64](https://github.com/ZenNotes/zennotes/releases/tag/core-2.77.0-core.hfe38f5ee2a70bd64) +(desktop commit `3f0356de`, clean tree). `npm run boundaries:check` verifies the pins, installed versions, singleton React/CodeMirror peers, and public export usage. `npm run core:adopt -- ` diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index db282e4..88f3611 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -608,12 +608,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.32.0; + MARKETING_VERSION = 1.33.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -737,12 +737,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.32.0; + MARKETING_VERSION = 1.33.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -759,12 +759,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.32.0; + MARKETING_VERSION = 1.33.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; @@ -779,12 +779,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.32.0; + MARKETING_VERSION = 1.33.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -801,12 +801,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.32.0; + MARKETING_VERSION = 1.33.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ZenWidgets; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -839,12 +839,12 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 48; + CURRENT_PROJECT_VERSION = 49; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.32.0; + MARKETING_VERSION = 1.33.0; PRODUCT_BUNDLE_IDENTIFIER = md.zennotes.ShareExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/ios/App/App/CloudFilesPlugin.swift b/ios/App/App/CloudFilesPlugin.swift index 826c0d8..e1dd0f3 100644 --- a/ios/App/App/CloudFilesPlugin.swift +++ b/ios/App/App/CloudFilesPlugin.swift @@ -65,6 +65,22 @@ public class CloudFilesPlugin: CAPPlugin, CAPBridgedPlugin { } } + /// The phones' stall bounds (shared-domain cloud-sync-stall.ts). URLSession + /// starts a request's timeout over whenever data arrives, so for a download + /// this is how long it may go silent, not how long a large one may take; + /// JavaScript runs a transfer that went silent again. + static let stallTimeout: TimeInterval = 30 + static let slowestUploadBytesPerSecond: Double = 8 * 1024 + + /// Bytes going out never restart that timeout, so an upload's has to allow + /// for the whole body at the slowest rate worth waiting for, plus the + /// store's answer. Nothing finer exists here: URLSession reports a body as + /// sent once it holds it, long before the bytes leave, so its progress + /// cannot tell a stalled upload from a slow one. + static func sendTimeout(bytes: Int) -> TimeInterval { + stallTimeout + (Double(max(0, bytes)) / slowestUploadBytesPerSecond).rounded(.up) + } + /// Streams a signed GET into a vault staging file. Redirects are refused, /// no account bearer is ever attached, and a mismatched length or hash /// removes the partial file and rejects. @@ -77,7 +93,7 @@ public class CloudFilesPlugin: CAPPlugin, CAPBridgedPlugin { } var request = URLRequest(url: url) request.httpMethod = "GET" - request.timeoutInterval = 300 + request.timeoutInterval = Self.stallTimeout request.setValue("identity", forHTTPHeaderField: "Accept-Encoding") for (name, value) in call.getObject("headers") ?? [:] { guard let value = value as? String, @@ -127,7 +143,7 @@ public class CloudFilesPlugin: CAPPlugin, CAPBridgedPlugin { } var request = URLRequest(url: url) request.httpMethod = "PUT" - request.timeoutInterval = 300 + request.timeoutInterval = Self.sendTimeout(bytes: expected.byteLength) request.setValue(String(expected.byteLength), forHTTPHeaderField: "Content-Length") var hasType = false for (name, value) in call.getObject("headers") ?? [:] { diff --git a/package-lock.json b/package-lock.json index 6df461d..7ffebcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -3666,9 +3666,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.76.0-core.h076cc71aa1ce0bbe", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", - "integrity": "sha512-z1j0s1yszCUjjnaw0zvVAF3tYUjj4WqDlbVbdswRABF8OmaSI15YjOpIwjnopi2YgsqQ+z7aq3DLg31MOYMMbQ==", + "version": "2.77.0-core.hfe38f5ee2a70bd64", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", + "integrity": "sha512-wZNZS/WkLLqi9DzWlKo5FYPl6Dpkk+C/viiiu7rhsryZARIrQodj5LDa9gTx3TUsIiDKFSg5XoP73ShmLuYsVA==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3698,8 +3698,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.76.0-boundaries.he867787f4c1f2ebc", - "@zennotes/shared-domain": "2.76.0-boundaries.he867787f4c1f2ebc", + "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "@zennotes/shared-domain": "2.77.0-boundaries.h3ebf6d85eb9ac49b", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3743,19 +3743,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.76.0-boundaries.he867787f4c1f2ebc", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "integrity": "sha512-mPHMyDU5lKdbv1dZzcmSN6sqlxgiO+xcULirLg+vqjk4sd1LevLv6fy7mFuXNEP3PeE17wA9DAAElzOR2D05Lw==", + "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "integrity": "sha512-wMp1ytIf4U1v4ngHlJ7CegQfQPC1OhyQQXoM4O+tPXmaTWYIUsPHVhgs+i7sptraWQJJZ636Ybn27jDQHTIQ6Q==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.76.0-boundaries.he867787f4c1f2ebc", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "integrity": "sha512-ebanh7nYqtwwf+WFy8fzE8+IE+RI9biRW7ph7KJ8e57m/lz+yT7BrN1esI+Ei18INs/XB2zrZKJXXkLFtO2dHA==", + "version": "2.77.0-boundaries.h3ebf6d85eb9ac49b", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "integrity": "sha512-x52yLX/DGx2tNN+3F5TBakphOnLq5TtXIhFM5prS1pp49i6IacXRo+ZZ9wmLr54zm4rqL6x2mfSbd4Dk3+fbJw==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.76.0-boundaries.he867787f4c1f2ebc", + "@zennotes/bridge-contract": "2.77.0-boundaries.h3ebf6d85eb9ac49b", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } diff --git a/package.json b/package.json index 579b9b0..7eb27bb 100644 --- a/package.json +++ b/package.json @@ -45,9 +45,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.4.0", "@xyflow/react": "^12.12.0", - "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.76.0-core.h076cc71aa1ce0bbe.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.76.0-boundaries.he867787f4c1f2ebc.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.76.0-boundaries.he867787f4c1f2ebc.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.77.0-core.hfe38f5ee2a70bd64.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.77.0-boundaries.h3ebf6d85eb9ac49b.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/bridge/cloud-sync-client.test.ts b/src/bridge/cloud-sync-client.test.ts index 130e65e..9f0f994 100644 --- a/src/bridge/cloud-sync-client.test.ts +++ b/src/bridge/cloud-sync-client.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' import { it } from 'node:test' import { generateCloudE2eeKey, toBase64 } from '@zennotes/shared-domain/cloud-e2ee' import { CloudE2eeKeyring } from '@zennotes/shared-domain/cloud-e2ee-account' @@ -65,3 +66,123 @@ it('marks every request once this host opens encrypted vaults, and lists their r ['zne1', 'Bearer test-only'], ['zne1', 'Bearer test-only'], [undefined, 'Bearer test-only'] ]) }) + +it('lets a request go 30 s without a byte, gives a body its time to go out, and a publication longer', async () => { + const options: Array<{ connectTimeout?: number; readTimeout?: number }> = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async (request: { connectTimeout?: number; readTimeout?: number }) => { + options.push(request) + return { status: 200, data: { data: [], id: 1, slug: 'note', url: 'https://example.test/s/note' } } + } } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'account' }) + await client.listVaults() + await client.publishNote({ note_path: 'Note.md', title: 'Note', markdown: 'Text' }) + const text = 'x'.repeat(1024 * 1024) + await client.mutate('vault-1', { mutations: [{ + type: 'upsert', operation_id: 'operation-1', item_id: 'item-1', base_revision: null, path: 'Big.md', kind: 'text', + content: { encoding: 'utf8', data: text, sha256: createHash('sha256').update(text).digest('hex'), byte_length: text.length, media_type: 'text/markdown' } + }] }).catch(() => {}) + // URLSession restarts the timeout whenever data arrives, so 30 s is how long + // a request may wait for a byte; a big page on a slow link takes as long as + // it needs. Bytes going out do not restart it, so a 1 MiB body also gets + // its time at 8 KiB a second. + assert.deepEqual(options.slice(0, 2).map(({ connectTimeout, readTimeout }) => ({ connectTimeout, readTimeout })), [ + { connectTimeout: 30_000, readTimeout: 30_000 }, + { connectTimeout: 300_000, readTimeout: 300_000 } + ]) + const push = options[2]! + assert.equal(push.readTimeout, push.connectTimeout) + assert.ok(push.readTimeout! > 30_000 + 128_000 && push.readTimeout! < 30_000 + 130_000, String(push.readTimeout)) +}) + +it('sends a read that went silent again instead of failing the sync', async (t) => { + t.mock.method(Math, 'random', () => 0) + const paths: string[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async ({ url }: { url: string }) => { + paths.push(new URL(url).pathname) + if (paths.length === 1) throw Object.assign(new Error('The request timed out.'), { code: 'NSURLErrorDomain' }) + return { status: 200, data: { data: [] } } + } } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'stalled-read' }) + assert.deepEqual(await client.listVaults(), { data: [] }) + assert.deepEqual(paths, ['/api/v1/vaults', '/api/v1/vaults']) +}) + +it('never sends a request again that the service might apply twice, and says the connection stalled', async () => { + let calls = 0 + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async () => { + calls++ + throw Object.assign(new Error('The request timed out.'), { code: 'NSURLErrorDomain' }) + } } + } + }) + const { describeCloudError, CLOUD_TIMEOUT_MESSAGE } = await import('@zennotes/shared-domain/cloud-error-copy') + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: 'stalled-write' }) + const error = await client.createVault('New vault').catch((failure: unknown) => failure) + assert.equal(calls, 1) + assert.equal((error as Error).name, 'CloudSyncTransportError') + assert.equal((error as { stalled?: boolean }).stalled, true) + assert.equal(describeCloudError(error).message, CLOUD_TIMEOUT_MESSAGE) +}) + +it('puts an object again when the store never answered, never when it refused', async (t) => { + t.mock.method(Math, 'random', () => 0) + for (const outcome of ['no answer', 'refused'] as const) { + const puts: Array<{ data?: string }> = [] + const requests: string[] = [] + const { createCloudSyncClient } = await loadMobileModule('./src/bridge/cloud-sync-client.ts', { + '@capacitor/core': { + registerPlugin: () => ({}), + CapacitorHttp: { request: async (options: any) => { + const { method, url, data } = options + const target = new URL(url) + if (target.host === 'objects.example.test') { + puts.push(options) + if (puts.length > 1) return { status: 200, data: '' } + if (outcome === 'refused') return { status: 403, data: '' } + throw Object.assign(new Error('The network connection was lost.'), { code: 'NSURLErrorDomain' }) + } + requests.push(`${method} ${target.pathname}`) + if (target.pathname.endsWith('/uploads')) { + return { status: 201, data: { data: { + id: 'upload-1', operation_id: data.operation_id, status: 'uploading', expected_bytes: data.content.byte_length, + expires_at: '2099-01-01T00:00:00Z', upload: { method: 'PUT', url: 'https://objects.example.test/put', headers: {} } + } } } + } + if (method === 'DELETE') return { status: 204, data: '' } + return { status: 200, data: { data: { id: 'upload-1', operation_id: 'operation-1', status: 'completed', + result: { acknowledged: [{ operation_id: 'operation-1', item_id: 'item-1', revision: 1, sequence: 1 }], conflicts: [], cursor: 1 } } } } + } } + } + }) + const client = createCloudSyncClient('https://example.test', 'test-only', { accountId: `put-${outcome}` }) + const bytes = Buffer.alloc(5 * 1024 * 1024 + 1, 7) + const mutation = client.mutate('vault-1', { mutations: [{ + type: 'upsert', operation_id: 'operation-1', item_id: 'item-1', base_revision: null, path: 'Scans/receipt.pdf', kind: 'binary', + content: { encoding: 'base64', data: bytes.toString('base64'), sha256: createHash('sha256').update(bytes).digest('hex'), + byte_length: bytes.length, media_type: 'application/pdf' } + }] }) + if (outcome === 'no answer') { + assert.equal((await mutation).acknowledged.length, 1) + assert.equal(puts.length, 2) + assert.equal(puts[1]!.data, puts[0]!.data) + assert.deepEqual(requests, ['POST /api/v1/vaults/vault-1/uploads', 'POST /api/v1/vaults/vault-1/uploads/upload-1/complete']) + } else { + await assert.rejects(mutation, /403/) + assert.equal(puts.length, 1) + assert.deepEqual(requests, ['POST /api/v1/vaults/vault-1/uploads', 'DELETE /api/v1/vaults/vault-1/uploads/upload-1']) + } + } +}) diff --git a/src/bridge/cloud-sync-client.ts b/src/bridge/cloud-sync-client.ts index dfd3b14..5d9a9c6 100644 --- a/src/bridge/cloud-sync-client.ts +++ b/src/bridge/cloud-sync-client.ts @@ -13,6 +13,13 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import type { CloudE2eeKeyring } from '@zennotes/shared-domain/cloud-e2ee-account' import { ENCRYPTED_VAULT_PLACEHOLDER_NAME } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { + CLOUD_TRANSPORT_RETRY_DELAYS_MS, + CloudSyncTransportError, + cloudSendTimeoutMs, + isCloudSyncTransportError, + retryCloudTransfer +} from '@zennotes/shared-domain/cloud-sync-stall' import { MobileDirectUploadError, mobileObjectUploadOptions, @@ -70,7 +77,12 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: { const transport: CloudSyncHttpTransport = { async request(request: CloudSyncHttpRequest): Promise { const multipart = request.body instanceof FormData - const timeoutMs = request.timeoutMs ?? 300_000 + // URLSession starts this timeout over whenever data arrives, so a big + // page on a slow link keeps going as long as bytes move. Bytes going out + // do not restart it: a body also gets the time it takes on a slow + // uplink (cloud-sync-stall.ts). A request that waits on the service's + // own work (a publication) says so in timeoutMs. + const timeoutMs = request.timeoutMs ?? cloudSendTimeoutMs(bodyBytes(request.body)) const response = await CapacitorHttp.request({ method: request.method, url: `${normalizedBaseUrl}${request.path}`, @@ -94,7 +106,7 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: { // entire request. Keep them equal so long publications can finish. connectTimeout: timeoutMs, readTimeout: timeoutMs - }) + }).catch((error: unknown) => { throw unanswered(error) }) if (response.status < 200 || response.status >= 300) { const error = response.data?.error @@ -133,10 +145,12 @@ export function createCloudSyncClient(baseUrl: string, token: string, options: { const loopback = /^http:\/\/(localhost|127\.\d+\.\d+\.\d+|\[::1\])(:\d+)?$/.test(normalizedBaseUrl) return new MobileCloudSyncApiClient(cloudSyncRateLimits.wrap(transport, { - baseUrl: normalizedBaseUrl, accountId: options.accountId, signal: lifetime + baseUrl: normalizedBaseUrl, accountId: options.accountId, signal: lifetime, + transportRetryDelaysMs: CLOUD_TRANSPORT_RETRY_DELAYS_MS }), async (request) => { if (lifetime.aborted) throw new DOMException('Cloud request cancelled.', 'AbortError') - await uploadObject(request) + // The same bytes to the same object: a PUT that got no answer is safe to send again. + await retryCloudTransfer(() => uploadObject(request), { signal: lifetime, isTransportFailure: unansweredUpload }) if (lifetime.aborted) throw new DOMException('Cloud request cancelled.', 'AbortError') }, { // Streaming host: large revisions arrive as references and are downloaded @@ -208,7 +222,7 @@ export const uploadObject: MobileObjectUpload = async (request) => { const response = request.uri !== undefined ? await CloudFiles.put({ url: request.url, headers: request.headers, uri: request.uri, sha256: request.sha256, byteLength: request.byteLength }) .catch(() => { throw new MobileDirectUploadError('ZenNotes could not reach Cloud object storage. Check your connection and try again.', 0, 'DIRECT_UPLOAD_FAILED') }) - : await CapacitorHttp.request(mobileObjectUploadOptions(request)) + : await CapacitorHttp.request(mobileObjectUploadOptions(request)).catch((error: unknown) => { throw unanswered(error) }) if (response.status < 200 || response.status >= 300) { throw new MobileDirectUploadError( `ZenNotes Cloud object upload failed (${response.status}).`, @@ -218,6 +232,37 @@ export const uploadObject: MobileObjectUpload = async (request) => { } } +/** + * CapacitorHttp resolves any HTTP answer, error statuses included, so a + * rejection means none came: the request went silent past its timeout, the + * connection dropped, or it never opened. iOS reports only the error domain, + * so a stall is known by URLSession's own words for it. + */ +function unanswered(error: unknown): CloudSyncTransportError { + const { message, code } = (error ?? {}) as { message?: unknown; code?: unknown } + const text = typeof message === 'string' && message ? message : 'ZenNotes Cloud did not answer.' + return new CloudSyncTransportError(text, { + code: typeof code === 'string' && code ? code : null, + stalled: /timed out/i.test(text), + cause: error + }) +} + +/** What the request body weighs on the wire, near enough to size its timeout. */ +function bodyBytes(body: unknown): number { + if (body === undefined) return 0 + if (!(body instanceof FormData)) return new TextEncoder().encode(JSON.stringify(body)).byteLength + let bytes = 0 + for (const [, value] of body.entries()) bytes += typeof value === 'string' ? new TextEncoder().encode(value).byteLength : value.size + return bytes +} + +/** The object store never answered the PUT (a status means it did). */ +function unansweredUpload(error: unknown): boolean { + return isCloudSyncTransportError(error) || + (error instanceof MobileDirectUploadError && error.status === 0 && error.code === 'DIRECT_UPLOAD_FAILED') +} + export function firstValidationMessage(errors: unknown): string | null { if (!errors || typeof errors !== 'object') return null diff --git a/src/bridge/cloud-sync-staging.test.ts b/src/bridge/cloud-sync-staging.test.ts index 48e4926..2cfa593 100644 --- a/src/bridge/cloud-sync-staging.test.ts +++ b/src/bridge/cloud-sync-staging.test.ts @@ -487,3 +487,46 @@ describe('native Cloud staging for end-to-end encrypted vaults', () => { assert.deepEqual(h.leftovers(), []) }) }) + +describe('a download that goes quiet', () => { + it('downloads again into a clean scratch file and stages the bytes', async (t) => { + t.mock.method(Math, 'random', () => 0) + const h = harness() + const bytes = Buffer.from('the whole note, as the PC saved it') + const ref = h.reference('item', 1, bytes) + const url = 'https://objects.example.test/item/1' + h.objects.set(url, bytes) + const download = h.native.download + const leftBehind: boolean[] = [] + let stalls = 1 + h.native.download = async (options) => { + leftBehind.push(h.files.has(options.to)) + if (stalls-- > 0) { + // Half the body arrived, then the connection went silent until the + // native read timeout gave up: no HTTP status to report. + h.files.set(options.to, bytes.subarray(0, 10)) + h.log.push(`stalled ${options.url}`) + throw Object.assign(new Error('Cloud object download failed.'), { code: 'DIRECT_DOWNLOAD_FAILED' }) + } + await download(options) + } + const file = await h.staging.stage(h.source(ref, url)) + assert.deepEqual(h.log, [`stalled ${url}`, `download ${url}`]) + assert.deepEqual(leftBehind, [false, false]) + assert.deepEqual(h.files.get(cloudSyncStagedHandle(file).path), bytes) + }) + + it('does not download again when the storage host answered with an error', async () => { + const h = harness() + const bytes = Buffer.from('cloud') + const url = 'https://objects.example.test/item/1' + let calls = 0 + h.native.download = async () => { + calls++ + throw Object.assign(new Error('Cloud object download failed (404).'), { code: 'DIRECT_DOWNLOAD_FAILED', data: { status: 404 } }) + } + await assert.rejects(h.staging.stage(h.source(h.reference('item', 1, bytes), url)), /404/) + assert.equal(calls, 1) + assert.deepEqual([...h.files.keys()], []) + }) +}) diff --git a/src/bridge/cloud-sync-staging.ts b/src/bridge/cloud-sync-staging.ts index 2d8bf8f..72c3425 100644 --- a/src/bridge/cloud-sync-staging.ts +++ b/src/bridge/cloud-sync-staging.ts @@ -13,6 +13,7 @@ import { type CloudSyncStagedFile } from '@zennotes/shared-domain/cloud-sync-content' import type { CloudSyncRepositoryConflict } from '@zennotes/shared-domain/cloud-sync-coordinator' +import { retryCloudTransfer } from '@zennotes/shared-domain/cloud-sync-stall' import type { CloudSyncLocalItem, CloudSyncTrackedItem } from '@zennotes/shared-domain/cloud-sync-engine' import type { CloudFileFingerprint } from './native-fs' import { randomUUID } from './uuid' @@ -77,10 +78,19 @@ export class NativeCloudStaging { } const expected = reference.encrypted ?? reference try { - await this.native.download({ - url: instruction.download.url, headers: instruction.download.headers, to: download, - byteLength: expected.byte_length, sha256: expected.sha256 - }) + // A download that stalled or dropped is a GET into a scratch file: + // run it again from the start instead of failing the whole sync. + await retryCloudTransfer(async () => { + try { + await this.native.download({ + url: instruction.download.url, headers: instruction.download.headers, to: download, + byteLength: expected.byte_length, sha256: expected.sha256 + }) + } catch (error) { + await discardDownload() + throw error + } + }, { signal: source.signal, isTransportFailure: isUnansweredDownload }) } catch (error) { if (attempt === 0 && isExpiredUrlError(error)) { await discardDownload() @@ -332,6 +342,17 @@ function hasNativeDecryption(native: CloudStagingNative): native is CloudStaging return typeof native.readHeader === 'function' && typeof native.decryptFile === 'function' } +/** + * The native download got no HTTP answer, or the bytes stopped short of what + * the reference promised (a dropped connection can end a body early). An HTTP + * status means the storage host answered, which a retry would not change. + */ +function isUnansweredDownload(error: unknown): boolean { + const candidate = error as { code?: unknown; status?: unknown; data?: { status?: unknown } } | null + return candidate?.code === 'DIRECT_DOWNLOAD_FAILED' && candidate.status === undefined && + candidate.data?.status === undefined +} + function isExpiredUrlError(error: unknown): boolean { // Capacitor plugin rejections carry the HTTP status under `data`. const candidate = error as { status?: unknown; data?: { status?: unknown } } diff --git a/src/bridge/mobile-direct-upload.test.ts b/src/bridge/mobile-direct-upload.test.ts index d3f6a4f..62b738c 100644 --- a/src/bridge/mobile-direct-upload.test.ts +++ b/src/bridge/mobile-direct-upload.test.ts @@ -63,8 +63,10 @@ describe('mutateWithMobileDirectUploads', () => { }, data: 'AQID', dataType: 'file', - connectTimeout: 300_000, - readTimeout: 300_000, + // Bytes going out never restart the iOS timeout: the stall bound plus + // the body's time at 8 KiB a second (1 ms for these 3 bytes). + connectTimeout: 30_001, + readTimeout: 30_001, disableRedirects: true }) assert.equal(options.headers.Authorization, undefined) diff --git a/src/bridge/mobile-direct-upload.ts b/src/bridge/mobile-direct-upload.ts index 9b8e660..07d1468 100644 --- a/src/bridge/mobile-direct-upload.ts +++ b/src/bridge/mobile-direct-upload.ts @@ -13,6 +13,7 @@ import type { } from '@zennotes/bridge-contract/cloud-sync' import { toBase64, zne1CiphertextLength } from '@zennotes/shared-domain/cloud-e2ee' import type { CloudVaultCipher } from '@zennotes/shared-domain/cloud-e2ee-vault' +import { cloudSendTimeoutMs } from '@zennotes/shared-domain/cloud-sync-stall' export const CLOUD_SYNC_INLINE_UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 @@ -106,9 +107,11 @@ export function mobileObjectUploadOptions( headers, data: request.base64, dataType: 'file', - // iOS uses connectTimeout ahead of readTimeout for the whole request. - connectTimeout: 300_000, - readTimeout: 300_000, + // iOS uses connectTimeout ahead of readTimeout for the whole request, and + // bytes going out never restart it: the PUT gets the time its body takes + // on a slow uplink, plus the stall bound for the store's answer. + connectTimeout: cloudSendTimeoutMs(request.byteLength), + readTimeout: cloudSendTimeoutMs(request.byteLength), disableRedirects: true } } diff --git a/src/ui-mobile/MobileShell.tsx b/src/ui-mobile/MobileShell.tsx index 5011489..7b382b9 100644 --- a/src/ui-mobile/MobileShell.tsx +++ b/src/ui-mobile/MobileShell.tsx @@ -6,7 +6,7 @@ * full-screen flow. Rendered into its own React root so app-core stays * untouched; state is driven through the shared Zustand store. */ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' import { Haptics, ImpactStyle } from '@capacitor/haptics' import { Keyboard } from '@capacitor/keyboard' @@ -15,7 +15,9 @@ import { getShellSnapshot, useShellSnapshot, subscribeShell, getAdjacentNotePath import { getBrowseSnapshot, requestCreateBrowseFolder, requestDeleteBrowseDirectory } from '@zennotes/app-core/browse' import { getWorkspaceSnapshot, useWorkspaceSnapshot, subscribeWorkspace, readPersistedHomeState, configureWorkspacePresentation, pickLocalVault, openLocalVault, connectRemoteProfile, refreshRemoteProfiles } from '@zennotes/app-core/workspace' -import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize } from '@zennotes/app-core/settings' +import { getSettingsSnapshot, useSettingsSnapshot, subscribeSettings, setSettingsVisible, setEditorFontSize, + showSettingsPage } from '@zennotes/app-core/settings' +import { DateCalendar } from '@zennotes/app-core/calendar' import { useEditorPresentation, setEditorMode, hasEditorSelection, runEditorCommand } from '@zennotes/app-core/editor' import { getTasksSnapshot, moveTaskToColumn, type KanbanGroupBy } from '@zennotes/app-core/tasks' import { runAppCommand, showCommandPalette, showSearch, showTemplates, showOutline } from '@zennotes/app-core/commands' @@ -251,10 +253,7 @@ function ActionSheet({ onClose }: { onClose: () => void }): React.JSX.Element { className="zn-mobile-sheet-row" onClick={() => { onClose() - window.setTimeout( - () => window.dispatchEvent(new Event('zen:toggle-calendar')), - 30 - ) + window.setTimeout(showCalendar, 30) }} > @@ -401,6 +400,71 @@ function CreateSheet({ onClose }: { onClose: () => void }): React.JSX.Element { ) } +const SHOW_CALENDAR_EVENT = 'zn:show-calendar' + +/** + * Open the Calendar. A phone has no room beside a note, and Home has no note + * to sit beside, so there it is a sheet of its own (CalendarSheet). An iPad + * keeps the desktop's panel beside the open note, which is the only place + * its ••• row offers it. + */ +function showCalendar(): void { + if (isPhoneWidth()) window.dispatchEvent(new Event(SHOW_CALENDAR_EVENT)) + else window.dispatchEvent(new Event('zen:toggle-calendar')) +} + +/** + * The Calendar (Discord, 2026-10-26: "I don't see anywhere to bring the + * calendar up"). The only way in used to be a note's ••• sheet, and only + * with daily or weekly notes on, so nobody on Home ever met it. This is + * app-core's date calendar (month grid with writing dots and task marks, + * the selected day's week of tasks below) as a sheet that needs no open + * note, opened from the circle's Calendar row and from •••. A tap selects a + * day and a tap on the selected day opens its daily note or offers to + * create it (core); once a day or a task opens, the sheet closes so the note + * is what shows. Done reaches it as a right-hand panel. + * + * With daily and weekly notes both off there is nothing for it to open, so + * the row says how to turn daily notes on instead of disappearing. + */ +function CalendarSheet({ onClose }: { onClose: () => void }): React.JSX.Element { + const { calendarAvailable } = useSettingsSnapshot() + useEffect(() => { + window.addEventListener('zen:close-right-panel', onClose) + return () => window.removeEventListener('zen:close-right-panel', onClose) + }, [onClose]) + + if (calendarAvailable) { + return ( +
+ +
+ ) + } + const turnOn = (): void => { + onClose() + window.setTimeout(() => showSettingsPage('periodic-notes'), 30) + } + return ( + <> +
+
+
Calendar
+

+ The calendar opens your daily notes: tap a day for its note, with that week's tasks + below. Daily notes are off in this vault. +

+
+ +
+
+ + ) +} + function MobileNav(): React.JSX.Element | null { const vault = useShellSnapshot().vault const { hasOpenNote, mode } = useEditorPresentation() @@ -410,6 +474,8 @@ function MobileNav(): React.JSX.Element | null { const [sheetOpen, setSheetOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false) + const [calendarOpen, setCalendarOpen] = useState(false) + const closeCalendar = useCallback(() => setCalendarOpen(false), []) // First-run coach mark: the unlabeled ensō reads as a loading spinner to // new users (real-user tested). Set by onboarding, cleared on first tap. const [fabHint, setFabHint] = useState( @@ -417,11 +483,17 @@ function MobileNav(): React.JSX.Element | null { ) // The pull-down quick action (usePullDownAction) can be set to "New - // note"; the sheet is this component's, so it is summoned by event. + // note"; the sheet is this component's, so it is summoned by event. So is + // the Calendar, which the ••• sheet opens too. useEffect(() => { const onCreate = (): void => setCreateOpen(true) + const onCalendar = (): void => setCalendarOpen(true) window.addEventListener('zn:quick-create', onCreate) - return () => window.removeEventListener('zn:quick-create', onCreate) + window.addEventListener(SHOW_CALENDAR_EVENT, onCalendar) + return () => { + window.removeEventListener('zn:quick-create', onCreate) + window.removeEventListener(SHOW_CALENDAR_EVENT, onCalendar) + } }, []) if (!vault) return null @@ -466,6 +538,8 @@ function MobileNav(): React.JSX.Element | null { ] : []), { label: 'More', icon: ICONS.more, run: () => setSheetOpen(true) }, + // Always listed, daily notes on or off: off, it says how to turn them on. + { label: 'Calendar', icon: ICONS.calendar, run: showCalendar }, { label: 'Browse', icon: ICONS.sidebar, run: () => setDrawerOpen(true) }, { label: 'Search', icon: ICONS.search, run: showSearch } ] @@ -544,6 +618,7 @@ function MobileNav(): React.JSX.Element | null { {sheetOpen && setSheetOpen(false)} />} {createOpen && setCreateOpen(false)} />} + {calendarOpen && } ) } @@ -1369,9 +1444,11 @@ function useCalendarWeekMode(): void { localStorage.getItem(CAL_SCOPE_KEY) === 'month' ? 'month' : 'week' const apply = (): void => { + // The date calendar (a note's panel, or the Calendar sheet over Tasks) + // marks its days the same way; its grid keeps the whole month. const cells = Array.from( document.querySelectorAll('button[data-cal-day]') - ) + ).filter((cell) => !cell.closest('[data-calendar-panel]')) const todayBtn = document.querySelector("button[title='Today (gt)']") if (cells.length === 0 || !todayBtn) { control?.remove() diff --git a/src/ui-mobile/mobile-layout.test.ts b/src/ui-mobile/mobile-layout.test.ts index 6705dea..17eeb2c 100644 --- a/src/ui-mobile/mobile-layout.test.ts +++ b/src/ui-mobile/mobile-layout.test.ts @@ -83,3 +83,10 @@ test('the date calendar hides its key help on phones and keeps Today on the righ assert.equal(declaration(hint, 'display'), 'none') assert.equal(declaration(`${hint} + button`, 'margin-left'), 'auto') }) + +test("the Calendar sheet is the date calendar's full-screen phone sheet, with finger-sized days", () => { + const panel = '.zn-mobile.zn-phone .zn-calendar-sheet > [data-calendar-panel]' + assert.equal(declaration(panel, 'position'), 'fixed') + assert.ok(declaration(panel, 'width')?.includes('100vw')) + assert.equal(declaration('.zn-mobile.zn-phone .zn-calendar-sheet button[data-cal-day]', 'min-height'), '42px') +}) diff --git a/src/ui-mobile/mobile.css b/src/ui-mobile/mobile.css index da3a327..1909f1b 100644 --- a/src/ui-mobile/mobile.css +++ b/src/ui-mobile/mobile.css @@ -1222,12 +1222,15 @@ /* Right-hand side panels (calendar/outline/connections/comments/reference) would split a 390pt screen in half — they become full-screen sheets, with a shell-injected close button (zen:close-right-panel). Panel roots vary: -
,
(calendar), and