diff --git a/README.md b/README.md index 2ef55a3b..d2e84c94 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ScummVM, and embedded web runtimes. ## Highlights -- Authentic Windows XP shell, Explorer, Paint, Notepad, Solitaire, Pinball, themes, and settings +- Authentic Windows XP shell, Explorer, Paint, Notepad, card games, Pinball, themes, and settings - Draggable game windows, task switching, fullscreen, and volume controls - Favorites, recently played games, categories, search, and deep links - Automatic offline support with optional per-game downloads diff --git a/site/apps/core/boxedwine.js b/site/apps/core/boxedwine.js index 7be090d9..0e30af7e 100644 --- a/site/apps/core/boxedwine.js +++ b/site/apps/core/boxedwine.js @@ -12,6 +12,7 @@ const runnerUrl = ({ executable, resolution, frameTop, + sound, }) => { const url = new URL(`${RUNTIME_ROOT}index.html`, document.baseURI); url.search = new URLSearchParams({ @@ -20,7 +21,7 @@ const runnerUrl = ({ executable, resolution, frameTop: String(frameTop), - sound: "false", + sound: String(sound), }); return url.href; }; @@ -42,6 +43,7 @@ export const mountBoxedWineApplication = ({ nativeHeight, resolution, frameTop = 0, + sound = true, background = "#000", }) => { const host = document.createElement("div"); @@ -67,6 +69,7 @@ export const mountBoxedWineApplication = ({ resolution: resolution || `${initialFramebuffer.width}x${initialFramebuffer.height}`, frameTop, + sound, }); host.appendChild(frame); diff --git a/site/apps/index.js b/site/apps/index.js index 8d31ccb4..90bb2bf9 100644 --- a/site/apps/index.js +++ b/site/apps/index.js @@ -8,6 +8,7 @@ import { winampApplication } from "./winamp/index.js"; import { minesweeperApplication } from "./minesweeper/index.js"; import { solitaireApplication } from "./solitaire/index.js"; import { pinballApplication } from "./pinball/index.js"; +import { xpCardGameApplications } from "./xp-card-games/index.js"; export const applicationRegistry = createApplicationRegistry([ ...accessoryApplications, @@ -19,6 +20,7 @@ export const applicationRegistry = createApplicationRegistry([ winampApplication, solitaireApplication, pinballApplication, + ...xpCardGameApplications, ]); window.AstroShellApplications.install(applicationRegistry); diff --git a/site/apps/xp-card-games/index.js b/site/apps/xp-card-games/index.js new file mode 100644 index 00000000..844b4f75 --- /dev/null +++ b/site/apps/xp-card-games/index.js @@ -0,0 +1,59 @@ +import { defineApplication } from "../core/application.js"; +import { mountBoxedWineApplication } from "../core/boxedwine.js"; + +const defineXpCardGame = ({ + id, + title, + icon, + archive, + executable, + width, + height, +}) => + defineApplication({ + id: `__${id}`, + title, + icon, + kind: "native-game", + deepLinkId: id, + offlineGameId: id, + window: { + width, + height, + fitToWorkArea: true, + resizable: false, + maximizable: false, + }, + mount: () => + mountBoxedWineApplication({ + title: `Windows XP ${title}`, + packageId: id, + archive, + executable, + nativeWidth: width, + nativeHeight: height - 32, + frameTop: 32, + background: "#27811f", + }), + }); + +export const xpCardGameApplications = [ + defineXpCardGame({ + id: "freecell", + title: "FreeCell", + icon: "FreeCell.png", + archive: "xp-freecell", + executable: "freecell.exe", + width: 700, + height: 520, + }), + defineXpCardGame({ + id: "spider-solitaire", + title: "Spider Solitaire", + icon: "SpiderSolitaire.png", + archive: "xp-spider-solitaire", + executable: "spider.exe", + width: 800, + height: 600, + }), +]; diff --git a/site/iframe/freecell/SOURCES.json b/site/iframe/freecell/SOURCES.json new file mode 100644 index 00000000..05254ae6 --- /dev/null +++ b/site/iframe/freecell/SOURCES.json @@ -0,0 +1,22 @@ +{ + "runtime": "boxedwine", + "windowsXp": { + "source": "User-supplied Windows XP Professional SP3 source-media ISO", + "sourceSha256": "fd8c8d42c1581e8767217fe800bfc0d5649c0ad20d754c927d6c763e446d1927", + "package": { + "file": "xp-freecell.zip", + "bytes": 177107, + "sha256": "a450542c141eab60dec0ff0611ef2c4ab6a1e2a03c090a81d3769d5ad92f1348" + }, + "files": { + "freecell.exe": { + "member": "I386/FREECELL.EX_", + "sha256": "ba517f623b778f4b842f559ea344cb64d3cae9de172aecfa4e330c733baad601" + }, + "cards.dll": { + "member": "I386/CARDS.DL_", + "sha256": "0fb81e48d8b45e9995ae1e05fd28c1631dbbcdc71180904c1356bfa2c1ead506" + } + } + } +} diff --git a/site/iframe/freecell/xp-freecell.zip b/site/iframe/freecell/xp-freecell.zip new file mode 100644 index 00000000..6ccb12f4 Binary files /dev/null and b/site/iframe/freecell/xp-freecell.zip differ diff --git a/site/iframe/spider-solitaire/SOURCES.json b/site/iframe/spider-solitaire/SOURCES.json new file mode 100644 index 00000000..c9e44b97 --- /dev/null +++ b/site/iframe/spider-solitaire/SOURCES.json @@ -0,0 +1,18 @@ +{ + "runtime": "boxedwine", + "windowsXp": { + "source": "User-supplied Windows XP Professional SP3 source-media ISO", + "sourceSha256": "fd8c8d42c1581e8767217fe800bfc0d5649c0ad20d754c927d6c763e446d1927", + "package": { + "file": "xp-spider-solitaire.zip", + "bytes": 270263, + "sha256": "ba3b5323193d91fcf5d32a2985e5e162881452228f9664b69f597b18de7dd70a" + }, + "files": { + "spider.exe": { + "member": "I386/SPIDER.EX_", + "sha256": "d309a839843ccd8b9edef7a271a611eb7794f83a825b69a87cc259c9ede87288" + } + } + } +} diff --git a/site/iframe/spider-solitaire/xp-spider-solitaire.zip b/site/iframe/spider-solitaire/xp-spider-solitaire.zip new file mode 100644 index 00000000..c9c7b00b Binary files /dev/null and b/site/iframe/spider-solitaire/xp-spider-solitaire.zip differ diff --git a/site/js/shell/start-menu.js b/site/js/shell/start-menu.js index 3b491675..da5cb7f2 100644 --- a/site/js/shell/start-menu.js +++ b/site/js/shell/start-menu.js @@ -987,9 +987,11 @@ const getAllProgramsTree = () => { label: "Games", icon: programFolder, children: [ + xpProgramMenuItem("__freecell"), xpProgramMenuItem("__minesweeper"), xpProgramMenuItem("__pinball"), xpProgramMenuItem("__solitaire"), + xpProgramMenuItem("__spider-solitaire"), { separator: true }, ...gameGroups, ], diff --git a/tests/helpers/shell-harness.ts b/tests/helpers/shell-harness.ts index 82d80976..99e0ce0f 100644 --- a/tests/helpers/shell-harness.ts +++ b/tests/helpers/shell-harness.ts @@ -197,7 +197,11 @@ export async function loadShell() { window.AstroGameLibrary.createManager = () => emptyLibrary; const offlineSnapshot = { - bundledGames: [{ id: "solitaire" }], + bundledGames: [ + { id: "freecell" }, + { id: "solitaire" }, + { id: "spider-solitaire" }, + ], downloadedGameIds: [], phase: "idle", updateAvailable: false, diff --git a/tests/shell-behavior.test.ts b/tests/shell-behavior.test.ts index 1c53fd74..a8ca34f9 100644 --- a/tests/shell-behavior.test.ts +++ b/tests/shell-behavior.test.ts @@ -624,9 +624,11 @@ test("All Programs exposes system applications and games in the XP hierarchy", a test("native games open from their public deep links", async () => { for (const [deepLink, applicationId] of [ + ["freecell", "__freecell"], ["minesweeper", "__minesweeper"], ["pinball", "__pinball"], ["solitaire", "__solitaire"], + ["spider-solitaire", "__spider-solitaire"], ]) { const shell = await loadShell(); shell.window.location.hash = `#${deepLink}`; @@ -678,7 +680,6 @@ test("placeholder-only applications are not installed or exposed by the shell", "__internet-hearts", "__internet-reversi", "__internet-spades", - "__spider-solitaire", "__windows-catalog", "__windows-update", "__backup", @@ -702,7 +703,6 @@ test("placeholder-only applications are not installed or exposed by the shell", "__scheduled-tasks", "__system-information", "__address-book", - "__freecell", ]; for (const applicationId of removedApplicationIds) { diff --git a/tests/solitaire.test.ts b/tests/solitaire.test.ts index e40437a0..9f082cf3 100644 --- a/tests/solitaire.test.ts +++ b/tests/solitaire.test.ts @@ -90,7 +90,7 @@ describe("Windows XP Solitaire through BoxedWine", () => { expect(url.searchParams.get("executable")).toBe("resize-host.exe"); expect(url.searchParams.get("resolution")).toBe("586x438"); expect(url.searchParams.get("frameTop")).toBe("32"); - expect(url.searchParams.get("sound")).toBe("false"); + expect(url.searchParams.get("sound")).toBe("true"); expect(solitaireWindow.querySelectorAll(".resize-handle")).toHaveLength(8); const maximize = solitaireWindow.querySelector(".maximize-btn"); expect(maximize.disabled).toBeFalse(); diff --git a/tests/xp-card-games.test.ts b/tests/xp-card-games.test.ts new file mode 100644 index 00000000..93ef7fa3 --- /dev/null +++ b/tests/xp-card-games.test.ts @@ -0,0 +1,101 @@ +// @ts-nocheck -- Happy DOM's element types intentionally replace lib.dom here. +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + cleanupShells, + flushShell, + loadShell, + login, +} from "./helpers/shell-harness"; + +const require = createRequire(import.meta.url); +const { unzipSync } = require("fflate"); +const projectDirectory = join(dirname(fileURLToPath(import.meta.url)), ".."); +const sha256 = (content: Uint8Array) => + createHash("sha256").update(content).digest("hex"); + +const games = [ + { + id: "freecell", + applicationId: "__freecell", + title: "FreeCell", + archive: "xp-freecell", + executable: "freecell.exe", + files: ["cards.dll", "freecell.exe"], + }, + { + id: "spider-solitaire", + applicationId: "__spider-solitaire", + title: "Spider Solitaire", + archive: "xp-spider-solitaire", + executable: "spider.exe", + files: ["spider.exe"], + }, +]; + +afterEach(cleanupShells); + +const launchGame = (shell, id, applicationId) => { + shell.document.getElementById("start-button").click(); + shell.document.getElementById("all-programs-button").click(); + const flyouts = shell.document.getElementById("start-menu-flyouts"); + flyouts.querySelector('[data-program-id="games"]').click(); + flyouts.querySelector(`[data-program-id="${id}"]`).click(); + return shell.document.querySelector( + `.xp-window[data-game="${applicationId}"]`, + ); +}; + +describe("Windows XP card games through BoxedWine", () => { + for (const game of games) { + test(`launches ${game.title} with sound and offline support`, async () => { + const shell = await login(await loadShell()); + shell.window.ASTRO_GAME_ROOTS = { + [game.id]: `iframe/${game.id}-build-123/`, + }; + + const gameWindow = launchGame(shell, game.id, game.applicationId); + const frame = gameWindow.querySelector(".boxedwine-app-frame"); + const url = new URL(frame.src); + + expect(gameWindow.querySelector(".title-text").textContent).toBe( + game.title, + ); + expect(new URL(url.searchParams.get("appRoot")).pathname).toBe( + `/iframe/${game.id}-build-123/`, + ); + expect(url.searchParams.get("archive")).toBe(game.archive); + expect(url.searchParams.get("executable")).toBe(game.executable); + expect(url.searchParams.get("frameTop")).toBe("32"); + expect(url.searchParams.get("sound")).toBe("true"); + expect(gameWindow.querySelector(".maximize-btn").disabled).toBeTrue(); + expect(gameWindow.querySelector(".resize-handle")).toBeNull(); + expect(shell.window.location.hash).toBe(`#${game.id}`); + await flushShell(); + expect(shell.offlineDownloads).toEqual([game.id]); + }); + + test(`${game.title} package matches its provenance manifest`, async () => { + const directory = join(projectDirectory, "site", "iframe", game.id); + const manifest = JSON.parse( + await readFile(join(directory, "SOURCES.json"), "utf8"), + ); + const packageContent = new Uint8Array( + await readFile(join(directory, `${game.archive}.zip`)), + ); + const files = unzipSync(packageContent); + + expect(packageContent.byteLength).toBe(manifest.windowsXp.package.bytes); + expect(sha256(packageContent)).toBe(manifest.windowsXp.package.sha256); + expect(Object.keys(files).sort()).toEqual(game.files); + for (const [name, source] of Object.entries(manifest.windowsXp.files)) { + expect(sha256(files[name])).toBe(source.sha256); + } + }); + } +}); diff --git a/tools/deploy.ts b/tools/deploy.ts index 2898c23d..85567833 100644 --- a/tools/deploy.ts +++ b/tools/deploy.ts @@ -686,7 +686,9 @@ export async function versionGamePackages( const gameRuntimes: Record = { "pink-panther-hokus-pokus": "scummvm", "pink-panther-passport-to-peril": "scummvm", + freecell: "boxedwine", solitaire: "boxedwine", + "spider-solitaire": "boxedwine", }; const packages: VersionedGamePackages = {}; for (const gameId of [...gameIds].sort()) {