diff --git a/internal/check-phone-print-keep.mjs b/internal/check-phone-print-keep.mjs index 8d9a9db86..7a6cac907 100644 --- a/internal/check-phone-print-keep.mjs +++ b/internal/check-phone-print-keep.mjs @@ -9,7 +9,7 @@ * later, after the reader taps Save, from the page as it is then. * * A timeline on fake timers and a stub DOM, with an Android user agent (Playwright cannot reproduce Android's print - * pipeline; the Driver's phone is the real check): + * pipeline; a real phone is the real check): * A. after print() and afterprint, the document and its print style are still mounted 30 s on (the Save render); * B. it goes once the reader is BACK: the page shown again after being hidden, the window focused again after * losing focus, or a touch after print(), then BACK_GRACE_MS later, not before; @@ -18,10 +18,15 @@ * D. a reader who never comes back: dropped at FRAME_CEILING_MS, not before; * E. printImageBlob: its object URL is NOT revoked at afterprint, and is revoked when the document goes; * F. a new print replaces the previous document (one root mounted, and the previous one released); - * G. the desktop path is the frame, as before (printDocument's first line, verbatim). + * G. the desktop path is the frame, as before (printDocument's first line, verbatim); + * H. a touch AFTER the first TOUCH_SETTLE_MS, then the print UI opening (the page hidden, or the window blurred): + * the pending drop is cancelled, so the document is still mounted well past BACK_GRACE_MS in the UI, and goes + * after the next "back" (a measured hole in the first cut, found on a second read); + * I. a touch in the first TOUCH_SETTLE_MS after print() (a double tap while the preview builds) is not "back". * `--impl=` runs the timeline against another copy (the pilot's, before this fix, fails A and E: the cause). * `--self-test`: the old drop at afterprint, no release when the reader is back, the listeners armed at mount (so the - * Print tap drops it), no grace, printImageBlob revoking at afterprint, and no ceiling must each FAIL. + * Print tap drops it), no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when the page goes away + * again during the grace, and no touch settle window must each FAIL. */ import { readFileSync } from 'node:fs' import { join, dirname } from 'node:path' @@ -96,8 +101,8 @@ const load = (src) => import('data:text/javascript;base64,' + Buffer.from(src + async function audit(src) { const fails = [] const sample = await load(src) - const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0 - if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS and BACK_GRACE_MS (the timeline runs on, with a grace of 0)') + const FRAME_CEILING_MS = sample.FRAME_CEILING_MS || 600000, BACK_GRACE_MS = sample.BACK_GRACE_MS || 0, TOUCH_SETTLE_MS = sample.TOUCH_SETTLE_MS || 0 + if (!sample.FRAME_CEILING_MS || !sample.BACK_GRACE_MS || !sample.TOUCH_SETTLE_MS) fails.push('G: printInPlace.js does not export FRAME_CEILING_MS, BACK_GRACE_MS and TOUCH_SETTLE_MS (the timeline runs on, with 0 for each missing one)') const HTML = '
packet
' // A + B (hidden → visible) @@ -117,16 +122,17 @@ async function audit(src) { if (!mounted()) fails.push('B: the document went while the window had lost focus') window.fire('focus'); advance(BACK_GRACE_MS + 1) if (mounted()) fails.push('B: the document stayed after the window was focused again (back on the page)') } - { world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(5000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1) + { world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); advance(BACK_GRACE_MS + 1) if (mounted()) fails.push('B: the document stayed after the page was touched again') } // C: a late print (the tap's activation expired): the Print tap is not "back" { world({ active: false }); const m = await load(src); let printNow = null const how = await m.printDocument(HTML, { onNeedsTap: (p) => { printNow = p } }) if (how !== 'needs-tap' || !printNow) fails.push(`C: an expired tap did not hand out printNow (returned ${how})`) else { - window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(30000) + // the reader taps Print a few seconds after the document is ready, then spends longer than the grace in the dialog + advance(TOUCH_SETTLE_MS + 3000); window.fire('pointerdown'); printNow(); window.fire('afterprint'); advance(BACK_GRACE_MS + 20000) if (window.prints.length !== 1 || !window.prints[0]) fails.push('C: printNow did not print the mounted document') - if (!mounted()) fails.push('C: the Print tap counted as being back: the document was gone 30 s into the dialog') + if (!mounted()) fails.push(`C: the Print tap counted as being back: the document was gone ${(BACK_GRACE_MS + 20000) / 1000} s into the dialog`) } } // D: never back { world(); const m = await load(src); await m.printDocument(HTML); window.fire('afterprint'); advance(FRAME_CEILING_MS - 1) @@ -145,6 +151,21 @@ async function audit(src) { await m.printDocument(HTML, { onRelease: () => { first++ } }); window.fire('afterprint'); await m.printDocument(HTML) if (rootCount() !== 1) fails.push(`F: ${rootCount()} print roots mounted after a second print (expected 1)`) if (first !== 1) fails.push(`F: the first print's onRelease ran ${first} time(s) when a second print replaced it (expected 1)`) } + // H: a touch after the settle window, then the print UI opens: away again cancels the pending drop + for (const via of ['hidden', 'blur']) { + world(); const m = await load(src); await m.printDocument(HTML); advance(TOUCH_SETTLE_MS + 500); window.fire('pointerdown'); advance(500) + if (via === 'hidden') { document.visibilityState = 'hidden'; document.fire('visibilitychange') } else window.fire('blur') + window.fire('afterprint'); advance(BACK_GRACE_MS + 20000) + if (!mounted()) fails.push(`H: a touch, then the print UI (${via}): the grace the touch started was not cancelled, and the document went while the reader was still in the UI (a PDF saved then is the bare page)`) + else { + if (via === 'hidden') { document.visibilityState = 'visible'; document.fire('visibilitychange') } else window.fire('focus') + advance(BACK_GRACE_MS + 1) + if (mounted()) fails.push(`H: after the print UI (${via}) closed, the document stayed past the grace (the next "back" did not count)`) + } + } + // I: a touch in the settle window (no hide, no blur: an in-page sheet) is not "back" + { world(); const m = await load(src); await m.printDocument(HTML); advance(Math.max(1, TOUCH_SETTLE_MS - 1500)); window.fire('pointerdown'); advance(BACK_GRACE_MS + 30000) + if (!mounted()) fails.push('I: a touch right after print() (a double tap while the preview builds) counted as being back, and the document went with the dialog still up') } // G: desktop unchanged (the frame path) if (!src.includes("if (!isPhonePrint()) return openPrintFrame(html, { autoprint: true }) ? 'frame' : 'blocked'")) fails.push('G: printDocument\'s desktop path is no longer the frame, verbatim') return fails @@ -160,6 +181,8 @@ if (process.argv.includes('--self-test')) { ['no grace once the reader is back', src.replace('ceiling = setTimeout(drop, BACK_GRACE_MS) }', 'drop() }'), /^B: the document went at once/], ['printImageBlob revoking at afterprint', src.replace(" const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n", " const release = () => { try { URL.revokeObjectURL(url) } catch { /* gone */ } }\n window.addEventListener('afterprint', () => setTimeout(release, 0))\n"), /^E: printImageBlob revoked/], ['no ceiling', src.replace(MOUNT_END, ' currentJob = job\n'), /^D: the document outlived/], + ['no cancel when the page goes away again', src.replace('const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } }', 'const awayAgain = () => { away = true }'), /^H: a touch, then the print UI/], + ['no touch settle window', src.replace('const onTouch = () => { if (!settling) back() }', 'const onTouch = () => back()'), /^I: /], ] let missed = 0 for (const [name, poisoned, rx] of poisons) { @@ -172,5 +195,5 @@ if (process.argv.includes('--self-test')) { } else { const f = await audit(src) if (f.length) { for (const x of f) console.log('✗ ' + x); console.log(`FAIL check:phone-print-keep (${f.length})`); process.exitCode = 1 } - else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop prints through the frame as before') + else console.log('PASS check:phone-print-keep — on a phone the print document (and printImageBlob\'s image) outlives afterprint, so a PDF saved after the preview is the document; it goes once the reader is back (shown, focused or touched again) plus a grace, or at the ceiling; the Print tap of a late print and a touch right after print() do not count; going away again cancels a pending drop; a new print replaces it; desktop prints through the frame as before') } diff --git a/library/composables/printInPlace.js b/library/composables/printInPlace.js index 009c4b5bb..04dbdd543 100644 --- a/library/composables/printInPlace.js +++ b/library/composables/printInPlace.js @@ -179,21 +179,27 @@ function mountPrintRoot(html, { onRelease = null } = {}) { // ★ KEPT UNTIL THE READER IS BACK, never dropped at afterprint (printDocument says why). From the moment print() is // called (printing(), not at mount: the Print tap that starts a late print must not count), the reader is back when // the page is shown again after being hidden, the window is focused again after losing focus, or the page is - // touched (the system print UI covers the page, so a touch there means it has gone). It goes BACK_GRACE_MS after - // that, since a print job can still be rendering while the dialog animates away. A new print replaces it, a - // cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none, so keeping it shows nothing. - let ceiling = null, armed = false, away = false, leaving = false + // touched (the system print UI covers the page, so a touch there means it has gone). A touch in the first + // TOUCH_SETTLE_MS after print() does not count (a double tap while the preview builds). It goes BACK_GRACE_MS after + // that, since a slow phone can still be writing the file after its dialog closes; if the page goes AWAY again during + // the grace (the print UI opened after an early touch), the pending drop is cancelled and the next "back" counts. A + // new print replaces it, a cancelled one drops it, and FRAME_CEILING_MS is the ceiling. On screen it is display:none, + // so keeping it shows nothing. + let ceiling = null, settle = null, armed = false, away = false, leaving = false, settling = false const back = () => { if (leaving) return; leaving = true; clearTimeout(ceiling); ceiling = setTimeout(drop, BACK_GRACE_MS) } - const onVisibility = () => { if (document.visibilityState === 'hidden') away = true; else if (away) back() } - const onBlur = () => { away = true } + const awayAgain = () => { away = true; if (leaving) { leaving = false; clearTimeout(ceiling); ceiling = setTimeout(drop, FRAME_CEILING_MS) } } + const onVisibility = () => { if (document.visibilityState === 'hidden') awayAgain(); else if (away) back() } + const onBlur = () => { awayAgain() } const onFocus = () => { if (away) back() } - const onTouch = () => back() + const onTouch = () => { if (!settling) back() } const job = { live: () => currentJob === job, drop, printing: () => { if (armed) return armed = true + settling = true + settle = setTimeout(() => { settling = false }, TOUCH_SETTLE_MS) document.addEventListener('visibilitychange', onVisibility) window.addEventListener('blur', onBlur) window.addEventListener('focus', onFocus) @@ -211,6 +217,7 @@ function mountPrintRoot(html, { onRelease = null } = {}) { } function drop() { clearTimeout(ceiling) + clearTimeout(settle) document.removeEventListener('visibilitychange', onVisibility) window.removeEventListener('blur', onBlur) window.removeEventListener('focus', onFocus) @@ -230,7 +237,9 @@ export const FRAME_KEEP_MS = 60000 /** The absolute ceiling for a print frame whose load never ends. */ export const FRAME_CEILING_MS = 600000 /** A phone's in-page print document goes this long after the reader is back on the page (mountPrintRoot). */ -export const BACK_GRACE_MS = 5000 +export const BACK_GRACE_MS = 60000 +/** A touch this soon after print() is not the reader coming back (a double tap while the preview builds). */ +export const TOUCH_SETTLE_MS = 2000 /** * Print a rendered poster IMAGE, at its real paper size, through the same in-place frame. diff --git a/package.json b/package.json index 8c981879e..7a3474453 100644 --- a/package.json +++ b/package.json @@ -438,7 +438,7 @@ "check:phone-print-keep:self-test": "node internal/check-phone-print-keep.mjs --self-test" }, "//scripts": { - "check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print()) plus BACK_GRACE_MS, or at the ceiling; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl= runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling.", + "check:phone-print-keep": "on a PHONE the in-page print document (#dxm-print-root and its print style, library/composables/printInPlace.js) and printImageBlob's object URL outlive afterprint, because Android Chrome fires afterprint once the PREVIEW is built and renders a saved PDF later, from the page as it is then (a phone's saved packet was the bare host page). A timeline on fake timers and a stub DOM with an Android user agent: still mounted 30 s after afterprint; released once the reader is back (shown again after hidden, focused again after blur, or touched after print() but not in its first TOUCH_SETTLE_MS) plus BACK_GRACE_MS (60 s), or at the ceiling; going away again during the grace cancels the pending drop; the Print tap of a late print does not count; a new print replaces it; desktop is the frame path, verbatim. --impl= runs it against another copy (the pre-fix file fails). Self-test poisons: the old drop at afterprint, no release when back, listeners armed at mount, no grace, printImageBlob revoking at afterprint, no ceiling, no cancel when away again, no touch settle window.", "_": "What each script does. These notes used to sit inside the command strings as trailing \"# …\" comments, which made the shell discard any arguments passed after \"--\" (so a gate given --url silently measured its hardcoded default port — possibly another agent's server). Keep notes here, never in a command.", "browser-gates": "lists every package.json script that launches a browser (the same detection run-parallel uses to route them through internal/browser-slot.sh). Run each as internal/browser-slot.sh bun run .", "cap:sync": "NOT `bunx` — bunx is not on PATH in this environment (command not found, exit 127), which broke cap:sync -> apk:debug -> phone at step one. The CLI IS installed locally (@capacitor/cli 7.6.9); invoke the local bin directly. The rest of package.json already uses the spaced `bun x` form.",