windows95 is a desktop Electron application that runs Windows 95
unmodified inside the v86 WebAssembly x86
emulator. The Electron main process owns OS integration (windowing, menus,
file dialogs, auto-update, code-signing); the renderer process owns the
emulator core, networking glue, file shares, clipboard sync, and machine
state. This guide is the source-of-truth reference for operators,
maintainers, and contributors.
The User Guide covers using the app. This document covers everything else: the Electron architecture, v86 wiring, build pipeline, code-signing, internal IPC, networking shims, the SMB stack, machine-state migration, and the diagnostic harness.
1. Architecture Overview
windows95 is a two-process Electron app on top of an emulator. The main process boots, owns the OS-native chrome (menu bar, About panel, file dialogs, session/cookie config, auto-update), and exposes a small IPC surface. The renderer process is where the action lives: it instantiates v86, drives the React UI, runs the SMB server, bridges TCP and clipboard, and persists machine state.
There is no separate worker. There is no daemon. There is no embedded
backend service. The whole app is one Electron BrowserWindow with
nodeIntegration: true and contextIsolation: false, which lets the
renderer call fs, net, and other Node APIs directly — including the
synchronous reads that v86's CD-ROM ATAPI emulation depends on.
┌──────────────────────────────────────────────────────────────────────┐
│ Electron main process (src/main/main.ts) │
│ │
│ onReady(): │
│ ├── setupSession() CORS-permissive request headers │
│ ├── setupIpcListeners() IPC: state path, downloads dir, share dir │
│ ├── getOrCreateWindow() BrowserWindow(1024×768, nodeIntegration) │
│ ├── setupAboutPanel() Linux/macOS About info │
│ ├── setupMenu() View/Window/Machine/Help menus │
│ ├── setupUpdates() update-electron-app (GitHub, hourly) │
│ └── setupFileServer() protocol.handle("http") → static/www │
│ │
└────────────────────┬─────────────────────────────────────────────────┘
│ ipc, file:// loadFile()
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Renderer process (src/renderer/app.tsx → emulator.tsx) │
│ │
│ App.setup() → render <Emulator/> │
│ │ │
│ ├─→ <CardStart> welcome / legacy-state recovery │
│ ├─→ <CardSettings> floppy / cdrom / share / interface / state │
│ ├─→ <EmulatorInfo> live CPU / disk / net stats │
│ └─→ canvas#emulator-canvas + div#emulator-text-screen │
│ │
│ startEmulator(): │
│ window.emulator = new V86({wasm, bios, hda, ne2k, sb16}) │
│ ├── setupDnsShim() intercept DoH for magic names │
│ ├── setupTcpRelay() bridge non-80/139 TCP to host │
│ ├── setupTcpTrace() opt-in packet tracer │
│ ├── setupSmbShare() SMB1 server on TCP 139 │
│ ├── setupClipboardSync() VMware backdoor clipboard │
│ └── (PROBE) startProbe() autonomous boot harness │
│ │
└──────────────────────────────────────────────────────────────────────┘
Note
The renderer runs with
contextIsolation: falseandnodeIntegration: true. This is intentional: v86's CD-ROM ATAPI emulation requires synchronous filesystem reads to avoid a BSY race in Win95'sESDI_506.PDR/CDVSD.VXDdriver chain (seesrc/renderer/sync-file-buffer.ts). It also letslibv86.jsuse Node'sfsandcryptoinstead of the browser'sfetchfor asset loading, and lets the SMB server usenetandfsdirectly. Lifting these would mean re-architecting the v86 integration end-to-end.
Component inventory
| Component | Path | Role |
|---|---|---|
| Main entry | src/main/main.ts |
Electron app bootstrap, lifecycle hooks |
| Windows / IPC / menu / session | src/main/{windows,ipc,menu,session}.ts |
OS chrome and renderer ↔ main glue |
| Settings (host side) | src/main/settings.ts |
Persists smbSharePath to settings.json |
| Auto-update | src/main/update.ts |
GitHub release polling via update-electron-app |
| File server | src/main/fileserver/fileserver.ts |
http://windows95/ → static/www |
| About panel | src/main/about-panel.ts |
Linux/macOS About info |
| Squirrel | src/main/squirrel.ts |
Windows install/uninstall shortcut handling |
| Renderer entry | src/renderer/app.tsx |
React root, defers to <Emulator> |
| Emulator shell | src/renderer/emulator.tsx |
Top-level component, drives v86, mouse, state |
| Welcome card | src/renderer/card-start.tsx |
Start, Settings, legacy recovery prompt |
| Settings card | src/renderer/card-settings.tsx |
Floppy / CD-ROM / share / interface / state |
| Emulator info bar | src/renderer/emulator-info.tsx |
Live CPU/disk/net stats with sparklines |
| Info-bar settings | src/renderer/info-bar-settings.ts |
localStorage persistence for the info bar |
| Sync file buffer | src/renderer/sync-file-buffer.ts |
Synchronous v86 disk-buffer adapter |
| Clipboard sync | src/renderer/clipboard.ts |
VMware-backdoor host↔guest clipboard |
| Debug harness | src/renderer/debug-harness.ts |
WIN95_PROBE boot probe |
| State path | src/renderer/utils/get-state-path.ts |
IPC wrapper |
| Reset state | src/renderer/utils/reset-state.ts |
Delete state-vN.bin |
| FAT32 walker | src/renderer/utils/fat32-extract.ts |
Read-only LFN-aware FAT32 reader |
| Legacy disk recovery | src/renderer/utils/recover-legacy-disk.ts |
Headless v86 + FAT32 walker |
| SMB1 server | src/renderer/smb/server.ts |
One-session SMB1 implementation |
| SMB hookup | src/renderer/smb/index.ts |
TCP-139 hook, send-window patch |
| NetBIOS framer | src/renderer/smb/netbios.ts |
Session messages over TCP |
| NBNS responder | src/renderer/smb/nbns.ts |
UDP/137 Node-Status answers |
| SMB protocol bits | src/renderer/smb/{smb,wire}.ts |
Header parser, Reader/Writer |
| Standalone test | src/renderer/smb/test-standalone.ts |
Out-of-app SMB exerciser |
| TCP relay | src/renderer/net/tcp-relay.ts |
Bridge non-80/139 TCP to host sockets |
| TCP trace | src/renderer/net/tcp-trace.ts |
Per-frame packet tracer |
| DNS shim | src/renderer/net/dns-shim.ts |
Local answers for magic hostnames |
| v86 library | src/renderer/lib/libv86.js |
Bundled v86 emulator (built artifact) |
| BIOS images | bios/{seabios,vgabios}.bin |
Bundled into the app |
| Guest agent | guest-tools/agent/w95tools.c |
C / Open Watcom build of W95TOOLS.EXE |
| Guest mouse | guest-tools/mouse-driver/ |
Pre-built VBMOUSE.DRV for absolute pointer |
| Forge config | forge.config.js |
electron-forge maker / packager / signing |
| Vite build | tools/vite-build.js |
Renderer + main bundling, libv86 patches |
Technology stack
| Layer | Choice | Why |
|---|---|---|
| Desktop runtime | Electron 41 | Cross-platform, mature signing/notarization story |
| UI framework | React 19 (react, react-dom) |
Familiar, supports class components used in <Emulator> |
| Bundler | Vite 7 | Fast renderer build, separate main / renderer configs |
| Language | TypeScript 6 | All src/ is TypeScript |
| Emulator | v86 (forked, windows95-base branch) |
x86 PC emulator, runs Windows 95 unmodified |
| Build / package | electron-forge 7.11.1 | Squirrel/DMG/DEB/RPM/Flatpak makers, GitHub publisher |
| Code signing | Apple notarization (@electron/osx-sign) + Azure Trusted Signing (Windows) |
Production signed builds |
| Auto-update | update-electron-app 3.1 |
GitHub Releases polling, Squirrel under the hood |
| Guest agent | C with Open Watcom v2 | Tiny, runs on Win95 RTM, no DLL deps beyond USER32/KERNEL32/MPR |
High-level data flow
The emulator is an opaque V86 instance with an event bus and a few
specific APIs (run, stop, restart, save_state, restore_state,
mouse_set_status, keyboard_send_scancodes, keyboard_send_text,
screen_set_scale, lock_mouse, add_listener, remove_listener,
set_fda, eject_fda, get_instruction_counter, is_running,
screen_make_screenshot).
Boot:
user clicks Start ─► Emulator.startEmulator()
├─ build options { wasm_path, bios, vga_bios, hda(async),
│ net_device(ne2k+doh), memory=128MB,
│ vga_memory=64MB, boot_order=0x132 }
├─ setupDnsShim() (intercept DoH globally)
├─ window.emulator = new V86(options)
├─ setupTcpRelay() (raw TCP egress)
├─ setupTcpTrace() (no-op unless WIN95_TCP_TRACE set)
├─ setupSmbShare() (NBNS + TCP/139 hooks)
├─ setupClipboardSync()
└─ on "emulator-loaded" → restoreState() → run() → screen_set_scale
Quit:
onbeforeunload ────► saveState() ────► fs.promises.writeFile(state-v5.bin)
│ ├─ Buffer.from(emulator.save_state())
│ └─ flush:true
└─► ipc APP_QUIT (defer until next macrotask)
Drive Z::
guest TCP SYN port 139 ──► v86 fake_network ──► bus.emit "tcp-connection" ──► smb/index.ts
│
▼
NetBIOSFramer + new SmbSession(hostPath)
│
▼
fs reads under realRoot, virtual files in TOOLS share
Note
The renderer pre-emptively
Object.assigns theprocess.env.WIN95_PROBE_OPTSJSON overoptionsbeforenew V86(). That's how the bisect harness can flipacpi:falseordisable_jit:truewithout rebuilding the renderer. It is a deliberate seam — anything the harness can flip, a contributor can flip locally too.
2. System Requirements
Operational requirements for running the packaged app and for building it from source.
Runtime (packaged app)
| Resource | Minimum | Notes |
|---|---|---|
| OS | macOS 11+, Windows 10+, modern Linux | Electron 41 deprecates older versions |
| CPU | 64-bit x86_64 or Apple Silicon arm64 | v86 itself is JS+wasm, runs anywhere Electron does |
| RAM (host) | 512 MB free | v86 allocates ~192 MB (128 MB guest + 64 MB VGA) |
| Disk | 1.5 GB | App bundle + 1 GB disk image + state |
| GPU | Any with 2D canvas | The screen is a single HTMLCanvasElement |
| Audio | Optional | Sound Blaster 16 emulation requires WebAudio |
Build (from source)
| Tool | Pinned | Purpose |
|---|---|---|
| Node.js | ≥ 22 | The Vite config targets node22 for the main bundle |
| npm | bundled with Node | npm install runs patch-package postinstall |
| Electron | 41.2.0 | Pinned exact in package.json devDeps |
| TypeScript | 6.0.2 | tsc -p tsconfig.json --noEmit for typecheck |
| Vite | 7.3.1 | Renderer + main bundling |
prettier |
3.8.1 | Code formatter (npm run lint) |
Optional v86 development
| Tool | Why |
|---|---|
Rust + cargo |
Build v86's wasm core |
rustup target add wasm32-unknown-unknown |
Required target |
| Clang | v86's C parts |
Java (e.g., brew install openjdk) |
Closure compiler invocation in v86's Makefile |
| Closure compiler v20210601 | Pinned by v86 |
Optional guest-tools development
| Tool | Why |
|---|---|
| Open Watcom v2 | Builds W95TOOLS.EXE against Win95 SDK headers |
make |
guest-tools/agent/Makefile |
| Docker (optional) | guest-tools/agent/Dockerfile provides a reproducible build env |
3. Configuration Reference
Every configuration knob the app exposes, end to end.
3.1 Build-time configuration
| File | Format | Purpose |
|---|---|---|
package.json |
JSON | Name, version, deps, npm scripts, main: ./dist/src/main/main.js, config.forge: ./forge.config.js |
forge.config.js |
CommonJS | electron-forge: makers, packagerConfig, signing, ignore patterns |
tsconfig.json |
JSON | TypeScript compiler options |
tools/vite-build.js |
CommonJS | Vite main + renderer config, libv86 patches |
patches/@electron+packager+18.4.4.patch |
unified diff | Local patches applied by patch-package postinstall |
3.2 Runtime configuration files
| File | Path | Purpose |
|---|---|---|
settings.json |
userData/settings.json |
Stores smbSharePath: string (the only field today) |
state-v5.bin |
userData/state-v5.bin |
Current-version v86 saved state |
state-v4.bin (legacy) |
userData/state-v4.bin |
Older state, used by recovery flow only |
infoBarSettings |
localStorage["infoBarSettings"] |
showCpu, showDisk, showNet, showSparklines |
images/windows95.img |
inside app bundle | The 1 GB disk image |
images/default-state.bin |
inside app bundle | Stock starting state for fresh boots |
3.3 Environment variables
Build / signing
| Variable | When read | Purpose |
|---|---|---|
SIGNTOOL_PATH |
forge.config.js:8 |
Path to Microsoft signtool.exe for Windows code signing |
AZURE_CODE_SIGNING_DLIB |
forge.config.js:9 |
Path to Azure.CodeSigning.Dlib.dll. Defaults to Microsoft.Trusted.Signing.Client.1.0.60/bin/x64/... |
AZURE_METADATA_JSON |
forge.config.js:10 |
Output path for the trusted-signing metadata JSON. Defaults to trusted-signing-metadata.json |
AZURE_TENANT_ID |
forge.config.js:11 |
Azure AD tenant for Trusted Signing |
AZURE_CLIENT_ID |
forge.config.js:12 |
Service principal client ID |
AZURE_CLIENT_SECRET |
forge.config.js:13 |
Service principal secret |
AZURE_CODE_SIGNING_ENDPOINT |
forge.config.js:21 |
Trusted Signing endpoint. Defaults to https://wcus.codesigning.azure.net |
AZURE_CODE_SIGNING_ACCOUNT_NAME |
forge.config.js:22 |
Code Signing account name |
AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME |
forge.config.js:23 |
Certificate profile within the account |
APPLE_ID |
forge.config.js:14 |
Apple ID for notarization |
APPLE_ID_PASSWORD |
forge.config.js:15 |
App-specific password for notarization |
Important
Without
AZURE_TENANT_IDandSIGNTOOL_PATHboth set, the build emits a warning and produces unsigned Windows binaries. macOS notarization needs bothAPPLE_IDandAPPLE_ID_PASSWORD; the team ID is hard-coded asLT94ZKYDCJinforge.config.js. Without notarization, macOS Gatekeeper will refuse to launch the app on end-user machines.
Runtime — main process
| Variable | When read | Purpose |
|---|---|---|
NODE_ENV |
src/main/main.ts:18 |
Set to production automatically when not in dev mode |
WIN95_DEBUG_PORT |
src/main/main.ts:69 |
Renderer DevTools Protocol port. Defaults to 9222. Only used in dev mode (isDevMode() true) |
Runtime — renderer / SMB / recovery
| Variable | When read | Purpose |
|---|---|---|
WIN95_SMB_SHARE |
src/renderer/emulator.tsx:499 |
Override smbSharePath for one launch. Highest priority |
WIN95_SMB_LOG |
src/renderer/smb/index.ts:18 |
Append SMB diagnostics to this file. Off by default |
WIN95_SMB_CAPTURE |
src/renderer/smb/server.ts:115 |
If set, log every parsed SMB request — chattier than WIN95_SMB_LOG |
WIN95_RECOVER_DIR |
src/renderer/emulator.tsx:345 |
Override the destination folder for legacy-state recovery. Defaults to <Downloads>/Recovered C Drive |
Runtime — debug harness (WIN95_PROBE_*)
These are read by src/renderer/debug-harness.ts and emulator.tsx,
intended for the boot probe at tools/probe-boot.sh. Setting them by
hand turns the renderer into an automated test runner.
| Variable | Default | Purpose |
|---|---|---|
WIN95_PROBE |
0 |
If 1, skip the welcome card, boot from scratch, start the probe |
WIN95_PROBE_OPTS |
{} |
JSON object merged into v86 options (e.g., {"acpi":false,"disable_jit":true}) |
WIN95_PROBE_STATUS |
/tmp/win95-probe.json |
Path to the per-tick status JSON |
WIN95_PROBE_SCREEN |
/tmp/win95-screen.png |
Path to the per-tick screenshot |
WIN95_PROBE_SCRIPT |
(unset) | After desktop, type \\<seg>\<seg> (segments split on /) |
WIN95_PROBE_RUN |
(unset) | After desktop, send Ctrl+Esc → R → type → Enter |
WIN95_PROBE_RUN2 |
(unset) | A second Run sequence after WIN95_PROBE_RUN2_WAIT ms |
WIN95_PROBE_RUN2_WAIT |
3000 |
Delay before the second Run |
WIN95_PROBE_RUN_AFTER |
(unset) | Extra text typed after WIN95_PROBE_RUN_WAIT ms |
WIN95_PROBE_RUN_WAIT |
6000 |
Delay before RUN_AFTER is typed |
WIN95_PROBE_DOSBOX |
0 |
Open COMMAND.COM and type dir |
WIN95_PROBE_DOSBOX_ALTENTER |
0 |
After opening DOS, send Alt+Enter to toggle full-screen |
WIN95_PROBE_VGATRACE |
0 |
Trace VGA I/O ports to /tmp/win95-vgatrace.json |
WIN95_PROBE_CDTRACE |
0 |
Trace IDE secondary ata_command / atapi_handle |
WIN95_PROBE_CDTRACE_FILE |
/tmp/win95-cdtrace.log |
CD trace destination |
WIN95_PROBE_CDROM |
(unset) | Path to an ISO to mount at boot (probe-only) |
Runtime — TCP relay / trace (developer-only)
| Variable | Purpose |
|---|---|
WIN95_TCP_RELAY_LOG |
Path to relay log. Defaults to <tmp>/win95-tcp-relay.log |
WIN95_TCP_TEST_PORT |
If set, that port short-circuits to an in-process echo server |
WIN95_TCP_TEST_MODE |
banner (default) or after-data |
WIN95_TCP_TEST_BYTES |
Banner size |
WIN95_TCP_TEST_DELAY |
Banner-send delay in ms |
WIN95_TCP_TRACE |
TCP destination port to trace |
WIN95_TCP_TRACE_FILE |
Path to the trace log. Defaults to /tmp/win95-tcp-trace.log |
3.4 IPC command catalog
The full set of IPC channels between main and renderer, defined in
src/constants.ts (IPC_COMMANDS). The corresponding handlers are in
src/main/ipc.ts and src/main/menu.ts (main side) and
src/renderer/emulator.tsx (renderer side).
| Command | Direction | Sender | Handler | Purpose |
|---|---|---|---|---|
GET_STATE_PATH |
renderer → main | getStatePath() |
main ipc.ts |
Resolve userData/state-v<STATE_VERSION>.bin |
GET_LEGACY_STATE_PATH |
renderer → main | getLegacyStatePath() |
main ipc.ts |
Find an older state-vN.bin (down to v4) if no current exists |
GET_DOWNLOADS_PATH |
renderer → main | recovery flow | main ipc.ts |
Resolve app.getPath("downloads") |
GET_SMB_SHARE_PATH |
renderer → main | <Emulator> ctor |
main ipc.ts (reads settings.json) |
Last-saved share folder |
SET_SMB_SHARE_PATH |
renderer → main | settings card / Machine menu | main ipc.ts (writes settings.json with realpath validation) |
Persist the share folder. Returns false if the path doesn't resolve to a directory |
PICK_FOLDER |
renderer → main | settings card | main ipc.ts (dialog.showOpenDialog) |
Open the host folder picker |
APP_QUIT |
renderer → main | unload handler | main ipc.ts (app.quit()) |
Quit after state save completes |
MACHINE_START |
main → renderer | menu | renderer Emulator.startEmulator |
|
MACHINE_BOOT_FROM_SCRATCH |
main → renderer | menu | Emulator.bootFromScratch |
|
MACHINE_RESTART |
main → renderer | menu | Emulator.restartEmulator |
|
MACHINE_STOP |
main → renderer | menu | Emulator.stopEmulator |
|
MACHINE_RESET |
main → renderer | menu (with confirm dialog) | Emulator.resetEmulator |
Deletes state, reloads renderer |
MACHINE_CTRL_ALT_DEL |
main → renderer | menu | sends scancodes 0x1D + 0x38 + 0x53 | |
MACHINE_ALT_F4 |
main → renderer | menu | sends 0x38 + 0x3E | |
MACHINE_ALT_ENTER |
main → renderer | menu | sends 0x38 + 0x00 | |
MACHINE_ESC |
main → renderer | menu | sends 0x18 | |
MACHINE_SET_FLOPPY |
main → renderer | menu file picker | set_fda / eject_fda |
|
MACHINE_SET_CDROM |
main → renderer | menu file picker | direct cdrom.set_cdrom(SyncFileBuffer) / cdrom.eject |
|
MACHINE_SET_SMB_SHARE |
main → renderer | menu folder picker | smbShare.setHostPath() + persist via SET_SMB_SHARE_PATH |
|
MACHINE_STARTED |
renderer → main | startEmulator after run() |
menu rebuilds with isRunning: true |
|
MACHINE_STOPPED |
renderer → main | stopEmulator | menu rebuilds with isRunning: false |
|
TOGGLE_INFO |
main → renderer | menu | flips isInfoDisplayed |
|
SHOW_DISK_IMAGE |
main → renderer | menu | shell.showItemInFolder(IMAGE_PATH) |
|
ZOOM_IN / ZOOM_OUT / ZOOM_RESET |
main → renderer | menu | screen_set_scale(scale * 1.2 / 0.8 / 1) |
3.5 v86 instantiation options
The renderer builds these in Emulator.startEmulator() and merges
WIN95_PROBE_OPTS on top:
const options = {
wasm_path: path.join(__dirname, "build/v86.wasm"),
memory_size: 128 * 1024 * 1024,
vga_memory_size: 64 * 1024 * 1024,
screen: { container: document.getElementById("emulator"), scale: 0 },
preserve_mac_from_state_image: true,
net_device: { relay_url: "fetch", type: "ne2k", dns_method: "doh" },
bios: { url: path.join(__dirname, "../../bios/seabios.bin") },
vga_bios:{ url: path.join(__dirname, "../../bios/vgabios.bin") },
hda: { url: CONSTANTS.IMAGE_PATH, async: true, size: <stat()> },
fda: this.state.floppyFile ? { buffer: this.state.floppyFile } : undefined,
cdrom: cdromPath ? new SyncFileBuffer(cdromPath) : undefined,
boot_order: 0x132,
};
| Key | Value | Why |
|---|---|---|
wasm_path |
<lib>/build/v86.wasm |
Loaded via Node require after the libv86.js patch in vite-build.js |
memory_size |
128 MB | Tradeoff between guest perceived performance and renderer memory pressure |
vga_memory_size |
64 MB | Generous so guest can use higher resolutions / colour depths |
boot_order |
0x132 |
Floppy → CD-ROM → HDD (vintage BIOS convention) |
net_device.type |
ne2k |
NE2000 ISA NIC, what Win95 has drivers for out of the box |
net_device.dns_method |
doh |
Real DNS for real names; static would NXDOMAIN everything |
preserve_mac_from_state_image |
true |
Stable MAC across launches so DHCP leases hold |
4. Deployment & Scaling
The app is single-user, single-window, single-process-pair. There is no fleet to scale. Deployment is the build pipeline that produces installers.
4.1 Build pipeline
tools/vite-build.js (and its compileVite export) is the actual build
runner. electron-forge invokes it from the generateAssets hook in
forge.config.js.
npm run package / make / publish
│
▼
electron-forge
├─ hooks.generateAssets: tools/generateAssets.js
│ └─ compileVite()
│ ├─ rm -rf dist
│ ├─ mkdir dist/static
│ ├─ copyLib() // copy src/renderer/lib → dist/static
│ │ ├─ patch libv86.js: dynamic import(node:*) → require()
│ │ └─ inject LIBV86_SHIM into static/index.html
│ ├─ build(mainConfig) // src/main/main.ts → dist/src/main/main.js
│ └─ build(rendererConfig) // src/renderer/app.tsx → dist/renderer.js
│
├─ packagerConfig: copy dist/, bios/, images/ etc., respecting `ignore` list
├─ osxSign / osxNotarize (macOS only, when APPLE_ID set)
├─ windowsSign (Win32 only, when AZURE_TENANT_ID + SIGNTOOL_PATH set)
│
└─ makers:
├─ @electron-forge/maker-squirrel (Windows .exe installer)
├─ @electron-forge/maker-zip (macOS + Windows .zip)
├─ @electron-forge/maker-deb (Linux .deb)
├─ @electron-forge/maker-rpm (Linux .rpm)
└─ @electron-forge/maker-flatpak (Linux .flatpak — devDep, opt-in)
The packagerConfig sets asar: false. This is intentional: libv86.js
expects to load v86.wasm from a real filesystem path (require), and
ASAR packaging breaks that path resolution.
4.2 What ships in the bundle
The ignore regexes in forge.config.js strip everything that doesn't
need to be in the shipped app. What remains:
dist/(compiled main + renderer + static assets)bios/seabios.bin,bios/vgabios.binimages/windows95.img,images/default-state.bin(these are not in the source repo — they're downloaded at build time from a private release, seetools/download-disk.{sh,ps1})guest-tools/(theW95TOOLS.EXEsource, the precompiledVBMOUSE.DRV, etc.)package.json,node_modules/(production deps only)assets/icon.{ico,icns},assets/boot.gif,assets/entitlements.plist
4.3 Code signing
macOS
The bundle is signed with Developer ID Application: Felix Rieseberg (LT94ZKYDCJ) and notarized via @electron/osx-sign's integration with
Apple's notary service. Required envs: APPLE_ID, APPLE_ID_PASSWORD
(an app-specific password), and the team ID is hard-coded.
Entitlements live in assets/entitlements.plist. Hardened runtime is
enabled; the entitlements file grants the JIT and library-validation
exceptions the JS+wasm runtime needs.
Windows
Two signing paths in priority order:
- Azure Trusted Signing (preferred): when
AZURE_TENANT_IDandSIGNTOOL_PATHare both set, the build writes a freshtrusted-signing-metadata.jsonand pointssigntool.exeat it via/dliband/dmdf. Hashes default to SHA-256, the timestamp server ishttp://timestamp.acs.microsoft.com. The metadata JSON is rebuilt on every build so rotated tenant credentials apply immediately. - Unsigned fallback: a
console.warnis printed and binaries ship without a signature. SmartScreen will warn end users.
Linux
No signing is performed. .deb/.rpm/.flatpak distribution channels
typically rely on repository-level signing (apt's signed-by, RPM key
imports, Flatpak's GPG). The makers don't configure those — that's the
responsibility of whoever uploads the artifacts.
4.4 Release publishing
@electron-forge/publisher-github is configured (devDep). Running
npm run publish after a build uploads the artifacts to a GitHub
release on the configured repo. The release tag and prerelease/draft
status come from forge's GitHub publisher options (not visible in the
checked-in forge.config.js, so the maintainer either passes them via
CLI flags or has overrides elsewhere).
4.5 Disk image distribution
The 1 GB disk image and default-state file are not in the source repository — they're hosted in a private companion release. CI and local builds pull them via:
| Script | Reads | Writes |
|---|---|---|
tools/download-disk.sh |
DISK_REPO, DISK_TAG, GH_TOKEN |
images/*.img, images/*.bin |
tools/download-disk.ps1 |
same | same |
tools/pack-disk.sh |
images/windows95.img, images/default-state.bin |
images_<date>.zip (used to publish a new disk release) |
Important
Forking the repo will not give you a working build until you point
DISK_REPO/DISK_TAGat a release that contains a Win95 disk image you have the rights to use. The license to redistribute Windows 95 binaries does not transfer with the source code.
4.6 Auto-update
update-electron-app is initialised in setupUpdates() only when
app.isPackaged is true. Configuration is hard-coded:
updateElectronApp({
repo: "felixrieseberg/windows95",
updateInterval: "1 hour",
});
Mechanism:
- On macOS / Windows it talks to GitHub's
update.electronjs.orgservice which fronts the GitHub Releases API. - On Linux it does nothing — Electron's auto-updater doesn't support Linux. Linux users update via their distro package manager.
- Updates apply on next launch (Squirrel restart-and-replace pattern).
5. Operations
5.1 Operator-facing menu actions
| Operation | Path | Notes |
|---|---|---|
| Cold-boot a fresh Win95 | Machine ▸ Start without state | Keeps existing state alongside; doesn't delete it |
| Wipe state and start over | Machine ▸ Reset | Confirms first; reloads the renderer page after deleting state |
| Clear Electron caches | Help ▸ Troubleshooting ▸ Clear Cache and Restart | Clears cookies, filesystem, IndexedDB, localStorage, shadercache, websql, serviceworkers, then app.relaunch(); app.quit() |
| Force-toggle full-screen | View ▸ Toggle Full Screen | Ctrl+Cmd+F (macOS), F11 (others) |
| Open DevTools | View ▸ Toggle Developer Tools | Cmd+Opt+I (macOS), Ctrl+Shift+I (others). Renderer console is the source of truth for runtime logs |
| Hot-swap floppy | Machine ▸ Floppy Drive | Loads as ArrayBuffer (entire file in memory) — fine for ≤2.88 MB |
| Hot-swap CD-ROM | Machine ▸ CD-ROM Drive | Goes straight to cdrom.set_cdrom(SyncFileBuffer(p)) to dodge v86's async-loader BSY race |
| Reveal disk image | Machine ▸ Go to Disk Image | shell.showItemInFolder(IMAGE_PATH) |
| Reveal log location | (no menu — DevTools console only) | Logs aren't persisted to disk by default |
5.2 Inspecting and tweaking state
Look at the saved state without touching it
ls -lh ~/Library/Application\ Support/windows95/state-v5.bin # macOS
dir %APPDATA%\windows95\state-v5.bin :: Windows
ls -lh ~/.config/windows95/state-v5.bin # Linux
The file is a packed v86 snapshot — opaque binary, no schema. Don't try to edit it; don't move it across major app versions (the recovery flow is the only sanctioned migration path).
Force a fresh boot for one launch
Hold Shift while clicking Start? No — the app doesn't read modifiers. Use Machine ▸ Start without state or Settings ▸ Machine State ▸ Boot from scratch.
Delete the share configuration
Edit userData/settings.json and set smbSharePath to "", or use the
in-app picker. The next renderer instantiation will fall back to
app.getPath('downloads').
5.3 Troubleshooting checklist
- [ ] App opens? (Electron itself starts) → if no, host driver / OS issue, see Section 5.5
- [ ] Welcome card visible? → if no, renderer crashed, check DevTools console
- [ ] Click Start: SeaBIOS appears? → if no, v86 wasm load failed, check network for blocked file://
- [ ] Splash → desktop transition completes? → if no, save-state corruption, Reset state
- [ ] Drive Z: shows up after login? → if no, smbSharePath empty / SMB log empty, see SMB diagnostics
- [ ] Network works (IE loads google.com)? → if no, DoH blocked or fetch adapter broken
- [ ] Clipboard works (copy/paste text)? → if no, W95TOOLS.EXE not running in guest
5.4 SMB diagnostics
Set WIN95_SMB_LOG=/tmp/smb.log (or any host path) before launching the
app to get protocol-level diagnostics. What you'll see:
[smb] serving /Users/me/Downloads on \\HOST\DOWNLOADS (+ \\HOST\TOOLS ← .../guest-tools) port 139
[smb] frames: {"total":42,"arp":4,"ip":38,...}
[smb] ← TCP SYN <tuple>
[smb] dialects offered: ["PC NETWORK PROGRAM 1.0", ..., "NT LM 0.12"]
[smb] negotiate → "NT LM 0.12" (idx 5)
[smb] tree connect: path="\\HOST\HOST" service="?????"
For per-request body capture, also set WIN95_SMB_CAPTURE=1. This is
extremely chatty (every word/byte block hex-dumped) — only use it when
chasing a specific protocol-level bug.
Tip
If you see
← TCP SYN ...followed byno share configured → RST, the user hasn't picked a folder. The app keeps the TCP-139 hook installed at all times so the user can change the folder mid-run without restarting; the cost is that an unconfigured app rejects SYNs rather than returning a different error.
5.5 Network diagnostics
For TCP relay tracing on a specific port:
WIN95_TCP_TRACE=21 WIN95_TCP_TRACE_FILE=/tmp/ftp.log <launch>
The trace decodes every TCP frame in/out for that port, plus the NE2000
RX-ring decisions (frame dropped because of STP, MAC, or FULL).
For a synthetic upstream that the relay short-circuits to:
WIN95_TCP_TEST_PORT=7777 WIN95_TCP_TEST_BYTES=3000 WIN95_TCP_TEST_MODE=banner <launch>
Then inside Win95: Start ▸ Run ▸ telnet 1.1.1.1 7777 — the relay will
write a 3000-byte banner and echo subsequent traffic. Useful for
exercising the recv() path without a real server.
5.6 Probe-driven boot tests
The probe harness was built for v86 bisects. Operators don't normally run it, but maintainers should know it exists. Two top-level scripts:
tools/probe-boot.sh '{"acpi":false}' # one boot, one verdict
tools/bisect-v86.sh <commit-ish> '{"acpi":false}' # build v86@commit, install, boot, repeat 3x
Verdicts: SUCCESS, FAIL_IOS, FAIL_KRNL386, FAIL_VXDLINK,
FAIL_PROTECTION, FAIL_SPLASH_HANG, FAIL_HUNG, FAIL_OTHER,
CRASHED, TIMEOUT. Status JSON at /tmp/win95-probe.json,
screenshot at /tmp/win95-screen.png, .done marker file at
/tmp/win95-probe.done.
5.7 Metrics emitted by the renderer
The <EmulatorInfo> component subscribes to four v86 listeners:
| Event | Payload | Meter |
|---|---|---|
ide-read-end |
[lba, byteCount] |
Disk read bytes/sec |
ide-write-end |
[lba, byteCount] |
Disk write bytes/sec |
eth-receive-end |
[byteCount] |
Net RX bytes/sec |
eth-transmit-end |
[byteCount] |
Net TX bytes/sec |
CPU instructions per second is sampled via
emulator.get_instruction_counter() at 500 ms intervals. None of these
metrics are persisted or exported — they're displayed only.
Tip
If you want a real metric stream out of the app, the cleanest hook is in
src/renderer/emulator-info.tsx— replicate the pattern there and emit to your collector of choice. Keep in mind the renderer doesn't have outbound network privileges by default beyond what the user's Electron build allows.
6. Performance
6.1 What costs CPU
| Cost | Approximate share | Notes |
|---|---|---|
| v86 wasm execution | dominant | The emulated CPU loop is by far the biggest budget item |
| React re-renders | small | Class components, no virtual DOM thrash |
| Canvas blit | small | Single full-screen canvas, browser handles compositing |
| EmulatorInfo tick | trivial | 500 ms interval, simple math |
| DevTools open | adds 10-30% | Chrome-style profiler hooks have overhead |
6.2 Tunable knobs
| Knob | Effect |
|---|---|
memory_size |
More guest RAM = more wasm memory pressure but the same emulation cost. Win95 doesn't benefit from much above 128 MB |
vga_memory_size |
64 MB is overkill for SVGA but cheap |
WIN95_PROBE_OPTS={"disable_jit":true} |
Slows v86 considerably; useful for bisects when the JIT is suspected |
WIN95_PROBE_OPTS={"acpi":false} |
Disables ACPI — sometimes needed for older v86 builds |
screen.scale |
Renderer-side CSS transform, near-free |
emulator.screen_set_scale(N) |
Same as above |
6.3 Save / restore performance
State files are uncompressed. The save path:
emulator.save_state() // synchronous in v86; serialises wasm memory + device state
→ ArrayBuffer
→ fs.promises.writeFile(path, Buffer.from(...), { flush: true })
flush: true matters: it forces an fsync so the file is on disk before
writeFile resolves, which is what onbeforeunload is waiting on.
Without it the renderer can resolve, the window can close, the OS can
reschedule the write, and a power-loss in that window leaves a torn
state file.
6.4 Why the SMB pump is patched
src/renderer/smb/index.ts monkey-patches TCPConnection.pump to keep
4 × MSS in flight instead of v86's stop-and-wait default. Why: the
emulated link is lossless (no retransmits), Win95's NE2000 driver has a
52-58 page RX ring, and a single MSS waiting for ACK leaves the guest
CPU doing nothing for most of a transfer. With 4 MSS the guest fills its
ring, gets an ATAPI-style burst, and copy throughput from \\HOST to
C: jumps from KB/s to multiple MB/s.
6.5 Why the CD-ROM uses a synchronous file buffer
v86's stock async loaders return from .get() immediately and resolve
the data on a later event-loop turn. For an ATAPI PIO READ(10) that
means atapi_read() leaves the drive in BSY while the emulated CPU
keeps running. Win95's ESDI_506.PDR / CDVSD.VXD path checks status
twice after pushing the CDB, sees BSY both times, and issues
DEVICE RESET (08h) — which cancels the in-flight read. Net effect: D:
shows up but the volume never mounts.
SyncFileBuffer (src/renderer/sync-file-buffer.ts) closes the BSY
window by serving bytes synchronously via fs.readSync. Trade-off: the
renderer's main thread blocks for the duration of each PIO read. For
CD-ROMs (modest read rates, low concurrency) this is invisible.
6.6 Why the hard disk doesn't need the same treatment
The HDD path uses bus-master DMA (hda.async: true), which is purely
IRQ-driven on the host side. There's no BSY-loop equivalent in
Win95's hard-disk path; the IDE driver is happy to wait for the IRQ.
7. Security
7.1 Renderer privilege model
| Setting | Value | Implication |
|---|---|---|
nodeIntegration |
true |
Renderer has full Node API: fs, net, child_process, etc. |
contextIsolation |
false |
No isolated world; preload globals are reachable |
sandbox |
false |
OS sandbox disabled |
webviewTag |
false |
No <webview> element allowed |
Warning
The renderer's privilege level means anything that compromises the renderer (a malicious page loaded inside the guest's IE could try to escape out via the Electron renderer) has full filesystem and network access. This is the deliberate trade-off for the synchronous filesystem reads v86's CD-ROM path needs. Treat the renderer as trusted code; do not load arbitrary external HTML into the Electron BrowserWindow itself.
7.2 Mitigations applied
will-navigate interception
src/main/windows.ts intercepts every will-navigate event on the
renderer's webContents. If the URL starts with http, navigation is
prevented and the link is opened in the host's default browser via
shell.openExternal. The renderer never loads remote pages.
mainWindow.webContents.on("will-navigate", (event, url) => {
if (url.startsWith("http")) {
event.preventDefault();
shell.openExternal(url);
}
});
Custom protocol handler
setupFileServer() calls protocol.handle("http") to intercept every
HTTP request the renderer makes. Requests beginning with
http://windows95/ are served from static/www; everything else is
forwarded through Node's fetch. The intercept also clamps path
traversal: a request whose resolved path leaves WWW_ROOT is silently
remapped back to WWW_ROOT.
Path validation in SET_SMB_SHARE_PATH
The IPC handler takes only strings, calls fs.realpathSync on them, and
verifies the target is a directory. Symlinks are resolved (so the share
follows the real path), and non-directories are rejected. Rationale:
the renderer has full Node access, so any code there could call this
IPC; if the value was trusted blindly, SmbSession.realpathSync would
throw inside a TCP callback and the share would silently never connect.
TCP relay destination filtering
isBlockedDest() in src/renderer/net/tcp-relay.ts blocks:
127.0.0.0/8(loopback)0.0.0.0/8(this network)>= 224.0.0.0/4(multicast / reserved)169.254.0.0/16(link-local + cloud metadata endpoints)192.168.86.0/24and192.168.87.0/24(the emulated v86 LAN)
The rest of RFC 1918 is left reachable on purpose — talking to a NAS or BBS on the user's LAN is a legitimate use of the bridge.
Caution
The TCP relay can reach any non-blocked TCP host on your network from the guest. A user running random software inside Windows 95 has the same network reach as the host. If you're running this on a sensitive corporate network, treat it like any other VM with bridged networking.
SMB share read-only and traversal-safe
The SMB server in src/renderer/smb/server.ts is read-only by design —
it implements OPEN, READ, READ_RAW, READ_ANDX, SEEK, CLOSE,
SEARCH, FIND_FIRST2/FIND_NEXT2, QUERY_INFORMATION,
CHECK_DIRECTORY, TREE_CONNECT_ANDX, TREE_DISCONNECT, LOGOFF_ANDX,
and NT_CREATE_ANDX, but no WRITE, WRITE_ANDX, CREATE, DELETE,
RENAME, MKDIR, RMDIR, or SET_INFORMATION opcodes. Path
resolution rejects traversal by canonicalising every component through
the SFN cache and refusing anything that escapes the share root. The
fs.realpathSync at session startup pins the root to a real directory,
so symlinks to outside-root targets fail open instead of leaking files.
CORS-permissive headers (deliberate)
setupSession() rewrites all outbound requests from the renderer to set
Origin: * and adds Access-Control-Allow-Origin: * to all responses.
This is required so the guest's IE can actually load arbitrary websites
through the fetch adapter. It's a permissive choice for a desktop
application that loads only its own bundled HTML; for a browser-style
context it would be unsafe.
7.3 Code-signing and integrity
macOS
- Hardened runtime enabled.
- Notarized via Apple's notary service (when build envs are set).
- Apple Developer ID signature on the bundle and embedded helpers.
assets/entitlements.plistgrants JIT and library-validation exemptions needed by the JS+wasm runtime.
Windows
- Azure Trusted Signing with SHA-256, timestamped against
timestamp.acs.microsoft.com. - The signed-with-params string is built dynamically from the env vars so credential rotation is a config change, not a code change.
Updates
update-electron-app verifies signatures via the platform updater
(Squirrel). On Windows that means the installer matches the previous
publisher; on macOS it relies on the notarization ticket. Linux
auto-update is intentionally not configured.
7.4 Secrets handling
There are no secrets at runtime — the app talks to GitHub Releases
unauthenticated for updates, and to Cloudflare DoH unauthenticated for
DNS. Build-time secrets (Apple ID password, Azure client secret) live
only in CI and are loaded via dotenv from a developer's local .env.
7.5 Threat model bullets
- Malicious disk image: a hostile
windows95.imgcould contain exploit payloads for the v86 emulator, but the renderer is the attack surface; v86 itself is sandboxed inside wasm. The supplied image is from a private release controlled by the maintainer. - Malicious file in shared folder: the host folder is read by the guest, not executed on the host. Files can't escape into host execution unless the user runs them themselves on the host.
- Malicious upstream when the relay reaches a real server: same threat model as a regular VM with NAT — the host's firewall and network policy are the perimeter.
- Compromised guest: a guest-side attacker has the SMB share read access (no write), the clipboard (text only, 64 KB), and TCP egress to most of the internet. They cannot directly call host APIs because the VMware backdoor protocol the agent uses only exposes clipboard get/set/version.
8. Developer Internals
8.1 Repository layout
windows95/
├── README.md (the public README — not in this briefing's repo_docs)
├── HELP.md User-facing FAQ-style help
├── CREDITS.md Attribution / license notes
├── LICENSE.md MIT
├── package.json Project manifest, scripts, deps
├── package-lock.json npm lock
├── tsconfig.json TypeScript compiler config
├── forge.config.js electron-forge config (signing, makers, ignores)
├── Dockerfile Optional Linux builder image
├── issue_template.md GitHub issue template
├── .gitattributes / .gitignore
├── assets/
│ ├── icon.icns / icon.ico App icons
│ ├── boot.gif Squirrel installer loading animation
│ └── entitlements.plist macOS hardened-runtime exceptions
├── bios/
│ ├── seabios.bin PC BIOS (built from copy/v86's seabios fork)
│ ├── vgabios.bin VGA BIOS
│ └── COPYING.LESSER LGPL notice
├── docs/
│ ├── docker-instructions.md
│ ├── docker-kubernetes-gitpod.md
│ ├── qemu.md Notes for running images in QEMU directly
│ ├── smb-share.md Internal notes on the SMB integration
│ └── v86-patches.md Why we maintain a v86 fork
├── guest-tools/
│ ├── README.md
│ ├── agent/
│ │ ├── w95tools.c Win95 backdoor agent (clipboard + auto-map)
│ │ ├── Makefile Open Watcom build
│ │ └── Dockerfile Reproducible build env
│ └── mouse-driver/
│ ├── VBMOUSE.DRV Pre-built absolute-pointer driver
│ ├── OEMSETUP.INF Pointer driver install metadata
│ ├── VBMOUSE.EN Localised strings
│ └── LICENSE.TXT
├── patches/
│ └── @electron+packager+18.4.4.patch Local fix applied via patch-package
├── src/
│ ├── cache.ts Electron session-cache wipe helpers
│ ├── constants.ts IPC enum, paths, STATE_VERSION = 5
│ ├── css/ Per-component stylesheets
│ ├── main/
│ │ ├── main.ts Electron entry
│ │ ├── windows.ts BrowserWindow factory + nav guard
│ │ ├── menu.ts Application menu
│ │ ├── ipc.ts Main-side IPC handlers
│ │ ├── session.ts CORS-permissive request rewriter
│ │ ├── settings.ts SettingsManager (settings.json)
│ │ ├── about-panel.ts Linux/macOS About info
│ │ ├── squirrel.ts Win-installer shortcut handling
│ │ ├── update.ts update-electron-app wiring
│ │ ├── logging.ts Stamped console.log helper
│ │ └── fileserver/fileserver.ts protocol.handle("http") → static/www
│ ├── renderer/
│ │ ├── app.tsx React root
│ │ ├── emulator.tsx <Emulator> top-level component
│ │ ├── card-start.tsx Welcome / legacy recovery card
│ │ ├── card-settings.tsx Settings tabs
│ │ ├── emulator-info.tsx Live stats bar
│ │ ├── info-bar-settings.ts Stats-bar prefs
│ │ ├── status.tsx (helper component)
│ │ ├── clipboard.ts Host↔guest clipboard sync
│ │ ├── debug-harness.ts WIN95_PROBE harness
│ │ ├── sync-file-buffer.ts Synchronous v86 disk buffer
│ │ ├── lib/ Bundled v86 build artifact
│ │ │ ├── libv86.js
│ │ │ └── (build/v86.wasm — copied at build time)
│ │ ├── net/
│ │ │ ├── tcp-relay.ts Raw TCP egress
│ │ │ ├── tcp-trace.ts Per-frame tracer
│ │ │ └── dns-shim.ts Local answers for magic names
│ │ ├── smb/
│ │ │ ├── README.md SMB integration notes
│ │ │ ├── index.ts Hookup glue
│ │ │ ├── server.ts SMB1 session
│ │ │ ├── nbns.ts NBNS Node-Status responder
│ │ │ ├── netbios.ts Session message framer
│ │ │ ├── smb.ts Header parse/build, opcodes, errors
│ │ │ ├── wire.ts Reader / Writer for SMB byte streams
│ │ │ └── test-standalone.ts Out-of-app exerciser
│ │ └── utils/
│ │ ├── disk-image-size.ts fs.stat() with default fallback
│ │ ├── get-state-path.ts IPC wrapper
│ │ ├── reset-state.ts Delete state file
│ │ ├── fat32-extract.ts LFN-aware FAT32 walker
│ │ └── recover-legacy-disk.ts Headless v86 + walker
│ └── utils/
│ ├── devmode.ts Detect dev mode via process.defaultApp
│ └── disk-image-size.ts (mirror)
├── static/
│ ├── index.html Renderer entry HTML
│ ├── boot-fresh.png / cdrom.png / floppy.png / reset*.png / settings.png / show-disk-image.png / start.png / select-*.png
│ ├── entitlements.plist (mirror for macOS packaging)
│ └── www/ Files served at http://windows95/
│ ├── apps.htm / credits.htm / help.htm / home.htm / index.htm / navigation.htm
│ ├── buttons/ images/ Vintage 88×31 web-ring buttons + page assets
└── tools/
├── add-macos-cert.sh Inject signing cert into a fresh keychain (CI)
├── bisect-v86.sh git-bisect helper for v86
├── check-links.js Validate links in README.md
├── download-disk.sh / .ps1 Pull bundled disk image from a private release
├── pack-disk.sh Inverse — produce a release zip
├── probe-boot.sh / probe-tcp.sh Probe drivers
├── generateAssets.js electron-forge generateAssets hook
├── resedit.js Edit Win32 PE metadata of the packaged exe
├── run-bin.js / tsc.js Generic helpers for npm scripts
├── update-v86.js Build v86 from a local checkout, install + sanity-check
└── vite-build.js / vite-watch.js Build runners
8.2 Architecture deep dive
Bootstrap order (renderer)
app.tsx loads:
└─ App.setup()
├─ dynamic import "react"
├─ dynamic import "react-dom/client"
├─ dynamic import "./emulator"
├─ render <div className={process.platform}><Emulator/></div>
└─ root.render(...)
<Emulator> constructor:
├─ bind methods
├─ initial state (currentUiCard: "start", ...)
├─ setupInputListeners() Esc → toggle pointer-lock; pointerlockchange → mouse_set_status
├─ setupIpcListeners() all MACHINE_* and zoom commands
├─ setupUnloadListeners() onbeforeunload → save state → app.quit
├─ ipcRenderer.invoke(GET_SMB_SHARE_PATH)
├─ getLegacyStatePath()
└─ if PROBE: setTimeout(bootFromScratch, 100)
Bootstrap order (main)
main.ts top-level:
└─ main()
├─ if shouldQuit() (Squirrel install hook): app.quit
├─ in dev: append --remote-debugging-port=WIN95_DEBUG_PORT
├─ app.setName("windows95")
└─ register listeners: ready/before-quit/window-all-closed
on "ready" → onReady():
├─ NODE_ENV = "production" if not dev
├─ setupSession() CORS rewrite
├─ setupIpcListeners() IPC handlers
├─ getOrCreateWindow() create + load file://.../static/index.html
├─ setupAboutPanel()
├─ setupMenu()
├─ setupUpdates() if app.isPackaged
└─ setupFileServer() protocol.handle("http")
State migration flow
new STATE_VERSION ships:
app launches
└─ <Emulator> constructor: getLegacyStatePath()
└─ IPC GET_LEGACY_STATE_PATH
├─ if state-v<CURRENT>.bin exists → return null (already migrated)
└─ for v in CURRENT-1 .. 4: if state-v<v>.bin → return that path
├─ if returned: state set { legacyStatePath } → <CardStart> renders the recovery prompt
└─ user clicks "Recover old C:\ drive..." → recoverLegacy()
├─ ipcRenderer.invoke(GET_DOWNLOADS_PATH)
├─ recoverLegacyDisk(legacyStatePath, "<downloads>/Recovered C Drive")
│ ├─ new V86({autostart:false, disable_keyboard, disable_mouse, disable_speaker})
│ ├─ wait for "emulator-loaded"
│ ├─ emulator.restore_state(legacy.buffer)
│ ├─ pull buffer.block_cache + block_cache_is_write off the IDE master
│ ├─ extractFat32(readSector, isDirty, outDir) // walks FAT32, copies dirty files only
│ └─ emulator.destroy(); fs.closeSync(baseFd)
└─ user clicks "Open folder" or "Discard old snapshot"
SMB1 server protocol coverage
The server picks the NT LM 0.12 dialect when offered. Why: only NT
LM 0.12 makes Win95's redirector switch to TRANS2/FIND_FIRST2 (long
filenames). LANMAN2.x falls back to CMD_SEARCH whose 13-byte name
field caps the client at 8.3.
Implemented opcodes (see cmdName map and handle() switch in
server.ts):
| Opcode | Purpose |
|---|---|
NEGOTIATE (0x72) |
Pick a dialect, advertise share-level security, no challenge |
SESSION_SETUP_ANDX (0x73) |
Accept anything as guest (UID=1) |
TREE_CONNECT_ANDX (0x75) |
Route by share name → TID_SHARE / TID_TOOLS / TID_IPC |
TREE_DISCONNECT (0x71) |
Drop TID |
LOGOFF_ANDX (0x74) |
No-op |
NT_CREATE_ANDX (0xA2) |
Open a file under realRoot |
OPEN_ANDX (0x2D) |
Older file-open path |
READ (0x0A) |
Core read |
READ_RAW (0x1A) |
Raw read (no SMB header in reply) |
READ_ANDX (0x2E) |
AndX read |
SEEK (0x12) |
Seek within FID |
CLOSE (0x04) |
Close FID |
TRANSACTION (0x25) |
RAP (NetShareEnum, NetServerEnum2) |
TRANSACTION2 (0x32) |
TRANS2_* subcommands |
ECHO (0x2B) |
Echo back the bytes |
QUERY_INFORMATION (0x08) |
DOS-style attributes/size for a path |
FIND_CLOSE2 (0x34) |
Close a search ID |
CHECK_DIRECTORY (0x10) |
Validate a directory exists |
SEARCH (0x81) |
Legacy 8.3 directory listing (LANMAN dialect) |
TRANS2 subcommands:
| Code | Purpose |
|---|---|
TRANS2_FIND_FIRST2 (0x01) |
Long-filename directory listing |
TRANS2_FIND_NEXT2 (0x02) |
Continue listing |
TRANS2_QUERY_FS_INFO (0x03) |
Volume info |
TRANS2_QUERY_PATH_INFO (0x05) |
File info by path |
The synthetic TOOLS share holds README.TXT and _MAPZ.BAT —
generated in-memory at session construction so the share name in the
NET USE command always matches the user's chosen folder name.
Clipboard transport
The host side (in clipboard.ts) polls clipboard.readText() every
500 ms. When the value differs from the last seen, it encodes to
Windows-1252 + CRLF, clamps to 64 KB, and pushes via
emulator.bus.send("vmware-clipboard-host", bytes). Inbound from the
guest, an add_listener("vmware-clipboard-guest", bytes) decodes from
cp1252 and writes to the host clipboard. A lastHost / lastGuest
shadow prevents bounce-back loops.
The guest side (W95TOOLS.EXE, guest-tools/agent/w95tools.c):
- Joins the Win32 clipboard-viewer chain via
SetClipboardViewer. - On
WM_DRAWCLIPBOARD, opens the clipboard, readsCF_TEXT, pushes to the host via VMware backdoor commandsCMD_SETLEN(8) andCMD_SETDATA(9). - On a 250 ms
WM_TIMER, pollsCMD_GETLEN(6) andCMD_GETDATA(7); if the length is non-negative, allocates and fills a global handle, empties the clipboard, setsCF_TEXT. Ag_ignorecounter prevents the next own-WM_DRAWCLIPBOARDfrom echoing it back. - A separate
TIMER_MAPfires on a 1 ms / 3 s schedule that callsWNetAddConnectionA(\\HOST\HOST, "Z:")until success or 5 attempts. This is the auto-Z: mapping at login.
8.3 Code patterns and conventions
| Pattern | Where | Why |
|---|---|---|
| Class components with bound methods in constructor | <Emulator>, <CardSettings>, <EmulatorInfo>, <CardStart> |
Older code style; predates hooks |
console.log everywhere |
renderer | DevTools is the only log sink |
Synchronous fs reads where async would do |
sync-file-buffer.ts, recover-legacy-disk.ts |
v86 / emulation correctness |
| Monkey-patching v86 internals | smb/index.ts (pump), debug-harness.ts (VGA / IDE prototypes) |
Necessary for working around closed APIs |
process.env reads at top level |
debug-harness.ts, tcp-relay.ts |
Read once at module load; values frozen for the run |
try {} catch {} swallowing |
debug-harness.ts |
The probe must never crash the renderer; bisect runs depend on tick-by-tick survival |
TypeScript any for v86 surfaces |
throughout | v86 has no public TypeScript types |
8.4 Testing strategy
- No unit tests in the source tree per the briefing.
- Integration / boot tests via
tools/probe-boot.shandtools/probe-tcp.sh. These spin up Electron, setWIN95_PROBE=1, watch/tmp/win95-probe.json, time out after a fixed budget, and emit a verdict. - v86 bisect harness at
tools/bisect-v86.sh: reproducibly checks out a v86 commit, builds the wasm, installs intosrc/renderer/lib, runs the boot probe up to 3 times (Win95 has sporadic bluescreens even on known-good v86 builds), and appends a one-line verdict to/tmp/win95-bisect.log. - Standalone SMB exerciser at
src/renderer/smb/test-standalone.ts: runs the SMB server out-of-process so you can drive it with smbclient or a Win95 VM without launching the full Electron app. - Link checker at
tools/check-links.js: validates external links in README.md, with carve-outs for GitHub Releases and user-attachments CDNs that 403 anonymous HEAD requests. npm run lint: prettier formatter + the link checker.npm run tsc: TypeScript no-emit typecheck.npm run qemu/npm run qemu:cdrom: convenience scripts to run the bundledwindows95.imgdirectly under QEMU (qemu-system-i386 -m 128 ... -cpu pentium -netdev user -device ne2k_isa) to confirm a problem is in v86 vs the disk image.
8.5 Update of v86 itself
tools/update-v86.js is the maintainer-blessed path to bring in a new
v86 build:
- Verifies prerequisites:
cargo, thewasm32-unknown-unknownrust target,clang, OpenJDK at/opt/homebrew/opt/openjdk/bin/java, and the pinnedclosure-compiler-v20210601.jar. - Builds
build/v86.wasmandbuild/libv86.jsin the v86 checkout. - Copies
v86.wasmandlibv86.jsintosrc/renderer/lib/, plusseabios.binandvgabios.binintobios/. - Sanity-checks the installed
libv86.jsfor invariants the rest of the app depends on:- No
await import("node:...")(the Electron renderer can't do dynamic node: imports — seevite-build.jsfor the patch path). - The IDE shared-register fix is present (writes go to both master and slave).
- The
module.exports+windowexport shim is intact. - The
tcp-connectionbus event is present (new SMB API path). on_tcp_connectionis still there (old SMB API fallback for pre-Feb-2025 v86 builds).
- No
The fork branch is fork/windows95-base. It carries:
electron-renderer-fs-loader— file loader usesrequire()instead of dynamic import (required by Electron renderer; PR #1540).ide-shared-registers— ATA Command Block register writes hit both master and slave as the spec says (fixes Win95 / 98 boot on disks535 MiB; PR #1541).
When either PR lands upstream, the corresponding patch should be dropped from the fork.
8.6 Potential improvements (open issues, not scoped)
- Two-way file share: today the SMB server has no
WRITEopcode, so files only flow host → guest. A user-requested feature (general theme, not one specific issue). - AppImage build: requested in #114.
- MSIX installer: requested in #221.
- Export HDA as a stand-alone img: requested in #321 — the recovery flow proves the machinery works; missing a UI surface.
- CD-ROM support polish: tracked in #326.
- More resilient state restore on restart: #299.
- Blank MS-DOS screen: #332 — needs a v86 VGA-mode-reset fix.
- Better install instructions for in-guest software: requested in #140 and #282.
8.7 Contributing
- The repo is on
master(per the upstream README and thegetDevBranchSuffixhelper which special-casesmasterandmain). - All TypeScript files; run
npm run lintandnpm run tscbefore pushing. - Don't bump the v86 build inside a feature PR —
tools/update-v86.jsis its own atomic change with sanity checks. - Don't disable the patches in
forge.config.js'signorelist without explaining why; they exist because shipping ignored content bloats every installer. - New env vars go in
src/constants.tsif they're shared, otherwise in the module that consumes them. Document them in the Configuration Reference table above. - The boot probe's verdicts (
FAIL_*enum) are stable — adding a new one is fine; renaming or repurposing one breaks the bisect harness.
9. Integration Points
The app is a closed system on the user's desktop. Its integration points are deliberately narrow:
| Integration | Direction | Purpose | Surface |
|---|---|---|---|
| GitHub Releases | outbound | Auto-update | update-electron-app polls hourly |
| Cloudflare DoH | outbound | DNS resolution for in-guest browsing | https://cloudflare-dns.com/dns-query (intercepted for magic names) |
| Host filesystem | bidirectional (read-only out, in via picker) | Shared folder, mounted media, state files | Folder picker IPC, file pickers in the Machine menu |
| Host clipboard | bidirectional | Text copy/paste | Electron clipboard.readText / writeText |
| Host network | outbound | TCP relay for non-80/139 ports | Node net.connect — every legitimate destination outside the blocked list |
| Apple notarization | build-time | macOS code signing | @electron/osx-sign |
| Azure Trusted Signing | build-time | Windows code signing | signtool.exe invocation |
| GitHub Releases | build-time | Disk image fetch (private repo) | gh release download |
| QEMU (optional) | dev-time | Image sanity check | npm run qemu |
There is no REST API, no GraphQL, no WebSocket server,
no local HTTP listener, no CLI surface beyond what Electron's
electron-forge start/package/make/publish provides.
The renderer's protocol.handle("http") interception means the guest
fetching http://windows95/* is served from the bundle, but that's
not an exposed integration — it's an internal route.