146 Commits

Author SHA1 Message Date
Felix Rieseberg
739931aec6 5.0.1 2026-04-13 10:19:19 -07:00
Felix Rieseberg
731a9b2369 Wait for v86 emulator-loaded before restoring state (#367)
The 500ms setTimeout raced against v86's async wasm/BIOS/hda load. When
loading exceeded 500ms (cold cache, Gatekeeper scan on packaged apps),
restore_state ran on an uninitialized CPU, threw, was silently caught,
and the VM cold-booted from default-state.bin instead of resuming the
user's session.
2026-04-13 10:18:41 -07:00
Felix Rieseberg
35c82c5d09 check-links: skip /releases/download/ URLs (chicken-and-egg with release builds) 2026-04-12 21:25:17 -07:00
Felix Rieseberg
60ee631575 Fetch disk image from private GitHub release; harden download scripts
OneDrive 1drv.ms links no longer serve raw bytes to headless clients after the SPO migration. The disk image now lives as a release asset on a private repo and is fetched via 'gh release download' using DISK_REPO + DISK_TAG vars and an IMAGES_REPO_TOKEN secret. Scripts fail fast on missing env, bad zip, or missing windows95.img.
2026-04-12 21:21:58 -07:00
Felix Rieseberg
dddaca9120 README: update download links for v5.0.0
Drops the Windows ARM64 row (not built in v5) and adds Linux ARM64/ARM rpm+deb links.
2026-04-12 18:38:28 -07:00
Felix Rieseberg
d383958f2b Remove committed guest-tools .EXE; drop now-unneeded windows-sign patch
VBMOUSE.EXE is the DOS TSR build artifact (only vbmouse.drv is referenced by OEMSETUP.INF). With no .exe under guest-tools/ the @electron/windows-sign exclude patch is no longer needed. .gitignore now covers all guest-tools/**/*.exe.
2026-04-12 17:53:18 -07:00
Felix Rieseberg
a1637b1de1 Add SMB READ_RAW support for faster bulk transfers (#366)
Win95's redirector was falling back to ~2.8KB core READs (no raw mode
advertised), so a 200KB copy took ~360 round-trips through the emulated
NIC/TCP stack (~10s). With READ_RAW it pulls up to 64KB per round-trip.

- server.ts: readRaw() handler (raw bytes only, 0-byte frame on error);
  negotiate now sets MaxRawSize=65535 + CAP_RAW_MODE (NT) and RawMode
  bit 0 (LM); per-packet hex capture gated on WIN95_SMB_CAPTURE
- smb.ts: CMD_READ_RAW constant
- test-standalone.ts: READ_RAW happy-path + bad-fid tests (57 pass)
2026-04-12 17:52:17 -07:00
Felix Rieseberg
fff371073d Patch @electron/windows-sign to skip guest-tools when signing
VBMOUSE.EXE and friends are 16-bit DOS/Win9x binaries served to the guest over SMB; signtool refuses them with 'file format cannot be signed'. The patch skips any path under guest-tools/ during file collection.
2026-04-12 17:49:01 -07:00
Felix Rieseberg
8153e91706 CI: wire up Azure Trusted Signing for Windows builds
Installs the Microsoft.Trusted.Signing.Client dlib via NuGet, locates signtool.exe from the Windows SDK, and passes AZURE_* secrets to the Make step. Removes the unused .pfx-based signing step.
2026-04-12 17:38:12 -07:00
Felix Rieseberg
fb701041c2 Use cwd-relative path in @electron/packager resedit patch
The patch hardcoded a local Windows path, breaking Windows CI builds. Now resolves tools/resedit.js relative to process.cwd() and uses process.execPath instead of 'node'. Also drops the deb maker diagnostic step now that Linux builds pass.
2026-04-12 16:56:20 -07:00
Felix Rieseberg
e1c30f701c Remove hardcoded TEMP path; expand deb maker diagnostic
The TEMP override in forge.config.js broke Windows builds on CI runners (mkdtemp ENOENT). The expanded diagnostic exercises require() from maker-base's own resolution context to surface the real Linux failure.
2026-04-12 14:23:55 -07:00
Felix Rieseberg
bbcc4f32cd Align all @electron-forge/* packages to 7.11.1
cli and maker-deb were pinned at 7.8.3 while other makers resolved to 7.11.1, leaving two copies of maker-base in the tree. The mismatch breaks maker-deb's isSupportedOnCurrentPlatform() check on Linux runners.
2026-04-12 14:16:31 -07:00
Felix Rieseberg
9c99b6cb65 CI: disable fail-fast and add deb maker diagnostic 2026-04-12 14:12:48 -07:00
Felix Rieseberg
2e01152d82 Regenerate package-lock.json to include all @rollup platform binaries
The lockfile only contained @rollup/rollup-darwin-arm64 because it was last regenerated on macOS, which trips npm's optional-dependency bug (npm/cli#4828) and breaks 'npm ci' on Linux/Windows runners.
2026-04-12 13:26:52 -07:00
Felix Rieseberg
63f02842bc Bump to v5.0.0 (STATE_VERSION 5) for new disk image 2026-04-12 13:13:23 -07:00
Felix Rieseberg
c847467de6 Recover user files from orphaned state-vN.bin after a version bump (#365)
* Detect orphaned state-vN.bin and offer file recovery to a host folder

When STATE_VERSION is bumped, users previously lost their C:\ silently.
The Welcome card now detects an older state file (v4+), explains what
happened, and offers a one-click recovery: spin up a throwaway v86
(no boot), restore the legacy state to populate the hda dirty-block
overlay, walk the FAT32 tree reading overlay-if-dirty-else-base, and
copy any file the guest ever wrote out to ~/Downloads/Recovered C Drive.

Directories are created lazily so empty branches never appear; success
and failure render in the panel (no native dialogs). The geometry
constraint that keeps overlay+new-base valid is documented next to
STATE_VERSION. Also makes the dev-mode CDP port overridable via
WIN95_DEBUG_PORT so worktree instances don't fight over 9222.

* prettier
2026-04-12 12:38:28 -07:00
Felix Rieseberg
fa0e4c691e Add tools/pack-disk.sh to build the images zip CI downloads via DISK_URL 2026-04-12 09:54:21 -07:00
Felix Rieseberg
6c964f99e9 Refresh home.htm: drop 'since the last version', bump Last Updated to 2026 2026-04-12 09:25:58 -07:00
Felix Rieseberg
1f116d607b Update shared-folder docs/UI: Z: is auto-mounted, drop \\HOST instructions 2026-04-12 09:24:32 -07:00
Felix Rieseberg
d38355ff16 Auto-map \\HOST to Z: from the W95TOOLS guest agent (#364)
* Auto-map \\HOST to Z: from W95TOOLS at login

W95TOOLS.EXE now calls WNetAddConnectionA("\\\\HOST\\HOST", NULL, "Z:")
on a short retry timer (5 tries, 3s apart) so the shared folder shows up
as a drive without a trip through Start -> Run. MPR.DLL is LoadLibrary'd
so the EXE keeps its USER32/KERNEL32-only import table and still launches
if MPR is somehow absent. Skipped if Z: is already taken; gives up
silently if no share is configured.

Works for any user folder because the SMB server's tree-connect already
routes every share name other than TOOLS/IPC$ to the user share; added a
comment in server.ts pointing at the dependency.

Verified by cold-booting the image with the new vs. old binary in
StartUp: new -> tree connect to \\HOST\\HOST within ~5s of desktop and
z:\ opens in Explorer; old -> no SMB traffic after 55s at desktop.

* Drop rebuilt W95TOOLS.EXE from the diff

Binary will be rebuilt and baked into the image alongside the next
default-state re-bake; keep this PR source-only.

* Stop tracking guest-tools/agent/W95TOOLS.EXE

It's a build output of `make -C guest-tools/agent` and CI doesn't
consume it (the disk image is baked out-of-band), so there's no reason
to carry the binary in git.
2026-04-12 09:16:54 -07:00
Felix Rieseberg
b74e6c7b0a Fix guest TCP recv() stalling when v86 NE2000 TX ring wraps (#363)
* Fix guest TCP recv() stalling under concurrent traffic

v86's fake_network stores TCPConnection routing fields (hsrc/hdest/
psrc/pdest) as zero-copy subarrays of the SYN frame, which is itself a
view into the NE2000 TX ring. Win95's driver uses a 12-slot ring; once
it wraps (any concurrent SMB/NBNS/ping while waiting for an upstream
reply), pump() emits segments with whatever IP now occupies that slot,
the guest RSTs them, and recv() blocks forever.

- libv86.js: copy the four address arrays at TCPConnection construction
  (matches felixrieseberg/v86@dd13099c on fake-network-copy-tcp-addrs,
  now merged into windows95-base)
- tools/probe-tcp.sh + net/tcp-trace.ts + tcp-relay.ts test stub +
  debug-harness WIN95_PROBE_RUN2: end-to-end regression harness
  (boot → ping -t → telnet → async write after ring wrap → assert ACK).
  All env-gated, no production-path change.
- docs/v86-patches.md: tracker for all fork patches + upstream PR state
- update-v86 SKILL.md: cross-link and new fork-branch entry

* Drop checked-in upstream PR description

Belongs on the GitHub PR, not in the repo.
2026-04-12 08:03:03 -07:00
Felix Rieseberg
3c63139fae Await restoreState before run(); update home.htm contact to Twitter
restoreState() was fire-and-forget so emulator.run() started cold-booting
while the snapshot was still being read and applied over live RAM/regs.
2026-04-11 20:29:52 -07:00
Felix Rieseberg
bc76e9c79a Shared text clipboard via VMware backdoor + W95TOOLS.EXE guest agent (#361)
Three layers:

  v86 — src/vmware.js gains the legacy text-clipboard backdoor commands
  (GETSELLENGTH/GETNEXTPIECE/SETSELLENGTH/SETNEXTPIECE, 6–9). The host
  stages bytes via the vmware-clipboard-host bus event; the guest pushes
  via 8/9 and the device emits vmware-clipboard-guest when the buffer
  fills. Same wire protocol as open-vm-tools' pre-RPC copy/paste.
  Committed on the windows95-base fork branch; libv86.js rebuilt here.

  renderer — src/renderer/clipboard.ts polls Electron's clipboard (no
  change event exists), translates host UTF-8/LF ↔ guest CP-1252/CRLF,
  and bounces bytes through the two bus events. Echo-suppressed so a
  value we just wrote does not come back as a change.

  guest — guest-tools/agent/W95TOOLS.EXE is a 22 KB hidden-window agent
  that joins the Win32 clipboard-viewer chain (push-on-copy) and polls
  the backdoor on a 250 ms timer (pull-from-host). Win9x runs ring-3
  with the I/O bitmap wide open, so a plain IN EAX,DX from a user
  process reaches the port — no driver needed. Named for growth: time
  sync and host-initiated shutdown will live here too. Built with Open
  Watcom v2 inside Docker (Makefile + Dockerfile alongside the source);
  subsystem 4.0, no msvcrt, runs on Win95 RTM.

Install: copy \\HOST\TOOLS\agent\W95TOOLS.EXE into the guest and drop a
shortcut in StartUp. Text only, 64 KB cap.
2026-04-11 20:17:06 -07:00
Felix Rieseberg
6e73df11ae Enable CD-ROM via a synchronous fs-backed buffer (#362)
v86's async loaders leave the ATAPI drive in BSY across an event-loop
turn after a READ(10) CDB. Win95's ESDI_506 reads status twice, sees
BSY both times, and issues DEVICE RESET ~165 instructions later, which
cancels the in-flight read — the drive enumerates but D: never mounts.

Serve the ISO through a small fs.readSync-backed buffer so the data is
available before the next emulated instruction runs, and re-enable the
CD-ROM settings tab.

Also: WIN95_PROBE_CDROM / WIN95_PROBE_CDTRACE harness hooks, and pump
one screen-adapter frame before screenshotting so probe captures work
when the Electron window is occluded.
2026-04-11 19:31:57 -07:00
Felix Rieseberg
5da7f94c5a Make "Start without state" always enabled (#360)
The menu item now works whether the machine is running or not. bootFromScratch() awaits stopEmulator() first (a no-op when already stopped), so invoking it mid-session saves the current state, tears down the running VM, and boots a fresh one.
2026-04-11 18:39:45 -07:00
Felix Rieseberg
766497bd5d v86: defer V86-mode VBE disable; add DOS-box probe scenario (#359)
Rebuilds libv86.js from felixrieseberg/v86@windows95-base, which now
carries vga-defer-vbe-disable-v86: when a windowed DOS VM's vgabios
writes dispi[4]=0, Win9x's VDD passes that through (it doesn't know
about ports 1CE/1CF) while virtualising the rest of the mode-set, so
v86 used to drop out of LFB rendering with the legacy registers still
holding SVGA values and the screen turned to planar garbage. The fix
defers the disable until a legacy attribute-mode write actually
reaches the hardware.

debug-harness: WIN95_PROBE_DOSBOX=1 opens command from Run, types
dir, optionally Alt+Enters (WIN95_PROBE_DOSBOX_ALTENTER=1).
WIN95_PROBE_VGATRACE=1 wraps the VGA io.ports[] entries (not the
VGAScreen methods, which are captured by-value at registration) and
dumps [port, op, value, eip+VM/PE/CPL] tuples to
/tmp/win95-vgatrace.json — that EIP/mode column is what pinned the
leak on V86-mode vgabios at C000:2C8x.
2026-04-11 17:32:07 -07:00
Felix Rieseberg
85c44513cb Raw TCP relay for non-port-80 guest egress + DoH DNS with magic-name shim (#358)
The fetch network adapter only intercepts port 80 (parsed as HTTP and
replayed via fetch). Everything else was RST'd. This adds a second
tcp-connection bus listener that bridges any other destination port
straight to a Node net.Socket in the renderer — no extra process, no
WebSocket relay, no new dependencies.

- net/tcp-relay.ts: hook v86 tcp-connection, accept SYN synchronously,
  open a real socket to conn.psrc:conn.sport, buffer until connect,
  pipe both directions, tear down on either side closing. Loopback,
  link-local and multicast are refused; the rest of RFC1918 is left
  reachable so the guest can still talk to LAN devices.
- net/dns-shim.ts: dns_method is now "doh" so the guest gets real IPs
  for the relay to dial, but Cloudflare can't answer single-label names
  like "windows95" or the NNN.external localhost magic. The shim wraps
  global fetch, spots /dns-query POSTs for those names, and answers
  with the same 192.168.87.1 placeholder the static resolver used to
  hand out — the port-80 fetch path then takes over via the Host header
  exactly as before.
- emulator.tsx: wire both in next to the SMB hook; set dns_method.
- debug-harness.ts: WIN95_PROBE_RUN/_RUN_AFTER/_RUN_WAIT to type an
  arbitrary command into Start→Run (used to e2e-test the relay with
  telnet against a host echo server).
2026-04-11 15:36:20 -07:00
Felix Rieseberg
ccd2b28169 Add "Boot from scratch" to the Machine menu (#357)
* Add "Boot from scratch" to the Machine menu

Wires a new MACHINE_BOOT_FROM_SCRATCH IPC command from the app menu to the existing Emulator.bootFromScratch() handler, so users can start a fresh boot without going through the settings card. Enabled only while the machine is stopped, matching Start.

* Rename menu item to "Start without state"

* Move "Send key" items below power management in Machine menu
2026-04-11 15:27:22 -07:00
Felix Rieseberg
27b9e0eb7a Seamless mouse (VMware backdoor + VBADOS) and fs-backed TOOLS share (#355)
* Seamless mouse via VMware backdoor + fs-backed TOOLS share

v86 (rebuilt libv86.js from fork branch vmware-mouse): new VMwareMouse
device on port 0x5658 implementing GETVERSION/ABSPOINTER_* fed by the
existing mouse-absolute bus event. Move-only packets are coalesced so
the guest cursor never falls more than one frame behind. Emits
vmware-absolute-mouse on the bus when the guest driver toggles mode.

Renderer: listens for that event, keeps the v86 mouse enabled without
pointer lock, drops the startup auto-capture, and hides the host cursor
over the canvas (.seamless-mouse) while the driver is active. Falls back
to click-to-capture when no driver is present.

SMB: TOOLS share is now backed by the bundled guest-tools/ directory
(subdirectories work) with the synthetic README.TXT/_MAPZ.BAT overlaid
at the root. resolve() routes by tid; SEARCH and FIND_FIRST2 share a
single listForSearch helper.

guest-tools/mouse-driver/: VBMOUSE.EXE + VBMOUSE.DRV from VBADOS
(Javier S. Pedro, GPLv2). Load the TSR from AUTOEXEC.BAT and set
mouse.drv=vbmouse.drv in SYSTEM.INI to enable seamless mouse.

Also: tsconfig rootDir "." for TS 6.0 (preserves dist/src/ layout).

* docs: windows95-base now includes vmware-abspointer
2026-04-11 14:15:20 -07:00
Felix Rieseberg
1dbb853fe6 SMB: filename safety + hidden attrs; drop http://my-computer host browser (#354)
SMB share:
- Mark dotfiles HIDDEN and host junk (.DS_Store, Thumbs.db, ...) HIDDEN+SYSTEM
  so Explorer hides them by default but View > Show all files still works.
- Guard DOS device names (CON/PRN/AUX/NUL/CLOCK$/COM1-9/LPT1-9) in both the
  long name and the generated 8.3 name; covers nul.tar.gz and 'con .txt' too.
- Replace trailing dot/space runs with '_' so names don't alias their stripped
  form; block DEL and C1 control bytes.
- Fix duplicate listing of sanitized names (sfnMap was mutated mid-iteration).
- Thread attrs through OPEN/QUERY/FIND replies via DirEntry.attr / hostAttrs().
- New [5d] section in test-standalone.ts; 55 tests pass.

http file server:
- Remove the http://my-computer/ host-filesystem browser now that SMB covers
  folder sharing. fileserver.ts now serves only static/www at http://windows95/
  with a sep-suffixed traversal guard.
- Delete page-directory-listing.ts, page-error.ts, encoding.ts, hide-files.ts
  and the three isFileServer* settings keys (and unused SettingsManager.delete).
- Point static/www/apps.htm at the SMB share instead.
2026-04-11 14:15:20 -07:00
Felix Rieseberg
9b217731f5 Replace Parcel 1 with Vite (#353)
Swap the unmaintained parcel-bundler@1.x for Vite's build API, called
from the same generateAssets hook. Output layout is unchanged
(dist/src/main/main.js + dist/static/index.html + dist/renderer.{js,css})
so no runtime path changes — __dirname-based asset lookup, loadFile, and
packagerConfig.ignore all keep working.

Renderer is built in lib/CJS mode with Node builtins + electron
externalized; a one-line banner aliases exports=module.exports since the
Electron <script> context provides module/require but not a bare exports
global. CSS (98.css + root.css) is now imported from app.tsx so Vite
emits a single renderer.css with fonts inlined.

Drops parcel-bundler and rimraf (vite-build clears dist/ itself).
~800ms full build.
2026-04-11 14:15:19 -07:00
Felix Rieseberg
148f8e4874 SMB: long filenames, basename-derived share name, synthetic TOOLS share (#352)
Negotiate NT LM 0.12 (17-word response, Capabilities=0) so Win95 switches
from CMD_SEARCH (8.3-only) to TRANS2/FIND_FIRST2. Implement info level
0x104 (FILE_BOTH_DIRECTORY_INFO) with the win9x quirks Samba carries:
honor SearchCount/MaxDataCount (VREDIR drops the session if exceeded),
4-byte-align entries and the trans2 reply param/data blocks, dir
EndOfFile=0, FileName null-terminated with the null counted in
FileNameLength, and ShortNameLength=0 (a UCS-2 ShortName in an OEM
session GPFs shell32 on the single-directory probe Explorer does when
entering a subfolder). Add TRANS2_QUERY_FS_INFO (0x105/1/2), SMB_COM_SEEK
and core SMB_COM_READ, which the redirector falls back to with no
CAP_LARGE_READX. Sanitize >0xFF / Windows-reserved chars in display names
so emoji folder names don't truncate to '<' and wedge Explorer.

The user share is now named after path.basename of the mounted folder
(LANMAN-safe, ≤12 chars, with TOOLS/IPC$ collision avoided). A second
purely-synthetic TOOLS share holds _MAPZ.BAT and README.TXT so they
don't clutter the user's directory; treeConnect routes by share name to
a TID and every path-resolving handler branches on it so TOOLS never
touches the host fs.

48 protocol tests; verified end-to-end in the emulator (browse \\HOST,
open both shares, list Downloads with 85+ mixed-name entries, navigate
subfolders, open files in Notepad).
2026-04-11 12:26:54 -07:00
Felix Rieseberg
43c025929b Show git branch in window title during development (#351)
When running via yarn start on a branch other than master/main, append
the branch name to the window title (e.g. "windows95 (my-feature)").
No-op in packaged builds.
2026-04-11 11:06:08 -07:00
Felix Rieseberg
3b62e1c9b5 Replace Less with native CSS (#350)
The stylesheets only used Less for variables and nesting, both of which
are now native CSS features supported by Electron 41's bundled Chromium.

- src/less/ -> src/css/, all .less files renamed to .css
- @win-* variables -> :root custom properties + var()
- // comments -> /* */
- Dropped unused @win-silver
- Removed less devDependency and the dead 'less' npm script
  (pointed at a non-existent tools/lessc.js)
2026-04-11 10:45:04 -07:00
Felix Rieseberg
e9ddfab65d Stabilize VM info bar width, add sparklines and Interface settings (#349)
* Stabilize VM info bar width and add sparkline mode

* Add Interface settings tab and switch info bar to N.N unit format
2026-04-11 09:45:08 -07:00
Felix Rieseberg
89c0a8575d docs: update-v86.js rewrite + SMB/v86/testing knowledge (#348)
* Rewrite update-v86.js for the current build pipeline

Both v86 fixes we've been carrying are now real branches on the fork
(PR #1540 electron-renderer-fs-loader, PR #1541 ide-shared-registers)
combined as felixrieseberg/v86:windows95-base. update-v86.js no longer
needs to patch sources at build time — it just builds whatever's checked
out and copies the result.

Gone: the fallback-to-copy.sh path, the skew-day check, the structural
regex patches for load_file/exportSymbol/fetch-bind, the phantom-slave
guard (both are in the branch), the --js-only flag. If you don't have
cargo/clang/java/closure, the script fails loudly — no silent fallbacks.

Added: sanity checks against the installed libv86.js for the invariants
our SMB integration and parcel-build shim depend on, so if upstream
changes something load-bearing we see it as a WARN at update time
instead of a runtime failure.

Tested end-to-end: 5/5 sanity checks, fresh boot SUCCESS in 32s.

* docs and skills: capture SMB/v86/testing knowledge from the session

- docs/smb-share.md: user-facing SMB integration overview (how to mount in
  Win95, what's implemented, what's not). Points at the protocol-level
  README inside src/renderer/smb/ for wire-level gotchas.
- .claude/skills/probe-win95: how to boot and test the VM without a human.
  Env vars, file locations, failure modes, the XT scancode keyboard trick,
  bisect rules of thumb.
- .claude/skills/update-v86: how to pull upstream v86 changes, what the
  five sanity-check WARNs mean, how to retire the fork branches when the
  PRs merge upstream.

.gitignore narrowed to exclude only the runtime dirs (scheduled_tasks.lock,
worktrees) instead of the whole .claude/ tree, so skills can be committed.
2026-04-11 09:33:51 -07:00
Felix Rieseberg
2ef06eb412 Merge pull request #346 from felixrieseberg/claude/crazy-kilby
Modernize GitHub Actions workflow
2026-04-11 09:32:54 -07:00
Felix Rieseberg
3eab26fed1 check-links: allow 403 from github user-attachments CDN 2026-04-11 09:31:38 -07:00
Felix Rieseberg
1bef3cce62 Switch CI from yarn to npm (repo uses package-lock.json) 2026-04-11 09:04:03 -07:00
Felix Rieseberg
17e0182ecb Merge pull request #347 from felixrieseberg/claude/sharp-shirley
Redesign launcher UI with 98.css
2026-04-11 09:03:14 -07:00
Felix Rieseberg
4ed96deecc Restore CD-ROM settings tab behind a feature flag
The tab, file picker, and prop wiring are all back and styled to match
the new Properties sheet, gated on CDROM_ENABLED=false until v86's IDE
CD path works again.
2026-04-11 09:01:29 -07:00
Felix Rieseberg
80505384c6 Remove unused 95css vendor bundle 2026-04-11 09:00:34 -07:00
Felix Rieseberg
a0ee5a2f10 Drop dead CD-ROM handler and tighten navigate typing 2026-04-11 08:51:08 -07:00
Felix Rieseberg
74fc2d291e Redesign launcher UI with 98.css
Replace the sparse start/settings cards with a Win95-styled launcher:

- Start screen is now a 'Welcome to Windows 95' dialog with a gradient
  side stripe, a rotating tip box, and a button column.
- Settings is a tabbed Properties sheet (Floppy / Network / State) with
  group boxes, sunken read-only path fields, and OK/Cancel.
- Vendored 98.css and its Pixelated MS Sans Serif fonts; dropped the
  old 95css-based .btn/.card/.nav classes.
- Removed the bottom taskbar nav (start-menu.tsx) — navigation now goes
  through the dialog buttons so the launcher isn't mistaken for the
  running OS.
2026-04-11 08:48:11 -07:00
Felix Rieseberg
20f7f8c70e Modernize GitHub Actions workflow
- Bump actions to latest releases and pin to commit SHAs
- Replace deprecated ::set-output + actions/cache with setup-node built-in yarn cache
- Bump Node 18 -> 20
- Use --frozen-lockfile in build job install
2026-04-11 08:17:18 -07:00
Felix Rieseberg
55c4fbb27e Update to v86 HEAD with IDE shared-register fix
Patches src/ide.js before building: restores the dual master+slave writes
for ATA Command Block registers (Features, Sector Count, LBA Low/Mid/High)
that 1b90d2e7 changed to current_interface-only. Those registers are
channel-shared per ATA spec; Win95's ESDI_506.PDR writes them then switches
drive-select expecting the values to persist. Found via JS-only bisect.

Boots fresh in ~32s with the same sporadic-bluescreen rate as the prod build.
The new build has the tcp-connection bus event so SMB uses the clean path
instead of the connection-theft hack (guard added for the old-API monkeypatch
to skip if the bus handler already accepted).

tools/update-v86.js applies the source patch automatically. v86 checkout
left clean (patch stashed).
2026-04-11 08:01:46 -07:00
Felix Rieseberg
c243ebbbcc Merge pull request #345 from felixrieseberg/claude/kind-hermann
Improve VM info bar: live CPU/disk/net throughput + hover-to-reveal
2026-04-11 07:57:17 -07:00
Felix Rieseberg
85e25ed3ab Improve VM info bar: CPU M/s, disk & net throughput, hover-to-reveal
- Show CPU as M/s (millions of instructions/sec) instead of raw count
- Replace Disk Idle/Read with actual R/W throughput (B/K/M per sec)
- Add Net ↓/↑ throughput from eth-receive-end / eth-transmit-end
- Always mount the bar; when hidden it slides off-screen and reveals on
  hover near the top edge (Pin/Hide toggle)
- Center via translateX(-50%) so the wider bar stays centered
2026-04-11 07:51:39 -07:00
Felix Rieseberg
e16afcb748 Merge pull request #344 from felixrieseberg/claude/keen-einstein
Only forward mouse input to the VM while pointer lock is held
2026-04-11 07:44:22 -07:00
Felix Rieseberg
585fbc9624 Only forward mouse input to the VM while pointer lock is held 2026-04-11 07:34:16 -07:00
Felix Rieseberg
45f5a136b2 Add SMB1 server and host folder share
Windows 95 can now mount a host folder as a network drive at \\HOST\HOST.
Read-only, ~1500 lines, zero deps. Defaults to ~/Downloads, configurable in
Settings.

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

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

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

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

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

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

Also adds tools/update-v86.js to pull new builds from copy.sh, and exposes
the renderer DevTools protocol on localhost:9222 in dev.
2026-04-10 20:53:44 -07:00
Felix Rieseberg
00943ae4da Update all dependencies
React 19, Electron 41, TypeScript 6, electron-forge 7.8, plus everything
else to latest. Migrated to React 19 createRoot API, updated for Electron 41
session/window type changes, and adjusted tsconfig for TS 6 deprecations.
Regenerated @electron/packager patch for 18.4.4.
2026-04-10 20:34:28 -07:00
Felix Rieseberg
a6d57c6538 Update resedit 2025-05-02 09:03:39 -07:00
Felix Rieseberg
35f7c3362d Update Readme 2025-02-21 08:27:47 -08:00
Felix Rieseberg
94021edb61 Fix missing icon 2025-02-20 21:42:49 -08:00
Felix Rieseberg
6f2025ffc0 Improve reset 2025-02-20 21:19:55 -08:00
Felix Rieseberg
3a7b37fff0 Fix a few smaller build issues 2025-02-20 17:27:00 -08:00
Felix Rieseberg
16eb63e13b Add bg image 2025-02-20 17:16:51 -08:00
Felix Rieseberg
54fe721f4f Better gitignore 2025-02-20 17:16:31 -08:00
Felix Rieseberg
6dee2f45a2 Hide text screen when paused 2025-02-20 14:31:10 -08:00
Felix Rieseberg
e7e047b0a0 Deal with resedit segfaults 2025-02-20 10:54:02 -08:00
Felix Rieseberg
5a334abb13 Use dotenv 2025-02-20 07:57:57 -08:00
Felix Rieseberg
aacfae7ada Update forge config 2025-02-19 13:23:21 -08:00
Felix Rieseberg
9b87b77570 Update links 2025-02-18 23:03:37 -08:00
Felix Rieseberg
e6a0d931af Move out helper images 2025-02-18 23:03:08 -08:00
Felix Rieseberg
973580d60b Ignore more files 2025-02-18 23:01:44 -08:00
Felix Rieseberg
8fcf5eaed3 Remove old forge settings 2025-02-18 22:54:30 -08:00
Felix Rieseberg
e15d918fb3 Update version number to 4.0.0 2025-02-18 22:50:32 -08:00
Felix Rieseberg
b442c6db08 Update UI 2025-02-18 22:49:52 -08:00
Felix Rieseberg
5c946bbca4 Now with working network 2025-02-18 22:39:47 -08:00
Felix Rieseberg
c9e45a9f39 Update qemu docs 2025-02-18 22:39:35 -08:00
Felix Rieseberg
bc42ce3231 Remove fs-extra 2025-02-15 09:45:22 -08:00
Felix Rieseberg
d91e72ccc5 Upgrade TypeScript 2025-02-15 09:38:41 -08:00
Felix Rieseberg
bd40f00f8d Remove node-abi 2025-02-15 09:38:04 -08:00
Felix Rieseberg
1cbfca7451 Upgrade rimraf, node-abi, electron-squirrel-startup 2025-02-15 09:37:38 -08:00
Felix Rieseberg
7710c4b7af Upgrade prettier 2025-02-15 09:36:37 -08:00
Felix Rieseberg
4cce1f0740 Upgrade electron & electron-forge 2025-02-15 09:35:50 -08:00
Felix Rieseberg
f8ae78f247 Update v86 2025-02-15 09:34:41 -08:00
Felix Rieseberg
62f8eb2696 v3.1.2 2023-07-14 12:40:08 -07:00
Felix Rieseberg
da4b0dd728 Update v86 2023-07-14 12:39:57 -07:00
Felix Rieseberg
6cc05fa042 Upgrade dependencies 2023-07-14 11:52:30 -07:00
Felix Rieseberg
dda3707a23 Merge pull request #292 from fjbecerra/fix_docker_doc
fix docker mac doc
2023-03-15 11:10:24 -07:00
Francis Becerra
a4bcd7fb61 fix docker mac doc 2023-02-11 22:08:41 +00:00
Felix Rieseberg
17a8139346 Update links 2022-10-17 09:48:30 -07:00
Felix Rieseberg
489c7312d0 v3.1.1 2022-10-08 13:12:13 +02:00
Felix Rieseberg
c3537ae330 Handle promises 2022-10-08 13:12:08 +02:00
Felix Rieseberg
c483871df9 v3.1.0 2022-10-04 17:20:52 +02:00
Felix Rieseberg
e66cbd70db Update dependencies (Electron 18 > 21, Forge 63 > 66) 2022-10-04 17:07:41 +02:00
Felix Rieseberg
19a1bbc002 Update v86 to 5d02960 2022-10-04 10:09:26 +02:00
Felix Rieseberg
ef57e3a7fe Update links 2022-06-26 15:23:34 -07:00
Felix Rieseberg
7eae250c2a Fix OSFMount link 2022-06-26 14:05:49 -07:00
Felix Rieseberg
33db389d59 Start with a larger zoom 2022-06-26 13:01:05 -07:00
Felix Rieseberg
61f3269a45 Add loading gif 2022-06-26 11:55:09 -07:00
Felix Rieseberg
e5d897c663 Change how we build, hoping it'll fix macOS 2022-06-25 22:29:26 -07:00
Felix Rieseberg
a7ae665adc Build things step by step 2022-05-06 18:39:22 -07:00
Felix Rieseberg
bea2267f42 Fix Linux arch 2022-05-05 10:25:14 -07:00
Felix Rieseberg
a55d08fafc Update dist 2022-05-05 09:00:01 -07:00
Felix Rieseberg
97702cb01b Update Node in GitHub Workflows 2022-05-04 15:11:00 -07:00
Felix Rieseberg
12160a1ac4 3.0.0 2022-05-04 14:58:37 -07:00
Felix Rieseberg
3dd50db272 Cleanup 2022-05-04 14:51:49 -07:00
Felix Rieseberg
7b92d33584 Update v86, patch to use fs.readFile 2022-04-26 12:20:59 -07:00
Felix Rieseberg
24a1c30502 Update dependencies 2022-04-21 15:07:15 -07:00
Felix Rieseberg
7ce0863ae8 Merge pull request #228 from barfin/master
fixing the armv7hf linux rpm link in readme
2022-04-21 14:51:34 -07:00
Felix Rieseberg
90ec67fb16 Merge pull request #242 from hmsjy2017/patch-1
Correct the wrong links
2022-04-21 14:51:04 -07:00
Tony
9cab8e46f6 Correct the wrong links
I found that several quoted links were wrong, which caused the downloaded files to not meet expectations.
2021-08-24 01:30:04 +08:00
barfin
03b39d76b5 Update README.md 2021-04-27 13:21:04 +04:30
Felix Rieseberg
d8b4a139ac Merge pull request #203 from maacarbo/patch-1
macOS download links inverted for Intel/M1
2021-02-07 12:52:49 -08:00
Felix Rieseberg
9f4771bf26 chore: Update Readme 2021-01-31 09:24:51 -08:00
Maarten
552b97eec5 macOS download links inverted for Intel/M1 2021-01-07 10:31:39 +01:00
Felix Rieseberg
6c0f00170c Update README.md 2021-01-05 10:53:35 -08:00
Felix Rieseberg
e3b9a839f5 build: Update Node version 2021-01-04 10:31:33 -08:00
Felix Rieseberg
238b07b7dd build: Add a check-links tool 2021-01-04 09:54:20 -08:00
Felix Rieseberg
9dc1e422ff chore: One more readme update 2021-01-03 19:30:55 -08:00
Felix Rieseberg
ebe7427385 chore: Include images 2021-01-03 19:27:11 -08:00
Felix Rieseberg
3e3bee2062 chore: Pretty readme 2021-01-03 19:26:06 -08:00
Felix Rieseberg
c93b6878a9 chore: Update the readme again 2021-01-03 19:18:38 -08:00
Felix Rieseberg
d2e26ef5d1 chore: Update readme 2021-01-03 18:52:39 -08:00
Felix Rieseberg
c41befae64 build: Update dependencies 2021-01-03 12:58:37 -08:00
Felix Rieseberg
8b720750db build: Try to build for all archs 2021-01-02 15:38:15 -08:00
Felix Rieseberg
ee317ec5aa v2.3.0 2020-12-13 16:37:37 -08:00
Felix Rieseberg
d7c657e671 build: Build on ARM 2020-12-13 16:32:44 -08:00
Felix Rieseberg
7a8a54c76b build: Update Electron & React 2020-12-13 16:14:18 -08:00
Felix Rieseberg
c29f98b6bc Merge pull request #183 from felixrieseberg/dependabot/npm_and_yarn/electron-9.3.1
build(deps-dev): bump electron from 9.1.2 to 9.3.1
2020-11-08 09:48:10 -08:00
dependabot[bot]
8d1847a8d1 build(deps-dev): bump electron from 9.1.2 to 9.3.1
Bumps [electron](https://github.com/electron/electron) from 9.1.2 to 9.3.1.
- [Release notes](https://github.com/electron/electron/releases)
- [Changelog](https://github.com/electron/electron/blob/master/docs/breaking-changes.md)
- [Commits](https://github.com/electron/electron/compare/v9.1.2...v9.3.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-10-06 19:02:36 +00:00
Felix Rieseberg
194f4fabaf Merge pull request #169 from PeterVatistas/patch-1
Fix amd64.deb download link
2020-08-04 16:00:56 -07:00
Peter Vatistas
3f4a5e97fa Revised all download links
All 7 download links were fixed using the latest release.
2020-08-04 10:01:46 -07:00
Peter Vatistas
3eb789d055 Fix amd64.deb download link 2020-08-03 19:33:08 -07:00
Felix Rieseberg
8a8f064864 docs Update readme 2020-08-03 15:50:13 -07:00
Felix Rieseberg
58add05655 build: Build on push 2020-08-02 13:33:54 -07:00
Felix Rieseberg
0a400d915f build: Fix macOS cert 2020-08-02 13:27:05 -07:00
Felix Rieseberg
f615e7754c 2.2.2 2020-08-02 13:13:52 -07:00
Felix Rieseberg
92717c8047 chore: Updated prettier run 2020-08-02 13:11:20 -07:00
Felix Rieseberg
045b83f843 build: Move to GitHub Actions, upgrade dependencies 2020-08-02 13:09:13 -07:00
Felix Rieseberg
1dd3b76187 Merge pull request #153 from PF94/patch-1
Improve HELP.MD a little bit.
2020-01-15 15:38:04 -08:00
PF94
4b1dd6146c Improve HELP.MD a little bit.
* Add some caplization to "Command Prompt", "Window Mode" and "Full Screen" mode
* Changed "brick" to "mess up" as "bricked" means that the operating system is permanently damaged.
* Add another "enter" for the "What's the FrontPage Username and Password?" section, now people won't accidently connect as "windows95Password".
* Fix DisplacedGamers missing "s" in the end.
2020-01-11 22:18:39 -05:00
Felix Rieseberg
3601599ff1 docs: Update readme 2019-12-04 10:17:07 -08:00
Felix Rieseberg
6bf7678079 build: Use ascProvider 2019-12-02 15:54:29 -08:00
Felix Rieseberg
5396cae0f0 build: Notarize the app 2019-12-02 13:23:53 -08:00
Felix Rieseberg
c5a24643fd build: Tell me what's going on 2019-12-01 19:19:53 -08:00
Felix Rieseberg
59a651a205 build: Oops, actually code-sign this thing 2019-12-01 17:49:57 -08:00
Felix Rieseberg
f5cb94776a 2.2.1 2019-11-30 13:09:58 -08:00
Felix Rieseberg
982c866899 chore: Bump dependencies 2019-11-30 12:44:43 -08:00
Felix Rieseberg
9e8cef8da7 chore: Update Electron 2019-11-22 18:13:59 -08:00
Felix Rieseberg
3b76a39060 fix: Ensure that links show up 2019-11-22 18:06:58 -08:00
Felix Rieseberg
e7d515de84 docs: Update Readme 2019-08-24 19:02:48 +02:00
141 changed files with 17281 additions and 14058 deletions

View File

@@ -1,45 +0,0 @@
environment:
matrix:
- nodejs_version: "10"
init:
- git config --global core.symlinks true
install:
# Setup the code signing certificate
- ps: >-
if (Test-Path Env:\WINDOWS_CERTIFICATE_P12) {
$workingDirectory = Convert-Path (Resolve-Path -path ".")
$filename = "$workingDirectory\cert.p12"
$bytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_P12)
[IO.File]::WriteAllBytes($filename, $bytes)
}
- ps: Install-Product node $env:nodejs_version x64
- node --version
- npm ci
- ps: mkdir images
- ps: cd images
- ps: Start-FileDownload 'https://1drv.ws/u/s!AkfaAw_EaahOkulh8rA41x2phgfYXQ' -FileName images.zip -Timeout 600000
- ps: 7z x images.zip -y -aoa
- ps: Remove-Item images.zip
- ps: Remove-Item __MACOSX -Recurse -ErrorAction Ignore
- ps: cd ..
- ps: Tree ./src /F
- ps: Tree ./images /F
cache:
- '%APPDATA%\npm-cache -> appveyor.yml'
test_script:
- node --version
- npm --version
- npm run lint
artifacts:
- path: 'out\make\squirrel.windows\**\*.exe'
build_script:
- if %APPVEYOR_REPO_TAG% EQU false npm run make
- if %APPVEYOR_REPO_TAG% EQU true npm run publish
- if %APPVEYOR_REPO_TAG% EQU true npm run publish -- --arch=ia32
- ps: Tree ./out/make /F

View File

@@ -0,0 +1,122 @@
---
name: probe-win95
description: Boot Windows 95 in Electron under Claude's control, without a human clicking anything. Use when testing v86 updates, SMB changes, keyboard input, boot stability, or bisecting regressions.
---
# Probing Windows 95 autonomously
You can run and test the Win95 VM yourself. The harness is already wired
up — three pieces:
| File | Role |
|---|---|
| `src/renderer/debug-harness.ts` | Activated by `WIN95_PROBE=1`. Boots fresh automatically, samples CPU + VGA + text screen every 5s, writes `/tmp/win95-probe.json` + `/tmp/win95-screen.png`, detects SUCCESS vs FAIL modes, optionally drives keyboard input. |
| `src/renderer/smb/index.ts` | Wraps `console.log` so `[smb]` and `[nbns]` lines tee to `$TMPDIR/windows95-smb.log` (outside Electron, readable by any polling script — no CDP needed). |
| `tools/probe-boot.sh` | One-shot: kill leftovers → parcel build → launch Electron → poll `/tmp/win95-probe.done` → report → kill. |
## One-shot boot test
```sh
tools/probe-boot.sh
```
Prints SUCCESS or a FAIL verdict. ~40s on a clean run.
## Boot + type into Run
```sh
pkill -9 -f "windows95.*electron"; sleep 2
rm -f "$HOME/Library/Application Support/windows95/state-v4.bin"
rm -f /tmp/win95-probe.json /tmp/win95-probe.done \
"$TMPDIR/windows95-smb.log"
WIN95_PROBE=1 \
WIN95_PROBE_SCRIPT='HOST/HOST' \
WIN95_SMB_SHARE="$HOME/Downloads" \
./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 &
```
`WIN95_PROBE_SCRIPT='HOST/HOST'` types `\\HOST\HOST` into Start → Run on
desktop. `WIN95_PROBE_DOSBOX=1` instead opens `command`, types `dir`,
and (with `WIN95_PROBE_DOSBOX_ALTENTER=1`) toggles fullscreen — this is
the regression scenario for the windowed-DOS-box VBE leak.
`WIN95_PROBE_CDROM=/path/to.iso` mounts an ISO on the secondary-IDE
ATAPI drive (bypasses the settings UI). `WIN95_PROBE_CDTRACE=1` logs
every secondary-channel ATA/ATAPI command to `/tmp/win95-cdtrace.log`.
`WIN95_PROBE_VGATRACE=1` wraps the VGA I/O ports at the `io.ports[]`
layer and writes `[port, op, value, "eip VMPE cplN"]` tuples to
`/tmp/win95-vgatrace.json` every tick (heavy — can hit 1M entries during
boot). `/``\` substitution (env var / shell quoting, pragmatism). The
harness drives it via XT scancodes — Win95 doesn't have Win+R (Win98+
only), so the sequence is Esc, Esc, Ctrl+Esc, R, backslashes + text,
Enter.
## Reading results
| File | What |
|---|---|
| `/tmp/win95-probe.json` | Live status: `phase` (`init`/`text-mode`/`splash`/`desktop`), `gfxW/H`, `textScreen`, `instructionDelta`, `verdict` |
| `/tmp/win95-probe.done` | Written once when verdict is decided |
| `/tmp/win95-screen.png` | Canvas screenshot, refreshed each tick |
| `$TMPDIR/windows95-smb.log` | SMB/NBNS protocol trace |
| `/tmp/win95-electron.log` | Electron stderr |
## Verdicts
| Verdict | Meaning | Action |
|---|---|---|
| `SUCCESS` | Canvas ≥640×480, CPU active, uptime >30s | desktop reached |
| `FAIL_VXDLINK` | "Invalid VxD dynamic link call" | flaky — retry |
| `FAIL_IOS` / `FAIL_PROTECTION` | IOS subsystem protection error | usually driver/BIOS mismatch |
| `FAIL_KRNL386` | "Cannot find KRNL386.EXE" in safe mode | disk reads returning garbage — wasm/BIOS drift |
| `FAIL_SPLASH_HANG` | Canvas stuck 320×400 for >70s | IRQ starvation — if you're on v86 master, check the IDE register fix |
| `FAIL_HUNG` | CPU stopped advancing or text screen frozen 40s | hard hang |
## Rules of the road
- **Sporadic bluescreens are normal** on all v86 versions. One FAIL_VXDLINK
or FAIL_HUNG doesn't prove anything — retry up to 3×.
- **Always clean state** (`state-v4.bin`) before a probe. `pkill` on a
wedged Electron triggers `onbeforeunload`, saving the *corrupted* state.
Deleting it forces fallback to `images/default-state.bin`.
- **Don't trust the text buffer in graphics mode.** After desktop (≥640×480)
the stale BIOS text lingers in the buffer. The harness's `phase` field
accounts for this; don't re-read `textScreen` in a `desktop` phase and
think you hit a BSOD.
- **Kill Electron when done.** Background processes pile up, each holding
the disk image lock. `pkill -f "windows95.*electron"` on every path out.
## Bisecting v86
`tools/bisect-v86.sh <commit>` handles one step. The harness retries 3×
per commit. Hard-won lessons:
1. **Validate bounds against a known-good binary.** Source-built wasm can
drift from prod due to cargo/rustc version differences. We hit this:
the "GOOD" bound produced a wasm that couldn't read the disk at all.
2. **JS-only when toolchain drifts.** Keep the prod wasm, rebuild only
libv86.js at each commit. Closure is deterministic enough; cargo
isn't always. Works until you cross a commit that changes the JS↔wasm
ABI (for v86, the APIC→Rust port in Aug 2025).
3. **Retry on FAIL, never on SUCCESS.** One SUCCESS = commit is good.
Three different FAILs at the same commit = commit is bad.
4. **State cleanup between runs** (see above). Skipping this is the #1
cause of spurious "bad" verdicts during bisect.
## Extending the harness
- New verdicts: add to the chain in `collectStatus` in `debug-harness.ts`
- New keyboard actions: extend `runScript` (current types: `keys`, `chord`,
`text`, `wait`)
- New probe signals: add to `ProbeStatus` interface
Gate everything new on `process.env.WIN95_PROBE === "1"` so it stays out
of the normal app.
## Common failure diagnostics
| Symptom | Check |
|---|---|
| No SMB traffic at all | `$TMPDIR/windows95-smb.log` should have `hooked adapter` line. If absent, v86 API changed — see `src/renderer/smb/README.md` |
| SMB hooks fire, no connection | Win95's "NetBIOS over TCP/IP" checkbox — bake into default-state.bin |
| Boot hangs on `2996c087` or older v86 | You probably have a ABI-mismatched wasm/JS pair. Prod wasm is the ground truth; rebuild JS against it. |

View File

@@ -0,0 +1,151 @@
---
name: update-v86
description: Build and install v86 (wasm + libv86.js + BIOS) into windows95. Use when pulling upstream v86 changes, fixing a broken build, verifying the fork branches are still in sync, or setting up a fresh v86 checkout.
---
# Updating v86
windows95 builds v86 from source — not from copy.sh. Two small bugfix
patches ride along on a fork branch until the upstream PRs land.
## Sources
| File | Built from |
|---|---|
| `src/renderer/lib/libv86.js` | `make build/libv86.js` in `../v86` |
| `src/renderer/lib/build/v86.wasm` | `make build/v86.wasm` |
| `bios/seabios.bin`, `bios/vgabios.bin` | copied from `../v86/bios/` |
`tools/update-v86.js` runs those targets, copies the artifacts, runs 5
sanity checks, and fails loudly if any prerequisite is missing. No
fallbacks, no fetching from copy.sh.
## The fork branch
v86 should be checked out on **`felixrieseberg/v86:windows95-base`**.
That branch merges the feature branches tracked in
[`docs/v86-patches.md`](../../../docs/v86-patches.md) — keep that table
in sync with this list. Each is upstreamable on its own:
- **`electron-renderer-fs-loader`** (PR #1540) — `src/lib.js` uses
`require("fs")` instead of `await import("node:fs/promises")`. Dynamic
import of `node:` URLs doesn't work in an Electron renderer.
- **`ide-shared-registers`** (PR #1541) — `src/ide.js` writes ATA Command
Block registers (Features, Sector Count, LBA Low/Mid/High) to both
master and slave. Without this, Win95/98 hang at the splash screen on
any disk >~535MiB. Root cause: v86 commit `1b90d2e7` changed those
writes to target only `current_interface`, but per ATA spec they're
channel-shared (one register file on the IDE cable; both drives latch
the same value).
- **`vmware-abspointer`** — `src/vmware.js` implements the VMware
backdoor (port `0x5658`): GETVERSION + ABSPOINTER_* so a guest driver
(VBADOS VBMOUSE) can read absolute cursor position and track the host
cursor 1:1 without pointer lock, and the legacy text-clipboard commands
69 so `W95TOOLS.EXE` (guest-tools/agent) can sync `CF_TEXT` with
the host. Consumes `mouse-absolute` and `vmware-clipboard-host` bus
events; emits `vmware-absolute-mouse` and `vmware-clipboard-guest`.
- **`fake-network-copy-tcp-addrs`** — `src/browser/fake_network.js`
copies the four address subarrays (`hsrc/hdest/psrc/pdest`) when a
`TCPConnection` is created from an inbound SYN. Upstream stores them
as zero-copy views into the NE2000 TX ring; once the guest's 12-slot
TX ring wraps (any concurrent traffic — SMB, NBNS, ping), `pump()`
builds segments with whatever IP now occupies that slot, the guest
RSTs them as belonging to no TCB, and `recv()` blocks forever.
Exercised by `tools/probe-tcp.sh`.
- **`vga-defer-vbe-disable-v86`** — `src/vga.js` defers `dispi[4]=0`
written from V86 mode until a legacy attribute-mode write reaches the
hardware. Win9x's VDD virtualises ports 3B03DF for a windowed DOS VM
but not 1CE/1CF, so vgabios's VBE-disable leaks through while the rest
of its mode-set is captured into the VM's virtual register file —
without this the screen turns to planar garbage the moment you open a
DOS box.
## Prerequisites
```sh
rustup target add wasm32-unknown-unknown
brew install openjdk
# one-time: fetch the Closure compiler v86's Makefile pins to
curl -sL https://repo1.maven.org/maven2/com/google/javascript/closure-compiler/v20210601/closure-compiler-v20210601.jar \
-o ../v86/closure-compiler/compiler.jar
```
Closure **must** be v20210601 — newer versions hit
[closure-compiler#3972](https://github.com/google/closure-compiler/issues/3972)
on v86's source. The pin is in v86's Makefile.
## Steps
```sh
cd ../v86
git fetch fork origin
git checkout windows95-base
git rebase fork/windows95-base # in case fork was updated elsewhere
cd ../windows95
node tools/update-v86.js
```
That's it. Script runs both `make` targets, copies, verifies.
## Sanity-check WARNs
The 5 checks assert invariants `src/renderer/smb/index.ts` and
`tools/parcel-build.js` depend on. A WARN means upstream changed
something load-bearing — don't ignore it:
1. **`await import("node:...")` still present** → PR #1540 was reverted
or the pattern moved. Electron renderer will fail to load disk images.
2. **`master.features_reg=` missing in minified** → PR #1541 was reverted
or `windows95-base` lost the commit. Win95 will hang at splash on
disks >535MiB. Check `cd ../v86 && git log --oneline windows95-base`.
3. **Export pattern changed**`tools/parcel-build.js` shim needs
updating. Look for `module.exports.V86=` and `window.V86=`.
4. **`tcp-connection` event gone** → SMB falls back to the old-API theft
hack in `src/renderer/smb/index.ts` — still works, but surprising.
5. **`on_tcp_connection` gone** → old-API fallback is dead. SMB integration
only works via the `tcp-connection` bus event now. Harmless; update
the comment in `index.ts` and retire the theft code.
## After updating, probe-test
```sh
node tools/update-v86.js && tools/probe-boot.sh
```
Should land SUCCESS in ~40s. If FAIL_SPLASH_HANG, the IDE fix didn't
take — check `grep master.features_reg src/renderer/lib/libv86.js`. If
FAIL_VXDLINK, retry — sporadic bluescreens are normal (see the
`probe-win95` skill).
## When a PR merges upstream
Rebase `windows95-base` to drop the now-redundant commit:
```sh
cd ../v86
git fetch origin
git checkout windows95-base
git rebase origin/master # drops the merged commit cleanly
git push fork windows95-base --force-with-lease
```
If **both** PRs are upstream, retire the fork branch entirely:
1. Point `tools/update-v86.js` default at `origin/master` (it already
uses `../v86`, so just `git checkout master` there)
2. Delete `fork/windows95-base`
3. Remove this skill's "The fork branch" section
4. Confirm the 5 sanity checks still pass — they're version-agnostic
## Integration contract with SMB
The SMB server sits on top of v86's network adapter. Details in
`src/renderer/smb/README.md`. Short version: the new path uses the
`tcp-connection` bus event; the fallback path uses
`adapter.on_tcp_connection` callback + connection-theft (stealing a
`TCPConnection` the HTTP probe builds for us). Both use `.on_data` on
the conn, not `.on("data")`, because Closure dead-code-eliminates the
event emitter plumbing.
If any v86 update breaks these assumptions, `src/renderer/smb/index.ts`
needs updating, not just `tools/update-v86.js`.

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
text eol=lf

BIN
.github/images/linux.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
.github/images/macos.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
.github/images/windows.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

110
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: Build & Release
on:
push:
branches:
- master
tags:
- v*
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20.x
cache: npm
- name: Install
run: npm ci
- name: lint
run: npm run lint
build:
needs: lint
name: Build (${{ matrix.os }} - ${{ matrix.arch }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Build for supported platforms
# https://github.com/electron/electron-packager/blob/ebcbd439ff3e0f6f92fa880ff28a8670a9bcf2ab/src/targets.js#L9
# 32-bit Linux unsupported as of 2019: https://www.electronjs.org/blog/linux-32bit-support
os: [ macOS-latest, ubuntu-latest, windows-latest ]
arch: [ x64, arm64 ]
include:
- os: windows-latest
arch: ia32
- os: ubuntu-latest
arch: armv7l
# Publishing artifacts for multiple Windows architectures has
# a bug which can cause the wrong architecture to be downloaded
# for an update, so until that is fixed, only build Windows x64
exclude:
- os: windows-latest
arch: arm64
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 20.x
cache: npm
- name: Set MacOS signing certs
if: matrix.os == 'macOS-latest'
run: chmod +x tools/add-macos-cert.sh && ./tools/add-macos-cert.sh
env:
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
- name: Set up Azure Trusted Signing
if: matrix.os == 'windows-latest' && startsWith(github.ref, 'refs/tags/')
shell: pwsh
run: |
nuget install Microsoft.Trusted.Signing.Client -Version 1.0.60 -OutputDirectory . -NonInteractive
$signtool = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter signtool.exe | Where-Object { $_.FullName -like "*\x64\*" } | Sort-Object FullName -Descending | Select-Object -First 1
echo "SIGNTOOL_PATH=$($signtool.FullName)" >> $env:GITHUB_ENV
echo "AZURE_CODE_SIGNING_DLIB=$((Resolve-Path 'Microsoft.Trusted.Signing.Client.1.0.60/bin/x64/Azure.CodeSigning.Dlib.dll').Path)" >> $env:GITHUB_ENV
- name: Download disk image (ps1)
run: tools/download-disk.ps1
if: matrix.os == 'windows-latest' && startsWith(github.ref, 'refs/tags/')
env:
DISK_REPO: ${{ vars.DISK_REPO }}
DISK_TAG: ${{ vars.DISK_TAG }}
GH_TOKEN: ${{ secrets.IMAGES_REPO_TOKEN }}
- name: Download disk image (sh)
run: chmod +x tools/download-disk.sh && ./tools/download-disk.sh
if: matrix.os != 'windows-latest' && startsWith(github.ref, 'refs/tags/')
env:
DISK_REPO: ${{ vars.DISK_REPO }}
DISK_TAG: ${{ vars.DISK_TAG }}
GH_TOKEN: ${{ secrets.IMAGES_REPO_TOKEN }}
- name: Install
run: npm ci
- name: Make
if: startsWith(github.ref, 'refs/tags/')
run: npm run make -- --arch=${{ matrix.arch }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_CODE_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_CODE_SIGNING_ACCOUNT_NAME }}
AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME: ${{ secrets.AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME }}
- name: Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
if: startsWith(github.ref, 'refs/tags/')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: true
files: |
out/**/*.deb
out/**/*.dmg
out/**/*setup*.exe
out/**/*.rpm
out/**/*.zip

21
.gitignore vendored
View File

@@ -1,6 +1,21 @@
node_modules
out
src/images
.DS_Store
images
dist
/images*/
/helper-images/
dist
!.github/images
*.code-workspace
*.pfx
Microsoft.Trusted.Signing.Client*
trusted-signing-metadata.json
.env
electron-windows-sign.log
.npmrc
guest-tools/**/*.EXE
guest-tools/**/*.exe
/.claude/scheduled_tasks.lock
/.claude/worktrees/

View File

@@ -1,64 +0,0 @@
language: node_js
node_js: "12"
os:
- linux
- osx
dist: trusty
osx_image: xcode8.3
sudo: false
cache:
directories:
- node_modules
- $HOME/.cache/electron
addons:
apt:
packages:
- fakeroot
- rpm
branches:
only:
- master
- /^v\d+\.\d+\.\d+/
install:
- npm install
- mkdir -p ./images
- cd ./images
- wget -O images.zip https://1drv.ws/u/s!AkfaAw_EaahOkulh8rA41x2phgfYXQ
- unzip -o images.zip
- rm images.zip
- rm -r __MACOSX
- cd ..
- ls src
- ls images
- |
if [[ "$TRAVIS_OS_NAME" == "osx" && "$TRAVIS_SECURE_ENV_VARS" == "true" ]]; then
export CERTIFICATE_P12=cert.p12;
echo $MACOS_CERT_P12 | base64 --decode > $CERTIFICATE_P12;
export KEYCHAIN=build.keychain;
# Create the keychain with a password
security create-keychain -p travis $KEYCHAIN;
# Make the custom keychain default, so xcodebuild will use it for signing
security default-keychain -s $KEYCHAIN;
# Unlock the keychain
security unlock-keychain -p travis $KEYCHAIN;
# Add certificates to keychain and allow codesign to access them
# Apple Worldwide Developer Relations Certification Authority
security import ./assets/certs/apple.cer -k ~/Library/Keychains/$KEYCHAIN -T /usr/bin/codesign
# Developer Authentication Certification Authority
security import ./assets/certs/dac.cer -k ~/Library/Keychains/$KEYCHAIN -T /usr/bin/codesign
# Developer ID Felix
security import $CERTIFICATE_P12 -k $KEYCHAIN -P $MACOS_CERT_PASSWORD -T /usr/bin/codesign 2>&1 >/dev/null;
rm $CERTIFICATE_P12;
security set-key-partition-list -S apple-tool:,apple: -s -k travis $KEYCHAIN
# Echo the identity
security find-identity -v -p codesigning
fi
script:
- npm run lint
- if test -z "$TRAVIS_TAG"; then npm run make; fi
after_success: if test -n "$TRAVIS_TAG"; then npm run publish; fi

28
HELP.md
View File

@@ -1,34 +1,14 @@
# Help & Commonly Asked Questions
## MS-DOS seems to brick the screen
Hit `Alt + Enter` to make the command screen "full screen" (as far as Windows 95 is
## MS-DOS seems to mess up the screen
Hit `Alt + Enter` to make the command screen "Full Screen" (as far as Windows 95 is
concerned). This should restore the display from the garbled mess you see and allow
you to access the command prompt. Press Alt-Enter again to leave full screen and go
back to a window mode. (Thanks to @DisplacedGamer for that wisdom)
you to access the Command Prompt. Press Alt-Enter again to leave Full Screen and go
back to Window Mode. (Thanks to @DisplacedGamers for that wisdom)
## Windows 95 is stuck in a bad state
On the app's home screen, select "Settings" in the lower menu. Then, delete your
machine's state before starting it again - this time hopefully without issues.
## I want to install additional apps or games
If you are running macOS, or Linux, you can probably "mount" the
virtual hard drive used by `windows95` to add files. Hit the "Modify C: Drive"
button, which will take you to the disk image.
On macOS, double-click the disk image to open it.
On Windows 10, Windows will _think_ that it can open up the image, but will
actually fail to do so. Use a tool [like OSFMount][osfmount] to mount your
disk image.
On Linux, search the Internet for instructions on how to mount an `img` disk
image on your distribution. It's likely that you'll be able to run `mount`
with the image as input.
[osfmount]: https://www.osforensics.com/tools/mount-disk-images.html
## What's the FrontPage Username and Password?
Username: windows95
Password: password

103
README.md
View File

@@ -3,12 +3,99 @@
This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes, it's the full thing. I'm sorry.
## Downloads
| | Windows | macOS | Linux |
|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Standalone Download | 📦[Standalone, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-win32-standalone-ia32.zip) <br /> 📦[Standalone, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-win32-standalone-x64.zip) | 📦[Standalone](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-macos-2.1.1.zip) | |
| Installer | 💽[Setup, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-setup-win32-x64.exe) <br /> 💽[Setup, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-setup-win32-ia32.exe) | | 💽[deb, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-linux-2.1.1_amd64.deb) <br /> 💽[rpm, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-linux-2.1.1.x86_64.rpm) |
![Screenshot](https://user-images.githubusercontent.com/1426799/44532591-4ceb3680-a6a8-11e8-8c2c-bc29f3bfdef7.png)
<table class="is-fullwidth">
</thead>
<tbody>
</tbody>
<tr>
<td>
<img src="./.github/images/windows.png" width="24"><br />
Windows
</td>
<td>
<span>32-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-setup-ia32.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-win32-ia32-5.0.0.zip">
📦 Standalone Zip
</a>
<br />
<span>64-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-setup-x64.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-win32-x64-5.0.0.zip">
📦 Standalone Zip
</a><br />
<span>
❓ Don't know what kind of chip you have? It's probably `x64`. To confirm, on your computer, hit Start, enter "processor" for info.
</span>
</td>
</tr>
<tr>
<td>
<img src="./.github/images/macos.png" width="24"><br />
macOS
</td>
<td>
<span>Apple Silicon Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-darwin-arm64-5.0.0.zip">
📦 Standalone Zip
</a><br />
<span>Intel Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-darwin-x64-5.0.0.zip">
📦 Standalone Zip
</a>
<span>
❓ Don't know what kind of chip you have? If you bought your computer after 2020, select "Apple Silicon". Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
</span>
</td>
</tr>
<tr>
<td>
<img src="./.github/images/linux.png" width="24"><br />
Linux
</td>
<td>
<span>64-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-1.x86_64.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95_5.0.0_amd64.deb">
💿 deb
</a><br />
<span>ARM64</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-1.arm64.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95_5.0.0_arm64.deb">
💿 deb
</a><br />
<span>ARM</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-1.armv7hl.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95_5.0.0_armhf.deb">
💿 deb
</a><br />
</td>
</tr>
</table>
<hr />
<table width="100%">
<tr>
<td width="50%">
<img src="https://github.com/user-attachments/assets/43ab7126-765e-444b-ad14-27b1beadbc7c" width="100%" alt="Screenshot showing Windows 95">
</td>
<td width="50%">
<img src="https://github.com/user-attachments/assets/7ac5dc36-cbd4-4455-a616-0e5cca314b34" width="100%" alt="Screenshot showing Windows 95">
</td>
</tr>
</table>
## Does it work?
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
@@ -17,13 +104,13 @@ Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this
Absolutely.
## Does it run Doom (or my other favorite game)?
You'll likely be better off with an actual virtualization app, but the short answer is yes. [Thanks to
You'll likely be better off with an actual virtualization app, but the short answer is yes. In fact, a few games are already preinstalled - and more can be found on the Internet, for instance at [archive.org](https://www.archive.org). [Thanks to
@DisplacedGamers](https://youtu.be/xDXqmdFxofM) I can recommend that you switch to a resolution of
640x480 @ 256 colors before starting DOS games - just like in the good ol' days.
## Credits
99% of the work was done over at [v86](https://github.com/copy/v86/) by Copy.
99% of the work was done over at [v86](https://github.com/copy/v86/) by Copy aka Fabian Hemmer and his contributors.
## Contributing
@@ -44,6 +131,8 @@ Unpack the `images` folder into the `src` folder, creating this layout:
Once you've done so, run `npm install` and `npm start` to run your local build.
If you want to tinker with the image or make a new one, check out the [QEMU docs](./docs/qemu.md).
## Other Questions
* [MS-DOS seems to brick the screen](./HELP.md#ms-dos-seems-to-brick-the-screen)

BIN
assets/boot.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Binary file not shown.

16
assets/entitlements.plist Normal file
View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -35,5 +35,5 @@ xhost +
```
4. run
```
docker run -it -e DISPLAY=host.docker.internal:1 toolboc/windows95
docker run -it -e DISPLAY=host.docker.internal:0 toolboc/windows95
```

48
docs/qemu.md Normal file
View File

@@ -0,0 +1,48 @@
# QEMU Instructions
The image built here was made with QEMU. In this doc, I'm keeping instructions
around.
Disk image creation
```sh
qemu-img create -f raw windows95_v4.raw 1G
```
ISO CD image creation
```sh
hdiutil makehybrid -o output.iso /path/to/folder -iso -joliet
```
Installation
```sh
qemu-system-i386 \
-cdrom Win95_OSR25.iso \
-m 128 \
-hda windows95.img \
-device sb16 \
-nic user,model=ne2k_pci \
-fda Win95_boot.img \
-boot a \
-M pc,acpi=off \
-cpu pentium
```
- Boot from floppy
- Run `fdisk` and `format c:`
- Run `D:\setup.exe` with `24796-OEM-0014736-66386`
- After completing setup and restarting your computer, you might get an IOS Windows protection error
- Use `fix95cpu.ima` as a bootable floppy to fix
- Use `vga-driver.iso` to install different video driver
```sh
qemu-system-i386 \
-m 128 \
-hda images/windows95.img \
-device sb16 \
-M pc,acpi=off \
-cpu pentium \
-netdev user,id=mynet0 \
-device ne2k_isa,netdev=mynet0,irq=10
```

45
docs/smb-share.md Normal file
View File

@@ -0,0 +1,45 @@
# Host folder over SMB
Windows 95 can mount a host folder as a network drive. The server lives in
`src/renderer/smb/` — ~1500 lines, zero dependencies, read-only. Defaults to
`~/Downloads`, configurable in Settings.
## Inside Win95
Open **My Computer****Z:**. The `W95TOOLS.EXE` guest agent auto-maps
`\\HOST\HOST` to `Z:` at login, so the share is available as a drive letter
out of the box. If the agent isn't running, the underlying UNC path still
works via Start → Run → `\\HOST\HOST`.
NetBIOS over TCP/IP must be enabled (Control Panel → Network → TCP/IP
properties → NetBIOS tab). This is baked into the default state image.
## Architecture
One SMB session per `TCPConnection`, hooked off v86's network adapter. The
server speaks SMB1 (LANMAN2.1 dialect) because that's what Win95 negotiates.
Full breakdown in `src/renderer/smb/README.md` — that file has the protocol
gotchas learned during implementation (NT dialect trap, NetBIOS name
null-termination, 8.3 `~N` mapping, RAP descriptor parsing).
**Security:** read-only, symlink-aware path traversal guard, share path
validated in main-process IPC. Not exposed until `smbSharePath` is set in
settings or `WIN95_SMB_SHARE=...` is in the env.
## Tests
```sh
npx tsc --ignoreConfig --module commonjs --target es2020 --esModuleInterop \
--moduleResolution bundler --outDir /tmp/smb-test --skipLibCheck \
src/renderer/smb/*.ts && node /tmp/smb-test/test-standalone.js
```
35 protocol tests, full round-trips with real file I/O. No Electron needed.
## What's not implemented
- Writes (read-only by design, but OPEN is easy to extend)
- Long filenames via TRANS2 (we serve 8.3 through the legacy SEARCH path,
which is enough for Win95 Explorer but loses the original casing/length)
- Multiple shares — everything is one share named `HOST`
- Authentication — guest access only

25
docs/v86-patches.md Normal file
View File

@@ -0,0 +1,25 @@
# v86 patches carried by windows95
windows95 builds v86 from a fork branch rather than upstream master.
Each fix lives on its own feature branch in `felixrieseberg/v86`, has an
upstream PR against `copy/v86`, and is merged into the integration
branch `felixrieseberg/v86:windows95-base` (which is what
`tools/update-v86.js` builds from). When a PR is merged upstream, drop
its row here, delete the feature branch, and rebase `windows95-base`.
| Fix | Branch | Upstream PR | windows95-base | Why we need it |
|---|---|---|---|---|
| Node fs loader in Electron renderer | [`electron-renderer-fs-loader`](https://github.com/felixrieseberg/v86/tree/electron-renderer-fs-loader) | [copy/v86#1540](https://github.com/copy/v86/pull/1540) | ✅ | `await import("node:fs/promises")` fails in an Electron renderer; disk images don't load. |
| IDE shared Command Block registers | [`ide-shared-registers`](https://github.com/felixrieseberg/v86/tree/ide-shared-registers) | [copy/v86#1541](https://github.com/copy/v86/pull/1541) | ✅ | Win9x hangs at splash on disks >535 MiB because per-device register writes violate the ATA shared-register-file spec. |
| VMware absolute-pointer backdoor | [`vmware-abspointer`](https://github.com/felixrieseberg/v86/tree/vmware-abspointer) | [copy/v86#1542](https://github.com/copy/v86/pull/1542) | ✅ | Port 0x5658 GETVERSION + ABSPOINTER_* so VBMOUSE can track the host cursor 1:1 without pointer lock. |
| VMware text-clipboard backdoor | [`vmware-clipboard`](https://github.com/felixrieseberg/v86/tree/vmware-clipboard) | — *(stacked on #1542)* | ✅ | Legacy backdoor commands 69 so `W95TOOLS.EXE` can sync `CF_TEXT` with the host. |
| Defer V86-mode VBE disable | [`vga-defer-vbe-disable-v86`](https://github.com/felixrieseberg/v86/tree/vga-defer-vbe-disable-v86) | [copy/v86#1543](https://github.com/copy/v86/pull/1543) | ✅ | Opening a windowed DOS box leaks vgabios's VBE-disable past Win9x's VDD and turns the screen to planar garbage. |
| fake_network: copy TCP addrs | [`fake-network-copy-tcp-addrs`](https://github.com/felixrieseberg/v86/tree/fake-network-copy-tcp-addrs) | — | ✅ | `TCPConnection` routing fields alias the NE2000 TX ring; concurrent guest traffic retargets async replies and the guest RSTs them. |
## Adding a fix
1. Branch off `origin/master` in `../v86`, commit, push to `fork`.
2. Open the PR against `copy/v86`.
3. `git checkout windows95-base && git merge --no-ff <branch> && git push fork windows95-base`
4. `node tools/update-v86.js` in this repo to rebuild `libv86.js` / `v86.wasm`.
5. Add a row above and a bullet in `.claude/skills/update-v86/SKILL.md`.

View File

@@ -1,9 +1,41 @@
const path = require('path');
const fs = require('fs');
const package = require('./package.json');
require('dotenv').config()
const FLAGS = {
SIGNTOOL_PATH: process.env.SIGNTOOL_PATH,
AZURE_CODE_SIGNING_DLIB: process.env.AZURE_CODE_SIGNING_DLIB || path.join(__dirname, 'Microsoft.Trusted.Signing.Client.1.0.60/bin/x64/Azure.CodeSigning.Dlib.dll'),
AZURE_METADATA_JSON: process.env.AZURE_METADATA_JSON || path.resolve(__dirname, 'trusted-signing-metadata.json'),
AZURE_TENANT_ID: process.env.AZURE_TENANT_ID,
AZURE_CLIENT_ID: process.env.AZURE_CLIENT_ID,
AZURE_CLIENT_SECRET: process.env.AZURE_CLIENT_SECRET,
APPLE_ID: process.env.APPLE_ID,
APPLE_ID_PASSWORD: process.env.APPLE_ID_PASSWORD,
}
let windowsSign;
if (FLAGS.AZURE_TENANT_ID && FLAGS.SIGNTOOL_PATH) {
fs.writeFileSync(FLAGS.AZURE_METADATA_JSON, JSON.stringify({
Endpoint: process.env.AZURE_CODE_SIGNING_ENDPOINT || "https://wcus.codesigning.azure.net",
CodeSigningAccountName: process.env.AZURE_CODE_SIGNING_ACCOUNT_NAME,
CertificateProfileName: process.env.AZURE_CODE_SIGNING_CERTIFICATE_PROFILE_NAME,
}, null, 2));
windowsSign = {
signToolPath: FLAGS.SIGNTOOL_PATH,
signWithParams: `/v /dlib ${FLAGS.AZURE_CODE_SIGNING_DLIB} /dmdf ${FLAGS.AZURE_METADATA_JSON}`,
timestampServer: "http://timestamp.acs.microsoft.com",
hashes: ["sha256"],
};
} else {
console.warn('AZURE_TENANT_ID / SIGNTOOL_PATH not set; Windows binaries will not be signed');
}
module.exports = {
hooks: {
generateAssets: require('./tools/generateAssets')
generateAssets: require('./tools/generateAssets'),
},
packagerConfig: {
asar: false,
@@ -12,22 +44,42 @@ module.exports = {
appCategoryType: 'public.app-category.developer-tools',
win32metadata: {
CompanyName: 'Felix Rieseberg',
OriginalFilename: 'windows95',
OriginalFilename: 'windows95'
},
osxSign: {
identity: 'Developer ID Application: Felix Rieseberg (LT94ZKYDCJ)'
identity: 'Developer ID Application: Felix Rieseberg (LT94ZKYDCJ)',
},
osxNotarize: {
appleId: FLAGS.APPLE_ID,
appleIdPassword: FLAGS.APPLE_ID_PASSWORD,
teamId: 'LT94ZKYDCJ'
},
windowsSign,
ignore: [
/\/assets(\/?)/,
/\/docs(\/?)/,
/\/tools(\/?)/,
/\/src\/.*\.ts/,
/\/test(\/?)/,
/\/@types(\/?)/,
/\/helper-images(\/?)/,
/package-lock\.json/,
/README\.md/,
/tsconfig\.json/,
/Dockerfile/,
/issue_template\.md/,
/HELP\.md/,
/forge\.config\.js/,
/\.github(\/?)/,
/\.circleci(\/?)/,
/\.vscode(\/?)/,
/\.gitignore/,
/\.gitattributes/,
/\.eslintignore/,
/\.eslintrc/,
/\.prettierrc/,
/\/Microsoft\.Trusted\.Signing\.Client.*/,
/\/trusted-signing-metadata/,
]
},
makers: [
@@ -41,10 +93,11 @@ module.exports = {
exe: 'windows95.exe',
noMsi: true,
remoteReleases: '',
iconUrl: 'https://raw.githubusercontent.com/felixrieseberg/windows95/master/assets/icon.ico',
loadingGif: './assets/boot.gif',
setupExe: `windows95-${package.version}-setup-${arch}.exe`,
setupIcon: path.resolve(__dirname, 'assets', 'icon.ico'),
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD
windowsSign
}
}
},
@@ -60,18 +113,5 @@ module.exports = {
name: '@electron-forge/maker-rpm',
platforms: ['linux']
}
],
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'felixrieseberg',
name: 'windows95'
},
draft: true,
prerelease: true
}
}
]
};

45
guest-tools/README.md Normal file
View File

@@ -0,0 +1,45 @@
# guest-tools
Files and folders in this directory are exposed read-only inside the VM at
`\\HOST\TOOLS` (alongside the synthetic `_MAPZ.BAT` and `README.TXT`).
Drop drivers and utilities here that you want available from within
Windows 95.
## mouse-driver/ — seamless mouse (VBADOS)
`VBMOUSE.EXE` (DOS TSR) + `VBMOUSE.DRV` (Windows 3.x/9x driver) from
[VBADOS](https://git.javispedro.com/cgit/vbados.git/) by Javier S. Pedro,
GPLv2. Talks to v86's VMware mouse backdoor (port 0x5658) so the Windows
95 cursor tracks the host cursor pixel-for-pixel without pointer lock.
Install inside the guest:
1. Copy `\\HOST\TOOLS\mouse-driver\VBMOUSE.EXE` to `C:\` and add a
`C:\VBMOUSE.EXE` line to `C:\AUTOEXEC.BAT`.
2. Windows Setup (or Control Panel → Mouse → General → Change → Have
Disk) → browse to `\\HOST\TOOLS\mouse-driver` → pick **VBMouse int33
absolute mouse driver**.
3. Reboot. The app detects the driver and stops grabbing pointer lock;
ESC still toggles lock for games that want raw relative input.
## agent/ — W95TOOLS guest agent
`W95TOOLS.EXE` is a hidden-window agent that talks to the emulator over
the VMware backdoor (port 0x5658). Currently it bridges Windows 95's
`CF_TEXT` clipboard to the host (legacy backdoor commands 69; host side
is `src/renderer/clipboard.ts`, which polls Electron's clipboard) and
auto-maps `\\HOST\HOST` to `Z:` at login via `WNetAddConnection`, so the
shared folder shows up as a drive without a trip through Start → Run.
It's also where time sync, host-initiated shutdown, and a tray icon will
live when those land.
Install inside the guest:
1. Copy `\\HOST\TOOLS\agent\W95TOOLS.EXE` to `C:\WINDOWS\`.
2. Drop a shortcut to it in
`C:\WINDOWS\Start Menu\Programs\StartUp` so it runs on login.
Copy text on either side and it appears on the other within ~250 ms.
Text only; conversion is Windows-1252 ↔ UTF-8 with CRLF ↔ LF, capped at
64 KB. Built from `w95tools.c` with Open Watcom v2 — `make -C
guest-tools/agent` (needs Docker).

View File

@@ -0,0 +1,12 @@
FROM --platform=linux/amd64 debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends curl xz-utils ca-certificates make && \
rm -rf /var/lib/apt/lists/*
RUN mkdir -p /opt/watcom && \
curl -fsSL https://github.com/open-watcom/open-watcom-v2/releases/download/Current-build/ow-snapshot.tar.xz \
| tar -xJ -C /opt/watcom
ENV WATCOM=/opt/watcom
ENV PATH=$WATCOM/binl64:$PATH
ENV INCLUDE=$WATCOM/h:$WATCOM/h/nt
ENV LIB=$WATCOM/lib386:$WATCOM/lib386/nt
WORKDIR /work

View File

@@ -0,0 +1,30 @@
# Build W95TOOLS.EXE for Windows 95 with Open Watcom v2, inside Docker.
#
# Watcom is the only readily-available cross-compiler that emits a PE binary
# Win95 RTM will load (subsystem 4.0, no msvcrt). mingw-w64 targets NT. The
# macOS-native Watcom binaries are unsigned and Gatekeeper kills them, so we
# run the linux/amd64 build under Docker instead.
IMAGE := windows95-ow2
# Docker Desktop's bind-mount file sync races with recently-edited files; work
# around it by piping the source on stdin and building in /tmp inside the
# container, then dumping the EXE bytes back over stdout.
DOCKER := docker run --rm -i --platform linux/amd64 $(IMAGE)
CFLAGS := -bt=nt -3r -zq -wx -we -os -s
.PHONY: all image clean
all: W95TOOLS.EXE
image:
docker build --platform linux/amd64 -t $(IMAGE) .
W95TOOLS.EXE: w95tools.c image
$(DOCKER) sh -c 'cd /tmp && cat >w95tools.c && \
wcc386 $(CFLAGS) w95tools.c && \
wlink system nt_win option quiet name W95TOOLS.EXE \
file w95tools.o library kernel32,user32 && \
cat W95TOOLS.EXE' <w95tools.c >$@
clean:
rm -f W95TOOLS.EXE

View File

@@ -0,0 +1,222 @@
/*
* W95TOOLS — guest-side integration agent for the windows95 emulator.
*
* Currently: bidirectional text clipboard, and auto-mapping the host's
* SMB share to Z: at login. Talks to the emulator over the
* legacy VMware backdoor (port 0x5658; implemented in v86's vmware.js).
* Joins the Win32 clipboard-viewer chain so guest copies are pushed
* immediately, and polls the backdoor on a timer so host copies show up
* within ~250 ms.
*
* Win9x runs ring-3 code with the I/O bitmap wide open, so a plain IN works
* from a user process — no driver needed. On NT this would #GP; we don't run
* there.
*
* Build with Open Watcom v2 (see Makefile). Links USER32/KERNEL32 only,
* runs on Win95 RTM.
*/
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#define VMW_MAGIC 0x564D5868UL
#define VMW_PORT 0x5658
#define CMD_GETLEN 6
#define CMD_GETDATA 7
#define CMD_SETLEN 8
#define CMD_SETDATA 9
#define CMD_VERSION 10
#define POLL_MS 250
#define MAX_CLIP 0xFFFF
#define TIMER_CLIP 1
#define TIMER_MAP 2
#define MAP_TRIES 5
#define MAP_DELAY 3000
/* The host SMB server routes any share name other than TOOLS/IPC$ to the
* user's folder, so a fixed UNC works regardless of which directory they
* picked. */
#define MAP_DRIVE "Z:"
#define MAP_UNC "\\\\HOST\\HOST"
extern unsigned long bd(unsigned long cmd, unsigned long arg);
#pragma aux bd = \
"mov eax, 564D5868h" \
"mov edx, 5658h" \
"in eax, dx" \
parm [ecx] [ebx] \
value [eax] \
modify [edx];
extern unsigned long bd_ebx(unsigned long cmd, unsigned long arg);
#pragma aux bd_ebx = \
"mov eax, 564D5868h" \
"mov edx, 5658h" \
"in eax, dx" \
parm [ecx] [ebx] \
value [ebx] \
modify [eax edx];
static HWND g_next;
static int g_ignore;
static int g_map_tries;
/* Map \\HOST to Z:. Loaded lazily so we don't grow a link-time dep on MPR;
* if the call fails (no share configured, network not up yet) the caller
* retries a few times on a timer and then gives up silently. */
static int map_host_drive(void)
{
typedef DWORD (APIENTRY *WNetAddConn)(LPCSTR, LPCSTR, LPCSTR);
static WNetAddConn fn;
DWORD rc;
if (!fn) {
HMODULE mpr = LoadLibrary("MPR.DLL");
if (!mpr) return 1;
fn = (WNetAddConn)GetProcAddress(mpr, "WNetAddConnectionA");
if (!fn) return 1;
}
if (GetDriveType(MAP_DRIVE "\\") > 1) return 1; /* letter taken */
rc = fn(MAP_UNC, 0, MAP_DRIVE);
return rc == NO_ERROR;
}
static void push_to_host(HWND hwnd)
{
HANDLE h;
char *p;
unsigned long len, i, w;
if (!IsClipboardFormatAvailable(CF_TEXT)) {
bd(CMD_SETLEN, 0);
return;
}
if (!OpenClipboard(hwnd)) return;
h = GetClipboardData(CF_TEXT);
if (h && (p = (char *)GlobalLock(h)) != 0) {
len = lstrlen(p);
if (len > MAX_CLIP) len = MAX_CLIP;
bd(CMD_SETLEN, len);
for (i = 0; i < len; i += 4) {
w = (unsigned char)p[i];
if (i + 1 < len) w |= (unsigned long)(unsigned char)p[i+1] << 8;
if (i + 2 < len) w |= (unsigned long)(unsigned char)p[i+2] << 16;
if (i + 3 < len) w |= (unsigned long)(unsigned char)p[i+3] << 24;
bd(CMD_SETDATA, w);
}
GlobalUnlock(h);
}
CloseClipboard();
}
static void pull_from_host(HWND hwnd)
{
long len;
unsigned long i, w;
HGLOBAL h;
char *p;
len = (long)bd(CMD_GETLEN, 0);
if (len < 0) return;
if (len > MAX_CLIP) len = MAX_CLIP;
h = GlobalAlloc(GMEM_MOVEABLE | GMEM_DDESHARE, (DWORD)len + 1);
if (!h) return;
p = (char *)GlobalLock(h);
for (i = 0; i < (unsigned long)len; i += 4) {
w = bd(CMD_GETDATA, 0);
p[i] = (char)w;
if (i + 1 < (unsigned long)len) p[i+1] = (char)(w >> 8);
if (i + 2 < (unsigned long)len) p[i+2] = (char)(w >> 16);
if (i + 3 < (unsigned long)len) p[i+3] = (char)(w >> 24);
}
p[len] = 0;
GlobalUnlock(h);
if (!OpenClipboard(hwnd)) { GlobalFree(h); return; }
g_ignore++;
EmptyClipboard();
SetClipboardData(CF_TEXT, h);
CloseClipboard();
}
static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
switch (msg) {
case WM_CREATE:
g_next = SetClipboardViewer(hwnd);
SetTimer(hwnd, TIMER_CLIP, POLL_MS, 0);
SetTimer(hwnd, TIMER_MAP, 1, 0);
return 0;
case WM_DRAWCLIPBOARD:
if (g_ignore > 0) g_ignore--;
else push_to_host(hwnd);
if (g_next) SendMessage(g_next, msg, wp, lp);
return 0;
case WM_CHANGECBCHAIN:
if ((HWND)wp == g_next) g_next = (HWND)lp;
else if (g_next) SendMessage(g_next, msg, wp, lp);
return 0;
case WM_TIMER:
if (wp == TIMER_MAP) {
if (map_host_drive() || ++g_map_tries >= MAP_TRIES)
KillTimer(hwnd, TIMER_MAP);
else
SetTimer(hwnd, TIMER_MAP, MAP_DELAY, 0);
return 0;
}
pull_from_host(hwnd);
return 0;
case WM_DESTROY:
ChangeClipboardChain(hwnd, g_next);
KillTimer(hwnd, TIMER_CLIP);
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, msg, wp, lp);
}
int PASCAL WinMain(HINSTANCE hi, HINSTANCE hp, LPSTR cmd, int show)
{
WNDCLASS wc;
HWND hwnd;
MSG msg;
(void)hp; (void)cmd; (void)show;
if (CreateMutex(0, FALSE, "W95Tools") && GetLastError() == ERROR_ALREADY_EXISTS)
return 0;
if (bd_ebx(CMD_VERSION, 0) != VMW_MAGIC) {
MessageBox(0, "VMware backdoor not present.", "W95Tools", MB_OK | MB_ICONSTOP);
return 1;
}
wc.style = 0;
wc.lpfnWndProc = WndProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hi;
wc.hIcon = 0;
wc.hCursor = 0;
wc.hbrBackground = 0;
wc.lpszMenuName = 0;
wc.lpszClassName = "W95Tools";
RegisterClass(&wc);
hwnd = CreateWindow("W95Tools", "W95Tools", WS_OVERLAPPED,
0, 0, 0, 0, 0, 0, hi, 0);
if (!hwnd) return 1;
while (GetMessage(&msg, 0, 0, 0) > 0) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}

View File

@@ -0,0 +1,6 @@
VBMouse - DOS/Windows absolute mouse driver
Copyright (C) 2022 Javier S. Pedro
Licensed under the GNU General Public License, version 2 or later.
Source: https://git.javispedro.com/cgit/vbados.git/
Binaries extracted from https://depot.javispedro.com/vbox/vbados/vbados.flp

View File

@@ -0,0 +1,8 @@
[data]
Version = "3.0"
[disks]
1 =., "VBADOS driver disk", disk1
[pointing.device]
vbmouse = 1:vbmouse.drv, "VBMouse int33 absolute mouse driver", x:*vmd, vbmouse

Binary file not shown.

View File

@@ -0,0 +1,54 @@
# Language: English
# Codepage: 437
#
# Spaces before text must be kept. Be sure that no spaces are
# added to the end of the lines.
#
0.0:Usage:
0.1: VBMOUSE <ACTION> <ARGS..>
0.2:Supported actions and options:
0.3: install Install the driver (default).
0.4: low Install in conventional memory (otherwise UMB).
0.5: uninstall Uninstall the driver from memory.
0.6: wheel <ON|OFF> Enable/disable wheel API support.
0.7: wheelkey <KEY|OFF> Emulate a specific keystroke on wheel scroll.
0.8: Supported keys: updn, pageupdn.
0.9: integ <ON|OFF> Enable/disable VirtualBox integration.
0.10: hostcur <ON|OFF> Enable/disable mouse cursor rendering in the host.
0.11: reset Reset mouse driver.
1.0:Wheel mouse found and enabled\n
1.1:Setting wheel support to %s\n
1.2:enabled
1.3:disabled
1.4:Generate Up Arrow / Down Arrow key presses on wheel movement\n
1.5:Generate PageUp / PageDown key presses on wheel movement\n
1.6:Disabling wheel keystroke generation\n
1.7:VirtualBox integration enabled\n
1.8:Disabled VirtualBox integration\n
1.9:VirtualBox integration already disabled or not available\n
1.10:Setting host cursor to %s\n
1.11:Found VMware protocol version %ld\n
1.12:VMware integration enabled\n
1.13:Disabled VMware integration\n
1.14:VMware integration already disabled or not available\n
1.15:Neither VirtualBox nor VMware integration available\n
1.16:VirtualBox integration not available\n
1.17:Driver installed\n
1.18:Driver uninstalled\n
1.19:Reset mouse driver\n
1.20:\nVBMouse %x.%x (like MSMOUSE %x.%x)\n
1.21:VBMouse already installed\n
3.0:Could not find PS/2 wheel mouse\n
3.1:Wheel not detected or support not enabled\n
3.2:Unknown key '%s'\n
3.3:Cannot find VirtualBox PCI device, err=%d\n
3.4:Cannot lock buffer used for VirtualBox communication, err=%d\n
3.5:VirtualBox communication is not working, err=%d\n
3.6:Could not detect VMware, err=%ld\n
3.7:VMware absolute pointer error, err=0x%lx\n
3.8:Cannot init PS/2 mouse BIOS, err=%d\n
3.9:INT33 has been hooked by someone else, cannot safely remove\n
3.10:INT2F has been hooked by someone else, cannot safely remove\n
3.11:Driver data not found (driver not installed?)\n
3.12:Invalid argument '%s'\n
3.13:Argument required for '%s'\n

20541
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,18 @@
{
"name": "windows95",
"productName": "windows95",
"version": "2.2.0",
"version": "5.0.1",
"description": "Windows 95, in an app. I'm sorry.",
"main": "./dist/src/main/main",
"main": "./dist/src/main/main.js",
"scripts": {
"start": "rimraf ./dist && electron-forge start",
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "prettier --write src/**/*.{ts,tsx}",
"less": "node ./tools/lessc.js"
"lint": "prettier --write src/**/*.{ts,tsx} && npm run check-links",
"tsc": "tsc -p tsconfig.json --noEmit",
"check-links": "node tools/check-links.js",
"postinstall": "patch-package"
},
"keywords": [],
"author": "Felix Rieseberg, felix@felixrieseberg.com",
@@ -18,43 +20,28 @@
"config": {
"forge": "./forge.config.js"
},
"standard": {
"globals": [
"appState",
"V86Starter",
"windows95"
],
"ignore": [
"/src/renderer/lib/*.js"
]
},
"dependencies": {
"electron-squirrel-startup": "^1.0.0",
"fs-extra": "^8.1.0",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"tslib": "^1.10.0",
"update-electron-app": "^1.5.0"
"electron-squirrel-startup": "^1.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"update-electron-app": "^3.1.2"
},
"devDependencies": {
"@electron-forge/cli": "^6.0.0-beta.44",
"@electron-forge/maker-deb": "^6.0.0-beta.44",
"@electron-forge/maker-flatpak": "^6.0.0-beta.44",
"@electron-forge/maker-rpm": "^6.0.0-beta.44",
"@electron-forge/maker-squirrel": "^6.0.0-beta.44",
"@electron-forge/maker-zip": "^6.0.0-beta.44",
"@electron-forge/publisher-github": "^6.0.0-beta.44",
"@types/fs-extra": "^8.0.0",
"@types/node": "^12.7.2",
"@types/react": "^16.9.2",
"@types/react-dom": "^16.8.5",
"electron": "6.0.2",
"less": "^3.10.1",
"node-abi": "^2.11.0",
"parcel-bundler": "^1.12.3",
"prettier": "^1.18.2",
"rimraf": "^3.0.0",
"standard": "^13.1.0",
"typescript": "^3.5.3"
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-flatpak": "7.11.1",
"@electron-forge/maker-rpm": "7.11.1",
"@electron-forge/maker-squirrel": "7.11.1",
"@electron-forge/maker-zip": "7.11.1",
"@electron-forge/publisher-github": "7.11.1",
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.3.1",
"electron": "41.2.0",
"patch-package": "^8.0.1",
"prettier": "^3.8.1",
"typescript": "^6.0.2",
"vite": "^7.3.1"
}
}

View File

@@ -0,0 +1,32 @@
diff --git a/node_modules/@electron/packager/dist/win32.js b/node_modules/@electron/packager/dist/win32.js
index d318f6c..bfde740 100644
--- a/node_modules/@electron/packager/dist/win32.js
+++ b/node_modules/@electron/packager/dist/win32.js
@@ -65,7 +65,26 @@ class WindowsApp extends platform_1.App {
resOpts.iconPath = icon;
}
(0, common_1.debug)(`Running resedit with the options ${JSON.stringify(resOpts)}`);
- await (0, resedit_1.resedit)(this.electronBinaryPath, resOpts);
+
+ // This causes segmentation faults for me on multiple machines
+ // It's unclear why exactly but this spawn hack fixes it
+ // await (0, resedit_1.resedit)(this.electronBinaryPath, resOpts);
+
+ const { spawnSync } = require('child_process');
+ const resEditProcess = spawnSync(process.execPath, [
+ require('path').resolve(process.cwd(), 'tools', 'resedit.js'),
+ this.electronBinaryPath
+ ], {
+ stdio: 'inherit'
+ });
+
+ if (resEditProcess.error) {
+ throw resEditProcess.error;
+ }
+
+ if (resEditProcess.status !== 0) {
+ throw new Error(`Resedit process exited with code ${resEditProcess.status}`);
+ }
}
async signAppIfSpecified() {
const windowsSignOpt = this.opts.windowsSign;

View File

@@ -1,8 +1,8 @@
import { session } from 'electron';
import { session } from "electron";
export async function clearCaches() {
await clearCache()
await clearStorageData()
await clearCache();
await clearStorageData();
}
export async function clearCache() {
@@ -11,15 +11,21 @@ export async function clearCache() {
}
}
export function clearStorageData() {
return new Promise((resolve) => {
if (!session.defaultSession) {
return resolve();
}
export async function clearStorageData() {
if (!session.defaultSession) {
return;
}
session.defaultSession.clearStorageData({
storages: [ 'appcache', 'cookies', 'filesystem', 'indexdb', 'localstorage', 'shadercache', 'websql', 'serviceworkers' ],
quotas: [ 'temporary', 'persistent', 'syncable' ]
}, resolve)
})
await session.defaultSession.clearStorageData({
storages: [
"cookies",
"filesystem",
"indexdb",
"localstorage",
"shadercache",
"websql",
"serviceworkers",
],
quotas: ["temporary"],
});
}

View File

@@ -1,31 +1,50 @@
import { remote, app } from 'electron';
import * as path from 'path';
import * as path from "path";
const _app = app || remote.app
const IMAGES_PATH = path.join(__dirname, "../../images");
// Bump when a release ships a v86/hardware/disk-image change that can't load
// older state-vN.bin snapshots. The app will detect an orphaned older state
// and offer to export the user's old C:\ as a mountable .img.
//
// That export splices the state's dirty-block overlay onto the *current*
// windows95.img — which only works while the partition table and FAT geometry
// stay constant across releases. If you ever resize the disk or reformat with
// different cluster params, the recovered .img won't mount.
export const STATE_VERSION = 5;
export const CONSTANTS = {
IMAGE_PATH: path.join(__dirname, '../../images/windows95.img'),
IMAGES_PATH,
IMAGE_PATH: path.join(IMAGES_PATH, "windows95.img"),
IMAGE_DEFAULT_SIZE: 1073741824, // 1GB
DEFAULT_STATE_PATH: path.join(__dirname, '../../images/default-state.bin'),
STATE_PATH: path.join(_app.getPath('userData'), 'state-v2.bin')
}
DEFAULT_STATE_PATH: path.join(IMAGES_PATH, "default-state.bin"),
TOOLS_PATH: path.join(__dirname, "../../guest-tools"),
};
export const IPC_COMMANDS = {
TOGGLE_INFO: 'TOGGLE_INFO',
SHOW_DISK_IMAGE: 'SHOW_DISK_IMAGE',
ZOOM_IN: 'ZOOM_IN',
ZOOM_OUT: 'ZOOM_OUT',
ZOOM_RESET: 'ZOOM_RESET',
TOGGLE_INFO: "TOGGLE_INFO",
SHOW_DISK_IMAGE: "SHOW_DISK_IMAGE",
ZOOM_IN: "ZOOM_IN",
ZOOM_OUT: "ZOOM_OUT",
ZOOM_RESET: "ZOOM_RESET",
// Machine instructions
MACHINE_START: 'MACHINE_START',
MACHINE_RESTART: 'MACHINE_RESTART',
MACHINE_STOP: 'MACHINE_STOP',
MACHINE_RESET: 'MACHINE_RESET',
MACHINE_ALT_F4: 'MACHINE_ALT_F4',
MACHINE_ESC: 'MACHINE_ESC',
MACHINE_ALT_ENTER: 'MACHINE_ALT_ENTER',
MACHINE_CTRL_ALT_DEL: 'MACHINE_CTRL_ALT_DEL',
MACHINE_START: "MACHINE_START",
MACHINE_BOOT_FROM_SCRATCH: "MACHINE_BOOT_FROM_SCRATCH",
MACHINE_RESTART: "MACHINE_RESTART",
MACHINE_STOP: "MACHINE_STOP",
MACHINE_RESET: "MACHINE_RESET",
MACHINE_ALT_F4: "MACHINE_ALT_F4",
MACHINE_ESC: "MACHINE_ESC",
MACHINE_ALT_ENTER: "MACHINE_ALT_ENTER",
MACHINE_CTRL_ALT_DEL: "MACHINE_CTRL_ALT_DEL",
// Machine events
MACHINE_STARTED: 'MACHINE_STARTED',
MACHINE_STOPPED: 'MACHINE_STOPPED'
}
MACHINE_STARTED: "MACHINE_STARTED",
MACHINE_STOPPED: "MACHINE_STOPPED",
// Else
APP_QUIT: "APP_QUIT",
GET_STATE_PATH: "GET_STATE_PATH",
GET_LEGACY_STATE_PATH: "GET_LEGACY_STATE_PATH",
GET_DOWNLOADS_PATH: "GET_DOWNLOADS_PATH",
GET_SMB_SHARE_PATH: "GET_SMB_SHARE_PATH",
SET_SMB_SHARE_PATH: "SET_SMB_SHARE_PATH",
PICK_FOLDER: "PICK_FOLDER",
};

36
src/css/emulator.css Normal file
View File

@@ -0,0 +1,36 @@
#emulator {
height: 100vh;
width: 100vw;
display: flex;
> div {
white-space: pre;
font: 14px monospace;
line-height: 14px
}
> canvas {
display: none;
margin: auto;
}
/* Guest absolute-mouse driver active: hide the host cursor over the VM so
only the Windows 95 cursor shows. Has to beat `* { cursor: default }`. */
&.seamless-mouse,
&.seamless-mouse * {
cursor: none;
}
}
.paused {
canvas {
opacity: 0.2;
filter: blur(2px);
z-index: -100;
}
#emulator-text-screen {
display: none;
visibility: hidden;
}
}

78
src/css/root.css Normal file
View File

@@ -0,0 +1,78 @@
@import "./status.css";
@import "./emulator.css";
@import "./info.css";
@import "./start.css";
@import "./settings.css";
/* 98.css uses the actual MS Sans Serif bitmap font and pixel-exact bevels.
Everything below is layout — the chrome comes from 98.css. */
:root {
--win-teal: #008080;
--win-font: "Pixelated MS Sans Serif", Arial, sans-serif;
}
* {
user-select: none;
cursor: default;
}
html,
body {
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: #000;
font-family: var(--win-font);
-webkit-font-smoothing: none;
image-rendering: pixelated;
}
body.paused {
background: var(--win-teal);
> #emulator {
display: none;
}
}
button:not(:disabled),
li[role="tab"],
.title-bar-controls button:not(:disabled) {
cursor: pointer;
}
button:focus {
outline: none;
}
/* 98.css renders button text via text-shadow (color: transparent) so the
bitmap font stays crisp; <img> children need their own alignment. */
button img {
height: 16px;
width: 16px;
margin-right: 4px;
vertical-align: -3px;
}
p {
font-family: var(--win-font);
font-size: 11px;
line-height: 1.5;
}
code {
font-family: "Courier New", monospace;
font-size: 11px;
}
section {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}

71
src/css/settings.css Normal file
View File

@@ -0,0 +1,71 @@
.settings-window {
width: 460px;
> .window-body {
margin: 8px;
}
}
.settings-panel {
padding: 3px;
> .window-body {
margin: 12px;
min-height: 220px;
}
fieldset {
margin: 0;
}
}
.settings-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
p {
margin: 0;
}
}
.settings-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.settings-window .field-row-stacked {
margin-bottom: 12px;
input[type="text"] {
width: 100%;
font-family: var(--win-font);
}
input[type="text"]:read-only {
background-color: #fff;
color: #222;
}
}
.settings-buttons {
display: flex;
gap: 6px;
button {
min-width: 110px;
}
}
.settings-footer {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 10px;
button {
min-width: 75px;
}
}

117
src/css/start.css Normal file
View File

@@ -0,0 +1,117 @@
/* "Welcome to Windows" splash — modelled on the real first-boot dialog. */
.welcome {
width: 540px;
}
.welcome-body {
display: flex;
align-items: stretch;
gap: 12px;
margin: 4px;
min-height: 250px;
}
.welcome-stripe {
width: 26px;
background: linear-gradient(180deg, #000 0%, navy 60%, #1084d0 100%);
position: relative;
flex-shrink: 0;
span {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%) rotate(180deg);
writing-mode: vertical-rl;
color: #fff;
font-weight: 700;
font-size: 14px;
letter-spacing: 1px;
white-space: nowrap;
}
}
.welcome-main {
flex: 1;
display: flex;
flex-direction: column;
padding: 8px 4px;
}
.welcome-title {
font-family: "Times New Roman", serif;
-webkit-font-smoothing: antialiased;
font-weight: 400;
font-size: 24px;
margin: 0 0 14px;
color: #000;
span {
font-weight: 700;
}
small {
color: #fff;
font-size: 24px;
font-weight: 700;
vertical-align: baseline;
margin-left: 1px;
}
}
.welcome-tip {
flex: 1;
background: #ffffe1;
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf,
inset 2px 2px #0a0a0a;
padding: 12px 14px;
.welcome-tip-header {
border-bottom: 1px solid grey;
box-shadow: 0 1px 0 #fff;
padding-bottom: 6px;
margin-bottom: 8px;
font-size: 11px;
}
p {
margin: 0;
}
}
.welcome-warn {
background: #fff;
p {
margin: 0 0 8px;
}
.welcome-warn-buttons {
display: flex;
gap: 6px;
margin-top: 4px;
button {
height: 24px;
}
}
}
.welcome-actions {
width: 130px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 4px;
flex-shrink: 0;
button {
width: 100%;
height: 24px;
}
.welcome-spacer {
flex: 1;
}
}

55
src/css/status.css Normal file
View File

@@ -0,0 +1,55 @@
#status-hotzone {
position: absolute;
z-index: 99;
top: 0;
left: 0;
right: 0;
height: 8px;
}
#status {
user-select: none;
position: absolute;
z-index: 100;
left: 50vw;
transform: translateX(-50%);
white-space: nowrap;
background: white;
font-size: 10px;
padding-bottom: 3px;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
overflow: hidden;
padding-left: 10px;
padding-right: 10px;
max-height: 18px;
top: 0;
transition: transform 0.12s ease-out;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
a.toggle {
display: inline-block;
width: 4ch;
text-align: center;
}
.spark {
vertical-align: -2px;
margin-right: 4px;
polyline {
fill: none;
stroke: currentColor;
stroke-width: 1;
}
}
&.hidden {
transform: translateX(-50%) translateY(-100%);
}
}
#status-hotzone:hover + #status.hidden,
#status.hidden:hover {
transform: translateX(-50%) translateY(0);
}

2
src/css/vendor/98.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
src/css/vendor/ms_sans_serif.woff vendored Normal file

Binary file not shown.

BIN
src/css/vendor/ms_sans_serif.woff2 vendored Normal file

Binary file not shown.

BIN
src/css/vendor/ms_sans_serif_bold.woff vendored Normal file

Binary file not shown.

BIN
src/css/vendor/ms_sans_serif_bold.woff2 vendored Normal file

Binary file not shown.

View File

@@ -1,24 +0,0 @@
#emulator {
height: 100vh;
width: 100vw;
display: flex;
> div {
white-space: pre;
font: 14px monospace;
line-height: 14px
}
> canvas {
display: none;
margin: auto;
}
}
.paused {
canvas {
opacity: 0.2;
filter: blur(2px);
z-index: -100;
}
}

View File

@@ -1,103 +0,0 @@
@import "./status.less";
@import "./emulator.less";
@import "./info.less";
@import "./settings.less";
@import "./start.less";
/* GENERAL RESETS */
html, body {
margin: 0;
padding: 0;
}
body {
background: #000;
}
body.paused > #emulator {
display: none;
}
body.paused {
background: #008080;
font-family: Courier;
}
#buttons {
user-select: none;
}
section {
display: flex;
position: absolute;
width: 100vw;
height: 100vh;
align-items: center;
justify-content: center;
}
.card {
width: 75%;
max-width: 700px;
min-width: 400px;
.card-title {
img {
margin-right: 5px;
}
}
}
.nav-link > img,
.btn > img {
height: 24px;
margin-right: 4px;
}
.windows95 {
* {
user-select: none;
}
*:focus {
outline: none;
}
nav .nav-link,
nav .nav-logo {
height: 37px;
display: flex;
}
nav .nav-logo img {
margin-left: 2px;
max-height: 20px;
}
nav .nav-logo > span {
position: absolute;
top: 9px;
left: 37px;
font-weight: bold;
}
.btn {
height: 40px;
padding-top: 3px;
}
.btn:focus {
border-color: #fff #000 #000 #fff;
outline: 5px auto -webkit-focus-ring-color;
}
.btn.active:before,
.btn:focus:before,
button.active:before,
button:focus:before,
input[type=submit].active:before,
input[type=submit]:focus:before {
border-color: #dedede grey grey #dedede;
}
}

View File

@@ -1,21 +0,0 @@
#floppy-path {
font-size: .6rem;
width: 100%;
height: 30px;
padding-left: 8px;
border-color: #000 #fff #fff #000;
border-style: solid;
border-width: 2px;
background-color: #c3c3c3;
line-height: 27px;
}
#file-input {
display: none;
}
.settings {
legend > img {
margin-right: 5px;
}
}

View File

@@ -1,9 +0,0 @@
#section-start {
display: flex;
flex-direction: column;
> small {
margin-top: 25px;
font-size: .8rem;
}
}

View File

@@ -1,16 +0,0 @@
#status {
user-select: none;
position: absolute;
z-index: 100;
left: calc(50vw - 110px);
background: white;
font-size: 10px;
padding-bottom: 3px;
border-bottom-left-radius: 15px;
border-bottom-right-radius: 15px;
overflow: hidden;
padding-left: 10px;
padding-right: 10px;
max-height: 18px;
top: 0;
}

BIN
src/less/vendor/95.ttf vendored

Binary file not shown.

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -12,7 +12,7 @@ export function setupAboutPanel(): void {
applicationName: "windows95",
applicationVersion: app.getVersion(),
version: process.versions.electron,
copyright: "Felix Rieseberg"
copyright: "Felix Rieseberg",
};
switch (process.platform) {

View File

@@ -0,0 +1,64 @@
import { protocol } from "electron";
import * as fs from "fs";
import * as path from "path";
import { log } from "../logging";
// Serves the bundled static/www site to the guest at http://windows95/.
// Host-filesystem browsing was removed in favour of the SMB share.
const APP_INTERCEPT = "http://windows95/";
const WWW_ROOT = path.resolve(__dirname, "../../../static/www");
export function setupFileServer() {
protocol.handle("http", async (request) => {
if (!request.url.startsWith(APP_INTERCEPT)) {
return fetch(request.url, {
headers: request.headers,
method: request.method,
body: request.body,
});
}
try {
const rel = decodeURIComponent(request.url.slice(APP_INTERCEPT.length));
let fullPath = path.join(WWW_ROOT, rel);
if (fullPath !== WWW_ROOT && !fullPath.startsWith(WWW_ROOT + path.sep)) {
fullPath = WWW_ROOT;
}
log(`FileServer: ${request.url}${fullPath}`);
const stats = await fs.promises.stat(fullPath);
if (stats.isDirectory()) fullPath = path.join(fullPath, "index.htm");
return await serveFile(fullPath);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
const status = code === "ENOENT" ? 404 : code === "EACCES" ? 403 : 500;
return new Response(`${status} ${code ?? "Error"}: ${request.url}`, {
status,
headers: { "Content-Type": "text/plain" },
});
}
});
}
const CONTENT_TYPES: Record<string, string> = {
".htm": "text/html",
".html": "text/html",
".txt": "text/plain",
".css": "text/css",
".js": "text/javascript",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
};
async function serveFile(fullPath: string): Promise<Response> {
const fileData = await fs.promises.readFile(fullPath);
const ext = path.extname(fullPath).toLowerCase();
return new Response(fileData, {
status: 200,
headers: {
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
},
});
}

65
src/main/ipc.ts Normal file
View File

@@ -0,0 +1,65 @@
import { ipcMain, app, dialog, BrowserWindow } from "electron";
import * as path from "path";
import * as fs from "fs";
import { IPC_COMMANDS, STATE_VERSION } from "../constants";
import { settings } from "./settings";
const statePathFor = (v: number) =>
path.join(app.getPath("userData"), `state-v${v}.bin`);
export function setupIpcListeners() {
ipcMain.handle(IPC_COMMANDS.GET_STATE_PATH, () => {
return statePathFor(STATE_VERSION);
});
ipcMain.handle(IPC_COMMANDS.GET_LEGACY_STATE_PATH, () => {
// If the user already has a current-version state, there's nothing to
// rescue — either they've migrated or never had an older one.
if (fs.existsSync(statePathFor(STATE_VERSION))) return null;
// v2/v3 predate the overlay-rescue machinery and aren't worth supporting.
for (let v = STATE_VERSION - 1; v >= 4; v--) {
const p = statePathFor(v);
if (fs.existsSync(p)) return p;
}
return null;
});
ipcMain.handle(IPC_COMMANDS.GET_DOWNLOADS_PATH, () => {
return app.getPath("downloads");
});
ipcMain.handle(IPC_COMMANDS.APP_QUIT, () => {
app.quit();
});
ipcMain.handle(IPC_COMMANDS.GET_SMB_SHARE_PATH, () => {
return settings.get("smbSharePath");
});
ipcMain.handle(IPC_COMMANDS.SET_SMB_SHARE_PATH, (_e, p: unknown) => {
// The only legitimate caller is the folder picker, which can't return
// a non-existent path — but the renderer has nodeIntegration so any
// code there can call this IPC. Reject anything that isn't an existing
// directory; otherwise SmbSession's realpathSync throws inside a TCP
// callback on next launch and the share silently never connects.
if (typeof p !== "string") return false;
let real: string;
try {
real = fs.realpathSync(p);
if (!fs.statSync(real).isDirectory()) return false;
} catch {
return false;
}
settings.set("smbSharePath", real);
return true;
});
ipcMain.handle(IPC_COMMANDS.PICK_FOLDER, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
const result = await dialog.showOpenDialog(win!, {
properties: ["openDirectory"],
});
return result.canceled ? null : result.filePaths[0];
});
}

3
src/main/logging.ts Normal file
View File

@@ -0,0 +1,3 @@
export function log(message: string, ...args: unknown[]) {
console.log(`[${new Date().toLocaleString()}] ${message}`, ...args);
}

View File

@@ -6,6 +6,9 @@ import { shouldQuit } from "./squirrel";
import { setupUpdates } from "./update";
import { getOrCreateWindow } from "./windows";
import { setupMenu } from "./menu";
import { setupIpcListeners } from "./ipc";
import { setupSession } from "./session";
import { setupFileServer } from "./fileserver/fileserver";
/**
* Handle the app's "ready" event. This is essentially
@@ -14,10 +17,13 @@ import { setupMenu } from "./menu";
export async function onReady() {
if (!isDevMode()) process.env.NODE_ENV = "production";
setupSession();
setupIpcListeners();
getOrCreateWindow();
setupAboutPanel();
setupMenu();
setupUpdates();
setupFileServer();
}
/**
@@ -55,6 +61,15 @@ export function main() {
return;
}
if (isDevMode()) {
// Renderer DevTools Protocol — connect Chrome to chrome://inspect
// or attach a debugger to localhost:9222
app.commandLine.appendSwitch(
"remote-debugging-port",
process.env.WIN95_DEBUG_PORT || "9222",
);
}
// Set the app's name
app.setName("windows95");

View File

@@ -1,25 +1,25 @@
import { app, shell, Menu, BrowserWindow, ipcMain, webFrame } from "electron";
import { app, shell, Menu, BrowserWindow, ipcMain, dialog } from "electron";
import { clearCaches } from "../cache";
import { IPC_COMMANDS } from "../constants";
import { isDevMode } from "../utils/devmode";
import { getOrCreateWindow } from "./windows";
import { log } from "./logging";
const LINKS = {
homepage: "https://www.twitter.com/felixrieseberg",
homepage: "https://www.felixrieseberg.com",
repo: "https://github.com/felixrieseberg/windows95",
credits: "https://github.com/felixrieseberg/windows95/blob/master/CREDITS.md",
help: "https://github.com/felixrieseberg/windows95/blob/master/HELP.md"
help: "https://github.com/felixrieseberg/windows95/blob/master/HELP.md",
};
export async function setupMenu() {
await createMenu();
ipcMain.on(IPC_COMMANDS.MACHINE_STARTED, () =>
createMenu({ isRunning: true })
createMenu({ isRunning: true }),
);
ipcMain.on(IPC_COMMANDS.MACHINE_STOPPED, () =>
createMenu({ isRunning: false })
createMenu({ isRunning: false }),
);
}
@@ -27,10 +27,10 @@ function send(cmd: string) {
const windows = BrowserWindow.getAllWindows();
if (windows[0]) {
console.log(`Sending "${cmd}"`);
log(`Sending "${cmd}"`);
windows[0].webContents.send(cmd);
} else {
console.log(`Tried to send "${cmd}", but could not find window`);
log(`Tried to send "${cmd}", but could not find window`);
}
}
@@ -41,52 +41,52 @@ async function createMenu({ isRunning } = { isRunning: false }) {
submenu: [
{
label: "Toggle Full Screen",
accelerator: (function() {
accelerator: (function () {
if (process.platform === "darwin") {
return "Ctrl+Command+F";
} else {
return "F11";
}
})(),
click: function(_item, focusedWindow) {
click: function (_item, focusedWindow) {
if (focusedWindow) {
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
}
}
},
},
{
label: "Toggle Developer Tools",
accelerator: (function() {
accelerator: (function () {
if (process.platform === "darwin") {
return "Alt+Command+I";
} else {
return "Ctrl+Shift+I";
}
})(),
click: function(_item, focusedWindow) {
if (focusedWindow) {
click: function (_item, focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
focusedWindow.webContents.toggleDevTools();
}
}
},
},
{
type: "separator"
type: "separator",
},
{
label: "Toggle Emulator Info",
click: () => send(IPC_COMMANDS.TOGGLE_INFO)
click: () => send(IPC_COMMANDS.TOGGLE_INFO),
},
{
type: "separator"
type: "separator",
},
{
role: "reload"
}
]
role: "reload",
},
],
},
{
role: "editMenu",
visible: isDevMode()
visible: isDevMode(),
},
{
label: "Window",
@@ -95,86 +95,104 @@ async function createMenu({ isRunning } = { isRunning: false }) {
{
label: "Minimize",
accelerator: "CmdOrCtrl+M",
role: "minimize"
role: "minimize",
},
{
label: "Close",
accelerator: "CmdOrCtrl+W",
role: "close"
role: "close",
},
{
type: "separator"
type: "separator",
},
{
label: "Zoom in",
click: () => send(IPC_COMMANDS.ZOOM_IN),
enabled: isRunning
enabled: isRunning,
},
{
label: "Zoom out",
click: () => send(IPC_COMMANDS.ZOOM_OUT),
enabled: isRunning
enabled: isRunning,
},
{
label: "Reset zoom",
click: () => send(IPC_COMMANDS.ZOOM_RESET),
enabled: isRunning
}
]
enabled: isRunning,
},
],
},
{
label: "Machine",
submenu: [
isRunning
? {
label: "Stop",
click: () => send(IPC_COMMANDS.MACHINE_STOP),
}
: {
label: "Start",
click: () => send(IPC_COMMANDS.MACHINE_START),
},
{
label: "Start without state",
click: () => send(IPC_COMMANDS.MACHINE_BOOT_FROM_SCRATCH),
},
{
label: "Restart",
click: () => send(IPC_COMMANDS.MACHINE_RESTART),
enabled: isRunning,
},
{
label: "Reset",
click: async () => {
const result = await dialog.showMessageBox({
type: "warning",
buttons: ["Reset", "Cancel"],
defaultId: 1,
title: "Reset Machine",
message: "Are you sure you want to reset the machine?",
detail:
"This will delete the machine state, including all changes you have made.",
});
if (result.response === 0) {
send(IPC_COMMANDS.MACHINE_RESET);
}
},
enabled: isRunning,
},
{
type: "separator",
},
{
label: "Send Ctrl+Alt+Del",
click: () => send(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL),
enabled: isRunning
enabled: isRunning,
},
{
label: "Send Alt+F4",
click: () => send(IPC_COMMANDS.MACHINE_ALT_F4),
enabled: isRunning
enabled: isRunning,
},
{
label: "Send Alt+Enter",
click: () => send(IPC_COMMANDS.MACHINE_ALT_ENTER),
enabled: isRunning
enabled: isRunning,
},
{
label: "Send Esc",
click: () => send(IPC_COMMANDS.MACHINE_ESC),
enabled: isRunning
enabled: isRunning,
},
{
type: "separator"
},
isRunning
? {
label: "Stop",
click: () => send(IPC_COMMANDS.MACHINE_STOP)
}
: {
label: "Start",
click: () => send(IPC_COMMANDS.MACHINE_START)
},
{
label: "Restart",
click: () => send(IPC_COMMANDS.MACHINE_RESTART),
enabled: isRunning
},
{
label: "Reset",
click: () => send(IPC_COMMANDS.MACHINE_RESET),
enabled: isRunning
},
{
type: "separator"
type: "separator",
},
{
label: "Go to Disk Image",
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE)
}
]
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),
},
],
},
{
label: "Help",
@@ -182,18 +200,18 @@ async function createMenu({ isRunning } = { isRunning: false }) {
submenu: [
{
label: "Author",
click: () => shell.openExternal(LINKS.homepage)
click: () => shell.openExternal(LINKS.homepage),
},
{
label: "windows95 on GitHub",
click: () => shell.openExternal(LINKS.repo)
click: () => shell.openExternal(LINKS.repo),
},
{
label: "Help",
click: () => shell.openExternal(LINKS.help)
click: () => shell.openExternal(LINKS.help),
},
{
type: "separator"
type: "separator",
},
{
label: "Troubleshooting",
@@ -205,12 +223,12 @@ async function createMenu({ isRunning } = { isRunning: false }) {
app.relaunch();
app.quit();
}
}
]
}
]
}
},
},
],
},
],
},
];
if (process.platform === "darwin") {
@@ -218,41 +236,41 @@ async function createMenu({ isRunning } = { isRunning: false }) {
label: "windows95",
submenu: [
{
role: "about"
role: "about",
},
{
type: "separator"
type: "separator",
},
{
role: "services"
role: "services",
},
{
type: "separator"
type: "separator",
},
{
label: "Hide windows95",
accelerator: "Command+H",
role: "hide"
role: "hide",
},
{
label: "Hide Others",
accelerator: "Command+Shift+H",
role: "hideothers"
role: "hideothers",
},
{
role: "unhide"
role: "unhide",
},
{
type: "separator"
type: "separator",
},
{
label: "Quit",
accelerator: "Command+Q",
click() {
app.quit();
}
}
]
},
},
],
} as any);
}

18
src/main/session.ts Normal file
View File

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

63
src/main/settings.ts Normal file
View File

@@ -0,0 +1,63 @@
import * as fs from "fs";
import * as path from "path";
import { app } from "electron";
export interface Settings {
smbSharePath: string;
}
const DEFAULT_SETTINGS: Settings = {
smbSharePath: app.getPath("downloads"),
};
class SettingsManager {
private filePath: string;
private data: Settings;
constructor() {
this.filePath = path.join(app.getPath("userData"), "settings.json");
this.data = this.load();
}
private load(): Settings {
try {
if (fs.existsSync(this.filePath)) {
const fileContent = fs.readFileSync(this.filePath, "utf8");
const parsed = JSON.parse(fileContent);
return {
...DEFAULT_SETTINGS,
...parsed,
};
}
} catch (error) {
console.error("Error loading settings:", error);
}
return DEFAULT_SETTINGS;
}
private save(): void {
try {
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
} catch (error) {
console.error("Error saving settings:", error);
}
}
get(key: keyof Settings): any {
return this.data[key];
}
set<K extends keyof Settings>(key: K, value: Settings[K]): void {
this.data[key] = value;
this.save();
}
clear(): void {
this.data = DEFAULT_SETTINGS;
this.save();
}
}
export const settings = new SettingsManager();

View File

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

View File

@@ -1,7 +1,28 @@
import { BrowserWindow } from "electron";
import { BrowserWindow, shell } from "electron";
import { execFileSync } from "child_process";
import { isDevMode } from "../utils/devmode";
let mainWindow: BrowserWindow | null = null;
function getDevBranchSuffix(): string {
if (!isDevMode()) return "";
try {
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
encoding: "utf8",
}).trim();
if (branch && branch !== "master" && branch !== "main") {
return ` (${branch})`;
}
} catch {
// git not available or not a repo — ignore
}
return "";
}
export function getOrCreateWindow(): BrowserWindow {
if (mainWindow) return mainWindow;
@@ -13,15 +34,35 @@ export function getOrCreateWindow(): BrowserWindow {
webPreferences: {
nodeIntegration: true,
sandbox: false,
webviewTag: false
}
webviewTag: false,
contextIsolation: false,
},
});
const branchSuffix = getDevBranchSuffix();
if (branchSuffix) {
mainWindow.on("page-title-updated", (event, title) => {
event.preventDefault();
mainWindow?.setTitle(`${title}${branchSuffix}`);
});
}
mainWindow.loadFile("./dist/static/index.html");
mainWindow.webContents.on("will-navigate", (event, url) =>
handleNavigation(event, url),
);
mainWindow.on("closed", () => {
mainWindow = null;
});
return mainWindow;
}
function handleNavigation(event: Electron.Event, url: string) {
if (url.startsWith("http")) {
event.preventDefault();
shell.openExternal(url);
}
}

View File

@@ -1,3 +1,15 @@
import "../css/vendor/98.css";
import "../css/root.css";
export interface Win95Window extends Window {
emulator: any;
win95: {
app: App;
};
}
declare let window: Win95Window;
/**
* The top-level class controlling the whole app. This is *not* a React component,
* but it does eventually render all components.
@@ -9,9 +21,9 @@ export class App {
* Initial setup call, loading Monaco and kicking off the React
* render process.
*/
public async setup(): Promise<void | Element> {
public async setup(): Promise<void> {
const React = await import("react");
const { render } = await import("react-dom");
const { createRoot } = await import("react-dom/client");
const { Emulator } = await import("./emulator");
const className = `${process.platform}`;
@@ -21,14 +33,13 @@ export class App {
</div>
);
const rendered = render(app, document.getElementById("app"));
return rendered;
const root = createRoot(document.getElementById("app")!);
root.render(app);
}
}
window["win95"] = window["win95"] || {
app: new App()
window.win95 = window.win95 || {
app: new App(),
};
window["win95"].app.setup();
window.win95.app.setup();

View File

@@ -1,111 +0,0 @@
import * as React from "react";
import { shell } from "electron";
interface CardDriveProps {
showDiskImage: () => void;
}
interface CardDriveState {}
export class CardDrive extends React.Component<CardDriveProps, CardDriveState> {
constructor(props: CardDriveProps) {
super(props);
this.state = {};
}
public render() {
let advice: JSX.Element | null = null;
if (process.platform === "win32") {
advice = this.renderAdviceWindows();
} else if (process.platform === "darwin") {
advice = this.renderAdviceMac();
} else {
advice = this.renderAdviceLinux();
}
return (
<section>
<div className="card settings">
<div className="card-header">
<h2 className="card-title">
<img src="../../static/drive.png" />
Modify C: Drive
</h2>
</div>
<div className="card-body">
<p>
windows95 (this app) uses a raw disk image. Windows 95 (the
operating system) is fragile, so adding or removing files is
risky.
</p>
{advice}
</div>
</div>
</section>
);
}
public renderAdviceWindows(): JSX.Element {
return (
<fieldset>
<legend>Changing the disk on Windows</legend>
<p>
Windows 10 cannot mount raw disk images (ironically, macOS and Linux
can). However, tools exist that let you mount this drive, like the
freeware tool{" "}
<a
href="#"
onClick={() =>
shell.openExternal(
"https://www.osforensics.com/tools/mount-disk-images.html"
)
}
>
OSFMount
</a>
. I am not affiliated with it, so please use it at your own risk.
</p>
{this.renderMountButton("Windows Explorer")}
</fieldset>
);
}
public renderAdviceMac(): JSX.Element {
return (
<fieldset>
<legend>Changing the disk on macOS</legend>
<p>
macOS can mount the disk image directly. Click the button below to see
the disk image in Finder. Then, double-click the image to mount it.
</p>
{this.renderMountButton("Finder")}
</fieldset>
);
}
public renderAdviceLinux(): JSX.Element {
return (
<fieldset>
<legend>Changing the disk on Linux</legend>
<p>
There are plenty of tools that enable Linux users to mount and modify
disk images. The disk image used by windows95 is a raw "img" disk
image and can probably be mounted using the <code>mount</code> tool,
which is likely installed on your machine.
</p>
{this.renderMountButton("file viewer")}
</fieldset>
);
}
public renderMountButton(explorer: string) {
return (
<button className="btn" onClick={this.props.showDiskImage}>
<img src="../../static/show-disk-image.png" />
<span>Show disk image in {explorer}</span>
</button>
);
}
}

View File

@@ -1,15 +1,28 @@
import * as React from "react";
import * as fs from "fs-extra";
import { CONSTANTS } from "../constants";
import { resetState } from "./utils/reset-state";
import { InfoBarSettings } from "./info-bar-settings";
const CDROM_ENABLED = true;
interface CardSettingsProps {
bootFromScratch: () => void;
setFloppy: (file: File) => void;
setCdrom: (file: File) => void;
setSmbSharePath: (path: string) => void;
pickFolder: () => Promise<string | null>;
navigate: (to: "start" | "settings") => void;
floppy?: File;
cdrom?: File;
smbSharePath: string;
infoBarSettings: InfoBarSettings;
setInfoBarSettings: (s: InfoBarSettings) => void;
}
type Tab = "floppy" | "cdrom" | "network" | "interface" | "state";
interface CardSettingsState {
tab: Tab;
isStateReset: boolean;
}
@@ -21,120 +34,253 @@ export class CardSettings extends React.Component<
super(props);
this.onChangeFloppy = this.onChangeFloppy.bind(this);
this.onChangeCdrom = this.onChangeCdrom.bind(this);
this.onResetState = this.onResetState.bind(this);
this.state = {
isStateReset: false
tab: "floppy",
isStateReset: false,
};
}
public render() {
const { tab } = this.state;
return (
<section>
<div className="card settings">
<div className="card-header">
<h2 className="card-title">
<img src="../../static/settings.png" />
Settings
</h2>
</div>
<div className="card-body">
{this.renderFloppy()}
<hr />
{this.renderState()}
<div className="window settings-window">
<div className="title-bar">
<div className="title-bar-text">windows95 Properties</div>
<div className="title-bar-controls">
<button aria-label="Help" disabled />
<button
aria-label="Close"
onClick={() => this.props.navigate("start")}
/>
</div>
</div>
</section>
<div className="window-body">
<menu role="tablist">
{this.renderTab("floppy", "Floppy Drive")}
{CDROM_ENABLED && this.renderTab("cdrom", "CD-ROM")}
{this.renderTab("network", "Shared Folder")}
{this.renderTab("interface", "Interface")}
{this.renderTab("state", "Machine State")}
</menu>
<div className="window settings-panel" role="tabpanel">
<div className="window-body">
{tab === "floppy" && this.renderFloppy()}
{tab === "cdrom" && this.renderCdrom()}
{tab === "network" && this.renderSmbShare()}
{tab === "interface" && this.renderInterface()}
{tab === "state" && this.renderState()}
</div>
</div>
<div className="settings-footer">
<button
className="default"
onClick={() => this.props.navigate("start")}
>
OK
</button>
<button onClick={() => this.props.navigate("start")}>Cancel</button>
</div>
</div>
</div>
);
}
public renderFloppy() {
private renderTab(id: Tab, label: string) {
return (
<li
role="tab"
aria-selected={this.state.tab === id}
onClick={() => this.setState({ tab: id })}
>
<a href="#">{label}</a>
</li>
);
}
private renderFloppy() {
const { floppy } = this.props;
return (
<fieldset>
<legend>
<img src="../../static/floppy.png" />
Floppy
</legend>
<legend>Drive A:</legend>
<input
id="floppy-input"
type="file"
onChange={this.onChangeFloppy}
style={{ display: "none" }}
/>
<p>
windows95 comes with a virtual floppy drive. It can mount floppy disk
images in the "img" format.
</p>
<p>
Back in the 90s and before CD-ROMs became a popular, software was
typically distributed on floppy disks. Some developers have since
released their apps or games for free, usually on virtual floppy disks
using the "img" format.
</p>
<p>
Once you've mounted a disk image, you might have to boot your virtual
windows95 machine from scratch.
</p>
<p id="floppy-path">
{floppy
? `Inserted Floppy Disk: ${floppy.path}`
: `No floppy mounted`}
</p>
<button
className="btn"
onClick={() =>
(document.querySelector("#floppy-input") as any).click()
}
>
<img src="../../static/select-floppy.png" />
<span>Mount floppy disk</span>
</button>
</fieldset>
);
}
public renderState() {
const { isStateReset } = this.state;
const { bootFromScratch } = this.props;
return (
<fieldset>
<legend>
<img src="../../static/reset.png" />
Reset machine state
</legend>
<div>
<div className="settings-row">
<img className="settings-icon" src="../../static/floppy.png" />
<p>
windows95 stores changes to your machine (like saved files) in a
state file. If you encounter any trouble, you can reset your state
or boot Windows 95 from scratch.{" "}
<strong>All your changes will be lost.</strong>
windows95 ships with a virtual 3½" floppy drive. Mount an{" "}
<code>.img</code> disk image here, then boot the machine to read it
from inside Windows.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="floppy-path">Mounted image</label>
<input
id="floppy-path"
type="text"
readOnly
value={floppy ? floppy.name : "(No disk in drive)"}
/>
</div>
<div className="settings-buttons">
<button
className="btn"
onClick={this.onResetState}
disabled={isStateReset}
style={{ marginRight: "5px" }}
onClick={() =>
(document.querySelector("#floppy-input") as any).click()
}
>
<img src="../../static/reset-state.png" />
{isStateReset ? "State reset" : "Reset state"}
</button>
<button className="btn" onClick={bootFromScratch}>
<img src="../../static/boot-fresh.png" />
Boot from scratch
Mount image...
</button>
</div>
</fieldset>
);
}
/**
* Handle a change in the floppy input
*
* @param event
*/
private renderCdrom() {
const { cdrom } = this.props;
return (
<fieldset>
<legend>Drive D:</legend>
<input
id="cdrom-input"
type="file"
onChange={this.onChangeCdrom}
style={{ display: "none" }}
/>
<div className="settings-row">
<img className="settings-icon" src="../../static/cdrom.png" />
<p>
windows95 ships with a virtual CD-ROM drive. Mount an{" "}
<code>.iso</code> image here, then boot the machine to read it from
inside Windows.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="cdrom-path">Mounted image</label>
<input
id="cdrom-path"
type="text"
readOnly
value={cdrom ? cdrom.name : "(No disc in drive)"}
/>
</div>
<div className="settings-buttons">
<button
onClick={() =>
(document.querySelector("#cdrom-input") as any).click()
}
>
Mount image...
</button>
</div>
</fieldset>
);
}
private renderSmbShare() {
const { smbSharePath } = this.props;
return (
<fieldset>
<legend>Drive Z:</legend>
<div className="settings-row">
<img
className="settings-icon"
src="../../static/show-disk-image.png"
/>
<p>
A folder on your computer is mounted inside Windows 95 as drive{" "}
<code>Z:</code>. Open My Computer inside Windows to find it.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="smb-path">Shared folder</label>
<input id="smb-path" type="text" readOnly value={smbSharePath} />
</div>
<div className="settings-buttons">
<button
onClick={async () => {
const picked = await this.props.pickFolder();
if (picked) this.props.setSmbSharePath(picked);
}}
>
Choose folder...
</button>
</div>
</fieldset>
);
}
private renderInterface() {
const { infoBarSettings, setInfoBarSettings } = this.props;
const checkbox = (key: keyof InfoBarSettings, label: string) => (
<div className="field-row">
<input
id={`ibs-${key}`}
type="checkbox"
checked={infoBarSettings[key]}
onChange={(e) =>
setInfoBarSettings({ ...infoBarSettings, [key]: e.target.checked })
}
/>
<label htmlFor={`ibs-${key}`}>{label}</label>
</div>
);
return (
<fieldset>
<legend>Info bar</legend>
<div className="settings-row">
<img className="settings-icon" src="../../static/settings.png" />
<p>
The bar at the top of the emulator shows live machine stats. Choose
which metrics to display and whether to draw sparkline graphs next
to them.
</p>
</div>
{checkbox("showCpu", "Show CPU speed")}
{checkbox("showDisk", "Show disk throughput")}
{checkbox("showNet", "Show network throughput")}
{checkbox("showSparklines", "Show sparklines")}
</fieldset>
);
}
private renderState() {
const { isStateReset } = this.state;
const { bootFromScratch } = this.props;
return (
<fieldset>
<legend>Reset</legend>
<div className="settings-row">
<img className="settings-icon" src="../../static/reset.png" />
<p>
Changes to your machine (saved files, installed programs) are stored
in a state file. If something breaks, you can either discard that
state or boot a fresh copy of Windows from scratch.{" "}
<strong>All your changes will be lost.</strong>
</p>
</div>
<div className="settings-buttons">
<button onClick={this.onResetState} disabled={isStateReset}>
{isStateReset ? "State has been reset" : "Reset state"}
</button>
<button onClick={bootFromScratch}>Boot from scratch</button>
</div>
</fieldset>
);
}
private onChangeFloppy(event: React.ChangeEvent<HTMLInputElement>) {
const floppyFile =
event.target.files && event.target.files.length > 0
@@ -148,14 +294,21 @@ export class CardSettings extends React.Component<
}
}
/**
* Handle the state reset
*/
private async onResetState() {
if (fs.existsSync(CONSTANTS.STATE_PATH)) {
await fs.remove(CONSTANTS.STATE_PATH);
}
private onChangeCdrom(event: React.ChangeEvent<HTMLInputElement>) {
const cdromFile =
event.target.files && event.target.files.length > 0
? event.target.files[0]
: null;
if (cdromFile) {
this.props.setCdrom(cdromFile);
} else {
console.log(`Cdrom: Input changed but no file selected`);
}
}
private async onResetState() {
await resetState();
this.setState({ isStateReset: true });
}
}

View File

@@ -2,18 +2,166 @@ import * as React from "react";
export interface CardStartProps {
startEmulator: () => void;
navigate: (to: "start" | "settings") => void;
legacyStatePath: string | null;
legacyRecovered: { dir: string; files: number } | null;
legacyRecoverBusy: boolean;
legacyRecoverError: string | null;
recoverLegacy: () => void;
showRecovered: () => void;
discardLegacy: () => void;
}
export class CardStart extends React.Component<CardStartProps, {}> {
const TIPS = [
"Press the Escape key at any time to release or recapture your mouse cursor.",
"You can mount a floppy image from Settings before booting to install vintage software.",
"A folder from your real computer is mounted as drive Z: — open My Computer inside Windows to find it.",
"Your machine state is saved automatically when you quit. Reset it from Settings if things get weird.",
"Use the Machine menu in the menubar to send Ctrl+Alt+Del and other special key combos.",
];
export class CardStart extends React.Component<CardStartProps> {
private tip = TIPS[Math.floor(Math.random() * TIPS.length)];
public render() {
return (
<section id="section-start">
<button className="btn" id="win95" onClick={this.props.startEmulator}>
<img src="../../static/run.png" />
<span>Start Windows 95</span>
</button>
<small>Hit ESC to lock or unlock your mouse</small>
</section>
<div className="window welcome" id="welcome-window">
<div className="title-bar">
<div className="title-bar-text">Welcome</div>
<div className="title-bar-controls">
<button aria-label="Minimize" disabled />
<button aria-label="Maximize" disabled />
<button aria-label="Close" disabled />
</div>
</div>
<div className="window-body welcome-body">
<aside className="welcome-stripe">
<span>Windows&nbsp;95</span>
</aside>
<div className="welcome-main">
<h1 className="welcome-title">
Welcome to <span>Windows</span>
<small>95</small>
</h1>
{this.props.legacyStatePath
? this.renderLegacyNotice()
: this.renderTip()}
</div>
<div className="welcome-actions">
<button
id="win95"
className="default"
onClick={this.props.startEmulator}
>
<u>S</u>tart Windows 95
</button>
<button onClick={() => this.props.navigate("settings")}>
S<u>e</u>ttings...
</button>
<div className="welcome-spacer" />
<button disabled>What's New</button>
</div>
</div>
</div>
);
}
private renderTip() {
return (
<div className="welcome-tip">
<div className="welcome-tip-header">
<strong>Did you know...</strong>
</div>
<p>{this.tip}</p>
</div>
);
}
private renderLegacyNotice() {
const { legacyRecovered, legacyRecoverBusy, legacyRecoverError } =
this.props;
if (legacyRecoverError) {
return (
<div className="welcome-tip welcome-warn">
<div className="welcome-tip-header">
<strong>Recovery failed</strong>
</div>
<p>
The old snapshot's format isn't compatible with the bundled
emulator, so files couldn't be extracted automatically. The snapshot
has been kept on disk.
</p>
<p>
<code>{legacyRecoverError}</code>
</p>
<div className="welcome-warn-buttons">
<button onClick={this.props.discardLegacy}>
Discard old snapshot
</button>
</div>
</div>
);
}
if (legacyRecovered) {
return (
<div className="welcome-tip welcome-warn">
<div className="welcome-tip-header">
<strong>Old C:\ recovered</strong>
</div>
<p>
{legacyRecovered.files} file
{legacyRecovered.files === 1 ? "" : "s"} you created or modified
have been copied out as ordinary files. Starting Windows here will
be a fresh machine.
</p>
<p>
<code>{legacyRecovered.dir}</code>
</p>
<div className="welcome-warn-buttons">
<button className="default" onClick={this.props.showRecovered}>
Open folder
</button>
<button onClick={this.props.discardLegacy}>
Discard old snapshot
</button>
</div>
</div>
);
}
return (
<div className="welcome-tip welcome-warn">
<div className="welcome-tip-header">
<strong>Your saved machine is from an older version</strong>
</div>
<p>
This release ships a new disk image and machine configuration. Files
you saved to <code>C:\</code> live only in the old snapshot.
</p>
<p>
Recovery copies anything you created or modified out to an ordinary
folder on this computer no booting, no disk images. Pre-installed
programs are skipped.
</p>
<div className="welcome-warn-buttons">
<button
className="default"
disabled={legacyRecoverBusy}
onClick={this.props.recoverLegacy}
>
{legacyRecoverBusy ? "Recovering…" : "Recover old C:\\ drive…"}
</button>
<button
disabled={legacyRecoverBusy}
onClick={this.props.discardLegacy}
>
Discard it
</button>
</div>
</div>
);
}
}

62
src/renderer/clipboard.ts Normal file
View File

@@ -0,0 +1,62 @@
// Bidirectional text clipboard between the host and the Win95 guest.
//
// Transport is the legacy VMware backdoor clipboard protocol (port 0x5658,
// commands 69) implemented in v86's vmware.js. Inside the guest, W95TOOLS.EXE
// (guest-tools/agent) polls the backdoor and bridges it to CF_TEXT via the
// Win32 clipboard-viewer chain. Out here we poll Electron's clipboard — there
// is no change event — and translate between host UTF-8/LF and guest
// Windows-1252/CRLF.
import { clipboard } from "electron";
const CP1252 = new TextDecoder("windows-1252");
// v86's vmware.js and the guest agent both clamp to 64 KB; clamp here too so
// a huge host clipboard never even gets allocated/encoded.
const CLIP_MAX = 0x10000;
function fromGuest(bytes: Uint8Array): string {
return CP1252.decode(bytes).replace(/\r\n/g, "\n");
}
function toGuest(text: string): Uint8Array {
const s = text.slice(0, CLIP_MAX).replace(/\r\n|\n/g, "\r\n");
const n = Math.min(s.length, CLIP_MAX);
const out = new Uint8Array(n);
for (let i = 0; i < n; i++) {
const c = s.charCodeAt(i);
out[i] = c < 256 ? c : 0x3f;
}
return out;
}
export function setupClipboardSync(emulator: any): () => void {
// Track the last value seen on each side so a value we just wrote doesn't
// bounce back as a "change" from the other side.
let lastHost = clipboard.readText();
let lastGuest = "";
emulator.add_listener("vmware-clipboard-guest", (bytes: Uint8Array) => {
const text = fromGuest(bytes);
if (text === lastHost || text === lastGuest) return;
lastGuest = text;
lastHost = text;
clipboard.writeText(text);
console.log("[clip] guest → host", text.length, "chars");
});
const poll = () => {
const text = clipboard.readText();
if (text === lastHost) return;
lastHost = text;
if (text === lastGuest) return;
emulator.bus.send("vmware-clipboard-host", toGuest(text));
console.log("[clip] host → guest", text.length, "chars");
};
const id = window.setInterval(poll, 500);
window.addEventListener("focus", poll);
return () => {
window.clearInterval(id);
window.removeEventListener("focus", poll);
};
}

View File

@@ -0,0 +1,579 @@
// Autonomous boot probe. Started from emulator.tsx when WIN95_PROBE=1.
// Writes status + screenshot to /tmp so an outer loop can read them
// without DevTools or CDP.
import * as fs from "fs";
const STATUS_FILE = process.env.WIN95_PROBE_STATUS || "/tmp/win95-probe.json";
const SCREEN_FILE = process.env.WIN95_PROBE_SCREEN || "/tmp/win95-screen.png";
const TICK_MS = 5000;
interface ProbeStatus {
ts: string;
uptimeSec: number;
phase: "init" | "running" | "text-mode" | "splash" | "desktop" | "done";
cpuRunning: boolean;
instructionCounter: number;
instructionDelta: number;
textScreen: string;
textHash: string;
gfxW: number;
gfxH: number;
dominantColor: string;
verdict:
| ""
| "SUCCESS"
| "FAIL_IOS"
| "FAIL_KRNL386"
| "FAIL_VXDLINK"
| "FAIL_PROTECTION"
| "FAIL_SPLASH_HANG"
| "FAIL_HUNG"
| "FAIL_OTHER";
}
let startTime = 0;
let lastInstr = 0;
let lastTextHash = "";
let stableTextTicks = 0;
// XT scancodes (set 1). Win95 doesn't have Win+R — that landed in Win98.
// Ctrl+Esc opens Start, then R is the underlined mnemonic for "Run...".
const SC = {
CTRL_DN: [0x1d],
CTRL_UP: [0x9d],
ESC_DN: [0x01],
ESC_UP: [0x81],
R_DN: [0x13],
R_UP: [0x93],
ENTER_DN: [0x1c],
ENTER_UP: [0x9c],
BACKSLASH_DN: [0x2b],
BACKSLASH_UP: [0xab],
ALT_DN: [0x38],
ALT_UP: [0xb8],
};
// WIN95_PROBE_CDTRACE=1 → wrap secondary-IDE ata_command/atapi_handle and
// log every command so we can see whether Win95's ESDI_506/CDVSD stack ever
// talks to the drive (and which ATAPI CDBs it sends).
const CDTRACE_FILE =
process.env.WIN95_PROBE_CDTRACE_FILE || "/tmp/win95-cdtrace.log";
let cdTraceArmed = false;
function armCdTrace(emulator: any) {
const dev = emulator.v86?.cpu?.devices;
if (!dev || cdTraceArmed) return;
cdTraceArmed = true;
const sec = dev.ide?.secondary;
fs.writeFileSync(
CDTRACE_FILE,
`[probe] cd buffer=${!!dev.cdrom?.buffer} bytes=${dev.cdrom?.buffer?.byteLength} is_atapi=${sec?.master?.is_atapi}\n`,
);
const t0 = Date.now();
const log = (s: string) =>
fs.appendFileSync(
CDTRACE_FILE,
`[${((Date.now() - t0) / 1000).toFixed(2)}s] ${s}\n`,
);
const proto = Object.getPrototypeOf(sec?.master || {});
for (const m of ["ata_command", "atapi_handle"]) {
const orig = proto?.[m];
if (typeof orig !== "function") continue;
proto[m] = function (this: any, ...a: any[]) {
if (this === sec?.master || this === sec?.slave) {
const who = this === sec.master ? "sm" : "ss";
if (m === "ata_command")
log(`${who} ata cmd=0x${(a[0] ?? 0).toString(16)}`);
else {
const d = this.data || [];
const cdb = Array.from(d.slice?.(0, 12) || [])
.map((b: any) => b.toString(16).padStart(2, "0"))
.join(" ");
log(`${who} atapi cmd=0x${(d[0] ?? 0).toString(16)} cdb=[${cdb}]`);
}
}
return orig.apply(this, a);
};
}
console.log("[probe] cd trace armed");
}
// WIN95_PROBE_VGATRACE=1 → wrap VGA I/O ports at the io.ports[] layer (the
// VGAScreen.portXXX_write methods are captured by-value at registration time,
// so monkey-patching them on the instance is a no-op for most ports). Each
// entry is [port, op, value, "eip VMPE cplN"] so you can tell vgabios in V86
// mode apart from the ring-0 display driver.
const VGATRACE_FILE = "/tmp/win95-vgatrace.json";
let vgaTrace: any[] | undefined;
function armVgaTrace(emulator: any) {
const cpu = emulator.v86?.cpu;
const io = cpu?.io;
if (!io || vgaTrace) return;
vgaTrace = [];
const ctx = () => {
try {
const ip = (cpu.instruction_pointer[0] >>> 0).toString(16);
const vm = cpu.flags[0] & (1 << 17) ? "VM" : " ";
const pe = cpu.cr[0] & 1 ? "PE" : " ";
return `${ip} ${vm}${pe} cpl${cpu.cpl[0]}`;
} catch {
return "?";
}
};
const W = [
0x3c0, 0x3c2, 0x3c4, 0x3c5, 0x3ce, 0x3cf, 0x3d4, 0x3d5, 0x3b4, 0x3b5, 0x1ce,
0x1cf,
];
const R = [0x1cf, 0x3da, 0x3c1];
for (const p of W)
for (const w of ["write8", "write16"]) {
const orig = io.ports[p][w];
io.ports[p][w] = function (v: number) {
vgaTrace!.push([p, w, v, ctx()]);
return orig.call(this, v);
};
}
for (const p of R)
for (const r of ["read8", "read16"]) {
const orig = io.ports[p][r];
io.ports[p][r] = function () {
const v = orig.call(this);
vgaTrace!.push([p, r, v, ctx()]);
return v;
};
}
console.log("[probe] vga trace armed");
}
function dumpVgaTrace(emulator: any) {
if (!vgaTrace) return;
const d = emulator.v86?.cpu?.devices?.vga;
const state = d && {
svga_enabled: d.svga_enabled,
graphical_mode: d.graphical_mode,
attribute_mode: d.attribute_mode,
miscellaneous_graphics_register: d.miscellaneous_graphics_register,
sequencer_memory_mode: d.sequencer_memory_mode,
clocking_mode: d.clocking_mode,
plane_write_bm: d.plane_write_bm,
crtc_mode: d.crtc_mode,
max_scan_line: d.max_scan_line,
underline_location_register: d.underline_location_register,
horizontal_display_enable_end: d.horizontal_display_enable_end,
horizontal_blank_start: d.horizontal_blank_start,
vertical_display_enable_end: d.vertical_display_enable_end,
vertical_blank_start: d.vertical_blank_start,
offset_register: d.offset_register,
dispi_enable_value: d.dispi_enable_value,
screen_width: d.screen_width,
screen_height: d.screen_height,
max_cols: d.max_cols,
max_rows: d.max_rows,
};
fs.writeFileSync(VGATRACE_FILE, JSON.stringify({ state, trace: vgaTrace }));
}
function sendChord(emu: any, ...keys: { dn: number[]; up: number[] }[]) {
for (const k of keys) emu.keyboard_send_scancodes(k.dn);
setTimeout(() => {
for (let i = keys.length - 1; i >= 0; i--)
emu.keyboard_send_scancodes(keys[i].up);
}, 60);
}
function sendKey(emu: any, dn: number[], up: number[]) {
emu.keyboard_send_scancodes(dn);
setTimeout(() => emu.keyboard_send_scancodes(up), 50);
}
/** Replay a list of actions: {type:"keys",dn,up} | {type:"text",text} | {type:"wait",ms} */
function runScript(emu: any, steps: any[]) {
let i = 0;
const next = () => {
if (i >= steps.length) {
console.log("[probe] script done");
return;
}
const s = steps[i++];
if (s.type === "wait") {
setTimeout(next, s.ms);
return;
}
if (s.type === "keys") {
sendKey(emu, s.dn, s.up);
setTimeout(next, 200);
return;
}
if (s.type === "chord") {
sendChord(emu, ...s.keys);
setTimeout(next, 200);
return;
}
if (s.type === "text") {
// keyboard_send_text handles ASCII → scancode for us
emu.keyboard_send_text(s.text);
setTimeout(next, 100 + s.text.length * 30);
return;
}
next();
};
next();
}
export function startProbe(emulator: any) {
startTime = Date.now();
console.log("[probe] writing to", STATUS_FILE);
// WIN95_PROBE_SCRIPT=\\HOST → after desktop, send Win+R, type, Enter
const scriptCmd = process.env.WIN95_PROBE_SCRIPT;
// WIN95_PROBE_RUN='telnet 1.2.3.4 7777' → literal text into Start→Run,
// Enter, then optional WIN95_PROBE_RUN_AFTER keystrokes after _RUN_WAIT ms.
// WIN95_PROBE_RUN2 fires a second Start→Run sequence after _RUN2_WAIT ms,
// for two-process scenarios (e.g., background ping + telnet).
const runCmd = process.env.WIN95_PROBE_RUN;
const runCmd2 = process.env.WIN95_PROBE_RUN2;
const runAfter = process.env.WIN95_PROBE_RUN_AFTER;
// WIN95_PROBE_DOSBOX=1 → after desktop, open COMMAND.COM, type `dir`,
// optionally Alt+Enter to fullscreen. Regression test for the windowed
// DOS box clobbering VBE (felixrieseberg/v86 vga-defer-vbe-disable-v86).
const dosBox = process.env.WIN95_PROBE_DOSBOX === "1";
const wantVgaTrace = process.env.WIN95_PROBE_VGATRACE === "1";
const wantCdTrace = process.env.WIN95_PROBE_CDTRACE === "1";
let scriptArmed = !!scriptCmd || !!runCmd || dosBox;
const tick = () => {
try {
if (wantVgaTrace && !vgaTrace) armVgaTrace(emulator);
if (wantCdTrace && !cdTraceArmed) armCdTrace(emulator);
const s = collectStatus(emulator);
fs.writeFileSync(STATUS_FILE, JSON.stringify(s, null, 2));
// Try to capture a screenshot — this can fail if the screen adapter
// isn't ready yet, so we swallow that.
try {
// rAF doesn't fire when the Electron window is occluded, so the
// screen adapter's render loop stalls. Pump one frame by hand.
try {
emulator.screen_adapter?.update_screen?.();
} catch {}
const img: HTMLImageElement = emulator.screen_make_screenshot();
// The Image has a data: URL src; decode it to bytes
if (img && img.src && img.src.startsWith("data:image/png;base64,")) {
const b64 = img.src.slice("data:image/png;base64,".length);
fs.writeFileSync(SCREEN_FILE, Buffer.from(b64, "base64"));
}
} catch {}
dumpVgaTrace(emulator);
// Once at desktop, fire the keyboard script (once). The 8s settle is
// for the "Welcome to Windows 95" tip dialog to be dismissable —
// we send Esc first to clear it.
if (scriptArmed && s.phase === "desktop" && s.uptimeSec > 8) {
scriptArmed = false;
if (dosBox) {
console.log("[probe] desktop detected, opening DOS box");
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: "command" },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
{ type: "wait", ms: 5000 },
{ type: "text", text: "dir" },
{ type: "wait", ms: 200 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
{ type: "wait", ms: 3000 },
...(process.env.WIN95_PROBE_DOSBOX_ALTENTER === "1"
? [
{
type: "chord",
keys: [
{ dn: SC.ALT_DN, up: SC.ALT_UP },
{ dn: SC.ENTER_DN, up: SC.ENTER_UP },
],
},
{ type: "wait", ms: 4000 },
]
: []),
]);
return;
}
if (runCmd) {
console.log("[probe] desktop detected, Run →", runCmd);
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP },
{ type: "wait", ms: 1000 },
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: runCmd },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
...(runCmd2
? [
{
type: "wait",
ms: Number(process.env.WIN95_PROBE_RUN2_WAIT) || 3000,
},
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: runCmd2 },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]
: []),
...(runAfter
? [
{
type: "wait",
ms: Number(process.env.WIN95_PROBE_RUN_WAIT) || 6000,
},
{ type: "text", text: runAfter },
{ type: "wait", ms: 200 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]
: []),
]);
return;
}
console.log("[probe] desktop detected, running script:", scriptCmd);
runScript(emulator, [
{ type: "wait", ms: 3000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // dismiss any dialog
{ type: "wait", ms: 1000 },
{ type: "keys", dn: SC.ESC_DN, up: SC.ESC_UP }, // again, for safety
{ type: "wait", ms: 1000 },
{
type: "chord",
keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
],
}, // Ctrl+Esc → Start
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP }, // Run mnemonic
{ type: "wait", ms: 1000 },
// keyboard_send_text can't reliably do backslash, so we interleave:
// scancode for each \ segment, text for each name segment.
// WIN95_PROBE_SCRIPT='HOST/HOST' → types \\HOST\HOST (we use / as
// the segment separator in the env var to dodge shell escaping hell)
...scriptCmd!.split("/").flatMap((seg, i) => [
...(i === 0
? [
{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP },
{ type: "wait", ms: 60 },
{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP },
]
: [{ type: "keys", dn: SC.BACKSLASH_DN, up: SC.BACKSLASH_UP }]),
{ type: "wait", ms: 60 },
{ type: "text", text: seg },
{ type: "wait", ms: 100 },
]),
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
]);
}
if (s.verdict) {
console.log("[probe] VERDICT:", s.verdict);
fs.writeFileSync(STATUS_FILE.replace(".json", ".done"), s.verdict);
}
} catch (e) {
console.log("[probe] tick error:", e);
}
};
tick();
setInterval(tick, TICK_MS);
}
function collectStatus(emulator: any): ProbeStatus {
const uptimeSec = (Date.now() - startTime) / 1000;
// CPU activity — instruction counter is u32 in wasm, wraps every ~4B
let instr = 0,
running = false;
try {
instr = emulator.get_instruction_counter() || 0;
} catch {}
try {
running = emulator.is_running();
} catch {}
const instrDelta = (instr - lastInstr) >>> 0;
lastInstr = instr;
// Text screen — only meaningful in text mode (BIOS, DOS, BSOD).
// In graphics mode this returns garbage or empty.
let textScreen = "";
try {
const screen = emulator.screen_adapter || emulator.v86?.screen_adapter;
if (screen) {
const rows = screen.get_text_screen?.() || [];
textScreen = rows
.map((r: string) => r.trimEnd())
.join("\n")
.trim();
}
} catch {}
// VGA state tells us everything: in graphics or text, and at what resolution.
// Win95 splash: 320×400. Win95 desktop: ≥640×480.
// Old v86 builds (pre-2025) don't expose screen_width/screen_height — fall
// back to the rendered canvas dimensions so the bisect harness works across
// versions.
let inGraphics = false,
gfxW = 0,
gfxH = 0;
try {
const vga = emulator.v86?.cpu?.devices?.vga;
if (vga) {
inGraphics = !!vga.graphical_mode;
gfxW = vga.screen_width || 0;
gfxH = vga.screen_height || 0;
}
} catch {}
if (gfxW === 0) {
try {
const canvas = document.querySelector(
"#emulator canvas",
) as HTMLCanvasElement | null;
if (canvas && canvas.width > 0) {
gfxW = canvas.width;
gfxH = canvas.height;
// Canvas exists with content → assume graphics. Text mode uses a div.
const textDiv = document.querySelector(
"#emulator div",
) as HTMLElement | null;
inGraphics =
canvas.style.display !== "none" &&
(!textDiv || textDiv.style.display === "none");
}
} catch {}
}
// Sample the framebuffer to identify which screen we're on.
// Splash is sky-blue gradient (R~120 G~175 B~215). Desktop is teal (0,128,128).
let dominantColor = "";
if (inGraphics) {
try {
const canvas = document.querySelector(
"#emulator canvas",
) as HTMLCanvasElement | null;
if (canvas) {
const ctx = canvas.getContext("2d")!;
const cx = Math.floor(canvas.width / 2);
const cy = Math.floor(canvas.height / 3); // upper-third → sky on splash, taskbar-free on desktop
const px = ctx.getImageData(cx, cy, 1, 1).data;
dominantColor = `${px[0]},${px[1]},${px[2]}`;
}
} catch {}
}
const textHash = hashStr(textScreen);
if (!inGraphics && textHash === lastTextHash && textScreen) stableTextTicks++;
else stableTextTicks = 0;
lastTextHash = textHash;
const hasMeaningfulText =
!inGraphics && textScreen.length > 20 && /[A-Za-z]{4,}/.test(textScreen);
const atSplash = inGraphics && gfxW > 0 && gfxW < 640;
const atDesktop = inGraphics && gfxW >= 640;
const phase: ProbeStatus["phase"] = !running
? "init"
: atDesktop
? "desktop"
: atSplash
? "splash"
: hasMeaningfulText
? "text-mode"
: "running";
let verdict: ProbeStatus["verdict"] = "";
const t = inGraphics ? "" : textScreen.toLowerCase();
if (t.includes("krnl386")) verdict = "FAIL_KRNL386";
else if (t.includes("vxd dynamic link")) verdict = "FAIL_VXDLINK";
else if (
t.includes("initializing device ios") &&
t.includes("protection error")
)
verdict = "FAIL_IOS";
else if (t.includes("windows protection error")) verdict = "FAIL_PROTECTION";
// Stuck at splash for >70s with CPU spinning → IDE IRQ never fired
else if (atSplash && uptimeSec > 70) verdict = "FAIL_SPLASH_HANG";
// Stuck on text for 40s
else if (stableTextTicks >= 8 && instrDelta > 1_000_000)
verdict = "FAIL_HUNG";
// CPU dead
else if (running && instrDelta < 1000 && uptimeSec > 30)
verdict = "FAIL_HUNG";
// Made it to ≥640×480 graphics → desktop reached. But if a keyboard
// script is running, hold off — the outer harness reads the SMB log
// directly and we just keep the app alive.
else if (
atDesktop &&
uptimeSec > 30 &&
!process.env.WIN95_PROBE_SCRIPT &&
!process.env.WIN95_PROBE_RUN &&
!process.env.WIN95_PROBE_DOSBOX
)
verdict = "SUCCESS";
// Timeout
else if (uptimeSec > 180) verdict = "FAIL_OTHER";
return {
ts: new Date().toISOString(),
uptimeSec: Math.round(uptimeSec),
phase,
cpuRunning: running,
instructionCounter: instr,
instructionDelta: instrDelta,
textScreen: textScreen.slice(0, 2000),
textHash,
gfxW,
gfxH,
dominantColor,
verdict,
};
}
function hashStr(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(16);
}

View File

@@ -1,48 +1,120 @@
import * as React from "react";
import { InfoBarSettings } from "./info-bar-settings";
interface EmulatorInfoProps {
toggleInfo: () => void;
emulator: any;
hidden: boolean;
settings: InfoBarSettings;
}
interface EmulatorInfoState {
cpu: number;
disk: string;
diskRead: number;
diskWrite: number;
netRx: number;
netTx: number;
lastCounter: number;
lastTick: number;
history: {
cpu: number[];
diskRead: number[];
diskWrite: number[];
netRx: number[];
netTx: number[];
};
}
const HISTORY_LEN = 30;
function Sparkline({ data }: { data: number[] }) {
const w = 20;
const h = 12;
const max = Math.max(1, ...data);
const step = data.length > 1 ? w / (data.length - 1) : 0;
const points = data
.map((v, i) => `${i * step},${h - (v / max) * h}`)
.join(" ");
return (
<svg className="spark" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
<polyline points={points} />
</svg>
);
}
export class EmulatorInfo extends React.Component<
EmulatorInfoProps,
EmulatorInfoState
> {
private cpuInterval = -1;
private tickInterval = -1;
private diskReadBytes = 0;
private diskWriteBytes = 0;
private netRxBytes = 0;
private netTxBytes = 0;
constructor(props: EmulatorInfoProps) {
super(props);
this.cpuCount = this.cpuCount.bind(this);
this.onIDEReadStart = this.onIDEReadStart.bind(this);
this.onIDEReadWriteEnd = this.onIDEReadWriteEnd.bind(this);
this.tick = this.tick.bind(this);
this.onIDEReadEnd = this.onIDEReadEnd.bind(this);
this.onIDEWriteEnd = this.onIDEWriteEnd.bind(this);
this.onEthReceiveEnd = this.onEthReceiveEnd.bind(this);
this.onEthTransmitEnd = this.onEthTransmitEnd.bind(this);
this.state = {
cpu: 0,
disk: "Idle",
diskRead: 0,
diskWrite: 0,
netRx: 0,
netTx: 0,
lastCounter: 0,
lastTick: 0
lastTick: 0,
history: {
cpu: new Array(HISTORY_LEN).fill(0),
diskRead: new Array(HISTORY_LEN).fill(0),
diskWrite: new Array(HISTORY_LEN).fill(0),
netRx: new Array(HISTORY_LEN).fill(0),
netTx: new Array(HISTORY_LEN).fill(0),
},
};
}
public render() {
const { cpu, disk } = this.state;
const { cpu, diskRead, diskWrite, netRx, netTx, history } = this.state;
const { hidden, toggleInfo, settings } = this.props;
const { showCpu, showDisk, showNet, showSparklines: spark } = settings;
return (
<div id="status">
Disk: <span>{disk}</span> | CPU Speed: <span>{cpu}</span> |{" "}
<a href="#" onClick={this.props.toggleInfo}>
Hide
</a>
</div>
<>
<div id="status-hotzone" />
<div id="status" className={hidden ? "hidden" : ""}>
{showCpu && (
<>
CPU: {spark && <Sparkline data={history.cpu} />}
{this.fmt(cpu, ["M", "G"])}/s |{" "}
</>
)}
{showDisk && (
<>
Disk: {spark && <Sparkline data={history.diskRead} />}R{" "}
{this.fmt(diskRead, ["B", "K", "M", "G"])}/s{" "}
{spark && <Sparkline data={history.diskWrite} />}W{" "}
{this.fmt(diskWrite, ["B", "K", "M", "G"])}/s |{" "}
</>
)}
{showNet && (
<>
Net: {spark && <Sparkline data={history.netRx} />}
{this.fmt(netRx, ["B", "K", "M", "G"])}/s{" "}
{spark && <Sparkline data={history.netTx} />}
{this.fmt(netTx, ["B", "K", "M", "G"])}/s |{" "}
</>
)}
<a href="#" className="toggle" onClick={toggleInfo}>
{hidden ? "Pin" : "Hide"}
</a>
</div>
</>
);
}
@@ -74,26 +146,22 @@ export class EmulatorInfo extends React.Component<
if (!emulator) {
console.log(
`Emulator info: Tried to install listeners, but emulator not defined yet.`
`Emulator info: Tried to install listeners, but emulator not defined yet.`,
);
return;
}
// CPU
if (this.cpuInterval > -1) {
clearInterval(this.cpuInterval);
if (this.tickInterval > -1) {
clearInterval(this.tickInterval);
}
// TypeScript think's we're using a Node.js setInterval. We're not.
this.cpuInterval = (setInterval(this.cpuCount, 500) as unknown) as number;
this.tickInterval = setInterval(this.tick, 500) as unknown as number;
// Disk
emulator.add_listener("ide-read-start", this.onIDEReadStart);
emulator.add_listener("ide-read-end", this.onIDEReadWriteEnd);
emulator.add_listener("ide-write-end", this.onIDEReadWriteEnd);
// Screen
emulator.add_listener("screen-set-size-graphical", console.log);
emulator.add_listener("ide-read-end", this.onIDEReadEnd);
emulator.add_listener("ide-write-end", this.onIDEWriteEnd);
emulator.add_listener("eth-receive-end", this.onEthReceiveEnd);
emulator.add_listener("eth-transmit-end", this.onEthTransmitEnd);
}
/**
@@ -104,63 +172,93 @@ export class EmulatorInfo extends React.Component<
if (!emulator) {
console.log(
`Emulator info: Tried to uninstall listeners, but emulator not defined yet.`
`Emulator info: Tried to uninstall listeners, but emulator not defined yet.`,
);
return;
}
// CPU
if (this.cpuInterval > -1) {
clearInterval(this.cpuInterval);
if (this.tickInterval > -1) {
clearInterval(this.tickInterval);
}
// Disk
emulator.remove_listener("ide-read-start", this.onIDEReadStart);
emulator.remove_listener("ide-read-end", this.onIDEReadWriteEnd);
emulator.remove_listener("ide-write-end", this.onIDEReadWriteEnd);
emulator.remove_listener("ide-read-end", this.onIDEReadEnd);
emulator.remove_listener("ide-write-end", this.onIDEWriteEnd);
emulator.remove_listener("eth-receive-end", this.onEthReceiveEnd);
emulator.remove_listener("eth-transmit-end", this.onEthTransmitEnd);
}
// Screen
emulator.remove_listener("screen-set-size-graphical", console.log);
private onIDEReadEnd(args: number[]) {
this.diskReadBytes += args[1];
}
private onIDEWriteEnd(args: number[]) {
this.diskWriteBytes += args[1];
}
private onEthReceiveEnd(args: number[]) {
this.netRxBytes += args[0];
}
private onEthTransmitEnd(args: number[]) {
this.netTxBytes += args[0];
}
/**
* The virtual IDE is handling read (start).
* Format a value as "N.NU" by walking the unit ladder until it fits in
* one digit before the decimal. Always exactly 4 chars (e.g. "0.0B",
* "3.2K", "9.9G") so the bar width never changes.
*/
private onIDEReadStart() {
this.requestIdle(() => this.setState({ disk: "Read" }));
private fmt(value: number, units: string[]) {
let v = Math.max(0, value);
let u = 0;
while (v >= 10 && u < units.length - 1) {
v /= 1000;
u++;
}
if (v >= 9.95) v = 9.9;
return `${v.toFixed(1)}${units[u]}`;
}
/**
* The virtual IDE is handling read/write (end).
* Once per interval, compute CPU speed and I/O throughput.
*/
private onIDEReadWriteEnd() {
this.requestIdle(() => this.setState({ disk: "Idle" }));
}
/**
* Request an idle callback with a 3s timeout.
*
* @param fn
*/
private requestIdle(fn: () => void) {
(window as any).requestIdleCallback(fn, { timeout: 3000 });
}
/**
* Calculates what's up with the virtual cpu.
*/
private cpuCount() {
private tick() {
const { lastCounter, lastTick } = this.state;
const now = Date.now();
const instructionCounter = this.props.emulator.get_instruction_counter();
const ips = instructionCounter - lastCounter;
const deltaTime = now - lastTick;
const deltaSec = deltaTime / 1000;
this.setState({
const cpu = Math.round(ips / deltaTime / 1000);
const diskRead = Math.round(this.diskReadBytes / deltaSec);
const diskWrite = Math.round(this.diskWriteBytes / deltaSec);
const netRx = Math.round(this.netRxBytes / deltaSec);
const netTx = Math.round(this.netTxBytes / deltaSec);
const push = (arr: number[], v: number) => [...arr, v].slice(-HISTORY_LEN);
this.setState((s) => ({
lastTick: now,
lastCounter: instructionCounter,
cpu: Math.round(ips / deltaTime)
});
cpu,
diskRead,
diskWrite,
netRx,
netTx,
history: {
cpu: push(s.history.cpu, cpu),
diskRead: push(s.history.diskRead, diskRead),
diskWrite: push(s.history.diskWrite, diskWrite),
netRx: push(s.history.netRx, netRx),
netTx: push(s.history.netTx, netTx),
},
}));
this.diskReadBytes = 0;
this.diskWriteBytes = 0;
this.netRxBytes = 0;
this.netTxBytes = 0;
}
}

View File

@@ -1,30 +1,66 @@
import * as React from "react";
import * as fs from "fs-extra";
import * as fs from "fs";
import * as path from "path";
import { ipcRenderer, remote, shell } from "electron";
import { ipcRenderer, shell, webUtils } from "electron";
import { CONSTANTS, IPC_COMMANDS } from "../constants";
import { getDiskImageSize } from "../utils/disk-image-size";
import { CardStart } from "./card-start";
import { StartMenu } from "./start-menu";
import { CardSettings } from "./card-settings";
import { EmulatorInfo } from "./emulator-info";
import { CardDrive } from "./card-drive";
import {
InfoBarSettings,
loadInfoBarSettings,
saveInfoBarSettings,
} from "./info-bar-settings";
import { getStatePath, getLegacyStatePath } from "./utils/get-state-path";
import { recoverLegacyDisk } from "./utils/recover-legacy-disk";
import { Win95Window } from "./app";
import { resetState } from "./utils/reset-state";
import { setupSmbShare } from "./smb";
import { setupTcpRelay } from "./net/tcp-relay";
import { setupTcpTrace } from "./net/tcp-trace";
import { setupDnsShim } from "./net/dns-shim";
import { setupClipboardSync } from "./clipboard";
import { startProbe } from "./debug-harness";
import { SyncFileBuffer } from "./sync-file-buffer";
const PROBE = process.env.WIN95_PROBE === "1";
const PROBE_OPTS: Record<string, unknown> = (() => {
try {
return JSON.parse(process.env.WIN95_PROBE_OPTS || "{}");
} catch {
return {};
}
})();
declare let window: Win95Window;
export interface EmulatorState {
currentUiCard: string;
currentUiCard: "start" | "settings";
emulator?: any;
scale: number;
floppyFile?: File;
cdromFile?: File;
smbSharePath: string;
isBootingFresh: boolean;
isCursorCaptured: boolean;
hasAbsoluteMouse: boolean;
isInfoDisplayed: boolean;
isRunning: boolean;
infoBarSettings: InfoBarSettings;
legacyStatePath: string | null;
legacyRecovered: { dir: string; files: number } | null;
legacyRecoverBusy: boolean;
legacyRecoverError: string | null;
}
export class Emulator extends React.Component<{}, EmulatorState> {
private isQuitting = false;
private isResetting = false;
// Mirrors state.hasAbsoluteMouse but updated synchronously — setState is
// batched, and the lock/unlock decisions can't wait for a render.
private absoluteMouse = false;
constructor(props: {}) {
super(props);
@@ -36,20 +72,37 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.bootFromScratch = this.bootFromScratch.bind(this);
this.state = {
isBootingFresh: false,
isBootingFresh: PROBE,
isCursorCaptured: false,
hasAbsoluteMouse: false,
isRunning: false,
legacyStatePath: null,
legacyRecovered: null,
legacyRecoverBusy: false,
legacyRecoverError: null,
currentUiCard: "start",
isInfoDisplayed: true,
scale: 1
smbSharePath: "",
infoBarSettings: loadInfoBarSettings(),
// We can start pretty large
// If it's too large, it'll just grow until it hits borders
scale: 2,
};
this.setupInputListeners();
this.setupIpcListeners();
this.setupUnloadListeners();
if (document.location.hash.includes("AUTO_START")) {
this.startEmulator();
ipcRenderer.invoke(IPC_COMMANDS.GET_SMB_SHARE_PATH).then((p: string) => {
this.setState({ smbSharePath: p });
});
getLegacyStatePath().then((p) => this.setState({ legacyStatePath: p }));
if (PROBE) {
// Skip the start card; boot fresh immediately. The 100ms delay
// lets React mount the #emulator div first.
setTimeout(() => this.bootFromScratch(), 100);
}
}
@@ -58,7 +111,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
*/
public setupInputListeners() {
// ESC
document.onkeydown = evt => {
document.onkeydown = (evt) => {
const { isCursorCaptured } = this.state;
evt = evt || window.event;
@@ -76,12 +129,25 @@ export class Emulator extends React.Component<{}, EmulatorState> {
// Click
document.addEventListener("click", () => {
const { isRunning } = this.state;
if (isRunning) {
if (this.state.isRunning && !this.absoluteMouse) {
this.lockMouse();
}
});
// Only forward mouse input to the VM while the pointer is actually
// captured (or while the guest's absolute-pointer driver is active —
// VBMOUSE/vmwmouse via the VMware backdoor — in which case the guest
// cursor tracks the host cursor 1:1 and we don't need pointer lock at
// all). Browsers can release pointer lock on their own (Esc, focus loss),
// so we sync v86's mouse status off the real lock state instead of
// assuming our lock/unlock calls succeeded.
document.addEventListener("pointerlockchange", () => {
const isCursorCaptured = !!document.pointerLockElement;
this.setState({ isCursorCaptured });
this.state.emulator?.mouse_set_status(
isCursorCaptured || this.absoluteMouse,
);
});
}
/**
@@ -95,11 +161,11 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.isQuitting = true;
setImmediate(() => {
remote.app.quit();
ipcRenderer.invoke(IPC_COMMANDS.APP_QUIT);
});
};
window.onbeforeunload = event => {
window.onbeforeunload = (event: Event) => {
if (this.isQuitting || this.isResetting) {
console.log(`Unload: Not preventing`);
return;
@@ -122,33 +188,37 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.sendKeys([
0x1d, // ctrl
0x38, // alt
0x53 // delete
0x53, // delete
]);
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_F4, () => {
this.sendKeys([
0x38, // alt
0x3e // f4
0x3e, // f4
]);
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_ENTER, () => {
this.sendKeys([
0x38, // alt
0 // enter
0, // enter
]);
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_ESC, () => {
this.sendKeys([
0x18 // alt
0x18, // alt
]);
});
ipcRenderer.on(IPC_COMMANDS.MACHINE_STOP, this.stopEmulator);
ipcRenderer.on(IPC_COMMANDS.MACHINE_RESET, this.resetEmulator);
ipcRenderer.on(IPC_COMMANDS.MACHINE_START, this.startEmulator);
ipcRenderer.on(
IPC_COMMANDS.MACHINE_BOOT_FROM_SCRATCH,
this.bootFromScratch,
);
ipcRenderer.on(IPC_COMMANDS.MACHINE_RESTART, this.restartEmulator);
ipcRenderer.on(IPC_COMMANDS.TOGGLE_INFO, () => {
@@ -178,36 +248,85 @@ export class Emulator extends React.Component<{}, EmulatorState> {
* 🤡
*/
public renderUI() {
const { isRunning, currentUiCard, floppyFile } = this.state;
const { isRunning, currentUiCard, floppyFile, cdromFile } = this.state;
if (isRunning) {
return null;
}
const navigate = (currentUiCard: "start" | "settings") =>
this.setState({ currentUiCard });
let card;
if (currentUiCard === "settings") {
card = (
<CardSettings
setFloppy={floppyFile => this.setState({ floppyFile })}
setFloppy={(floppyFile) => this.setState({ floppyFile })}
setCdrom={(cdromFile) => this.setState({ cdromFile })}
setSmbSharePath={(smbSharePath) => {
this.setState({ smbSharePath });
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, smbSharePath);
}}
pickFolder={() => ipcRenderer.invoke(IPC_COMMANDS.PICK_FOLDER)}
bootFromScratch={this.bootFromScratch}
floppy={floppyFile}
cdrom={cdromFile}
smbSharePath={this.state.smbSharePath}
infoBarSettings={this.state.infoBarSettings}
setInfoBarSettings={(infoBarSettings) => {
this.setState({ infoBarSettings });
saveInfoBarSettings(infoBarSettings);
}}
navigate={navigate}
/>
);
} else if (currentUiCard === "drive") {
card = <CardDrive showDiskImage={this.showDiskImage} />;
} else {
card = <CardStart startEmulator={this.startEmulator} />;
card = (
<CardStart
startEmulator={this.startEmulator}
navigate={navigate}
legacyStatePath={this.state.legacyStatePath}
legacyRecovered={this.state.legacyRecovered}
legacyRecoverBusy={this.state.legacyRecoverBusy}
legacyRecoverError={this.state.legacyRecoverError}
recoverLegacy={async () => {
const p = this.state.legacyStatePath;
if (!p) return;
this.setState({
legacyRecoverBusy: true,
legacyRecoverError: null,
});
try {
const downloads =
process.env.WIN95_RECOVER_DIR ||
(await ipcRenderer.invoke(IPC_COMMANDS.GET_DOWNLOADS_PATH));
const outDir = path.join(downloads, "Recovered C Drive");
const out = await recoverLegacyDisk(p, outDir);
this.setState({ legacyRecovered: out });
} catch (e) {
console.error("recoverLegacy:", e);
this.setState({
legacyRecoverError: e instanceof Error ? e.message : String(e),
});
} finally {
this.setState({ legacyRecoverBusy: false });
}
}}
showRecovered={() =>
this.state.legacyRecovered &&
shell.openPath(this.state.legacyRecovered.dir)
}
discardLegacy={async () => {
const p = this.state.legacyStatePath;
if (p) await fs.promises.unlink(p).catch(() => {});
this.setState({ legacyStatePath: null });
}}
/>
);
}
return (
<>
{card}
<StartMenu
navigate={target => this.setState({ currentUiCard: target })}
/>
</>
);
return <section>{card}</section>;
}
/**
@@ -218,9 +337,12 @@ export class Emulator extends React.Component<{}, EmulatorState> {
<>
{this.renderInfo()}
{this.renderUI()}
<div id="emulator">
<div></div>
<canvas></canvas>
<div
id="emulator"
className={this.state.hasAbsoluteMouse ? "seamless-mouse" : undefined}
>
<div id="emulator-text-screen"></div>
<canvas id="emulator-canvas"></canvas>
</div>
</>
);
@@ -230,13 +352,11 @@ export class Emulator extends React.Component<{}, EmulatorState> {
* Render the little info thingy
*/
public renderInfo() {
if (!this.state.isInfoDisplayed) {
return null;
}
return (
<EmulatorInfo
emulator={this.state.emulator}
settings={this.state.infoBarSettings}
hidden={!this.state.isInfoDisplayed}
toggleInfo={() => {
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
}}
@@ -247,7 +367,8 @@ export class Emulator extends React.Component<{}, EmulatorState> {
/**
* Boot the emulator without restoring state
*/
public bootFromScratch() {
public async bootFromScratch() {
await this.stopEmulator();
this.setState({ isBootingFresh: true });
this.startEmulator();
}
@@ -257,12 +378,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
*/
public showDiskImage() {
// Contents/Resources/app/dist/static
const imagePath = path
.join(__dirname, "../../images/windows95.img");
console.log(`Showing disk image in ${CONSTANTS.IMAGE_PATH}`);
console.log(`Showing disk image in ${imagePath}`);``
shell.showItemInFolder(imagePath);
shell.showItemInFolder(CONSTANTS.IMAGE_PATH);
}
/**
@@ -271,51 +389,109 @@ export class Emulator extends React.Component<{}, EmulatorState> {
private async startEmulator() {
document.body.classList.remove("paused");
const imageSize = await getDiskImageSize();
const cdromPath =
process.env.WIN95_PROBE_CDROM ||
(this.state.cdromFile
? webUtils.getPathForFile(this.state.cdromFile)
: null);
const options = {
wasm_path: path.join(__dirname, "build/v86.wasm"),
memory_size: 128 * 1024 * 1024,
video_memory_size: 32 * 1024 * 1024,
screen_container: document.getElementById("emulator"),
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",
// Real IPs for the guest so the raw-TCP relay knows where to dial;
// the default "static" resolver returns a placeholder for every name.
dns_method: "doh",
},
bios: {
url: "../../bios/seabios.bin"
url: path.join(__dirname, "../../bios/seabios.bin"),
},
vga_bios: {
url: "../../bios/vgabios.bin"
url: path.join(__dirname, "../../bios/vgabios.bin"),
},
hda: {
url: "../../images/windows95.img",
url: CONSTANTS.IMAGE_PATH,
async: true,
size: imageSize
size: await getDiskImageSize(CONSTANTS.IMAGE_PATH),
},
fda: {
buffer: this.state.floppyFile
},
boot_order: 0x132
fda: this.state.floppyFile
? {
buffer: this.state.floppyFile,
}
: undefined,
cdrom: cdromPath ? new SyncFileBuffer(cdromPath) : undefined,
boot_order: 0x132,
};
// PROBE_OPTS lets the outer harness override options without rebuilding
// (e.g. WIN95_PROBE_OPTS='{"acpi":false,"disable_jit":true}')
Object.assign(options, PROBE_OPTS);
console.log(`🚜 Starting emulator with options`, options);
window["emulator"] = new V86Starter(options);
// Answer the app's magic hostnames locally before v86's DoH path sends
// them to Cloudflare (which would NXDOMAIN them).
setupDnsShim();
window["emulator"] = new V86(options);
// Raw TCP egress for ports the fetch adapter ignores (everything but 80).
setupTcpRelay(window["emulator"]);
setupTcpTrace(window["emulator"]);
// Serve a host folder over SMB on port 139. Read-only, traversal/symlink
// guarded. In Win95: Start → Run → \\HOST\HOST. The env var wins so the
// probe harness can point at a fixture dir without touching settings.
const smbRoot = process.env.WIN95_SMB_SHARE || this.state.smbSharePath;
if (smbRoot) {
setupSmbShare(window["emulator"], smbRoot, CONSTANTS.TOOLS_PATH);
}
if (PROBE) {
startProbe(window["emulator"]);
}
// Host ↔ guest text clipboard. The guest side is W95TOOLS.EXE on the
// TOOLS share; until that's running this is a no-op poller.
setupClipboardSync(window["emulator"]);
// New v86 instance
// Mouse stays disabled until either the pointer is captured or the guest's
// VMware-backdoor mouse driver requests absolute mode.
window["emulator"].mouse_set_status(false);
window["emulator"].add_listener("vmware-absolute-mouse", (on: boolean) => {
this.absoluteMouse = on;
this.setState({ hasAbsoluteMouse: on });
window["emulator"].mouse_set_status(on || !!document.pointerLockElement);
if (on && document.pointerLockElement) {
this.unlockMouse();
}
});
this.setState({
emulator: window["emulator"],
isRunning: true
isRunning: true,
});
ipcRenderer.send(IPC_COMMANDS.MACHINE_STARTED);
// Restore state. We can't do this right away
// and randomly chose 500ms as the appropriate
// wait time (lol)
setTimeout(async () => {
// Wait for v86 to finish loading wasm/bios/hda before restoring — calling
// restore_state on an uninitialized cpu throws and we'd silently cold-boot.
window["emulator"].add_listener("emulator-loaded", async () => {
if (!this.state.isBootingFresh) {
this.restoreState();
await this.restoreState();
}
this.lockMouse();
this.state.emulator.run();
}, 500);
window["emulator"].run();
window["emulator"].screen_set_scale(this.state.scale);
});
}
/**
@@ -344,19 +520,23 @@ export class Emulator extends React.Component<{}, EmulatorState> {
await this.saveState();
this.unlockMouse();
emulator.stop();
await emulator.stop();
this.setState({ isRunning: false });
this.resetCanvas();
document.body.classList.add("paused");
ipcRenderer.send(IPC_COMMANDS.MACHINE_STOPPED);
}
/**
* Reset the emulator by reloading the whole page (lol)
* Reset the emulator by reloading the whole page
*/
private async resetEmulator() {
this.isResetting = true;
document.location.hash = `#AUTO_START`;
await this.stopEmulator();
await resetState();
document.location.reload();
}
@@ -366,81 +546,73 @@ export class Emulator extends React.Component<{}, EmulatorState> {
*/
private async saveState(): Promise<void> {
const { emulator } = this.state;
const statePath = await getStatePath();
return new Promise(resolve => {
if (!emulator || !emulator.save_state) {
console.log(`restoreState: No emulator present`);
return resolve();
}
if (!emulator || !emulator.save_state) {
console.log(`restoreState: No emulator present`);
return;
}
emulator.save_state(async (error: Error, newState: ArrayBuffer) => {
if (error) {
console.warn(`saveState: Could not save state`, error);
return resolve();
}
await fs.outputFile(CONSTANTS.STATE_PATH, Buffer.from(newState));
console.log(`saveState: Saved state to ${CONSTANTS.STATE_PATH}`);
resolve();
try {
const newState = await emulator.save_state();
await fs.promises.writeFile(statePath, Buffer.from(newState), {
flush: true,
});
});
} catch (error) {
console.warn(`saveState: Could not save state`, error);
}
}
/**
* Restores state to the emulator.
*/
private restoreState() {
const { emulator } = this.state;
const state = this.getState();
private async restoreState(): Promise<boolean> {
const { emulator, isBootingFresh } = this.state;
const state = await this.getState();
// Nothing to do with if we don't have a state
if (!state) {
if (isBootingFresh) {
console.log(`restoreState: Booting fresh, not restoring.`);
return true;
} else if (!state) {
console.log(`restoreState: No state present, not restoring.`);
}
if (!emulator) {
return false;
} else if (!emulator) {
console.log(`restoreState: No emulator present`);
return false;
}
try {
this.state.emulator.restore_state(state);
await this.state.emulator.restore_state(state);
return true;
} catch (error) {
console.log(
`State: Could not read state file. Maybe none exists?`,
error
`restoreState: Could not read state file. Maybe none exists?`,
error,
);
return false;
}
}
/**
* Returns the current machine's state - either what
* we have saved or alternatively the default state.
*
* @returns {ArrayBuffer}
*/
private getState(): ArrayBuffer | null {
const statePath = fs.existsSync(CONSTANTS.STATE_PATH)
? CONSTANTS.STATE_PATH
private async getState(): Promise<ArrayBuffer | null> {
const expectedStatePath = await getStatePath();
const statePath = fs.existsSync(expectedStatePath)
? expectedStatePath
: CONSTANTS.DEFAULT_STATE_PATH;
if (fs.existsSync(statePath)) {
return fs.readFileSync(statePath).buffer;
} else {
console.log(`getState: No state file found at ${statePath}`);
}
return null;
}
private unlockMouse() {
const { emulator } = this.state;
this.setState({ isCursorCaptured: false });
if (emulator) {
emulator.mouse_set_status(false);
}
document.exitPointerLock();
}
@@ -448,12 +620,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
const { emulator } = this.state;
if (emulator) {
this.setState({ isCursorCaptured: true });
emulator.mouse_set_status(true);
emulator.lock_mouse();
} else {
console.warn(
`Emulator: Tried to lock mouse, but no emulator or not running`
`Emulator: Tried to lock mouse, but no emulator or not running`,
);
}
}
@@ -490,4 +660,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.state.emulator.keyboard_send_scancodes(scancodes);
}
}
/**
* Reset the canvas
*/
private resetCanvas() {
const canvas = document.getElementById("emulator-canvas");
if (canvas instanceof HTMLCanvasElement) {
const ctx = canvas.getContext("2d");
ctx?.clearRect(0, 0, canvas.width, canvas.height);
}
}
}

View File

@@ -1,2 +1,2 @@
declare const V86Starter: any;
declare const V86: any;
declare const win95: any;

View File

@@ -0,0 +1,29 @@
export interface InfoBarSettings {
showCpu: boolean;
showDisk: boolean;
showNet: boolean;
showSparklines: boolean;
}
export const DEFAULT_INFO_BAR_SETTINGS: InfoBarSettings = {
showCpu: true,
showDisk: true,
showNet: true,
showSparklines: true,
};
const KEY = "infoBarSettings";
export function loadInfoBarSettings(): InfoBarSettings {
try {
const raw = localStorage.getItem(KEY);
if (raw) return { ...DEFAULT_INFO_BAR_SETTINGS, ...JSON.parse(raw) };
} catch {}
return { ...DEFAULT_INFO_BAR_SETTINGS };
}
export function saveInfoBarSettings(s: InfoBarSettings) {
try {
localStorage.setItem(KEY, JSON.stringify(s));
} catch {}
}

View File

@@ -1,4 +1,4 @@
Copyright (c) 2012-2018, Fabian Hemmer
Copyright (c) 2012, The v86 contributors
All rights reserved.
Redistribution and use in source and binary forms, with or without
@@ -19,8 +19,4 @@ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation are those
of the authors and should not be interpreted as representing official policies,
either expressed or implied, of the FreeBSD Project.
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

BIN
src/renderer/lib/build/v86.wasm Executable file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
// With dns_method:"doh" the guest's UDP/53 queries are POSTed verbatim to
// cloudflare-dns.com/dns-query. Real DNS can't answer the app's magic names
// (single-label "windows95", "<port>.external"), which the static resolver
// used to map to a placeholder IP so the fetch adapter could take over via
// the Host header. This shim wraps global fetch, spots DoH requests for
// those names, and answers them locally with the same placeholder; every
// other lookup goes out to Cloudflare unchanged.
const PLACEHOLDER_IP = [192, 168, 87, 1];
function qnameOf(msg: Uint8Array): string {
const labels: string[] = [];
let i = 12;
while (i < msg.length) {
const len = msg[i++];
if (len === 0) break;
labels.push(String.fromCharCode(...msg.subarray(i, i + len)));
i += len;
}
return labels.join(".");
}
function isMagicName(name: string): boolean {
if (!name.includes(".")) return true; // windows95, host, …
if (/^\d+\.external$/i.test(name)) return true; // 8080.external → localhost:8080
return false;
}
function synthAResponse(query: Uint8Array): Uint8Array {
// End of the question section: QNAME (null-terminated) + QTYPE(2) + QCLASS(2).
let i = 12;
while (query[i] !== 0) i += query[i] + 1;
const qend = i + 1 + 4;
const out = new Uint8Array(qend + 16);
out.set(query.subarray(0, qend));
out[2] = 0x81;
out[3] = 0x80; // QR=1 RD=1 RA=1, RCODE=0
out[4] = 0;
out[5] = 1; // QDCOUNT=1
out[6] = 0;
out[7] = 1; // ANCOUNT=1
out[8] = out[9] = out[10] = out[11] = 0;
let o = qend;
out[o++] = 0xc0;
out[o++] = 0x0c; // NAME → pointer to question
out[o++] = 0;
out[o++] = 1; // TYPE A
out[o++] = 0;
out[o++] = 1; // CLASS IN
out[o++] = 0;
out[o++] = 0;
out[o++] = 0x02;
out[o++] = 0x58; // TTL 600
out[o++] = 0;
out[o++] = 4; // RDLENGTH
out.set(PLACEHOLDER_IP, o);
return out;
}
export function setupDnsShim() {
const realFetch = globalThis.fetch;
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("/dns-query") && init?.body instanceof Uint8Array) {
const q = init.body as Uint8Array;
const name = qnameOf(q).toLowerCase();
if (isMagicName(name)) {
const body = synthAResponse(q);
return Promise.resolve(
new Response(body as unknown as BodyInit, {
status: 200,
headers: { "content-type": "application/dns-message" },
}),
);
}
}
return realFetch(input, init);
}) as typeof fetch;
}

View File

@@ -0,0 +1,153 @@
// Bridge v86's userspace TCP stack to real host sockets so the guest can
// reach arbitrary TCP ports — not just the fetch adapter's port-80 HTTP path.
// We keep relay_url:"fetch" (for DHCP/ARP/ICMP/DNS plumbing and the existing
// port-80 handling) and simply claim every other SYN on the same bus event.
//
// Requires net_device.dns_method:"doh" so the guest resolves real IPs; the
// default "static" mode hands out 192.168.87.1 for everything, which only
// works because the fetch adapter re-reads the Host header.
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as net from "net";
interface TCPConnection {
sport: number; // dest port the guest connected to
psrc: Uint8Array; // dest IP (4 bytes) — "source" from the router's POV
tuple: string;
net: { tcp_conn: Record<string, unknown> };
on(event: "data", handler: (data: Uint8Array) => void): void;
write(data: Uint8Array): void;
accept(): void;
close(): void;
on_shutdown: () => void;
on_close: () => void;
}
interface V86 {
bus: { register(name: string, fn: (arg: unknown) => void): void };
}
const LOG_FILE =
process.env.WIN95_TCP_RELAY_LOG ||
path.join(os.tmpdir(), "win95-tcp-relay.log");
try {
fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`);
} catch {}
const log = (...a: unknown[]) => {
console.log("[tcp-relay]", ...a);
try {
fs.appendFileSync(LOG_FILE, a.join(" ") + "\n");
} catch {}
};
// Ports already claimed by other in-process handlers.
const RESERVED_PORTS = new Set([80, 139]);
// WIN95_TCP_TEST_PORT=<n> short-circuits that port to an in-process fake
// upstream that writes a banner asynchronously (i.e., from outside a CPU
// tick) and then echoes everything back. Lets the probe harness exercise
// the recv() path deterministically without a real network endpoint.
const TEST_PORT = Number(process.env.WIN95_TCP_TEST_PORT) || 0;
const TEST_BANNER_BYTES = Number(process.env.WIN95_TCP_TEST_BYTES) || 3000;
// Destinations we never relay to. The emulated LAN (192.168.86/87) has nothing
// real behind it; loopback and link-local would let guest software poke at the
// host's own services or cloud-metadata endpoints. The rest of RFC1918 is left
// reachable on purpose — talking to a NAS or BBS on the user's LAN is a
// legitimate use of this bridge, and the port-80 fetch path already allows it.
function isBlockedDest(p: Uint8Array): boolean {
const [a, b] = p;
if (a === 192 && b === 168 && (p[2] === 86 || p[2] === 87)) return true;
if (a === 127 || a === 0 || a >= 224) return true; // loopback, "this", multicast+
if (a === 169 && b === 254) return true; // link-local / metadata
return false;
}
export function setupTcpRelay(emulator: V86) {
emulator.bus.register("tcp-connection", (c: unknown) => {
const conn = c as TCPConnection;
const port = conn.sport;
if (RESERVED_PORTS.has(port)) return;
if (isBlockedDest(conn.psrc)) return;
const ip = Array.from(conn.psrc).join(".");
// Must accept synchronously or v86 RSTs the SYN right after dispatch.
conn.accept();
log(`${ip}:${port} (${conn.tuple})`);
if (TEST_PORT && port === TEST_PORT) {
const mode = process.env.WIN95_TCP_TEST_MODE || "banner";
const banner = Buffer.alloc(TEST_BANNER_BYTES, 0x41);
banner.write(`HELLO from tcp-relay test, ${TEST_BANNER_BYTES}B\r\n`);
let n = 0;
conn.on("data", (d) => {
if (d.length === 0) return;
n += d.length;
log(`test ← guest ${d.length}B (total ${n})`);
if (mode === "after-data" && n >= 1) {
setTimeout(() => {
log(`test → guest ${banner.length}B banner (async, after-data)`);
conn.write(banner);
}, 10);
}
});
if (mode === "banner")
setTimeout(
() => {
log(`test → guest ${banner.length}B banner (async)`);
conn.write(banner);
},
Number(process.env.WIN95_TCP_TEST_DELAY) || 50,
);
conn.on_shutdown = conn.on_close = () => log("test guest closed");
return;
}
let connected = false;
let pending: Buffer[] | null = [];
let upstreamGone = false;
const sock = net.connect({ host: ip, port });
sock.on("connect", () => {
connected = true;
if (pending) {
for (const b of pending) sock.write(b);
pending = null;
}
});
sock.on("data", (d) => conn.write(d));
sock.on("close", () => {
upstreamGone = true;
conn.close();
});
sock.on("error", (e: NodeJS.ErrnoException) => {
log(`${ip}:${port} ${e.code || e.message}`);
upstreamGone = true;
conn.close();
});
// tcp_data is a subarray into v86's reused frame buffer — copy before
// handing to an async writer.
conn.on("data", (d) => {
if (d.length === 0) return;
const buf = Buffer.from(d);
if (connected) sock.write(buf);
else pending?.push(buf);
});
const teardown = () => {
if (upstreamGone) return;
upstreamGone = true;
if (connected) sock.end();
else sock.destroy();
};
conn.on_shutdown = teardown;
conn.on_close = teardown;
});
log("active — guest TCP to non-80/139 ports is bridged to host sockets");
}

View File

@@ -0,0 +1,118 @@
// Deep packet trace for the v86 userspace TCP ↔ NE2000 path. Activated by
// WIN95_TCP_TRACE=<port> — every guest-TX and host-RX TCP frame on that
// dest port is decoded and appended to /tmp/win95-tcp-trace.log, and the
// NE2000 receive() is wrapped so we can see whether frames injected from
// outside a CPU tick ever land in the RX ring (vs. being filtered out).
import * as fs from "fs";
const TRACE_FILE =
process.env.WIN95_TCP_TRACE_FILE || "/tmp/win95-tcp-trace.log";
interface V86 {
bus: { register(name: string, fn: (arg: unknown) => void): void };
v86?: { cpu?: { devices?: { net?: unknown } } };
}
function flags(b: number): string {
let s = "";
if (b & 1) s += "F";
if (b & 2) s += "S";
if (b & 4) s += "R";
if (b & 8) s += "P";
if (b & 16) s += "A";
return s || ".";
}
function decodeTcp(f: Uint8Array, port: number) {
if (f.length < 54) return null;
if (((f[12] << 8) | f[13]) !== 0x0800) return null;
const ihl = (f[14] & 0x0f) * 4;
if (f[14 + 9] !== 6) return null;
const ipLen = (f[16] << 8) | f[17];
const t = 14 + ihl;
const sport = (f[t] << 8) | f[t + 1];
const dport = (f[t + 2] << 8) | f[t + 3];
if (sport !== port && dport !== port) return null;
const seq =
(f[t + 4] * 0x1000000 + (f[t + 5] << 16) + (f[t + 6] << 8) + f[t + 7]) >>>
0;
const ack =
(f[t + 8] * 0x1000000 + (f[t + 9] << 16) + (f[t + 10] << 8) + f[t + 11]) >>>
0;
const doff = (f[t + 12] >> 4) * 4;
const fl = f[t + 13];
const win = (f[t + 14] << 8) | f[t + 15];
const dlen = ipLen - ihl - doff;
return { sport, dport, seq, ack, fl: flags(fl), win, dlen, frameLen: f.length };
}
export function setupTcpTrace(emulator: V86) {
const port = Number(process.env.WIN95_TCP_TRACE);
if (!port) return;
const t0 = Date.now();
fs.writeFileSync(TRACE_FILE, `--- trace port ${port} ---\n`);
const w = (s: string) => {
const line = `[${((Date.now() - t0) / 1000).toFixed(3)}s] ${s}\n`;
try {
fs.appendFileSync(TRACE_FILE, line);
} catch {}
};
emulator.bus.register("net0-send", (raw: unknown) => {
const f = raw as Uint8Array;
const r = decodeTcp(f, port);
if (r)
w(
`guest→ ${r.sport}>${r.dport} seq=${r.seq} ack=${r.ack} ${r.fl} win=${r.win} len=${r.dlen}`,
);
else if (f.length >= 14) {
const et = (f[12] << 8) | f[13];
const dst =
et === 0x0800 ? f.subarray(30, 34).join(".") : et === 0x0806 ? "arp" : "?";
w(
`guest→ [other] len=${f.length}@${f.byteOffset} et=0x${et.toString(16)} dst=${dst}`,
);
}
});
// Wrap NE2000 receive() to see if/why a frame is dropped at the NIC layer.
const arm = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ne2k = (emulator as any).v86?.cpu?.devices?.net;
if (!ne2k) return false;
const orig = ne2k.receive.bind(ne2k);
ne2k.receive = function (a: Uint8Array) {
const r = decodeTcp(a, port);
if (r) {
const pages = 1 + ((Math.max(60, a.length) + 4) >> 8);
const avail =
this.boundary > this.curpg
? this.boundary - this.curpg
: this.pstop - this.curpg + this.boundary - this.pstart;
const macOk =
a[0] === this.mac[0] && a[1] === this.mac[1] && a[2] === this.mac[2];
const drop =
this.cr & 1
? "STP"
: !macOk && a[0] !== 0xff && !(this.rxcr & 16)
? "MAC"
: avail < pages && this.boundary !== 0
? "FULL"
: "";
w(
` ne2k.recv frame=${a.length} sip=${a.slice(26, 30).join(".")} cr=0x${this.cr.toString(16)}` +
` cur=${this.curpg} bnd=${this.boundary}${drop ? " DROP:" + drop : ""}`,
);
}
return orig(a);
};
w("ne2k.receive wrapped");
return true;
};
if (!arm()) {
const p = setInterval(() => arm() && clearInterval(p), 100);
setTimeout(() => clearInterval(p), 10000);
}
}

103
src/renderer/smb/README.md Normal file
View File

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

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

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

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

@@ -0,0 +1,472 @@
// Standalone test of the SMB stack — no v86, no Electron. Feeds canned
// requests through NetBIOSFramer + SmbSession and inspects responses.
// Run: see src/renderer/smb/README.md for the ts-node invocation.
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
import { NetBIOSFramer, nbWrap } from "./netbios";
import { SmbSession } from "./server";
import { parseSmb, CMD_NEGOTIATE, CMD_SESSION_SETUP_ANDX,
CMD_TREE_CONNECT_ANDX, CMD_TRANSACTION2, CMD_OPEN_ANDX,
CMD_READ_ANDX, CMD_CLOSE } from "./smb";
let pass = 0, fail = 0;
const ok = (cond: boolean, msg: string) => {
if (cond) { pass++; console.log(" ✓", msg); }
else { fail++; console.log(" ✗", msg); }
};
// @ts-ignore — kept for debugging when tests fail
const hex = (b: Uint8Array, n = 32) =>
Array.from(b.slice(0, n)).map(x => x.toString(16).padStart(2, "0")).join(" ");
void hex;
// ─── Build a minimal SMB request from scratch ────────────────────────────────
function smbReq(cmd: number, words: number[], bytes: number[],
tid = 0, uid = 0, mid = 1): Uint8Array {
const out: number[] = [];
out.push(0xff, 0x53, 0x4d, 0x42); // magic
out.push(cmd); // cmd
out.push(0, 0, 0, 0); // status
out.push(0x18); // flags (caseless+canonical)
out.push(0x01, 0x00); // flags2: long names, no unicode
for (let i = 0; i < 12; i++) out.push(0); // reserved
out.push(tid & 0xff, tid >> 8);
out.push(0, 0); // pid
out.push(uid & 0xff, uid >> 8);
out.push(mid & 0xff, mid >> 8);
if (words.length % 2) throw new Error("words must be even");
out.push(words.length / 2);
out.push(...words);
out.push(bytes.length & 0xff, bytes.length >> 8);
out.push(...bytes);
return new Uint8Array(out);
}
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
const u32 = (v: number) => [...u16(v & 0xffff), ...u16((v >>> 16) & 0xffff)];
const cstr = (s: string) => [...Buffer.from(s, "ascii"), 0];
// ─── Setup test fixture ──────────────────────────────────────────────────────
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "smbtest-"));
fs.writeFileSync(path.join(tmpRoot, "hello.txt"), "Hello from the host!\n");
fs.mkdirSync(path.join(tmpRoot, "subdir"));
fs.writeFileSync(path.join(tmpRoot, "subdir", "nested.dat"), Buffer.alloc(100, 0xAB));
console.log("fixture:", tmpRoot);
const session = new SmbSession(tmpRoot);
session.capture = false;
// ─── Test 1: NetBIOS framing ─────────────────────────────────────────────────
console.log("\n[1] NetBIOS framer");
{
const framer = new NetBIOSFramer();
// Session request: type 0x81, len 68 (called name 34 + calling name 34)
const sessReq = new Uint8Array([0x81, 0, 0, 68, ...new Array(68).fill(0x20)]);
const msgs1 = framer.push(sessReq);
ok(msgs1.length === 1 && msgs1[0].type === 0x81, "parses session request");
// Fragmented session message
const payload = new Uint8Array([0xff, 0x53, 0x4d, 0x42, 0x72, 0, 0, 0, 0, 0]);
const wrapped = nbWrap(payload);
const msgs2 = framer.push(wrapped.slice(0, 5));
ok(msgs2.length === 0, "incomplete frame buffers");
const msgs3 = framer.push(wrapped.slice(5));
ok(msgs3.length === 1 && msgs3[0].type === 0x00, "completes on second chunk");
ok(msgs3[0].type === 0x00 && msgs3[0].payload[0] === 0xff && msgs3[0].payload[1] === 0x53,
"payload extracted");
}
// ─── Test 2: NEGOTIATE ───────────────────────────────────────────────────────
console.log("\n[2] NEGOTIATE");
{
// Real Win95 dialect list (abbreviated). Each entry is 0x02 + cstr.
const dialects = ["PC NETWORK PROGRAM 1.0", "LANMAN1.0", "LM1.2X002",
"LANMAN2.1", "NT LM 0.12"];
const bytes: number[] = [];
for (const d of dialects) { bytes.push(0x02); bytes.push(...cstr(d)); }
const req = smbReq(CMD_NEGOTIATE, [], bytes);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.cmd === CMD_NEGOTIATE, "cmd echoed");
ok((parsed.flags & 0x80) !== 0, "reply flag set");
ok(parsed.status === 0, "status OK");
ok(parsed.wordCount === 17, "17-word NT response");
// word[0] = dialect index — NT LM 0.12 is idx 4 and gets the 17-word
// response; the 13-word LM shape is now only emitted as a fallback.
const pickedIdx = parsed.words[0] | (parsed.words[1] << 8);
ok(pickedIdx === 4, `picked NT LM 0.12 (idx ${pickedIdx})`);
// Fallback: a client that doesn't offer NT LM 0.12 still gets the 13-word
// LANMAN response.
const lmBytes: number[] = [];
for (const d of dialects.slice(0, 4)) { lmBytes.push(0x02); lmBytes.push(...cstr(d)); }
const lmParsed = parseSmb(session.handle(smbReq(CMD_NEGOTIATE, [], lmBytes))!)!;
ok(lmParsed.wordCount === 13, "13-word LM fallback");
}
// ─── Test 3: SESSION_SETUP ───────────────────────────────────────────────────
console.log("\n[3] SESSION_SETUP_ANDX");
{
// Minimal setup: AndX(4) MaxBuf(2) MaxMpx(2) VcNum(2) SessKey(4)
// PwLen(2) Reserved(4) — bytes: password + account + domain + os + lanman
const words = [0xff, 0, 0, 0, ...u16(4096), ...u16(1), ...u16(0),
...u32(0), ...u16(0), ...u32(0)];
const bytes = [...cstr(""), ...cstr("GUEST"), ...cstr("WORKGROUP"),
...cstr("Windows 4.0"), ...cstr("Windows 4.0")];
const req = smbReq(CMD_SESSION_SETUP_ANDX, words, bytes);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
ok(parsed.uid === 1, `assigned uid=${parsed.uid}`);
// Action word at offset 4 (after AndX) = guest bit
const action = parsed.words[4] | (parsed.words[5] << 8);
ok((action & 1) === 1, "guest bit set");
}
// ─── Test 4: TREE_CONNECT ────────────────────────────────────────────────────
console.log("\n[4] TREE_CONNECT_ANDX");
{
const words = [0xff, 0, 0, 0, ...u16(0), ...u16(1)]; // pwLen=1
const bytes = [0, ...cstr("\\\\192.168.86.1\\HOST"), ...cstr("?????")];
const req = smbReq(CMD_TREE_CONNECT_ANDX, words, bytes, 0, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
ok(parsed.tid === 1, `assigned tid=${parsed.tid}`);
// bytes should start with "A:\0"
const svc = String.fromCharCode(parsed.bytes[0], parsed.bytes[1]);
ok(svc === "A:", `service="${svc}"`);
}
// ─── Test 5: TRANS2 FIND_FIRST2 (directory listing) ──────────────────────────
console.log("\n[5] TRANS2 FIND_FIRST2");
{
// TRANS2 setup is gnarly. Build from spec:
// params: SearchAttrs(2) SearchCount(2) Flags(2) InfoLevel(2) Storage(4) "\*"\0
const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(1),
...u32(0), ...cstr("\\*")];
// setup word = TRANS2_FIND_FIRST2 (1)
// word block: TotPrm(2) TotData(2) MaxPrm(2) MaxData(2) MaxSetup(1) Rsvd(1)
// Flags(2) Timeout(4) Rsvd(2) PrmCnt(2) PrmOff(2) DataCnt(2) DataOff(2)
// SetupCnt(1) Rsvd(1) Setup[0](2)
const wc = 14 + 1; // 14 fixed + 1 setup
const bytesStart = 32 + 1 + wc * 2 + 2;
const paramOff = bytesStart + 3; // 3 bytes pad ("\0\0\0") before params
const words = [
...u16(t2params.length), ...u16(0), ...u16(100), ...u16(8000),
1, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(t2params.length), ...u16(paramOff),
...u16(0), ...u16(0),
1, 0, ...u16(1) // SetupCount=1, Setup[0]=FIND_FIRST2
];
const bytes = [0, 0, 0, ...t2params]; // 3-byte name padding + params
const req = smbReq(CMD_TRANSACTION2, words, bytes, 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
// Reply params: SID(2) Count(2) EOS(2) EaErr(2) LastName(2)
// Reply words tell us where params live
const rw = parsed.words;
const replyParamOffset = rw[8] | (rw[9] << 8);
const replyParamCount = rw[6] | (rw[7] << 8);
const replyBytesStart = 32 + 1 + parsed.wordCount * 2 + 2;
const pStart = replyParamOffset - replyBytesStart;
const replyParams = parsed.bytes.slice(pStart, pStart + replyParamCount);
const searchCount = replyParams[2] | (replyParams[3] << 8);
// Should find: . .. hello.txt subdir = 4 (virtuals moved to TOOLS share)
ok(searchCount === 4, `found ${searchCount} entries (expect 4)`);
// Data block has the entries — just verify they're in there somewhere
const dataStr = String.fromCharCode(...parsed.bytes);
ok(!dataStr.includes("_MAPZ.BAT"), "no virtual leak in user share");
ok(dataStr.includes("hello.txt"), "hello.txt in listing");
ok(dataStr.includes("subdir"), "subdir in listing");
}
// ─── Test 5b: FIND_FIRST2 level 0x104 (LFN) ──────────────────────────────────
console.log("\n[5b] TRANS2 FIND_FIRST2 level=0x104");
{
fs.writeFileSync(path.join(tmpRoot, "A Long Filename Here.txt"), "lfn");
// Same envelope as [5] but InfoLevel=0x104
const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(0x104),
...u32(0), ...cstr("\\*")];
const wc = 14 + 1;
const bytesStart = 32 + 1 + wc * 2 + 2;
const paramOff = bytesStart + 3;
const words = [
...u16(t2params.length), ...u16(0), ...u16(100), ...u16(8000),
1, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(t2params.length), ...u16(paramOff),
...u16(0), ...u16(0),
1, 0, ...u16(1)
];
const bytes = [0, 0, 0, ...t2params];
const reply = session.handle(smbReq(CMD_TRANSACTION2, words, bytes, 1, 1))!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "status OK");
// Walk the data block via NextEntryOffset and verify the long name appears
// intact and the chain terminates with 0.
const rw = parsed.words;
const dOff = (rw[14] | (rw[15] << 8)) - (32 + 1 + parsed.wordCount * 2 + 2);
const dLen = rw[12] | (rw[13] << 8);
const data = parsed.bytes.slice(dOff, dOff + dLen);
const names: string[] = [];
let off = 0;
for (;;) {
const next = data[off] | (data[off+1]<<8) | (data[off+2]<<16) | (data[off+3]<<24);
const fnLen = data[off+60] | (data[off+61]<<8);
// FileNameLength counts the trailing null (Samba/win9x compat)
names.push(String.fromCharCode(...data.slice(off+94, off+94+fnLen)).replace(/\0$/, ""));
if (next === 0) break;
off += next;
}
ok(names.includes("A Long Filename Here.txt"), `LFN intact: ${JSON.stringify(names)}`);
ok(names.includes(".") && names.includes(".."), "dot entries present");
}
// ─── Test 5d: filename safety + hidden attrs ─────────────────────────────────
console.log("\n[5d] filename safety");
{
const hz = path.join(tmpRoot, "hazard");
fs.mkdirSync(hz);
for (const n of ["con.txt", "aux", "nul.tar.gz", ".DS_Store", ".secret", "trail. "])
fs.writeFileSync(path.join(hz, n), "x");
const t2params = [...u16(0x16), ...u16(100), ...u16(0), ...u16(0x104),
...u32(0), ...cstr("\\hazard\\*")];
const wc = 14 + 1;
const bytesStart = 32 + 1 + wc * 2 + 2;
const paramOff = bytesStart + 3;
const words = [
...u16(t2params.length), ...u16(0), ...u16(100), ...u16(8000),
1, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(t2params.length), ...u16(paramOff),
...u16(0), ...u16(0),
1, 0, ...u16(1)
];
const reply = session.handle(smbReq(CMD_TRANSACTION2, words,
[0, 0, 0, ...t2params], 1, 1))!;
const parsed = parseSmb(reply)!;
const rw = parsed.words;
const dOff = (rw[14] | (rw[15] << 8)) - (32 + 1 + parsed.wordCount * 2 + 2);
const dLen = rw[12] | (rw[13] << 8);
const data = parsed.bytes.slice(dOff, dOff + dLen);
const ents = new Map<string, number>();
for (let off = 0;;) {
const next = data[off] | (data[off+1]<<8) | (data[off+2]<<16) | (data[off+3]<<24);
const attr = data[off+56] | (data[off+57]<<8);
const fnLen = data[off+60] | (data[off+61]<<8);
const nm = String.fromCharCode(...data.slice(off+94, off+94+fnLen)).replace(/\0$/, "");
ents.set(nm, attr);
if (next === 0) break;
off += next;
}
const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;
const bad = [...ents.keys()].filter(n => reserved.test(n.split(".")[0]));
ok(bad.length === 0, `no reserved basenames: ${JSON.stringify([...ents.keys()])}`);
ok(ents.has("con_.txt") && ents.has("aux_"), "reserved names suffixed");
ok(ents.has("nul_.tar.gz"), "reserved across multi-ext");
ok(ents.has("trail__"), "trailing dot/space replaced");
ok((ents.get(".DS_Store")! & 0x06) === 0x06, ".DS_Store hidden+system");
ok((ents.get(".secret")! & 0x02) === 0x02, "dotfile hidden");
ok((ents.get("con_.txt")! & 0x02) === 0, "regular file not hidden");
}
// ─── Test 5c: RAP NetShareEnum lists user share + TOOLS ──────────────────────
console.log("\n[5c] RAP NetShareEnum");
{
// TREE_CONNECT IPC$ first
const ipc = parseSmb(session.handle(smbReq(CMD_TREE_CONNECT_ANDX,
[0xff,0,0,0,...u16(0),...u16(1)],
[0, ...cstr("\\\\HOST\\IPC$"), ...cstr("IPC")], 0, 1))!)!;
ok(ipc.tid === 0xfffe, `IPC$ tid=${ipc.tid}`);
// RAP NetShareEnum: TRANS over \PIPE\LANMAN
const rap = [...u16(0), ...cstr("WrLeh"), ...cstr("B13BWz"), ...u16(1), ...u16(4096)];
const wc = 14;
const bytesStart = 32 + 1 + wc * 2 + 2;
const name = cstr("\\PIPE\\LANMAN");
const paramOff = bytesStart + name.length;
const words = [
...u16(rap.length), ...u16(0), ...u16(100), ...u16(4096),
0, 0, ...u16(0), ...u32(0), ...u16(0),
...u16(rap.length), ...u16(paramOff),
...u16(0), ...u16(0),
0, 0
];
const reply = session.handle(smbReq(0x25, words, [...name, ...rap], ipc.tid, 1))!;
const dataStr = String.fromCharCode(...parseSmb(reply)!.bytes);
const userShare = path.basename(tmpRoot).replace(/[^A-Za-z0-9_$~!#%&'()@^`{}.-]/g, "")
.toUpperCase().slice(0, 12);
ok(dataStr.includes("TOOLS"), "TOOLS share listed");
ok(dataStr.includes(userShare), `user share "${userShare}" listed`);
}
// ─── Test 6: OPEN + READ + CLOSE ─────────────────────────────────────────────
console.log("\n[6] OPEN_ANDX + READ_ANDX + CLOSE");
let openedFid = 0;
{
// OPEN_ANDX words: AndX(4) Flags(2) Access(2) SrchAttr(2) FileAttr(2)
// CreateTime(4) OpenFunc(2) AllocSize(4) Timeout(4) Rsvd(4)
const words = [0xff, 0, 0, 0, ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u16(1), ...u32(0), ...u32(0), ...u32(0)];
const bytes = [...cstr("\\hello.txt")];
const req = smbReq(CMD_OPEN_ANDX, words, bytes, 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "open status OK");
openedFid = parsed.words[4] | (parsed.words[5] << 8); // FID after AndX
ok(openedFid > 0, `fid=${openedFid}`);
// OPEN_ANDX response: AndX(4) FID(2) Attrs(2) LastWrite(4) DataSize(4) ...
const fileSize = parsed.words[12] | (parsed.words[13] << 8) |
(parsed.words[14] << 16) | (parsed.words[15] << 24);
ok(fileSize === 21, `size=${fileSize} (expect 21)`);
}
{
// READ_ANDX: AndX(4) FID(2) Offset(4) MaxCount(2) MinCount(2)
// Timeout(4) Remaining(2) [OffsetHigh(4)]
const words = [0xff, 0, 0, 0, ...u16(openedFid), ...u32(0), ...u16(100),
...u16(0), ...u32(0), ...u16(0)];
const req = smbReq(CMD_READ_ANDX, words, [], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "read status OK");
const dataLen = parsed.words[10] | (parsed.words[11] << 8);
ok(dataLen === 21, `read ${dataLen} bytes`);
// bytes = pad(1) + data
const text = String.fromCharCode(...parsed.bytes.slice(1, 1 + dataLen));
ok(text === "Hello from the host!\n", `content: ${JSON.stringify(text)}`);
}
{
const words = [...u16(openedFid), ...u32(0)];
const req = smbReq(CMD_CLOSE, words, [], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "close status OK");
}
// ─── Test 7: error paths ─────────────────────────────────────────────────────
console.log("\n[7] Error handling");
{
const words = [0xff, 0, 0, 0, ...u16(0), ...u16(0), ...u16(0), ...u16(0),
...u32(0), ...u16(1), ...u32(0), ...u32(0), ...u32(0)];
const req = smbReq(CMD_OPEN_ANDX, words, [...cstr("\\nope.txt")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, `nonexistent file → status=0x${parsed.status.toString(16)}`);
// DOS error: class=1 (ERRDOS), code=2 (badfile)
ok((parsed.status & 0xff) === 1 && (parsed.status >> 16) === 2, "ERRDOS/ERR_badfile");
}
{
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\..\\..\\etc\\passwd")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, "lexical traversal (../) blocked");
}
{
// Virtual file: connect to TOOLS share, open and read _MAPZ.BAT
const tcReq = smbReq(CMD_TREE_CONNECT_ANDX,
[0xff, 0, 0, 0, ...u16(0), ...u16(1)],
[0, ...cstr("\\\\192.168.86.1\\TOOLS"), ...cstr("?????")], 0, 1);
const tcParsed = parseSmb(session.handle(tcReq)!)!;
ok(tcParsed.tid === 2, `TOOLS share tid=${tcParsed.tid}`);
const oReq = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\_MAPZ.BAT")], tcParsed.tid, 1);
const oReply = session.handle(oReq)!;
const oParsed = parseSmb(oReply)!;
ok(oParsed.status === 0, "open virtual _MAPZ.BAT");
const vfid = oParsed.words[4] | (oParsed.words[5] << 8);
const rReq = smbReq(CMD_READ_ANDX,
[0xff,0,0,0,...u16(vfid),...u32(0),...u16(500),...u16(0),...u32(0),...u16(0)], [], tcParsed.tid, 1);
const rReply = session.handle(rReq)!;
const rParsed = parseSmb(rReply)!;
const len = rParsed.words[10] | (rParsed.words[11] << 8);
const text = String.fromCharCode(...rParsed.bytes.slice(1, 1 + len));
ok(text.includes("NET USE Z:"), `virtual read: ${JSON.stringify(text.slice(0, 40))}`);
// SEEK to end → file size, then core READ (0x0a). This is the exact path
// Win95+Notepad take under NT LM 0.12 with Capabilities=0.
const oReq2 = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\README.TXT")], tcParsed.tid, 1);
const oP2 = parseSmb(session.handle(oReq2)!)!;
const fid2 = oP2.words[4] | (oP2.words[5] << 8);
ok(oP2.status === 0 && fid2 > 0, `open README.TXT fid=${fid2}`);
const sP = parseSmb(session.handle(smbReq(0x12,
[...u16(fid2), ...u16(2), ...u32(0)], [], tcParsed.tid, 1))!)!;
const seekPos = sP.words[0] | (sP.words[1] << 8) | (sP.words[2] << 16) | (sP.words[3] << 24);
ok(sP.status === 0 && seekPos > 100, `SEEK end → size=${seekPos}`);
const rP = parseSmb(session.handle(smbReq(0x0a,
[...u16(fid2), ...u16(seekPos), ...u32(0), ...u16(seekPos)], [], tcParsed.tid, 1))!)!;
ok(rP.status === 0, "core READ status OK");
// bytes: 0x01 + len(2) + data
const dlen = rP.bytes[1] | (rP.bytes[2] << 8);
const body = String.fromCharCode(...rP.bytes.slice(3, 3 + dlen));
ok(dlen === seekPos, `core READ returned ${dlen} bytes`);
ok(body.includes("windows95 tools"), `README content: ${JSON.stringify(body.slice(0, 30))}`);
// READ_RAW (0x1a): response is raw bytes, no SMB header.
const raw = session.handle(smbReq(0x1a,
[...u16(fid2), ...u32(0), ...u16(65535), ...u16(0), ...u32(0), ...u16(0)],
[], tcParsed.tid, 1))!;
ok(raw.length === seekPos && raw[0] === 0x77 /* 'w' */,
`READ_RAW returned ${raw.length} raw bytes (no SMB header)`);
const rawBad = session.handle(smbReq(0x1a,
[...u16(0x7777), ...u32(0), ...u16(100), ...u16(0), ...u32(0), ...u16(0)],
[], tcParsed.tid, 1))!;
ok(rawBad.length === 0, "READ_RAW bad fid → 0-byte reply");
}
{
// symlink escape: link inside share → file outside share
const outside = path.join(os.tmpdir(), "smbtest-secret.txt");
fs.writeFileSync(outside, "leaked");
fs.symlinkSync(outside, path.join(tmpRoot, "evil"));
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\evil")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, "symlink escape blocked");
fs.unlinkSync(outside);
}
{
// symlink directory escape: link inside share → dir outside, then walk into it
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "smbtest-out-"));
fs.writeFileSync(path.join(outsideDir, "secret.txt"), "leaked");
fs.symlinkSync(outsideDir, path.join(tmpRoot, "evildir"));
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\evildir\\secret.txt")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status !== 0, "symlink dir escape blocked");
fs.rmSync(outsideDir, { recursive: true });
}
{
// symlink that stays INSIDE the share should still work
fs.symlinkSync(path.join(tmpRoot, "hello.txt"), path.join(tmpRoot, "alias"));
const req = smbReq(CMD_OPEN_ANDX,
[0xff,0,0,0,...u16(0),...u16(0),...u16(0),...u16(0),...u32(0),...u16(1),...u32(0),...u32(0),...u32(0)],
[...cstr("\\alias")], 1, 1);
const reply = session.handle(req)!;
const parsed = parseSmb(reply)!;
ok(parsed.status === 0, "internal symlink allowed");
}
// ─── Cleanup ─────────────────────────────────────────────────────────────────
session.destroy();
fs.rmSync(tmpRoot, { recursive: true });
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail > 0 ? 1 : 0);

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

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

View File

@@ -1,43 +0,0 @@
import * as React from "react";
export interface StartMenuProps {
navigate: (to: string) => void;
}
export class StartMenu extends React.Component<StartMenuProps, {}> {
constructor(props: StartMenuProps) {
super(props);
this.navigate = this.navigate.bind(this);
}
public render() {
return (
<nav className="nav nav-bottom">
<a onClick={this.navigate} href="#" id="start" className="nav-link">
<img src="../../static/start.png" alt="Start" />
<span>Start</span>
</a>
<div className="nav-menu">
<a
onClick={this.navigate}
href="#"
id="settings"
className="nav-link"
>
<img src="../../static/settings.png" />
<span>Settings</span>
</a>
<a onClick={this.navigate} href="#" id="drive" className="nav-link">
<img src="../../static/drive.png" />
<span>Modify C: Drive</span>
</a>
</div>
</nav>
);
}
private navigate(event: React.SyntheticEvent<HTMLAnchorElement>) {
this.props.navigate(event.currentTarget.id);
}
}

1
src/renderer/styles.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "*.css";

View File

@@ -0,0 +1,50 @@
import * as fs from "fs";
/**
* v86 disk buffer backed by synchronous fs reads.
*
* v86's stock async loaders (AsyncXHRBuffer / AsyncFileBuffer) 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/CDVSD 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. Serving the bytes synchronously closes that window.
*
* The hard disk doesn't hit this because ESDI_506 drives it via bus-master
* DMA, which is purely IRQ-driven on the host side.
*/
export class SyncFileBuffer {
public byteLength: number;
public onload: undefined | ((e: { buffer?: ArrayBuffer }) => void);
public onprogress: undefined | (() => void);
private fd: number;
constructor(path: string) {
this.fd = fs.openSync(path, "r");
this.byteLength = fs.fstatSync(this.fd).size;
this.onload = undefined;
this.onprogress = undefined;
}
load() {
this.onload?.({});
}
get(start: number, len: number, fn: (data: Uint8Array) => void) {
const buf = Buffer.alloc(len);
fs.readSync(this.fd, buf, 0, len, start);
fn(new Uint8Array(buf.buffer, buf.byteOffset, len));
}
set(_start: number, _slice: Uint8Array, fn: () => void) {
fn();
}
get_state() {
return [[]];
}
set_state(_state: unknown) {}
}

View File

@@ -0,0 +1,132 @@
import * as fs from "fs";
import * as path from "path";
/**
* Minimal read-only FAT32 walker. Just enough to pull user files out of the
* recovered overlay+base view — no writes, no FAT12/16, no exFAT.
*
* `readSector(lba)` returns 512 bytes at absolute LBA from the *full disk*
* (MBR at LBA 0). `isDirty(lba)` reports whether that sector came from the
* guest's write overlay; we use it to skip files the user never touched so
* the output isn't 200 MB of possibly-mismatched OS binaries.
*/
export async function extractFat32(
readSector: (lba: number) => Buffer,
isDirty: (lba: number) => boolean,
outDir: string,
): Promise<number> {
// First partition from the MBR.
const mbr = readSector(0);
const partLba = mbr.readUInt32LE(0x1be + 8);
const bpb = readSector(partLba);
const bytesPerSec = bpb.readUInt16LE(11);
const secPerClus = bpb.readUInt8(13);
const rsvd = bpb.readUInt16LE(14);
const nFats = bpb.readUInt8(16);
const secPerFat = bpb.readUInt32LE(36);
const rootClus = bpb.readUInt32LE(44);
if (bytesPerSec !== 512) throw new Error("unexpected sector size");
const fatLba = partLba + rsvd;
const dataLba = partLba + rsvd + nFats * secPerFat;
const clusLba = (c: number) => dataLba + (c - 2) * secPerClus;
const fatSecCache = new Map<number, Buffer>();
const nextCluster = (c: number) => {
const off = c * 4;
const sec = fatLba + (off >> 9);
let b = fatSecCache.get(sec);
if (!b) fatSecCache.set(sec, (b = readSector(sec)));
return b.readUInt32LE(off & 511) & 0x0fffffff;
};
const chain = (c: number) => {
const out: number[] = [];
while (c >= 2 && c < 0x0ffffff8 && out.length < 1 << 20) {
out.push(c);
c = nextCluster(c);
}
return out;
};
const readClusters = (clusters: number[]) => {
const buf = Buffer.allocUnsafe(clusters.length * secPerClus * 512);
let o = 0;
for (const c of clusters) {
const base = clusLba(c);
for (let s = 0; s < secPerClus; s++)
readSector(base + s).copy(buf, o + s * 512);
o += secPerClus * 512;
}
return buf;
};
const anyDirty = (clusters: number[]) => {
for (const c of clusters) {
const base = clusLba(c);
for (let s = 0; s < secPerClus; s++) if (isDirty(base + s)) return true;
}
return false;
};
const safe = (n: string) =>
n.replace(/[\\/:*?"<>|]/g, "_").replace(/[. ]+$/, "") || "_";
let files = 0;
const walk = async (clus: number, hostDir: string) => {
const raw = readClusters(chain(clus));
let lfn = "";
for (let i = 0; i + 32 <= raw.length; i += 32) {
const e = raw.subarray(i, i + 32);
if (e[0] === 0) break;
if (e[0] === 0xe5) {
lfn = "";
continue;
}
const attr = e[11];
if ((attr & 0x3f) === 0x0f) {
// VFAT LFN entries arrive last-first; each carries 13 UCS-2 chars.
let part = "";
for (const o of [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]) {
const ch = e.readUInt16LE(o);
if (ch === 0 || ch === 0xffff) break;
part += String.fromCharCode(ch);
}
lfn = part + lfn;
continue;
}
if (attr & 0x08) {
lfn = "";
continue; // volume label
}
const short =
e.toString("latin1", 0, 8).trimEnd() +
(e[8] !== 0x20
? "." + e.toString("latin1", 8, 11).trimEnd()
: "");
const name = lfn || short;
lfn = "";
if (name === "." || name === "..") continue;
const start = (e.readUInt16LE(20) << 16) | e.readUInt16LE(26);
if (attr & 0x10) {
if (start >= 2) await walk(start, path.join(hostDir, safe(name)));
} else {
const size = e.readUInt32LE(28);
if (size === 0 || start < 2) continue;
const cl = chain(start);
if (!anyDirty(cl)) continue;
await fs.promises.mkdir(hostDir, { recursive: true });
await fs.promises.writeFile(
path.join(hostDir, safe(name)),
readClusters(cl).subarray(0, size),
);
files++;
}
}
};
await fs.promises.mkdir(outDir, { recursive: true });
await walk(rootClus, outDir);
return files;
}

View File

@@ -0,0 +1,17 @@
import { ipcRenderer } from "electron";
import { IPC_COMMANDS } from "../../constants";
let _statePath = "";
export async function getStatePath(): Promise<string> {
if (_statePath) {
return _statePath;
}
const statePath = await ipcRenderer.invoke(IPC_COMMANDS.GET_STATE_PATH);
return (_statePath = statePath);
}
export function getLegacyStatePath(): Promise<string | null> {
return ipcRenderer.invoke(IPC_COMMANDS.GET_LEGACY_STATE_PATH);
}

Some files were not shown because too many files have changed in this diff Show More