diff --git a/site/apps/catalog/accessories.js b/site/apps/catalog/accessories.js index 4e4d2072..c0efc088 100644 --- a/site/apps/catalog/accessories.js +++ b/site/apps/catalog/accessories.js @@ -5,12 +5,29 @@ const program = (id, title, icon, kind, extra = {}) => export const accessoryApplications = [ program("__calculator", "Calculator", "Calculator.png", "calculator", { - window: { width: 260, height: 330 }, + window: { + width: 260, + height: 259, + resizable: false, + maximizable: false, + className: "xp-calculator-window", + }, }), program( "__command-prompt", "Command Prompt", "CommandPrompt.png", "terminal", + { + window: { + width: 668, + height: 338, + left: 24, + top: 30, + resizable: false, + maximizable: false, + className: "xp-command-prompt-window", + }, + }, ), ]; diff --git a/site/apps/core/boxedwine.js b/site/apps/core/boxedwine.js index 0e30af7e..4b9837bc 100644 --- a/site/apps/core/boxedwine.js +++ b/site/apps/core/boxedwine.js @@ -34,6 +34,11 @@ const getFramebufferSize = (host, nativeWidth, nativeHeight, frameTop) => ({ ), }); +const getHostSize = (host, nativeWidth, nativeHeight) => ({ + width: Math.max(1, Math.round(host.clientWidth || nativeWidth)), + height: Math.max(1, Math.round(host.clientHeight || nativeHeight)), +}); + export const mountBoxedWineApplication = ({ title, packageId, @@ -45,6 +50,7 @@ export const mountBoxedWineApplication = ({ frameTop = 0, sound = true, background = "#000", + scaleOnly = false, }) => { const host = document.createElement("div"); host.className = "window-content boxedwine-app-host"; @@ -74,12 +80,35 @@ export const mountBoxedWineApplication = ({ host.appendChild(frame); const updateLayout = () => { - const framebuffer = getFramebufferSize( - host, - nativeWidth, - nativeHeight, - frameTop, - ); + const hostSize = getHostSize(host, nativeWidth, nativeHeight); + const shouldScale = + scaleOnly || + hostSize.width < nativeWidth || + hostSize.height < nativeHeight; + const scale = shouldScale + ? Math.min( + 1, + hostSize.width / nativeWidth, + hostSize.height / nativeHeight, + ) + : 1; + const framebuffer = shouldScale + ? { width: nativeWidth, height: nativeHeight + frameTop } + : { width: hostSize.width, height: hostSize.height + frameTop }; + + if (shouldScale) { + frame.style.width = `${nativeWidth}px`; + frame.style.height = `${nativeHeight}px`; + frame.style.left = `${Math.max(0, (hostSize.width - nativeWidth * scale) / 2)}px`; + frame.style.transform = `scale(${scale})`; + frame.style.transformOrigin = "top left"; + } else { + frame.style.width = "100%"; + frame.style.height = "100%"; + frame.style.left = "0px"; + frame.style.transform = ""; + frame.style.transformOrigin = ""; + } frame.contentWindow?.postMessage( { type: "boxedwine-framebuffer-resize", ...framebuffer }, new URL(frame.src).origin, diff --git a/site/apps/programs/command-session.js b/site/apps/programs/command-session.js new file mode 100644 index 00000000..0aac4ba5 --- /dev/null +++ b/site/apps/programs/command-session.js @@ -0,0 +1,444 @@ +const VERSION = "Microsoft Windows XP [Version 5.1.2600]"; +const COPYRIGHT = "(C) Copyright 1985-2001 Microsoft Corp."; +const DEFAULT_TITLE = "C:\\WINDOWS\\system32\\cmd.exe"; + +const tokenize = (value) => { + const tokens = []; + let token = ""; + let quoted = false; + for (const character of String(value)) { + if (character === '"') { + quoted = !quoted; + continue; + } + if (/\s/.test(character) && !quoted) { + if (token) tokens.push(token); + token = ""; + } else { + token += character; + } + } + if (token) tokens.push(token); + return tokens; +}; + +const formatDate = (timestamp) => { + const date = new Date(timestamp); + const part = (value) => String(value).padStart(2, "0"); + let hour = date.getHours(); + const suffix = hour >= 12 ? "PM" : "AM"; + hour = hour % 12 || 12; + return `${part(date.getMonth() + 1)}/${part(date.getDate())}/${date.getFullYear()} ${part(hour)}:${part(date.getMinutes())} ${suffix}`; +}; + +const displaySize = (size) => Number(size || 0).toLocaleString("en-US"); + +const errorText = (error) => + error instanceof Error + ? error.message.replace(/^(?:VirtualFS|FileOperations):\s*/, "") + : String(error); + +export class CommandSession { + constructor(context) { + this.context = context; + this.fs = context.fs; + this.cwd = this.fs.USER_PROFILE; + this.environment = new Map([ + ["COMSPEC", DEFAULT_TITLE], + ["HOMEDRIVE", "C:"], + ["HOMEPATH", "\\Documents and Settings\\Administrator"], + ["OS", "Windows_NT"], + ["PATH", "C:\\WINDOWS\\system32;C:\\WINDOWS"], + ["PATHEXT", ".COM;.EXE;.BAT;.CMD"], + ["PROMPT", "$P$G"], + ["SYSTEMDRIVE", "C:"], + ["SYSTEMROOT", "C:\\WINDOWS"], + ["TEMP", "C:\\DOCUME~1\\ADMINI~1\\LOCALS~1\\Temp"], + ["TMP", "C:\\DOCUME~1\\ADMINI~1\\LOCALS~1\\Temp"], + ["USERNAME", "Administrator"], + ["USERPROFILE", "C:\\Documents and Settings\\Administrator"], + ["WINDIR", "C:\\WINDOWS"], + ]); + } + + get banner() { + return `${VERSION}\n${COPYRIGHT}\n`; + } + + displayPath(id = this.cwd) { + const path = this.fs.getPath(id) || "C:\\"; + if (id === this.fs.USER_PROFILE) { + return "C:\\Documents and Settings\\Administrator"; + } + const profilePath = this.fs.getPath(this.fs.USER_PROFILE); + return profilePath && path.startsWith(profilePath) + ? `C:\\Documents and Settings\\Administrator${path.slice(profilePath.length)}` + : path; + } + + get prompt() { + return `${this.displayPath()}>`; + } + + resolve(path, options = {}) { + let value = String(path || "").trim(); + if (!value || value === ".") return this.cwd; + value = value.replaceAll("/", "\\"); + const driveMatch = value.match(/^([cdf]):(?:\\|$)/i); + let current = driveMatch + ? { + c: this.fs.DRIVE_C, + d: this.fs.DRIVE_D, + f: this.fs.DRIVE_F, + }[driveMatch[1].toLowerCase()] + : value.startsWith("\\") + ? this.driveRoot(this.cwd) + : this.cwd; + if (driveMatch) value = value.slice(driveMatch[0].length); + else if (value.startsWith("\\")) value = value.slice(1); + const segments = value.split("\\").filter(Boolean); + for (const segment of segments) { + if (segment === ".") continue; + if (segment === "..") { + current = this.fs.getParent(current)?.id || current; + continue; + } + const child = this.fs.findChild(current, segment); + if (!child) { + if (options.parent && segment === segments.at(-1)) { + return { parentId: current, name: segment }; + } + return null; + } + current = child.id; + } + return options.parent ? { node: this.fs.getNode(current) } : current; + } + + driveRoot(id) { + let node = this.fs.getNode(id); + while (node?.parent && node.parent !== this.fs.MY_COMPUTER) { + node = this.fs.getParent(node.id); + } + return node?.id || this.fs.DRIVE_C; + } + + expandEnvironment(value) { + return String(value).replace(/%([^%]+)%/g, (match, name) => + this.environment.has(name.toUpperCase()) + ? this.environment.get(name.toUpperCase()) + : match, + ); + } + + execute(rawLine) { + const line = this.expandEnvironment(rawLine).trim(); + if (!line) return { output: "" }; + const tokens = tokenize(line); + const command = tokens.shift().toLowerCase(); + try { + if (/^[cdf]:$/i.test(command)) return this.changeDrive(command); + const method = this[`command_${command}`]; + if (method) return method.call(this, tokens, line); + if (this.launch(command, tokens)) return { output: "" }; + return { + output: `'${command}' is not recognized as an internal or external command,\noperable program or batch file.`, + }; + } catch (error) { + return { output: errorText(error) }; + } + } + + changeDrive(drive) { + const root = { c: this.fs.DRIVE_C, d: this.fs.DRIVE_D, f: this.fs.DRIVE_F }[ + drive[0].toLowerCase() + ]; + if (!root) return { output: "The system cannot find the drive specified." }; + this.cwd = root; + return { output: "" }; + } + + command_cls() { + return { clear: true, output: "" }; + } + + command_ver() { + return { output: `\n${VERSION}` }; + } + + command_exit() { + this.context.close(); + return { exit: true, output: "" }; + } + + command_cd(args) { + const values = args.filter((value) => value.toLowerCase() !== "/d"); + if (!values.length) return { output: this.displayPath() }; + const id = this.resolve(values.join(" ")); + const node = id && this.fs.getNode(id); + if (!node || node.type !== "folder") { + return { output: "The system cannot find the path specified." }; + } + this.cwd = id; + return { output: "" }; + } + + command_chdir(args) { + return this.command_cd(args); + } + + command_dir(args) { + const target = args.find((value) => !value.startsWith("/")) || "."; + const id = this.resolve(target); + const node = id && this.fs.getNode(id); + if (!node) return { output: "File Not Found" }; + const folder = node.type === "folder" ? node : this.fs.getParent(node.id); + const children = + node.type === "folder" ? this.fs.getChildren(node.id) : [node]; + const lines = [ + " Volume in drive C has no label.", + " Volume Serial Number is B836-2A76", + "", + ` Directory of ${this.displayPath(folder.id)}`, + "", + ]; + if (node.type === "folder") { + lines.push(`${formatDate(folder.modified)} .`); + if (folder.parent && folder.parent !== this.fs.MY_COMPUTER) { + lines.push(`${formatDate(folder.modified)} ..`); + } + } + let fileCount = 0; + let directoryCount = 0; + let totalSize = 0; + for (const child of children) { + if (child.type === "folder") { + directoryCount += 1; + lines.push( + `${formatDate(child.modified)} ${child.name}`, + ); + } else { + fileCount += 1; + totalSize += child.size || 0; + lines.push( + `${formatDate(child.modified)} ${displaySize(child.size).padStart(14)} ${child.name}`, + ); + } + } + lines.push( + `${String(fileCount).padStart(15)} File(s) ${displaySize(totalSize).padStart(14)} bytes`, + `${String(directoryCount + (node.type === "folder" ? (folder.parent === this.fs.MY_COMPUTER ? 1 : 2) : 0)).padStart(15)} Dir(s) 5,952,212,992 bytes free`, + ); + return { output: lines.join("\n") }; + } + + command_echo(args, line) { + const body = line.slice(line.search(/\s|$/)).trimStart(); + if (!body) return { output: "ECHO is on." }; + const redirect = body.match(/^(.*?)(>{1,2})([^>]+)$/); + if (!redirect) return { output: body }; + const [, text, operator, path] = redirect; + const destination = this.resolve(path.trim(), { parent: true }); + if (!destination) + return { output: "The system cannot find the path specified." }; + const content = `${text.trimEnd()}\n`; + if (destination.node) { + if (destination.node.type !== "file") + return { output: "Access is denied." }; + this.fs.setContent( + destination.node.id, + operator === ">>" + ? `${this.fs.getContent(destination.node.id)}${content}` + : content, + ); + } else { + this.context.fileOps.createFile(destination.parentId, destination.name, { + content, + }); + } + return { output: "" }; + } + + command_type(args) { + const id = this.resolve(args.join(" ")); + const node = id && this.fs.getNode(id); + if (!node || node.type !== "file") { + return { output: "The system cannot find the file specified." }; + } + return { output: this.fs.getContent(node.id).replace(/\n$/, "") }; + } + + command_md(args) { + const target = this.resolve(args.join(" "), { parent: true }); + if (!target || target.node) { + return { output: "A subdirectory or file already exists." }; + } + this.context.fileOps.createFolder(target.parentId, target.name); + return { output: "" }; + } + + command_mkdir(args) { + return this.command_md(args); + } + + command_del(args) { + const target = args.filter((value) => !value.startsWith("/")); + const id = this.resolve(target.join(" ")); + const node = id && this.fs.getNode(id); + if (!node || node.type !== "file") + return { output: "Could Not Find the file specified." }; + this.fs.destroy(node.id); + return { output: "" }; + } + + command_erase(args) { + return this.command_del(args); + } + + command_rd(args) { + const recursive = args.some((value) => value.toLowerCase() === "/s"); + const target = args.filter((value) => !value.startsWith("/")); + const id = this.resolve(target.join(" ")); + const node = id && this.fs.getNode(id); + if (!node || node.type !== "folder") { + return { output: "The system cannot find the path specified." }; + } + if (this.fs.getChildren(node.id).length && !recursive) { + return { output: "The directory is not empty." }; + } + this.fs.destroy(node.id); + return { output: "" }; + } + + command_rmdir(args) { + return this.command_rd(args); + } + + command_ren(args) { + if (args.length < 2) + return { output: "The syntax of the command is incorrect." }; + const id = this.resolve(args[0]); + if (!id) return { output: "The system cannot find the file specified." }; + this.context.fileOps.rename(id, args.slice(1).join(" ")); + return { output: "" }; + } + + command_rename(args) { + return this.command_ren(args); + } + + copyOrMove(args, move) { + if (args.length < 2) + return { output: "The syntax of the command is incorrect." }; + const sourceId = this.resolve(args[0]); + const destinationId = this.resolve(args.slice(1).join(" ")); + const source = sourceId && this.fs.getNode(sourceId); + const destination = destinationId && this.fs.getNode(destinationId); + if (!source || !destination || destination.type !== "folder") { + return { output: "The system cannot find the path specified." }; + } + if (move) this.fs.move(source.id, destination.id); + else this.fs.copy(source.id, destination.id); + return { output: " 1 file(s) copied." }; + } + + command_copy(args) { + return this.copyOrMove(args, false); + } + + command_move(args) { + return this.copyOrMove(args, true); + } + + command_set(args, line) { + const body = line.slice(line.search(/\s|$/)).trimStart(); + if (!body) { + return { + output: [...this.environment.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => `${name}=${value}`) + .join("\n"), + }; + } + const equals = body.indexOf("="); + if (equals < 1) { + const name = body.toUpperCase(); + return { + output: this.environment.has(name) + ? `${name}=${this.environment.get(name)}` + : `Environment variable ${body} not defined`, + }; + } + const name = body.slice(0, equals).trim().toUpperCase(); + const value = body.slice(equals + 1); + if (value) this.environment.set(name, value); + else this.environment.delete(name); + return { output: "" }; + } + + command_title(args) { + this.context.setTitle(args.join(" ") || DEFAULT_TITLE); + return { output: "" }; + } + + command_start(args) { + const values = args[0] === "" ? args.slice(1) : args; + if (!values.length) + return { output: "The system cannot find the file specified." }; + const pathId = this.resolve(values.join(" ")); + if (pathId && this.fs.open(pathId)) return { output: "" }; + return this.launch(values[0], values.slice(1)) + ? { output: "" } + : { output: "The system cannot find the file specified." }; + } + + command_help() { + return { + output: [ + "For more information on a specific command, type HELP command-name", + "CD Displays the name of or changes the current directory.", + "CLS Clears the screen.", + "COPY Copies one or more files to another location.", + "DEL Deletes one or more files.", + "DIR Displays a list of files and subdirectories in a directory.", + "ECHO Displays messages, or turns command echoing on or off.", + "EXIT Quits the CMD.EXE program.", + "MD Creates a directory.", + "MOVE Moves files from one directory to another directory.", + "RD Removes a directory.", + "REN Renames a file or files.", + "SET Displays, sets, or removes environment variables.", + "START Starts a program or opens a file or folder.", + "TITLE Sets the window title for a CMD.EXE session.", + "TYPE Displays the contents of a text file.", + "VER Displays the Windows version.", + ].join("\n"), + }; + } + + launch(command, args) { + const aliases = { + calc: "__calculator", + "calc.exe": "__calculator", + command: "__command-prompt", + explorer: "__my-computer", + "explorer.exe": "__my-computer", + mspaint: "__paint", + "mspaint.exe": "__paint", + notepad: "__notepad", + "notepad.exe": "__notepad", + }; + const applicationId = aliases[command.toLowerCase()]; + if (!applicationId) return false; + let file = null; + if (args.length) { + const id = this.resolve(args.join(" ")); + file = id && this.fs.getNode(id); + if (!file) return false; + } + return !!this.context.launchApplication( + applicationId, + file ? { file } : {}, + ); + } +} diff --git a/site/apps/programs/renderers/calculator.js b/site/apps/programs/renderers/calculator.js index 1f0bdab9..9a42e645 100644 --- a/site/apps/programs/renderers/calculator.js +++ b/site/apps/programs/renderers/calculator.js @@ -1,76 +1,393 @@ import { createProgramRoot } from "../ui.js"; -export const renderCalculator = (context, program, programId) => { +const MAX_DIGITS = 32; + +const formatNumber = (value) => { + if (!Number.isFinite(value)) return "Cannot divide by zero."; + if (Object.is(value, -0)) return "0"; + const absoluteValue = Math.abs(value); + if (absoluteValue >= 1e16 || (absoluteValue > 0 && absoluteValue < 1e-15)) { + return value + .toExponential(15) + .replace(/\.0+e/, "e") + .replace(/(\.\d*?)0+e/, "$1e"); + } + return String(Number(value.toPrecision(15))); +}; + +const BUTTONS = [ + ["MC", "memory-clear", "memory"], + ["7", "7", "number"], + ["8", "8", "number"], + ["9", "9", "number"], + ["/", "divide", "operator"], + ["sqrt", "square-root", "function"], + ["MR", "memory-recall", "memory"], + ["4", "4", "number"], + ["5", "5", "number"], + ["6", "6", "number"], + ["*", "multiply", "operator"], + ["%", "percent", "function"], + ["MS", "memory-store", "memory"], + ["1", "1", "number"], + ["2", "2", "number"], + ["3", "3", "number"], + ["-", "subtract", "operator"], + ["1/x", "reciprocal", "function"], + ["M+", "memory-add", "memory"], + ["0", "0", "number"], + ["+/-", "sign", "number"], + [".", "decimal", "number"], + ["+", "add", "operator"], + ["=", "equals", "operator"], +]; + +const createMenu = (label, items) => { + const group = document.createElement("div"); + group.className = "xp-calculator-menu-group"; + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "xp-calculator-menu-trigger"; + trigger.textContent = label; + trigger.setAttribute("aria-haspopup", "menu"); + trigger.setAttribute("aria-expanded", "false"); + const popup = document.createElement("div"); + popup.className = "xp-calculator-popup"; + popup.hidden = true; + popup.setAttribute("role", "menu"); + for (const item of items) { + if (item.separator) { + const separator = document.createElement("hr"); + separator.setAttribute("role", "separator"); + popup.appendChild(separator); + continue; + } + const button = document.createElement("button"); + button.type = "button"; + button.dataset.command = item.command; + button.disabled = Boolean(item.disabled); + button.setAttribute("role", item.role || "menuitem"); + if (item.checked !== undefined) { + button.setAttribute("aria-checked", String(item.checked)); + } + button.innerHTML = `${item.checked ? "●" : ""}${item.label}${item.key || ""}`; + popup.appendChild(button); + } + group.append(trigger, popup); + return group; +}; + +export const renderCalculator = (context, program) => { const content = createProgramRoot(program); + content.tabIndex = -1; + content.innerHTML = ` + +
+ +
+ + + + +
+
+
`; - content.innerHTML = `
`; const display = content.querySelector(".xp-calculator-display"); const keys = content.querySelector(".xp-calculator-keys"); - let accumulator = 0; - let operator = null; - let freshValue = true; - const calculate = (value) => { - if (operator === "+") accumulator += value; - else if (operator === "−") accumulator -= value; - else if (operator === "×") accumulator *= value; - else if (operator === "÷") - accumulator = value === 0 ? 0 : accumulator / value; - else accumulator = value; - display.value = String(Number(accumulator.toFixed(10))); + const memoryIndicator = content.querySelector( + ".xp-calculator-memory-indicator", + ); + let entry = "0"; + let accumulator = null; + let pendingOperator = null; + let replaceEntry = true; + let lastOperator = null; + let lastOperand = null; + let memory = 0; + let error = false; + + const showEntry = () => { + display.value = error ? entry : entry.includes(".") ? entry : `${entry}.`; }; - [ - "Back", - "CE", - "C", - "7", - "8", - "9", - "÷", - "4", - "5", - "6", - "×", - "1", - "2", - "3", - "−", - "0", - ".", - "=", - "+", - ].forEach((label) => { + + const setValue = (value) => { + entry = formatNumber(value); + error = !Number.isFinite(value); + replaceEntry = true; + showEntry(); + }; + + const currentValue = () => Number(entry); + + const calculate = (left, operator, right) => { + if (operator === "add") return left + right; + if (operator === "subtract") return left - right; + if (operator === "multiply") return left * right; + if (operator === "divide") return right === 0 ? Number.NaN : left / right; + return right; + }; + + const clearAll = () => { + entry = "0"; + accumulator = null; + pendingOperator = null; + lastOperator = null; + lastOperand = null; + replaceEntry = true; + error = false; + showEntry(); + }; + + const inputDigit = (digit) => { + if (error) clearAll(); + if (replaceEntry) { + entry = digit; + replaceEntry = false; + } else if (entry === "0") { + entry = digit; + } else if (entry.replace(/[-.]/g, "").length < MAX_DIGITS) { + entry += digit; + } + showEntry(); + }; + + const inputDecimal = () => { + if (error) clearAll(); + if (replaceEntry) { + entry = "0."; + replaceEntry = false; + } else if (!entry.includes(".")) { + entry += "."; + } + showEntry(); + }; + + const selectOperator = (operator) => { + if (error) return; + const value = currentValue(); + if (pendingOperator && !replaceEntry) { + const result = calculate(accumulator, pendingOperator, value); + setValue(result); + accumulator = result; + } else if (accumulator === null) { + accumulator = value; + } + pendingOperator = operator; + replaceEntry = true; + }; + + const equals = () => { + if (error) return; + let operator = pendingOperator; + let operand = currentValue(); + if (!operator && lastOperator) { + operator = lastOperator; + operand = lastOperand; + } + if (!operator) return; + const left = accumulator ?? currentValue(); + const result = calculate(left, operator, operand); + lastOperator = operator; + lastOperand = operand; + pendingOperator = null; + accumulator = result; + setValue(result); + }; + + const runCommand = (command) => { + if (/^\d$/.test(command)) { + inputDigit(command); + return; + } + if (command === "decimal") { + inputDecimal(); + return; + } + if (command === "clear") { + clearAll(); + return; + } + if (command === "clear-entry") { + entry = "0"; + replaceEntry = true; + error = false; + showEntry(); + return; + } + if (command === "backspace") { + if (!replaceEntry && !error) { + entry = entry.length > 1 ? entry.slice(0, -1) : "0"; + if (entry === "-") entry = "0"; + showEntry(); + } + return; + } + if (["add", "subtract", "multiply", "divide"].includes(command)) { + selectOperator(command); + return; + } + if (command === "equals") { + equals(); + return; + } + if (error) return; + const value = currentValue(); + if (command === "sign") { + if (value !== 0) + entry = entry.startsWith("-") ? entry.slice(1) : `-${entry}`; + showEntry(); + } else if (command === "square-root") { + setValue(value < 0 ? Number.NaN : Math.sqrt(value)); + } else if (command === "reciprocal") { + setValue(value === 0 ? Number.NaN : 1 / value); + } else if (command === "percent") { + const percentValue = + pendingOperator === "add" || pendingOperator === "subtract" + ? ((accumulator ?? 0) * value) / 100 + : value / 100; + setValue(percentValue); + } else if (command === "memory-clear") { + memory = 0; + memoryIndicator.textContent = ""; + } else if (command === "memory-recall") { + setValue(memory); + } else if (command === "memory-store") { + memory = value; + memoryIndicator.textContent = memory === 0 ? "" : "M"; + replaceEntry = true; + } else if (command === "memory-add") { + memory += value; + memoryIndicator.textContent = memory === 0 ? "" : "M"; + replaceEntry = true; + } + }; + + for (const [label, command, type] of BUTTONS) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; - button.addEventListener("click", () => { - if (/^\d$/.test(label) || label === ".") { - display.value = freshValue - ? label === "." - ? "0." - : label - : `${display.value}${label}`; - freshValue = false; - } else if (label === "Back") { - display.value = - display.value.length > 1 ? display.value.slice(0, -1) : "0"; - } else if (label === "C" || label === "CE") { - display.value = "0"; - if (label === "C") { - accumulator = 0; - operator = null; + button.dataset.command = command; + button.dataset.keyType = type; + keys.appendChild(button); + } + + content.querySelectorAll("[data-command]").forEach((button) => { + button.addEventListener("click", () => runCommand(button.dataset.command)); + }); + + const menuBar = content.querySelector(".xp-calculator-menu-bar"); + const menus = [ + createMenu("Edit", [ + { label: "Copy", command: "copy", key: "Ctrl+C" }, + { label: "Paste", command: "paste", key: "Ctrl+V" }, + ]), + createMenu("View", [ + { + label: "Standard", + command: "standard", + role: "menuitemradio", + checked: true, + }, + { + label: "Scientific", + command: "scientific", + role: "menuitemradio", + checked: false, + disabled: true, + }, + { separator: true }, + { + label: "Digit grouping", + command: "grouping", + role: "menuitemcheckbox", + checked: false, + }, + ]), + createMenu("Help", [ + { label: "Help Topics", command: "help-topics" }, + { separator: true }, + { label: "About Calculator", command: "about" }, + ]), + ]; + menuBar.append(...menus); + + const closeMenus = () => { + content.querySelectorAll(".xp-calculator-popup").forEach((popup) => { + popup.hidden = true; + }); + content + .querySelectorAll(".xp-calculator-menu-trigger") + .forEach((trigger) => { + trigger.setAttribute("aria-expanded", "false"); + }); + }; + + content.querySelectorAll(".xp-calculator-menu-trigger").forEach((trigger) => { + trigger.addEventListener("click", () => { + const popup = trigger.nextElementSibling; + const open = popup.hidden; + closeMenus(); + popup.hidden = !open; + trigger.setAttribute("aria-expanded", String(open)); + }); + }); + + content.querySelectorAll(".xp-calculator-popup button").forEach((button) => { + button.addEventListener("click", async () => { + closeMenus(); + if (button.dataset.command === "copy") { + await navigator.clipboard?.writeText(entry); + } else if (button.dataset.command === "paste") { + const value = await navigator.clipboard?.readText(); + if (value && Number.isFinite(Number(value.trim()))) { + entry = String(Number(value.trim())); + replaceEntry = true; + error = false; + showEntry(); } - freshValue = true; - } else if (label === "=") { - calculate(Number(display.value)); - operator = null; - freshValue = true; - } else { - calculate(Number(display.value)); - operator = label; - freshValue = true; + } else if (button.dataset.command === "help-topics") { + context.dialogs.alert( + "Use the number and operation buttons to perform a calculation.", + "Calculator Help", + "info", + ); + } else if (button.dataset.command === "about") { + context.dialogs.alert( + "Microsoft Calculator\nVersion 5.1", + "About Calculator", + "info", + ); } }); - keys.appendChild(button); }); + + const keyboardCommands = { + Enter: "equals", + "=": "equals", + Escape: "clear", + Backspace: "backspace", + Delete: "clear-entry", + "+": "add", + "-": "subtract", + "*": "multiply", + "/": "divide", + "%": "percent", + ".": "decimal", + ",": "decimal", + }; + content.addEventListener("keydown", (event) => { + const command = /^\d$/.test(event.key) + ? event.key + : keyboardCommands[event.key]; + if (!command) return; + event.preventDefault(); + runCommand(command); + }); + + content.addEventListener("pointerdown", (event) => { + if (!event.target.closest(".xp-calculator-menu-group")) closeMenus(); + }); + showEntry(); return content; }; diff --git a/site/apps/programs/renderers/terminal.js b/site/apps/programs/renderers/terminal.js index 1b6bfbde..e0630eaa 100644 --- a/site/apps/programs/renderers/terminal.js +++ b/site/apps/programs/renderers/terminal.js @@ -1,34 +1,54 @@ import { createProgramRoot } from "../ui.js"; +import { CommandSession } from "../command-session.js"; export const renderTerminal = (context, program, programId) => { const content = createProgramRoot(program); - - content.innerHTML = `
Microsoft Windows XP [Version 5.1.2600]\n(C) Copyright 1985-2001 Microsoft Corp.\n\nC:\\Documents and Settings\\Administrator>
`; + const session = new CommandSession(context); + context.setTitle("C:\\WINDOWS\\system32\\cmd.exe"); + content.innerHTML = `
`; const output = content.querySelector(".xp-terminal-output"); + const screen = content.querySelector(".xp-terminal-screen"); + const prompt = content.querySelector(".xp-terminal-prompt span"); const input = content.querySelector("input"); + const history = []; + let historyIndex = 0; + + output.textContent = `${session.banner}\n`; + const updatePrompt = () => { + prompt.textContent = session.prompt; + }; + updatePrompt(); + input.addEventListener("keydown", (event) => { - if (event.key !== "Enter") return; - const command = input.value.trim(); - const normalized = command.toLowerCase(); - if (normalized === "cls") output.textContent = ""; - else { - const response = - normalized === "help" - ? "Supported commands: CLS, DIR, ECHO, HELP, VER" - : normalized === "dir" - ? " Directory of C:\\Documents and Settings\\Administrator\n\nMy Documents My Pictures My Music" - : normalized === "ver" - ? "Microsoft Windows XP [Version 5.1.2600]" - : normalized.startsWith("echo ") - ? command.slice(5) - : command - ? `'${command}' is not recognized as an internal or external command.` - : ""; - output.textContent += `\nC:\\Documents and Settings\\Administrator>${command}\n${response}`; + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + if (!history.length) return; + historyIndex = Math.max( + 0, + Math.min( + history.length, + historyIndex + (event.key === "ArrowUp" ? -1 : 1), + ), + ); + input.value = history[historyIndex] || ""; + input.setSelectionRange(input.value.length, input.value.length); + return; } + if (event.key !== "Enter") return; + const command = input.value; + if (command.trim()) history.push(command); + historyIndex = history.length; + const enteredPrompt = session.prompt; + const result = session.execute(command); + if (result.exit) return; + if (result.clear) output.textContent = ""; + else + output.textContent += `${enteredPrompt}${command}\n${result.output}${result.output ? "\n" : ""}`; input.value = ""; - output.scrollTop = output.scrollHeight; + updatePrompt(); + screen.scrollTop = screen.scrollHeight; }); + screen.addEventListener("pointerdown", () => input.focus()); setTimeout(() => input.focus(), 0); return content; }; diff --git a/site/apps/xp-card-games/index.js b/site/apps/xp-card-games/index.js index 844b4f75..2733f9f4 100644 --- a/site/apps/xp-card-games/index.js +++ b/site/apps/xp-card-games/index.js @@ -9,6 +9,8 @@ const defineXpCardGame = ({ executable, width, height, + nativeWidth = width - 6, + nativeHeight = height - 31, }) => defineApplication({ id: `__${id}`, @@ -30,10 +32,11 @@ const defineXpCardGame = ({ packageId: id, archive, executable, - nativeWidth: width, - nativeHeight: height - 32, + nativeWidth, + nativeHeight, frameTop: 32, background: "#27811f", + scaleOnly: true, }), }); @@ -44,8 +47,10 @@ export const xpCardGameApplications = [ icon: "FreeCell.png", archive: "xp-freecell", executable: "freecell.exe", - width: 700, - height: 520, + width: 646, + height: 479, + nativeWidth: 640, + nativeHeight: 448, }), defineXpCardGame({ id: "spider-solitaire", diff --git a/site/css/shell/themes.css b/site/css/shell/themes.css index a31cd8e9..abc009e6 100644 --- a/site/css/shell/themes.css +++ b/site/css/shell/themes.css @@ -689,62 +689,234 @@ html[data-xp-appearance="classic"] .start-program-flyout button:focus-visible { sans-serif; } +.xp-calculator-window { + min-width: min(260px, calc(100% - 8px)); + min-height: min(259px, calc(100% - 8px)); +} + +.xp-native-calculator { + position: relative; + height: 100%; + padding: 0; + overflow: hidden; + background: #ece9d8; + user-select: none; +} + +.xp-native-calculator button { + font: + 11px Tahoma, + sans-serif; +} + +.xp-calculator-menu-bar { + position: relative; + z-index: 3; + display: flex; + height: 21px; + padding-left: 1px; + background: #ece9d8; +} + +.xp-calculator-menu-group { + position: relative; +} + +.xp-calculator-menu-trigger { + height: 21px; + padding: 1px 6px 2px; + border: 1px solid transparent; + background: transparent; + color: #000; +} + +.xp-calculator-menu-trigger:hover, +.xp-calculator-menu-trigger[aria-expanded="true"] { + border-color: #316ac5; + background: #316ac5; + color: #fff; +} + +.xp-calculator-popup { + position: absolute; + top: 20px; + left: 0; + min-width: 164px; + padding: 2px; + border: 1px solid #808080; + background: #fff; + box-shadow: 2px 2px 3px rgb(0 0 0 / 35%); +} + +.xp-calculator-popup[hidden] { + display: none; +} + +.xp-calculator-popup button { + display: grid; + grid-template-columns: 15px 1fr auto; + width: 100%; + min-height: 20px; + padding: 2px 7px 2px 2px; + border: 0; + background: transparent; + color: #000; + text-align: left; +} + +.xp-calculator-popup button:hover:not(:disabled) { + background: #316ac5; + color: #fff; +} + +.xp-calculator-popup button:disabled { + color: #aca899; +} + +.xp-calculator-popup kbd { + margin-left: 20px; + font: inherit; +} + +.xp-calculator-popup hr { + height: 1px; + margin: 2px 1px 2px 17px; + border: 0; + background: #aca899; +} + +.xp-calculator-body { + padding: 3px 6px 6px; +} + .xp-calculator-display { box-sizing: border-box; width: 100%; - height: 28px; - margin-bottom: 10px; - padding: 3px 6px; + height: 23px; + margin: 0 0 13px; + padding: 2px 4px; + border: 1px solid #7f9db9; + border-radius: 0; + background: #fff; + color: #000; text-align: right; font: - 16px "Courier New", - monospace; + 11px Tahoma, + sans-serif; } -.xp-calculator-keys { +.xp-calculator-clear-row { display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 6px; + grid-template-columns: 34px 61px 60px 60px; + gap: 5px; + margin-bottom: 6px; } +.xp-calculator-clear-row .xp-calculator-memory-indicator { + box-sizing: border-box; + width: 26px; + height: 26px; + margin-left: 4px; + border: 1px solid; + border-color: #7f7f7f #fff #fff #7f7f7f; + color: #d00000; + text-align: center; + font: + 11px/24px Tahoma, + sans-serif; +} + +.xp-calculator-clear-row button, .xp-calculator-keys button { - min-height: 34px; + box-sizing: border-box; + height: 27px; + padding: 1px 2px; + border: 1px solid #003c74; + border-radius: 3px; + background: #ece9d8; + box-shadow: + inset 1px 1px #fff, + inset -1px -1px #aca899; } -.xp-calculator-keys button:nth-child(-n + 3) { - color: #b00000; +.xp-calculator-clear-row button:active, +.xp-calculator-keys button:active { + padding: 2px 1px 0 3px; + background: #e3e0d2; + box-shadow: inset 1px 1px #aca899; } -.xp-terminal-output, -.xp-terminal-prompt { - display: block; - margin: -10px -10px 0; - padding: 8px; +.xp-calculator-clear-row button { + color: #d00000; +} + +.xp-calculator-keys { + display: grid; + grid-template-columns: repeat(6, 34px); + gap: 6px 5px; +} + +.xp-calculator-keys button[data-key-type="number"] { + color: #0000d0; +} + +.xp-calculator-keys button[data-key-type="operator"], +.xp-calculator-keys button[data-key-type="memory"] { + color: #d00000; +} + +.xp-command-prompt-window { + min-width: min(668px, calc(100% - 8px)); + min-height: min(338px, calc(100% - 8px)); +} + +.xp-command-prompt-window .window-content, +.xp-native-terminal { + background: #000; +} + +.xp-native-terminal { + padding: 0; + overflow: hidden; +} + +.xp-terminal-screen { + box-sizing: border-box; + height: 100%; + padding: 2px 4px; + overflow: auto; background: #000; color: #c0c0c0; font: - 13px "Courier New", + 12px/1 "Lucida Console", monospace; - white-space: pre-wrap; + cursor: text; + scrollbar-color: #d4d0c8 #fff; } .xp-terminal-output { - height: calc(100% - 36px); - overflow: auto; + display: inline; + margin: 0; + font: inherit; + white-space: pre-wrap; } .xp-terminal-prompt { - display: flex; - margin-top: 0; + display: inline; + font: inherit; + white-space: nowrap; } .xp-terminal-prompt input { - flex: 1; + width: 1ch; + min-width: calc(100% - 42ch); + padding: 0; border: 0; outline: 0; background: #000; color: #c0c0c0; font: inherit; + caret-color: #c0c0c0; } .xp-native-paint-window { diff --git a/site/js/apps/programs.js b/site/js/apps/programs.js index 8ce4f7e1..696b68eb 100644 --- a/site/js/apps/programs.js +++ b/site/js/apps/programs.js @@ -22,6 +22,13 @@ const applicationContext = (win) => ({ dialogs: XPDialogs, fileOps, fs, + launchApplication(applicationId, options = {}) { + const application = window.XPApplicationRegistry.get(applicationId); + if (!application) return false; + if (application.kind === "system") openSystemWindow(applicationId); + else openXPProgram(applicationId, options); + return true; + }, getDesktopSize, getMasterVolume, setMasterVolume, @@ -85,12 +92,14 @@ const openXPProgram = (programId, options = {}) => { el.style.minWidth = `${preferredWidth}px`; el.style.minHeight = `${preferredHeight}px`; } - const windowWidth = - desktopWidth > 16 + const windowWidth = program.window.fitToWorkArea + ? preferredWidth + : desktopWidth > 16 ? Math.min(preferredWidth, desktopWidth - 16) : preferredWidth; - const windowHeight = - desktopHeight > 16 + const windowHeight = program.window.fitToWorkArea + ? preferredHeight + : desktopHeight > 16 ? Math.min(preferredHeight, desktopHeight - 16) : preferredHeight; el.style.width = `${windowWidth}px`; diff --git a/site/js/shell/window-manager.js b/site/js/shell/window-manager.js index 3b2d1ac1..7aa0dd94 100644 --- a/site/js/shell/window-manager.js +++ b/site/js/shell/window-manager.js @@ -102,18 +102,30 @@ const fitNativeProgramToWorkArea = (win) => { Math.max(0, Math.floor(viewport.height - (taskbarHeight || 0))), ) : desktopHeight; - if ( - visibleWidth <= 0 || - visibleHeight <= 0 || - (visibleWidth >= preferred.width && visibleHeight >= preferred.height) - ) - return false; + if (visibleWidth <= 0 || visibleHeight <= 0) return false; + if (visibleWidth >= preferred.width && visibleHeight >= preferred.height) { + if (!win.workAreaFitRect) return false; + Object.assign(win.el.style, win.workAreaFitRect); + win.workAreaFitRect = null; + return true; + } + + win.workAreaFitRect ||= { + left: win.el.style.left, + top: win.el.style.top, + width: `${preferred.width}px`, + height: `${preferred.height}px`, + minWidth: win.el.style.minWidth, + minHeight: win.el.style.minHeight, + }; Object.assign(win.el.style, { left: "0px", top: "0px", width: `${visibleWidth}px`, height: `${visibleHeight}px`, + minWidth: "0px", + minHeight: "0px", }); return true; }; @@ -126,8 +138,10 @@ const keepWindowsInWorkArea = () => { if (win.maximized) return; if (fitNativeProgramToWorkArea(win)) return; const el = win.el; - el.style.width = `${Math.min(el.offsetWidth, desktopWidth)}px`; - el.style.height = `${Math.min(el.offsetHeight, desktopHeight)}px`; + const width = parseWindowLength(el.style.width, el.offsetWidth); + const height = parseWindowLength(el.style.height, el.offsetHeight); + el.style.width = `${Math.min(width, desktopWidth)}px`; + el.style.height = `${Math.min(height, desktopHeight)}px`; const position = clampWindowPosition(win, el.offsetLeft, el.offsetTop); el.style.left = `${position.left}px`; el.style.top = `${position.top}px`; diff --git a/tests/calculator.test.ts b/tests/calculator.test.ts new file mode 100644 index 00000000..3996a34d --- /dev/null +++ b/tests/calculator.test.ts @@ -0,0 +1,116 @@ +// @ts-nocheck -- Happy DOM's element types intentionally replace lib.dom here. +import { afterEach, describe, expect, test } from "bun:test"; +import { cleanupShells, loadShell, login } from "./helpers/shell-harness"; + +afterEach(cleanupShells); + +const launchCalculator = async () => { + const shell = await login(await loadShell()); + 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="accessories"]').click(); + flyouts.querySelector('[data-program-id="calculator"]').click(); + const calculator = shell.document.querySelector( + '.xp-window[data-game="__calculator"]', + ); + const display = calculator.querySelector(".xp-calculator-display"); + const press = (...labels) => { + for (const label of labels) { + [...calculator.querySelectorAll("button")] + .find((button) => button.textContent === label) + .click(); + } + }; + return { shell, calculator, display, press }; +}; + +describe("Windows XP Calculator", () => { + test("matches the fixed standard-mode layout from XP", async () => { + const { calculator, display } = await launchCalculator(); + + expect(calculator.style.width).toBe("260px"); + expect(calculator.style.height).toBe("259px"); + expect(calculator.querySelector(".maximize-btn").disabled).toBeTrue(); + expect(display.value).toBe("0."); + expect( + [...calculator.querySelectorAll(".xp-calculator-menu-trigger")].map( + (button) => button.textContent, + ), + ).toEqual(["Edit", "View", "Help"]); + expect( + [...calculator.querySelectorAll(".xp-calculator-keys button")].map( + (button) => button.textContent, + ), + ).toEqual([ + "MC", + "7", + "8", + "9", + "/", + "sqrt", + "MR", + "4", + "5", + "6", + "*", + "%", + "MS", + "1", + "2", + "3", + "-", + "1/x", + "M+", + "0", + "+/-", + ".", + "+", + "=", + ]); + }); + + test("performs chained, repeated, percent, and unary calculations", async () => { + const { display, press } = await launchCalculator(); + + press("2", "+", "3", "="); + expect(display.value).toBe("5."); + press("="); + expect(display.value).toBe("8."); + press("C", "2", "0", "0", "+", "1", "0", "%", "="); + expect(display.value).toBe("220."); + press("C", "9", "sqrt"); + expect(display.value).toBe("3."); + press("1/x"); + expect(display.value).toBe("0.333333333333333"); + }); + + test("supports memory, correction controls, errors, and keyboard input", async () => { + const { shell, calculator, display, press } = await launchCalculator(); + + press("4", "2", "MS", "C", "MR"); + expect(display.value).toBe("42."); + expect( + calculator.querySelector(".xp-calculator-memory-indicator").textContent, + ).toBe("M"); + press("MC"); + expect( + calculator.querySelector(".xp-calculator-memory-indicator").textContent, + ).toBe(""); + + press("C", "8", "9", "Backspace"); + expect(display.value).toBe("8."); + press("/", "0", "="); + expect(display.value).toBe("Cannot divide by zero."); + press("C"); + + for (const key of ["6", "*", "7", "Enter"]) { + calculator + .querySelector(".xp-native-calculator") + .dispatchEvent( + new shell.window.KeyboardEvent("keydown", { key, bubbles: true }), + ); + } + expect(display.value).toBe("42."); + }); +}); diff --git a/tests/shell-behavior.test.ts b/tests/shell-behavior.test.ts index a8ca34f9..2a8685dd 100644 --- a/tests/shell-behavior.test.ts +++ b/tests/shell-behavior.test.ts @@ -517,7 +517,7 @@ test("All Programs exposes system applications and games in the XP hierarchy", a expect( calculatorWindow.querySelector(".xp-calculator-display")! .value, - ).toBe("5"); + ).toBe("5."); shell.document.getElementById("start-button")!.click(); shell.document.getElementById("all-programs-button")!.click(); @@ -622,6 +622,78 @@ test("All Programs exposes system applications and games in the XP hierarchy", a ).not.toBeNull(); }); +test("Command Prompt uses the XP console layout and operates on the shared filesystem", async () => { + const shell = await login(await loadShell()); + 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="accessories"]')! + .click(); + flyouts + .querySelector('[data-program-id="command-prompt"]')! + .click(); + + const commandWindow = shell.document.querySelector( + '.xp-window[data-game="__command-prompt"]', + )!; + const input = commandWindow.querySelector( + ".xp-terminal-prompt input", + )!; + const output = commandWindow.querySelector( + ".xp-terminal-output", + )!; + const prompt = commandWindow.querySelector( + ".xp-terminal-prompt span", + )!; + const run = (command: string) => { + input.value = command; + input.dispatchEvent( + new shell.window.KeyboardEvent("keydown", { + bubbles: true, + key: "Enter", + }), + ); + }; + + expect(commandWindow.style.width).toBe("668px"); + expect(commandWindow.style.height).toBe("338px"); + expect(commandWindow.style.left).toBe("24px"); + expect(commandWindow.style.top).toBe("30px"); + expect( + commandWindow.querySelector(".maximize-btn")!.disabled, + ).toBeTrue(); + expect(commandWindow.querySelector(".resize-handle")).toBeNull(); + expect(commandWindow.querySelector(".title-text")!.textContent).toBe( + "C:\\WINDOWS\\system32\\cmd.exe", + ); + expect(output.textContent).toStartWith( + "Microsoft Windows XP [Version 5.1.2600]", + ); + expect(prompt.textContent).toBe("C:\\Documents and Settings\\Administrator>"); + + run('cd "My Documents"'); + expect(prompt.textContent).toBe( + "C:\\Documents and Settings\\Administrator\\My Documents>", + ); + run("echo Hello XP>note.txt"); + const note = shell.window.VirtualFS.findChild( + shell.window.VirtualFS.MY_DOCUMENTS, + "note.txt", + ); + expect(note).not.toBeNull(); + expect(shell.window.VirtualFS.getContent(note.id)).toBe("Hello XP\n"); + + run("type note.txt"); + expect(output.textContent).toContain("Hello XP"); + run("notepad note.txt"); + expect( + shell.document.querySelector('.xp-window[data-game="__notepad"]'), + ).not.toBeNull(); + run("del note.txt"); + expect(shell.window.VirtualFS.getNode(note.id)).toBeNull(); +}); + test("native games open from their public deep links", async () => { for (const [deepLink, applicationId] of [ ["freecell", "__freecell"], diff --git a/tests/solitaire.test.ts b/tests/solitaire.test.ts index 9f082cf3..2324de6e 100644 --- a/tests/solitaire.test.ts +++ b/tests/solitaire.test.ts @@ -65,6 +65,15 @@ describe("Windows XP Solitaire through BoxedWine", () => { expect(solitaireWindow.style.top).toBe("0px"); expect(solitaireWindow.style.width).toBe("844px"); expect(solitaireWindow.style.height).toBe("360px"); + + Object.defineProperties(desktop, { + clientWidth: { configurable: true, value: 1024 }, + clientHeight: { configurable: true, value: 738 }, + }); + shell.window.dispatchEvent(new shell.window.Event("resize")); + + expect(solitaireWindow.style.width).toBe("592px"); + expect(solitaireWindow.style.height).toBe("438px"); }); test("launches the native executable in a resizable XP shell window", async () => { @@ -201,6 +210,25 @@ describe("Windows XP Solitaire through BoxedWine", () => { origin: new URL(frame.src).origin, }); + Object.defineProperties(host, { + clientWidth: { configurable: true, value: 390 }, + clientHeight: { configurable: true, value: 780 }, + }); + resize(); + expect(frame.style.width).toBe("586px"); + expect(frame.style.height).toBe("406px"); + expect(frame.style.left).toBe("0px"); + expect(frame.style.transform).toBe(`scale(${390 / 586})`); + expect(frame.style.transformOrigin).toBe("top left"); + expect(resizeMessages.at(-1)).toEqual({ + message: { + type: "boxedwine-framebuffer-resize", + width: 586, + height: 438, + }, + origin: new URL(frame.src).origin, + }); + solitaireWindow.querySelector(".close-btn").click(); expect(disconnected).toBeTrue(); }); diff --git a/tests/xp-card-games.test.ts b/tests/xp-card-games.test.ts index 93ef7fa3..0b00fd68 100644 --- a/tests/xp-card-games.test.ts +++ b/tests/xp-card-games.test.ts @@ -80,6 +80,59 @@ describe("Windows XP card games through BoxedWine", () => { expect(shell.offlineDownloads).toEqual([game.id]); }); + test(`${game.title} scales its complete native surface into a phone window`, async () => { + const shell = await login(await loadShell()); + const desktop = shell.document.getElementById("desktop"); + Object.defineProperties(desktop, { + clientWidth: { configurable: true, value: 390 }, + clientHeight: { configurable: true, value: 814 }, + }); + let resize; + shell.window.ResizeObserver = class ResizeObserver { + constructor(callback) { + resize = callback; + } + observe() {} + disconnect() {} + }; + + const gameWindow = launchGame(shell, game.id, game.applicationId); + const host = gameWindow.querySelector(".boxedwine-app-host"); + const frame = host.querySelector(".boxedwine-app-frame"); + const resizeMessages = []; + frame.contentWindow.postMessage = (message, origin) => + resizeMessages.push({ message, origin }); + Object.defineProperties(host, { + clientWidth: { configurable: true, value: 384 }, + clientHeight: { configurable: true, value: 783 }, + }); + + resize(); + + const nativeWidth = game.id === "freecell" ? 640 : 794; + const nativeHeight = game.id === "freecell" ? 448 : 569; + expect(gameWindow.style.width).toBe("390px"); + expect(gameWindow.style.height).toBe("814px"); + expect(gameWindow.style.minWidth).toBe("0px"); + expect(gameWindow.style.minHeight).toBe("0px"); + expect(frame.style.width).toBe(`${nativeWidth}px`); + expect(frame.style.height).toBe(`${nativeHeight}px`); + expect(frame.style.transform).toBe(`scale(${384 / nativeWidth})`); + expect(resizeMessages.at(-1).message).toEqual({ + type: "boxedwine-framebuffer-resize", + width: nativeWidth, + height: nativeHeight + 32, + }); + + Object.defineProperties(desktop, { + clientWidth: { configurable: true, value: 1024 }, + clientHeight: { configurable: true, value: 738 }, + }); + shell.window.dispatchEvent(new shell.window.Event("resize")); + expect(gameWindow.style.minWidth).not.toBe("0px"); + expect(gameWindow.style.minHeight).not.toBe("0px"); + }); + test(`${game.title} package matches its provenance manifest`, async () => { const directory = join(projectDirectory, "site", "iframe", game.id); const manifest = JSON.parse(