From ebd6948789572738ce519002af8ab6100cfd4712 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 15:40:25 +0300 Subject: [PATCH 1/3] [feat] share into the app + open files from the OS (60 Q4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - manifest: share_target (POST multipart `files`: images, glb/gltf, tpscene), file_handlers (.tpscene, .glb, .gltf → /), launch_handler focus-existing - static/sw.js: receives the share POST (a static site has no server), parks the files in the `tp-share-inbox` cache and 303s to /?shared=; everything else is still a no-cache passthrough - src/lib/launchInbox.js: reads the inbox ONCE (cache deleted, ?shared= stripped) and takes launchQueue file launches; routes .tpscene → open (Sidebar Load path), models → import into the current scene (companions ride along), lone images → Explorer Library + "Show in Library"; waits for the scene group AND the restore-session answer first - autosave: restoreSettled() — resolves once the boot's restore question is answered (no snapshot / non-empty / auto-restored / Restore finished / Dismiss) - tests/unit/launchInbox.test.js (14): manifest keys, the real sw.js in a fake worker scope (park + 303, text-only, replace, passthrough), the worker → app round trip, classifier Co-Authored-By: Claude Opus 5.5 (1M context) --- src/App.svelte | 3 + src/lib/autosave.js | 40 +++++- src/lib/launchInbox.js | 229 +++++++++++++++++++++++++++++++ static/manifest.webmanifest | 27 ++++ static/sw.js | 55 +++++++- tests/unit/launchInbox.test.js | 243 +++++++++++++++++++++++++++++++++ 6 files changed, 591 insertions(+), 6 deletions(-) create mode 100644 src/lib/launchInbox.js create mode 100644 tests/unit/launchInbox.test.js diff --git a/src/App.svelte b/src/App.svelte index db386f8ce..86d275788 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -295,6 +295,9 @@ import { startMusicToolbox } from './lib/musicToolbox' startShortcuts() startSnapping() startAutosave() + // 60 Q4: files shared into the app (share_target → sw.js inbox) or opened from the OS + // (file_handlers → launchQueue); they wait for the restore question (launchInbox.js) + if (!exportMode) import('$lib/launchInbox').then((m) => m.startLaunchInbox()).catch(() => {}) startSceneAssets() startCodeWorkspace() // 36-code: scriptEditorOpen -> a workspace tab, file follow startNetworkQuality() diff --git a/src/lib/autosave.js b/src/lib/autosave.js index b3ee84927..5254d301d 100644 --- a/src/lib/autosave.js +++ b/src/lib/autosave.js @@ -522,15 +522,32 @@ export function annotationsRestore(annotations) { if (annotations?.length && annotationsRestorer) annotationsRestorer(annotations); } +/** + * 60 Q4: "has this boot's restore question been ANSWERED?" — the launch inbox (a file shared + * into the app or opened from the OS) waits on it, because a model imported into the empty + * boot scene is wiped by a Restore pressed a second later, and a .tpscene opened over the + * prompt overwrites the very autosave the prompt offers. Resolves once: no snapshot, a scene + * already non-empty, an auto-restore finished, or the offer answered (Restore → after its + * load; Dismiss → at once). A restore's Cancel that re-offers does not re-arm it. + */ +let settleRestore = () => {}; +const restoreDecided = new Promise((resolve) => (settleRestore = () => resolve(undefined))); +/** the running applyRestore of an answered offer (restoreSnapshot), for restoreDecided + * @type {Promise | null} */ +let restoreJob = null; +export function restoreSettled() { + return restoreDecided; +} + async function checkRestore() { try { const snapshot = await idbGet('latest'); - if (!snapshot || (!snapshot.scene && !snapshot.nodes?.length)) return; + if (!snapshot || (!snapshot.scene && !snapshot.nodes?.length)) return settleRestore(); // wait for the scene group to exist, then only offer when it's still empty const unsubscribe = objectsGroup.subscribe((group) => { if (!group) return; setTimeout(() => unsubscribe(), 0); - if (group.children.length !== 0) return; + if (group.children.length !== 0) return settleRestore(); let armed = false; try { armed = typeof localStorage !== 'undefined' && !!safeStorage.getItem('restoreArmed'); @@ -545,11 +562,23 @@ async function checkRestore() { // 27-D: `risky` means the previous restore of this snapshot never reached a clean // flow tick. Auto-restoring it again is how one bad scene becomes a boot loop the // user cannot escape, so it always goes to the PROMPT, which says why. - if (get(autoRestoreEnabled) && !armed) autoRestore(offer); - else restoreAvailable.set(offer); + if (get(autoRestoreEnabled) && !armed) autoRestore(offer).finally(settleRestore); + else { + restoreAvailable.set(offer); + // answered = the offer cleared; a Restore clears it BEFORE its load starts, so + // the microtask lets restoreSnapshot publish the job first + const off = restoreAvailable.subscribe((value) => { + if (value) return; + queueMicrotask(() => { + off(); + Promise.resolve(restoreJob).finally(settleRestore); + }); + }); + } }); } catch (error) { log('warn', 'autosave', 'restore check failed', String(error)); + settleRestore(); } } @@ -788,7 +817,8 @@ export async function restoreSnapshot() { const offer = get(restoreAvailable); if (!offer) return; restoreAvailable.set(null); - const ok = await applyRestore(offer.snapshot, offer); + restoreJob = applyRestore(offer.snapshot, offer); + const ok = await restoreJob; // null: cancelled (it said so itself and re-offered) or superseded by another load if (ok === null) return; showToast( diff --git a/src/lib/launchInbox.js b/src/lib/launchInbox.js new file mode 100644 index 000000000..7080c99fe --- /dev/null +++ b/src/lib/launchInbox.js @@ -0,0 +1,229 @@ +// launchInbox.js — 60 Q4: files that arrive FROM THE OS rather than through a picker. +// +// Two doors, one router: +// · "Share → theprototype" (Android share sheet, desktop Chrome share) — the manifest's +// `share_target` POSTs the files to /share-target, which only the service worker can +// receive on a static site (static/sw.js): it parks them in the `tp-share-inbox` cache +// and redirects to /?shared=. `readShareInbox` collects them ONCE and deletes the +// cache. Chromium ignores `launch_handler` for share launches, so this is always a +// fresh navigation and the worker always sees the POST. +// · Double-click a .tpscene / .glb / .gltf on the desktop (installed PWA, `file_handlers`) +// — the files arrive on `window.launchQueue`. `launch_handler: focus-existing` sends a +// second double-click to the window already open, whose consumer runs again. +// +// What each file does (`classifyLaunchFiles`, PURE): a .tpscene OPENS (the Sidebar Load +// path — the scene replaces the world, unsaved, proposed to peers when connected; one at a +// time), models are IMPORTED into the current scene (the import dialog's path, so an .obj's +// .mtl and textures ride along), and an image on its own goes into the Explorer LIBRARY with +// a "Show in Library" action — a picture has no single right place in a 3D scene, and the +// library is where every texture/HUD/shader picker already reads from. +// +// Nothing runs until the boot is ready to take it: the scene group exists AND the +// restore-session question is answered (`restoreSettled`), because a model imported into +// the empty boot scene is wiped by a Restore pressed a second later, and a scene opened over +// the prompt overwrites the very autosave it offers. +import { objectsGroup } from '../stores/sceneStore'; +import { explorerClose, showInfoToast, showToast } from '../stores/appStore'; + +// kept in step with static/sw.js +export const SHARE_INBOX = 'tp-share-inbox'; +export const SHARE_PARAM = 'shared'; + +const SCENE_EXT = /\.tpscene$/i; +const MODEL_EXT = /\.(glb|gltf|obj|stl|fbx)$/i; +const COMPANION_EXT = /\.(mtl|png|jpe?g|webp|bmp|gif)$/i; +const IMAGE_EXT = /\.(png|jpe?g|webp|bmp|gif|heic|heif|avif)$/i; + +/** + * Sort a launch's files into what each will do. PURE. Images travel as a model's + * companions when a model came with them (an .obj and its textures), otherwise each one + * is a library image. + * @template {{name: string, type?: string}} F + * @param {F[]} files + * @returns {{scenes: F[], models: F[], companions: F[], images: F[], skipped: F[]}} + */ +export function classifyLaunchFiles(files) { + /** @type {{scenes: F[], models: F[], companions: F[], images: F[], skipped: F[]}} */ + const out = { scenes: [], models: [], companions: [], images: [], skipped: [] }; + const list = [...(files ?? [])].filter(Boolean); + const hasModel = list.some((f) => MODEL_EXT.test(f.name ?? '')); + for (const f of list) { + const name = f.name ?? ''; + if (SCENE_EXT.test(name)) out.scenes.push(f); + else if (MODEL_EXT.test(name)) out.models.push(f); + else if (hasModel && COMPANION_EXT.test(name)) out.companions.push(f); + else if (IMAGE_EXT.test(name) || String(f.type ?? '').startsWith('image/')) out.images.push(f); + else out.skipped.push(f); + } + return out; +} + +/** the boot is ready for a launch: the scene exists and the restore question is answered */ +async function bootReady() { + await new Promise((resolve) => { + const off = objectsGroup.subscribe((group) => { + if (!group) return; + queueMicrotask(() => off()); + resolve(undefined); + }); + }); + const { restoreSettled } = await import('./autosave'); + await restoreSettled(); +} + +/** + * Route a launch's files. Serialised, so two launches in quick succession (two + * double-clicks) land in order instead of interleaving their imports. + * @param {File[]} files @param {'share' | 'open'} source + * @returns {Promise<{scene: string | null, models: number, images: number, skipped: string[]}>} + */ +export function receiveLaunchFiles(files, source) { + const run = queue.then(() => route(files, source)); + queue = run.catch(() => {}); + return run; +} +/** @type {Promise} */ +let queue = Promise.resolve(); + +/** @param {File[]} files @param {'share' | 'open'} source */ +async function route(files, source) { + const result = { + scene: /** @type {string | null} */ (null), + models: 0, + images: 0, + skipped: /** @type {string[]} */ ([]) + }; + const c = classifyLaunchFiles(files); + result.skipped = c.skipped.map((f) => f.name); + if (!c.scenes.length && !c.models.length && !c.images.length) { + if (files.length) + showToast( + 'Cannot open ' + + result.skipped.join(', ') + + ' — theprototype takes .tpscene, .glb, .gltf and images' + ); + lastLaunch.value = { source, ...result }; + return result; + } + await bootReady(); + const fh = await import('./fileHandler.svelte'); + // the scene first: models shared WITH it land in it, not in the world it replaces + if (c.scenes.length) { + const scene = c.scenes[0]; + await fh.load(scene); + result.scene = scene.name; + if (c.scenes.length > 1) + showToast( + `Opened ${scene.name} — one scene at a time, so the other ${c.scenes.length - 1} stayed closed` + ); + } + if (c.models.length) { + const r = fh.importModelFiles([...c.models, ...c.companions]); + result.models = r.models; + } + if (c.images.length) { + const { importFiles, revealItem } = await import('./explorer'); + const created = await importFiles(c.images, null, { duplicates: 'reuse' }); + result.images = created.length; + if (created.length) { + const first = created[0]; + const label = + created.length === 1 + ? `${first.name} is in your Library` + : `${created.length} images are in your Library`; + showInfoToast( + `launch-images-${Date.now()}`, + `${label} — drag one onto an object to texture it.`, + [ + { + label: 'Show in Library', + action: () => { + explorerClose.set(false); + revealItem(first.id); + } + } + ] + ); + } + } + if (c.skipped.length) + showToast('Skipped ' + result.skipped.join(', ') + ' (not a scene, model or image)'); + lastLaunch.value = { source, ...result }; + return result; +} + +/** the last routed launch — read by the e2e debug hook */ +export const lastLaunch = { value: /** @type {any} */ (null) }; + +/** + * Collect what the service worker parked for a share, ONCE: every file in the inbox, then + * the cache is deleted and `?shared=` leaves the address bar (a reload must not re-import). + * @param {{caches?: CacheStorage, location?: Location, history?: History}} [env] injectable for tests + * @returns {Promise} + */ +export async function readShareInbox(env = globalThis) { + const loc = env.location; + if (!loc || !new URLSearchParams(loc.search).has(SHARE_PARAM)) return []; + try { + const url = new URL(loc.href); + url.searchParams.delete(SHARE_PARAM); + env.history?.replaceState(env.history.state, '', url.pathname + url.search + url.hash); + } catch { + /* the address bar keeps the param — the inbox is gone either way */ + } + const store = env.caches; + if (!store) return []; + /** @type {File[]} */ + const files = []; + try { + const inbox = await store.open(SHARE_INBOX); + const keys = [...(await inbox.keys())]; + const nth = (/** @type {Request} */ r) => Number(r.url.split('/').pop()); + keys.sort((a, b) => nth(a) - nth(b)); + for (const key of keys) { + const res = await inbox.match(key); + if (!res) continue; + const blob = await res.blob(); + let name = 'shared'; + try { + name = decodeURIComponent(res.headers.get('X-File-Name') || name); + } catch { + /* keep the fallback */ + } + files.push( + new File([blob], name, { type: blob.type || res.headers.get('Content-Type') || '' }) + ); + } + } catch { + /* an unreadable inbox is an empty one */ + } + await store.delete(SHARE_INBOX).catch(() => false); + return files; +} + +let started = false; +/** App boot: collect a share, and take file launches for the life of the page. */ +export function startLaunchInbox() { + if (started || typeof window === 'undefined') return; + started = true; + void readShareInbox().then((files) => { + if (files.length) void receiveLaunchFiles(files, 'share'); + }); + /** @type {any} */ + const lq = /** @type {any} */ (window).launchQueue; + if (lq && typeof lq.setConsumer === 'function') { + lq.setConsumer(async (/** @type {any} */ params) => { + const handles = Array.isArray(params?.files) ? params.files : []; + if (!handles.length) return; + const files = []; + for (const h of handles) { + try { + if (h && typeof h.getFile === 'function') files.push(await h.getFile()); + } catch { + /* a handle we may not read is skipped */ + } + } + if (files.length) void receiveLaunchFiles(files, 'open'); + }); + } +} diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest index d47f21fe2..087b0ed90 100644 --- a/static/manifest.webmanifest +++ b/static/manifest.webmanifest @@ -9,6 +9,33 @@ "background_color": "#111827", "theme_color": "#111827", "categories": ["productivity", "graphics", "developer"], + "share_target": { + "action": "/share-target", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": ["image/*", "model/gltf-binary", "model/gltf+json", "application/x-tpscene", ".glb", ".gltf", ".tpscene"] + } + ] + } + }, + "file_handlers": [ + { + "action": "/", + "accept": { + "application/x-tpscene": [".tpscene"], + "model/gltf-binary": [".glb"], + "model/gltf+json": [".gltf"] + } + } + ], + "launch_handler": { "client_mode": ["focus-existing", "auto"] }, "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, diff --git a/static/sw.js b/static/sw.js index d1d669d25..bdb9059ac 100644 --- a/static/sw.js +++ b/static/sw.js @@ -10,9 +10,62 @@ // If precaching is ever added, wire skipWaiting()/clients.claim() to the // version poll first, or the update toast will be lying. +// +// 60 Q4 — the ONE exception, and it is not the app shell: the manifest's share_target +// POSTs "Share → theprototype" here (multipart, field `files`). A static site has no server +// to receive it, so the worker does: each shared file is parked in the `tp-share-inbox` +// cache under /share-inbox/ (name in X-File-Name), and the POST is answered with a 303 +// to /?shared=. The app (src/lib/launchInbox.js) reads the inbox ONCE and deletes +// the whole cache, so nothing outlives the hand-over. Text/url shares carry no file and +// just open the app. Kept in step with launchInbox.js: SHARE_INBOX, SHARE_PATH, SHARE_PARAM. + +const SHARE_INBOX = 'tp-share-inbox'; +const SHARE_PATH = '/share-target'; +const SHARE_PARAM = 'shared'; +const SHARE_MAX_FILES = 20; + +/** @param {Request} request */ +async function receiveShare(request) { + let count = 0; + try { + const form = await request.formData(); + const files = form + .getAll('files') + .filter((f) => typeof f === 'object' && f && 'arrayBuffer' in f) + .slice(0, SHARE_MAX_FILES); + if (files.length) { + // a previous share nobody collected is not mixed into this one + await caches.delete(SHARE_INBOX); + const inbox = await caches.open(SHARE_INBOX); + for (const file of files) { + await inbox.put( + new Request(new URL(`/share-inbox/${count++}`, self.location.origin).href), + new Response(file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream', + 'X-File-Name': encodeURIComponent(file.name || `shared-${count}`) + } + }) + ); + } + } + } catch { + count = 0; + } + return Response.redirect( + new URL(count ? `/?${SHARE_PARAM}=${count}` : '/', self.location.origin).href, + 303 + ); +} + self.addEventListener('install', () => self.skipWaiting()); self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method === 'POST' && new URL(request.url).pathname === SHARE_PATH) { + event.respondWith(receiveShare(request)); + return; + } // straight to the network — no caches.match, no caches.put - event.respondWith(fetch(event.request)); + event.respondWith(fetch(request)); }); diff --git a/tests/unit/launchInbox.test.js b/tests/unit/launchInbox.test.js new file mode 100644 index 000000000..73cf989fc --- /dev/null +++ b/tests/unit/launchInbox.test.js @@ -0,0 +1,243 @@ +// 60 Q4 — share target / file handlers: the manifest keys, the service worker's share +// inbox (static/sw.js run in a fake worker scope), the app's inbox reader and the router's +// classification. The browser halves (an installed PWA receiving a share, launchQueue) are +// the e2e suite `launch-inbox` and the owner rows. +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { + SHARE_INBOX, + SHARE_PARAM, + classifyLaunchFiles, + readShareInbox +} from '../../src/lib/launchInbox.js'; + +const manifest = JSON.parse( + readFileSync(new URL('../../static/manifest.webmanifest', import.meta.url), 'utf8') +); +const swSource = readFileSync(new URL('../../static/sw.js', import.meta.url), 'utf8'); + +/** a CacheStorage good enough for both halves */ +function fakeCaches() { + /** @type {Map>} */ + const stores = new Map(); + const open = async (/** @type {string} */ name) => { + if (!stores.has(name)) stores.set(name, new Map()); + const m = /** @type {Map} */ (stores.get(name)); + return { + put: async (/** @type {Request} */ req, /** @type {Response} */ res) => + void m.set(new URL(req.url, 'https://app.test').pathname, res), + keys: async () => [...m.keys()].map((p) => new Request('https://app.test' + p)), + match: async (/** @type {Request} */ req) => m.get(new URL(req.url).pathname)?.clone() + }; + }; + return { stores, open, delete: async (/** @type {string} */ name) => stores.delete(name) }; +} + +/** run static/sw.js in a fake ServiceWorkerGlobalScope and hand back its fetch listener + * @param {any} caches */ +function loadWorker(caches) { + /** @type {Record} */ + const listeners = {}; + const self = { + location: new URL('https://app.test/sw.js'), + addEventListener: (/** @type {string} */ t, /** @type {Function} */ fn) => (listeners[t] = fn), + skipWaiting() {}, + clients: { claim() {} } + }; + let fetched = 0; + const fetchStub = async () => (fetched++, new Response('net')); + new Function('self', 'caches', 'fetch', swSource)(self, caches, fetchStub); + return { fetch: listeners.fetch, fetched: () => fetched }; +} + +/** @param {any} listener @param {Request} request */ +async function dispatch(listener, request) { + /** @type {Promise | null} */ + let answer = null; + listener({ + request, + respondWith: (/** @type {Promise} */ p) => (answer = Promise.resolve(p)) + }); + return /** @type {any} */ (answer); +} + +describe('manifest (60 Q4)', () => { + it('share_target POSTs multipart files to the path the worker takes', () => { + const st = manifest.share_target; + expect(st.action).toBe('/share-target'); + expect(st.method).toBe('POST'); + expect(st.enctype).toBe('multipart/form-data'); + expect(swSource).toContain(`const SHARE_PATH = '${st.action}'`); + const files = st.params.files; + expect(files).toHaveLength(1); + expect(files[0].name).toBe('files'); + expect(swSource).toMatch(/form\s*\.getAll\('files'\)/); + for (const a of [ + 'image/*', + '.glb', + '.gltf', + '.tpscene', + 'model/gltf-binary', + 'model/gltf+json' + ]) + expect(files[0].accept).toContain(a); + }); + it('file_handlers open .tpscene, .glb and .gltf in the app', () => { + const exts = manifest.file_handlers.flatMap((/** @type {any} */ h) => + Object.values(h.accept).flat() + ); + expect(exts.sort()).toEqual(['.glb', '.gltf', '.tpscene']); + for (const h of manifest.file_handlers) expect(h.action.startsWith('/')).toBe(true); + }); + it('launch_handler focuses the window already open', () => { + expect(manifest.launch_handler.client_mode[0]).toBe('focus-existing'); + }); + it('the worker and the app agree on the inbox name and the param', () => { + expect(swSource).toContain(`const SHARE_INBOX = '${SHARE_INBOX}'`); + expect(swSource).toContain(`const SHARE_PARAM = '${SHARE_PARAM}'`); + }); +}); + +describe('sw.js share inbox', () => { + const share = (/** @type {FormData} */ form) => + new Request('https://app.test/share-target', { method: 'POST', body: form }); + + it('parks every shared file and redirects to /?shared=', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const form = new FormData(); + form.append('title', 'look'); + form.append( + 'files', + new File([new Uint8Array([1, 2, 3])], 'chair model.glb', { type: 'model/gltf-binary' }) + ); + form.append('files', new File(['png'], 'ref.png', { type: 'image/png' })); + const res = await dispatch(sw.fetch, share(form)); + expect(res.status).toBe(303); + expect( + new URL(/** @type {string} */ (res.headers.get('Location'))).pathname + + new URL(/** @type {string} */ (res.headers.get('Location'))).search + ).toBe('/?shared=2'); + expect(sw.fetched()).toBe(0); + const inbox = /** @type {Map} */ (caches.stores.get(SHARE_INBOX)); + expect([...inbox.keys()]).toEqual(['/share-inbox/0', '/share-inbox/1']); + const first = /** @type {Response} */ (inbox.get('/share-inbox/0')); + expect(decodeURIComponent(/** @type {string} */ (first.headers.get('X-File-Name')))).toBe( + 'chair model.glb' + ); + expect(new Uint8Array(await first.clone().arrayBuffer())).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('a text-only share opens the app and parks nothing', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const form = new FormData(); + form.append('url', 'https://example.com'); + const res = await dispatch(sw.fetch, share(form)); + expect(res.status).toBe(303); + expect(new URL(/** @type {string} */ (res.headers.get('Location'))).search).toBe(''); + expect(caches.stores.has(SHARE_INBOX)).toBe(false); + }); + + it('a new share replaces an uncollected one', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const a = new FormData(); + a.append('files', new File(['a'], 'a.png')); + a.append('files', new File(['b'], 'b.png')); + await dispatch(sw.fetch, share(a)); + const b = new FormData(); + b.append('files', new File(['c'], 'c.glb')); + await dispatch(sw.fetch, share(b)); + expect([ + .../** @type {Map} */ (caches.stores.get(SHARE_INBOX)).keys() + ]).toEqual(['/share-inbox/0']); + }); + + it('everything else still goes straight to the network (no shell cache)', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + await dispatch(sw.fetch, new Request('https://app.test/')); + await dispatch(sw.fetch, new Request('https://app.test/other', { method: 'POST', body: 'x' })); + expect(sw.fetched()).toBe(2); + expect(caches.stores.size).toBe(0); + }); + + it('the round trip: what the worker parks, the app reads back once', async () => { + const caches = fakeCaches(); + const sw = loadWorker(caches); + const form = new FormData(); + form.append('files', new File(['scene-bytes'], 'Yard.tpscene')); + form.append('files', new File(['img'], 'photo 1.jpg', { type: 'image/jpeg' })); + const res = await dispatch(sw.fetch, share(form)); + const loc = new URL(/** @type {string} */ (res.headers.get('Location'))); + /** @type {string[]} */ + const replaced = []; + const env = { + caches: /** @type {any} */ (caches), + location: /** @type {any} */ ({ href: loc.href + '#peer', search: loc.search }), + history: /** @type {any} */ ({ + state: null, + replaceState: ( + /** @type {any} */ _s, + /** @type {string} */ _t, + /** @type {string} */ url + ) => replaced.push(url) + }) + }; + const files = await readShareInbox(env); + expect(files.map((f) => f.name)).toEqual(['Yard.tpscene', 'photo 1.jpg']); + expect(await files[0].text()).toBe('scene-bytes'); + expect(files[1].type).toBe('image/jpeg'); + expect(replaced).toEqual(['/#peer']); + expect(caches.stores.has(SHARE_INBOX)).toBe(false); + // a second read (a reload with the param still there) finds nothing + expect(await readShareInbox(env)).toEqual([]); + }); + + it('no ?shared= param = the inbox is not touched', async () => { + const caches = fakeCaches(); + await ( + await caches.open(SHARE_INBOX) + ).put(new Request('https://app.test/share-inbox/0'), new Response('x')); + const files = await readShareInbox({ + caches: /** @type {any} */ (caches), + location: /** @type {any} */ ({ href: 'https://app.test/', search: '' }) + }); + expect(files).toEqual([]); + expect(caches.stores.has(SHARE_INBOX)).toBe(true); + }); +}); + +describe('classifyLaunchFiles', () => { + const f = (/** @type {string} */ name, type = '') => ({ name, type }); + const names = (/** @type {any[]} */ xs) => xs.map((x) => x.name); + + it('scenes open, models import, a lone image goes to the library', () => { + const c = classifyLaunchFiles([ + f('Yard.tpscene'), + f('chair.GLB'), + f('tree.gltf'), + f('notes.txt') + ]); + expect(names(c.scenes)).toEqual(['Yard.tpscene']); + expect(names(c.models)).toEqual(['chair.GLB', 'tree.gltf']); + expect(names(c.skipped)).toEqual(['notes.txt']); + const img = classifyLaunchFiles([f('photo.jpg'), f('IMG_0001', 'image/heic')]); + expect(names(img.images)).toEqual(['photo.jpg', 'IMG_0001']); + expect(img.companions).toEqual([]); + }); + it('images WITH a model are its companions (an .obj and its textures)', () => { + const c = classifyLaunchFiles([f('house.obj'), f('house.mtl'), f('bricks.png')]); + expect(names(c.models)).toEqual(['house.obj']); + expect(names(c.companions)).toEqual(['house.mtl', 'bricks.png']); + expect(c.images).toEqual([]); + }); + it('a lone .mtl is not an image and is skipped', () => { + expect(names(classifyLaunchFiles([f('house.mtl')]).skipped)).toEqual(['house.mtl']); + }); + it('nothing in, nothing out', () => { + const c = classifyLaunchFiles([]); + expect(c.scenes.length + c.models.length + c.images.length + c.skipped.length).toBe(0); + }); +}); From 7f5d55f0f12f14d2c0deed9f2c9bf7a9b7f39e27 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 16:44:16 +0300 Subject: [PATCH 2/3] [test] launch-inbox e2e + debug hook (60 Q4) Share POST through the real service worker (image -> Library, .glb -> scene), the restore rule, CDP manifest/installability, launchQueue file launches. Not yet run green: the first run timed out waiting for a slot. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/debugHooks.js | 2 + tests/e2e/launch-inbox.test.cjs | 308 ++++++++++++++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/e2e/launch-inbox.test.cjs diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js index 46aa9ba1b..cdb5dc34d 100644 --- a/src/lib/debugHooks.js +++ b/src/lib/debugHooks.js @@ -62,6 +62,8 @@ export const DEBUG_HOOKS = [ ['perfLiveWire', () => import('./perf/liveWire')], ['vrControls', () => import('./vrControls')], ['autosave', () => import('./autosave')], + // 60 Q4: files shared into the app / opened from the OS (lastLaunch, receiveLaunchFiles, readShareInbox) + ['launchInbox', () => import('./launchInbox.js')], // 36 B14: named checkpoints / the version timeline (checkpoints, checkpointsDebug, saveCheckpoint…) ['checkpoints', () => import('./checkpoints')], ['voiceChat', () => import('./voiceChat')], diff --git a/tests/e2e/launch-inbox.test.cjs b/tests/e2e/launch-inbox.test.cjs new file mode 100644 index 000000000..3c1afdf70 --- /dev/null +++ b/tests/e2e/launch-inbox.test.cjs @@ -0,0 +1,308 @@ +// 60 Q4 — SHARE INTO THE APP, OPEN FILES FROM THE OS. +// +// The OS halves cannot be driven headlessly (Android's share sheet, a desktop double-click), +// so this drives exactly what they hand the browser: +// 1. Chromium's own verdicts over CDP: the manifest parses with no errors and carries +// share_target / file_handlers / launch_handler, and the page is INSTALLABLE (which needs +// the service worker with its fetch handler). +// 2. SHARE = the POST navigation a share sheet makes: a real multipart form submitted to +// /share-target with the files in `files`. The service worker (static/sw.js — registered +// by the app on a PROD build, by this suite on a dev server) parks them and 303s to +// /?shared=; the app collects them once. An image → the Explorer Library; a .glb → an +// object in the scene; ?shared= leaves the address bar; the inbox cache is gone. +// 3. THE RESTORE RULE: a share that boots onto a "Restore previous session?" prompt WAITS — +// nothing is imported while the question is open, and after Restore both the restored +// scene and the shared model are there (imported first, the restore would wipe it). +// 4. FILE HANDLERS = launchQueue: best effort through CDP `PWA.install` + +// `PWA.launchFilesInApp` (reported, not gating — headless support varies), then through +// the app's own launchQueue consumer: a .tpscene OPENS (current scene = its name), and a +// second launch into the same window (focus-existing) imports a .glb INTO it. +// +// LANE_DEV_CMD=… e2e-slot --dev . 5453 -- env APP_URL=https://localhost:5453/ node tests/e2e/launch-inbox.test.cjs +// (a built app: LANE_DEV_CMD='node scripts/serve-build.cjs --port $PORT') +const h = require('./helpers.cjs'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '../..'); +const DOOR = path.join(ROOT, 'tests/e2e/fixtures/test-door.glb'); +const SCENE = path.join(ROOT, 'tests/fixtures/scenes-1.22/fluid-tank-toy.tpscene'); +// a 2×2 PNG +const PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP4z8DwHwyBNAMDA8N/BgYAWwkH+U0xOJMAAAAASUVORK5CYII=', + 'base64' +); + +/** the scene's top-level object names @param {any} page */ +const objectNames = (page) => + page.evaluate(() => { + let names = []; + window.__stores.objectsGroup.subscribe( + (g) => (names = g ? g.children.map((c) => c.name) : []) + )(); + return names; + }); +/** @param {any} page */ +const lastLaunch = (page) => + page.evaluate(async () => (await window.__stores.launchInbox)?.lastLaunch?.value ?? null); +/** @param {any} page */ +const libraryNames = (page) => + page.evaluate(async () => { + const m = await window.__stores.explorer; + let names = []; + m.explorerItems.subscribe((v) => (names = v.map((i) => i.name)))(); + return names; + }); + +/** make sure a service worker controls the page (a dev server never registers one) @param {any} page */ +async function ensureWorker(page) { + return page.evaluate(async () => { + if (!('serviceWorker' in navigator)) return 'none'; + if (navigator.serviceWorker.controller) return 'app'; + await navigator.serviceWorker.register('/sw.js'); + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) + await new Promise((r) => { + navigator.serviceWorker.addEventListener('controllerchange', r, { once: true }); + setTimeout(r, 5000); + }); + return navigator.serviceWorker.controller ? 'suite' : 'none'; + }); +} + +/** + * Share files the way a share sheet does: a multipart POST navigation to /share-target. + * @param {any} page @param {{name: string, mimeType: string, buffer: Buffer}[]} files + */ +async function share(page, files) { + await page.evaluate(() => { + document.getElementById('tp-share-form')?.remove(); + const form = Object.assign(document.createElement('form'), { + id: 'tp-share-form', + method: 'post', + action: '/share-target', + enctype: 'multipart/form-data' + }); + form.innerHTML = + ''; + form.style.cssText = 'position:fixed;left:-9999px'; + document.body.append(form); + }); + await page.locator('#tp-share-form input[type=file]').setInputFiles(files); + await Promise.all([ + page.waitForURL(/[?&]shared=\d+/, { timeout: 30000, waitUntil: 'domcontentloaded' }), + page.evaluate(() => + /** @type {HTMLFormElement} */ (document.getElementById('tp-share-form')).submit() + ) + ]); + await page.waitForFunction(() => window.__stores && !!window.__stores.moduleSDK, null, { + timeout: 90000 + }); +} + +/** wait until `fn` answers truthy (no assertion — the check after it says what failed) + * @param {() => Promise} fn @param {number} timeout */ +async function until(fn, timeout) { + const end = Date.now() + timeout; + while (Date.now() < end) { + if (await fn().catch(() => false)) return true; + await new Promise((r) => setTimeout(r, 400)); + } + return false; +} + +/** @param {any} page */ +const inboxCacheExists = (page) => page.evaluate(() => caches.has('tp-share-inbox')); + +h.run(async () => { + const browser = await h.launch(); + const peer = await h.setupPage(browser, 'A'); + const { page } = peer; + + // ---- 1. Chromium's verdicts --------------------------------------------------------------- + const worker = await ensureWorker(page); + h.check(worker === 'app' || worker === 'suite', `a service worker controls the page (${worker})`); + const cdp = await page.context().newCDPSession(page); + const man = await cdp + .send('Page.getAppManifest') + .catch((e) => ({ errors: [{ message: String(e) }] })); + h.check( + Array.isArray(man.errors) && man.errors.length === 0, + 'the manifest parses with no errors' + + (man.errors?.length ? ': ' + JSON.stringify(man.errors) : '') + ); + const raw = man.data ? JSON.parse(man.data) : {}; + h.check( + raw.share_target?.action === '/share-target' && + raw.file_handlers?.length === 1 && + raw.launch_handler?.client_mode?.[0] === 'focus-existing', + 'served manifest carries share_target, file_handlers, launch_handler' + ); + const inst = await cdp + .send('Page.getInstallabilityErrors') + .catch((e) => ({ installabilityErrors: [{ errorId: String(e) }] })); + h.check( + (inst.installabilityErrors ?? []).length === 0, + 'Chromium says the app is installable' + + (inst.installabilityErrors?.length ? ': ' + JSON.stringify(inst.installabilityErrors) : '') + ); + const appId = await cdp.send('Page.getAppId').catch(() => ({})); + console.log( + ` (manifest id: ${appId.appId ?? '?'}; native launchQueue in a tab: ${await page.evaluate(() => 'launchQueue' in window)})` + ); + + // ---- 2. share an image, then a model ------------------------------------------------------ + await share(page, [{ name: 'shared-photo.png', mimeType: 'image/png', buffer: PNG }]); + await h.answerRestore(peer); + await page + .waitForFunction(async () => (await window.__stores.launchInbox)?.lastLaunch?.value, null, { + timeout: 30000 + }) + .catch(() => {}); + const img = await lastLaunch(page); + h.check(img?.source === 'share' && img?.images === 1, 'a shared image was received once', img); + h.check((await libraryNames(page)).includes('shared-photo.png'), 'it is in the Explorer Library'); + h.check(!/[?&]shared=/.test(page.url()), '?shared= left the address bar: ' + page.url()); + h.check(!(await inboxCacheExists(page)), 'the share inbox cache is gone'); + h.check( + await page + .getByText(/shared-photo\.png is in your Library/) + .first() + .isVisible() + .catch(() => false), + 'a toast says where it went (with Show in Library)' + ); + + await share(page, [ + { name: 'shared-door.glb', mimeType: 'model/gltf-binary', buffer: fs.readFileSync(DOOR) } + ]); + await h.answerRestore(peer, 'dismiss'); + await until(async () => (await objectNames(page)).some((n) => /shared-door/.test(n)), 30000); + const door = await objectNames(page); + h.check( + door.some((n) => /shared-door/.test(n)), + 'a shared .glb is imported into the scene: ' + JSON.stringify(door) + ); + + // ---- 3. the restore rule ------------------------------------------------------------------ + await page.evaluate(async () => (await window.__stores.autosave).saveNow()); + await share(page, [ + { name: 'second-door.glb', mimeType: 'model/gltf-binary', buffer: fs.readFileSync(DOOR) } + ]); + const offered = await page + .waitForFunction( + async () => { + const m = await window.__stores.autosave; + let v = null; + m.restoreAvailable.subscribe((x) => (v = x))(); + return !!v; + }, + null, + { timeout: 20000 } + ) + .then(() => true) + .catch(() => false); + h.check(offered, 'the reload after the share offers "Restore previous session?"'); + await page.waitForTimeout(3000); + const held = { names: await objectNames(page), launch: await lastLaunch(page) }; + h.check( + held.names.length === 0 && held.launch === null, + 'while the question is open NOTHING is imported', + held + ); + await h.answerRestore(peer, 'restore', 20000); + await until(async () => { + const n = await objectNames(page); + return n.some((x) => /second-door/.test(x)) && n.some((x) => /shared-door/.test(x)); + }, 30000); + const after = await objectNames(page); + h.check( + after.some((n) => /shared-door/.test(n)) && after.some((n) => /second-door/.test(n)), + 'after Restore: the restored door AND the newly shared one: ' + JSON.stringify(after) + ); + await until(async () => !!(await lastLaunch(page)), 5000); + h.check((await lastLaunch(page))?.models === 1, 'the share was routed after the answer'); + + // ---- 4. file handlers --------------------------------------------------------------------- + // 4a. best effort: a real install + launch through CDP (Chromium's PWA domain) + try { + const bcdp = await browser.newBrowserCDPSession(); + const manifestId = appId.appId || appId.recommendedId; + await bcdp.send('PWA.install', { manifestId, installUrlOrBundleUrl: h.URL }); + const r = await bcdp.send('PWA.launchFilesInApp', { manifestId, files: [SCENE] }); + console.log( + ` (CDP PWA.install + launchFilesInApp: ok, targets ${JSON.stringify(r.targetIds ?? r)})` + ); + } catch (e) { + console.log( + ` (CDP PWA.install / launchFilesInApp not available here: ${String(e?.message ?? e).split('\n')[0]} — using the app's launchQueue consumer)` + ); + } + + // 4b. the app's launchQueue consumer, as Chromium calls it for an installed app window + const lq = await h.setupPage(browser, 'B', { + init: () => { + /** @type {any} */ (window).__lqConsumers = []; + Object.defineProperty(window, 'launchQueue', { + configurable: true, + value: { + setConsumer: (/** @type {any} */ fn) => /** @type {any} */ (window).__lqConsumers.push(fn) + } + }); + } + }); + await h.answerRestore(lq); + await lq.page + .waitForFunction(() => window.__lqConsumers?.length === 1, null, { timeout: 30000 }) + .catch(() => {}); + h.check( + await lq.page.evaluate(() => window.__lqConsumers?.length === 1), + 'the app set ONE launchQueue consumer' + ); + /** @param {string} name @param {Buffer} buf */ + const launch = (name, buf) => + lq.page.evaluate( + ([n, b64]) => { + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const handle = { kind: 'file', name: n, getFile: async () => new File([bytes], n) }; + window.__lqConsumers[0]({ targetURL: location.href, files: [handle] }); + }, + [name, buf.toString('base64')] + ); + await launch('fluid-tank-toy.tpscene', fs.readFileSync(SCENE)); + await until(async () => (await lastLaunch(lq.page))?.scene === 'fluid-tank-toy.tpscene', 30000); + const level = await lq.page.evaluate(async () => { + const m = await window.__stores.levels; + let v = null; + m.currentLevel.subscribe((x) => (v = x))(); + return v; + }); + const sceneObjects = await objectNames(lq.page); + h.check( + !!level?.name && level.unsaved === true && sceneObjects.length > 0, + `a launched .tpscene OPENS as the current scene ("${level?.name}", unsaved, ${sceneObjects.length} objects)` + ); + await launch('launched-door.glb', fs.readFileSync(DOOR)); + await until(async () => (await objectNames(lq.page)).some((n) => /launched-door/.test(n)), 30000); + const into = await objectNames(lq.page); + h.check( + into.some((n) => /launched-door/.test(n)) && into.length === sceneObjects.length + 1, + 'a second launch into the same window imports the .glb INTO that scene' + ); + const levelAfter = await lq.page.evaluate(async () => { + const m = await window.__stores.levels; + let v = null; + m.currentLevel.subscribe((x) => (v = x))(); + return v?.name; + }); + h.check(levelAfter === level?.name, 'and the scene stayed the current one'); + await launch('notes.txt', Buffer.from('hello')); + await until(async () => (await lastLaunch(lq.page))?.skipped?.[0] === 'notes.txt', 10000); + h.check( + (await objectNames(lq.page)).length === into.length, + 'an unsupported file changes nothing' + ); + + h.check(h.pageErrors(peer).length === 0 && h.pageErrors(lq).length === 0, 'no page errors'); + await h.finish(browser); +}); From 547f91daf9ca41859762a41c1762623c51c9c66a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Fri, 23 Oct 2026 17:27:41 +0300 Subject: [PATCH 3/3] [fix] launch inbox: load the library before an image import; e2e green - a lone shared image no longer pulls in fileHandler (a cold dev-server compile stalled it) - loadExplorer() before importFiles, so the index write cannot replace an unloaded library - a failed route records the error and toasts it; lastLaunch.stage for diagnosis - suite: --ignore-certificate-errors (SW script fetch on the self-signed dev cert), poll with until() instead of an async waitForFunction predicate, wait for the level name Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/launchInbox.js | 33 +++++++++++++++++++++++++-------- tests/e2e/launch-inbox.test.cjs | 33 +++++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/lib/launchInbox.js b/src/lib/launchInbox.js index 7080c99fe..2b664c2ef 100644 --- a/src/lib/launchInbox.js +++ b/src/lib/launchInbox.js @@ -78,7 +78,13 @@ async function bootReady() { * @returns {Promise<{scene: string | null, models: number, images: number, skipped: string[]}>} */ export function receiveLaunchFiles(files, source) { - const run = queue.then(() => route(files, source)); + const run = queue.then(() => + route(files, source).catch((e) => { + lastLaunch.value = { source, error: String(e?.message ?? e) }; + showToast(`Could not open ${files.map((f) => f.name).join(', ')}: ${lastLaunch.value.error}`); + throw e; + }) + ); queue = run.catch(() => {}); return run; } @@ -105,12 +111,15 @@ async function route(files, source) { lastLaunch.value = { source, ...result }; return result; } + lastLaunch.stage = 'boot'; await bootReady(); - const fh = await import('./fileHandler.svelte'); + lastLaunch.stage = 'route'; + // fileHandler is a heavy import; a lone image never needs it + const fh = c.scenes.length || c.models.length ? await import('./fileHandler.svelte') : null; // the scene first: models shared WITH it land in it, not in the world it replaces if (c.scenes.length) { const scene = c.scenes[0]; - await fh.load(scene); + await fh?.load(scene); result.scene = scene.name; if (c.scenes.length > 1) showToast( @@ -118,12 +127,19 @@ async function route(files, source) { ); } if (c.models.length) { - const r = fh.importModelFiles([...c.models, ...c.companions]); - result.models = r.models; + const r = fh?.importModelFiles([...c.models, ...c.companions]); + result.models = r?.models ?? 0; } if (c.images.length) { - const { importFiles, revealItem } = await import('./explorer'); + lastLaunch.stage = 'images'; + const { importFiles, loadExplorer, revealItem } = await import('./explorer'); + // the library index must be in memory first, or the import's index write would + // replace the saved library with just this one file + lastLaunch.stage = 'images:load'; + await loadExplorer(); + lastLaunch.stage = 'images:import'; const created = await importFiles(c.images, null, { duplicates: 'reuse' }); + lastLaunch.stage = 'images:toast'; result.images = created.length; if (created.length) { const first = created[0]; @@ -149,11 +165,12 @@ async function route(files, source) { if (c.skipped.length) showToast('Skipped ' + result.skipped.join(', ') + ' (not a scene, model or image)'); lastLaunch.value = { source, ...result }; + lastLaunch.stage = 'done'; return result; } -/** the last routed launch — read by the e2e debug hook */ -export const lastLaunch = { value: /** @type {any} */ (null) }; +/** the last routed launch, and how far the running one got — read by the e2e debug hook */ +export const lastLaunch = { value: /** @type {any} */ (null), stage: '' }; /** * Collect what the service worker parked for a share, ONCE: every file in the inbox, then diff --git a/tests/e2e/launch-inbox.test.cjs b/tests/e2e/launch-inbox.test.cjs index 3c1afdf70..989437057 100644 --- a/tests/e2e/launch-inbox.test.cjs +++ b/tests/e2e/launch-inbox.test.cjs @@ -115,7 +115,9 @@ async function until(fn, timeout) { const inboxCacheExists = (page) => page.evaluate(() => caches.has('tp-share-inbox')); h.run(async () => { - const browser = await h.launch(); + // the dev server's certificate is self-signed: pages load past it, but a service worker + // script fetch is refused unless the browser itself ignores certificate errors + const browser = await h.launch({ args: ['--ignore-certificate-errors'] }); const peer = await h.setupPage(browser, 'A'); const { page } = peer; @@ -154,14 +156,20 @@ h.run(async () => { // ---- 2. share an image, then a model ------------------------------------------------------ await share(page, [{ name: 'shared-photo.png', mimeType: 'image/png', buffer: PNG }]); await h.answerRestore(peer); - await page - .waitForFunction(async () => (await window.__stores.launchInbox)?.lastLaunch?.value, null, { - timeout: 30000 - }) - .catch(() => {}); + await until(() => lastLaunch(page), 60000); const img = await lastLaunch(page); - h.check(img?.source === 'share' && img?.images === 1, 'a shared image was received once', img); - h.check((await libraryNames(page)).includes('shared-photo.png'), 'it is in the Explorer Library'); + h.check( + img?.source === 'share' && img?.images === 1, + 'a shared image was received once: ' + + JSON.stringify(img) + + ' stage ' + + (await page.evaluate(async () => (await window.__stores.launchInbox)?.lastLaunch?.stage)) + ); + const lib = await libraryNames(page); + h.check( + lib.includes('shared-photo.png'), + 'it is in the Explorer Library: ' + JSON.stringify(lib) + ); h.check(!/[?&]shared=/.test(page.url()), '?shared= left the address bar: ' + page.url()); h.check(!(await inboxCacheExists(page)), 'the share inbox cache is gone'); h.check( @@ -271,6 +279,15 @@ h.run(async () => { ); await launch('fluid-tank-toy.tpscene', fs.readFileSync(SCENE)); await until(async () => (await lastLaunch(lq.page))?.scene === 'fluid-tank-toy.tpscene', 30000); + await until( + () => + lq.page.evaluate(async () => { + let v = null; + (await window.__stores.levels).currentLevel.subscribe((x) => (v = x))(); + return v?.name; + }), + 10000 + ); const level = await lq.page.evaluate(async () => { const m = await window.__stores.levels; let v = null;