Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
40 changes: 35 additions & 5 deletions src/lib/autosave.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | null> | 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');
Expand All @@ -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();
}
}

Expand Down Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/lib/debugHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')],
Expand Down
246 changes: 246 additions & 0 deletions src/lib/launchInbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// 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=<n>. `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).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;
}
/** @type {Promise<any>} */
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;
}
lastLaunch.stage = 'boot';
await bootReady();
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);
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 ?? 0;
}
if (c.images.length) {
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];
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 };
lastLaunch.stage = 'done';
return result;
}

/** 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
* 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<File[]>}
*/
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');
});
}
}
27 changes: 27 additions & 0 deletions static/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading
Loading