Filesystem support (#249)

This commit is contained in:
Maurice Heumann
2025-04-30 13:32:00 +02:00
committed by GitHub
17 changed files with 1733 additions and 306 deletions

View File

@@ -1,3 +1,3 @@
Module['preRun'] = () => {
ENV = process.env;
};
Module["preRun"] = () => {
ENV = process.env;
};

84
page/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<HashRouter>
<Routes>
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="/" element={<LandingPage />} />
<Route path="/playground" element={<Playground />} />
</Routes>
</HashRouter>
<TooltipProvider>
<HashRouter>
<Routes>
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="/" element={<LandingPage />} />
<Route path="/playground" element={<Playground />} />
</Routes>
</HashRouter>
</TooltipProvider>
</ThemeProvider>
);
}

View File

@@ -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<UserFile> {
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<FileReader>) {
const arrayBuffer = e.target?.result;
resolve({
name: file.name,
data: arrayBuffer as ArrayBuffer,
});
};
reader.onerror = function (e: ProgressEvent<FileReader>) {
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<Output>(null);
const [settings, setSettings] = useState(createDefaultSettings());
const [emulator, setEmulator] = useState<Emulator | null>(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 (
<>
<Header
title="Playground - Sogen"
description="Playground to test and run Sogen, the Windows user space emulator, right in your browser."
/>
<div className="h-[100dvh] flex flex-col">
<header className="flex shrink-0 items-center gap-2 border-b p-2 overflow-y-auto">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="fancy">
<PlayFill /> Run
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Run Application</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => createEmulator()}>
<ImageFill className="mr-2" />
<span>Select Sample</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => loadAndRunUserFile()}>
<FileEarmarkCheckFill className="mr-2" />
<span>Select your .exe</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
disabled={!emulator}
size="sm"
variant="secondary"
className="fancy"
onClick={() => emulator?.stop()}
>
<StopFill /> Stop
</Button>
<Button
size="sm"
disabled={!emulator}
variant="secondary"
className="fancy"
onClick={toggleEmulatorState}
>
{isEmulatorPaused() ? (
<>
<PlayFill /> Resume
</>
) : (
<>
<PauseFill /> Pause
</>
)}
</Button>
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="secondary" className="fancy">
<GearFill /> Settings
</Button>
</PopoverTrigger>
<PopoverContent>
<SettingsMenu settings={settings} onChange={setSettings} />
</PopoverContent>
</Popover>
<div className="text-right flex-1">
<StatusIndicator
state={emulator ? emulator.getState() : EmulationState.Stopped}
/>
</div>
</header>
<div className="flex flex-1 flex-col gap-2 p-2 overflow-auto">
<Output ref={output} />
</div>
</div>
</>
);
}

View File

@@ -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 <FileEarmarkBinary className={className} />;
}
if (element.name.endsWith(".exe")) {
return <FiletypeExe className={className} />;
}
return <FileEarmark className={className} />;
case FolderElementType.Folder:
return element.name == ".." ? (
<FolderSymlinkFill className={className} />
) : (
<FolderFill className={className} />
);
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 (
<div
onClick={() => 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)}
<span className="whitespace-nowrap text-ellipsis overflow-hidden w-20">
{element.name}
</span>
</div>
);
}
function renderElementWithContext(element: FolderElement, props: FolderProps) {
if (element.name == "..") {
return renderElement(element, props.clickHandler);
}
return (
<ContextMenu>
<ContextMenuTrigger>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
{renderElement(element, props.clickHandler)}
</TooltipTrigger>
<TooltipContent>
<p>{element.name}</p>
</TooltipContent>
</Tooltip>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel inset>{element.name}</ContextMenuLabel>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => props.renameElementHandler(element)}>
Rename
</ContextMenuItem>
<ContextMenuItem onClick={() => props.removeElementHandler(element)}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function renderElementWrapper(element: FolderElement, props: FolderProps) {
return (
<div key={`folder-element-${element.name}`}>
{renderElementWithContext(element, props)}
</div>
);
}
export function Folder(props: FolderProps) {
return (
<ContextMenu>
<ContextMenuTrigger>
<ScrollArea className="h-[50dvh]">
<div className="folder flex flex-wrap h-full">
{props.elements
.sort(elementComparator)
.map((e) => renderElementWrapper(e, props))}
</div>
</ScrollArea>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={props.createFolderHandler}>
Create new Folder
</ContextMenuItem>
<ContextMenuItem onClick={props.addFilesHandler}>
Add Files
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -24,8 +24,10 @@ export class SettingsMenu extends React.Component<SettingsMenuProps, Settings> {
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() {

View File

@@ -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<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -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<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -1,5 +1,3 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";

View File

@@ -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",

View File

@@ -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<FileWithData> {
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<FileWithData[]> {
const promises = [];
for (let i = 0; i < files.length; i++) {
promises.push(readFile(files[i]));
}
return Promise.all(promises);
}
function selectFiles(): Promise<FileList> {
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: <HouseFill />,
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 (
<Dialog
open={this.state.createFolder}
onOpenChange={(open) => this.setState({ createFolder: open })}
>
<DialogContent className="sm:max-w-[425px]">
<form
onSubmit={(e) => {
const folderName = (e.target as any).elements.name.value;
this._onFolderCreate(folderName);
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Create new folder</DialogTitle>
<DialogDescription className="hidden">
Create new folder
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input id="name" defaultValue="New Folder" />
</div>
<DialogFooter>
<Button type="submit" className="fancy rounded-lg">
Create
</Button>
<DialogClose asChild>
<Button variant="secondary" className="fancy rounded-lg">
Cancel
</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
_renderRenameDialog() {
return (
<Dialog
open={this.state.renameFile.length > 0}
onOpenChange={(open) => (open ? {} : this.setState({ renameFile: "" }))}
>
<DialogContent className="sm:max-w-[425px]">
<form
onSubmit={(e) => {
const newName = (e.target as any).elements.name.value;
this._onFileRename(this.state.renameFile, newName);
e.preventDefault();
}}
>
<DialogHeader>
<DialogTitle>Rename {this.state.renameFile}</DialogTitle>
<DialogDescription className="hidden">
Rename {this.state.renameFile}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input id="name" defaultValue={this.state.renameFile} />
</div>
<DialogFooter>
<Button type="submit" className="fancy rounded-lg">
Rename
</Button>
<DialogClose asChild>
<Button variant="secondary" className="fancy rounded-lg">
Cancel
</Button>
</DialogClose>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
_renderErrorDialog() {
return (
<Dialog
open={this.state.errorText.length > 0}
onOpenChange={(open) => (open ? {} : this.setState({ errorText: "" }))}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Error</DialogTitle>
<DialogDescription className="hidden">
Error: {this.state.errorText}
</DialogDescription>
</DialogHeader>
<div className="py-4">{this.state.errorText}</div>
<DialogFooter>
<Button
variant="destructive"
onClick={() => this.setState({ errorText: "" })}
>
Ok
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
_renderRemoveDialog() {
return (
<Dialog
open={this.state.removeFile.length > 0}
onOpenChange={(open) => (open ? {} : this.setState({ removeFile: "" }))}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Delete {this.state.removeFile}?</DialogTitle>
<DialogDescription className="hidden">
Delete {this.state.removeFile}
</DialogDescription>
</DialogHeader>
<div className="py-4">
Are you sure you want to delete{" "}
<b>
{makeRelativePathWithState(this.state, this.state.removeFile)}
</b>
</div>
<DialogFooter>
<Button
variant="destructive"
onClick={() => {
const file = makeFullPathWithState(
this.state,
this.state.removeFile,
);
this.setState({ removeFile: "" });
this.props.filesystem
.unlink(file)
.then(() => this.forceUpdate());
}}
>
Ok
</Button>
<Button
variant="secondary"
onClick={() => {
this.setState({ removeFile: "" });
}}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
_renderResetDialog() {
return (
<Dialog
open={this.state.resetFilesys}
onOpenChange={(open) => this.setState({ resetFilesys: open })}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Reset filesystem</DialogTitle>
<DialogDescription className="hidden">
Reset filesystem
</DialogDescription>
</DialogHeader>
<div className="py-4">
Are you sure you want to reset the filesystem?
</div>
<DialogFooter>
<Button
variant="destructive"
onClick={() => {
this.setState({ resetFilesys: false });
this.props.resetFilesys();
}}
>
Ok
</Button>
<Button
variant="secondary"
onClick={() => {
this.setState({ resetFilesys: false });
}}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
_renderBreadcrumbElements() {
const elements = generateBreadcrumbElements(this.state.path);
const nodes = elements.map((e, index) => {
if (index == this.state.path.length) {
return (
<BreadcrumbItem key={`breadcrumb-item-${index}`}>
<BreadcrumbPage key={`breadcrumb-page-${index}`}>
{e.node}
</BreadcrumbPage>
</BreadcrumbItem>
);
}
const navigate = () => this.setState({ path: e.targetPath });
return (
<BreadcrumbItem key={`breadcrumb-item-${index}`}>
<BreadcrumbLink key={`breadcrumb-link-${index}`} onClick={navigate}>
{e.node}
</BreadcrumbLink>
</BreadcrumbItem>
);
});
return [
...nodes.map((n, index) => [
n,
<BreadcrumbSeparator key={`breadcrumb-separator-${index}`} />,
]),
].slice(0, -1);
}
_renderBreadCrumb() {
return (
<Breadcrumb>
<BreadcrumbList>{this._renderBreadcrumbElements()}</BreadcrumbList>
</Breadcrumb>
);
}
render() {
const elements = getFolderElements(this.props.filesystem, this.state.path);
return (
<>
{this._renderCreateFolderDialog()}
{this._renderRenameDialog()}
{this._renderErrorDialog()}
{this._renderRemoveDialog()}
{this._renderResetDialog()}
<div className="flex flex-row w-full items-center gap-3">
<div className="whitespace-nowrap">{this._renderBreadCrumb()}</div>
<div className="flex-1 text-right">
<Button
onClick={() => this.setState({ resetFilesys: true })}
variant="destructive"
>
Reset
</Button>
</div>
</div>
<Dropzone onDrop={this._uploadFiles} noClick={true}>
{({ getRootProps, getInputProps }) => (
<div {...getRootProps()}>
<input {...getInputProps()} />
<Folder
elements={elements}
clickHandler={this._onElementSelect}
createFolderHandler={() =>
this.setState({ createFolder: true })
}
removeElementHandler={(e) =>
this.setState({ removeFile: e.name })
}
renameElementHandler={(e) =>
this.setState({ renameFile: e.name })
}
addFilesHandler={this._onAddFiles}
/>
</div>
)}
</Dropzone>
</>
);
}
}

View File

@@ -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<IDBDatabase> {
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<void>((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;
}

257
page/src/playground.tsx Normal file
View File

@@ -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<Filesystem> | null;
filesystem: Filesystem | null;
emulator: Emulator | null;
drawerOpen: boolean;
}
export class Playground extends React.Component<
PlaygroundProps,
PlaygroundState
> {
private output: React.RefObject<Output | null>;
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<Filesystem>((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 (
<>
<Header
title="Playground - Sogen"
description="Playground to test and run Sogen, the Windows user space emulator, right in your browser."
/>
<div className="h-[100dvh] flex flex-col">
<header className="flex shrink-0 items-center gap-2 border-b p-2 overflow-y-auto">
<Button size="sm" className="fancy" onClick={this.start}>
<PlayFill /> <span>Start</span>
</Button>
<Button
disabled={!this.state.emulator}
size="sm"
variant="secondary"
className="fancy"
onClick={() => this.state.emulator?.stop()}
>
<StopFill /> <span className="hidden sm:inline">Stop</span>
</Button>
<Button
size="sm"
disabled={!this.state.emulator}
variant="secondary"
className="fancy"
onClick={this.toggleEmulatorState}
>
{this.isEmulatorPaused() ? (
<>
<PlayFill /> <span className="hidden sm:inline">Resume</span>
</>
) : (
<>
<PauseFill /> <span className="hidden sm:inline">Pause</span>
</>
)}
</Button>
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="secondary" className="fancy">
<GearFill />{" "}
<span className="hidden sm:inline">Settings</span>
</Button>
</PopoverTrigger>
<PopoverContent>
<SettingsMenu
settings={this.state.settings}
onChange={(s) => this.setState({ settings: s })}
/>
</PopoverContent>
</Popover>
{!this.state.filesystem ? (
<></>
) : (
<Drawer
open={this.state.drawerOpen}
onOpenChange={(o) => this.setState({ drawerOpen: o })}
>
<DrawerContent>
<DrawerHeader>
<DrawerTitle className="hidden">
Filesystem Explorer
</DrawerTitle>
<DrawerDescription className="hidden">
Filesystem Explorer
</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<FilesystemExplorer
filesystem={this.state.filesystem}
runFile={this.createEmulator}
resetFilesys={this.resetFilesys}
path={["c"]}
/>
</DrawerFooter>
</DrawerContent>
</Drawer>
)}
<div className="text-right flex-1">
<StatusIndicator
state={
this.state.emulator
? this.state.emulator.getState()
: EmulationState.Stopped
}
/>
</div>
</header>
<div className="flex flex-1 flex-col gap-2 p-2 overflow-auto">
<Output ref={this.output} />
</div>
</div>
</>
);
}
}

View File

@@ -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