mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-09 00:24:09 +00:00
Compare commits
3 Commits
3151ffff3c
...
d0090e5569
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0090e5569 | ||
|
|
b14ea86ced | ||
|
|
41394e4d8e |
@@ -36,6 +36,10 @@ export const IPC_COMMANDS = {
|
||||
MACHINE_ESC: "MACHINE_ESC",
|
||||
MACHINE_ALT_ENTER: "MACHINE_ALT_ENTER",
|
||||
MACHINE_CTRL_ALT_DEL: "MACHINE_CTRL_ALT_DEL",
|
||||
// Runtime media swap (main → renderer, payload = host path or undefined=eject)
|
||||
MACHINE_SET_FLOPPY: "MACHINE_SET_FLOPPY",
|
||||
MACHINE_SET_CDROM: "MACHINE_SET_CDROM",
|
||||
MACHINE_SET_SMB_SHARE: "MACHINE_SET_SMB_SHARE",
|
||||
// Machine events
|
||||
MACHINE_STARTED: "MACHINE_STARTED",
|
||||
MACHINE_STOPPED: "MACHINE_STOPPED",
|
||||
|
||||
@@ -23,17 +23,34 @@ export async function setupMenu() {
|
||||
);
|
||||
}
|
||||
|
||||
function send(cmd: string) {
|
||||
function send(cmd: string, ...args: unknown[]) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
|
||||
if (windows[0]) {
|
||||
log(`Sending "${cmd}"`);
|
||||
windows[0].webContents.send(cmd);
|
||||
windows[0].webContents.send(cmd, ...args);
|
||||
} else {
|
||||
log(`Tried to send "${cmd}", but could not find window`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile(filters: Electron.FileFilter[]) {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openFile"],
|
||||
filters,
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
}
|
||||
|
||||
async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
const template: Array<Electron.MenuItemConstructorOptions> = [
|
||||
{
|
||||
@@ -188,6 +205,61 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
// Hot-swap removable media. v86 always instantiates the FDC and the
|
||||
// ATAPI secondary-master, so insert/eject work even if nothing was
|
||||
// mounted at boot. The pre-boot Settings card covers the !isRunning
|
||||
// case, so these are runtime-only.
|
||||
{
|
||||
label: "Floppy Drive",
|
||||
enabled: isRunning,
|
||||
submenu: [
|
||||
{
|
||||
label: "Insert Disk Image…",
|
||||
click: async () => {
|
||||
const p = await pickFile([
|
||||
{ name: "Floppy image", extensions: ["img", "ima", "vfd"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
]);
|
||||
if (p) send(IPC_COMMANDS.MACHINE_SET_FLOPPY, p);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Eject",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_SET_FLOPPY, null),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "CD-ROM Drive",
|
||||
enabled: isRunning,
|
||||
submenu: [
|
||||
{
|
||||
label: "Insert Disc Image…",
|
||||
click: async () => {
|
||||
const p = await pickFile([
|
||||
{ name: "ISO image", extensions: ["iso"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
]);
|
||||
if (p) send(IPC_COMMANDS.MACHINE_SET_CDROM, p);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Eject",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_SET_CDROM, null),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Change Shared Folder…",
|
||||
enabled: isRunning,
|
||||
click: async () => {
|
||||
const p = await pickFolder();
|
||||
if (p) send(IPC_COMMANDS.MACHINE_SET_SMB_SHARE, p);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Go to Disk Image",
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface EmulatorState {
|
||||
export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
private isQuitting = false;
|
||||
private isResetting = false;
|
||||
private smbShare?: ReturnType<typeof setupSmbShare>;
|
||||
// Mirrors state.hasAbsoluteMouse but updated synchronously — setState is
|
||||
// batched, and the lock/unlock decisions can't wait for a render.
|
||||
private absoluteMouse = false;
|
||||
@@ -229,6 +230,48 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
this.showDiskImage();
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_FLOPPY, (_e, p: string | null) => {
|
||||
const emu = this.state.emulator;
|
||||
if (!emu) return;
|
||||
if (p) {
|
||||
// Floppies are ≤2.88MB — load whole image into memory and hand v86 a
|
||||
// plain ArrayBuffer so its SyncBuffer path is used.
|
||||
const buf = fs.readFileSync(p);
|
||||
const ab = buf.buffer.slice(
|
||||
buf.byteOffset,
|
||||
buf.byteOffset + buf.byteLength,
|
||||
);
|
||||
emu.set_fda({ buffer: ab });
|
||||
console.log(`💾 floppy ← ${p}`);
|
||||
} else {
|
||||
emu.eject_fda();
|
||||
console.log(`💾 floppy ejected`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_CDROM, (_e, p: string | null) => {
|
||||
const emu = this.state.emulator;
|
||||
// The public emu.set_cdrom() routes through v86's async loaders, which
|
||||
// re-introduce the ATAPI BSY race documented in sync-file-buffer.ts.
|
||||
// Go straight to the device with our fs-backed synchronous buffer —
|
||||
// same object the boot path hands to the `cdrom:` option.
|
||||
const dev = emu?.v86?.cpu?.devices?.cdrom;
|
||||
if (!dev) return;
|
||||
if (p) {
|
||||
dev.set_cdrom(new SyncFileBuffer(p));
|
||||
console.log(`💿 cdrom ← ${p}`);
|
||||
} else {
|
||||
dev.eject();
|
||||
console.log(`💿 cdrom ejected`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_SMB_SHARE, (_e, p: string) => {
|
||||
this.smbShare?.setHostPath(p);
|
||||
this.setState({ smbSharePath: p });
|
||||
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, p);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_IN, () => {
|
||||
this.setScale(this.state.scale * 1.2);
|
||||
});
|
||||
@@ -450,10 +493,15 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
// 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, CONSTANTS.TOOLS_PATH);
|
||||
}
|
||||
// The hook is installed unconditionally so the Machine ▸ Change Shared
|
||||
// Folder menu can point it at a directory later without a restart.
|
||||
const smbRoot =
|
||||
process.env.WIN95_SMB_SHARE || this.state.smbSharePath || null;
|
||||
this.smbShare = setupSmbShare(
|
||||
window["emulator"],
|
||||
smbRoot,
|
||||
CONSTANTS.TOOLS_PATH,
|
||||
);
|
||||
|
||||
if (PROBE) {
|
||||
startProbe(window["emulator"]);
|
||||
|
||||
@@ -8,26 +8,28 @@
|
||||
// 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, shareNameFor, TOOLS_SHARE } from "./server";
|
||||
|
||||
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
|
||||
const LOG_FILE = process.env.WIN95_SMB_LOG || 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 {}
|
||||
}
|
||||
};
|
||||
// Diagnostics tee — opt-in via WIN95_SMB_LOG. The console.log override and
|
||||
// per-frame counter below sit on the hot path; don't pay for them unless
|
||||
// someone is actually watching.
|
||||
const LOG_FILE = process.env.WIN95_SMB_LOG;
|
||||
if (LOG_FILE) {
|
||||
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;
|
||||
@@ -56,33 +58,42 @@ interface V86 {
|
||||
|
||||
const log = (...a: unknown[]) => console.log("[smb]", ...a);
|
||||
|
||||
export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: string) {
|
||||
log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
|
||||
`(+ \\\\HOST\\${TOOLS_SHARE}${toolsRoot ? ` ← ${toolsRoot}` : ""}) port 139`);
|
||||
export function setupSmbShare(emulator: V86, hostPath: string | null, toolsRoot?: string) {
|
||||
// hostPath is read on every new TCP 139 connection, so the menu can re-aim
|
||||
// the share at a different folder without restarting. Existing SmbSessions
|
||||
// keep their old root until Win95 reconnects (close the Explorer window or
|
||||
// `net use z: /delete` then re-map).
|
||||
const announce = () => hostPath
|
||||
? log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
|
||||
`(+ \\\\HOST\\${TOOLS_SHARE}${toolsRoot ? ` ← ${toolsRoot}` : ""}) port 139`)
|
||||
: log(`port 139 hooked, no host folder shared yet`);
|
||||
announce();
|
||||
|
||||
// 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);
|
||||
if (LOG_FILE) {
|
||||
// Count every ethernet frame so we know if the NIC is emitting anything
|
||||
// at all. Logged on a timer so the absence of a tick proves the bus is
|
||||
// dead. Opt-in: this hook fires once per TX frame during a file copy.
|
||||
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]);
|
||||
@@ -100,6 +111,12 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
|
||||
|
||||
const wireConn = (conn: TCPConnection) => {
|
||||
log(`← TCP SYN ${conn.tuple}`);
|
||||
if (!hostPath) {
|
||||
// No folder picked yet — caller declines the SYN so the guest sees a
|
||||
// clean RST instead of a half-open NetBIOS session.
|
||||
log("no share configured → RST");
|
||||
return false;
|
||||
}
|
||||
const framer = new NetBIOSFramer();
|
||||
const session = new SmbSession(hostPath, toolsRoot);
|
||||
|
||||
@@ -123,14 +140,39 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
|
||||
} else {
|
||||
(conn as any).on_data = handler;
|
||||
}
|
||||
|
||||
// v86's TCP is stop-and-wait (one MSS, wait for ACK). The link is lossless
|
||||
// and has no retransmit anyway, so keep a small window in flight by sliding
|
||||
// the ring-buffer view under the original pump(). The burst lands in the
|
||||
// NE2000 RX ring before the guest CPU runs, so the cap is the ring (52–58
|
||||
// pages for Win95's driver) — 4×MSS + our ACK ≈ 36 pages, 8 overflowed it.
|
||||
const c = conn as any, sb = c.send_buffer;
|
||||
const mss: number = c.send_chunk_buf?.length ?? 1460;
|
||||
const pump1 = Object.getPrototypeOf(c)?.pump;
|
||||
if (pump1 && sb?.buffer) {
|
||||
let hi: number | undefined;
|
||||
c.pump = function () {
|
||||
if (this.pending || !sb.length) return pump1.call(this);
|
||||
const cap = sb.buffer.length, t0 = sb.tail, l0 = sb.length, s0 = this.seq;
|
||||
const win = 4 * mss;
|
||||
let off = hi === undefined ? 0 : Math.max(0, Math.min(hi - s0, l0));
|
||||
for (; off < l0 && off < win; off += Math.min(mss, l0 - off)) {
|
||||
sb.tail = (t0 + off) % cap; sb.length = l0 - off;
|
||||
this.seq = s0 + off; this.pending = false;
|
||||
pump1.call(this);
|
||||
}
|
||||
hi = s0 + off;
|
||||
sb.tail = t0; sb.length = l0; this.seq = s0; this.pending = true;
|
||||
};
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 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();
|
||||
if (wireConn(conn)) conn.accept();
|
||||
});
|
||||
|
||||
// Old API: monkey-patch adapter.on_tcp_connection. The adapter is created
|
||||
@@ -177,7 +219,7 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
|
||||
// "established" and we want a fresh handshake.
|
||||
conn.tuple = tuple;
|
||||
conn.state = "syn-received";
|
||||
wireConn(conn);
|
||||
if (!wireConn(conn)) return false;
|
||||
try {
|
||||
conn.accept(packet);
|
||||
} catch (e) {
|
||||
@@ -195,4 +237,11 @@ export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: strin
|
||||
const poll = setInterval(() => { if (tryHook()) clearInterval(poll); }, 100);
|
||||
setTimeout(() => clearInterval(poll), 10000);
|
||||
}
|
||||
|
||||
return {
|
||||
setHostPath(p: string) {
|
||||
hostPath = p;
|
||||
announce();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -670,7 +670,11 @@ export class SmbSession {
|
||||
new Uint8Array(0), new Uint8Array(0));
|
||||
}
|
||||
const words = new Writer().u16(data.length).zero(8).build();
|
||||
const bytes = new Writer().u8(0x01).u16(data.length).bytes(data).build();
|
||||
const bytes = new Uint8Array(3 + data.length);
|
||||
bytes[0] = 0x01;
|
||||
bytes[1] = data.length & 0xff;
|
||||
bytes[2] = (data.length >> 8) & 0xff;
|
||||
bytes.set(data, 3);
|
||||
return buildSmb(req, CMD_READ, 0, words, bytes);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 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";
|
||||
import { Reader } from "./wire";
|
||||
|
||||
export const SMB_MAGIC = [0xff, 0x53, 0x4d, 0x42]; // \xFF SMB
|
||||
|
||||
@@ -106,25 +106,28 @@ export function buildSmb(
|
||||
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);
|
||||
if (words.length % 2 !== 0) throw new Error("word block must be even");
|
||||
// Hot path for READ replies (bytes can be ~16K) — assemble directly instead
|
||||
// of pushing byte-by-byte through Writer.
|
||||
const out = new Uint8Array(32 + 1 + words.length + 2 + bytes.length);
|
||||
const v = new DataView(out.buffer);
|
||||
out[0] = 0xff; out[1] = 0x53; out[2] = 0x4d; out[3] = 0x42;
|
||||
out[4] = cmd;
|
||||
v.setUint32(5, status, true);
|
||||
out[9] = 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();
|
||||
v.setUint16(10, (overrides?.flags2 ?? req.flags2) & FLAGS2_LONG_NAMES, true);
|
||||
// 12 bytes reserved already zero
|
||||
v.setUint16(24, overrides?.tid ?? req.tid, true);
|
||||
v.setUint16(26, req.pid, true);
|
||||
v.setUint16(28, overrides?.uid ?? req.uid, true);
|
||||
v.setUint16(30, req.mid, true);
|
||||
out[32] = words.length / 2;
|
||||
out.set(words, 33);
|
||||
v.setUint16(33 + words.length, bytes.length, true);
|
||||
out.set(bytes, 35 + words.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dosError(errClass: number, errCode: number): number {
|
||||
|
||||
Reference in New Issue
Block a user