diff --git a/native/calculator/window-host.c b/native/calculator/window-host.c new file mode 100644 index 0000000..eb2e11b --- /dev/null +++ b/native/calculator/window-host.c @@ -0,0 +1,82 @@ +#define WIN32_LEAN_AND_MEAN +#include + +#define SIZE_FILE L"D:\\boxedwine-window-size.txt" + +typedef struct { + DWORD process_id; + HWND window; +} WindowSearch; + +static BOOL CALLBACK find_process_window(HWND window, LPARAM parameter) { + WindowSearch *search = (WindowSearch *)parameter; + DWORD process_id = 0; + GetWindowThreadProcessId(window, &process_id); + if (process_id == search->process_id && IsWindowVisible(window) && + !GetWindow(window, GW_OWNER)) { + search->window = window; + return FALSE; + } + return TRUE; +} + +static void append_number(char **cursor, unsigned int value) { + char digits[16]; + unsigned int length = 0; + do { + digits[length++] = (char)('0' + value % 10); + value /= 10; + } while (value); + while (length) *(*cursor)++ = digits[--length]; +} + +static void write_window_size(unsigned int width, unsigned int height) { + char buffer[40]; + char *cursor = buffer; + append_number(&cursor, width); + *cursor++ = ' '; + append_number(&cursor, height); + HANDLE file = CreateFileW(SIZE_FILE, GENERIC_WRITE, FILE_SHARE_READ, NULL, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + if (file == INVALID_HANDLE_VALUE) return; + DWORD written; + WriteFile(file, buffer, (DWORD)(cursor - buffer), &written, NULL); + CloseHandle(file); +} + +static DWORD run(void) { + STARTUPINFOW startup = {0}; + PROCESS_INFORMATION process = {0}; + WCHAR calculator_path[MAX_PATH]; + unsigned int previous_width = 0; + unsigned int previous_height = 0; + + startup.cb = sizeof(startup); + DWORD path_length = GetModuleFileNameW(NULL, calculator_path, MAX_PATH); + if (!path_length || path_length == MAX_PATH) return 1; + WCHAR *filename = calculator_path + path_length; + while (filename > calculator_path && filename[-1] != L'\\') --filename; + if (filename == calculator_path) return 1; + lstrcpyW(filename, L"calc.exe"); + if (!CreateProcessW(calculator_path, NULL, NULL, NULL, FALSE, 0, NULL, NULL, + &startup, &process)) + return 1; + + CloseHandle(process.hThread); + while (WaitForSingleObject(process.hProcess, 50) == WAIT_TIMEOUT) { + WindowSearch search = {process.dwProcessId, NULL}; + RECT bounds; + EnumWindows(find_process_window, (LPARAM)&search); + if (!search.window || !GetWindowRect(search.window, &bounds)) continue; + unsigned int width = (unsigned int)(bounds.right - bounds.left); + unsigned int height = (unsigned int)(bounds.bottom - bounds.top); + if (width == previous_width && height == previous_height) continue; + write_window_size(width, height); + previous_width = width; + previous_height = height; + } + CloseHandle(process.hProcess); + return 0; +} + +void WinMainCRTStartup(void) { ExitProcess(run()); } diff --git a/site/apps/calculator/index.js b/site/apps/calculator/index.js new file mode 100644 index 0000000..3721589 --- /dev/null +++ b/site/apps/calculator/index.js @@ -0,0 +1,36 @@ +import { defineApplication } from "../core/application.js"; +import { mountBoxedWineApplication } from "../core/boxedwine.js"; + +export const calculatorApplication = defineApplication({ + id: "__calculator", + title: "Calculator", + icon: "Calculator.png", + kind: "native-game", + window: { + width: 260, + height: 260, + resizable: false, + maximizable: false, + className: "xp-calculator-window", + }, + mount: (context) => + mountBoxedWineApplication({ + title: "Windows XP Calculator", + packageId: "calculator", + archive: "xp-calculator", + executable: "window-host.exe", + nativeWidth: 254, + nativeHeight: 229, + frameTop: 32, + nativeFrameWidth: 6, + nativeFrameHeight: 31, + background: "#ece9d8", + onWindowSize(width, height) { + const workArea = context.getDesktopSize(); + context.setSize( + workArea.width > 0 ? Math.min(width, workArea.width) : width, + workArea.height > 0 ? Math.min(height, workArea.height) : height, + ); + }, + }), +}); diff --git a/site/apps/catalog/accessories.js b/site/apps/catalog/accessories.js index beabf17..dae5950 100644 --- a/site/apps/catalog/accessories.js +++ b/site/apps/catalog/accessories.js @@ -4,15 +4,6 @@ const program = (id, title, icon, kind, extra = {}) => defineProgram({ id, title, icon, kind, ...extra }); export const accessoryApplications = [ - program("__calculator", "Calculator", "Calculator.png", "calculator", { - window: { - width: 260, - height: 260, - resizable: false, - maximizable: false, - className: "xp-calculator-window", - }, - }), program( "__command-prompt", "Command Prompt", diff --git a/site/apps/core/boxedwine-resize.js b/site/apps/core/boxedwine-resize.js index 9802aaf..6ec2cc5 100644 --- a/site/apps/core/boxedwine-resize.js +++ b/site/apps/core/boxedwine-resize.js @@ -17,6 +17,32 @@ const isResizeMessage = (event, origin) => { export const installBoxedWineResizeBridge = (hostWindow, module) => { let pendingResize = null; let runtimeInitialized = false; + let windowSizeTimer = null; + let previousWindowSize = ""; + + const reportWindowSize = () => { + if (!runtimeInitialized || typeof module.FS?.readFile !== "function") + return; + try { + const text = module.FS.readFile("/d_drive/boxedwine-window-size.txt", { + encoding: "utf8", + }).trim(); + if (text === previousWindowSize) return; + const match = /^(\d+) (\d+)$/.exec(text); + if (!match) return; + previousWindowSize = text; + hostWindow.parent.postMessage( + { + type: "boxedwine-window-size", + width: Number(match[1]), + height: Number(match[2]), + }, + hostWindow.location.origin, + ); + } catch { + // Most applications do not publish their native window size. + } + }; const applyResize = () => { if ( @@ -53,8 +79,17 @@ export const installBoxedWineResizeBridge = (hostWindow, module) => { onRuntimeInitialized?.(); runtimeInitialized = true; applyResize(); + if ( + typeof module.FS?.readFile === "function" && + typeof hostWindow.setInterval === "function" + ) { + windowSizeTimer = hostWindow.setInterval(reportWindowSize, 100); + } }; hostWindow.addEventListener("message", onMessage); - return () => hostWindow.removeEventListener("message", onMessage); + return () => { + hostWindow.removeEventListener("message", onMessage); + if (windowSizeTimer !== null) hostWindow.clearInterval(windowSizeTimer); + }; }; diff --git a/site/apps/core/boxedwine.js b/site/apps/core/boxedwine.js index 5ceec91..6c19d5d 100644 --- a/site/apps/core/boxedwine.js +++ b/site/apps/core/boxedwine.js @@ -51,7 +51,12 @@ export const mountBoxedWineApplication = ({ sound = true, background = "#000", scaleOnly = false, + onWindowSize, + nativeFrameWidth = 0, + nativeFrameHeight = 0, }) => { + let activeNativeWidth = nativeWidth; + let activeNativeHeight = nativeHeight; const host = document.createElement("div"); host.className = "window-content boxedwine-app-host"; host.style.background = background; @@ -80,26 +85,29 @@ export const mountBoxedWineApplication = ({ host.appendChild(frame); const updateLayout = () => { - const hostSize = getHostSize(host, nativeWidth, nativeHeight); + const hostSize = getHostSize(host, activeNativeWidth, activeNativeHeight); const shouldScale = scaleOnly || - hostSize.width < nativeWidth || - hostSize.height < nativeHeight; + hostSize.width < activeNativeWidth || + hostSize.height < activeNativeHeight; const scale = shouldScale ? Math.min( 1, - hostSize.width / nativeWidth, - hostSize.height / nativeHeight, + hostSize.width / activeNativeWidth, + hostSize.height / activeNativeHeight, ) : 1; const framebuffer = shouldScale - ? { width: nativeWidth, height: nativeHeight + frameTop } + ? { + width: activeNativeWidth, + height: activeNativeHeight + 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.width = `${activeNativeWidth}px`; + frame.style.height = `${activeNativeHeight}px`; + frame.style.left = `${Math.max(0, (hostSize.width - activeNativeWidth * scale) / 2)}px`; frame.style.transform = `scale(${scale})`; frame.style.transformOrigin = "top left"; } else { @@ -113,8 +121,8 @@ export const mountBoxedWineApplication = ({ { type: "boxedwine-framebuffer-resize", ...framebuffer, - baseWidth: nativeWidth, - baseHeight: nativeHeight + frameTop, + baseWidth: activeNativeWidth, + baseHeight: activeNativeHeight + frameTop, }, new URL(frame.src).origin, ); @@ -124,12 +132,33 @@ export const mountBoxedWineApplication = ({ : null; resizeObserver?.observe(host); frame.addEventListener("load", updateLayout); + const handleWindowMessage = (event) => { + const { type, width, height } = event.data || {}; + if ( + event.source !== frame.contentWindow || + event.origin !== new URL(frame.src).origin || + type !== "boxedwine-window-size" || + !Number.isInteger(width) || + !Number.isInteger(height) || + width < 100 || + height < 100 + ) + return; + if (nativeFrameWidth || nativeFrameHeight) { + activeNativeWidth = Math.max(1, width - nativeFrameWidth); + activeNativeHeight = Math.max(1, height - nativeFrameHeight); + } + onWindowSize?.(width, height); + updateLayout(); + }; + window.addEventListener("message", handleWindowMessage); updateLayout(); return { element: host, unmount() { resizeObserver?.disconnect(); + window.removeEventListener("message", handleWindowMessage); frame.removeEventListener("load", updateLayout); frame.src = "about:blank"; frame.remove(); diff --git a/site/apps/index.js b/site/apps/index.js index 90bb2bf..25fc45c 100644 --- a/site/apps/index.js +++ b/site/apps/index.js @@ -9,11 +9,13 @@ import { minesweeperApplication } from "./minesweeper/index.js"; import { solitaireApplication } from "./solitaire/index.js"; import { pinballApplication } from "./pinball/index.js"; import { xpCardGameApplications } from "./xp-card-games/index.js"; +import { calculatorApplication } from "./calculator/index.js"; export const applicationRegistry = createApplicationRegistry([ ...accessoryApplications, ...systemToolApplications, ...systemApplications, + calculatorApplication, minesweeperApplication, notepadApplication, paintApplication, diff --git a/site/apps/programs/define-program.js b/site/apps/programs/define-program.js index 34df2a8..5234074 100644 --- a/site/apps/programs/define-program.js +++ b/site/apps/programs/define-program.js @@ -2,7 +2,6 @@ import { defineApplication } from "../core/application.js"; import * as renderers from "./renderers/index.js"; const PROGRAM_RENDERERS = Object.freeze({ - calculator: renderers.renderCalculator, terminal: renderers.renderTerminal, volume: renderers.renderVolume, }); diff --git a/site/apps/programs/renderers/calculator.js b/site/apps/programs/renderers/calculator.js deleted file mode 100644 index 9a42e64..0000000 --- a/site/apps/programs/renderers/calculator.js +++ /dev/null @@ -1,393 +0,0 @@ -import { createProgramRoot } from "../ui.js"; - -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 = ` - -
- -
- - - - -
-
-
`; - - const display = content.querySelector(".xp-calculator-display"); - const keys = content.querySelector(".xp-calculator-keys"); - 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}.`; - }; - - 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.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(); - } - } 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", - ); - } - }); - }); - - 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/index.js b/site/apps/programs/renderers/index.js index 76f921a..7c86ef9 100644 --- a/site/apps/programs/renderers/index.js +++ b/site/apps/programs/renderers/index.js @@ -1,3 +1,2 @@ -export { renderCalculator } from "./calculator.js"; export { renderTerminal } from "./terminal.js"; export { renderVolume } from "./volume.js"; diff --git a/site/css/shell/themes.css b/site/css/shell/themes.css index 9ee2751..3d88514 100644 --- a/site/css/shell/themes.css +++ b/site/css/shell/themes.css @@ -694,217 +694,11 @@ html[data-xp-appearance="classic"] .start-program-flyout button:focus-visible { min-height: min(260px, 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: 0; +.xp-calculator-window .window-content, +.xp-calculator-window .boxedwine-app-host { background: #ece9d8; } -.xp-calculator-menu-group { - position: relative; -} - -.xp-calculator-menu-trigger { - box-sizing: border-box; - height: 21px; - padding: 1px 3px 2px; - border: 1px solid transparent; - background: transparent; - color: #000; -} - -.xp-calculator-menu-group:nth-child(1) .xp-calculator-menu-trigger { - width: 30px; -} - -.xp-calculator-menu-group:nth-child(2) .xp-calculator-menu-trigger, -.xp-calculator-menu-group:nth-child(3) .xp-calculator-menu-trigger { - width: 34px; -} - -.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 { - position: relative; - height: 209px; - padding: 0; -} - -.xp-calculator-display { - box-sizing: border-box; - position: absolute; - top: 0; - left: 7px; - width: 239px; - height: 23px; - margin: 0; - padding: 2px 4px; - border: 1px solid #7f9db9; - border-radius: 0; - background: #fff; - color: #000; - text-align: right; - font: - 11px Tahoma, - sans-serif; -} - -.xp-calculator-clear-row { - position: absolute; - top: 36px; - left: 11px; - width: 234px; - height: 28px; -} - -.xp-calculator-clear-row .xp-calculator-memory-indicator { - box-sizing: border-box; - width: 26px; - height: 27px; - margin: 1px 0 0; - border: 1px solid; - border-color: #7f7f7f #fff #fff #7f7f7f; - color: #f00; - text-align: center; - font: - 11px/24px Tahoma, - sans-serif; -} - -.xp-calculator-clear-row button { - position: absolute; - top: 0; - width: 61px; - height: 28px; -} - -.xp-calculator-clear-row button:nth-of-type(1) { - left: 43px; -} - -.xp-calculator-clear-row button:nth-of-type(2) { - left: 108px; -} - -.xp-calculator-clear-row button:nth-of-type(3) { - left: 173px; -} - -.xp-calculator-clear-row button, -.xp-calculator-keys button { - box-sizing: border-box; - height: 27px; - padding: 1px 2px; - border: 1px solid #003c74; - border-radius: 3px; - background: linear-gradient(180deg, #fff, #f7f6f1 8%, #ece9d8 88%, #d6d0c4); - box-shadow: inset 1px 1px #fff; -} - -.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-calculator-clear-row button { - color: #f00; -} - -.xp-calculator-keys { - position: absolute; - top: 72px; - left: 9px; - display: grid; - grid-template-columns: 39px repeat(5, 34px); - gap: 6px; -} - -.xp-calculator-keys button { - width: 34px; -} - -.xp-calculator-keys button[data-key-type="number"] { - color: #00f; -} - -.xp-calculator-keys button[data-key-type="operator"], -.xp-calculator-keys button[data-key-type="memory"] { - color: #f00; -} - .xp-command-prompt-window { min-width: min(320px, calc(100% - 8px)); min-height: min(180px, calc(100% - 8px)); diff --git a/site/iframe/calculator/SOURCES.json b/site/iframe/calculator/SOURCES.json new file mode 100644 index 0000000..67a5d28 --- /dev/null +++ b/site/iframe/calculator/SOURCES.json @@ -0,0 +1,27 @@ +{ + "runtime": "boxedwine", + "windowsXp": { + "source": "User-supplied Windows XP Professional SP3 source-media ISO", + "sourceSha256": "fd8c8d42c1581e8767217fe800bfc0d5649c0ad20d754c927d6c763e446d1927", + "package": { + "file": "xp-calculator.zip", + "bytes": 69002, + "sha256": "8fb2df72143efdd104e1c2fe4508a5bf6e379d59f2c2648cd8753d85f21036d2" + }, + "files": { + "calc.exe": { + "member": "I386/CALC.EX_", + "sha256": "37121ecb7c1e112b735bd21b0dfe3e526352ecb98c434c5f40e6a2a582380cdd" + }, + "calc.chm": { + "member": "I386/CALC.CH_", + "sha256": "180e7b08e5a72ab907e4c5372530df107097de6b3cd6e6f4a63519fc171bd3bb" + }, + "window-host.exe": { + "source": "native/calculator/window-host.c", + "build": "SOURCE_DATE_EPOCH=0 i686-w64-mingw32-gcc -Os -s -nostdlib -mwindows -Wl,--no-insert-timestamp native/calculator/window-host.c -lkernel32 -luser32 -o window-host.exe", + "sha256": "3d1180fc264d7d026ea53f6b58542d61da6927216dc08a92850e29c50d07020e" + } + } + } +} diff --git a/site/iframe/calculator/xp-calculator.zip b/site/iframe/calculator/xp-calculator.zip new file mode 100644 index 0000000..0467151 Binary files /dev/null and b/site/iframe/calculator/xp-calculator.zip differ diff --git a/tests/calculator.test.ts b/tests/calculator.test.ts index 2a56efa..fc30cb0 100644 --- a/tests/calculator.test.ts +++ b/tests/calculator.test.ts @@ -1,6 +1,25 @@ // @ts-nocheck -- Happy DOM's element types intentionally replace lib.dom here. import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + import { cleanupShells, loadShell, login } from "./helpers/shell-harness"; +import { installBoxedWineResizeBridge } from "../site/apps/core/boxedwine-resize.js"; + +const require = createRequire(import.meta.url); +const { unzipSync } = require("fflate"); +const projectDirectory = join(dirname(fileURLToPath(import.meta.url)), ".."); +const calculatorDirectory = join( + projectDirectory, + "site", + "iframe", + "calculator", +); +const sha256 = (content: Uint8Array) => + createHash("sha256").update(content).digest("hex"); afterEach(cleanupShells); @@ -14,103 +33,162 @@ const launchCalculator = async () => { 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 }; + return { shell, calculator }; }; -describe("Windows XP Calculator", () => { - test("matches the fixed standard-mode layout from XP", async () => { - const { calculator, display } = await launchCalculator(); +describe("original Windows XP Calculator through BoxedWine", () => { + test("launches the authentic XP executable in its fixed Standard window", async () => { + const { calculator } = await launchCalculator(); + const frame = calculator.querySelector(".boxedwine-app-frame"); + const url = new URL(frame.src); expect(calculator.style.width).toBe("260px"); expect(calculator.style.height).toBe("260px"); 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", - "+/-", - ".", - "+", - "=", - ]); + expect(calculator.querySelector(".resize-handle")).toBeNull(); + expect(url.searchParams.get("archive")).toBe("xp-calculator"); + expect(url.searchParams.get("executable")).toBe("window-host.exe"); + expect(url.searchParams.get("resolution")).toBe("254x261"); + expect(url.searchParams.get("frameTop")).toBe("32"); }); - 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("follows the real Calculator window when XP switches to Scientific mode", async () => { + const { shell, calculator } = await launchCalculator(); + const frame = calculator.querySelector(".boxedwine-app-frame"); + + shell.window.dispatchEvent( + new shell.window.MessageEvent("message", { + data: { type: "boxedwine-window-size", width: 480, height: 260 }, + origin: new URL(frame.src).origin, + source: frame.contentWindow, + }), + ); + + expect(calculator.style.width).toBe("480px"); + expect(calculator.style.height).toBe("260px"); + + shell.window.dispatchEvent( + new shell.window.MessageEvent("message", { + data: { type: "boxedwine-window-size", width: 260, height: 260 }, + origin: new URL(frame.src).origin, + source: frame.contentWindow, + }), + ); + expect(calculator.style.width).toBe("260px"); }); - 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 }), - ); + test("scales the complete Scientific 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() {} + }; + + 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 host = calculator.querySelector(".boxedwine-app-host"); + const frame = host.querySelector(".boxedwine-app-frame"); + + shell.window.dispatchEvent( + new shell.window.MessageEvent("message", { + data: { type: "boxedwine-window-size", width: 480, height: 260 }, + origin: new URL(frame.src).origin, + source: frame.contentWindow, + }), + ); + Object.defineProperties(host, { + clientWidth: { configurable: true, value: 384 }, + clientHeight: { configurable: true, value: 229 }, + }); + resize(); + + expect(calculator.style.width).toBe("390px"); + expect(calculator.style.height).toBe("260px"); + expect(frame.style.width).toBe("474px"); + expect(frame.style.height).toBe("229px"); + expect(frame.style.transform).toBe(`scale(${384 / 474})`); + }); + + test("packages the original executable, complete help, and window host", async () => { + const manifest = JSON.parse( + await readFile(join(calculatorDirectory, "SOURCES.json"), "utf8"), + ); + const packageContent = new Uint8Array( + await readFile(join(calculatorDirectory, "xp-calculator.zip")), + ); + const files = unzipSync(packageContent); + + expect(packageContent.byteLength).toBe(manifest.windowsXp.package.bytes); + expect(sha256(packageContent)).toBe(manifest.windowsXp.package.sha256); + expect(Object.keys(files).sort()).toEqual([ + "calc.chm", + "calc.exe", + "window-host.exe", + ]); + for (const [name, source] of Object.entries(manifest.windowsXp.files)) { + expect(sha256(files[name])).toBe(source.sha256); } - expect(display.value).toBe("42."); + }); + + test("publishes native Standard and Scientific window sizes to the shell", () => { + let pollWindowSize; + const posted = []; + const runnerWindow = { + location: { origin: "https://flash.test" }, + parent: { + postMessage(message, origin) { + posted.push({ message, origin }); + }, + }, + addEventListener() {}, + removeEventListener() {}, + setInterval(callback) { + pollWindowSize = callback; + return 1; + }, + clearInterval() {}, + }; + let nativeSize = "260 260"; + const module = { + _boxedwine_resize_screen() {}, + FS: { + readFile() { + return nativeSize; + }, + writeFile() {}, + unlink() {}, + rename() {}, + }, + }; + installBoxedWineResizeBridge(runnerWindow, module); + module.onRuntimeInitialized(); + + pollWindowSize(); + expect(posted.at(-1)).toEqual({ + message: { type: "boxedwine-window-size", width: 260, height: 260 }, + origin: "https://flash.test", + }); + + nativeSize = "480 260"; + pollWindowSize(); + expect(posted.at(-1)).toEqual({ + message: { type: "boxedwine-window-size", width: 480, height: 260 }, + origin: "https://flash.test", + }); }); }); diff --git a/tests/shell-behavior.test.ts b/tests/shell-behavior.test.ts index 71a1c68..e9e2931 100644 --- a/tests/shell-behavior.test.ts +++ b/tests/shell-behavior.test.ts @@ -506,18 +506,11 @@ test("All Programs exposes system applications and games in the XP hierarchy", a const calculatorWindow = shell.document.querySelector( '.xp-window[data-game="__calculator"]', )!; - const calculatorKeys = [ - ...calculatorWindow.querySelectorAll( - ".xp-calculator-keys button", - ), - ]; - for (const key of ["2", "+", "3", "="]) { - calculatorKeys.find((button) => button.textContent === key)!.click(); - } - expect( - calculatorWindow.querySelector(".xp-calculator-display")! - .value, - ).toBe("5."); + const calculatorUrl = new URL( + calculatorWindow.querySelector("iframe")!.src, + ); + expect(calculatorUrl.searchParams.get("archive")).toBe("xp-calculator"); + expect(calculatorUrl.searchParams.get("executable")).toBe("window-host.exe"); shell.document.getElementById("start-button")!.click(); shell.document.getElementById("all-programs-button")!.click();