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
26 changes: 25 additions & 1 deletion src/AppShell/src/components/LocalFileRecentCard.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,31 @@
<script lang="ts">
import type { RecentGame } from "$lib/filesystem/electron-adapter";
import { resolveAndCacheCover } from "$lib/local-cover";
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();

// game.coverDataUrl is cached on the RecentGame record (resolved once at play time, or
// here as a one-off self-heal for a legacy entry — see local-cover.ts) — the common case
// is a synchronous read, no engine boot needed. undefined (never resolved yet) is the only
// case that falls back to resolving live; starts null so the Gamepad2 placeholder below
// shows while that's in flight. Keyed off dirPath+filename so a change of `game` (grid
// re-sorted by recency) re-checks rather than keeping a stale image.
let coverUrl = $state<string | null>(null);
$effect(() => {
const { dirPath, filename, coverDataUrl } = game;
if (coverDataUrl !== undefined) {
coverUrl = coverDataUrl;
return;
}
coverUrl = null;
void resolveAndCacheCover(dirPath, filename).then((url) => {
if (game.dirPath === dirPath && game.filename === filename) coverUrl = url;
});
});

function folderName(dirPath: string): string {
return dirPath.split(/[\\/]/).pop() || dirPath;
}
Expand Down Expand Up @@ -33,7 +53,11 @@
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" />
{#if coverUrl}
<img src={coverUrl} alt="" loading="lazy" class="w-full h-full object-cover" />
{:else}
<Gamepad2 size={40} class="text-surface-600" />
{/if}
</div>
<div class="p-2 flex flex-col gap-1">
<div class="text-sm font-semibold truncate">{game.filename}</div>
Expand Down
31 changes: 19 additions & 12 deletions src/AppShell/src/components/PlayCatalog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,20 @@
// 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).
// in the same grid (RecentGameCard / LocalFileRecentCard), interleaved by timestamp —
// lastPlayed and lastOpened are both plain Date.now() epoch-ms, so they compare directly.
let recentCatalogPlays = $state<RecentCatalogPlay[]>([]);
let recentLocalPlays = $state<RecentGame[]>([]);

type RecentEntry =
| { kind: "catalog"; key: string; timestamp: number; game: RecentCatalogPlay }
| { kind: "local"; key: string; timestamp: number; game: RecentGame };

const recentEntries = $derived<RecentEntry[]>([
...recentCatalogPlays.map((game): RecentEntry => ({ kind: "catalog", key: game.id, timestamp: game.lastPlayed, game })),
...recentLocalPlays.map((game): RecentEntry => ({ kind: "local", key: game.dirPath + "/" + game.filename, timestamp: game.lastOpened, game })),
].sort((a, b) => b.timestamp - a.timestamp));

function refreshRecentCatalogPlays() {
recentCatalogPlays = listRecentCatalogPlays();
}
Expand Down Expand Up @@ -310,18 +318,17 @@
<p class="text-error-500 text-sm text-center">{startError}</p>
{/if}

{#if recentCatalogPlays.length > 0 || (isElectronApp && recentLocalPlays.length > 0)}
{#if recentEntries.length > 0}
<section>
<h2 class="text-lg font-semibold mb-3">Recently played</h2>
<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 recentEntries as entry (entry.key)}
{#if entry.kind === "catalog"}
<RecentGameCard game={entry.game} onremove={() => handleRemoveRecentCatalog(entry.game.id)} />
{:else}
<LocalFileRecentCard game={entry.game} onplay={() => playLocalRecent(entry.game)} onremove={() => handleRemoveRecentLocal(entry.game)} />
{/if}
{/each}
{#if isElectronApp}
{#each recentLocalPlays as game (game.dirPath + "/" + game.filename)}
<LocalFileRecentCard {game} onplay={() => playLocalRecent(game)} onremove={() => handleRemoveRecentLocal(game)} />
{/each}
{/if}
</div>
</section>
{/if}
Expand Down
8 changes: 7 additions & 1 deletion src/AppShell/src/lib/electron-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ interface ElectronRecentGame {
dirPath: string;
filename: string;
lastOpened: number;
// "play"-kind only — see ElectronApp's recent-games.ts. undefined = never resolved;
// null = resolved, no cover; string = the resolved cover as a data: URL.
coverDataUrl?: string | null;
}

// "edit" = opened/created/saved-as through the editor (File > Open Recent,
Expand All @@ -51,8 +54,11 @@ type ElectronRecentKind = "edit" | "play";

interface ElectronRecentApi {
list(kind: ElectronRecentKind): Promise<ElectronRecentGame[]>;
add(kind: ElectronRecentKind, dirPath: string, filename: string): Promise<ElectronRecentGame[]>;
add(kind: ElectronRecentKind, dirPath: string, filename: string, coverDataUrl?: string | null): Promise<ElectronRecentGame[]>;
remove(kind: ElectronRecentKind, dirPath: string, filename: string): Promise<ElectronRecentGame[]>;
// Patches an existing entry's coverDataUrl in place — unlike add(), doesn't bump
// lastOpened or reorder. Used to self-heal a legacy/unresolved entry in the background.
setCover(kind: ElectronRecentKind, dirPath: string, filename: string, coverDataUrl: string | null): Promise<ElectronRecentGame[]>;
onChanged(callback: (kind: ElectronRecentKind) => void): () => void;
}

Expand Down
12 changes: 12 additions & 0 deletions src/AppShell/src/lib/filesystem/electron-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ export interface RecentGame {
dirPath: string;
filename: string;
lastOpened: number;
// "play"-kind only — see ElectronApp's recent-games.ts. undefined = never resolved;
// null = resolved, no cover; string = the resolved cover as a data: URL.
coverDataUrl?: string | null;
}

// Mirrors ElectronRecentKind. "edit" (the default below) is every editor
Expand All @@ -66,6 +69,15 @@ export async function removeRecentGame(dirPath: string, filename: string, kind:
await electronApp().recent.remove(kind, dirPath, filename);
}

export async function setRecentGameCover(
dirPath: string,
filename: string,
coverDataUrl: string | null,
kind: RecentKind = "play",
): Promise<void> {
await electronApp().recent.setCover(kind, dirPath, filename, coverDataUrl);
}

// Best-effort: the recent list is a convenience, so a tracking failure should
// never surface as an error on the actual open/create/save-as/play it followed.
async function trackRecent(dirPath: string, filename: string, kind: RecentKind = "edit"): Promise<void> {
Expand Down
20 changes: 19 additions & 1 deletion src/AppShell/src/lib/filesystem/local-play.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { loadElectronFile, openElectronPlayFile } from "./electron-adapter";
import { loadElectronFile, openElectronPlayFile, listRecentGames } from "./electron-adapter";
import { resolveAndCacheCover } from "../local-cover";

function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve) => {
Expand Down Expand Up @@ -27,6 +28,8 @@ export async function playElectronFile(dirPath: string, filename: string, onPlay
const { bytes, adapter } = await loadElectronFile(dirPath, filename, "play");
onPlayed?.();

void resolveCoverIfNeeded(dirPath, filename);

closeLocalPlayChannel();
const bc = new BroadcastChannel("quest-play-local");
playChannel = bc;
Expand All @@ -50,6 +53,21 @@ export async function playElectronFile(dirPath: string, filename: string, onPlay
}
}

// Fire-and-forget, deliberately not awaited by playElectronFile — cover resolution boots the
// full engine just to read one field (see local-cover.ts), which must never delay the player
// window opening. Runs in this (the main AppShell) renderer, a separate OS process from the
// player window it just opened, so it can't make that window janky either. Skipped when this
// file's cover is already cached — addRecentGame's carry-forward (see recent-games.ts) means
// replaying an already-resolved game leaves its coverDataUrl untouched, so this only ever does
// real work the first time a given local file is played.
async function resolveCoverIfNeeded(dirPath: string, filename: string): Promise<void> {
const recents = await listRecentGames("play");
const entry = recents.find((g) => g.dirPath === dirPath && g.filename === filename);
if (entry && entry.coverDataUrl === undefined) {
await resolveAndCacheCover(dirPath, filename);
}
}

// 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".
Expand Down
77 changes: 77 additions & 0 deletions src/AppShell/src/lib/local-cover.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { loadWasm } from "./wasm";
import { ElectronFileAdapter, setRecentGameCover } from "./filesystem/electron-adapter";

interface LocalCoverResult {
name: string;
dataUrl: string | null;
}

function blobToDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
}

// Computing a local play file's cover art means booting the full engine just to read one field
// (see WasmEditorBridge.ResolveLocalCover) — too expensive to redo on every app startup. The
// result is cached on the RecentGame record itself (coverDataUrl, persisted to disk — see
// ElectronApp's recent-games.ts), resolved once via resolveAndCacheCover below, either when the
// game is played (local-play.ts) or, for a legacy/never-resolved entry, the first time its card
// is shown (LocalFileRecentCard.svelte). Neither caller needs an in-memory result cache of its
// own on top of that — this module only guards against two callers racing to resolve the same
// entry concurrently (e.g. a Play-tab re-render overlapping a play-time resolution).
const inFlight = new Map<string, Promise<string | null>>();

function cacheKey(dirPath: string, filename: string): string {
return dirPath + "/" + filename;
}

// Best-effort: a malformed/unsupported local file, or one with no cover attribute set, must
// never break the Recently Played list — any failure here resolves to null, same as "no cover".
function resolveLocalCoverUrl(dirPath: string, filename: string): Promise<string | null> {
const key = cacheKey(dirPath, filename);
const existing = inFlight.get(key);
if (existing) return existing;

const promise = resolveUncached(dirPath, filename).finally(() => {
if (inFlight.get(key) === promise) inFlight.delete(key);
});
inFlight.set(key, promise);
return promise;
}

async function resolveUncached(dirPath: string, filename: string): Promise<string | null> {
try {
const bytes = await window.electronApp!.fs.readFile(window.electronApp!.path.join(dirPath, filename));
const bridge = await loadWasm();
const resultJson = await bridge.ResolveLocalCover(bytes, filename);
if (!resultJson) return null;
const { name, dataUrl } = JSON.parse(resultJson) as LocalCoverResult;

// Embedded (a .quest package, or a self-contained legacy .asl/.cas) — the bridge
// already extracted and base64-encoded the bytes from the game's own archive, so
// dataUrl is directly usable as an <img src> and safe to cache to disk as-is.
// Otherwise (a plain unpacked .aslx) the cover is a sibling file on disk — also
// converted to a data: URL rather than an object URL, since this result gets
// persisted on the RecentGame record and object URLs don't survive past the page
// that created them.
if (dataUrl) return dataUrl;

const adapter = new ElectronFileAdapter(dirPath, filename);
const blob = await adapter.getAsset(name);
return blob ? await blobToDataUrl(blob) : null;
} catch {
return null;
}
}

// Resolves (if not already in flight) and persists a local play file's cover onto its
// RecentGame record — the one-stop call every caller should use, so resolving never happens
// without also being cached for next time.
export async function resolveAndCacheCover(dirPath: string, filename: string): Promise<string | null> {
const url = await resolveLocalCoverUrl(dirPath, filename);
await setRecentGameCover(dirPath, filename, url);
return url;
}
50 changes: 37 additions & 13 deletions src/AppShell/src/lib/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,27 +108,51 @@ export interface WasmBridge {
// New game
GetGameTemplates(): string
CreateGameFromTemplate(templateId: string, gameName: string): string
// Play tab "Recently played" cover art — see WasmEditorBridge.ResolveLocalCover. Return
// value is a JSON-encoded LocalCoverResult ({ name, dataUrl }) or null, not a bare string —
// multiple of these can be in flight at once (one per recent-local-play card), so unlike
// most of the "returns a name, fetch the rest via a second synchronous call" pairs above,
// this can't stash its result in shared state between the two calls without racing.
ResolveLocalCover(gameFileBytes: Uint8Array, filename: string): Promise<string | null>
}

let _bridge: WasmBridge | null = null;
// The in-flight load itself, not just its result — dotnet.js's runtime module can only be
// created once per page (a second concurrent dotnet.create() throws "Runtime module already
// loaded", and _bridge never gets set, permanently wedging every later caller too). A plain
// `if (_bridge) return` guard only protects callers that arrive after the first load has
// already finished; two callers arriving before then (e.g. several LocalFileRecentCard
// instances resolving cover art on the same Play-tab render) would each see _bridge still
// null and race to call create() themselves. Caching the promise makes every concurrent
// caller await the same single load instead.
let _loading: Promise<WasmBridge> | null = null;

export async function loadWasm(): Promise<WasmBridge> {
if (_bridge) return _bridge;
if (_loading) return _loading;

// dotnet.js is served at runtime by the Vite AppBundle middleware (vite.config.ts).
// Use new Function to prevent Vite's import-analysis plugin from trying to resolve
// the URL at build time — it only exists as a runtime-served file.
const loadModule = new Function("url", "return import(url)");
const { dotnet } = (await loadModule("/AppBundle/_framework/dotnet.js")) as { dotnet: any };
_loading = (async () => {
// dotnet.js is served at runtime by the Vite AppBundle middleware (vite.config.ts).
// Use new Function to prevent Vite's import-analysis plugin from trying to resolve
// the URL at build time — it only exists as a runtime-served file.
const loadModule = new Function("url", "return import(url)");
const { dotnet } = (await loadModule("/AppBundle/_framework/dotnet.js")) as { dotnet: any };

const { getAssemblyExports, getConfig, runMain } = await dotnet
.withDiagnosticTracing(false)
.create();
const { getAssemblyExports, getConfig, runMain } = await dotnet
.withDiagnosticTracing(false)
.create();

await runMain();
await runMain();

const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
_bridge = exports.QuestViva.WasmEditor.WasmEditorBridge as WasmBridge;
return _bridge;
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
_bridge = exports.QuestViva.WasmEditor.WasmEditorBridge as WasmBridge;
return _bridge;
})();

try {
return await _loading;
} finally {
_loading = null;
}
}
15 changes: 12 additions & 3 deletions src/ElectronApp/src/ipc/recent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ipcMain } from "electron";
import { listRecentGames, addRecentGame, removeRecentGame, type RecentKind } from "../recent-games";
import { listRecentGames, addRecentGame, removeRecentGame, setRecentGameCover, type RecentKind } from "../recent-games";

// Backs window.electronApp.recent in preload.ts. Every call carries a
// RecentKind ("edit" vs "play" — see recent-games.ts) since the two lists are
Expand All @@ -10,8 +10,8 @@ import { listRecentGames, addRecentGame, removeRecentGame, type RecentKind } fro
export function registerRecentHandlers(onChange: (kind: RecentKind) => void): void {
ipcMain.handle("recent:list", async (_event, kind: RecentKind) => listRecentGames(kind));

ipcMain.handle("recent:add", async (_event, kind: RecentKind, dirPath: string, filename: string) => {
const games = await addRecentGame(kind, dirPath, filename);
ipcMain.handle("recent:add", async (_event, kind: RecentKind, dirPath: string, filename: string, coverDataUrl?: string | null) => {
const games = await addRecentGame(kind, dirPath, filename, coverDataUrl);
onChange(kind);
return games;
});
Expand All @@ -21,4 +21,13 @@ export function registerRecentHandlers(onChange: (kind: RecentKind) => void): vo
onChange(kind);
return games;
});

// Patches a cached cover onto an existing entry — see recent-games.ts's setRecentGameCover.
// Fires onChange like the other mutations so a Recently Played list open in another window
// picks up the newly-resolved cover too.
ipcMain.handle("recent:setCover", async (_event, kind: RecentKind, dirPath: string, filename: string, coverDataUrl: string | null) => {
const games = await setRecentGameCover(kind, dirPath, filename, coverDataUrl);
onChange(kind);
return games;
});
}
6 changes: 4 additions & 2 deletions src/ElectronApp/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ contextBridge.exposeInMainWorld("electronApp", {
// playing a game never pollutes the list File > Open Recent loads
// into the editor, and vice versa.
list: (kind: RecentKind): Promise<RecentGame[]> => ipcRenderer.invoke("recent:list", kind),
add: (kind: RecentKind, dirPath: string, filename: string): Promise<RecentGame[]> =>
ipcRenderer.invoke("recent:add", kind, dirPath, filename),
add: (kind: RecentKind, dirPath: string, filename: string, coverDataUrl?: string | null): Promise<RecentGame[]> =>
ipcRenderer.invoke("recent:add", kind, dirPath, filename, coverDataUrl),
remove: (kind: RecentKind, dirPath: string, filename: string): Promise<RecentGame[]> =>
ipcRenderer.invoke("recent:remove", kind, dirPath, filename),
setCover: (kind: RecentKind, dirPath: string, filename: string, coverDataUrl: string | null): Promise<RecentGame[]> =>
ipcRenderer.invoke("recent:setCover", kind, dirPath, filename, coverDataUrl),
// Fires whenever a recent list changes from outside the page that's
// showing it — e.g. the native "Clear Recent" menu item (edit-kind
// only), which mutates the list entirely in the main process with no
Expand Down
Loading
Loading