From 24925cec394d3fd4db7d63a84b545091a06ea0e8 Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Sat, 8 Aug 2026 10:58:00 +0100 Subject: [PATCH] feat(AppShell): unify Recently Played across catalog and local files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Play tab had no recency tracking for textadventures.co.uk catalog games (only local Electron files had one), and the local-file picker lived on its own /play/local page whose two-pane layout was mostly just a lone button — pointless on the browser build, and redundant with the toolbar on Electron. - Catalog plays are now tracked client-side (recent-catalog-plays.ts) and merged with Electron's existing local-file recents into one "Recently played" section on the main Play page, rendered as one card grid (RecentGameCard / LocalFileRecentCard). - /play/local is gone entirely: Electron's manual open and the ?action=play-file file-association deep link, and the browser's two-step pick-then-Start flow, all now live inline in PlayCatalog.svelte. This reverts the routing decision from an earlier commit that had split the file-association handler out to its own page. - Extracted the Electron play-launch logic into local-play.ts so the toolbar button, Recently Played replays, and the file-association effect share one implementation instead of duplicating it. - Local-file recent cards match the catalog cards' shape (icon placeholder instead of a thumbnail) rather than sitting in a separate list of rows. Co-Authored-By: Claude Sonnet 5 --- docs/electron-desktop-app.md | 2 +- .../src/components/LocalFileRecentCard.svelte | 56 ++++ .../src/components/PlayCatalog.svelte | 240 ++++++++++++++- .../src/components/RecentGameCard.svelte | 87 ++++++ src/AppShell/src/lib/electron-types.d.ts | 4 +- .../src/lib/filesystem/electron-adapter.ts | 13 +- src/AppShell/src/lib/filesystem/local-play.ts | 61 ++++ src/AppShell/src/lib/recent-catalog-plays.ts | 51 ++++ src/AppShell/src/routes/+layout.svelte | 10 +- .../src/routes/play/[id]/+page.svelte | 17 +- .../src/routes/play/local/+page.svelte | 286 ------------------ src/ElectronApp/src/ipc/player.ts | 4 +- src/ElectronApp/src/main.ts | 18 +- src/ElectronApp/src/preload.ts | 4 +- src/WasmPlayer/wasm-player.js | 6 +- tests/e2e/verify-electron-play-local-file.mjs | 33 +- 16 files changed, 570 insertions(+), 322 deletions(-) create mode 100644 src/AppShell/src/components/LocalFileRecentCard.svelte create mode 100644 src/AppShell/src/components/RecentGameCard.svelte create mode 100644 src/AppShell/src/lib/filesystem/local-play.ts create mode 100644 src/AppShell/src/lib/recent-catalog-plays.ts delete mode 100644 src/AppShell/src/routes/play/local/+page.svelte 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/components/PlayCatalog.svelte b/src/AppShell/src/components/PlayCatalog.svelte index 751b300be..d77b45dd0 100644 --- a/src/AppShell/src/components/PlayCatalog.svelte +++ b/src/AppShell/src/components/PlayCatalog.svelte @@ -1,11 +1,18 @@ + +{#snippet cardBody()} +
+ {#if game.cover || game.thumbnail} + + {/if} +
+
+
{game.name}
+ {#if game.author} +
by {game.author}
+ {/if} +
+ + {game.isGamebook ? "Gamebook" : "Text Adventure"} + + {#if game.language !== "en"} + + {languageName(game.language)} + + {/if} +
+ {#if game.rating > 0} +
{ratingStars(game.rating)}
+ {/if} +
+{/snippet} + +
+ {#if isElectronApp} + + {:else} + + {@render cardBody()} + + {/if} + +
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 @@ - - - -
-
- ← Back to Play - - -
-
- {#if isElectronApp} - - {#if electronError} -

{electronError}

- {/if} - {:else if !pickedFile} - - {:else} -
- {pickedFile.name} - - -
- {/if} - {#if !isElectronApp && pickError} -

{pickError}

- {/if} - {#if !isElectronApp && startError} -

{startError}

- {/if} -
- - {#if isElectronApp && recentPlayed.length > 0} -
-

Recently played

-
- {#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);