diff --git a/README.md b/README.md index 813d7d9..186816c 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.78.0-core.h0bc32cbd73a78fac](https://github.com/ZenNotes/zennotes/releases/tag/core-2.78.0-core.h0bc32cbd73a78fac) -(desktop commit `52ab86fd`, clean tree). +[core-2.79.0-core.h7896a01910a2ea0f](https://github.com/ZenNotes/zennotes/releases/tag/core-2.79.0-core.h7896a01910a2ea0f) +(desktop commit `47d26827`, 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 d08d306..9ea0594 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -624,7 +624,7 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; @@ -753,7 +753,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -775,7 +775,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = WYY7PK57DM; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -795,7 +795,7 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; @@ -817,7 +817,7 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ZenWidgets/ZenWidgets.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ZenWidgets/Info.plist; @@ -855,7 +855,7 @@ CLANG_ENABLE_OBJC_WEAK = NO; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 52; + CURRENT_PROJECT_VERSION = 53; DEVELOPMENT_TEAM = WYY7PK57DM; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = ShareExtension/Info.plist; diff --git a/package-lock.json b/package-lock.json index a5baf0e..6fbab0e 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.78.0-core.h0bc32cbd73a78fac.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h7896a01910a2ea0f.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -716,6 +716,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -732,6 +733,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -748,6 +750,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -764,6 +767,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -780,6 +784,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -796,6 +801,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -812,6 +818,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -828,6 +835,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -844,6 +852,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -860,6 +869,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -876,6 +886,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -892,6 +903,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -908,6 +920,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -924,6 +937,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -940,6 +954,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -956,6 +971,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -972,6 +988,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -988,6 +1005,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1004,6 +1022,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1020,6 +1039,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1036,6 +1056,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1052,6 +1073,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1068,6 +1090,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1084,6 +1107,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1100,6 +1124,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1116,6 +1141,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2874,6 +2900,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2890,6 +2917,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2906,6 +2934,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2922,6 +2951,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2938,6 +2968,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2954,6 +2985,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2970,6 +3002,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2986,6 +3019,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3002,6 +3036,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3018,6 +3053,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3034,6 +3070,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3050,6 +3087,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3066,6 +3104,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3082,6 +3121,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3098,6 +3138,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3444,7 +3485,7 @@ "version": "22.20.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -3625,9 +3666,9 @@ } }, "node_modules/@zennotes/app-core": { - "version": "2.78.0-core.h0bc32cbd73a78fac", - "resolved": "file:vendor/zennotes/zennotes-app-core-2.78.0-core.h0bc32cbd73a78fac.tgz", - "integrity": "sha512-Hq5qAJFFBJrJd67PxazwxgVPTZQ6K6bXkU9FoUILCywi4JcGR/hy60Klb5rClsO/Q+E8a/l6IbkE+T9TsUK4YA==", + "version": "2.79.0-core.h7896a01910a2ea0f", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h7896a01910a2ea0f.tgz", + "integrity": "sha512-NUULX0/XabETj9gziIIglT465in8uWMfJU1FQDe7ilbTSeAKF4XtcVC5Zx/+8y1v3q1Xg+P868JE5U7tc35xGg==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3657,8 +3698,8 @@ "@replit/codemirror-vim": "^6.3.0", "@terrastruct/d2": "0.1.33", "@xyflow/react": "^12.11.2", - "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4", - "@zennotes/shared-domain": "2.78.0-boundaries.h328ca592d70ad8f4", + "@zennotes/bridge-contract": "2.79.0-boundaries.h28492494a5dfa837", + "@zennotes/shared-domain": "2.79.0-boundaries.h28492494a5dfa837", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3702,19 +3743,19 @@ } }, "node_modules/@zennotes/bridge-contract": { - "version": "2.78.0-boundaries.h328ca592d70ad8f4", - "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", - "integrity": "sha512-7Hm+frjf+iiQg9x0UXaZzZ0Wbbbg2aqbHb7W2iXGAaOEjvQXnxwzRv8vOGlPlf3KKsQvz8X0TRQ1TzGoOh/vAQ==", + "version": "2.79.0-boundaries.h28492494a5dfa837", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz", + "integrity": "sha512-1VGbTL3V0714Ppt8KBxzHYbYPBGbXXEZgDnN2Qk0xwNTCfpSEPVnUfvslnVB/qsPxFop3bI8/xBwkE9qIILnGw==", "license": "MIT" }, "node_modules/@zennotes/shared-domain": { - "version": "2.78.0-boundaries.h328ca592d70ad8f4", - "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", - "integrity": "sha512-SlD18XyKoX0/68yPwZKUuitgsd3lzOLbsnC6vytvem/qfC3LdHDRWg4LaphxLO7h3bMChOLWUhd9MG36n5hldw==", + "version": "2.79.0-boundaries.h28492494a5dfa837", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz", + "integrity": "sha512-ZrSPk9MILWPB3Uo1N/8+jL8Im+GhZ2aFufmYY+6LTtTKxSjcjUaMq1gSWoTBWqcl1A1fzsSp/nSOwN/2j1ecmg==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4", + "@zennotes/bridge-contract": "2.79.0-boundaries.h28492494a5dfa837", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } @@ -5009,7 +5050,7 @@ "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -5250,6 +5291,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5852,7 +5894,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -6022,6 +6064,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6042,6 +6085,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6062,6 +6106,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6082,6 +6127,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6102,6 +6148,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6122,6 +6169,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6142,6 +6190,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6162,6 +6211,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6182,6 +6232,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6202,6 +6253,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6222,6 +6274,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9003,7 +9056,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unified": { diff --git a/package.json b/package.json index a459d8b..40ae67c 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.78.0-core.h0bc32cbd73a78fac.tgz", - "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.78.0-boundaries.h328ca592d70ad8f4.tgz", - "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.78.0-boundaries.h328ca592d70ad8f4.tgz", + "@zennotes/app-core": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h7896a01910a2ea0f.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h28492494a5dfa837.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h28492494a5dfa837.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/boot-wait.test.ts b/src/boot-wait.test.ts new file mode 100644 index 0000000..358c8fa --- /dev/null +++ b/src/boot-wait.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' +import { BOOT_NATIVE_WAIT_MS, settleWithin, type BootTimer } from './boot-wait.ts' + +/** A timer the test fires by hand. Plain fields, not parameter properties: + * node --test strips types only and refuses those. */ +class FakeTimer implements BootTimer { + callback: (() => void) | null = null + ms = 0 + cleared = false + set(callback: () => void, ms: number): unknown { + this.callback = callback + this.ms = ms + return 1 + } + clear(): void { + this.cleared = true + this.callback = null + } + fire(): void { + this.callback?.() + } +} + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((r) => { resolve = r }) + return { promise, resolve } +} + +describe('settleWithin', () => { + it('lets boot continue when the native answer never comes, logging once', async () => { + const timer = new FakeTimer() + let logged = 0 + const waiting = settleWithin(new Promise(() => {}), BOOT_NATIVE_WAIT_MS, () => { logged++ }, timer) + assert.equal(timer.ms, BOOT_NATIVE_WAIT_MS) + timer.fire() + assert.deepEqual(await waiting, { inTime: false }) + timer.fire() // a second tick changes nothing + assert.equal(logged, 1) + }) + + it('applies a fast answer and stops the timer', async () => { + const timer = new FakeTimer() + let logged = 0 + const answer = await settleWithin(Promise.resolve('1.34.0'), BOOT_NATIVE_WAIT_MS, () => { logged++ }, timer) + assert.deepEqual(answer, { inTime: true, value: '1.34.0' }) + assert.equal(timer.cleared, true) + assert.equal(logged, 0) + }) + + it('never lets a late answer overwrite what was written since boot', async () => { + const timer = new FakeTimer() + const native = deferred() + let version = 'placeholder' + const waiting = settleWithin(native.promise, BOOT_NATIVE_WAIT_MS, () => {}, timer).then((answer) => { + if (answer.inTime) version = answer.value + }) + timer.fire() + await waiting + version = 'set after boot' + native.resolve('late native answer') + await native.promise + await new Promise((r) => setTimeout(r, 0)) + assert.equal(version, 'set after boot') + }) + + it('passes a failure in time through to the caller', async () => { + const timer = new FakeTimer() + await assert.rejects(settleWithin(Promise.reject(new Error('no plugin')), 10, () => {}, timer), /no plugin/) + }) +}) + +describe('boot', () => { + const main = readFileSync(new URL('./main.tsx', import.meta.url), 'utf8') + const bridge = readFileSync(new URL('./bridge/mobile-bridge.ts', import.meta.url), 'utf8') + + it('bounds every native answer it can do without before the app renders', () => { + assert.match(main, /await waitAtBoot\('Cloud sign-in links', configureMobileCloudAuth\(appVersion\)\)/) + assert.match(main, /await waitAtBoot\('shared captures', importPendingShares\(\)/) + assert.match(bridge, /await waitAtBoot\('the app version', CapApp\.getInfo\(\)\)/) + assert.match(bridge, /if \(info\.inTime && info\.value\.version\) appVersion = info\.value\.version/) + }) + + it('keeps the two second wait generous next to a normal answer', () => { + assert.ok(BOOT_NATIVE_WAIT_MS >= 1500 && BOOT_NATIVE_WAIT_MS <= 3000) + }) +}) diff --git a/src/boot-wait.ts b/src/boot-wait.ts new file mode 100644 index 0000000..3f18915 --- /dev/null +++ b/src/boot-wait.ts @@ -0,0 +1,73 @@ +/** + * How long boot waits for a native answer it can do without. + * + * Boot awaits a few native bridge calls before anything renders: the app + * version, the Cloud sign-in link listeners and launch URL, and the Share + * Extension's waiting captures. A call that never answers left a blank page + * with nothing to do but quit (the Android shell saw it after about eleven + * rapid Layout reloads, on a Preferences call). Each of these waits now ends + * after BOOT_NATIVE_WAIT_MS and boot goes on without the answer, logging + * once. The work itself keeps running: listeners still register and shared + * captures still land when their answer comes, but an answer that comes late + * is never applied over what boot already decided (the app version stays the + * placeholder Cloud sign-in was configured with). The vault itself is not + * bounded: without it there is nothing to show. + * + * The Android shell measured its whole native preferences restore at a few + * tens of milliseconds; two seconds is far above any normal answer and still + * short enough that a stalled bridge costs a launch seconds, not the whole + * app. Same helper as the Android shell's. + */ +export const BOOT_NATIVE_WAIT_MS = 2000 + +export interface BootTimer { + set(callback: () => void, ms: number): unknown + clear(handle: unknown): void +} + +const browserTimer: BootTimer = { + set: (callback, ms) => setTimeout(callback, ms), + clear: (handle) => clearTimeout(handle as ReturnType) +} + +export type BootAnswer = { inTime: true; value: T } | { inTime: false } + +/** Wait for `work` at most `ms`. A rejection in time rejects; after the wait + * ends, the answer (or failure) is dropped and `onLate` has been called once. */ +export function settleWithin( + work: Promise, + ms: number, + onLate: () => void, + timer: BootTimer = browserTimer +): Promise> { + return new Promise((resolve, reject) => { + let done = false + const handle = timer.set(() => { + if (done) return + done = true + onLate() + resolve({ inTime: false }) + }, ms) + work.then( + (value) => { + if (done) return + done = true + timer.clear(handle) + resolve({ inTime: true, value }) + }, + (error: unknown) => { + if (done) return + done = true + timer.clear(handle) + reject(error) + } + ) + }) +} + +/** Boot's wait for a native step, named for the one log line it may write. */ +export function waitAtBoot(step: string, work: Promise): Promise> { + return settleWithin(work, BOOT_NATIVE_WAIT_MS, () => { + console.warn(`ZenNotes boot: ${step} did not answer within ${BOOT_NATIVE_WAIT_MS} ms; continuing without it`) + }) +} diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index 023de79..112fd35 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -1,4 +1,5 @@ import { relocateLocalVault } from '@zennotes/app-core/workspace' +import { waitAtBoot } from '../boot-wait' /** * The mobile `window.zen` — third ZenBridge implementation (after Electron IPC * and the web HTTP bridge). Vault operations run against the on-device vault @@ -166,8 +167,10 @@ let appVersion = '0.0.0-dev' export async function loadNativeAppVersion(): Promise { try { - const info = await CapApp.getInfo() - if (info.version) appVersion = info.version + // Bounded (boot-wait.ts): a late answer is dropped, so the version stays + // the placeholder Cloud sign-in is configured with below. + const info = await waitAtBoot('the app version', CapApp.getInfo()) + if (info.inTime && info.value.version) appVersion = info.value.version } catch { // No native layer (browser dev server) — keep the placeholder. } diff --git a/src/bridge/remote-client.ts b/src/bridge/remote-client.ts index de7b89c..89f9e32 100644 --- a/src/bridge/remote-client.ts +++ b/src/bridge/remote-client.ts @@ -109,8 +109,8 @@ export class RemoteRequestError extends Error { * Transport-level failure message. iOS drops connections to local-network * hosts until the user grants the Local Network permission (the app declares * NSLocalNetworkUsageDescription so the prompt appears on first use), and a - * denied prompt looks exactly like a server that is down — so say where to - * turn it back on. + * denied prompt looks exactly like a server that is down, so the message says + * where to turn it back on. */ export function connectionErrorMessage(baseUrl: string, error: unknown): string { const detail = diff --git a/src/main.tsx b/src/main.tsx index 0c7d8c1..eb746c7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -30,6 +30,7 @@ import { mountMobileShell } from './ui-mobile/MobileShell' import { installHomeGuard } from './ui-mobile/nav' import { refreshVault, wireICloudLiveRefresh } from './ui-mobile/refresh' import { isPhoneDevice, watchPhoneClass } from './viewport' +import { waitAtBoot } from './boot-wait' import './ui-mobile/mobile.css' function wireKeyboard(): void { @@ -94,8 +95,10 @@ async function boot(): Promise { // Before the bridge is installed: getAppInfo() is synchronous in the // contract, so the native version has to be in hand by the time anything // can ask for it. + // Native answers boot can do without are bounded (boot-wait.ts), so a + // bridge call that never answers cannot leave a blank page. const appVersion = await loadNativeAppVersion() - await configureMobileCloudAuth(appVersion) + await waitAtBoot('Cloud sign-in links', configureMobileCloudAuth(appVersion)) installMobileBridge() installBackgroundSync(mobileBackgroundSyncHost) // FIRST store subscriber, ahead of everything React mounts: the guard @@ -112,7 +115,7 @@ async function boot(): Promise { // notes land in the tier the user actually picked (iCloud vs. on-device). await maybeRunFirstRunOnboarding() await bootVault() - await importPendingShares().catch(() => 0) + await waitAtBoot('shared captures', importPendingShares().catch(() => 0)) const root = document.getElementById('root') if (!root) throw new Error('Renderer root element #root was not found') diff --git a/src/ui-mobile/MobileDrawer.tsx b/src/ui-mobile/MobileDrawer.tsx index 53d38db..90f2e47 100644 --- a/src/ui-mobile/MobileDrawer.tsx +++ b/src/ui-mobile/MobileDrawer.tsx @@ -26,6 +26,8 @@ import { goHome } from './nav' import { usePins, toggleNotePin, toggleFolderPin } from './pins' import { archiveNote, openNoteMenu, trashNote } from './note-actions' import { refreshVault } from './refresh' +import { openToRead } from './read-open' +import { DRAWER_CLOSE_MS, openDrawerRowToRead } from './drawer-open' import { SwipeRow } from './SwipeRow' import { drawerFileRows, fileExtensionLabel, type DrawerFileRow } from './browse-files' import { hiddenDrawerFolders } from './drawer-folders' @@ -875,7 +877,17 @@ export function MobileDrawer(): React.JSX.Element | null { const go = (action: () => unknown): void => { close() - window.setTimeout(() => void action(), 30) + window.setTimeout(() => void action(), DRAWER_CLOSE_MS) + } + + // A row that opens what it lists (a note, a database, a file) opens it to + // be read, keyboard down, like core's lists (drawer-open.ts). + const read = (path: string): void => { + void openDrawerRowToRead(() => openNote(path), { + close, + wait: (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)), + openToRead + }).catch(reportActionError) } @@ -898,6 +910,7 @@ export function MobileDrawer(): React.JSX.Element | null { noteSortOrder={noteSortOrder} close={close} go={go} + read={read} onOpenVaults={() => openMobileSheet('vaults')} /> @@ -1069,6 +1082,7 @@ function MobileDrawerBody(props: { noteSortOrder: NoteSortOrder close: () => void go: (action: () => unknown) => void + read: (path: string) => void }): React.JSX.Element { const { vaultName, @@ -1086,7 +1100,8 @@ function MobileDrawerBody(props: { childFiles, noteSortOrder, close, - go + go, + read } = props const lp = useLongPress() const [sortOpen, setSortOpen] = useState(false) @@ -1310,7 +1325,7 @@ function MobileDrawerBody(props: {