Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

windows95 Administrator & Developer Guide

Generated April 2026 from felixrieseberg/windows95 at d0090e5

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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: false and nodeIntegration: true. This is intentional: v86's CD-ROM ATAPI emulation requires synchronous filesystem reads to avoid a BSY race in Win95's ESDI_506.PDR / CDVSD.VXD driver chain (see src/renderer/sync-file-buffer.ts). It also lets libv86.js use Node's fs and crypto instead of the browser's fetch for asset loading, and lets the SMB server use net and fs directly. 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 the process.env.WIN95_PROBE_OPTS JSON over options before new V86(). That's how the bisect harness can flip acpi:false or disable_jit:true without 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_ID and SIGNTOOL_PATH both set, the build emits a warning and produces unsigned Windows binaries. macOS notarization needs both APPLE_ID and APPLE_ID_PASSWORD; the team ID is hard-coded as LT94ZKYDCJ in forge.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.bin
  • images/windows95.img, images/default-state.bin (these are not in the source repo — they're downloaded at build time from a private release, see tools/download-disk.{sh,ps1})
  • guest-tools/ (the W95TOOLS.EXE source, the precompiled VBMOUSE.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:

  1. Azure Trusted Signing (preferred): when AZURE_TENANT_ID and SIGNTOOL_PATH are both set, the build writes a fresh trusted-signing-metadata.json and points signtool.exe at it via /dlib and /dmdf. Hashes default to SHA-256, the timestamp server is http://timestamp.acs.microsoft.com. The metadata JSON is rebuilt on every build so rotated tenant credentials apply immediately.
  2. Unsigned fallback: a console.warn is 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_TAG at 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.org service 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 by no 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/24 and 192.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.plist grants 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.img could 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, reads CF_TEXT, pushes to the host via VMware backdoor commands CMD_SETLEN(8) and CMD_SETDATA(9).
  • On a 250 ms WM_TIMER, polls CMD_GETLEN(6) and CMD_GETDATA(7); if the length is non-negative, allocates and fills a global handle, empties the clipboard, sets CF_TEXT. A g_ignore counter prevents the next own-WM_DRAWCLIPBOARD from echoing it back.
  • A separate TIMER_MAP fires on a 1 ms / 3 s schedule that calls WNetAddConnectionA(\\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.sh and tools/probe-tcp.sh. These spin up Electron, set WIN95_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 into src/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 bundled windows95.img directly 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:

  1. Verifies prerequisites: cargo, the wasm32-unknown-unknown rust target, clang, OpenJDK at /opt/homebrew/opt/openjdk/bin/java, and the pinned closure-compiler-v20210601.jar.
  2. Builds build/v86.wasm and build/libv86.js in the v86 checkout.
  3. Copies v86.wasm and libv86.js into src/renderer/lib/, plus seabios.bin and vgabios.bin into bios/.
  4. Sanity-checks the installed libv86.js for invariants the rest of the app depends on:
    • No await import("node:...") (the Electron renderer can't do dynamic node: imports — see vite-build.js for the patch path).
    • The IDE shared-register fix is present (writes go to both master and slave).
    • The module.exports + window export shim is intact.
    • The tcp-connection bus event is present (new SMB API path).
    • on_tcp_connection is still there (old SMB API fallback for pre-Feb-2025 v86 builds).

The fork branch is fork/windows95-base. It carries:

  • electron-renderer-fs-loader — file loader uses require() 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 disks

    535 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 WRITE opcode, 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 the getDevBranchSuffix helper which special-cases master and main).
  • All TypeScript files; run npm run lint and npm run tsc before pushing.
  • Don't bump the v86 build inside a feature PR — tools/update-v86.js is its own atomic change with sanity checks.
  • Don't disable the patches in forge.config.js's ignore list without explaining why; they exist because shipping ignored content bloats every installer.
  • New env vars go in src/constants.ts if 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.