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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 -- <core-tag>`
Expand Down
24 changes: 12 additions & 12 deletions ios/App/App.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)";
Expand All @@ -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 = "";
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 18 additions & 2 deletions ios/App/App/CloudFilesPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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") ?? [:] {
Expand Down
30 changes: 15 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
121 changes: 121 additions & 0 deletions src/bridge/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'])
}
}
})
Loading
Loading