diff --git a/docs/electron-desktop-app.md b/docs/electron-desktop-app.md
index d2575ce46..f76edb399 100644
--- a/docs/electron-desktop-app.md
+++ b/docs/electron-desktop-app.md
@@ -70,7 +70,7 @@ electron/
- Editor window: loads the AppShell SPA over a local loopback HTTP server (`src/ElectronApp/src/static-server.ts`) — not `file://` (fetch of `.wasm`/`.dll` assets over `file://` hits CORS/mime-type issues in Chromium) and not a custom protocol. The server binds `127.0.0.1:0` (random free port, same trick as LocalPlayer's own port selection below) and serves the same three-directory layout `deploy-play.yml` already produces for play.questviva.com — `editor/` (AppShell build) at `/`, `AppBundle/` (WasmEditor) at `/AppBundle/`, `player/` (WasmPlayer, Phase 1) or LocalPlayer (Phase 2) at `/player/`.
- Player windows: Phase 1 navigates a second `BrowserWindow` to `http://127.0.0.1:{port}/player/` (bundled WasmPlayer); Phase 2 switches this to LocalPlayer's `http://localhost:{port}/?game={encodedPath}`.
- Phase 1's Preview button needs no IPC round-trip at all: `previewInWasmPlayer()` (`editor-store.ts`) already does a plain `window.open('/player/?source=editor', ...)` and talks to it over `BroadcastChannel('quest-preview')` — both work unchanged against the loopback origin. `editorWindow.webContents.setWindowOpenHandler` in `main.ts` just needs to `{ action: "allow" }` same-origin `/player/` popups (and send anything else to `shell.openExternal`) for that `window.open` call to become the player `BrowserWindow`.
-- **File associations**: `package.json`'s `build.fileAssociations` registers `.aslx` (role `Editor`) and `.quest`/`.asl`/`.cas` (role `Viewer`) with the OS, so double-clicking one of these launches or focuses the app. `.aslx` routes to the editor (`/open?action=open-recent&...`, reusing the same query shape as the native "Open Recent" submenu); `.quest`/`.asl`/`.cas` route to `play/local/+page.svelte`'s Play flow (`/play/local?action=play-file&...`). `app.requestSingleInstanceLock()` plus a `second-instance` handler in `main.ts` make a second double-click while the app is already running focus the existing window instead of spawning another — Windows/Linux pass the file path via argv (cold start: `process.argv`; already-running: the `second-instance` event's argv), macOS via the `open-file` app event (registered before `whenReady()`, since it can fire before `ready`). A cold-start open is folded straight into the editor window's initial URL rather than sent over IPC, since a `webContents.send()` right after constructing the window would race the page's own `onMount` listeners and get dropped; an already-running instance uses IPC (`open-recent-game` / the new `open-play-file` channel) since those listeners are already wired up.
+- **File associations**: `package.json`'s `build.fileAssociations` registers `.aslx` (role `Editor`) and `.quest`/`.asl`/`.cas` (role `Viewer`) with the OS, so double-clicking one of these launches or focuses the app. `.aslx` routes to the editor (`/open?action=open-recent&...`, reusing the same query shape as the native "Open Recent" submenu); `.quest`/`.asl`/`.cas` route to `PlayCatalog.svelte`'s Play flow, on the Play tab's own root URL (`/?action=play-file&...`). `app.requestSingleInstanceLock()` plus a `second-instance` handler in `main.ts` make a second double-click while the app is already running focus the existing window instead of spawning another — Windows/Linux pass the file path via argv (cold start: `process.argv`; already-running: the `second-instance` event's argv), macOS via the `open-file` app event (registered before `whenReady()`, since it can fire before `ready`). A cold-start open is folded straight into the editor window's initial URL rather than sent over IPC, since a `webContents.send()` right after constructing the window would race the page's own `onMount` listeners and get dropped; an already-running instance uses IPC (`open-recent-game` / the new `open-play-file` channel) since those listeners are already wired up.
### contextBridge API (`preload.ts`)
diff --git a/src/AppShell/src/components/LocalFileRecentCard.svelte b/src/AppShell/src/components/LocalFileRecentCard.svelte
new file mode 100644
index 000000000..d89869a60
--- /dev/null
+++ b/src/AppShell/src/components/LocalFileRecentCard.svelte
@@ -0,0 +1,56 @@
+
+
+
diff --git a/src/AppShell/src/lib/electron-types.d.ts b/src/AppShell/src/lib/electron-types.d.ts
index c03760d42..1f75fa97b 100644
--- a/src/AppShell/src/lib/electron-types.d.ts
+++ b/src/AppShell/src/lib/electron-types.d.ts
@@ -67,12 +67,12 @@ interface ElectronMenuApi {
// id: catalog game (textadventures.co.uk id); omitted: a locally-picked
// file, whose bytes/resources the caller hands over separately via the
// 'quest-play-local' BroadcastChannel — see ipc/player.ts and
-// routes/play/local/+page.svelte.
+// components/PlayCatalog.svelte.
interface ElectronPlayerApi {
openWindow(request?: { id?: string }): Promise;
// Fired by a file-association open of a play-kind file (.quest/.asl/.cas)
// while a window already exists — see ElectronApp's main.ts
- // (routeOpenedFile) and preload.ts. play/local/+page.svelte's listener
+ // (routeOpenedFile) and preload.ts. PlayCatalog.svelte's listener
// loads the file and launches a player window for it via
// playElectronFile, same as its own file-picker/Recently Played flows.
onOpenPlayFile(callback: (file: { dirPath: string; filename: string }) => void): () => void;
diff --git a/src/AppShell/src/lib/filesystem/electron-adapter.ts b/src/AppShell/src/lib/filesystem/electron-adapter.ts
index 27bb1ebed..74246afd0 100644
--- a/src/AppShell/src/lib/filesystem/electron-adapter.ts
+++ b/src/AppShell/src/lib/filesystem/electron-adapter.ts
@@ -52,9 +52,10 @@ export interface RecentGame {
// Mirrors ElectronRecentKind. "edit" (the default below) is every editor
// entry point — open/create/save-as, all in this file; "play" is only
-// play/local/+page.svelte's file picker. Kept as separate lists on the main-process
-// side (see ElectronApp's recent-games.ts) precisely so a game opened to play
-// never shows up in File > Open Recent (which loads into the editor).
+// PlayCatalog.svelte's file picker (via local-play.ts's playElectronFile).
+// Kept as separate lists on the main-process side (see ElectronApp's
+// recent-games.ts) precisely so a game opened to play never shows up in
+// File > Open Recent (which loads into the editor).
export type RecentKind = "edit" | "play";
export async function listRecentGames(kind: RecentKind = "edit"): Promise {
@@ -93,7 +94,7 @@ export class ElectronFileAdapter implements FileAdapter {
// Resource names reaching getAsset() can be attacker-controlled — a
// downloaded game's own /image/sound references, forwarded
// here verbatim from the WasmPlayer 'resource-request' handoff (see
- // play/local/+page.svelte and editor-store.ts's previewInWasmPlayer). window
+ // local-play.ts and editor-store.ts's previewInWasmPlayer). window
// .electronApp.path.join is a plain string join with no traversal
// protection (see preload.ts), so without this a name like
// "../../../../.ssh/id_rsa" would resolve outside dirPath and getAsset
@@ -173,7 +174,7 @@ export async function openElectronFile(): Promise<{ dirPath: string; filename: s
// Broader than ASLX_FILTER above (which is Save/SaveAs-scoped, and the
// editor only ever opens unpacked .aslx) — Play accepts every format
// WasmPlayer itself can boot, matching the browser build's pickFile(".quest,
-// .aslx,.asl,.cas") in play/local/+page.svelte.
+// .aslx,.asl,.cas") in PlayCatalog.svelte.
const PLAY_FILTER = [{ name: "Quest game files", extensions: ["quest", "aslx", "asl", "cas"] }];
export async function openElectronPlayFile(): Promise<{ dirPath: string; filename: string } | null> {
@@ -182,7 +183,7 @@ export async function openElectronPlayFile(): Promise<{ dirPath: string; filenam
return { dirPath: dirname(filePath), filename: basename(filePath) };
}
-// kind defaults to "edit" for the /open (editor) callers; play/local/+page.svelte
+// kind defaults to "edit" for the /open (editor) callers; local-play.ts
// passes "play" explicitly so a game opened to play tracks into its own
// Recently Played list instead of the editor's Recent — see RecentKind above.
export async function loadElectronFile(
diff --git a/src/AppShell/src/lib/filesystem/local-play.ts b/src/AppShell/src/lib/filesystem/local-play.ts
new file mode 100644
index 000000000..527930549
--- /dev/null
+++ b/src/AppShell/src/lib/filesystem/local-play.ts
@@ -0,0 +1,61 @@
+import { loadElectronFile, openElectronPlayFile } from "./electron-adapter";
+
+function blobToDataUrl(blob: Blob): Promise {
+ return new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.readAsDataURL(blob);
+ });
+}
+
+// Singleton, not per-caller — only one channel is ever "the current answerer" at a time
+// regardless of which of PlayCatalog.svelte's call sites (the manual toolbar click, a
+// Recently Played replay, or the file-association effect) most recently launched a game,
+// so "close the previous one before opening a new one" only needs one shared slot.
+let playChannel: BroadcastChannel | null = null;
+
+export function closeLocalPlayChannel(): void {
+ playChannel?.close();
+ playChannel = null;
+}
+
+// Loads dirPath/filename's bytes, opens a dedicated Electron player window (see
+// ipc/player.ts's player:openWindow), and answers that window's game/resource-request
+// handoff over BroadcastChannel("quest-play-local") for as long as it stays open. Throws
+// on failure — the caller (PlayCatalog.svelte) owns its own busy/error UI around this call.
+export async function playElectronFile(dirPath: string, filename: string, onPlayed?: () => void): Promise {
+ const { bytes, adapter } = await loadElectronFile(dirPath, filename, "play");
+ onPlayed?.();
+
+ closeLocalPlayChannel();
+ const bc = new BroadcastChannel("quest-play-local");
+ playChannel = bc;
+ bc.onmessage = async ({ data }) => {
+ if (data.type === "ready") {
+ bc.postMessage({ type: "game", bytes, filename });
+ } else if (data.type === "resource-request") {
+ const blob = await adapter.getAsset(data.name);
+ if (blob) {
+ const dataUrl = await blobToDataUrl(blob);
+ bc.postMessage({ type: "resource-response", id: data.id, dataUrl });
+ }
+ }
+ };
+
+ const opened = await window.electronApp!.player.openWindow();
+ if (!opened) {
+ bc.close();
+ playChannel = null;
+ throw new Error("Couldn't open the player window.");
+ }
+}
+
+// Returns false only when the user cancelled the native file picker — a real failure (e.g.
+// playElectronFile's "couldn't open the player window") throws instead, so callers can
+// tell "user backed out" apart from "something went wrong".
+export async function pickAndPlayElectronFile(onPlayed?: () => void): Promise {
+ const picked = await openElectronPlayFile();
+ if (!picked) return false;
+ await playElectronFile(picked.dirPath, picked.filename, onPlayed);
+ return true;
+}
diff --git a/src/AppShell/src/lib/recent-catalog-plays.ts b/src/AppShell/src/lib/recent-catalog-plays.ts
new file mode 100644
index 000000000..ce0355307
--- /dev/null
+++ b/src/AppShell/src/lib/recent-catalog-plays.ts
@@ -0,0 +1,51 @@
+import type { CatalogGame } from "./home-catalog";
+
+export interface RecentCatalogPlay extends CatalogGame {
+ lastPlayed: number;
+}
+
+const STORAGE_KEY = "questviva-recent-catalog-plays";
+const MAX_ENTRIES = 12;
+
+function readAll(): RecentCatalogPlay[] {
+ if (typeof localStorage === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+function writeAll(entries: RecentCatalogPlay[]): void {
+ if (typeof localStorage === "undefined") return;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
+ } catch {
+ // ignore — recent-plays tracking is a convenience, never worth failing the actual play over
+ }
+}
+
+// Already stored sorted most-recent-first (see recordCatalogPlay), so this is a plain read.
+export function listRecentCatalogPlays(): RecentCatalogPlay[] {
+ return readAll();
+}
+
+// Best-effort, mirrors electron-adapter.ts's trackRecent — a tracking failure must never
+// surface as an error on the play it followed. Dedupes by id (a replay bumps lastPlayed
+// and moves back to the front rather than creating a second entry).
+export function recordCatalogPlay(game: CatalogGame): void {
+ try {
+ const rest = readAll().filter((entry) => entry.id !== game.id);
+ const entries = [{ ...game, lastPlayed: Date.now() }, ...rest].slice(0, MAX_ENTRIES);
+ writeAll(entries);
+ } catch {
+ // ignore
+ }
+}
+
+export function removeRecentCatalogPlay(id: string): void {
+ writeAll(readAll().filter((entry) => entry.id !== id));
+}
diff --git a/src/AppShell/src/routes/+layout.svelte b/src/AppShell/src/routes/+layout.svelte
index 166a3d0f3..09db5384e 100644
--- a/src/AppShell/src/routes/+layout.svelte
+++ b/src/AppShell/src/routes/+layout.svelte
@@ -113,13 +113,13 @@
});
// A play-kind file association (.quest/.asl/.cas) opened while this
// window already exists (ElectronApp's main.ts routeOpenedFile, via
- // preload.ts's player.onOpenPlayFile) — routed to /play/local with
- // the same query shape a cold-start open uses (see main.ts's
- // initialUrlPath), so that page's own $effect handles both. Never
- // touches the editor's own loaded game, so no saveGame() flush first.
+ // preload.ts's player.onOpenPlayFile) — routed to root with the same
+ // query shape a cold-start open uses (see main.ts's initialUrlPath),
+ // so PlayCatalog.svelte's own $effect handles both. Never touches
+ // the editor's own loaded game, so no saveGame() flush first.
const unsubscribePlayFile = window.electronApp!.player.onOpenPlayFile((file) => {
const query = `?action=play-file&dir=${encodeURIComponent(file.dirPath)}&file=${encodeURIComponent(file.filename)}&t=${Date.now()}`;
- void goto(`${base}/play/local${query}`);
+ void goto(`${rootPath}${query}`);
});
return () => {
unsubscribeAction();
diff --git a/src/AppShell/src/routes/play/[id]/+page.svelte b/src/AppShell/src/routes/play/[id]/+page.svelte
index 773347e27..e9a89bd0f 100644
--- a/src/AppShell/src/routes/play/[id]/+page.svelte
+++ b/src/AppShell/src/routes/play/[id]/+page.svelte
@@ -4,6 +4,7 @@
import { base } from "$app/paths";
import { fetchGameDetails, languageName, type GameDetails } from "$lib/home-catalog";
import { isElectron } from "$lib/runtime";
+ import { recordCatalogPlay } from "$lib/recent-catalog-plays";
const isElectronApp = isElectron();
@@ -17,11 +18,21 @@
// window's own window.electronApp (fs/dialog access) straight into a
// downloaded, third-party game's page, and Quest games can eval their
// own resources (see wasm-player.js's WebPlayer.runJs).
+ //
+ // recordCatalogPlay is the single write site for "recently played" catalog
+ // games (see the Play tab's home page) — called here, not on page load,
+ // since only an actual Play click should count.
function handleElectronPlay() {
if (!details) return;
+ recordCatalogPlay(details);
void window.electronApp!.player.openWindow({ id: details.id });
}
+ function handleBrowserPlay() {
+ if (!details) return;
+ recordCatalogPlay(details);
+ }
+
onMount(async () => {
const id = page.params.id;
if (!id) {
@@ -85,7 +96,11 @@
{#if isElectronApp}
{:else}
- Play
+ Play
{/if}
View on textadventures.co.uk
diff --git a/src/AppShell/src/routes/play/local/+page.svelte b/src/AppShell/src/routes/play/local/+page.svelte
deleted file mode 100644
index 0671352bc..000000000
--- a/src/AppShell/src/routes/play/local/+page.svelte
+++ /dev/null
@@ -1,286 +0,0 @@
-
-
-
-
- {#each recentPlayed as game (game.dirPath + "/" + game.filename)}
-
-
-
-
- {/each}
-
-
- {/if}
-
-
-
diff --git a/src/ElectronApp/src/ipc/player.ts b/src/ElectronApp/src/ipc/player.ts
index 61bd258ae..56884ae7c 100644
--- a/src/ElectronApp/src/ipc/player.ts
+++ b/src/ElectronApp/src/ipc/player.ts
@@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow, dialog, shell } from "electron";
export interface PlayerWindowRequest {
// Catalog game (textadventures.co.uk id) vs. a locally-picked file whose
- // bytes/resource-request handling play/local/+page.svelte hands over the
+ // bytes/resource-request handling PlayCatalog.svelte hands over the
// BroadcastChannel once this window signals 'ready' — see wasm-player.js's
// `source=local` boot branch.
id?: string;
@@ -17,7 +17,7 @@ export interface PlayerWindowRequest {
// executed via eval (see wasm-player.js's WebPlayer.runJs) — an untrusted
// trust boundary that must never get window.electronApp's fs/dialog bridge.
// A locally-picked game's sibling resources are instead resolved by
-// play/local/+page.svelte (which *does* have fs access) answering
+// PlayCatalog.svelte (which *does* have fs access) answering
// 'resource-request' messages over the same BroadcastChannel used to hand
// over the initial game bytes, exactly like the existing editor-preview path.
export function registerPlayerHandlers(getOrigin: () => string | null): void {
diff --git a/src/ElectronApp/src/main.ts b/src/ElectronApp/src/main.ts
index b1172735e..36603915a 100644
--- a/src/ElectronApp/src/main.ts
+++ b/src/ElectronApp/src/main.ts
@@ -192,9 +192,9 @@ function sendOpenRecentGame(game: RecentGame): void {
}
// Same delivery as sendOpenRecentGame, for a file-association open of a
-// play-kind file (.quest/.asl/.cas) — play/local/+page.svelte's
-// onOpenPlayFile listener (see preload.ts) launches a player window for it,
-// the same as its own file-picker/Recently Played flows.
+// play-kind file (.quest/.asl/.cas) — PlayCatalog.svelte's onOpenPlayFile
+// listener (see preload.ts) launches a player window for it, the same as
+// its own file-picker/Recently Played flows.
function sendOpenPlayFile(file: { dirPath: string; filename: string }): void {
focusEditorWindow();
editorWindow?.webContents.send("open-play-file", file);
@@ -202,9 +202,9 @@ function sendOpenPlayFile(file: { dirPath: string; filename: string }): void {
// .aslx opens the editor (it's the unpacked source format the editor works
// with); .quest/.asl/.cas launch the player directly — matches the split
-// play/local/+page.svelte/electron-adapter.ts already draw between the
-// editor's Open (ASLX_FILTER, .aslx only) and Play's file picker
-// (PLAY_FILTER, all four).
+// PlayCatalog.svelte/electron-adapter.ts already draw between the editor's
+// Open (ASLX_FILTER, .aslx only) and Play's file picker (PLAY_FILTER, all
+// four).
const PLAY_EXTENSIONS = new Set([".quest", ".asl", ".cas"]);
const GAME_EXTENSIONS = new Set([".aslx", ...PLAY_EXTENSIONS]);
@@ -244,8 +244,8 @@ function routeOpenedFile(filePath: string): void {
// URL rather than delivered over IPC (see routeOpenedFile's comment) — both
// query shapes are already handled by the target page: /open?action=
// open-recent&... by open/+page.svelte (shared with the native "Open Recent"
-// menu), /play/local?action=play-file&... by play/local/+page.svelte
-// (mirrors it for Play).
+// menu), /?action=play-file&... by PlayCatalog.svelte (mirrors it for Play —
+// root is always the Play tab in this app, PUBLIC_SHOW_HOME is always true here).
function initialUrlPath(filePath: string | null): string {
if (!filePath) return "/";
const ext = path.extname(filePath).toLowerCase();
@@ -256,7 +256,7 @@ function initialUrlPath(filePath: string | null): string {
return `/open?action=open-recent&dir=${encodeURIComponent(dirPath)}&file=${encodeURIComponent(filename)}&t=${t}`;
}
if (PLAY_EXTENSIONS.has(ext)) {
- return `/play/local?action=play-file&dir=${encodeURIComponent(dirPath)}&file=${encodeURIComponent(filename)}&t=${t}`;
+ return `/?action=play-file&dir=${encodeURIComponent(dirPath)}&file=${encodeURIComponent(filename)}&t=${t}`;
}
return "/";
}
diff --git a/src/ElectronApp/src/preload.ts b/src/ElectronApp/src/preload.ts
index a417dbcc3..01b3de14a 100644
--- a/src/ElectronApp/src/preload.ts
+++ b/src/ElectronApp/src/preload.ts
@@ -127,8 +127,8 @@ contextBridge.exposeInMainWorld("electronApp", {
// Fired when the OS launches or relaunches the app via a play-kind
// file association (.quest/.asl/.cas) while a window already exists
// — main.ts's routeOpenedFile sends this instead of focusing straight
- // into a player window, since play/local/+page.svelte needs to load
- // the file's bytes first (see its onOpenPlayFile listener). Cold-start
+ // into a player window, since PlayCatalog.svelte needs to load the
+ // file's bytes first (see its onOpenPlayFile listener). Cold-start
// opens don't use this channel at all — see main.ts's initialUrlPath.
onOpenPlayFile: (callback: (file: { dirPath: string; filename: string }) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, file: { dirPath: string; filename: string }) => callback(file);
diff --git a/src/WasmPlayer/wasm-player.js b/src/WasmPlayer/wasm-player.js
index fb27fb4db..07b501106 100644
--- a/src/WasmPlayer/wasm-player.js
+++ b/src/WasmPlayer/wasm-player.js
@@ -1713,7 +1713,7 @@ function showFileProtocolError() {
// The AppShell tab that was going to hand over game bytes (see the
// `source=local` boot branch below) turned out not to be there to answer —
// closed, navigated away, or superseded by a newer Start click in that same
-// tab (see play/local/+page.svelte's handleBrowserStart, which closes its previous
+// tab (see PlayCatalog.svelte's handleBrowserStart, which closes its previous
// channel before opening a new one). Without this, that's a silent infinite
// loading spinner with no way out; this at least gets the user back to a
// working picker in the same tab.
@@ -1897,7 +1897,7 @@ async function fetchGameBytes(url) {
return;
}
- // AppShell's Play tab (see play/local/+page.svelte) — the user already picked
+ // AppShell's Play tab (see PlayCatalog.svelte) — the user already picked
// a file and clicked Start (browser build) or the window just got opened
// straight from the file picker (Electron, see ipc/player.ts), so the
// game bytes are sitting in that tab's memory. Same handoff as the editor
@@ -1905,7 +1905,7 @@ async function fetchGameBytes(url) {
// build a raw picked File has nothing to answer those with (so they just
// go unanswered — fine for a self-contained .quest package, which is all
// the plain file-input path in wireStartScreen() below ever supported
- // either), but Electron's play/local/+page.svelte backs the picked file with a
+ // either), but Electron's local-play.ts backs the picked file with a
// real ElectronFileAdapter and answers them from disk, exactly like
// editor-store.ts's previewInWasmPlayer does for the live editor. A
// distinct channel name keeps this from cross-talking with a real
diff --git a/tests/e2e/verify-electron-play-local-file.mjs b/tests/e2e/verify-electron-play-local-file.mjs
index 4fd9b6561..22c2c61a3 100644
--- a/tests/e2e/verify-electron-play-local-file.mjs
+++ b/tests/e2e/verify-electron-play-local-file.mjs
@@ -1,5 +1,5 @@
// Ad-hoc manual verification for Electron's Play tab "Open a game file…"
-// flow (PlayCatalog.svelte's handleElectronPlay): a single click on the
+// flow (PlayCatalog.svelte's handleOpenLocal): a single click on the
// button should pick a file via the native dialog *and* launch it — no
// separate "Start" click (unlike the browser build, which needs one to get a
// fresh activation for its own window.open()). The launched player window is
@@ -51,15 +51,23 @@ try {
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [fp] });
}, aslxPath);
- // Root is the Play tab (PUBLIC_SHOW_HOME=true, see electron.sh).
+ // Root is the Play tab (PUBLIC_SHOW_HOME=true, see electron.sh) — there's
+ // no separate /play/local route at all; the toolbar button calls
+ // pickAndPlayElectronFile() inline (see PlayCatalog.svelte's
+ // handleOpenLocal / lib/filesystem/local-play.ts).
await win.waitForSelector('button:has-text("Open a game file…")', { timeout: 30000 });
console.log('[editor] Play tab loaded, "Open a game file…" button present');
+ const editorUrlBeforeOpen = win.url();
const [playerWindow] = await Promise.all([
app.waitForEvent('window'),
win.click('button:has-text("Open a game file…")'),
]);
console.log('[editor] single click opened a new player window — no separate Start click needed');
+ if (win.url() !== editorUrlBeforeOpen) {
+ throw new Error(`Editor window navigated away instead of opening inline (${editorUrlBeforeOpen} -> ${win.url()})`);
+ }
+ console.log('PASS: editor window stayed on the Play tab — no navigation away for the manual click');
playerWindow.on('pageerror', err => console.log('[player] [pageerror]', err.message));
playerWindow.on('console', msg => { if (msg.type() === 'error') console.log('[player] [console.error]', msg.text()); });
@@ -108,6 +116,27 @@ try {
console.log('PASS: editor window itself did not navigate (still fs-bridge-capable, but never sees game content)');
await catalogPlayerWindow.close();
+ // File-association deep link (?action=play-file&dir=&file=&t=) — both
+ // main.ts's initialUrlPath (cold start) and +layout.svelte's
+ // onOpenPlayFile (warm window) now land this on root instead of the old
+ // /play/local, since PlayCatalog.svelte's own $effect handles it there.
+ // Navigating the editor window straight to that URL exercises the
+ // renderer-side effect directly, without needing a real OS
+ // file-association trigger.
+ const playFileUrl = `${new URL(win.url()).origin}/?action=play-file&dir=${encodeURIComponent(gameDir)}&file=${encodeURIComponent('restart-test.aslx')}&t=${Date.now()}`;
+ const [assocPlayerWindow] = await Promise.all([
+ app.waitForEvent('window'),
+ win.goto(playFileUrl),
+ ]);
+ console.log('PASS: action=play-file landing on root opened a player window:', assocPlayerWindow.url());
+ if (win.url() !== playFileUrl) {
+ throw new Error(`Editor window ended up somewhere unexpected: ${win.url()}`);
+ }
+ assocPlayerWindow.on('pageerror', err => console.log('[player] [pageerror]', err.message));
+ await assocPlayerWindow.waitForFunction(() => document.title === 'Simple', null, { timeout: 15000 });
+ console.log('PASS: file-association game booted, document.title:', await assocPlayerWindow.title());
+ await assocPlayerWindow.close();
+
console.log('PASS: all checks passed');
} catch (err) {
console.error('FAIL:', err.message);