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.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(