diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 200f3f3..4e4255a 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -893,13 +893,14 @@ step('and taking it reads the folder again', (await page.$$eval(thumbs, (els) => // the roll again, every folder is listed a second time, and the counter starts // from zero while the frames it already holds are read again to be skipped. // -// The position belongs to the tab now, so the reading carries on instead of +// The position belongs to the origin now, so the reading carries on instead of // starting over. `check.hold` stops the reading on the frame it is reading, which // is where a reload lands — and the reading it left behind is still there when the // key goes, so the same reading finishes the roll. Everything is taken out of the // catalogue first, so the reading has frames on both sides of the reload. await page.evaluate(async () => { sessionStorage.clear(); + localStorage.removeItem('recipescam.library.walk.CheckRoll'); localStorage.setItem('check.hold', '1'); const db = await new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); @@ -913,10 +914,10 @@ await page.evaluate(async () => { tx.onerror = () => reject(tx.error); }); }); -// The position the tab is holding, the line the toolbar reads out, and the wait +// The position the origin is holding, the line the toolbar reads out, and the wait // for it: the line is how far the reading has come, and it is the one thing on // screen that says whether a reading started over. -const position = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('recipescam.library.walk.CheckRoll') ?? 'null')); +const position = () => page.evaluate(() => JSON.parse(localStorage.getItem('recipescam.library.walk.CheckRoll') ?? 'null')); const readout = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => ''); const counting = () => page.waitForFunction( diff --git a/docker/frontend/scripts/walk-share-check.mjs b/docker/frontend/scripts/walk-share-check.mjs new file mode 100644 index 0000000..ddbc2bb --- /dev/null +++ b/docker/frontend/scripts/walk-share-check.mjs @@ -0,0 +1,218 @@ +// Where a reading had got to is written down for the whole origin, not for the +// tab. The tab that reads a roll for minutes is often not the one that finishes +// it: the app is closed and the installed one opens, or the tab is thrown away +// and the reader comes back. The position used to live in sessionStorage, which +// is the tab's own, so the second context found nothing, walked the roll from the +// top and listed every folder again — the frame counter starting over while the +// frames it already held were read again only to be skipped. +// +// Two pages of one browser context are two contexts of one origin: the first +// picks the stand-in roll and is closed mid-walk, the second opens and takes the +// reading up. What it costs is what says whether it did — a reading that took the +// position up lists nothing, since the folders behind it came back with it, where +// one that started at the top lists the whole roll a second time. +// +// npm run build && node scripts/walk-share-check.mjs +// PLAYWRIGHT_CORE= node scripts/walk-share-check.mjs +// +// Playwright is not a dependency of this package, so a run without it says SKIP +// and exits 0. APP_PORT says where the preview goes, CHROME says which browser. +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const PORT = Number(process.env.APP_PORT ?? 4197); +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); + process.exit(0); +} + +// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which +// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails. +const app = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', +}); +const base = `http://127.0.0.1:${PORT}`; +let up = false; +for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); +} +if (!up) { + app.kill('SIGTERM'); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); + process.exit(1); +} + +// Dated folders of five frames each: a roll large enough that the reading is +// still going when the first page is taken away, and small enough to finish. +const DIRS = {}; +for (let i = 0; i < 40; i++) { + DIRS[`d${String(i).padStart(2, '0')}`] = { files: [1, 2, 3, 4, 5].map((n) => `F${i}_${n}.JPG`), t: 1_700_000_000_000 }; +} + +const browser = await playwright.chromium.launch({ + // Playwright's own Chromium when it has one; CHROME points at any other. + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], +}); +const context = await browser.newContext(); + +// The picker stands in for the person: a folder of handles over frames made in +// the page (a beat apiece, so the reading can be caught in the middle of the +// roll), and the same handles put back on the rows IndexedDB hands out — a cloned +// handle is a plain object with no `getFile` on it. +await context.addInitScript( + ({ dirs }) => { + let bytes = null; + const jpeg = () => + (bytes ??= new Promise((resolve) => { + const c = document.createElement('canvas'); + c.width = 24; + c.height = 16; + const g = c.getContext('2d'); + g.fillStyle = '#b3412a'; + g.fillRect(0, 0, 24, 16); + c.toBlob(async (b) => resolve(new Uint8Array(await b.arrayBuffer())), 'image/jpeg', 0.8); + })); + const fileHandle = (name, t) => { + const handle = { kind: 'file', name, __fake: true }; + Object.defineProperty(handle, 'getFile', { + value: async () => { + await new Promise((r) => setTimeout(r, 120)); + return new File([await jpeg()], name, { type: 'image/jpeg', lastModified: t }); + }, + }); + return handle; + }; + const dirHandle = (label, node) => { + const handle = { kind: 'directory', name: label, __fake: true }; + Object.defineProperties(handle, { + values: { + value: () => { + // Every listing a reading asks for, counted for the whole origin: a + // reading that starts over lists the same folder a second time, and + // that second listing is the whole of what the position saves. + localStorage.setItem('walkcheck.lists', String(Number(localStorage.getItem('walkcheck.lists') ?? 0) + 1)); + return [ + ...(node.files ?? []).map((f) => fileHandle(f, node.t)), + ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), + ][Symbol.iterator](); + }, + }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + getDirectoryHandle: { + value: async (name) => { + const kid = node.dirs?.[name]; + if (!kid) throw new Error(`no folder named ${name}`); + return dirHandle(name, kid); + }, + }, + getFileHandle: { + value: async (name) => { + if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`); + return fileHandle(name, node.t); + }, + }, + }); + return handle; + }; + const root = () => dirHandle('BigRoll', { files: [], dirs }); + window.showDirectoryPicker = async () => root(); + const revive = (row) => { + if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { + row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name, 1_700_000_000_000); + } + return row; + }; + for (const method of ['get', 'getAll']) { + const original = IDBObjectStore.prototype[method]; + IDBObjectStore.prototype[method] = function (...args) { + const request = original.apply(this, args); + const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); + Object.defineProperty(request, 'result', { + configurable: true, + get() { + const value = descriptor.get.call(this); + return Array.isArray(value) ? value.map(revive) : revive(value); + }, + }); + return request; + }; + } + }, + { dirs: DIRS } +); + +const progress = (p) => p.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => ''); +const at = (text) => { + const m = /(\d+)\/(\d+)/.exec(text); + return m ? { n: Number(m[1]), t: Number(m[2]) } : null; +}; +const listed = (p) => p.evaluate(() => Number(localStorage.getItem('walkcheck.lists') ?? 0)); +const held = (p) => + p.evaluate(() => { + const raw = localStorage.getItem('recipescam.library.walk.BigRoll'); + if (!raw) return null; + const s = JSON.parse(raw); + return { done: s.progress?.done, walked: s.walked?.length, frames: s.frames?.length }; + }); + +// --- the first context: it starts the reading and is taken away ------------- +const first = await context.newPage(); +await first.goto(`${base}/library`); +await first.evaluate(() => { + localStorage.removeItem('walkcheck.lists'); + localStorage.removeItem('recipescam.library.walk.BigRoll'); + sessionStorage.clear(); +}); +await first.click('[data-key="lib-add"]'); +let stopped = null; +for (let i = 0; i < 600; i++) { + const now = at(await progress(first)); + if (now && now.n >= 40 && now.t >= 200) { + stopped = now; + break; + } + await first.waitForTimeout(50); +} +const wrote = await held(first); +const listsA = await listed(first); +await first.close(); +if (!stopped || !wrote) { + await browser.close(); + app.kill('SIGTERM'); + console.log(`FAIL the first reading never got going — ${stopped ? `${stopped.n}/${stopped.t}` : 'no counter'}, position ${JSON.stringify(wrote)}`); + process.exit(1); +} + +// --- the next context: it opens the app and reads where it starts ----------- +const second = await context.newPage(); +await second.goto(`${base}/library`); +let opened = null; +for (let i = 0; i < 1200; i++) { + const now = at(await progress(second)); + if (now) { + opened = now; + break; + } + await second.waitForTimeout(25); +} +// Let it work for a moment, then look at what it was: a reading that took the +// position up has nothing to walk — the queue and the folders behind it came +// back with it — where one that started at the top lists the whole roll again. +await second.waitForTimeout(2500); +const listsB = await listed(second); +const session = await second.evaluate(() => sessionStorage.getItem('recipescam.library.walk.BigRoll') !== null); + +await browser.close(); +app.kill('SIGTERM'); + +const ok = opened !== null && listsB === listsA && !session; +console.log(`${ok ? 'ok ' : 'FAIL'} walk share — a reading another context left behind carries on`); +console.log(` first context stopped at ${stopped.n}/${stopped.t}, ${listsA} folders listed, wrote ${JSON.stringify(wrote)}`); +console.log(` next context opened at ${opened ? `${opened.n}/${opened.t}` : 'nothing — it never started'}, ${listsB} folders listed, position under the tab's own key=${session}`); +process.exit(ok ? 0 : 1); diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 74b3276..46c94a5 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -361,10 +361,13 @@ export interface ScanProgress { // the catalogue only holds the frames that were read — so the reading would // start again at the top of the roll and walk every folder a second time. // -// Session storage holds exactly that position, and holds it because it is the -// tab's: the tab that reloads carries on from the frame it stopped at, and a new -// tab starts a reading of its own from the top. The key goes when the reading -// finishes. +// Local storage holds exactly that position, and holds it for the whole origin, +// which is the point: the tab that reloads carries on from the frame it stopped +// at, and so does the next context to open the app — the installed one beside the +// browser, where session storage, being the tab's own, handed it nothing and the +// walk started at the top of the roll a second time, every folder listed again +// and every frame it already held read again only to be skipped. The key goes +// when the reading finishes. const WALK_KEY = (folder: string) => `recipescam.library.walk.${folder}`; interface WalkSaved { @@ -383,8 +386,14 @@ interface WalkSaved { } function loadWalk(folder: string): WalkSaved | null { + // One reading at a time: a peer that has announced this folder is walking it + // right now, and what it has written down is a step of a walk still in + // progress. Taken up here, two readings would write the same roll down at + // once, each undoing the other's queue. In the tab's own storage this could not + // come up; now that the position is the origin's, it can. + if (peer?.folder === folder) return null; try { - const raw = sessionStorage.getItem(WALK_KEY(folder)); + const raw = localStorage.getItem(WALK_KEY(folder)); if (!raw) return null; const saved = JSON.parse(raw) as WalkSaved; return Array.isArray(saved?.walked) && @@ -410,7 +419,7 @@ function saveWalk( progress: ScanProgress ): void { try { - sessionStorage.setItem(WALK_KEY(folder), JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved)); + localStorage.setItem(WALK_KEY(folder), JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved)); } catch { // Nothing to write down: the reading starts from the top, as it did before. } @@ -418,7 +427,7 @@ function saveWalk( function clearWalk(folder: string): void { try { - sessionStorage.removeItem(WALK_KEY(folder)); + localStorage.removeItem(WALK_KEY(folder)); } catch { // Nothing to clear. }