6 Commits

Author SHA1 Message Date
Felix Rieseberg
85e25ed3ab Improve VM info bar: CPU M/s, disk & net throughput, hover-to-reveal
- Show CPU as M/s (millions of instructions/sec) instead of raw count
- Replace Disk Idle/Read with actual R/W throughput (B/K/M per sec)
- Add Net ↓/↑ throughput from eth-receive-end / eth-transmit-end
- Always mount the bar; when hidden it slides off-screen and reveals on
  hover near the top edge (Pin/Hide toggle)
- Center via translateX(-50%) so the wider bar stays centered
2026-04-11 07:51:39 -07:00
Felix Rieseberg
45f5a136b2 Add SMB1 server and host folder share
Windows 95 can now mount a host folder as a network drive at \\HOST\HOST.
Read-only, ~1500 lines, zero deps. Defaults to ~/Downloads, configurable in
Settings.

Protocol: NEGOTIATE (LANMAN2.1), SESSION_SETUP, TREE_CONNECT, TRANSACTION/RAP
(NetShareEnum, NetServerGetInfo, NetWkstaGetInfo), TRANSACTION2/FIND_FIRST2,
SEARCH (8.3 with ~N suffix mapping), OPEN_ANDX, NT_CREATE_ANDX, READ_ANDX,
CLOSE, QUERY_INFORMATION, CHECK_DIRECTORY. NetBIOS Name Service on UDP 137
answers Node Status and Name Query so \\HOST resolves.

v86 hook: monkeypatches adapter.on_tcp_connection (old API), shadows
adapter.receive during a port-80 probe to steal a TCPConnection without
side effects, re-aims it at port 139. Data via .on_data (Closure
dead-code-eliminated .on/.emit). Also registers tcp-connection bus event
for newer v86 builds.

Security: read-only, path traversal blocked lexically and through symlinks
(realpath the deepest existing ancestor, re-append tail, confirm under root).
Share path validated in main-process IPC.

BIOS updated to SeaBIOS 1.16.2 (compatible with old v86). v86 itself stays
on the Feb 2025 prod build — newer builds hang at the splash screen on fresh
boot (bisect tooling included in tools/).

Also: tools/update-v86.js builds wasm+libv86+BIOS from a local v86 checkout
and refuses to install JS/wasm pairs more than 14 days apart (copy.sh ships
mismatched pairs). tools/parcel-build.js dynamic-import patch made tolerant
of post-d4c5fa86 builds.
2026-04-11 01:03:34 -07:00
Felix Rieseberg
2d34183e14 Update v86 to latest, replace string-match patches with stable shim
The previous build patched libv86.js by exact-string match against
Closure-mangled identifiers (k.load_file, H.exportSymbol, pa, qa),
which broke on every upstream rebuild.

Of the three old patches:
- exportSymbol order: now a one-line HTML shim copying module.exports.V86
  to window after libv86 loads
- this.fetch binding: fixed upstream
- load_file XHR vs fs: replaced by patching await import('node:fs/promises')
  to require('fs').promises - string literals survive Closure, fails loud
  if absent

Also adds tools/update-v86.js to pull new builds from copy.sh, and exposes
the renderer DevTools protocol on localhost:9222 in dev.
2026-04-10 20:53:44 -07:00
Felix Rieseberg
00943ae4da Update all dependencies
React 19, Electron 41, TypeScript 6, electron-forge 7.8, plus everything
else to latest. Migrated to React 19 createRoot API, updated for Electron 41
session/window type changes, and adjusted tsconfig for TS 6 deprecations.
Regenerated @electron/packager patch for 18.4.4.
2026-04-10 20:34:28 -07:00
Felix Rieseberg
a6d57c6538 Update resedit 2025-05-02 09:03:39 -07:00
Felix Rieseberg
35f7c3362d Update Readme 2025-02-21 08:27:47 -08:00
42 changed files with 6885 additions and 2868 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ Microsoft.Trusted.Signing.Client*
trusted-signing-metadata.json
.env
electron-windows-sign.log
.npmrc

View File

@@ -15,29 +15,29 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
</td>
<td>
<span>32-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-ia32.exe">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-ia32.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-ia32-3.1.1.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-win32-ia32-4.0.0.zip">
📦 Standalone Zip
</a>
<br />
<span>64-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-x64.exe">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-x64.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-x64-3.1.1.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-win32-x64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>ARM64</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-arm64.exe">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-arm64.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-arm64-3.1.1.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-win32-arm64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>
❓ Don't know what kind of chip you have? Hit start, enter "processor" for info.
❓ Don't know what kind of chip you have? It's probably `x64`. To confirm, on your computer, hit Start, enter "processor" for info.
</span>
</td>
</tr>
@@ -47,16 +47,16 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
macOS
</td>
<td>
<span>Apple Silicon Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-darwin-arm64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>Intel Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-darwin-x64-3.1.1.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-darwin-x64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>Apple M1 Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-darwin-arm64-3.1.1.zip">
📦 Standalone Zip
</a><br />
</a>
<span>
❓ Don't know what kind of chip you have? Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
❓ Don't know what kind of chip you have? If you bought your computer after 2020, select "Apple Silicon". Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
</span>
</td>
</tr>
@@ -67,33 +67,28 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
</td>
<td>
<span>64-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.x86_64.rpm">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-1.x86_64.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_amd64.deb">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95_4.0.0_amd64.deb">
💿 deb
</a><br />
<span>ARM64</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.arm64.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_arm64.deb">
💿 deb
</a><br />
<span>ARMv7 (armhf)</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.armv7hl.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_armhf.deb">
💿 deb
</a>
</td>
</tr>
</table>
<hr />
![Screenshot](https://user-images.githubusercontent.com/1426799/44532591-4ceb3680-a6a8-11e8-8c2c-bc29f3bfdef7.png)
<table width="100%">
<tr>
<td width="50%">
<img src="https://github.com/user-attachments/assets/43ab7126-765e-444b-ad14-27b1beadbc7c" width="100%" alt="Screenshot showing Windows 95">
</td>
<td width="50%">
<img src="https://github.com/user-attachments/assets/7ac5dc36-cbd4-4455-a616-0e5cca314b34" width="100%" alt="Screenshot showing Windows 95">
</td>
</tr>
</table>
## Does it work?
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
@@ -102,7 +97,7 @@ Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this
Absolutely.
## Does it run Doom (or my other favorite game)?
You'll likely be better off with an actual virtualization app, but the short answer is yes. [Thanks to
You'll likely be better off with an actual virtualization app, but the short answer is yes. In fact, a few games are already preinstalled - and more can be found on the Internet, for instance at [archive.org](https://www.archive.org). [Thanks to
@DisplacedGamers](https://youtu.be/xDXqmdFxofM) I can recommend that you switch to a resolution of
640x480 @ 256 colors before starting DOS games - just like in the good ol' days.

Binary file not shown.

Binary file not shown.

6191
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,28 +23,28 @@
},
"dependencies": {
"electron-squirrel-startup": "^1.0.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"update-electron-app": "^2.0.1"
"react": "^19.2.4",
"react-dom": "^19.2.4",
"update-electron-app": "^3.1.2"
},
"devDependencies": {
"@electron-forge/cli": "7.6.1",
"@electron-forge/maker-deb": "7.6.1",
"@electron-forge/maker-flatpak": "^7.6.1",
"@electron-forge/maker-rpm": "^7.6.1",
"@electron-forge/maker-squirrel": "^7.6.1",
"@electron-forge/maker-zip": "^7.6.1",
"@electron-forge/publisher-github": "^7.6.1",
"@types/node": "^20",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"dotenv": "^16.4.7",
"electron": "34.2.0",
"less": "^3.13.0",
"@electron-forge/cli": "7.8.3",
"@electron-forge/maker-deb": "7.8.3",
"@electron-forge/maker-flatpak": "^7.8.3",
"@electron-forge/maker-rpm": "^7.8.3",
"@electron-forge/maker-squirrel": "^7.8.3",
"@electron-forge/maker-zip": "^7.8.3",
"@electron-forge/publisher-github": "^7.8.3",
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.3.1",
"electron": "41.2.0",
"less": "^4.6.4",
"parcel-bundler": "^1.12.5",
"patch-package": "^8.0.0",
"prettier": "^3.5.1",
"rimraf": "^6.0.1",
"typescript": "^5.7.3"
"patch-package": "^8.0.1",
"prettier": "^3.8.1",
"rimraf": "^6.1.3",
"typescript": "^6.0.2"
}
}

View File

@@ -1,8 +1,8 @@
diff --git a/node_modules/@electron/packager/dist/win32.js b/node_modules/@electron/packager/dist/win32.js
index 5399b3e..f3b6e88 100644
index d318f6c..bfde740 100644
--- a/node_modules/@electron/packager/dist/win32.js
+++ b/node_modules/@electron/packager/dist/win32.js
@@ -57,7 +57,26 @@ class WindowsApp extends platform_1.App {
@@ -65,7 +65,26 @@ class WindowsApp extends platform_1.App {
resOpts.iconPath = icon;
}
(0, common_1.debug)(`Running resedit with the options ${JSON.stringify(resOpts)}`);

View File

@@ -18,7 +18,6 @@ export async function clearStorageData() {
await session.defaultSession.clearStorageData({
storages: [
"appcache",
"cookies",
"filesystem",
"indexdb",
@@ -27,6 +26,6 @@ export async function clearStorageData() {
"websql",
"serviceworkers",
],
quotas: ["temporary", "persistent", "syncable"],
quotas: ["temporary"],
});
}

View File

@@ -30,4 +30,7 @@ export const IPC_COMMANDS = {
// Else
APP_QUIT: "APP_QUIT",
GET_STATE_PATH: "GET_STATE_PATH",
GET_SMB_SHARE_PATH: "GET_SMB_SHARE_PATH",
SET_SMB_SHARE_PATH: "SET_SMB_SHARE_PATH",
PICK_FOLDER: "PICK_FOLDER",
};

View File

@@ -1,8 +1,19 @@
#status-hotzone {
position: absolute;
z-index: 99;
top: 0;
left: 0;
right: 0;
height: 8px;
}
#status {
user-select: none;
position: absolute;
z-index: 100;
left: calc(50vw - 110px);
left: 50vw;
transform: translateX(-50%);
white-space: nowrap;
background: white;
font-size: 10px;
padding-bottom: 3px;
@@ -13,4 +24,14 @@
padding-right: 10px;
max-height: 18px;
top: 0;
transition: transform 0.12s ease-out;
&.hidden {
transform: translateX(-50%) translateY(-100%);
}
}
#status-hotzone:hover + #status.hidden,
#status.hidden:hover {
transform: translateX(-50%) translateY(0);
}

View File

@@ -1,6 +1,6 @@
export function encode(text: string) {
// Convert to windows-1252 compatible string by removing unsupported chars
let result = text.replaceAll(/[^\x00-\xFF]/g, '');
let result = text.replaceAll(/[^\x00-\xFF]/g, "");
// If result would be empty, return original
if (!result.trim()) {

View File

@@ -1,9 +1,9 @@
import { protocol, net } from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { generateDirectoryListing } from './page-directory-listing';
import { generateErrorPage } from './page-error';
import { log } from '../logging';
import { protocol } from "electron";
import * as fs from "fs";
import * as path from "path";
import { generateDirectoryListing } from "./page-directory-listing";
import { generateErrorPage } from "./page-error";
import { log } from "../logging";
export interface FileEntry {
name: string;
@@ -11,18 +11,15 @@ export interface FileEntry {
stats: fs.Stats;
}
export const APP_INTERCEPT = 'http://windows95/';
export const MY_COMPUTER_INTERCEPT = 'http://my-computer/';
export const APP_INTERCEPT = "http://windows95/";
export const MY_COMPUTER_INTERCEPT = "http://my-computer/";
const interceptedUrls = [
MY_COMPUTER_INTERCEPT,
APP_INTERCEPT
];
const interceptedUrls = [MY_COMPUTER_INTERCEPT, APP_INTERCEPT];
export function setupFileServer() {
// Register protocol handler for our custom schema
protocol.handle('http', async (request) => {
if (!interceptedUrls.some(url => request.url.startsWith(url))) {
protocol.handle("http", async (request) => {
if (!interceptedUrls.some((url) => request.url.startsWith(url))) {
return fetch(request.url, {
headers: request.headers,
method: request.method,
@@ -33,19 +30,22 @@ export function setupFileServer() {
try {
const { fullPath, decodedPath } = getFilePath(request.url);
log(`FileServer: Handling request for ${request.url}`, { fullPath, decodedPath });
log(`FileServer: Handling request for ${request.url}`, {
fullPath,
decodedPath,
});
// Check if path exists
if (!fs.existsSync(fullPath)) {
return new Response(generateErrorPage(
'File or Directory Not Found',
decodedPath
), {
status: 404,
headers: {
'Content-Type': 'text/html'
}
});
return new Response(
generateErrorPage("File or Directory Not Found", decodedPath),
{
status: 404,
headers: {
"Content-Type": "text/html",
},
},
);
}
// Check if it's a directory
@@ -53,7 +53,7 @@ export function setupFileServer() {
if (stats.isDirectory()) {
// If we're in an app-intercept, check if there's an index.htm file in the directory
if (request.url.startsWith(APP_INTERCEPT)) {
const indexHtmlPath = path.join(fullPath, 'index.htm');
const indexHtmlPath = path.join(fullPath, "index.htm");
if (fs.existsSync(indexHtmlPath)) {
return serveFile(indexHtmlPath);
}
@@ -65,24 +65,27 @@ export function setupFileServer() {
return new Response(listing, {
status: 200,
headers: {
'Content-Type': 'text/html'
}
"Content-Type": "text/html",
},
});
} else {
try {
return await serveFile(fullPath);
} catch (error) {
// Handle specific file read errors
if (error.code === 'EACCES') {
return new Response(generateErrorPage(
'Access Denied',
'You do not have permission to access this file'
), {
status: 403,
headers: {
'Content-Type': 'text/html'
}
});
if ((error as NodeJS.ErrnoException).code === "EACCES") {
return new Response(
generateErrorPage(
"Access Denied",
"You do not have permission to access this file",
),
{
status: 403,
headers: {
"Content-Type": "text/html",
},
},
);
}
// Re-throw other errors to be caught by outer try-catch
@@ -90,15 +93,16 @@ export function setupFileServer() {
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorPage = generateErrorPage(
'Internal Server Error',
`An error occurred while processing your request: ${error.message}`
"Internal Server Error",
`An error occurred while processing your request: ${message}`,
);
return new Response(errorPage, {
status: 500,
headers: {
'Content-Type': 'text/html'
}
"Content-Type": "text/html",
},
});
}
});
@@ -110,14 +114,18 @@ function getFilePath(url: string) {
let decodedPath: string;
if (url.startsWith(APP_INTERCEPT)) {
fullPath = path.resolve(__dirname, '../../../static/www', url.replace(APP_INTERCEPT, ''));
decodedPath = '.';
fullPath = path.resolve(
__dirname,
"../../../static/www",
url.replace(APP_INTERCEPT, ""),
);
decodedPath = ".";
} else if (url.startsWith(MY_COMPUTER_INTERCEPT)) {
urlPath = url.replace(MY_COMPUTER_INTERCEPT, '');
urlPath = url.replace(MY_COMPUTER_INTERCEPT, "");
decodedPath = decodeURIComponent(urlPath);
fullPath = path.join('/', decodedPath);
fullPath = path.join("/", decodedPath);
} else {
throw new Error('Invalid URL');
throw new Error("Invalid URL");
}
return { fullPath, decodedPath };
@@ -128,19 +136,19 @@ async function serveFile(fullPath: string): Promise<Response> {
// Determine content type based on file extension
const ext = path.extname(fullPath).toLowerCase();
let contentType = 'application/octet-stream';
let contentType = "application/octet-stream";
// Common content types
const contentTypes: Record<string, string> = {
'.htm': 'text/html',
'.html': 'text/html',
'.txt': 'text/plain',
'.css': 'text/css',
'.js': 'text/javascript',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif'
".htm": "text/html",
".html": "text/html",
".txt": "text/plain",
".css": "text/css",
".js": "text/javascript",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
};
if (ext in contentTypes) {
@@ -150,8 +158,7 @@ async function serveFile(fullPath: string): Promise<Response> {
return new Response(fileData, {
status: 200,
headers: {
'Content-Type': contentType
}
"Content-Type": contentType,
},
});
}

View File

@@ -1,37 +1,39 @@
import { Stats } from "fs";
import { settings } from "../settings";
import { FileEntry } from "./fileserver";
const FILES_TO_HIDE_ON_DARWIN: string[] = [
'.DS_Store',
'.localized',
'.Trashes',
'.fseventsd',
'.Spotlight-V100',
'.file',
'.hotfiles.btree',
'.DocumentRevisions-V100',
'.TemporaryItems',
'.file (resource fork files)',
'.VolumeIcon.icns',
".DS_Store",
".localized",
".Trashes",
".fseventsd",
".Spotlight-V100",
".file",
".hotfiles.btree",
".DocumentRevisions-V100",
".TemporaryItems",
".file (resource fork files)",
".VolumeIcon.icns",
];
const FILES_TO_HIDE_ON_WINDOWS: string[] = [
'desktop.ini',
'Thumbs.db',
'ehthumbs.db',
'ehthumbs.db-shm',
'ehthumbs.db-wal',
"desktop.ini",
"Thumbs.db",
"ehthumbs.db",
"ehthumbs.db-shm",
"ehthumbs.db-wal",
];
const FILES_TO_HIDE_ON_LINUX: string[] = [];
export function shouldHideFile(file: FileEntry) {
if (isHiddenFile(file) && !settings.get('isFileServerShowingHiddenFiles')) {
if (isHiddenFile(file) && !settings.get("isFileServerShowingHiddenFiles")) {
return true;
}
if (isSystemHiddenFile(file) && !settings.get('isFileServerShowingSystemHiddenFiles')) {
if (
isSystemHiddenFile(file) &&
!settings.get("isFileServerShowingSystemHiddenFiles")
) {
return true;
}
@@ -39,15 +41,15 @@ export function shouldHideFile(file: FileEntry) {
}
export function isHiddenFile(file: FileEntry) {
if (process.platform === 'win32') {
if (process.platform === "win32") {
return (file.stats.mode & 0x2) === 0x2;
} else {
return file.name.startsWith('.');
return file.name.startsWith(".");
}
}
export function isSystemHiddenFile(file: FileEntry) {
return getFilesToHide().some(hiddenFile => file.name.endsWith(hiddenFile));
return getFilesToHide().some((hiddenFile) => file.name.endsWith(hiddenFile));
}
let _filesToHide: string[];
@@ -57,9 +59,9 @@ function getFilesToHide() {
return _filesToHide;
}
if (process.platform === 'darwin') {
if (process.platform === "darwin") {
_filesToHide = FILES_TO_HIDE_ON_DARWIN;
} else if (process.platform === 'win32') {
} else if (process.platform === "win32") {
_filesToHide = FILES_TO_HIDE_ON_WINDOWS;
} else {
_filesToHide = FILES_TO_HIDE_ON_LINUX;
@@ -67,6 +69,3 @@ function getFilesToHide() {
return _filesToHide;
}

View File

@@ -7,29 +7,31 @@ import { encode, getEncoding } from "./encoding";
import { log } from "console";
import { app } from "electron";
export function generateDirectoryListing(currentPath: string, files: string[]): string {
const parentPath = path.dirname(currentPath || '/');
const title = currentPath === '/' ? 'My Host Computer' : `Directory: ${encode(currentPath)}`;
export function generateDirectoryListing(
currentPath: string,
files: string[],
): string {
const parentPath = path.dirname(currentPath || "/");
const title =
currentPath === "/"
? "My Host Computer"
: `Directory: ${encode(currentPath)}`;
// Get file info and sort (directories first, then alphabetically)
const items = files
.map(name => {
.map((name) => {
const fullPath = path.join(currentPath, name);
let stats: fs.Stats;
try {
stats = fs.statSync(fullPath);
const stats = fs.statSync(fullPath);
return { name, fullPath, stats } as FileEntry;
} catch (error) {
log(`FileServer: Failed to get stats for ${fullPath}: ${error}`);
stats = new fs.Stats();
return null;
}
return {
name,
fullPath,
stats
} as FileEntry;
})
.filter(entry => entry.stats && !shouldHideFile(entry))
.filter(
(entry): entry is FileEntry => entry !== null && !shouldHideFile(entry),
)
.sort((a, b) => {
if (a.stats.isDirectory() !== b.stats.isDirectory()) {
return a.stats.isDirectory() ? -1 : 1;
@@ -37,7 +39,7 @@ export function generateDirectoryListing(currentPath: string, files: string[]):
return a.name.localeCompare(b.name);
})
.map(getFileLiHtml)
.join('')
.join("");
// Generate very simple HTML that works in IE 5.5
return `
@@ -60,7 +62,7 @@ export function generateDirectoryListing(currentPath: string, files: string[]):
function getParentFolderLinkHtml(parentPath: string) {
return `
${getIconHtml('folder.gif')}
${getIconHtml("folder.gif")}
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(parentPath)}">
[Parent Directory]
</a>
@@ -68,10 +70,10 @@ function getParentFolderLinkHtml(parentPath: string) {
}
function getDesktopLinkHtml() {
const desktopPath = app.getPath('desktop');
const desktopPath = app.getPath("desktop");
return `
${getIconHtml('desktop.gif')}
${getIconHtml("desktop.gif")}
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(desktopPath)}">
Desktop
</a>
@@ -79,10 +81,10 @@ function getDesktopLinkHtml() {
}
function getDownloadsLinkHtml() {
const downloadsPath = app.getPath('downloads');
const downloadsPath = app.getPath("downloads");
return `
${getIconHtml('network.gif')}
${getIconHtml("network.gif")}
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(downloadsPath)}">
Downloads
</a>
@@ -95,8 +97,12 @@ function getIconHtml(icon: string) {
function getFileLiHtml(entry: FileEntry) {
const encodedPath = encodeURI(entry.fullPath);
const sizeDisplay = entry.stats.isDirectory() ? '' : ` (${formatFileSize(entry.stats.size)})`;
const icon = entry.stats.isDirectory() ? getIconHtml('folder.gif') : getIconHtml('doc.gif');
const sizeDisplay = entry.stats.isDirectory()
? ""
: ` (${formatFileSize(entry.stats.size)})`;
const icon = entry.stats.isDirectory()
? getIconHtml("folder.gif")
: getIconHtml("doc.gif");
return `<li>
${icon}
@@ -112,9 +118,9 @@ function getDisplayName(entry: FileEntry) {
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}

View File

@@ -1,7 +1,10 @@
import { getEncoding } from "./encoding";
import { MY_COMPUTER_INTERCEPT } from "./fileserver";
export function generateErrorPage(errorMessage: string, requestedPath: string): string {
export function generateErrorPage(
errorMessage: string,
requestedPath: string,
): string {
return `
<html>
<head>

View File

@@ -1,7 +1,9 @@
import { ipcMain, app } from "electron";
import { ipcMain, app, dialog, BrowserWindow } from "electron";
import * as path from "path";
import * as fs from "fs";
import { IPC_COMMANDS } from "../constants";
import { settings } from "./settings";
export function setupIpcListeners() {
ipcMain.handle(IPC_COMMANDS.GET_STATE_PATH, () => {
@@ -11,4 +13,34 @@ export function setupIpcListeners() {
ipcMain.handle(IPC_COMMANDS.APP_QUIT, () => {
app.quit();
});
ipcMain.handle(IPC_COMMANDS.GET_SMB_SHARE_PATH, () => {
return settings.get("smbSharePath");
});
ipcMain.handle(IPC_COMMANDS.SET_SMB_SHARE_PATH, (_e, p: unknown) => {
// The only legitimate caller is the folder picker, which can't return
// a non-existent path — but the renderer has nodeIntegration so any
// code there can call this IPC. Reject anything that isn't an existing
// directory; otherwise SmbSession's realpathSync throws inside a TCP
// callback on next launch and the share silently never connects.
if (typeof p !== "string") return false;
let real: string;
try {
real = fs.realpathSync(p);
if (!fs.statSync(real).isDirectory()) return false;
} catch {
return false;
}
settings.set("smbSharePath", real);
return true;
});
ipcMain.handle(IPC_COMMANDS.PICK_FOLDER, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
const result = await dialog.showOpenDialog(win!, {
properties: ["openDirectory"],
});
return result.canceled ? null : result.filePaths[0];
});
}

View File

@@ -8,7 +8,7 @@ import { getOrCreateWindow } from "./windows";
import { setupMenu } from "./menu";
import { setupIpcListeners } from "./ipc";
import { setupSession } from "./session";
import { setupFileServer } from './fileserver/fileserver';
import { setupFileServer } from "./fileserver/fileserver";
/**
* Handle the app's "ready" event. This is essentially
@@ -61,6 +61,12 @@ export function main() {
return;
}
if (isDevMode()) {
// Renderer DevTools Protocol — connect Chrome to chrome://inspect
// or attach a debugger to localhost:9222
app.commandLine.appendSwitch("remote-debugging-port", "9222");
}
// Set the app's name
app.setName("windows95");

View File

@@ -64,7 +64,7 @@ async function createMenu({ isRunning } = { isRunning: false }) {
}
})(),
click: function (_item, focusedWindow) {
if (focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
focusedWindow.webContents.toggleDevTools();
}
},
@@ -166,12 +166,13 @@ async function createMenu({ isRunning } = { isRunning: false }) {
label: "Reset",
click: async () => {
const result = await dialog.showMessageBox({
type: 'warning',
buttons: ['Reset', 'Cancel'],
type: "warning",
buttons: ["Reset", "Cancel"],
defaultId: 1,
title: 'Reset Machine',
message: 'Are you sure you want to reset the machine?',
detail: 'This will delete the machine state, including all changes you have made.',
title: "Reset Machine",
message: "Are you sure you want to reset the machine?",
detail:
"This will delete the machine state, including all changes you have made.",
});
if (result.response === 0) {

View File

@@ -3,16 +3,14 @@ import { session } from "electron";
export function setupSession() {
const s = session.defaultSession;
s.webRequest.onBeforeSendHeaders(
(details, callback) => {
callback({ requestHeaders: { Origin: '*', ...details.requestHeaders } });
},
);
s.webRequest.onBeforeSendHeaders((details, callback) => {
callback({ requestHeaders: { Origin: "*", ...details.requestHeaders } });
});
s.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
'Access-Control-Allow-Origin': ['*'],
"Access-Control-Allow-Origin": ["*"],
...details.responseHeaders,
},
});

View File

@@ -1,17 +1,19 @@
import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
import * as fs from "fs";
import * as path from "path";
import { app } from "electron";
export interface Settings {
isFileServerEnabled: boolean;
isFileServerShowingHiddenFiles: boolean;
isFileServerShowingSystemHiddenFiles: boolean;
smbSharePath: string;
}
const DEFAULT_SETTINGS: Settings = {
isFileServerEnabled: true,
isFileServerShowingHiddenFiles: false,
isFileServerShowingSystemHiddenFiles: false,
smbSharePath: app.getPath("downloads"),
};
class SettingsManager {
@@ -19,14 +21,14 @@ class SettingsManager {
private data: Settings;
constructor() {
this.filePath = path.join(app.getPath('userData'), 'settings.json');
this.filePath = path.join(app.getPath("userData"), "settings.json");
this.data = this.load();
}
private load(): Settings {
try {
if (fs.existsSync(this.filePath)) {
const fileContent = fs.readFileSync(this.filePath, 'utf8');
const fileContent = fs.readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(fileContent);
return {
@@ -35,7 +37,7 @@ class SettingsManager {
};
}
} catch (error) {
console.error('Error loading settings:', error);
console.error("Error loading settings:", error);
}
return DEFAULT_SETTINGS;
@@ -45,7 +47,7 @@ class SettingsManager {
try {
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
} catch (error) {
console.error('Error saving settings:', error);
console.error("Error saving settings:", error);
}
}
@@ -53,7 +55,7 @@ class SettingsManager {
return this.data[key];
}
set(key: keyof Settings, value: any): void {
set<K extends keyof Settings>(key: K, value: Settings[K]): void {
this.data[key] = value;
this.save();
}

View File

@@ -2,7 +2,8 @@ import { app } from "electron";
export function setupUpdates() {
if (app.isPackaged) {
require("update-electron-app")({
const { updateElectronApp } = require("update-electron-app");
updateElectronApp({
repo: "felixrieseberg/windows95",
updateInterval: "1 hour",
});

View File

@@ -18,7 +18,6 @@ export function getOrCreateWindow(): BrowserWindow {
},
});
// mainWindow.webContents.toggleDevTools();
mainWindow.loadFile("./dist/static/index.html");
mainWindow.webContents.on("will-navigate", (event, url) =>

View File

@@ -18,9 +18,9 @@ export class App {
* Initial setup call, loading Monaco and kicking off the React
* render process.
*/
public async setup(): Promise<void | Element> {
public async setup(): Promise<void> {
const React = await import("react");
const { render } = await import("react-dom");
const { createRoot } = await import("react-dom/client");
const { Emulator } = await import("./emulator");
const className = `${process.platform}`;
@@ -30,9 +30,8 @@ export class App {
</div>
);
const rendered = render(app, document.getElementById("app"));
return rendered;
const root = createRoot(document.getElementById("app")!);
root.render(app);
}
}

View File

@@ -6,8 +6,11 @@ interface CardSettingsProps {
bootFromScratch: () => void;
setFloppy: (file: File) => void;
setCdrom: (cdrom: File) => void;
setSmbSharePath: (path: string) => void;
pickFolder: () => Promise<string | null>;
floppy?: File;
cdrom?: File;
smbSharePath: string;
}
interface CardSettingsState {
@@ -45,6 +48,8 @@ export class CardSettings extends React.Component<
<hr />
{this.renderFloppy()}
<hr />
{this.renderSmbShare()}
<hr />
{this.renderState()}
</div>
</div>
@@ -75,7 +80,7 @@ export class CardSettings extends React.Component<
"iso" format.
</p>
<p id="floppy-path">
{cdrom ? `Inserted CD: ${cdrom?.path}` : `No CD mounted`}
{cdrom ? `Inserted CD: ${cdrom?.name}` : `No CD mounted`}
</p>
<button
className="btn"
@@ -90,6 +95,34 @@ export class CardSettings extends React.Component<
);
}
public renderSmbShare() {
const { smbSharePath } = this.props;
return (
<fieldset>
<legend>Network Share</legend>
<p>
A folder on your computer is exposed inside Windows 95 as a
network drive. From inside Windows, open Start Run and type{" "}
<code>\\HOST\HOST</code> to browse it, or use Map Network Drive to
give it a drive letter.
</p>
<p>
Shared folder: <code>{smbSharePath}</code>
</p>
<button
className="btn"
onClick={async () => {
const picked = await this.props.pickFolder();
if (picked) this.props.setSmbSharePath(picked);
}}
>
<span>Choose folder</span>
</button>
</fieldset>
);
}
public renderFloppy() {
const { floppy } = this.props;

View File

@@ -0,0 +1,265 @@
// Autonomous boot probe. Started from emulator.tsx when WIN95_PROBE=1.
// Writes status + screenshot to /tmp so an outer loop can read them
// without DevTools or CDP.
import * as fs from "fs";
const STATUS_FILE = "/tmp/win95-probe.json";
const SCREEN_FILE = "/tmp/win95-screen.png";
const TICK_MS = 5000;
interface ProbeStatus {
ts: string;
uptimeSec: number;
phase: "init" | "running" | "text-mode" | "splash" | "desktop" | "done";
cpuRunning: boolean;
instructionCounter: number;
instructionDelta: number;
textScreen: string;
textHash: string;
gfxW: number;
gfxH: number;
dominantColor: string;
verdict: "" | "SUCCESS" | "FAIL_IOS" | "FAIL_KRNL386" | "FAIL_VXDLINK" | "FAIL_PROTECTION" | "FAIL_SPLASH_HANG" | "FAIL_HUNG" | "FAIL_OTHER";
}
let startTime = 0;
let lastInstr = 0;
let lastTextHash = "";
let stableTextTicks = 0;
// XT scancodes (set 1). Win95 doesn't have Win+R — that landed in Win98.
// Ctrl+Esc opens Start, then R is the underlined mnemonic for "Run...".
const SC = {
CTRL_DN: [0x1d], CTRL_UP: [0x9d],
ESC_DN: [0x01], ESC_UP: [0x81],
R_DN: [0x13], R_UP: [0x93],
ENTER_DN: [0x1c], ENTER_UP: [0x9c],
BACKSLASH_DN: [0x2b], BACKSLASH_UP: [0xab],
};
function sendChord(emu: any, ...keys: { dn: number[]; up: number[] }[]) {
for (const k of keys) emu.keyboard_send_scancodes(k.dn);
setTimeout(() => {
for (let i = keys.length - 1; i >= 0; i--) emu.keyboard_send_scancodes(keys[i].up);
}, 60);
}
function sendKey(emu: any, dn: number[], up: number[]) {
emu.keyboard_send_scancodes(dn);
setTimeout(() => emu.keyboard_send_scancodes(up), 50);
}
/** Replay a list of actions: {type:"keys",dn,up} | {type:"text",text} | {type:"wait",ms} */
function runScript(emu: any, steps: any[]) {
let i = 0;
const next = () => {
if (i >= steps.length) { console.log("[probe] script done"); return; }
const s = steps[i++];
if (s.type === "wait") { setTimeout(next, s.ms); return; }
if (s.type === "keys") { sendKey(emu, s.dn, s.up); setTimeout(next, 200); return; }
if (s.type === "chord") { sendChord(emu, ...s.keys); setTimeout(next, 200); return; }
if (s.type === "text") {
// keyboard_send_text handles ASCII → scancode for us
emu.keyboard_send_text(s.text);
setTimeout(next, 100 + s.text.length * 30);
return;
}
next();
};
next();
}
export function startProbe(emulator: any) {
startTime = Date.now();
console.log("[probe] writing to", STATUS_FILE);
// WIN95_PROBE_SCRIPT=\\HOST → after desktop, send Win+R, type, Enter
const scriptCmd = process.env.WIN95_PROBE_SCRIPT;
let scriptArmed = !!scriptCmd;
const tick = () => {
try {
const s = collectStatus(emulator);
fs.writeFileSync(STATUS_FILE, JSON.stringify(s, null, 2));
// Try to capture a screenshot — this can fail if the screen adapter
// isn't ready yet, so we swallow that.
try {
const img: HTMLImageElement = emulator.screen_make_screenshot();
// The Image has a data: URL src; decode it to bytes
if (img && img.src && img.src.startsWith("data:image/png;base64,")) {
const b64 = img.src.slice("data:image/png;base64,".length);
fs.writeFileSync(SCREEN_FILE, Buffer.from(b64, "base64"));
}
} catch {}
// Once at desktop, fire the keyboard script (once). The 8s settle is
// for the "Welcome to Windows 95" tip dialog to be dismissable —
// we send Esc first to clear it.
if (scriptArmed && s.phase === "desktop" && s.uptimeSec > 8) {
scriptArmed = false;
console.log("[probe] desktop detected, running script:", scriptCmd);
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // dismiss any dialog
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // again, for safety
{ type: "wait", ms: 1000 },
{ type: "chord", keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
]}, // Ctrl+Esc → Start
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP }, // Run mnemonic
{ type: "wait", ms: 1000 },
// keyboard_send_text can't reliably do backslash, so we interleave:
// scancode for each \ segment, text for each name segment.
// WIN95_PROBE_SCRIPT='HOST/HOST' → types \\HOST\HOST (we use / as
// the segment separator in the env var to dodge shell escaping hell)
...scriptCmd!.split("/").flatMap((seg, i) => [
...(i === 0
? [{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP },
{ type: "wait", ms: 60 },
{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP }]
: [{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP }]),
{ type: "wait", ms: 60 },
{ type: "text", text: seg },
{ type: "wait", ms: 100 },
]),
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]);
}
if (s.verdict) {
console.log("[probe] VERDICT:", s.verdict);
fs.writeFileSync(STATUS_FILE.replace(".json", ".done"), s.verdict);
}
} catch (e) {
console.log("[probe] tick error:", e);
}
};
tick();
setInterval(tick, TICK_MS);
}
function collectStatus(emulator: any): ProbeStatus {
const uptimeSec = (Date.now() - startTime) / 1000;
// CPU activity — instruction counter is u32 in wasm, wraps every ~4B
let instr = 0, running = false;
try { instr = emulator.get_instruction_counter() || 0; } catch {}
try { running = emulator.is_running(); } catch {}
const instrDelta = (instr - lastInstr) >>> 0;
lastInstr = instr;
// Text screen — only meaningful in text mode (BIOS, DOS, BSOD).
// In graphics mode this returns garbage or empty.
let textScreen = "";
try {
const screen = emulator.screen_adapter || emulator.v86?.screen_adapter;
if (screen) {
const rows = screen.get_text_screen?.() || [];
textScreen = rows.map((r: string) => r.trimEnd()).join("\n").trim();
}
} catch {}
// VGA state tells us everything: in graphics or text, and at what resolution.
// Win95 splash: 320×400. Win95 desktop: ≥640×480.
// Old v86 builds (pre-2025) don't expose screen_width/screen_height — fall
// back to the rendered canvas dimensions so the bisect harness works across
// versions.
let inGraphics = false, gfxW = 0, gfxH = 0;
try {
const vga = emulator.v86?.cpu?.devices?.vga;
if (vga) {
inGraphics = !!vga.graphical_mode;
gfxW = vga.screen_width || 0;
gfxH = vga.screen_height || 0;
}
} catch {}
if (gfxW === 0) {
try {
const canvas = document.querySelector("#emulator canvas") as HTMLCanvasElement | null;
if (canvas && canvas.width > 0) {
gfxW = canvas.width;
gfxH = canvas.height;
// Canvas exists with content → assume graphics. Text mode uses a div.
const textDiv = document.querySelector("#emulator div") as HTMLElement | null;
inGraphics = canvas.style.display !== "none" &&
(!textDiv || textDiv.style.display === "none");
}
} catch {}
}
// Sample the framebuffer to identify which screen we're on.
// Splash is sky-blue gradient (R~120 G~175 B~215). Desktop is teal (0,128,128).
let dominantColor = "";
if (inGraphics) {
try {
const canvas = document.querySelector("#emulator canvas") as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext("2d")!;
const cx = Math.floor(canvas.width / 2);
const cy = Math.floor(canvas.height / 3); // upper-third → sky on splash, taskbar-free on desktop
const px = ctx.getImageData(cx, cy, 1, 1).data;
dominantColor = `${px[0]},${px[1]},${px[2]}`;
}
} catch {}
}
const textHash = hashStr(textScreen);
if (!inGraphics && textHash === lastTextHash && textScreen) stableTextTicks++;
else stableTextTicks = 0;
lastTextHash = textHash;
const hasMeaningfulText = !inGraphics && textScreen.length > 20 && /[A-Za-z]{4,}/.test(textScreen);
const atSplash = inGraphics && gfxW > 0 && gfxW < 640;
const atDesktop = inGraphics && gfxW >= 640;
const phase: ProbeStatus["phase"] =
!running ? "init" :
atDesktop ? "desktop" :
atSplash ? "splash" :
hasMeaningfulText ? "text-mode" :
"running";
let verdict: ProbeStatus["verdict"] = "";
const t = inGraphics ? "" : textScreen.toLowerCase();
if (t.includes("krnl386")) verdict = "FAIL_KRNL386";
else if (t.includes("vxd dynamic link")) verdict = "FAIL_VXDLINK";
else if (t.includes("initializing device ios") && t.includes("protection error")) verdict = "FAIL_IOS";
else if (t.includes("windows protection error")) verdict = "FAIL_PROTECTION";
// Stuck at splash for >70s with CPU spinning → IDE IRQ never fired
else if (atSplash && uptimeSec > 70) verdict = "FAIL_SPLASH_HANG";
// Stuck on text for 40s
else if (stableTextTicks >= 8 && instrDelta > 1_000_000) verdict = "FAIL_HUNG";
// CPU dead
else if (running && instrDelta < 1000 && uptimeSec > 30) verdict = "FAIL_HUNG";
// Made it to ≥640×480 graphics → desktop reached. But if a keyboard
// script is running, hold off — the outer harness reads the SMB log
// directly and we just keep the app alive.
else if (atDesktop && uptimeSec > 30 && !process.env.WIN95_PROBE_SCRIPT) verdict = "SUCCESS";
// Timeout
else if (uptimeSec > 180) verdict = "FAIL_OTHER";
return {
ts: new Date().toISOString(),
uptimeSec: Math.round(uptimeSec),
phase, cpuRunning: running,
instructionCounter: instr,
instructionDelta: instrDelta,
textScreen: textScreen.slice(0, 2000),
textHash, gfxW, gfxH, dominantColor,
verdict,
};
}
function hashStr(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(16);
}

View File

@@ -3,11 +3,15 @@ import * as React from "react";
interface EmulatorInfoProps {
toggleInfo: () => void;
emulator: any;
hidden: boolean;
}
interface EmulatorInfoState {
cpu: number;
disk: string;
diskRead: number;
diskWrite: number;
netRx: number;
netTx: number;
lastCounter: number;
lastTick: number;
}
@@ -16,33 +20,49 @@ export class EmulatorInfo extends React.Component<
EmulatorInfoProps,
EmulatorInfoState
> {
private cpuInterval = -1;
private tickInterval = -1;
private diskReadBytes = 0;
private diskWriteBytes = 0;
private netRxBytes = 0;
private netTxBytes = 0;
constructor(props: EmulatorInfoProps) {
super(props);
this.cpuCount = this.cpuCount.bind(this);
this.onIDEReadStart = this.onIDEReadStart.bind(this);
this.onIDEReadWriteEnd = this.onIDEReadWriteEnd.bind(this);
this.tick = this.tick.bind(this);
this.onIDEReadEnd = this.onIDEReadEnd.bind(this);
this.onIDEWriteEnd = this.onIDEWriteEnd.bind(this);
this.onEthReceiveEnd = this.onEthReceiveEnd.bind(this);
this.onEthTransmitEnd = this.onEthTransmitEnd.bind(this);
this.state = {
cpu: 0,
disk: "Idle",
diskRead: 0,
diskWrite: 0,
netRx: 0,
netTx: 0,
lastCounter: 0,
lastTick: 0,
};
}
public render() {
const { cpu, disk } = this.state;
const { cpu, diskRead, diskWrite, netRx, netTx } = this.state;
const { hidden, toggleInfo } = this.props;
return (
<div id="status">
Disk: <span>{disk}</span> | CPU Speed: <span>{cpu}</span> |{" "}
<a href="#" onClick={this.props.toggleInfo}>
Hide
</a>
</div>
<>
<div id="status-hotzone" />
<div id="status" className={hidden ? "hidden" : ""}>
CPU: <span>{cpu}M/s</span> | Disk:{" "}
<span>R {this.rate(diskRead)}</span>{" "}
<span>W {this.rate(diskWrite)}</span> | Net:{" "}
<span>{this.rate(netRx)}</span> <span>{this.rate(netTx)}</span> |{" "}
<a href="#" onClick={toggleInfo}>
{hidden ? "Pin" : "Hide"}
</a>
</div>
</>
);
}
@@ -79,21 +99,17 @@ export class EmulatorInfo extends React.Component<
return;
}
// CPU
if (this.cpuInterval > -1) {
clearInterval(this.cpuInterval);
if (this.tickInterval > -1) {
clearInterval(this.tickInterval);
}
// TypeScript think's we're using a Node.js setInterval. We're not.
this.cpuInterval = setInterval(this.cpuCount, 500) as unknown as number;
this.tickInterval = setInterval(this.tick, 500) as unknown as number;
// Disk
emulator.add_listener("ide-read-start", this.onIDEReadStart);
emulator.add_listener("ide-read-end", this.onIDEReadWriteEnd);
emulator.add_listener("ide-write-end", this.onIDEReadWriteEnd);
// Screen
emulator.add_listener("screen-set-size-graphical", console.log);
emulator.add_listener("ide-read-end", this.onIDEReadEnd);
emulator.add_listener("ide-write-end", this.onIDEWriteEnd);
emulator.add_listener("eth-receive-end", this.onEthReceiveEnd);
emulator.add_listener("eth-transmit-end", this.onEthTransmitEnd);
}
/**
@@ -109,58 +125,67 @@ export class EmulatorInfo extends React.Component<
return;
}
// CPU
if (this.cpuInterval > -1) {
clearInterval(this.cpuInterval);
if (this.tickInterval > -1) {
clearInterval(this.tickInterval);
}
// Disk
emulator.remove_listener("ide-read-start", this.onIDEReadStart);
emulator.remove_listener("ide-read-end", this.onIDEReadWriteEnd);
emulator.remove_listener("ide-write-end", this.onIDEReadWriteEnd);
emulator.remove_listener("ide-read-end", this.onIDEReadEnd);
emulator.remove_listener("ide-write-end", this.onIDEWriteEnd);
emulator.remove_listener("eth-receive-end", this.onEthReceiveEnd);
emulator.remove_listener("eth-transmit-end", this.onEthTransmitEnd);
}
// Screen
emulator.remove_listener("screen-set-size-graphical", console.log);
private onIDEReadEnd(args: number[]) {
this.diskReadBytes += args[1];
}
private onIDEWriteEnd(args: number[]) {
this.diskWriteBytes += args[1];
}
private onEthReceiveEnd(args: number[]) {
this.netRxBytes += args[0];
}
private onEthTransmitEnd(args: number[]) {
this.netTxBytes += args[0];
}
/**
* The virtual IDE is handling read (start).
* Format bytes/sec into a compact human string.
*/
private onIDEReadStart() {
this.requestIdle(() => this.setState({ disk: "Read" }));
private rate(bytesPerSec: number) {
if (bytesPerSec <= 0) return "0";
if (bytesPerSec < 1024) return `${bytesPerSec}B/s`;
if (bytesPerSec < 1024 * 1024) return `${Math.round(bytesPerSec / 1024)}K/s`;
return `${(bytesPerSec / 1024 / 1024).toFixed(1)}M/s`;
}
/**
* The virtual IDE is handling read/write (end).
* Once per interval, compute CPU speed and I/O throughput.
*/
private onIDEReadWriteEnd() {
this.requestIdle(() => this.setState({ disk: "Idle" }));
}
/**
* Request an idle callback with a 3s timeout.
*
* @param fn
*/
private requestIdle(fn: () => void) {
(window as any).requestIdleCallback(fn, { timeout: 3000 });
}
/**
* Calculates what's up with the virtual cpu.
*/
private cpuCount() {
private tick() {
const { lastCounter, lastTick } = this.state;
const now = Date.now();
const instructionCounter = this.props.emulator.get_instruction_counter();
const ips = instructionCounter - lastCounter;
const deltaTime = now - lastTick;
const deltaSec = deltaTime / 1000;
this.setState({
lastTick: now,
lastCounter: instructionCounter,
cpu: Math.round(ips / deltaTime),
cpu: Math.round(ips / deltaTime / 1000),
diskRead: Math.round(this.diskReadBytes / deltaSec),
diskWrite: Math.round(this.diskWriteBytes / deltaSec),
netRx: Math.round(this.netRxBytes / deltaSec),
netTx: Math.round(this.netTxBytes / deltaSec),
});
this.diskReadBytes = 0;
this.diskWriteBytes = 0;
this.netRxBytes = 0;
this.netTxBytes = 0;
}
}

View File

@@ -12,6 +12,14 @@ import { EmulatorInfo } from "./emulator-info";
import { getStatePath } from "./utils/get-state-path";
import { Win95Window } from "./app";
import { resetState } from "./utils/reset-state";
import { setupSmbShare } from "./smb";
import { startProbe } from "./debug-harness";
const PROBE = process.env.WIN95_PROBE === "1";
const PROBE_OPTS: Record<string, unknown> = (() => {
try { return JSON.parse(process.env.WIN95_PROBE_OPTS || "{}"); }
catch { return {}; }
})();
declare let window: Win95Window;
@@ -21,6 +29,7 @@ export interface EmulatorState {
scale: number;
floppyFile?: File;
cdromFile?: File;
smbSharePath: string;
isBootingFresh: boolean;
isCursorCaptured: boolean;
isInfoDisplayed: boolean;
@@ -41,11 +50,12 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.bootFromScratch = this.bootFromScratch.bind(this);
this.state = {
isBootingFresh: false,
isBootingFresh: PROBE,
isCursorCaptured: false,
isRunning: false,
currentUiCard: "start",
isInfoDisplayed: true,
smbSharePath: "",
// We can start pretty large
// If it's too large, it'll just grow until it hits borders
scale: 2,
@@ -54,6 +64,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.setupInputListeners();
this.setupIpcListeners();
this.setupUnloadListeners();
ipcRenderer.invoke(IPC_COMMANDS.GET_SMB_SHARE_PATH).then((p: string) => {
this.setState({ smbSharePath: p });
});
if (PROBE) {
// Skip the start card; boot fresh immediately. The 100ms delay
// lets React mount the #emulator div first.
setTimeout(() => this.bootFromScratch(), 100);
}
}
/**
@@ -194,9 +214,15 @@ export class Emulator extends React.Component<{}, EmulatorState> {
<CardSettings
setFloppy={(floppyFile) => this.setState({ floppyFile })}
setCdrom={(cdromFile) => this.setState({ cdromFile })}
setSmbSharePath={(smbSharePath) => {
this.setState({ smbSharePath });
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, smbSharePath);
}}
pickFolder={() => ipcRenderer.invoke(IPC_COMMANDS.PICK_FOLDER)}
bootFromScratch={this.bootFromScratch}
floppy={floppyFile}
cdrom={cdromFile}
smbSharePath={this.state.smbSharePath}
/>
);
} else {
@@ -207,7 +233,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
<>
{card}
<StartMenu
navigate={(target) => this.setState({ currentUiCard: target as "start" | "settings" })}
navigate={(target) =>
this.setState({ currentUiCard: target as "start" | "settings" })
}
/>
</>
);
@@ -233,13 +261,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
* Render the little info thingy
*/
public renderInfo() {
if (!this.state.isInfoDisplayed) {
return null;
}
return (
<EmulatorInfo
emulator={this.state.emulator}
hidden={!this.state.isInfoDisplayed}
toggleInfo={() => {
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
}}
@@ -281,7 +306,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
vga_memory_size: 64 * 1024 * 1024,
screen: {
container: document.getElementById("emulator"),
scale: 0
scale: 0,
},
preserve_mac_from_state_image: true,
net_device: {
@@ -314,10 +339,26 @@ export class Emulator extends React.Component<{}, EmulatorState> {
boot_order: 0x132,
};
// PROBE_OPTS lets the outer harness override options without rebuilding
// (e.g. WIN95_PROBE_OPTS='{"acpi":false,"disable_jit":true}')
Object.assign(options, PROBE_OPTS);
console.log(`🚜 Starting emulator with options`, options);
window["emulator"] = new V86(options);
// Serve a host folder over SMB on port 139. Read-only, traversal/symlink
// guarded. In Win95: Start → Run → \\HOST\HOST. The env var wins so the
// probe harness can point at a fixture dir without touching settings.
const smbRoot = process.env.WIN95_SMB_SHARE || this.state.smbSharePath;
if (smbRoot) {
setupSmbShare(window["emulator"], smbRoot);
}
if (PROBE) {
startProbe(window["emulator"]);
}
// New v86 instance
this.setState({
emulator: window["emulator"],
@@ -402,7 +443,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
try {
const newState = await emulator.save_state();
await fs.promises.writeFile(statePath, Buffer.from(newState), {
flush: true
flush: true,
});
} catch (error) {
console.warn(`saveState: Could not save state`, error);
@@ -524,7 +565,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
const canvas = document.getElementById("emulator-canvas");
if (canvas instanceof HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext("2d");
ctx?.clearRect(0, 0, canvas.width, canvas.height);
}
}

View File

@@ -54,14 +54,14 @@ var ka={4:"PORT_DMA_ADDR_2",5:"PORT_DMA_CNT_2",10:"PORT_DMA1_MASK_REG",11:"PORT_
D.prototype.next_tick=function(a){const b=++this.tick_counter;this.idle=!0;this.yield(a,b)};D.prototype.yield_callback=function(a){a===this.tick_counter&&this.do_tick()};D.prototype.stop=function(){this.running&&(this.stopping=!0)};D.prototype.destroy=function(){this.unregister_yield()};D.prototype.restart=function(){this.cpu.reset_cpu();this.cpu.load_bios()};D.prototype.init=function(a){this.cpu.init(a,this.bus);this.bus.send("emulator-ready")};
if("undefined"!==typeof process)D.prototype.yield=function(a,b){1>a?global.setImmediate(c=>this.yield_callback(c),b):setTimeout(c=>this.yield_callback(c),a,b)},D.prototype.register_yield=function(){},D.prototype.unregister_yield=function(){};else if("undefined"!==typeof Worker){function a(){let b;globalThis.onmessage=function(c){const d=c.data.t;b=b&&clearTimeout(b);1>d?postMessage(c.data.tick):b=setTimeout(()=>postMessage(c.data.tick),d)}}D.prototype.register_yield=function(){const b=URL.createObjectURL(new Blob(["("+
a.toString()+")()"],{type:"text/javascript"}));this.worker=new Worker(b);this.worker.onmessage=c=>this.yield_callback(c.data);URL.revokeObjectURL(b)};D.prototype.yield=function(b,c){this.worker.postMessage({t:b,tick:c})};D.prototype.unregister_yield=function(){this.worker&&this.worker.terminate();this.worker=null}}else D.prototype.yield=function(a){setTimeout(()=>{this.do_tick()},a)},D.prototype.register_yield=function(){},D.prototype.unregister_yield=function(){};D.prototype.save_state=function(){return this.cpu.save_state()};
D.prototype.restore_state=function(a){return this.cpu.restore_state(a)};if("object"===typeof performance&&performance.now)D.microtick=performance.now.bind(performance);else if("function"===typeof require){const {performance:a}=require("perf_hooks");D.microtick=a.now.bind(a)}else D.microtick="object"===typeof process&&process.hrtime?function(){var a=process.hrtime();return 1E3*a[0]+a[1]/1E6}:Date.now;var H=H||{};H.exportSymbol=function(a,b){"undefined"!==typeof module&&"undefined"!==typeof module.exports?module.exports[a]=b:"undefined"!==typeof window?window[a]=b:"function"===typeof importScripts&&(self[a]=b)};H.exportProperty=function(){};var k=k||{};k.pads=function(a,b){return(a||0===a?a+"":"").padEnd(b," ")};k.pad0=function(a,b){return(a||0===a?a+"":"").padStart(b,"0")};k.zeros=function(a){return Array(a).fill(0)};k.range=function(a){return Array.from(Array(a).keys())};
D.prototype.restore_state=function(a){return this.cpu.restore_state(a)};if("object"===typeof performance&&performance.now)D.microtick=performance.now.bind(performance);else if("function"===typeof require){const {performance:a}=require("perf_hooks");D.microtick=a.now.bind(a)}else D.microtick="object"===typeof process&&process.hrtime?function(){var a=process.hrtime();return 1E3*a[0]+a[1]/1E6}:Date.now;var H=H||{};H.exportSymbol=function(a,b){"undefined"!==typeof window?window[a]=b:"undefined"!==typeof module&&"undefined"!==typeof module.exports?module.exports[a]=b:"function"===typeof importScripts&&(self[a]=b)};H.exportProperty=function(){};var k=k||{};k.pads=function(a,b){return(a||0===a?a+"":"").padEnd(b," ")};k.pad0=function(a,b){return(a||0===a?a+"":"").padStart(b,"0")};k.zeros=function(a){return Array(a).fill(0)};k.range=function(a){return Array.from(Array(a).keys())};
k.view=function(a,b,c,d){return new Proxy({},{get:function(e,g){e=new a(b.buffer,c,d);const f=e[g];if("function"===typeof f)return f.bind(e);/^\d+$/.test(g);return f},set:function(e,g,f){/^\d+$/.test(g);(new a(b.buffer,c,d))[g]=f;return!0}})};function B(a,b){a=a?a.toString(16):"";return"0x"+k.pad0(a.toUpperCase(),b||1)}
if("undefined"!==typeof crypto&&crypto.getRandomValues){const a=new Int32Array(1);k.get_rand_int=function(){crypto.getRandomValues(a);return a[0]}}else if("undefined"!==typeof require){const a=require("crypto");k.get_rand_int=function(){return a.randomBytes(4).readInt32LE(0)}}
(function(){if("function"===typeof Math.clz32)k.int_log2=function(d){return 31-Math.clz32(d)};else{for(var a=new Int8Array(256),b=0,c=-2;256>b;b++)b&b-1||c++,a[b]=c;k.int_log2=function(d){d>>>=0;var e=d>>>16;if(e){var g=e>>>8;return g?24+a[g]:16+a[e]}return(g=d>>>8)?8+a[g]:a[d]}}})();k.round_up_to_next_power_of_2=function(a){return 1>=a?1:1<<1+k.int_log2(a-1)};
function la(a){var b=new Uint8Array(a),c,d;this.length=0;this.push=function(e){this.length!==a&&this.length++;b[d]=e;d=d+1&a-1};this.shift=function(){if(this.length){var e=b[c];c=c+1&a-1;this.length--;return e}return-1};this.peek=function(){return this.length?b[c]:-1};this.clear=function(){this.length=d=c=0};this.clear()}function oa(a){this.size=a;this.data=new Float32Array(a);this.length=this.end=this.start=0}
oa.prototype.push=function(a){this.length===this.size?this.start=this.start+1&this.size-1:this.length++;this.data[this.end]=a;this.end=this.end+1&this.size-1};oa.prototype.shift=function(){if(this.length){var a=this.data[this.start];this.start=this.start+1&this.size-1;this.length--;return a}};
oa.prototype.shift_block=function(a){var b=new Float32Array(a);a>this.length&&(a=this.length);var c=this.start+a,d=this.data.subarray(this.start,c);b.set(d);c>=this.size&&(c-=this.size,b.set(this.data.subarray(0,c),d.length));this.start=c;this.length-=a;return b};oa.prototype.peek=function(){if(this.length)return this.data[this.start]};oa.prototype.clear=function(){this.length=this.end=this.start=0};
k.Bitmap=function(a){"number"===typeof a?this.view=new Uint8Array(a+7>>3):a instanceof ArrayBuffer&&(this.view=new Uint8Array(a))};k.Bitmap.prototype.set=function(a,b){const c=a>>3;a=1<<(a&7);this.view[c]=b?this.view[c]|a:this.view[c]&~a};k.Bitmap.prototype.get=function(a){return this.view[a>>3]>>(a&7)&1};k.Bitmap.prototype.get_buffer=function(){return this.view.buffer};k.load_file="undefined"===typeof XMLHttpRequest?pa:qa;
k.Bitmap=function(a){"number"===typeof a?this.view=new Uint8Array(a+7>>3):a instanceof ArrayBuffer&&(this.view=new Uint8Array(a))};k.Bitmap.prototype.set=function(a,b){const c=a>>3;a=1<<(a&7);this.view[c]=b?this.view[c]|a:this.view[c]&~a};k.Bitmap.prototype.get=function(a){return this.view[a>>3]>>(a&7)&1};k.Bitmap.prototype.get_buffer=function(){return this.view.buffer};k.load_file=pa;
function qa(a,b,c){function d(){const l=c||0;setTimeout(()=>{qa(a,b,l+1)},1E3*([1,1,2,3,5,8,13,21][l]||34))}var e=new XMLHttpRequest;e.open(b.method||"get",a,!0);e.responseType=b.as_json?"json":"arraybuffer";if(b.headers)for(var g=Object.keys(b.headers),f=0;f<g.length;f++){var h=g[f];e.setRequestHeader(h,b.headers[h])}b.range&&(g=b.range.start,e.setRequestHeader("Range","bytes="+g+"-"+(g+b.range.length-1)),e.setRequestHeader("X-Accept-Encoding","identity"),e.onreadystatechange=function(){200===e.status&&
(console.error("Server sent full file in response to ranged request, aborting",{filename:a}),e.abort())});e.onload=function(){if(4===e.readyState)if(200!==e.status&&206!==e.status)console.error("Loading the image "+a+" failed (status %d)",e.status),500<=e.status&&600>e.status&&d();else if(e.response){if(b.range){const l=e.getResponseHeader("Content-Encoding");l&&"identity"!==l&&console.error("Server sent Content-Encoding in response to ranged request",{filename:a,enc:l})}b.done&&b.done(e.response,
e)}};e.onerror=function(l){console.error("Loading the image "+a+" failed",l);d()};b.progress&&(e.onprogress=function(l){b.progress(l)});e.send(null)}
@@ -638,7 +638,7 @@ ec.prototype.send_wisp_frame=function(a){let b,c;switch(a.type){case "CONNECT":c
2);c.setUint32(1,a.stream_id,!0);b.set(a.data,5);break;case "CLOSE":b=new Uint8Array(6),c=new DataView(b.buffer),c.setUint8(0,4),c.setUint32(1,a.stream_id,!0),c.setUint8(5,a.reason)}this.send_packet(b,a.type,a.stream_id)};ec.prototype.destroy=function(){this.wispws&&(this.wispws.onmessage=null,this.wispws.onclose=null,this.wispws.close(),this.wispws=null)};
ec.prototype.on_tcp_connection=function(a,b){let c=new Cc;c.state="syn-received";c.net=this;c.tuple=b;c.stream_id=this.last_stream++;this.tcp_conn[b]=c;c.on_data=d=>{0!==d.length&&this.send_wisp_frame({type:"DATA",stream_id:c.stream_id,data:d})};c.on_close=()=>{this.send_wisp_frame({type:"CLOSE",stream_id:c.stream_id,reason:2})};c.on_shutdown=c.on_close;this.send_wisp_frame({type:"CONNECT",stream_id:c.stream_id,hostname:a.ipv4.dest.join("."),port:a.tcp.dport,data_callback:d=>{c.write(d)},close_callback:()=>
{c.close()}});c.accept(a);return!0};ec.prototype.send=function(a){zc(a,this)};ec.prototype.receive=function(a){this.bus.send("net"+this.id+"-receive",new Uint8Array(a))};function cc(a,b){b=b||{};this.bus=a;this.id=b.id||0;this.router_mac=new Uint8Array((b.router_mac||"52:54:0:1:2:3").split(":").map(function(c){return parseInt(c,16)}));this.router_ip=new Uint8Array((b.router_ip||"192.168.86.1").split(".").map(function(c){return parseInt(c,10)}));this.vm_ip=new Uint8Array((b.vm_ip||"192.168.86.100").split(".").map(function(c){return parseInt(c,10)}));this.masquerade=void 0===b.masquerade||!!b.masquerade;this.vm_mac=new Uint8Array(6);this.dns_method=b.dns_method||"static";
this.doh_server=b.doh_server;this.tcp_conn={};this.eth_encoder_buf=tc();this.fetch=fetch;this.cors_proxy=b.cors_proxy;this.bus.register("net"+this.id+"-mac",function(c){this.vm_mac=new Uint8Array(c.split(":").map(function(d){return parseInt(d,16)}))},this);this.bus.register("net"+this.id+"-send",function(c){this.send(c)},this)}H.exportSymbol("FetchNetworkAdapter",cc);cc.prototype.destroy=function(){};
this.doh_server=b.doh_server;this.tcp_conn={};this.eth_encoder_buf=tc();this.fetch=(...args)=>fetch(...args);this.cors_proxy=b.cors_proxy;this.bus.register("net"+this.id+"-mac",function(c){this.vm_mac=new Uint8Array(c.split(":").map(function(d){return parseInt(d,16)}))},this);this.bus.register("net"+this.id+"-send",function(c){this.send(c)},this)}H.exportSymbol("FetchNetworkAdapter",cc);cc.prototype.destroy=function(){};
cc.prototype.on_tcp_connection=function(a,b){if(80===a.tcp.dport){let c=new Cc;c.state="syn-received";c.net=this;c.on_data=Dc;c.tuple=b;c.accept(a);this.tcp_conn[b]=c;return!0}return!1};cc.prototype.connect=function(a){return Bc(a,this)};
async function Dc(a){this.read=this.read||"";if((this.read+=(new TextDecoder).decode(a))&&-1!==this.read.indexOf("\r\n\r\n")){var b=this.read.indexOf("\r\n\r\n");a=this.read.substring(0,b).split(/\r\n/);b=this.read.substring(b+4);this.read="";let c=a[0].split(" "),d;d=/^https?:/.test(c[1])?new URL(c[1]):new URL("http://host"+c[1]);"undefined"!==typeof window&&"http:"===d.protocol&&"https:"===window.location.protocol&&(d.protocol="https:");let e=new Headers;for(let l=1;l<a.length;++l){const m=this.net.parse_http_header(a[l]);
if(!m){console.warn('The request contains an invalid header: "%s"',a[l]);this.write((new TextEncoder).encode("HTTP/1.1 400 Bad Request\r\nContent-Length: 0"));return}"host"===m.key.toLowerCase()?d.host=m.value:e.append(m.key,m.value)}this.name=d.href;a={method:c[0],headers:e};-1!==["put","post"].indexOf(a.method.toLowerCase())&&(a.body=b);const g=this.net.cors_proxy?this.net.cors_proxy+encodeURIComponent(d.href):d.href,f=new TextEncoder;let h=!1;this.net.fetch(g,a).then(l=>{const m=[`HTTP/1.1 ${l.status} ${l.statusText}`,

View File

@@ -0,0 +1,91 @@
# SMB1 server for Windows 95
Zero-dependency SMB1/CIFS server that lets Windows 95 (running inside v86) mount
a host folder as a network drive. Read-only. ~1500 lines.
## Stack
| Layer | File | What it does |
|---|---|---|
| Ethernet/IP/UDP | `nbns.ts` | Taps `bus.register("net0-send")` for raw frames, parses UDP 137, builds reply frames manually |
| NetBIOS Name Service | `nbns.ts` | Answers Node Status (0x21) and Name Query (0x20) — Win95 won't try TCP until this resolves |
| TCP 139 hook | `index.ts` | Monkeypatches `adapter.on_tcp_connection` (old v86) or registers `tcp-connection` bus event (new v86) |
| NetBIOS Session | `netbios.ts` | RFC 1002 framing — 4-byte header, reassembles fragmented TCP |
| SMB1 wire | `wire.ts`, `smb.ts` | Little-endian Reader/Writer, header parse/build |
| Commands | `server.ts` | NEGOTIATE, SESSION_SETUP, TREE_CONNECT, TRANSACTION (RAP), TRANSACTION2, SEARCH, OPEN, READ, CLOSE, etc. |
## Protocol gotchas (learned the hard way)
### NEGOTIATE: don't pick NT LM 0.12 unless you implement the NT response
Win95 offers `["PC NETWORK PROGRAM 1.0", "MICROSOFT NETWORKS 3.0", "DOS LM1.2X002",
"DOS LANMAN2.1", "Windows for Workgroups 3.1a", "NT LM 0.12"]`. We send the
13-word LANMAN-style negotiate response. If you pick `NT LM 0.12` and send 13
words, Win95 silently drops the connection — it expects the 17-word NT response
with capability flags. Pick `DOS LANMAN2.1` instead.
### SEARCH (0x81): single-file probes vs wildcard listings
`SEARCH "\FOO.TXT"` is a stat probe — Win95 wants exactly one entry back. If you
prepend `.` and `..` like you would for `\*`, Win95 reads the first entry (`.`,
attr=DIRECTORY) and treats `FOO.TXT` as a folder. Only prepend dots when the
pattern contains `*` or `?`.
### SEARCH filename: null-terminate before padding
The 13-byte name field must be `name\0\0\0...`, not `name \0`. Space-padding
before the null means Win95 sees `FOO.BAT ` (with trailing spaces) and can't
match the `.BAT` file association.
### 8.3 mapping needs `~N` suffixes, not just truncation
84 files in a real Downloads folder → most have long names → naive truncation
gives 30 copies of `15_UNDER.PDF`. Use Windows-style `~N` and keep a per-dir
SFN→real-name map so OPEN can find the actual file. `resolve()` walks each path
component through the map.
### RAP (TRANSACTION 0x25): Win95 loops until ServerGetInfo answers
After `TREE_CONNECT \\HOST\IPC$`, Win95 sends RAP NetShareEnum (func=0, `WrLeh`/
`B13BWz`) then NetWkstaGetInfo (func=63, `WrLh`/`zzzBBzz`) then NetServerGetInfo
(func=13, `WrLh`/`B16BBDz`). The data descriptor tells you the layout:
`B16` = 16-byte inline name, `z` = string pointer (4 bytes into a heap that
follows the struct), `B` = byte, `D` = dword. We synthesize the struct from the
descriptor so any info-level Win95 asks for gets a plausible reply.
### Virtual files need to be visible to QUERY_INFORMATION too
The injected `_MAPZ.BAT` showed in listings but Win95 stats before opening,
got ERR_BADFILE, said "cannot find". Hook `getVirtual()` into QUERY_INFO and
CHECK_DIRECTORY, not just OPEN.
## v86 integration (the hard part)
### Old v86 (Feb 2025 — what currently boots): connection theft
The `tcp-connection` bus event was added later. The old API is
`adapter.on_tcp_connection(packet, tuple)` — you must construct `TCPConnection`
yourself, but it's closure-scoped in Closure-compiled `libv86.js`. Worse,
`.on()`/`.emit()`/`events_handlers` were dead-code-eliminated; the data callback
is a flat `.on_data` property.
The trick: shadow `adapter.receive` with a no-op (own-prop on a prototype method
**must** restore via `delete`, not reassignment), call the original handler
with a fake port-80 SYN, take the `TCPConnection` it builds, re-aim it at port
139. `accept(packet)` overwrites all routing fields (sport/dport/hsrc/psrc/seq/
ack), `.on_data = handler` replaces the HTTP callback.
### New v86: just `bus.register("tcp-connection")`
Clean API. The new code keeps both paths; the bus event is a no-op on old builds.
### Exception in a bus listener kills the emulator
`bus.send` doesn't catch listener exceptions. They bubble through ne2k →
`port_write8` → wasm. Win95 freezes. The corrupted state then gets saved by
`onbeforeunload`. Wrap everything that runs in a callback.
## Security
- Read-only.
- Path traversal blocked lexically (`../`) AND through symlinks: `realpathSync`
the deepest existing ancestor, re-append the unresolved tail, confirm under
root. Symlinks pointing inside the share still work; symlinks pointing out
return ERR_BADFILE.
- Share path validated in main-process IPC (`realpathSync` + `isDirectory()`).
## Tests
`test-standalone.ts` — 35 protocol tests, full round-trips with real file I/O.
Run: `npx tsc --ignoreConfig --module commonjs --target es2020 --esModuleInterop
--moduleResolution bundler --outDir /tmp/smb-test --skipLibCheck
src/renderer/smb/*.ts && node /tmp/smb-test/test-standalone.js`

193
src/renderer/smb/index.ts Normal file
View File

@@ -0,0 +1,193 @@
// Glue: hook v86's TCP-connection bus event for port 139 and bridge it to
// our SMB server. Windows 95 connects via NetBIOS-over-TCP — ethernet frame
// → ne2k → fake_network's userspace TCP/IP → tcp-connection event with a
// stream-like TCPConnection object.
//
// To use: in emulator.tsx after `new V86()`, call
// setupSmbShare(window.emulator, "/Users/you/share")
// Then inside Win95: Start → Run → \\192.168.86.1\host
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { NetBIOSFramer, nbPositiveResponse, nbWrap } from "./netbios";
import { setupNbns } from "./nbns";
import { SmbSession } from "./server";
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
const LOG_FILE = path.join(os.tmpdir(), "windows95-smb.log");
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
const origLog = console.log;
console.log = (...args: unknown[]) => {
origLog(...args);
const tag = String(args[0] ?? "");
if (tag === "[smb]" || tag === "[nbns]") {
try {
fs.appendFileSync(LOG_FILE, args.map(a =>
typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n");
} catch {}
}
};
interface TCPConnection {
sport: number;
tuple: string;
state: string;
net: unknown;
on(event: "data", handler: (data: Uint8Array) => void): void;
write(data: Uint8Array): void;
accept(packet?: unknown): void;
close(): void;
}
interface NetworkAdapter {
tcp_conn: Record<string, TCPConnection>;
on_tcp_connection?: (packet: any, tuple: string) => boolean;
router_mac: Uint8Array;
router_ip: Uint8Array;
}
interface V86 {
bus: {
register(name: string, fn: (arg: unknown) => void, ctx?: unknown): void;
};
network_adapter?: NetworkAdapter;
}
const log = (...a: unknown[]) => console.log("[smb]", ...a);
export function setupSmbShare(emulator: V86, hostPath: string) {
log(`serving ${hostPath} on \\\\HOST\\host (port 139)`);
// SPIKE diagnostic: count every ethernet frame so we know if the NIC is
// emitting anything at all (DHCP, ARP, anything). Logged on a timer so
// we don't flood — and so the absence of a tick proves the bus is dead.
let frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
emulator.bus.register("net0-send", (raw: unknown) => {
const f = raw as Uint8Array;
frameStats.total++;
if (f.length < 14) { frameStats.other++; return; }
const et = (f[12] << 8) | f[13];
if (et === 0x0806) frameStats.arp++;
else if (et === 0x0800) {
frameStats.ip++;
const proto = f[14 + 9];
if (proto === 6) frameStats.tcp++;
else if (proto === 17) frameStats.udp++;
} else frameStats.other++;
});
setInterval(() => {
if (frameStats.total > 0) {
log("frames:", JSON.stringify(frameStats));
frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
}
}, 5000);
// Win95 won't even try TCP 139 until UDP 137 answers a Node Status query
setupNbns(emulator as Parameters<typeof setupNbns>[0]);
// ─── TCP 139 hook ───────────────────────────────────────────────────────
// v86 has two APIs depending on age:
// new (2025+): bus event "tcp-connection" with a pre-built conn
// old (≤Feb 2025): adapter.on_tcp_connection(packet, tuple) callback
// where we must construct TCPConnection ourselves
// We can't `new TCPConnection()` directly (closure-scoped), so for the
// old API we steal the constructor from the prototype of any existing
// connection — which means we need a probe HTTP connection to fire first
// (or we wait for one). The fetch adapter itself uses the constructor for
// port 80, so as soon as anything in Win95 hits HTTP, we can steal it.
const wireConn = (conn: TCPConnection) => {
log(`← TCP SYN ${conn.tuple}`);
const framer = new NetBIOSFramer();
const session = new SmbSession(hostPath);
const handler = (data: Uint8Array) => {
for (const msg of framer.push(data)) {
if (msg.type === 0x81) {
log("← NB session request → +response");
conn.write(nbPositiveResponse());
} else if (msg.type === 0x00) {
const reply = session.handle(msg.payload);
if (reply) conn.write(nbWrap(reply));
}
}
};
// New v86 has .on(); old v86 had .on/.emit dead-code-eliminated by
// Closure into a flat .on_data callback property. Check for the method
// first, fall back to direct assignment.
if (typeof (conn as any).on === "function") {
conn.on("data", handler);
} else {
(conn as any).on_data = handler;
}
};
// New API: bus event (no-op on old v86 — event never fires)
emulator.bus.register("tcp-connection", (c: unknown) => {
const conn = c as TCPConnection;
if (conn.sport !== 139) return;
wireConn(conn);
conn.accept();
});
// Old API: monkey-patch adapter.on_tcp_connection. The adapter is created
// inside V86's async init, so poll for it.
//
// Instead of stealing the TCPConnection constructor (closure-scoped, brittle
// with new-on-stolen-ctor), we make the original handler build one for us
// by handing it a port-80 SYN — then RECONFIGURE that connection for 139.
// accept(packet) overwrites every routing field (sport/dport/hsrc/etc), and
// .on("data") overwrites the HTTP handler. The probe's fake SYN-ACK is eaten
// by shadowing adapter.receive (prototype method — `delete` to restore).
const tryHook = () => {
const adapter = emulator.network_adapter;
if (!adapter || typeof adapter.on_tcp_connection !== "function") return false;
const orig = adapter.on_tcp_connection.bind(adapter);
adapter.on_tcp_connection = function (packet: any, tuple: string): boolean {
if (packet.tcp.dport !== 139) return orig(packet, tuple);
const adapterAny = adapter as any;
adapterAny.receive = () => {};
let conn: TCPConnection | undefined;
try {
const fakeTuple = "__nbt__";
orig({ ...packet, tcp: { ...packet.tcp, dport: 80 } }, fakeTuple);
conn = adapter.tcp_conn[fakeTuple];
delete adapter.tcp_conn[fakeTuple];
} finally {
delete adapterAny.receive;
}
if (!conn) {
log("⚠ probe didn't yield a connection; RST");
return false;
}
// Re-aim it at port 139. accept() overwrites sport/dport/hsrc/psrc/seq/ack
// from the packet; .on("data") replaces the HTTP handler (assignment, not
// push). Only state needs explicit reset — the probe accept set it to
// "established" and we want a fresh handshake.
conn.tuple = tuple;
conn.state = "syn-received";
wireConn(conn);
try {
conn.accept(packet);
} catch (e) {
log("accept threw:", e instanceof Error ? e.message : String(e));
return false;
}
adapter.tcp_conn[tuple] = conn;
return true;
};
log("hooked adapter.on_tcp_connection (old API, conn-recycling)");
return true;
};
if (!tryHook()) {
const poll = setInterval(() => { if (tryHook()) clearInterval(poll); }, 100);
setTimeout(() => clearInterval(poll), 10000);
}
}

258
src/renderer/smb/nbns.ts Normal file
View File

@@ -0,0 +1,258 @@
// NetBIOS Name Service (RFC 1002, UDP 137). Win95 won't connect to
// \\192.168.86.1 until this answers — even with an IP address it sends a
// Node Status Request to learn our NetBIOS name for the session-layer
// "called name" field.
//
// fake_network.js handles DNS/DHCP/NTP/echo and silently drops everything
// else. We tap net0-send to see raw ethernet frames, parse UDP 137 ourselves,
// and inject replies via net0-receive.
const ETHERTYPE_IPV4 = 0x0800;
const IPPROTO_UDP = 17;
const NBNS_PORT = 137;
const NB_NAME = "HOST"; // what shows up in Network Neighborhood
const NB_WORKGROUP = "WORKGROUP";
const log = (...a: unknown[]) => console.log("[nbns]", ...a);
interface V86 {
bus: {
register(name: string, fn: (data: Uint8Array) => void): void;
send(name: string, data: Uint8Array): void;
};
network_adapter?: {
router_mac: Uint8Array;
router_ip: Uint8Array;
vm_mac: Uint8Array;
vm_ip: Uint8Array;
};
}
export function setupNbns(emulator: V86) {
emulator.bus.register("net0-send", (frame: Uint8Array) => {
const r = parseUdp(frame);
if (!r || r.dport !== NBNS_PORT) return;
const reply = handleNbns(r.payload, emulator);
if (reply) {
const eth = buildUdpFrame(emulator, r, NBNS_PORT, r.sport, reply);
emulator.bus.send("net0-receive", eth);
}
});
log(`listening on UDP 137 — answering as "${NB_NAME}"`);
}
// ─── Packet parsing ──────────────────────────────────────────────────────────
interface UdpPacket {
srcMac: Uint8Array; dstMac: Uint8Array;
srcIp: Uint8Array; dstIp: Uint8Array;
sport: number; dport: number;
payload: Uint8Array;
}
function parseUdp(frame: Uint8Array): UdpPacket | null {
if (frame.length < 42) return null;
const ethertype = (frame[12] << 8) | frame[13];
if (ethertype !== ETHERTYPE_IPV4) return null;
const ip = 14;
const ihl = (frame[ip] & 0x0f) * 4;
if (frame[ip + 9] !== IPPROTO_UDP) return null;
const udp = ip + ihl;
const sport = (frame[udp] << 8) | frame[udp + 1];
const dport = (frame[udp + 2] << 8) | frame[udp + 3];
const len = (frame[udp + 4] << 8) | frame[udp + 5];
return {
srcMac: frame.slice(6, 12),
dstMac: frame.slice(0, 6),
srcIp: frame.slice(ip + 12, ip + 16),
dstIp: frame.slice(ip + 16, ip + 20),
sport, dport,
payload: frame.slice(udp + 8, udp + len),
};
}
// ─── NBNS protocol ───────────────────────────────────────────────────────────
// Format is DNS-like. Names are encoded by splitting each byte into two
// nibbles, adding 'A' (0x41) to each — so "HOST " becomes 32 chars.
const TYPE_NB = 0x0020; // name query → IP
const TYPE_NBSTAT = 0x0021; // node status → name list
const CLASS_IN = 0x0001;
function handleNbns(data: Uint8Array, emulator: V86): Uint8Array | null {
if (data.length < 12) return null;
const txid = (data[0] << 8) | data[1];
const flags = (data[2] << 8) | data[3];
const opcode = (flags >> 11) & 0x0f;
const qdcount = (data[4] << 8) | data[5];
if (opcode !== 0 || qdcount < 1) return null; // not a query
// Parse first question. Name is L1-encoded: length byte (always 32), then
// 32 chars, then 0x00, then type(2) + class(2).
let p = 12;
const nameLen = data[p++];
if (nameLen !== 32) return null;
const encoded = data.slice(p, p + 32);
p += 32;
if (data[p++] !== 0) return null; // scope terminator
const qtype = (data[p] << 8) | data[p + 1]; p += 2;
/* qclass */ p += 2;
const name = decodeNbName(encoded);
const adapter = emulator.network_adapter;
if (!adapter) { log("no adapter yet"); return null; }
log(`← query type=0x${qtype.toString(16)} name="${name}" txid=${txid}`);
if (qtype === TYPE_NBSTAT) {
// Node Status: "what names are registered on this node?"
// RDATA = num_names(1) + (name(15) + suffix(1) + flags(2)) * N + stats(46)
const names = [
{ name: NB_NAME, suffix: 0x00, flags: 0x0400 }, // workstation, unique, active
{ name: NB_NAME, suffix: 0x20, flags: 0x0400 }, // file server, unique, active
{ name: NB_WORKGROUP, suffix: 0x00, flags: 0x8400 }, // workgroup, group, active
];
const rdata: number[] = [names.length];
for (const n of names) {
const padded = n.name.padEnd(15, " ");
for (let i = 0; i < 15; i++) rdata.push(padded.charCodeAt(i));
rdata.push(n.suffix);
rdata.push((n.flags >> 8) & 0xff, n.flags & 0xff);
}
// 46-byte statistics block: 6-byte MAC + 40 bytes of zeros
for (const b of adapter.router_mac) rdata.push(b);
for (let i = 0; i < 40; i++) rdata.push(0);
return buildNbnsAnswer(txid, encoded, TYPE_NBSTAT, new Uint8Array(rdata));
}
if (qtype === TYPE_NB) {
// Name Query: "what IP has this name?" — answer if it's us or wildcard
const trimmed = name.trim().toUpperCase();
if (trimmed !== NB_NAME && trimmed !== "*") {
return null; // not us — drop, let it time out
}
// RDATA = flags(2) + ip(4)
const rdata = new Uint8Array([
0x00, 0x00, // unique, B-node
...adapter.router_ip,
]);
return buildNbnsAnswer(txid, encoded, TYPE_NB, rdata);
}
return null;
}
function buildNbnsAnswer(txid: number, encodedName: Uint8Array, type: number,
rdata: Uint8Array): Uint8Array {
const out: number[] = [];
const u16 = (v: number) => out.push((v >> 8) & 0xff, v & 0xff);
const u32 = (v: number) => { u16((v >>> 16) & 0xffff); u16(v & 0xffff); };
u16(txid);
u16(0x8400); // response + authoritative, opcode=0, rcode=0
u16(0); // qdcount
u16(1); // ancount
u16(0); u16(0); // ns/ar
// answer RR: name(L1-encoded) + type + class + ttl + rdlen + rdata
out.push(32); for (const b of encodedName) out.push(b); out.push(0);
u16(type);
u16(CLASS_IN);
u32(300); // TTL 5min
u16(rdata.length);
for (const b of rdata) out.push(b);
return new Uint8Array(out);
}
function decodeNbName(enc: Uint8Array): string {
// Each pair of bytes encodes one byte: ((b1-'A')<<4) | (b2-'A')
let s = "";
for (let i = 0; i < 30; i += 2) {
const hi = enc[i] - 0x41;
const lo = enc[i + 1] - 0x41;
s += String.fromCharCode((hi << 4) | lo);
}
return s; // 15 chars, space-padded; 16th byte (suffix) ignored here
}
// ─── Ethernet frame building ─────────────────────────────────────────────────
function buildUdpFrame(emulator: V86, req: UdpPacket, sport: number,
dport: number, payload: Uint8Array): Uint8Array {
const a = emulator.network_adapter!;
// For broadcast queries, reply unicast from router_ip → vm_ip; for
// unicast, just swap. Either way the dest MAC/IP come from the request.
const srcMac = a.router_mac;
const dstMac = req.srcMac;
const srcIp = a.router_ip;
const dstIp = req.srcIp;
const udpLen = 8 + payload.length;
const ipLen = 20 + udpLen;
const total = 14 + ipLen;
const f = new Uint8Array(total);
// Ethernet
f.set(dstMac, 0);
f.set(srcMac, 6);
f[12] = ETHERTYPE_IPV4 >> 8; f[13] = ETHERTYPE_IPV4 & 0xff;
// IPv4 (offset 14)
const ip = 14;
f[ip] = 0x45; // v4, IHL=5
f[ip + 1] = 0; // DSCP/ECN
f[ip + 2] = ipLen >> 8; f[ip + 3] = ipLen & 0xff;
f[ip + 4] = 0; f[ip + 5] = 0; // ID
f[ip + 6] = 0x40; f[ip + 7] = 0; // DF, no fragment
f[ip + 8] = 64; // TTL
f[ip + 9] = IPPROTO_UDP;
f[ip + 10] = 0; f[ip + 11] = 0; // checksum placeholder
f.set(srcIp, ip + 12);
f.set(dstIp, ip + 16);
const ipck = ipChecksum(f.subarray(ip, ip + 20));
f[ip + 10] = ipck >> 8; f[ip + 11] = ipck & 0xff;
// UDP (offset 34)
const udp = ip + 20;
f[udp] = sport >> 8; f[udp + 1] = sport & 0xff;
f[udp + 2] = dport >> 8; f[udp + 3] = dport & 0xff;
f[udp + 4] = udpLen >> 8; f[udp + 5] = udpLen & 0xff;
f[udp + 6] = 0; f[udp + 7] = 0; // checksum placeholder
f.set(payload, udp + 8);
const uck = udpChecksum(srcIp, dstIp, f.subarray(udp, udp + udpLen));
f[udp + 6] = uck >> 8; f[udp + 7] = uck & 0xff;
return f;
}
function ipChecksum(hdr: Uint8Array): number {
let sum = 0;
for (let i = 0; i < hdr.length; i += 2) {
sum += (hdr[i] << 8) | hdr[i + 1];
}
while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16);
return (~sum) & 0xffff;
}
function udpChecksum(srcIp: Uint8Array, dstIp: Uint8Array, udp: Uint8Array): number {
// pseudo-header: src(4) + dst(4) + zero(1) + proto(1) + udplen(2)
let sum = 0;
const add = (hi: number, lo: number) => { sum += (hi << 8) | lo; };
add(srcIp[0], srcIp[1]); add(srcIp[2], srcIp[3]);
add(dstIp[0], dstIp[1]); add(dstIp[2], dstIp[3]);
add(0, IPPROTO_UDP);
add(udp.length >> 8, udp.length & 0xff);
for (let i = 0; i < udp.length - 1; i += 2) add(udp[i], udp[i + 1]);
if (udp.length & 1) add(udp[udp.length - 1], 0);
while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16);
const ck = (~sum) & 0xffff;
return ck === 0 ? 0xffff : ck; // UDP: zero means "no checksum", so flip
}

View File

@@ -0,0 +1,65 @@
// NetBIOS Session Service (RFC 1002, port 139). All SMB1 traffic from
// Windows 95 is wrapped in these 4-byte-header frames.
const NB_SESSION_MESSAGE = 0x00;
const NB_SESSION_REQUEST = 0x81;
const NB_POSITIVE_RESPONSE = 0x82;
const NB_SESSION_KEEPALIVE = 0x85;
export type NBMessage =
| { type: typeof NB_SESSION_MESSAGE; payload: Uint8Array }
| { type: typeof NB_SESSION_REQUEST }
| { type: typeof NB_SESSION_KEEPALIVE };
/**
* Reassembles NetBIOS frames from a TCP stream. TCP delivers in
* arbitrary chunks so we buffer until we have a complete frame.
*/
export class NetBIOSFramer {
private buf = new Uint8Array(0);
push(chunk: Uint8Array): NBMessage[] {
// append
const merged = new Uint8Array(this.buf.length + chunk.length);
merged.set(this.buf);
merged.set(chunk, this.buf.length);
this.buf = merged;
const out: NBMessage[] = [];
while (this.buf.length >= 4) {
const type = this.buf[0];
// length is 17-bit: high bit of byte 1, then bytes 2-3 big-endian
const len = ((this.buf[1] & 0x01) << 16) | (this.buf[2] << 8) | this.buf[3];
const total = 4 + len;
if (this.buf.length < total) break;
const frame = this.buf.subarray(0, total);
this.buf = this.buf.slice(total);
if (type === NB_SESSION_REQUEST) {
out.push({ type: NB_SESSION_REQUEST });
} else if (type === NB_SESSION_MESSAGE) {
out.push({ type: NB_SESSION_MESSAGE, payload: frame.slice(4) });
} else if (type === NB_SESSION_KEEPALIVE) {
out.push({ type: NB_SESSION_KEEPALIVE });
}
// anything else: drop
}
return out;
}
}
export function nbPositiveResponse(): Uint8Array {
return new Uint8Array([NB_POSITIVE_RESPONSE, 0, 0, 0]);
}
export function nbWrap(payload: Uint8Array): Uint8Array {
const len = payload.length;
const out = new Uint8Array(4 + len);
out[0] = NB_SESSION_MESSAGE;
out[1] = (len >> 16) & 0x01;
out[2] = (len >> 8) & 0xff;
out[3] = len & 0xff;
out.set(payload, 4);
return out;
}

1120
src/renderer/smb/server.ts Normal file

File diff suppressed because it is too large Load Diff

154
src/renderer/smb/smb.ts Normal file
View File

@@ -0,0 +1,154 @@
// Minimal SMB1/CIFS implementation — just enough for Windows 95 to map a
// drive and read files. Spec: [MS-CIFS] / [MS-SMB].
//
// SMB1 message = 32-byte header + word block + byte block.
// Header is at a fixed offset; word/byte blocks vary by command.
import { Reader, Writer } from "./wire";
export const SMB_MAGIC = [0xff, 0x53, 0x4d, 0x42]; // \xFF SMB
// Commands we handle
export const CMD_NEGOTIATE = 0x72;
export const CMD_SESSION_SETUP_ANDX = 0x73;
export const CMD_TREE_CONNECT_ANDX = 0x75;
export const CMD_TREE_DISCONNECT = 0x71;
export const CMD_LOGOFF_ANDX = 0x74;
export const CMD_NT_CREATE_ANDX = 0xa2;
export const CMD_OPEN_ANDX = 0x2d;
export const CMD_READ_ANDX = 0x2e;
export const CMD_CLOSE = 0x04;
export const CMD_TRANSACTION = 0x25;
export const CMD_TRANSACTION2 = 0x32;
export const CMD_ECHO = 0x2b;
export const CMD_QUERY_INFORMATION = 0x08;
export const CMD_QUERY_INFORMATION2 = 0x23;
export const CMD_FIND_CLOSE2 = 0x34;
export const CMD_CHECK_DIRECTORY = 0x10;
export const CMD_SEARCH = 0x81;
// TRANS2 subcommands
export const TRANS2_FIND_FIRST2 = 0x01;
export const TRANS2_FIND_NEXT2 = 0x02;
export const TRANS2_QUERY_PATH_INFO = 0x05;
export const TRANS2_QUERY_FILE_INFO = 0x07;
// Status codes (DOS-style, not NT)
export const STATUS_OK = 0x00000000;
export const ERRDOS = 0x01;
export const ERRSRV = 0x02;
export const ERR_BADFILE = 0x0002; // file not found
export const ERR_BADPATH = 0x0003; // path not found
export const ERR_NOACCESS = 0x0005;
export const ERR_BADFID = 0x0006;
export const ERR_NOFILES = 0x0012; // no more files
export const ERR_BADFUNC = 0x0001; // unsupported
// Flags
const FLAGS_REPLY = 0x80;
const FLAGS_CASELESS = 0x08;
const FLAGS_CANONICAL = 0x10;
// Flags2 (we only echo LONG_NAMES; never claim NT_STATUS or UNICODE)
const FLAGS2_LONG_NAMES = 0x0001;
export interface SmbHeader {
cmd: number;
status: number;
flags: number;
flags2: number;
tid: number;
pid: number;
uid: number;
mid: number;
wordCount: number;
words: Uint8Array; // raw parameter words (wordCount*2 bytes)
byteCount: number;
bytes: Uint8Array; // raw data bytes
}
export function parseSmb(buf: Uint8Array): SmbHeader | null {
if (buf.length < 33) return null;
if (buf[0] !== 0xff || buf[1] !== 0x53 || buf[2] !== 0x4d || buf[3] !== 0x42) {
return null;
}
const r = new Reader(buf, 4);
const cmd = r.u8();
const status = r.u32();
const flags = r.u8();
const flags2 = r.u16();
r.skip(12); // PIDHigh(2) + SecurityFeatures(8) + Reserved(2)
const tid = r.u16();
const pid = r.u16();
const uid = r.u16();
const mid = r.u16();
const wordCount = r.u8();
const words = r.bytes(wordCount * 2);
const byteCount = r.u16();
const bytes = r.bytes(byteCount);
return { cmd, status, flags, flags2, tid, pid, uid, mid, wordCount, words, byteCount, bytes };
}
/**
* Build an SMB1 reply. The reply echoes tid/pid/uid/mid from the request and
* sets the reply flag. Status uses DOS error class/code in the low bytes
* (we don't set FLAGS2_NT_STATUS).
*/
export function buildSmb(
req: SmbHeader,
cmd: number,
status: number,
words: Uint8Array,
bytes: Uint8Array,
overrides?: { tid?: number; uid?: number; flags2?: number }
): Uint8Array {
const w = new Writer();
w.bytes(SMB_MAGIC);
w.u8(cmd);
w.u32(status);
w.u8(FLAGS_REPLY | FLAGS_CASELESS | FLAGS_CANONICAL);
// mirror long-name capability so the client keeps sending long names; never
// claim NT status or unicode (we reply in ASCII)
w.u16((overrides?.flags2 ?? req.flags2) & FLAGS2_LONG_NAMES);
w.zero(12);
w.u16(overrides?.tid ?? req.tid);
w.u16(req.pid);
w.u16(overrides?.uid ?? req.uid);
w.u16(req.mid);
if (words.length % 2 !== 0) throw new Error("word block must be even");
w.u8(words.length / 2);
w.bytes(words);
w.u16(bytes.length);
w.bytes(bytes);
return w.build();
}
export function dosError(errClass: number, errCode: number): number {
// DOS-style: byte 0 = class, byte 1 = reserved, bytes 2-3 = code (LE)
return errClass | (errCode << 16);
}
/** AndX: most replies have a 4-byte AndX header at the start of words */
export function andxNone(): number[] {
return [0xff, 0x00, 0x00, 0x00]; // AndXCommand=0xFF (none), reserved, offset=0
}
export const cmdName: Record<number, string> = {
[CMD_NEGOTIATE]: "NEGOTIATE",
[CMD_SESSION_SETUP_ANDX]: "SESSION_SETUP",
[CMD_TREE_CONNECT_ANDX]: "TREE_CONNECT",
[CMD_TREE_DISCONNECT]: "TREE_DISCONNECT",
[CMD_LOGOFF_ANDX]: "LOGOFF",
[CMD_NT_CREATE_ANDX]: "NT_CREATE",
[CMD_OPEN_ANDX]: "OPEN",
[CMD_READ_ANDX]: "READ",
[CMD_CLOSE]: "CLOSE",
[CMD_TRANSACTION]: "TRANS(RAP)",
[CMD_TRANSACTION2]: "TRANS2",
[CMD_ECHO]: "ECHO",
[CMD_QUERY_INFORMATION]: "QUERY_INFO",
[CMD_QUERY_INFORMATION2]: "QUERY_INFO2",
[CMD_FIND_CLOSE2]: "FIND_CLOSE2",
[CMD_CHECK_DIRECTORY]: "CHECK_DIR",
[CMD_SEARCH]: "SEARCH",
};

View File

@@ -0,0 +1,308 @@
// Standalone test of the SMB stack — no v86, no Electron. Feeds canned
// requests through NetBIOSFramer + SmbSession and inspects responses.
// Run: npx ts-node src/renderer/smb/test-standalone.ts
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { NetBIOSFramer, nbWrap } from "./netbios";
import { SmbSession } from "./server";
import { parseSmb, CMD_NEGOTIATE, CMD_SESSION_SETUP_ANDX,
CMD_TREE_CONNECT_ANDX, CMD_TRANSACTION2, CMD_OPEN_ANDX,
CMD_READ_ANDX, CMD_CLOSE } from "./smb";
let pass = 0, fail = 0;
const ok = (cond: boolean, msg: string) => {
if (cond) { pass++; console.log(" ✓", msg); }
else { fail++; console.log(" ✗", msg); }
};
// @ts-ignore — kept for debugging when tests fail
const hex = (b: Uint8Array, n = 32) =>
Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, "0")).join(" ");
void hex;
// ─── Build a minimal SMB request from scratch ────────────────────────────────
function smbReq(cmd: number, words: number[], bytes: number[],
tid = 0, uid = 0, mid = 1): Uint8Array {
const out: number[] = [];
out.push(0xff, 0x53, 0x4d, 0x42); // magic
out.push(cmd); // cmd
out.push(0, 0, 0, 0); // status
out.push(0x18); // flags (caseless+canonical)
out.push(0x01, 0x00); // flags2: long names, no unicode
for (let i = 0; i < 12; i++) out.push(0); // reserved
out.push(tid & 0xff, tid >> 8);
out.push(0, 0); // pid
out.push(uid & 0xff, uid >> 8);
out.push(mid & 0xff, mid >> 8);
if (words.length % 2) throw new Error("words must be even");
out.push(words.length / 2);
out.push(...words);
out.push(bytes.length & 0xff, bytes.length >> 8);
out.push(...bytes);
return new Uint8Array(out);
}
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
const u32 = (v: number) => [...u16(v & 0xffff), ...u16((v >>> 16) & 0xffff)];
const cstr = (s: string) => [...Buffer.from(s, "ascii"), 0];
// ─── Setup test fixture ──────────────────────────────────────────────────────
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "smbtest-"));
fs.writeFileSync(path.join(tmpRoot, "hello.txt"), "Hello from the host!\n");
fs.mkdirSync(path.join(tmpRoot, "subdir"));
fs.writeFileSync(path.join(tmpRoot, "subdir", "nested.dat"), Buffer.alloc(100, 0xAB));
console.log("fixture:", tmpRoot);
const session = new SmbSession(tmpRoot);
session.capture = false;
// ─── Test 1: NetBIOS framing ─────────────────────────────────────────────────
console.log("\n[1] NetBIOS framer");
{
const framer = new NetBIOSFramer();
// Session request: type 0x81, len 68 (called name 34 + calling name 34)
const sessReq = new Uint8Array([0x81, 0, 0, 68, ...new Array(68).fill(0x20)]);
const msgs1 = framer.push(sessReq);
ok(msgs1.length === 1 && msgs1[0].type === 0x81, "parses session request");
// Fragmented session message
const payload = new Uint8Array([0xff, 0x53, 0x4d, 0x42, 0x72, 0, 0, 0, 0, 0]);
const wrapped = nbWrap(payload);
const msgs2 = framer.push(wrapped.slice(0, 5));
ok(msgs2.length === 0, "incomplete frame buffers");
const msgs3 = framer.push(wrapped.slice(5));
ok(msgs3.length === 1 && msgs3[0].type === 0x00, "completes on second chunk");
ok(msgs3[0].type === 0x00 && msgs3[0].payload[0] === 0xff && msgs3[0].payload[1] === 0x53,
"payload extracted");
}
// ─── Test 2: NEGOTIATE ───────────────────────────────────────────────────────
console.log("\n[2] NEGOTIATE");
{
// Real Win95 dialect list (abbreviated). Each entry is 0x02 + cstr.
const dialects = ["PC NETWORK PROGRAM 1.0", "LANMAN1.0", "LM1.2X002",
"LANMAN2.1", "NT LM 0.12"];
const bytes: number[] = [];
for (const d of dialects) { bytes.push(0x02); bytes.push(...cstr(d)); }
const req = smbReq(CMD_NEGOTIATE, [], bytes);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.cmd === CMD_NEGOTIATE, "cmd echoed");
ok((parsed.flags & 0x80) !== 0, "reply flag set");
ok(parsed.status === 0, "status OK");
ok(parsed.wordCount === 13, "13-word LM response");
// word[0] = dialect index — we pick LANMAN2.1 (idx 3) since our 13-word
// response is the LANMAN format; picking NT LM 0.12 would require the
// 17-word NT response which we don't implement
const pickedIdx = parsed.words[0] | (parsed.words[1] << 8);
ok(pickedIdx === 3, `picked LANMAN2.1 (idx ${pickedIdx})`);
}
// ─── Test 3: SESSION_SETUP ───────────────────────────────────────────────────
console.log("\n[3] SESSION_SETUP_ANDX");
{
// Minimal setup: AndX(4) MaxBuf(2) MaxMpx(2) VcNum(2) SessKey(4)
// PwLen(2) Reserved(4) — bytes: password + account + domain + os + lanman
const words = [0xff, 0, 0, 0, ...u16(4096), ...u16(1), ...u16(0),
...u32(0), ...u16(0), ...u32(0)];
const bytes = [...cstr(""), ...cstr("GUEST"), ...cstr("WORKGROUP"),
...cstr("Windows 4.0"), ...cstr("Windows 4.0")];
const req = smbReq(CMD_SESSION_SETUP_ANDX, words, bytes);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
ok(parsed.uid === 1, `assigned uid=${parsed.uid}`);
// Action word at offset 4 (after AndX) = guest bit
const action = parsed.words[4] | (parsed.words[5] << 8);
ok((action & 1) === 1, "guest bit set");
}
// ─── Test 4: TREE_CONNECT ────────────────────────────────────────────────────
console.log("\n[4] TREE_CONNECT_ANDX");
{
const words = [0xff, 0, 0, 0, ...u16(0), ...u16(1)]; // pwLen=1
const bytes = [0, ...cstr("\\\\192.168.86.1\\HOST"), ...cstr("?????")];
const req = smbReq(CMD_TREE_CONNECT_ANDX, words, bytes, 0, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
ok(parsed.tid === 1, `assigned tid=${parsed.tid}`);
// bytes should start with "A:\0"
const svc = String.fromCharCode(parsed.bytes[0], parsed.bytes[1]);
ok(svc === "A:", `service="${svc}"`);
}
// ─── Test 5: TRANS2 FIND_FIRST2 (directory listing) ──────────────────────────
console.log("\n[5] TRANS2 FIND_FIRST2");
{
// TRANS2 setup is gnarly. Build from spec:
// params: SearchAttrs(2) SearchCount(2) Flags(2) InfoLevel(2) Storage(4) "\*"\0
const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(1),
...u32(0), ...cstr("\\*")];
// setup word = TRANS2_FIND_FIRST2 (1)
// word block: TotPrm(2) TotData(2) MaxPrm(2) MaxData(2) MaxSetup(1) Rsvd(1)
// Flags(2) Timeout(4) Rsvd(2) PrmCnt(2) PrmOff(2) DataCnt(2) DataOff(2)
// SetupCnt(1) Rsvd(1) Setup[0](2)
const wc = 14 + 1; // 14 fixed + 1 setup
const bytesStart = 32 + 1 + wc * 2 + 2;
const paramOff = bytesStart + 3; // 3 bytes pad ("\0\0\0") before params
const words = [
...u16(t2params.length), ...u16(0), ...u16(100), ...u16(8000),
1, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(t2params.length), ...u16(paramOff),
...u16(0), ...u16(0),
1, 0, ...u16(1) // SetupCount=1, Setup[0]=FIND_FIRST2
];
const bytes = [0, 0, 0, ...t2params]; // 3-byte name padding + params
const req = smbReq(CMD_TRANSACTION2, words, bytes, 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
// Reply params: SID(2) Count(2) EOS(2) EaErr(2) LastName(2)
// Reply words tell us where params live
const rw = parsed.words;
const replyParamOffset = rw[8] | (rw[9] << 8);
const replyParamCount = rw[6] | (rw[7] << 8);
const replyBytesStart = 32 + 1 + parsed.wordCount * 2 + 2;
const pStart = replyParamOffset - replyBytesStart;
const replyParams = parsed.bytes.slice(pStart, pStart + replyParamCount);
const searchCount = replyParams[2] | (replyParams[3] << 8);
// Should find: . .. _MAPZ.BAT(virtual) hello.txt subdir = 5
ok(searchCount === 5, `found ${searchCount} entries (expect 5)`);
// Data block has the entries — just verify they're in there somewhere
const dataStr = String.fromCharCode(...parsed.bytes);
ok(dataStr.includes("_MAPZ.BAT"), "virtual _MAPZ.BAT in listing");
ok(dataStr.includes("hello.txt"), "hello.txt in listing");
ok(dataStr.includes("subdir"), "subdir in listing");
}
// ─── Test 6: OPEN + READ + CLOSE ─────────────────────────────────────────────
console.log("\n[6] OPEN_ANDX + READ_ANDX + CLOSE");
let openedFid = 0;
{
// OPEN_ANDX words: AndX(4) Flags(2) Access(2) SrchAttr(2) FileAttr(2)
// CreateTime(4) OpenFunc(2) AllocSize(4) Timeout(4) Rsvd(4)
const words = [0xff, 0, 0, 0, ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u16(1), ...u32(0), ...u32(0), ...u32(0)];
const bytes = [...cstr("\\hello.txt")];
const req = smbReq(CMD_OPEN_ANDX, words, bytes, 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "open status OK");
openedFid = parsed.words[4] | (parsed.words[5] << 8); // FID after AndX
ok(openedFid > 0, `fid=${openedFid}`);
// OPEN_ANDX response: AndX(4) FID(2) Attrs(2) LastWrite(4) DataSize(4) ...
const fileSize = parsed.words[12] | (parsed.words[13] << 8) |
(parsed.words[14] << 16) | (parsed.words[15] << 24);
ok(fileSize === 21, `size=${fileSize} (expect 21)`);
}
{
// READ_ANDX: AndX(4) FID(2) Offset(4) MaxCount(2) MinCount(2)
// Timeout(4) Remaining(2) [OffsetHigh(4)]
const words = [0xff, 0, 0, 0, ...u16(openedFid), ...u32(0), ...u16(100),
...u16(0), ...u32(0), ...u16(0)];
const req = smbReq(CMD_READ_ANDX, words, [], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "read status OK");
const dataLen = parsed.words[10] | (parsed.words[11] << 8);
ok(dataLen === 21, `read ${dataLen} bytes`);
// bytes = pad(1) + data
const text = String.fromCharCode(...parsed.bytes.slice(1, 1 + dataLen));
ok(text === "Hello from the host!\n", `content: ${JSON.stringify(text)}`);
}
{
const words = [...u16(openedFid), ...u32(0)];
const req = smbReq(CMD_CLOSE, words, [], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "close status OK");
}
// ─── Test 7: error paths ─────────────────────────────────────────────────────
console.log("\n[7] Error handling");
{
const words = [0xff, 0, 0, 0, ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u16(1), ...u32(0), ...u32(0), ...u32(0)];
const req = smbReq(CMD_OPEN_ANDX, words, [...cstr("\\nope.txt")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, `nonexistent file → status=0x${parsed.status.toString(16)}`);
// DOS error: class=1 (ERRDOS), code=2 (badfile)
ok((parsed.status & 0xff) === 1 && (parsed.status >> 16) === 2, "ERRDOS/ERR_badfile");
}
{
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\..\\..\\etc\\passwd")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, "lexical traversal (../) blocked");
}
{
// Virtual file: open and read _MAPZ.BAT
const oReq = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\_MAPZ.BAT")], 1, 1);
const oReply = session.handle(oReq)!;
const oParsed = parseSmb(oReply)!;
ok(oParsed.status === 0, "open virtual _MAPZ.BAT");
const vfid = oParsed.words[4] | (oParsed.words[5] << 8);
const rReq = smbReq(CMD_READ_ANDX,
[0xff,0,0,0,...u16(vfid),...u32(0),...u16(500),...u16(0),...u32(0),...u16(0)], [], 1, 1);
const rReply = session.handle(rReq)!;
const rParsed = parseSmb(rReply)!;
const len = rParsed.words[10] | (rParsed.words[11] << 8);
const text = String.fromCharCode(...rParsed.bytes.slice(1, 1 + len));
ok(text.includes("NET USE Z:"), `virtual read: ${JSON.stringify(text.slice(0, 40))}`);
}
{
// symlink escape: link inside share → file outside share
const outside = path.join(os.tmpdir(), "smbtest-secret.txt");
fs.writeFileSync(outside, "leaked");
fs.symlinkSync(outside, path.join(tmpRoot, "evil"));
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\evil")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, "symlink escape blocked");
fs.unlinkSync(outside);
}
{
// symlink directory escape: link inside share → dir outside, then walk into it
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "smbtest-out-"));
fs.writeFileSync(path.join(outsideDir, "secret.txt"), "leaked");
fs.symlinkSync(outsideDir, path.join(tmpRoot, "evildir"));
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\evildir\\secret.txt")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, "symlink dir escape blocked");
fs.rmSync(outsideDir, { recursive: true });
}
{
// symlink that stays INSIDE the share should still work
fs.symlinkSync(path.join(tmpRoot, "hello.txt"), path.join(tmpRoot, "alias"));
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\alias")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "internal symlink allowed");
}
// ─── Cleanup ─────────────────────────────────────────────────────────────────
session.destroy();
fs.rmSync(tmpRoot, { recursive: true });
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);

50
src/renderer/smb/wire.ts Normal file
View File

@@ -0,0 +1,50 @@
// SMB1 wire format helpers. Everything is little-endian except the
// 0xFF 'SMB' magic.
export class Reader {
pos = 0;
constructor(private buf: Uint8Array, start = 0) {
this.pos = start;
}
u8() { return this.buf[this.pos++]; }
u16() { const v = this.buf[this.pos] | (this.buf[this.pos+1] << 8); this.pos += 2; return v; }
u32() { const v = this.u16() | (this.u16() << 16); return v >>> 0; }
skip(n: number) { this.pos += n; }
bytes(n: number) { const v = this.buf.slice(this.pos, this.pos + n); this.pos += n; return v; }
rest() { return this.buf.slice(this.pos); }
/** OEM string, null-terminated */
cstr(): string {
let end = this.pos;
while (end < this.buf.length && this.buf[end] !== 0) end++;
const s = String.fromCharCode(...this.buf.slice(this.pos, end));
this.pos = end + 1;
return s;
}
/** UCS-2LE string, null-terminated */
ucs2(): string {
let end = this.pos;
while (end + 1 < this.buf.length && (this.buf[end] | this.buf[end+1]) !== 0) end += 2;
const s = Buffer.from(this.buf.slice(this.pos, end)).toString('ucs2');
this.pos = end + 2;
return s;
}
}
export class Writer {
private chunks: number[] = [];
u8(v: number) { this.chunks.push(v & 0xff); return this; }
u16(v: number) { this.chunks.push(v & 0xff, (v >> 8) & 0xff); return this; }
u32(v: number) { return this.u16(v & 0xffff).u16((v >>> 16) & 0xffff); }
u64(lo: number, hi = 0) { return this.u32(lo).u32(hi); }
bytes(b: Uint8Array | number[]) { for (const x of b) this.chunks.push(x & 0xff); return this; }
zero(n: number) { for (let i = 0; i < n; i++) this.chunks.push(0); return this; }
cstr(s: string) { for (let i = 0; i < s.length; i++) this.chunks.push(s.charCodeAt(i) & 0xff); this.chunks.push(0); return this; }
ucs2(s: string) {
const b = Buffer.from(s, 'ucs2');
for (const x of b) this.chunks.push(x);
this.chunks.push(0, 0);
return this;
}
get length() { return this.chunks.length; }
build() { return new Uint8Array(this.chunks); }
}

80
tools/bisect-v86.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/bin/bash
# Bisect harness: checkout v86 to a commit, rebuild wasm, probe boot.
# Logs to /tmp/win95-bisect.log
#
# Usage:
# tools/bisect-v86.sh <commit-ish> # test one commit
# tools/bisect-v86.sh <commit-ish> '{"acpi":false}' # with options
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
V86="${V86_DIR:-$ROOT/../v86}"
LOG=/tmp/win95-bisect.log
COMMIT="$1"
OPTS="${2:-{}}"
[ -z "$COMMIT" ] && { echo "usage: $0 <commit> [opts-json]"; exit 1; }
cd "$V86"
SAVED_HEAD=$(git rev-parse HEAD)
trap "cd '$V86' && git checkout -q '$SAVED_HEAD' 2>/dev/null" EXIT
echo "─── checkout $COMMIT ───"
git checkout -q "$COMMIT" 2>&1 | head -3
HASH=$(git rev-parse --short HEAD)
SUBJ=$(git log -1 --format='%s' | head -c 60)
DATE=$(git log -1 --format='%ci' | cut -d' ' -f1)
export PATH="/opt/homebrew/opt/openjdk/bin:$PATH"
echo "─── build wasm + libv86.js @ $HASH ($DATE) ───"
rm -f build/v86.wasm build/libv86.js
make build/v86.wasm 2>&1 | tail -3
[ -f build/v86.wasm ] || { echo "WASM BUILD FAILED"; exit 1; }
make build/libv86.js 2>&1 | tail -3
[ -f build/libv86.js ] || { echo "LIBV86 BUILD FAILED"; exit 1; }
WASM_SIZE=$(stat -f%z build/v86.wasm)
JS_SIZE=$(stat -f%z build/libv86.js)
cp build/v86.wasm "$ROOT/src/renderer/lib/build/v86.wasm"
cp build/libv86.js "$ROOT/src/renderer/lib/libv86.js"
# Re-apply phantom-slave patch (it's a v86 bug from May 2025 onwards;
# harmless before that since the pattern won't match)
node -e '
const fs=require("fs");
let s=fs.readFileSync(process.argv[1],"utf8");
const re=/(\w+)\[0\]\[1\]=\{buffer:(\w+)\.hdb\}/g;
const n=[...s.matchAll(re)].length;
if(n===1){s=s.replace(re,"$2.hdb&&($1[0][1]={buffer:$2.hdb})");fs.writeFileSync(process.argv[1],s);console.log("phantom-slave: patched")}
else console.log("phantom-slave: skip ("+n+" matches)");
' "$ROOT/src/renderer/lib/libv86.js"
# Win95 has sporadic bluescreens on all v86 versions — a single FAIL doesn't
# mean the commit is bad. Probe up to 3 times; one SUCCESS = good commit.
echo "─── probe (up to 3 attempts) ───"
cd "$ROOT"
VERDICT="UNKNOWN"
for ATTEMPT in 1 2 3; do
echo " attempt $ATTEMPT/3"
set +e
tools/probe-boot.sh "$OPTS" 2>&1 | tee /tmp/win95-probe-out.log | tail -10
set -e
V=$(cat /tmp/win95-probe.done 2>/dev/null || echo "UNKNOWN")
if [ "$V" = "SUCCESS" ]; then
VERDICT="SUCCESS"
break
fi
VERDICT="$V" # keep the last failure mode
[ "$ATTEMPT" -lt 3 ] && sleep 3
done
GFX=$(python3 -c "import json;s=json.load(open('/tmp/win95-probe.json'));print(f\"{s.get('gfxW',0)}x{s.get('gfxH',0)} {s.get('dominantColor','')}\")" 2>/dev/null || echo "?")
LINE="$HASH $DATE | wasm=${WASM_SIZE} opts=$OPTS | $VERDICT $GFX | $SUBJ"
echo "$LINE" >> "$LOG"
echo ""
echo "═══ $LINE ═══"
exit $RESULT

View File

@@ -4,27 +4,44 @@ const Bundler = require('parcel-bundler')
const path = require('path')
const fs = require('fs')
// libv86 checks `typeof module.exports` before `typeof window` when deciding
// where to export V86. In an Electron renderer with nodeIntegration both exist,
// so it ends up on module.exports instead of window. This shim copies it over.
const LIBV86_SHIM = `<script src="libv86.js"></script>
<script>if (typeof module !== "undefined" && module.exports && module.exports.V86) window.V86 = module.exports.V86;</script>`
// v86's node-path file loader used `await import("node:...")` until d4c5fa86
// switched it to require(). Dynamic import of node: URLs doesn't work in an
// Electron renderer — only require() does. The literals are stable across
// Closure builds; if they're absent the build is post-d4c5fa86 and already
// uses require, so a no-op is correct.
const V86_NODE_IMPORTS = [
['await import("node:fs/promises")', 'require("fs").promises'],
['await import("node:"+"fs/promises")', 'require("fs").promises'],
['await import("node:crypto")', 'require("crypto")'],
];
async function copyLib() {
const target = path.join(__dirname, '../dist/static')
const lib = path.join(__dirname, '../src/renderer/lib')
const index = path.join(target, 'index.html')
// Copy in lib
await fs.promises.cp(lib, target, { recursive: true });
// Patch so that fs.read is used
const libv86path = path.join(target, 'libv86.js')
const libv86 = fs.readFileSync(libv86path, 'utf-8')
let libv86 = fs.readFileSync(libv86path, 'utf-8')
let patchCount = 0;
for (const [from, to] of V86_NODE_IMPORTS) {
const next = libv86.split(from).join(to);
if (next !== libv86) { patchCount++; libv86 = next; }
}
if (patchCount > 0) {
fs.writeFileSync(libv86path, libv86)
console.log(`libv86: ${patchCount} dynamic-import → require`)
}
let patchedLibv86 = libv86.replace('k.load_file="undefined"===typeof XMLHttpRequest?pa:qa', 'k.load_file=pa')
patchedLibv86 = patchedLibv86.replace('H.exportSymbol=function(a,b){"undefined"!==typeof module&&"undefined"!==typeof module.exports?module.exports[a]=b:"undefined"!==typeof window?window[a]=b:"function"===typeof importScripts&&(self[a]=b)}', 'H.exportSymbol=function(a,b){"undefined"!==typeof window?window[a]=b:"undefined"!==typeof module&&"undefined"!==typeof module.exports?module.exports[a]=b:"function"===typeof importScripts&&(self[a]=b)}')
patchedLibv86 = patchedLibv86.replace('this.fetch=fetch;', 'this.fetch=(...args)=>fetch(...args);')
fs.writeFileSync(libv86path, patchedLibv86)
// Overwrite
const indexContents = fs.readFileSync(index, 'utf-8');
const replacedContents = indexContents.replace('<!-- libv86 -->', '<script src="libv86.js"></script>')
const replacedContents = indexContents.replace('<!-- libv86 -->', LIBV86_SHIM)
fs.writeFileSync(index, replacedContents)
}

76
tools/probe-boot.sh Executable file
View File

@@ -0,0 +1,76 @@
#!/bin/bash
# Single boot probe: build → launch → wait for verdict → kill → report.
# Usage: tools/probe-boot.sh [json-options]
# tools/probe-boot.sh '{"acpi":false}'
# tools/probe-boot.sh '{"disable_jit":true}'
set -e
cd "$(dirname "$0")/.."
OPTS="${1:-{}}"
STATUS=/tmp/win95-probe.json
DONE=/tmp/win95-probe.done
SCREEN=/tmp/win95-screen.png
TIMEOUT=200
echo "═══ probe: opts=$OPTS ═══"
# clean slate
rm -f "$STATUS" "$DONE" "$SCREEN"
pkill -f "windows95/node_modules/electron" 2>/dev/null || true
sleep 1
# build (parcel only — forge's generateAssets does this too but we want
# direct control without the forge startup overhead)
rm -rf dist .cache
node tools/parcel-build.js > /tmp/win95-build.log 2>&1
if [ $? -ne 0 ]; then
echo "BUILD FAILED"
tail -20 /tmp/win95-build.log
exit 1
fi
# launch electron directly (skip forge to avoid double-build)
WIN95_PROBE=1 WIN95_PROBE_OPTS="$OPTS" \
./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 &
PID=$!
echo "electron pid=$PID, waiting for verdict (timeout ${TIMEOUT}s)..."
# poll
for i in $(seq 1 $TIMEOUT); do
if [ -f "$DONE" ]; then
VERDICT=$(cat "$DONE")
echo "verdict at ${i}s: $VERDICT"
break
fi
if ! kill -0 $PID 2>/dev/null; then
echo "electron died at ${i}s"
tail -30 /tmp/win95-electron.log
VERDICT="CRASHED"
break
fi
sleep 1
done
if [ -z "$VERDICT" ]; then
echo "TIMEOUT at ${TIMEOUT}s"
VERDICT="TIMEOUT"
fi
# capture final state
echo "─── final status ───"
[ -f "$STATUS" ] && python3 -c "
import json
s=json.load(open('$STATUS'))
print(f\"phase={s['phase']} cpu={s['cpuRunning']} instr_delta={s['instructionDelta']:,}\")
print(f\"uptime={s['uptimeSec']}s\")
t=s['textScreen'].strip()
if t: print('text:'); print(' ' + t.replace(chr(10), chr(10)+' ')[:500])
" || echo "(no status file)"
# kill
kill $PID 2>/dev/null || true
wait $PID 2>/dev/null || true
echo "═══ $VERDICT ═══"
[ "$VERDICT" = "SUCCESS" ] && exit 0 || exit 1

View File

@@ -8,12 +8,11 @@ const exePath = process.argv[process.argv.length - 1]
console.log(exePath)
async function main() {
await resedit.resedit(exePath, {
"productVersion": package.version,
"fileVersion": package.version,
"productName": package.productName,
"icon": path.join(__dirname, "../assets/icon.ico"),
"iconPath": path.join(__dirname, "../assets/icon.ico"),
"win32Metadata": {
"FileDescription": package.productName,
"InternalName": package.name,

151
tools/update-v86.js Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* Updates v86 by building the wasm from a local checkout. The libv86.js +
* v86.wasm pair MUST be ABI-matched — copy.sh historically rebuilds the JS
* without rebuilding the wasm, and a mismatch silently breaks fresh boot
* (state restore still works because the CPU snapshot is opaque, so you
* won't notice until Win95 BSODs at the splash screen with "Invalid VxD
* dynamic link call").
*
* Usage:
* node tools/update-v86.js [path/to/v86] # builds wasm from source
* node tools/update-v86.js --js-only # just download libv86.js
*
* The wasm build needs `rustup target add wasm32-unknown-unknown` and clang.
* libv86.js needs Java + Closure; if you don't have those, --js-only fetches
* from copy.sh and warns if its Last-Modified is far from your wasm build.
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { execSync } = require('child_process');
const LIB_DIR = path.join(__dirname, '../src/renderer/lib');
const V86_DIR = process.argv.find(a => a !== process.argv[0] && a !== process.argv[1] && !a.startsWith('--'))
|| path.resolve(__dirname, '../../v86');
const JS_ONLY = process.argv.includes('--js-only');
const SKEW_DAYS = 14;
function head(url) {
return new Promise((resolve, reject) => {
https.request(url, { method: 'HEAD' }, (res) => {
resolve({ status: res.statusCode, lastModified: res.headers['last-modified'] });
}).on('error', reject).end();
});
}
function download(url, dest) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode !== 200) return reject(new Error(`${url} → HTTP ${res.statusCode}`));
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
fs.writeFileSync(dest, buf);
console.log(` ${path.basename(dest)}: ${(buf.length / 1024).toFixed(0)} KB`);
resolve(res.headers['last-modified']);
});
}).on('error', reject);
});
}
async function main() {
const jsDest = path.join(LIB_DIR, 'libv86.js');
const wasmDest = path.join(LIB_DIR, 'build/v86.wasm');
// ─── wasm ────────────────────────────────────────────────────────────────
let wasmDate;
if (JS_ONLY) {
if (!fs.existsSync(wasmDest)) {
throw new Error(`--js-only requires an existing wasm at ${wasmDest}`);
}
wasmDate = fs.statSync(wasmDest).mtime;
console.log(`Keeping existing wasm (${wasmDate.toISOString().slice(0, 10)})`);
} else {
if (!fs.existsSync(path.join(V86_DIR, 'Makefile'))) {
throw new Error(`No v86 checkout at ${V86_DIR}. Clone copy/v86 there or pass a path.`);
}
const head = execSync('git log -1 --format="%h %ci"', { cwd: V86_DIR }).toString().trim();
console.log(`Building wasm from ${V86_DIR} @ ${head}`);
execSync('make build/v86.wasm', { cwd: V86_DIR, stdio: 'inherit' });
fs.copyFileSync(path.join(V86_DIR, 'build/v86.wasm'), wasmDest);
wasmDate = new Date();
console.log(` v86.wasm: ${(fs.statSync(wasmDest).size / 1024).toFixed(0)} KB`);
}
// ─── libv86.js ───────────────────────────────────────────────────────────
// Build from source if Closure is available; otherwise fetch and check skew.
const hasClosure = !JS_ONLY && fs.existsSync(path.join(V86_DIR, 'closure-compiler/compiler.jar'));
if (hasClosure) {
console.log('Building libv86.js (Closure)…');
execSync('make build/libv86.js', { cwd: V86_DIR, stdio: 'inherit' });
fs.copyFileSync(path.join(V86_DIR, 'build/libv86.js'), jsDest);
console.log(` libv86.js: ${(fs.statSync(jsDest).size / 1024).toFixed(0)} KB`);
} else {
console.log('No Closure jar — fetching libv86.js from copy.sh');
const lm = await download('https://copy.sh/v86/build/libv86.js', jsDest);
const jsDate = new Date(lm);
const skew = Math.abs(jsDate - wasmDate) / 86400000;
console.log(` JS: ${jsDate.toISOString().slice(0, 10)}`);
console.log(` wasm: ${wasmDate.toISOString().slice(0, 10)}`);
if (skew > SKEW_DAYS) {
throw new Error(
`JS and wasm are ${skew.toFixed(0)} days apart. ` +
`Either install Closure (java + v86/closure-compiler/compiler.jar) ` +
`to build libv86.js from the same commit, or git-checkout v86 to a ` +
`commit near ${jsDate.toISOString().slice(0, 10)} and rebuild the wasm.`
);
}
}
// ─── BIOS ────────────────────────────────────────────────────────────────
// SeaBIOS sets up the interrupt controller for whatever the emulated
// hardware presents. New v86 + old BIOS = APIC never armed = IDE IRQs
// never fire = boot hangs at the splash screen with no disk activity.
if (!JS_ONLY) {
const biosDir = path.join(__dirname, '../bios');
for (const f of ['seabios.bin', 'vgabios.bin']) {
fs.copyFileSync(path.join(V86_DIR, 'bios', f), path.join(biosDir, f));
console.log(` ${f}: ${(fs.statSync(path.join(biosDir, f)).size / 1024).toFixed(0)} KB`);
}
}
// ─── patch: phantom slave drive ──────────────────────────────────────────
// v86 bug since 1b90d2e7 (May 2025 IDE refactor): cpu.js does
// ide_config[0][1] = { buffer: settings.hdb }
// unconditionally inside the `if(settings.hda)` block. When hdb is
// undefined this creates a phantom 0-size HD on primary slave; Win95's
// ESDI_506.PDR detects it, sends IDENTIFY, and spins forever waiting for
// DRQ from a drive that has no sectors. State restore skips driver init,
// so it only bites on fresh boot.
//
// The pattern is structurally stable: `buffer` and `hdb` are option keys
// (externed, not mangled), `[0][1]=` is literal.
let js = fs.readFileSync(jsDest, 'utf-8');
const phantom = /(\w+)\[0\]\[1\]=\{buffer:(\w+)\.hdb\}/g;
const matches = [...js.matchAll(phantom)];
if (matches.length !== 1) {
throw new Error(
`phantom-slave patch: expected exactly 1 match, found ${matches.length}. ` +
`Either v86 fixed this upstream (good — remove this patch) or the ` +
`pattern changed. Check src/cpu.js around ide_config[0][1].`
);
}
js = js.replace(phantom, '$2.hdb&&($1[0][1]={buffer:$2.hdb})');
fs.writeFileSync(jsDest, js);
console.log(' patched: phantom slave drive guard (1 site)');
// ─── sanity ──────────────────────────────────────────────────────────────
if (!js.includes('process.versions.node'))
throw new Error('libv86 lost the process.versions.node check (file loader regression)');
if (!/this\.fetch=\([^)]*\)=>fetch\(/.test(js))
throw new Error('libv86 lost the fetch arrow wrapper');
if (!js.includes('window.V86=') && !js.includes('module.exports.V86='))
throw new Error('libv86 export pattern changed — check the runtime shim');
console.log('✓ installed (sanity checks pass)');
}
main().catch((e) => { console.error('✗', e.message); process.exit(1); });

View File

@@ -20,13 +20,13 @@
"noEmitHelpers": false,
"module": "commonjs",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"pretty": true,
"target": "es2023",
"jsx": "react",
"typeRoots": [
"./node_modules/@types"
],
"baseUrl": "."
]
},
"include": [
"src/**/*"