# windows95 Administrator & Developer Guide

`windows95` is a desktop Electron application that runs Windows 95
unmodified inside the [v86](https://github.com/copy/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.

```text
┌──────────────────────────────────────────────────────────────────────┐
│  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`).

```text
   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.assign`s 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:

```javascript
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`.

```text
   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:

```javascript
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

```text
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

```text
- [ ] 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:

```text
[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:

```bash
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:

```bash
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:

```bash
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:

```text
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.

```javascript
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

```text
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)

```text
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)

```text
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

```text
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](https://github.com/felixrieseberg/windows95/issues/114).
- **MSIX installer**: requested in
  [#221](https://github.com/felixrieseberg/windows95/issues/221).
- **Export HDA as a stand-alone img**: requested in
  [#321](https://github.com/felixrieseberg/windows95/issues/321) — the
  recovery flow proves the machinery works; missing a UI surface.
- **CD-ROM support polish**: tracked in
  [#326](https://github.com/felixrieseberg/windows95/issues/326).
- **More resilient state restore on restart**:
  [#299](https://github.com/felixrieseberg/windows95/issues/299).
- **Blank MS-DOS screen**:
  [#332](https://github.com/felixrieseberg/windows95/issues/332) —
  needs a v86 VGA-mode-reset fix.
- **Better install instructions for in-guest software**: requested in
  [#140](https://github.com/felixrieseberg/windows95/issues/140) and
  [#282](https://github.com/felixrieseberg/windows95/issues/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.

---

_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. Unofficial; not affiliated with or reviewed by the project._
