diff --git a/README.md b/README.md index 1227f7d..d00effb 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ and `@zennotes/shared-domain` packages. The exact archives are vendored under `vendor/zennotes/` with their source identity and checksums (`manifest.json`), and `package-lock.json` pins the complete install. No source checkout is used. 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). Run `npm run +[core-2.79.0-core.h747103b57a975077](https://github.com/ZenNotes/zennotes/releases/tag/core-2.79.0-core.h747103b57a975077) +(desktop commit `cefd86d9`, clean tree). Run `npm run boundaries:check` to verify archives, installed versions, singleton editor/React peers, and imports; it refuses an archive built from a dirty upstream tree unless `ZEN_ALLOW_DIRTY_CORE=1` is set for a local try-out. diff --git a/android/app/build.gradle b/android/app/build.gradle index 98b9f08..08066ba 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 52 - versionName "1.1.49" + versionCode 53 + versionName "1.1.50" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/package-lock.json b/package-lock.json index 33970c3..fa39aee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-android", - "version": "1.1.49", + "version": "1.1.50", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-android", - "version": "1.1.49", + "version": "1.1.50", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.1", "@capacitor/android": "^8.5.3", @@ -32,9 +32,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.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.h747103b57a975077.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h113d73cec350fa67.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h113d73cec350fa67.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", @@ -718,6 +718,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -734,6 +735,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -750,6 +752,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -766,6 +769,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -782,6 +786,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -798,6 +803,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -814,6 +820,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -830,6 +837,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -846,6 +854,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -862,6 +871,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -878,6 +888,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -894,6 +905,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -910,6 +922,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -926,6 +939,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -942,6 +956,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -958,6 +973,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -974,6 +990,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -990,6 +1007,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1006,6 +1024,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1022,6 +1041,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1038,6 +1058,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1054,6 +1075,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1092,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1086,6 +1109,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1102,6 +1126,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1118,6 +1143,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2876,6 +2902,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2892,6 +2919,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2908,6 +2936,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2924,6 +2953,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2940,6 +2970,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2956,6 +2987,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2972,9 +3004,7 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2991,9 +3021,7 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3010,9 +3038,7 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3029,9 +3055,7 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3048,9 +3072,7 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3067,9 +3089,7 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3086,6 +3106,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3102,6 +3123,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3118,6 +3140,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3464,7 +3487,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" @@ -3645,9 +3668,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.h747103b57a975077", + "resolved": "file:vendor/zennotes/zennotes-app-core-2.79.0-core.h747103b57a975077.tgz", + "integrity": "sha512-EOaFrwF1qazJo4RkI2GaAQ8dBlhBB9d/QYgeDCaR7M1LupNGkXY6ahyDUDiPxjsG4duxV81BEkcAhtM0/hwirA==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -3677,8 +3700,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.h113d73cec350fa67", + "@zennotes/shared-domain": "2.79.0-boundaries.h113d73cec350fa67", "dompurify": "^3.3.4", "function-plot": "^1.25.3", "gray-matter": "^4.0.3", @@ -3722,19 +3745,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.h113d73cec350fa67", + "resolved": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h113d73cec350fa67.tgz", + "integrity": "sha512-6reYSSfS+G8jEhVhuebsAbVDF5Y+J1ZaGlSQyyqGcZ+mRDR0FttgaKTgsXgp4qxxcmIW/KTD0ugaPy6lqHD0/g==", "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.h113d73cec350fa67", + "resolved": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h113d73cec350fa67.tgz", + "integrity": "sha512-q76VGmPl56PRCSMWaJOytQAwMC76n+FfNIsFK+RKXRbA3fvRY6bODow4hj6qDM9aAqor2Z7JVH6xXJFWGJvlOQ==", "license": "MIT", "dependencies": { "@scure/bip39": "2.4.0", - "@zennotes/bridge-contract": "2.78.0-boundaries.h328ca592d70ad8f4", + "@zennotes/bridge-contract": "2.79.0-boundaries.h113d73cec350fa67", "hash-wasm": "4.12.0", "lz-string": "^1.5.0" } @@ -5040,7 +5063,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": { @@ -5281,6 +5304,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, @@ -5883,7 +5907,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" @@ -6053,6 +6077,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6073,6 +6098,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6093,6 +6119,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6113,6 +6140,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6133,6 +6161,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6153,6 +6182,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6173,6 +6203,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6193,6 +6224,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6213,6 +6245,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6233,6 +6266,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6253,6 +6287,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9069,7 +9104,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 0e9a60b..6608772 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-android", "private": true, - "version": "1.1.49", + "version": "1.1.50", "type": "module", "description": "ZenNotes for Android — Capacitor shell over the ZenNotes app core", "homepage": "https://zennotes.org", @@ -43,9 +43,9 @@ "@lezer/highlight": "^1.2.5", "@replit/codemirror-vim": "^6.3.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.h747103b57a975077.tgz", + "@zennotes/bridge-contract": "file:vendor/zennotes/zennotes-bridge-contract-2.79.0-boundaries.h113d73cec350fa67.tgz", + "@zennotes/shared-domain": "file:vendor/zennotes/zennotes-shared-domain-2.79.0-boundaries.h113d73cec350fa67.tgz", "codemirror": "^6.0.1", "dompurify": "^3.4.17", "function-plot": "^1.26.0", diff --git a/src/boot-preferences.test.ts b/src/boot-preferences.test.ts new file mode 100644 index 0000000..e83af44 --- /dev/null +++ b/src/boot-preferences.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { applyNativePreferences, readNativePreferences, type MirroredPreference } from './boot-preferences.ts' +import { settleWithin, type BootTimer } from './boot-wait.ts' + +const PAIRS: MirroredPreference[] = [ + { web: 'zen:prefs:v2', native: 'zn-app-preferences-v2' }, + { web: 'zn:layout-mode', native: 'zn-layout-mode' }, + { web: 'zn:start-screen', native: 'zn-start-screen' } +] + +class Web { + readonly items = new Map() + getItem(key: string): string | null { return this.items.get(key) ?? null } + setItem(key: string, value: string): void { this.items.set(key, value) } +} + +class FakeTimer implements BootTimer { + callback: (() => void) | null = null + set(callback: () => void): unknown { this.callback = callback; return 1 } + clear(): void { this.callback = null } + fire(): void { this.callback?.() } +} + +/** The restore as bootstrap.ts runs it, with its stores and timer handed in. */ +async function restore(native: { get(o: { key: string }): Promise<{ value: string | null }> }, web: Web, timer: BootTimer, writes: Array<[string, string]>, themes: string[]): Promise { + const answer = await settleWithin(readNativePreferences(PAIRS, native), 2000, () => {}, timer) + if (!answer.inTime) return false + applyNativePreferences(PAIRS, answer.value, web, (key, value) => writes.push([key, value]), (pair, value) => { + if (pair.web === 'zen:prefs:v2') themes.push(value) + }) + return true +} + +describe('the bootstrap restore', () => { + it('lets boot continue within the wait when native storage never answers', async () => { + const timer = new FakeTimer() + const web = new Web() + web.setItem('zn:layout-mode', 'phone') + const writes: Array<[string, string]> = [] + const done = restore({ get: () => new Promise(() => {}) }, web, timer, writes, []) + timer.fire() + assert.equal(await done, false) + assert.equal(web.getItem('zn:layout-mode'), 'phone') + assert.deepEqual(writes, []) + }) + + it('applies a fast restore: native values back into localStorage, theme first', async () => { + const timer = new FakeTimer() + const web = new Web() + web.setItem('zn:start-screen', 'home') + const native = new Map([['zn-app-preferences-v2', '{"themeId":"nord","vimMode":true}'], ['zn-layout-mode', 'desktop']]) + const writes: Array<[string, string]> = [] + const themes: string[] = [] + const inTime = await restore({ get: async ({ key }) => ({ value: native.get(key) ?? null }) }, web, timer, writes, themes) + assert.equal(inTime, true) + assert.equal(web.getItem('zen:prefs:v2'), '{"themeId":"nord","vimMode":true}') + assert.equal(web.getItem('zn:layout-mode'), 'desktop') + assert.deepEqual(themes, ['{"themeId":"nord","vimMode":true}']) + // Only localStorage held the Start screen: it is copied to native. + assert.deepEqual(writes, [['zn-start-screen', 'home']]) + }) + + it('never lets a late answer clobber what the app wrote after boot went on', async () => { + const timer = new FakeTimer() + const web = new Web() + let answer!: (value: { value: string | null }) => void + const pending = new Promise<{ value: string | null }>((r) => { answer = r }) + const writes: Array<[string, string]> = [] + const done = restore({ get: () => pending }, web, timer, writes, []) + timer.fire() + assert.equal(await done, false) + web.setItem('zn:layout-mode', 'phone') // the person switched Layout after boot + answer({ value: 'desktop' }) // the stale native answer arrives + await new Promise((r) => setTimeout(r, 0)) + assert.equal(web.getItem('zn:layout-mode'), 'phone') + assert.deepEqual(writes, []) + }) + + it('reads every native value before writing any', async () => { + const order: string[] = [] + const web = { getItem: () => null, setItem: (key: string) => { order.push('write ' + key) } } + const values = await readNativePreferences(PAIRS, { get: async ({ key }) => { order.push('read ' + key); return { value: 'x' } } }) + applyNativePreferences(PAIRS, values, web, () => {}) + assert.deepEqual(order.slice(0, 3), ['read zn-app-preferences-v2', 'read zn-layout-mode', 'read zn-start-screen']) + assert.ok(order.slice(3).every((step) => step.startsWith('write'))) + }) +}) diff --git a/src/boot-preferences.ts b/src/boot-preferences.ts new file mode 100644 index 0000000..4379bab --- /dev/null +++ b/src/boot-preferences.ts @@ -0,0 +1,61 @@ +/** + * Bootstrap's restore of the preferences mirrored in native storage + * (bootstrap.ts): WebView storage alone is not trusted to survive, so at boot + * a value native storage holds is put back into localStorage, and a value + * only localStorage holds is copied to native storage. + * + * Reading and applying are split so a late answer can be dropped whole + * (boot-wait.ts): the reads run first and write nothing; the writes happen in + * one synchronous step, only when every read answered in time. If boot gave + * up waiting, the app has been running on what localStorage had and may have + * changed some of it since, so applying even part of a late answer could put + * an older value back over a newer one. Ignoring it is the safe choice: the + * native copy catches up from localStorage through the mirror, which is + * installed either way. Kept apart from Capacitor so node --test covers it. + */ + +export interface MirroredPreference { + /** The localStorage key the app reads. */ + readonly web: string + /** Its copy in native Preferences. */ + readonly native: string +} + +export interface NativeReader { + get(options: { key: string }): Promise<{ value: string | null }> +} + +export interface WebStorage { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +/** Every native value, read without writing anything. */ +export async function readNativePreferences( + pairs: readonly MirroredPreference[], + native: NativeReader +): Promise> { + const values = await Promise.all(pairs.map((pair) => native.get({ key: pair.native }))) + return new Map(pairs.map((pair, i) => [pair.native, values[i]?.value ?? null])) +} + +/** Apply a restore that answered in time: native wins, else localStorage's + * value goes to native. `restored` hears each value put back, in order. */ +export function applyNativePreferences( + pairs: readonly MirroredPreference[], + nativeValues: ReadonlyMap, + web: WebStorage, + writeNative: (key: string, value: string) => void, + restored: (pair: MirroredPreference, value: string) => void = () => {} +): void { + for (const pair of pairs) { + const nativeValue = nativeValues.get(pair.native) ?? null + if (nativeValue) { + web.setItem(pair.web, nativeValue) + restored(pair, nativeValue) + continue + } + const webValue = web.getItem(pair.web) + if (webValue) writeNative(pair.native, webValue) + } +} diff --git a/src/boot-wait.test.ts b/src/boot-wait.test.ts new file mode 100644 index 0000000..d6a8b6a --- /dev/null +++ b/src/boot-wait.test.ts @@ -0,0 +1,97 @@ +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 bootstrap = readFileSync(new URL('./bootstrap.ts', import.meta.url), 'utf8') + 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 the native preferences restore before main.tsx loads', () => { + assert.match(bootstrap, /await waitAtBoot\('native preferences', readNativePreferences\(RESTORED_PREFERENCES, Preferences\)\)/) + assert.match(bootstrap, /if \(!answer\.inTime\) return/) + const boot = bootstrap.slice(bootstrap.indexOf('async function bootstrap(')) + assert.ok(boot.indexOf('await restoreNativePreferences()') < boot.indexOf("await import('./main')")) + }) + + 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..a1b10f3 --- /dev/null +++ b/src/boot-wait.ts @@ -0,0 +1,74 @@ +/** + * How long boot waits for a native answer it can do without. + * + * Boot awaits a few native bridge calls before anything renders: bootstrap's + * restore of the preferences mirrored in native storage, then the app + * version, the Cloud sign-in link listeners and the Share sheet's waiting + * captures. A call that never answers left a blank page with nothing to do + * but quit: after about eleven rapid Settings Layout reloads in one process, + * the page stopped inside bootstrap's restore, before main.tsx ever loaded, + * and a reload did not bring it back. Each of these waits now ends after + * BOOT_NATIVE_WAIT_MS and boot goes on without the answer, logging once. An + * answer that comes late is never applied over what boot already decided. + * The vault itself is not bounded: without it there is nothing to show. + * + * Measured on the API 37 emulator with the release build, from bootstrap's + * script to main.tsx's first request: the preferences restore took 13 to 28 + * ms on a cold launch (165 ms on the first launch after install) and 5 to 6 + * ms on a reload. Two seconds is more than twelve times the slowest of those, + * and still short enough that a stalled bridge costs a launch seconds instead + * of the whole app. Same helper as the iPhone 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/bootstrap.ts b/src/bootstrap.ts index 5d0bc73..108269b 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,6 +1,8 @@ import './random-uuid-polyfill' import { Preferences } from '@capacitor/preferences' import { BACKGROUND_SYNC_KEY, GESTURES_KEY, HIDE_STATUS_BAR_KEY, LAYOUT_MODE_KEY, START_SCREEN_KEY } from './viewport' +import { waitAtBoot } from './boot-wait' +import { applyNativePreferences, readNativePreferences, type MirroredPreference } from './boot-preferences' const WEB_PREFERENCES_KEY = 'zen:prefs:v2' const NATIVE_PREFERENCES_KEY = 'zn-app-preferences-v2' @@ -58,49 +60,33 @@ function applyStoredTheme(rawPreferences: string): void { } } +// Restored at boot, in this order: the app-core preferences first (their +// theme is applied before anything draws), then the shell's own choices. +const RESTORED_PREFERENCES: readonly MirroredPreference[] = [ + { web: WEB_PREFERENCES_KEY, native: NATIVE_PREFERENCES_KEY }, + { web: LAYOUT_MODE_KEY, native: NATIVE_LAYOUT_MODE_KEY }, + { web: HIDE_STATUS_BAR_KEY, native: NATIVE_HIDE_STATUS_BAR_KEY }, + { web: GESTURES_KEY, native: NATIVE_GESTURES_KEY }, + { web: START_SCREEN_KEY, native: NATIVE_START_SCREEN_KEY } +] + +/** Put native values back into localStorage before the app reads them. A + * native call that never answered once left a blank page after rapid Layout + * reloads, so the reads are bounded (boot-wait.ts) and a late answer is + * dropped whole (boot-preferences.ts says why). */ async function restoreNativePreferences(): Promise { try { - const nativePreferences = await Preferences.get({ key: NATIVE_PREFERENCES_KEY }) - const webPreferences = localStorage.getItem(WEB_PREFERENCES_KEY) - - if (nativePreferences.value) { - localStorage.setItem(WEB_PREFERENCES_KEY, nativePreferences.value) - applyStoredTheme(nativePreferences.value) - } else if (webPreferences) { - await Preferences.set({ key: NATIVE_PREFERENCES_KEY, value: webPreferences }) - } - - const nativeLayout = await Preferences.get({ key: NATIVE_LAYOUT_MODE_KEY }) - const webLayout = localStorage.getItem(LAYOUT_MODE_KEY) - if (nativeLayout.value) { - localStorage.setItem(LAYOUT_MODE_KEY, nativeLayout.value) - } else if (webLayout) { - await Preferences.set({ key: NATIVE_LAYOUT_MODE_KEY, value: webLayout }) - } - - const nativeStatusBar = await Preferences.get({ key: NATIVE_HIDE_STATUS_BAR_KEY }) - const webStatusBar = localStorage.getItem(HIDE_STATUS_BAR_KEY) - if (nativeStatusBar.value) { - localStorage.setItem(HIDE_STATUS_BAR_KEY, nativeStatusBar.value) - } else if (webStatusBar) { - await Preferences.set({ key: NATIVE_HIDE_STATUS_BAR_KEY, value: webStatusBar }) - } - - const nativeGestures = await Preferences.get({ key: NATIVE_GESTURES_KEY }) - const webGestures = localStorage.getItem(GESTURES_KEY) - if (nativeGestures.value) { - localStorage.setItem(GESTURES_KEY, nativeGestures.value) - } else if (webGestures) { - await Preferences.set({ key: NATIVE_GESTURES_KEY, value: webGestures }) - } - - const nativeStartScreen = await Preferences.get({ key: NATIVE_START_SCREEN_KEY }) - const webStartScreen = localStorage.getItem(START_SCREEN_KEY) - if (nativeStartScreen.value) { - localStorage.setItem(START_SCREEN_KEY, nativeStartScreen.value) - } else if (webStartScreen) { - await Preferences.set({ key: NATIVE_START_SCREEN_KEY, value: webStartScreen }) - } + const answer = await waitAtBoot('native preferences', readNativePreferences(RESTORED_PREFERENCES, Preferences)) + if (!answer.inTime) return + applyNativePreferences( + RESTORED_PREFERENCES, + answer.value, + window.localStorage, + (key, value) => enqueuePersistence(() => Preferences.set({ key, value })), + (pair, value) => { + if (pair.web === WEB_PREFERENCES_KEY) applyStoredTheme(value) + } + ) } catch { // Continue with WebView storage when native preferences are unavailable. } diff --git a/src/bridge/mobile-bridge.ts b/src/bridge/mobile-bridge.ts index fa15749..4878ad4 100644 --- a/src/bridge/mobile-bridge.ts +++ b/src/bridge/mobile-bridge.ts @@ -12,6 +12,7 @@ import { relocateLocalVault } from '@zennotes/app-core/workspace' */ import { registerPlugin } from '@capacitor/core' import { App as CapApp } from '@capacitor/app' +import { waitAtBoot } from '../boot-wait' import { Clipboard } from '@capacitor/clipboard' import { installZenBridge, @@ -156,12 +157,14 @@ import { import { folderForRelativePath, posixNormalize, sanitizeNoteTitle } from './vault-core' import { isPhoneViewport } from '../viewport' -let appVersion = '1.1.49' +let appVersion = '1.1.50' 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 package fallback 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 package fallback. } diff --git a/src/bridge/remote-client-message.test.ts b/src/bridge/remote-client-message.test.ts new file mode 100644 index 0000000..e240cf4 --- /dev/null +++ b/src/bridge/remote-client-message.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { it } from 'node:test' +import { loadMobileModule } from '../../tooling/load-mobile-module.ts' + +const { connectionErrorMessage } = await loadMobileModule('./src/bridge/remote-client.ts', { + '@capacitor/core': { CapacitorHttp: {} } +}) + +it('never points an Android user at the iPhone Local Network setting', () => { + for (const url of ['http://192.168.1.20:7878', 'https://notes.example.com']) { + const text: string = connectionErrorMessage(url, new Error('Failed to fetch')) + assert.doesNotMatch(text, /Local Network|Privacy & Security/) + assert.ok(text.includes(`Could not connect to the ZenNotes server at ${url}.`)) + assert.ok(text.includes('Could not reach the server: Failed to fetch.')) + } +}) + +it('says where to install a certificate authority for an https server only', () => { + assert.ok(connectionErrorMessage('https://notes.example.com', null).includes('Install a certificate')) + assert.ok(!connectionErrorMessage('http://192.168.1.20:7878', null).includes('certificate')) +}) diff --git a/src/bridge/remote-client.ts b/src/bridge/remote-client.ts index 94b8dca..5e009c0 100644 --- a/src/bridge/remote-client.ts +++ b/src/bridge/remote-client.ts @@ -104,20 +104,25 @@ 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. + * Transport-level failure message. Android has no Local Network permission + * to point at (this text was carried over from the iPhone app, which has + * one). What does stop an Android connection that a browser on the same + * phone makes is an HTTPS server whose certificate comes from the user's own + * certificate authority: since #141 the app trusts certificates installed + * in the user store, so say where to install one. */ export function connectionErrorMessage(baseUrl: string, error: unknown): string { const detail = error instanceof Error && error.message ? ` Could not reach the server: ${error.message}.` : '' + const https = /^https:/i.test(baseUrl) return ( `Could not connect to the ZenNotes server at ${baseUrl}. ` + `Make sure the server is running and the URL is correct.${detail}` + - ` If the server is on your local network (e.g. your Mac), check that ZenNotes` + - ` is allowed under Settings → Privacy & Security → Local Network.` + (https + ? ` If the server uses a certificate from your own certificate authority, install that` + + ` authority's certificate under Settings → Security → Encryption & credentials →` + + ` Install a certificate.` + : '') ) } diff --git a/src/main.tsx b/src/main.tsx index a48a302..66c308a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,6 +29,7 @@ import { mountMobileShell } from './ui-mobile/MobileShell' import { installHomeGuard } from './ui-mobile/nav' import { refreshVault } from './ui-mobile/refresh' import { isPhoneViewport, watchPhoneClass } from './viewport' +import { waitAtBoot } from './boot-wait' import './ui-mobile/mobile.css' function wireKeyboard(): void { @@ -78,8 +79,10 @@ function wireForegroundRescan(): void { } async function boot(): Promise { + // 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 @@ -97,7 +100,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 02c1386..bb34a39 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 { openDrawerRowToRead } from './drawer-open' import { SwipeRow } from './SwipeRow' import { drawerFileRows, fileExtensionLabel, type DrawerFileRow } from './browse-files' import { hiddenDrawerFolders } from './drawer-folders' @@ -790,6 +792,16 @@ export function MobileDrawer(): React.JSX.Element | null { window.setTimeout(() => void action(), 30) } + // 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) + } + return ( <> @@ -810,6 +822,7 @@ export function MobileDrawer(): React.JSX.Element | null { noteSortOrder={noteSortOrder} close={close} go={go} + read={read} onOpenVaults={() => openMobileSheet('vaults')} /> @@ -884,6 +897,7 @@ function MobileDrawerBody(props: { noteSortOrder: NoteSortOrder close: () => void go: (action: () => unknown) => void + read: (path: string) => void }): React.JSX.Element { const { vaultName, @@ -901,7 +915,8 @@ function MobileDrawerBody(props: { childFiles, noteSortOrder, close, - go + go, + read } = props const lp = useLongPress() const [sortOpen, setSortOpen] = useState(false) @@ -1185,7 +1200,7 @@ function MobileDrawerBody(props: {