diff --git a/cmake/misc/node-pre-script.js b/cmake/misc/node-pre-script.js index ee8eb4ee..de622d94 100644 --- a/cmake/misc/node-pre-script.js +++ b/cmake/misc/node-pre-script.js @@ -1,3 +1,3 @@ -Module['preRun'] = () => { - ENV = process.env; -}; +Module["preRun"] = () => { + ENV = process.env; +}; diff --git a/page/package-lock.json b/page/package-lock.json index 51c380ce..9ebe7d75 100644 --- a/page/package-lock.json +++ b/page/package-lock.json @@ -11,6 +11,7 @@ "@fontsource/inter": "^5.2.5", "@irori/idbfs": "^0.5.0", "@radix-ui/react-checkbox": "^1.2.3", + "@radix-ui/react-context-menu": "^2.2.12", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-label": "^2.1.4", @@ -30,13 +31,15 @@ "react": "^19.0.0", "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", "react-helmet": "^6.1.0", "react-resizable-panels": "^2.1.9", "react-router-dom": "^7.5.3", "react-window": "^1.8.11", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", - "tw-animate-css": "^1.2.8" + "tw-animate-css": "^1.2.8", + "vaul": "^1.1.2" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -1356,6 +1359,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.12.tgz", + "integrity": "sha512-5UFKuTMX8F2/KjHvyqu9IYT8bEtDSCJwwIx1PghBo4jh9S6jJVsceq9xIjqsOVcxsynGwV5eaqPE3n/Cu+DrSA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.12", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.11.tgz", @@ -3044,6 +3075,15 @@ "node": ">=10" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3630,6 +3670,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4617,6 +4669,23 @@ "react": "^19.1.0" } }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -5265,6 +5334,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vite": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", diff --git a/page/package.json b/page/package.json index 374fc448..2d5911ea 100644 --- a/page/package.json +++ b/page/package.json @@ -13,6 +13,7 @@ "@fontsource/inter": "^5.2.5", "@irori/idbfs": "^0.5.0", "@radix-ui/react-checkbox": "^1.2.3", + "@radix-ui/react-context-menu": "^2.2.12", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-label": "^2.1.4", @@ -32,13 +33,15 @@ "react": "^19.0.0", "react-bootstrap-icons": "^1.11.5", "react-dom": "^19.0.0", + "react-dropzone": "^14.3.8", "react-helmet": "^6.1.0", "react-resizable-panels": "^2.1.9", "react-router-dom": "^7.5.3", "react-window": "^1.8.11", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", - "tw-animate-css": "^1.2.8" + "tw-animate-css": "^1.2.8", + "vaul": "^1.1.2" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/page/src/App.tsx b/page/src/App.tsx index 17a58b11..ce332ae1 100644 --- a/page/src/App.tsx +++ b/page/src/App.tsx @@ -1,7 +1,8 @@ import { ThemeProvider } from "@/components/theme-provider"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { HashRouter, Route, Routes, Navigate } from "react-router-dom"; -import { Playground } from "./Playground"; -import { LandingPage } from "./LandingPage"; +import { Playground } from "./playground"; +import { LandingPage } from "./landing-page"; import "@fontsource/inter/100.css"; import "@fontsource/inter/200.css"; @@ -18,13 +19,15 @@ import "./App.css"; function App() { return ( - - - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + + + ); } diff --git a/page/src/Playground.tsx b/page/src/Playground.tsx deleted file mode 100644 index f9b98885..00000000 --- a/page/src/Playground.tsx +++ /dev/null @@ -1,201 +0,0 @@ -import { useState, useRef, useReducer } from "react"; -import { Output } from "@/components/output"; - -import { Separator } from "@/components/ui/separator"; - -import { Emulator, UserFile, EmulationState } from "./emulator"; -import { setupFilesystem } from "./filesystem"; - -import "./App.css"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; - -import { createDefaultSettings } from "./settings"; -import { SettingsMenu } from "@/components/settings-menu"; - -import { - PlayFill, - StopFill, - GearFill, - PauseFill, - FileEarmarkCheckFill, - ImageFill, -} from "react-bootstrap-icons"; -import { StatusIndicator } from "@/components/status-indicator"; -import { Header } from "./Header"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuGroup, -} from "@/components/ui/dropdown-menu"; -import { Button } from "@/components/ui/button"; - -function selectAndReadFile(): Promise { - return new Promise((resolve, reject) => { - const fileInput = document.createElement("input"); - fileInput.type = "file"; - fileInput.accept = ".exe"; - - fileInput.addEventListener("change", function (event) { - const file = (event as any).target.files[0]; - if (file) { - const reader = new FileReader(); - - reader.onload = function (e: ProgressEvent) { - const arrayBuffer = e.target?.result; - resolve({ - name: file.name, - data: arrayBuffer as ArrayBuffer, - }); - }; - - reader.onerror = function (e: ProgressEvent) { - reject(new Error("Error reading file: " + e.target?.error)); - }; - - reader.readAsArrayBuffer(file); - } else { - reject(new Error("No file selected")); - } - }); - - fileInput.click(); - }); -} - -export function Playground() { - const output = useRef(null); - const [settings, setSettings] = useState(createDefaultSettings()); - const [emulator, setEmulator] = useState(null); - const [, forceUpdate] = useReducer((x) => x + 1, 0); - - function logLine(line: string) { - output.current?.logLine(line); - } - - function logLines(lines: string[]) { - output.current?.logLines(lines); - } - - function isEmulatorPaused() { - return emulator && emulator.getState() == EmulationState.Paused; - } - - function toggleEmulatorState() { - if (isEmulatorPaused()) { - emulator?.resume(); - } else { - emulator?.pause(); - } - } - - async function createEmulator(userFile: UserFile | null = null) { - emulator?.stop(); - output.current?.clear(); - - logLine("Starting emulation..."); - - await setupFilesystem((current, total, file) => { - logLine(`Processing filesystem (${current}/${total}): ${file}`); - }); - - const new_emulator = new Emulator(logLines, (_) => forceUpdate()); - new_emulator.onTerminate().then(() => setEmulator(null)); - setEmulator(new_emulator); - - new_emulator.start(settings, userFile); - } - - async function loadAndRunUserFile() { - const fileBuffer = await selectAndReadFile(); - await createEmulator(fileBuffer); - } - - return ( - <> - - - - - - - Run - - - - Run Application - - - createEmulator()}> - - Select Sample - - loadAndRunUserFile()}> - - Select your .exe - - - - - - emulator?.stop()} - > - Stop - - - {isEmulatorPaused() ? ( - <> - Resume - > - ) : ( - <> - Pause - > - )} - - - - - - Settings - - - - - - - - - - - - - - - > - ); -} diff --git a/page/src/components/folder.tsx b/page/src/components/folder.tsx new file mode 100644 index 00000000..3c92d8ed --- /dev/null +++ b/page/src/components/folder.tsx @@ -0,0 +1,158 @@ +import { + FolderFill, + FolderSymlinkFill, + FileEarmark, + FiletypeExe, + FileEarmarkBinary, +} from "react-bootstrap-icons"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + ContextMenuSeparator, + ContextMenuLabel, +} from "@/components/ui/context-menu"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +export enum FolderElementType { + Folder = 0, + File, +} + +export interface FolderElement { + name: string; + type: FolderElementType; +} + +type ClickHandler = (element: FolderElement) => void; +type CreateFolderHandler = () => void; +type RemoveElementHandler = (element: FolderElement) => void; +type RenameElementHandler = (element: FolderElement) => void; +type AddFilesHandler = () => void; + +export interface FolderProps { + elements: FolderElement[]; + clickHandler: ClickHandler; + createFolderHandler: CreateFolderHandler; + removeElementHandler: RemoveElementHandler; + renameElementHandler: RenameElementHandler; + addFilesHandler: AddFilesHandler; +} + +function elementComparator(e1: FolderElement, e2: FolderElement) { + if (e1.type != e2.type) { + return e1.type - e2.type; + } + + return e1.name.localeCompare(e2.name); +} + +function getIcon(element: FolderElement, className: string = "") { + switch (element.type) { + case FolderElementType.File: + if (element.name.endsWith(".dll")) { + return ; + } + if (element.name.endsWith(".exe")) { + return ; + } + return ; + case FolderElementType.Folder: + return element.name == ".." ? ( + + ) : ( + + ); + default: + return <>>; + } +} + +function renderIcon(element: FolderElement) { + let className = "w-6 h-6 flex-1"; + return getIcon(element, className); +} + +function renderElement(element: FolderElement, clickHandler: ClickHandler) { + return ( + clickHandler(element)} + className="folder-element select-none flex flex-col gap-2 items-center text-center text-xs p-2 m-2 w-25 h-18 rounded-lg border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50" + > + {renderIcon(element)} + + {element.name} + + + ); +} + +function renderElementWithContext(element: FolderElement, props: FolderProps) { + if (element.name == "..") { + return renderElement(element, props.clickHandler); + } + + return ( + + + + + {renderElement(element, props.clickHandler)} + + + {element.name} + + + + + {element.name} + + props.renameElementHandler(element)}> + Rename + + props.removeElementHandler(element)}> + Delete + + + + ); +} + +function renderElementWrapper(element: FolderElement, props: FolderProps) { + return ( + + {renderElementWithContext(element, props)} + + ); +} + +export function Folder(props: FolderProps) { + return ( + + + + + {props.elements + .sort(elementComparator) + .map((e) => renderElementWrapper(e, props))} + + + + + + Create new Folder + + + Add Files + + + + ); +} diff --git a/page/src/components/settings-menu.tsx b/page/src/components/settings-menu.tsx index aa80d528..06a0dac4 100644 --- a/page/src/components/settings-menu.tsx +++ b/page/src/components/settings-menu.tsx @@ -24,8 +24,10 @@ export class SettingsMenu extends React.Component { this.setState(() => settings); } - componentDidUpdate() { - this.props.onChange(this.state); + componentDidUpdate(_: SettingsMenuProps, oldSettings: Settings) { + if (JSON.stringify(oldSettings) !== JSON.stringify(this.state)) { + this.props.onChange(this.state); + } } render() { diff --git a/page/src/components/ui/context-menu.tsx b/page/src/components/ui/context-menu.tsx new file mode 100644 index 00000000..f7cd5022 --- /dev/null +++ b/page/src/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/page/src/components/ui/dialog.tsx b/page/src/components/ui/dialog.tsx new file mode 100644 index 00000000..3b37abd1 --- /dev/null +++ b/page/src/components/ui/dialog.tsx @@ -0,0 +1,133 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/page/src/components/ui/drawer.tsx b/page/src/components/ui/drawer.tsx new file mode 100644 index 00000000..7e2126e4 --- /dev/null +++ b/page/src/components/ui/drawer.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +function Drawer({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/page/src/components/ui/tooltip.tsx b/page/src/components/ui/tooltip.tsx index bf4a342a..515ee323 100644 --- a/page/src/components/ui/tooltip.tsx +++ b/page/src/components/ui/tooltip.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as React from "react"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; diff --git a/page/src/emulator.ts b/page/src/emulator.ts index 3bc7f45a..63962ed7 100644 --- a/page/src/emulator.ts +++ b/page/src/emulator.ts @@ -5,15 +5,10 @@ import * as flatbuffers from "flatbuffers"; import * as fbDebugger from "@/fb/debugger"; import * as fbDebuggerEvent from "@/fb/debugger/event"; -import { storeFile } from "./filesystem"; +import { Filesystem } from "./filesystem"; type LogHandler = (lines: string[]) => void; -export interface UserFile { - name: string; - data: ArrayBuffer; -} - export enum EmulationState { Stopped, Paused, @@ -77,19 +72,7 @@ export class Emulator { this.worker.onmessage = this._onMessage.bind(this); } - async start( - settings: Settings = createDefaultSettings(), - userFile: UserFile | null = null, - ) { - var file = "c:/test-sample.exe"; - if (userFile) { - const filename = userFile.name.split("/").pop()?.split("\\").pop(); - const canonicalName = filename?.toLowerCase(); - file = "c:/" + canonicalName; - - await storeFile("root/filesys/c/" + canonicalName, userFile.data); - } - + async start(settings: Settings = createDefaultSettings(), file: string) { this._setState(EmulationState.Running); this.worker.postMessage({ message: "run", diff --git a/page/src/filesystem-explorer.tsx b/page/src/filesystem-explorer.tsx new file mode 100644 index 00000000..b3d24df9 --- /dev/null +++ b/page/src/filesystem-explorer.tsx @@ -0,0 +1,572 @@ +import React from "react"; +import { Folder, FolderElement, FolderElementType } from "./components/folder"; +import { Filesystem } from "./filesystem"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "./components/ui/button"; +import { Input } from "./components/ui/input"; +import { DialogDescription } from "@radix-ui/react-dialog"; + +import Dropzone from "react-dropzone"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +import { HouseFill } from "react-bootstrap-icons"; + +export interface FilesystemExplorerProps { + filesystem: Filesystem; + runFile: (file: string) => void; + resetFilesys: () => void; + path: string[]; +} +export interface FilesystemExplorerState { + path: string[]; + createFolder: boolean; + resetFilesys: boolean; + errorText: string; + removeFile: string; + renameFile: string; +} + +function makeFullPath(path: string[]) { + return "/root/filesys/" + path.join("/"); +} + +function makeFullPathAndJoin(path: string[], element: string) { + return makeFullPath([...path, element]); +} + +function makeFullPathWithState( + state: FilesystemExplorerState, + element: string, +) { + return makeFullPathAndJoin(state.path, element); +} + +function relativePathToWindowsPath(fullPath: string) { + if (fullPath.length == 0) { + return fullPath; + } + + const drive = fullPath.substring(0, 1); + const rest = fullPath.substring(1); + + return `${drive}:${rest}`; +} + +function makeRelativePathWithState( + state: FilesystemExplorerState, + element: string, +) { + return [...state.path, element].join("/"); +} + +function makeWindowsPathWithState( + state: FilesystemExplorerState, + element: string, +) { + const fullPath = makeRelativePathWithState(state, element); + return relativePathToWindowsPath(fullPath); +} + +function getFolderElements(filesystem: Filesystem, path: string[]) { + const fullPath = makeFullPath(path); + const files = filesystem.readDir(fullPath); + + return files + .filter((f) => { + if (f == ".") { + return false; + } + + if (path.length == 0 && f == "..") { + return false; + } + + return true; + }) + .map((f) => { + const element: FolderElement = { + name: f, + type: filesystem.isFolder(`${fullPath}/${f}`) + ? FolderElementType.Folder + : FolderElementType.File, + }; + + return element; + }); +} + +interface FileWithData { + file: File; + data: ArrayBuffer; +} + +function readFile(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (reader.readyState === FileReader.DONE) { + resolve({ + file, + data: reader.result as ArrayBuffer, + }); + } + }; + reader.onerror = reject; + reader.readAsArrayBuffer(file); + }); +} + +async function readFiles(files: FileList | File[]): Promise { + const promises = []; + + for (let i = 0; i < files.length; i++) { + promises.push(readFile(files[i])); + } + + return Promise.all(promises); +} + +function selectFiles(): Promise { + return new Promise((resolve) => { + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".exe"; + + fileInput.addEventListener("change", function (event) { + const files = (event as any).target.files as FileList; + resolve(files); + }); + + fileInput.click(); + }); +} + +interface BreadcrumbElement { + node: React.ReactNode; + targetPath: string[]; +} + +function generateBreadcrumbElements(path: string[]): BreadcrumbElement[] { + const elements = path.map((p, index) => { + const e: BreadcrumbElement = { + node: p, + targetPath: path.slice(0, index + 1), + }; + + return e; + }); + elements.unshift({ + node: , + targetPath: [], + }); + + return elements; +} + +export class FilesystemExplorer extends React.Component< + FilesystemExplorerProps, + FilesystemExplorerState +> { + constructor(props: FilesystemExplorerProps) { + super(props); + + this._onAddFiles = this._onAddFiles.bind(this); + this._uploadFiles = this._uploadFiles.bind(this); + this._onElementSelect = this._onElementSelect.bind(this); + + this.state = { + path: this.props.path, + createFolder: false, + resetFilesys: false, + errorText: "", + removeFile: "", + renameFile: "", + }; + } + + _showError(errorText: string) { + this.setState({ errorText }); + } + + _onElementSelect(element: FolderElement) { + if (element.type != FolderElementType.Folder) { + if (element.name.endsWith(".exe")) { + const file = makeWindowsPathWithState(this.state, element.name); + this.props.runFile(file); + } + + return; + } + + this.setState((s) => { + const path = [...s.path]; + + if (element.name == "..") { + path.pop(); + } else { + path.push(element.name); + } + + return { + path, + }; + }); + } + + async _onFileRename(file: string, newFile: string) { + const oldPath = makeFullPathWithState(this.state, file); + const newPath = makeFullPathWithState(this.state, newFile); + + this.setState({ renameFile: "" }); + + await this.props.filesystem.rename(oldPath, newPath); + this.forceUpdate(); + } + + async _onAddFiles() { + const files = await selectFiles(); + await this._uploadFiles(files); + } + + async _onFolderCreate(name: string) { + this.setState({ createFolder: false }); + + name = name.toLowerCase(); + + if (name.length == 0) { + return; + } + + if (name.includes("/") || name.includes("\\")) { + this._showError("Folder must not contain special characters"); + return; + } + + if (this.state.path.length == 0 && name.length > 1) { + this._showError("Drives must be a single letter"); + return; + } + + const fullPath = makeFullPathWithState(this.state, name); + await this.props.filesystem.createFolder(fullPath); + this.forceUpdate(); + } + + async _uploadFiles(files: FileList | File[]) { + if (files.length == 0) { + return; + } + + if (this.state.path.length == 0) { + this._showError("Files must be within a drive"); + return; + } + + const fileData = (await readFiles(files)).map((f) => { + return { + name: makeFullPathWithState(this.state, f.file.name.toLowerCase()), + data: f.data, + }; + }); + + await this.props.filesystem.storeFiles(fileData); + this.forceUpdate(); + } + + _renderCreateFolderDialog() { + return ( + this.setState({ createFolder: open })} + > + + { + const folderName = (e.target as any).elements.name.value; + this._onFolderCreate(folderName); + e.preventDefault(); + }} + > + + Create new folder + + Create new folder + + + + + + + + Create + + + + Cancel + + + + + + + ); + } + + _renderRenameDialog() { + return ( + 0} + onOpenChange={(open) => (open ? {} : this.setState({ renameFile: "" }))} + > + + { + const newName = (e.target as any).elements.name.value; + this._onFileRename(this.state.renameFile, newName); + e.preventDefault(); + }} + > + + Rename {this.state.renameFile} + + Rename {this.state.renameFile} + + + + + + + + Rename + + + + Cancel + + + + + + + ); + } + + _renderErrorDialog() { + return ( + 0} + onOpenChange={(open) => (open ? {} : this.setState({ errorText: "" }))} + > + + + Error + + Error: {this.state.errorText} + + + {this.state.errorText} + + this.setState({ errorText: "" })} + > + Ok + + + + + ); + } + + _renderRemoveDialog() { + return ( + 0} + onOpenChange={(open) => (open ? {} : this.setState({ removeFile: "" }))} + > + + + Delete {this.state.removeFile}? + + Delete {this.state.removeFile} + + + + Are you sure you want to delete{" "} + + {makeRelativePathWithState(this.state, this.state.removeFile)} + + + + { + const file = makeFullPathWithState( + this.state, + this.state.removeFile, + ); + this.setState({ removeFile: "" }); + this.props.filesystem + .unlink(file) + .then(() => this.forceUpdate()); + }} + > + Ok + + { + this.setState({ removeFile: "" }); + }} + > + Cancel + + + + + ); + } + + _renderResetDialog() { + return ( + this.setState({ resetFilesys: open })} + > + + + Reset filesystem + + Reset filesystem + + + + Are you sure you want to reset the filesystem? + + + { + this.setState({ resetFilesys: false }); + this.props.resetFilesys(); + }} + > + Ok + + { + this.setState({ resetFilesys: false }); + }} + > + Cancel + + + + + ); + } + + _renderBreadcrumbElements() { + const elements = generateBreadcrumbElements(this.state.path); + + const nodes = elements.map((e, index) => { + if (index == this.state.path.length) { + return ( + + + {e.node} + + + ); + } + + const navigate = () => this.setState({ path: e.targetPath }); + return ( + + + {e.node} + + + ); + }); + + return [ + ...nodes.map((n, index) => [ + n, + , + ]), + ].slice(0, -1); + } + + _renderBreadCrumb() { + return ( + + {this._renderBreadcrumbElements()} + + ); + } + + render() { + const elements = getFolderElements(this.props.filesystem, this.state.path); + + return ( + <> + {this._renderCreateFolderDialog()} + {this._renderRenameDialog()} + {this._renderErrorDialog()} + {this._renderRemoveDialog()} + {this._renderResetDialog()} + + + {this._renderBreadCrumb()} + + this.setState({ resetFilesys: true })} + variant="destructive" + > + Reset + + + + + + {({ getRootProps, getInputProps }) => ( + + + + this.setState({ createFolder: true }) + } + removeElementHandler={(e) => + this.setState({ removeFile: e.name }) + } + renameElementHandler={(e) => + this.setState({ renameFile: e.name }) + } + addFilesHandler={this._onAddFiles} + /> + + )} + + > + ); + } +} diff --git a/page/src/filesystem.ts b/page/src/filesystem.ts index 0b264a27..357e3f31 100644 --- a/page/src/filesystem.ts +++ b/page/src/filesystem.ts @@ -1,65 +1,6 @@ -import { parseZipFile, FileEntry, ProgressHandler } from "./zip-file"; +import { parseZipFile, ProgressHandler } from "./zip-file"; import idbfsModule, { MainModule } from "@irori/idbfs"; -function openDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open("cacheDB", 1); - - request.onerror = (event: Event) => { - reject(event); - }; - - request.onsuccess = (event: Event) => { - resolve((event as any).target.result as IDBDatabase); - }; - - request.onupgradeneeded = (event: Event) => { - const db = (event as any).target.result as IDBDatabase; - if (!db.objectStoreNames.contains("cacheStore")) { - db.createObjectStore("cacheStore", { keyPath: "id" }); - } - }; - }); -} - -async function saveData(id: string, data: any) { - const db = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(["cacheStore"], "readwrite"); - const objectStore = transaction.objectStore("cacheStore"); - const request = objectStore.put({ id: id, data: data }); - - request.onsuccess = () => { - resolve("Data saved successfully"); - }; - - request.onerror = (event) => { - reject("Save error: " + (event as any).target.errorCode); - }; - }); -} - -async function getData(id: string) { - const db = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(["cacheStore"], "readonly"); - const objectStore = transaction.objectStore("cacheStore"); - const request = objectStore.get(id); - - request.onsuccess = (event) => { - if ((event as any).target.result) { - resolve((event as any).target.result.data); - } else { - resolve(null); - } - }; - - request.onerror = (event) => { - reject("Retrieve error: " + (event as any).target.errorCode); - }; - }); -} - function fetchFilesystemZip() { return fetch("./root.zip?1", { method: "GET", @@ -97,11 +38,131 @@ async function initializeIDBFS() { return idbfs; } +export interface FileWithData { + name: string; + data: ArrayBuffer; +} + +function deleteDatabase(dbName: string) { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(dbName); + + request.onsuccess = () => { + resolve(); + }; + + request.onerror = () => { + reject(new Error(`Error deleting database ${dbName}.`)); + }; + + request.onblocked = () => { + reject(new Error(`Deletion of database ${dbName} blocked.`)); + }; + }); +} + +function filterPseudoDir(e: string) { + return e != "." && e != ".."; +} + +export class Filesystem { + private idbfs: MainModule; + + constructor(idbfs: MainModule) { + this.idbfs = idbfs; + } + + _storeFile(file: FileWithData) { + if (file.name.includes("/")) { + const folder = file.name.split("/").slice(0, -1).join("/"); + this._createFolder(folder); + } + + const buffer = new Uint8Array(file.data); + this.idbfs.FS.writeFile(file.name, buffer); + } + + async storeFiles(files: FileWithData[]) { + files.forEach((f) => { + this._storeFile(f); + }); + + await this.sync(); + } + + _unlinkRecursive(element: string) { + if (!this.isFolder(element)) { + this.idbfs.FS.unlink(element); + return; + } + + this.readDir(element) // + .filter(filterPseudoDir) + .forEach((e) => { + this._unlinkRecursive(`${element}/${e}`); + }); + + this.idbfs.FS.rmdir(element); + } + + async rename(oldFile: string, newFile: string) { + this.idbfs.FS.rename(oldFile, newFile); + await this.sync(); + } + + async unlink(file: string) { + this._unlinkRecursive(file); + await this.sync(); + } + + _createFolder(folder: string) { + this.idbfs.FS.mkdirTree(folder, 0o777); + } + + async createFolder(folder: string) { + this._createFolder(folder); + await this.sync(); + } + + async sync() { + await synchronizeIDBFS(this.idbfs, false); + } + + readDir(dir: string): string[] { + return this.idbfs.FS.readdir(dir); + } + + stat(file: string) { + return this.idbfs.FS.stat(file, false); + } + + isFolder(file: string) { + return (this.stat(file).mode & 0x4000) != 0; + } + + async delete() { + this.readDir("/root") // + .filter(filterPseudoDir) // + .forEach((e) => { + try { + this._unlinkRecursive(e); + } catch (_) {} + }); + + await this.sync(); + + try { + await deleteDatabase("/root"); + } catch (e) {} + } +} + export async function setupFilesystem(progressHandler: ProgressHandler) { const idbfs = await initializeIDBFS(); + const fs = new Filesystem(idbfs); if (idbfs.FS.analyzePath("/root/api-set.bin", false).exists) { - return; + return fs; } const filesystem = await fetchFilesystem(progressHandler); @@ -119,12 +180,7 @@ export async function setupFilesystem(progressHandler: ProgressHandler) { } }); - await synchronizeIDBFS(idbfs, false); -} + await fs.sync(); -export async function storeFile(file: string, data: ArrayBuffer) { - const idbfs = await initializeIDBFS(); - const buffer = new Uint8Array(data); - idbfs.FS.writeFile(file, buffer); - await synchronizeIDBFS(idbfs, false); + return fs; } diff --git a/page/src/LandingPage.tsx b/page/src/landing-page.tsx similarity index 100% rename from page/src/LandingPage.tsx rename to page/src/landing-page.tsx diff --git a/page/src/playground.tsx b/page/src/playground.tsx new file mode 100644 index 00000000..80f5320e --- /dev/null +++ b/page/src/playground.tsx @@ -0,0 +1,257 @@ +import React from "react"; + +import { Output } from "@/components/output"; + +import { Emulator, EmulationState } from "./emulator"; +import { Filesystem, setupFilesystem } from "./filesystem"; + +import "./App.css"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +import { createDefaultSettings, Settings } from "./settings"; +import { SettingsMenu } from "@/components/settings-menu"; + +import { PlayFill, StopFill, GearFill, PauseFill } from "react-bootstrap-icons"; +import { StatusIndicator } from "@/components/status-indicator"; +import { Header } from "./Header"; + +import { Button } from "@/components/ui/button"; + +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { FilesystemExplorer } from "./filesystem-explorer"; + +interface PlaygroundProps {} +interface PlaygroundState { + settings: Settings; + filesystemPromise: Promise | null; + filesystem: Filesystem | null; + emulator: Emulator | null; + drawerOpen: boolean; +} + +export class Playground extends React.Component< + PlaygroundProps, + PlaygroundState +> { + private output: React.RefObject; + + constructor(props: PlaygroundProps) { + super(props); + + this.output = React.createRef(); + + this.start = this.start.bind(this); + this.resetFilesys = this.resetFilesys.bind(this); + this.createEmulator = this.createEmulator.bind(this); + this.toggleEmulatorState = this.toggleEmulatorState.bind(this); + + this.state = { + settings: createDefaultSettings(), + filesystemPromise: null, + filesystem: null, + emulator: null, + drawerOpen: false, + }; + } + + async resetFilesys() { + if (!this.state.filesystem) { + return; + } + + await this.state.filesystem.delete(); + + this.setState({ + filesystemPromise: null, + filesystem: null, + drawerOpen: false, + }); + + this.output.current?.clear(); + } + + initFilesys() { + if (this.state.filesystemPromise) { + return this.state.filesystemPromise; + } + + const promise = new Promise((resolve) => { + this.logLine("Loading filesystem..."); + setupFilesystem((current, total, file) => { + this.logLine(`Processing filesystem (${current}/${total}): ${file}`); + }).then(resolve); + }); + + promise.then((filesystem) => this.setState({ filesystem })); + this.setState({ filesystemPromise: promise }); + + return promise; + } + + setDrawerOpen(drawerOpen: boolean) { + this.setState({ drawerOpen }); + } + + async start() { + await this.initFilesys(); + this.setDrawerOpen(true); + } + + logLine(line: string) { + this.output.current?.logLine(line); + } + + logLines(lines: string[]) { + this.output.current?.logLines(lines); + } + + isEmulatorPaused() { + return ( + this.state.emulator && + this.state.emulator.getState() == EmulationState.Paused + ); + } + + toggleEmulatorState() { + if (this.isEmulatorPaused()) { + this.state.emulator?.resume(); + } else { + this.state.emulator?.pause(); + } + } + + async createEmulator(userFile: string) { + this.state.emulator?.stop(); + this.output.current?.clear(); + + this.setDrawerOpen(false); + + this.logLine("Starting emulation..."); + + if (this.state.filesystemPromise) { + await this.state.filesystemPromise; + } + + const new_emulator = new Emulator( + (l) => this.logLines(l), + (_) => this.forceUpdate(), + ); + new_emulator.onTerminate().then(() => this.setState({ emulator: null })); + + this.setState({ emulator: new_emulator }); + + new_emulator.start(this.state.settings, userFile); + } + + render() { + return ( + <> + + + + + Start + + + this.state.emulator?.stop()} + > + Stop + + + {this.isEmulatorPaused() ? ( + <> + Resume + > + ) : ( + <> + Pause + > + )} + + + + + + {" "} + Settings + + + + this.setState({ settings: s })} + /> + + + + {!this.state.filesystem ? ( + <>> + ) : ( + this.setState({ drawerOpen: o })} + > + + + + Filesystem Explorer + + + Filesystem Explorer + + + + + + + + )} + + + + + + + + + + > + ); + } +} diff --git a/src/tools/create-root.bat b/src/tools/create-root.bat index 07936796..5c2fe140 100644 --- a/src/tools/create-root.bat +++ b/src/tools/create-root.bat @@ -126,6 +126,7 @@ CALL :collect d3d10.dll CALL :collect d3d10core.dll CALL :collect cabinet.dll CALL :collect msacm32.dll +CALL :collect coloradapterclient.dll CALL :collect locale.nls CALL :collect c_1252.nls
{element.name}