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
2 changes: 1 addition & 1 deletion docs/electron-desktop-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
56 changes: 56 additions & 0 deletions src/AppShell/src/components/LocalFileRecentCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<script lang="ts">
import type { RecentGame } from "$lib/filesystem/electron-adapter";
import Gamepad2 from "@lucide/svelte/icons/gamepad-2";

// Electron only — there's no browser equivalent (see PlayCatalog.svelte),
// so unlike RecentGameCard this never needs a non-Electron branch.
let { game, onplay, onremove }: { game: RecentGame; onplay: () => void; onremove: () => void } = $props();

function folderName(dirPath: string): string {
return dirPath.split(/[\\/]/).pop() || dirPath;
}

function relativeTime(ms: number): string {
const mins = Math.round((Date.now() - ms) / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.round(hours / 24)}d ago`;
}

function handleRemove(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
onremove();
}
</script>

<div class="relative">
<button
type="button"
onclick={onplay}
class="flex flex-col w-full text-left rounded-lg border border-surface-800 overflow-hidden hover:border-primary-500 transition-colors"
>
<div class="aspect-[3/4] bg-surface-800 flex items-center justify-center overflow-hidden">
<Gamepad2 size={40} class="text-surface-600" />
</div>
<div class="p-2 flex flex-col gap-1">
<div class="text-sm font-semibold truncate">{game.filename}</div>
<div class="text-xs text-surface-400 truncate">{folderName(game.dirPath)}</div>
<div class="text-xs text-surface-500">{relativeTime(game.lastOpened)}</div>
</div>
</button>
<button
type="button"
class="absolute top-1 right-1 flex items-center justify-center size-6 rounded-full bg-surface-950/80 text-surface-300 hover:text-error-500 transition-colors"
title="Remove from Recently Played"
aria-label="Remove from Recently Played"
onclick={handleRemove}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
</div>
240 changes: 237 additions & 3 deletions src/AppShell/src/components/PlayCatalog.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, onDestroy } from "svelte";
import { base } from "$app/paths";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { fetchCatalog, type CatalogCategory, type UpdateInfo } from "$lib/home-catalog";
import { isElectron } from "$lib/runtime";
import { listRecentGames, removeRecentGame, type RecentGame } from "$lib/filesystem/electron-adapter";
import { playElectronFile, pickAndPlayElectronFile, closeLocalPlayChannel } from "$lib/filesystem/local-play";
import { pickFile } from "$lib/filesystem/file-picker";
import { listRecentCatalogPlays, removeRecentCatalogPlay, type RecentCatalogPlay } from "$lib/recent-catalog-plays";
import UpdateBanner from "$components/UpdateBanner.svelte";
import GameCard from "$components/GameCard.svelte";
import RecentGameCard from "$components/RecentGameCard.svelte";
import LocalFileRecentCard from "$components/LocalFileRecentCard.svelte";
import ChevronDown from "@lucide/svelte/icons/chevron-down";

const isElectronApp = isElectron();
Expand All @@ -29,7 +36,185 @@
}
}

onMount(load);
// Recently played — merged from two sources: catalog games (tracked client-side in
// recent-catalog-plays.ts, works everywhere) and local files (Electron only — tracked
// via electron-adapter.ts's "play"-kind RecentGame list; there's no persistent file
// handle across sessions in a plain browser to track this with). Both render as cards
// in the same grid (RecentGameCard / LocalFileRecentCard) — catalog entries first,
// local files after, not interleaved by timestamp (the two lists come from unrelated
// sources with no shared ordering guarantee worth relying on).
let recentCatalogPlays = $state<RecentCatalogPlay[]>([]);
let recentLocalPlays = $state<RecentGame[]>([]);

function refreshRecentCatalogPlays() {
recentCatalogPlays = listRecentCatalogPlays();
}

async function refreshRecentLocalPlays() {
if (!isElectronApp) return;
recentLocalPlays = await listRecentGames("play");
}

function handleRemoveRecentCatalog(id: string) {
removeRecentCatalogPlay(id);
refreshRecentCatalogPlays();
}

async function handleRemoveRecentLocal(game: RecentGame) {
await removeRecentGame(game.dirPath, game.filename, "play");
await refreshRecentLocalPlays();
}

// Electron: a single click launches the game — no second "Start" click needed,
// because the player window is created by the *main* process (see ipc/player.ts's
// player:openWindow), not by this renderer's own window.open(). Renderer-driven
// window.open() is what forces the browser build's two-click flow below (a native
// file dialog and a script-driven window.open() can't share one click's activation
// grant — see handleBrowserStart); main-process window creation isn't subject to
// that at all, so there's no separate destination page needed here either way.
let electronOpenBusy = $state(false);
let electronOpenError = $state<string | null>(null);

async function handleOpenLocal() {
electronOpenError = null;
electronOpenBusy = true;
try {
await pickAndPlayElectronFile(refreshRecentLocalPlays);
} catch (err) {
electronOpenError = String(err);
} finally {
electronOpenBusy = false;
}
}

async function playLocalRecent(game: RecentGame) {
electronOpenError = null;
electronOpenBusy = true;
try {
await playElectronFile(game.dirPath, game.filename, refreshRecentLocalPlays);
} catch (err) {
electronOpenError = String(err);
} finally {
electronOpenBusy = false;
}
}

// Electron: a file-association open of a play-kind file (.quest/.asl/.cas) lands
// here as a query string — either baked into this window's initial URL for a cold
// start (see ElectronApp's main.ts, initialUrlPath) or via a goto() from
// +layout.svelte's onOpenPlayFile listener once this page is already mounted. Same
// nonce-guarded pattern as open/+page.svelte's action=open-recent effect, so a
// repeat firing on the same file (nonce unchanged) doesn't relaunch the player.
// There's no user gesture here to attach a picker button to, so this has to be a
// effect rather than a click handler — but it needs no page of its own, root is
// always mounted when Electron is running (PUBLIC_SHOW_HOME is always true there).
let handledPlayNonce = "";
$effect(() => {
if (!isElectronApp) return;
const params = page.url.searchParams;
if (params.get("action") !== "play-file") return;
const nonce = params.get("t") ?? "";
if (nonce === handledPlayNonce) return;
const dirPath = params.get("dir");
const filename = params.get("file");
if (!dirPath || !filename) return;
handledPlayNonce = nonce;
electronOpenError = null;
electronOpenBusy = true;
void playElectronFile(dirPath, filename, refreshRecentLocalPlays)
.catch((err) => { electronOpenError = String(err); })
.finally(() => { electronOpenBusy = false; });
});

// ── Browser build only (isElectronApp false) ────────────────────────────
// Two deliberate clicks, not one click-through-then-another: picking the file and
// starting the game are each their own genuine user gesture, so each gets its own
// fresh browser activation — a single click can't do both (see handleBrowserStart)
// because a native file dialog and a script-driven window.open() fight over the
// same click's single-use activation grant.
let pickedFile = $state<File | null>(null);
let pickedBytes = $state<Uint8Array | null>(null);
let pickError = $state<string | null>(null);
let starting = $state(false);
let startError = $state<string | null>(null);

// Kept across handoffs (not just a local var) so a new one can close the previous —
// otherwise a stale channel would *also* answer a new window's 'ready' broadcast
// (with the wrong bytes), and would go on answering a refresh of the now-superseded
// old window. Independent of local-play.ts's own Electron-only channel singleton —
// the browser build never touches that module.
let browserPlayChannel: BroadcastChannel | null = null;

async function handlePickFile() {
pickError = null;
startError = null;
const file = await pickFile(".quest,.aslx,.asl,.cas");
if (!file) return;
try {
pickedBytes = new Uint8Array(await file.arrayBuffer());
pickedFile = file;
} catch (err) {
pickError = String(err);
}
}

function handleClearPicked() {
pickedFile = null;
pickedBytes = null;
pickError = null;
startError = null;
}

// window.open() must be the very first thing this does — it's what spends this
// click's activation, and awaiting anything beforehand (there's nothing to await
// here; the bytes are already read in handlePickFile) would let the popup blocker
// silently no-op it. Hands the bytes to the new tab over a BroadcastChannel — see
// wasm-player.js's `source=local` boot branch. No resource-request handling on this
// path: a raw picked File has no directory to resolve sibling assets against (unlike
// the Electron path above), so this only really supports self-contained .quest packages.
function handleBrowserStart() {
if (!pickedFile || !pickedBytes) return;
startError = null;

browserPlayChannel?.close();

const popup = window.open(`${base}/player/?source=local`, "_blank");
if (!popup) {
startError = "Please allow pop-ups for this site to play the game.";
return;
}

starting = true;
const bytes = pickedBytes;
const filename = pickedFile.name;
const bc = new BroadcastChannel("quest-play-local");
browserPlayChannel = bc;
// Deliberately left open (not closed after the first message) — WasmPlayer
// re-broadcasts 'ready' on every load of that tab, including a plain refresh,
// so this needs to keep answering for as long as this page is still open,
// exactly like the never-closed editor-preview channel it mirrors.
bc.onmessage = ({ data }) => {
if (data.type === "ready") {
bc.postMessage({ type: "game", bytes, filename });
starting = false;
handleClearPicked();
}
};
}

onMount(() => {
void load();
refreshRecentCatalogPlays();
void refreshRecentLocalPlays();
});

// PlayCatalog.svelte unmounts on every navigation away from "/" (e.g. to a game's
// /play/{id} details page, or to the Create tab), unlike the always-mounted root
// layout, so channel cleanup here is load-bearing, not just defensive.
onDestroy(() => {
browserPlayChannel?.close();
closeLocalPlayChannel();
});

let searchQuery = $state("");
function handleSearch(e: SubmitEvent) {
Expand Down Expand Up @@ -89,9 +274,58 @@
<ChevronDown size={14} class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-surface-400" />
</div>
{/if}
<a href="{base}/play/local" class="btn preset-outlined-surface-500 whitespace-nowrap">Open a local file…</a>
{#if isElectronApp}
<button
type="button"
class="btn preset-outlined-surface-500 whitespace-nowrap"
onclick={handleOpenLocal}
disabled={electronOpenBusy}
>
{electronOpenBusy ? "Opening…" : "Open a game file…"}
</button>
{:else if !pickedFile}
<button type="button" class="btn preset-outlined-surface-500 whitespace-nowrap" onclick={handlePickFile}>
Open a game file&hellip;
</button>
{:else}
<div class="flex items-center gap-2">
<span class="text-sm text-surface-300 truncate max-w-[20ch]">{pickedFile.name}</span>
<button type="button" class="btn btn-sm preset-outlined-surface-500" onclick={handleClearPicked} disabled={starting}>
Change
</button>
<button type="button" class="btn preset-filled-primary-500" onclick={handleBrowserStart} disabled={starting}>
{starting ? "Starting…" : "Start ▶"}
</button>
</div>
{/if}
</div>

{#if electronOpenError}
<p class="text-error-500 text-sm text-center">{electronOpenError}</p>
{/if}
{#if !isElectronApp && pickError}
<p class="text-error-500 text-sm text-center">{pickError}</p>
{/if}
{#if !isElectronApp && startError}
<p class="text-error-500 text-sm text-center">{startError}</p>
{/if}

{#if recentCatalogPlays.length > 0 || (isElectronApp && recentLocalPlays.length > 0)}
<section>
<h2 class="text-lg font-semibold mb-3">Recently played</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-4">
{#each recentCatalogPlays as game (game.id)}
<RecentGameCard {game} onremove={() => handleRemoveRecentCatalog(game.id)} />
{/each}
{#if isElectronApp}
{#each recentLocalPlays as game (game.dirPath + "/" + game.filename)}
<LocalFileRecentCard {game} onplay={() => playLocalRecent(game)} onremove={() => handleRemoveRecentLocal(game)} />
{/each}
{/if}
</div>
</section>
{/if}

{#if loading}
<div class="flex flex-col items-center gap-3 py-12">
<div class="size-10 rounded-full border-4 border-surface-800 border-t-primary-500 animate-spin"></div>
Expand Down
Loading
Loading