mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-14 18:31:59 +00:00
Compare commits
209 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0090e5569 | ||
|
|
b14ea86ced | ||
|
|
41394e4d8e | ||
|
|
3151ffff3c | ||
|
|
7d48709cff | ||
|
|
739931aec6 | ||
|
|
731a9b2369 | ||
|
|
35c82c5d09 | ||
|
|
60ee631575 | ||
|
|
dddaca9120 | ||
|
|
d383958f2b | ||
|
|
a1637b1de1 | ||
|
|
fff371073d | ||
|
|
8153e91706 | ||
|
|
fb701041c2 | ||
|
|
e1c30f701c | ||
|
|
bbcc4f32cd | ||
|
|
9c99b6cb65 | ||
|
|
2e01152d82 | ||
|
|
63f02842bc | ||
|
|
c847467de6 | ||
|
|
fa0e4c691e | ||
|
|
6c964f99e9 | ||
|
|
1f116d607b | ||
|
|
d38355ff16 | ||
|
|
b74e6c7b0a | ||
|
|
3c63139fae | ||
|
|
bc76e9c79a | ||
|
|
6e73df11ae | ||
|
|
5da7f94c5a | ||
|
|
766497bd5d | ||
|
|
85c44513cb | ||
|
|
ccd2b28169 | ||
|
|
27b9e0eb7a | ||
|
|
1dbb853fe6 | ||
|
|
9b217731f5 | ||
|
|
148f8e4874 | ||
|
|
43c025929b | ||
|
|
3b62e1c9b5 | ||
|
|
e9ddfab65d | ||
|
|
89c0a8575d | ||
|
|
2ef06eb412 | ||
|
|
3eab26fed1 | ||
|
|
1bef3cce62 | ||
|
|
17e0182ecb | ||
|
|
4ed96deecc | ||
|
|
80505384c6 | ||
|
|
a0ee5a2f10 | ||
|
|
74fc2d291e | ||
|
|
20f7f8c70e | ||
|
|
55c4fbb27e | ||
|
|
c243ebbbcc | ||
|
|
85e25ed3ab | ||
|
|
e16afcb748 | ||
|
|
585fbc9624 | ||
|
|
45f5a136b2 | ||
|
|
2d34183e14 | ||
|
|
00943ae4da | ||
|
|
a6d57c6538 | ||
|
|
35f7c3362d | ||
|
|
94021edb61 | ||
|
|
6f2025ffc0 | ||
|
|
3a7b37fff0 | ||
|
|
16eb63e13b | ||
|
|
54fe721f4f | ||
|
|
6dee2f45a2 | ||
|
|
e7e047b0a0 | ||
|
|
5a334abb13 | ||
|
|
aacfae7ada | ||
|
|
9b87b77570 | ||
|
|
e6a0d931af | ||
|
|
973580d60b | ||
|
|
8fcf5eaed3 | ||
|
|
e15d918fb3 | ||
|
|
b442c6db08 | ||
|
|
5c946bbca4 | ||
|
|
c9e45a9f39 | ||
|
|
bc42ce3231 | ||
|
|
d91e72ccc5 | ||
|
|
bd40f00f8d | ||
|
|
1cbfca7451 | ||
|
|
7710c4b7af | ||
|
|
4cce1f0740 | ||
|
|
f8ae78f247 | ||
|
|
62f8eb2696 | ||
|
|
da4b0dd728 | ||
|
|
6cc05fa042 | ||
|
|
dda3707a23 | ||
|
|
a4bcd7fb61 | ||
|
|
17a8139346 | ||
|
|
489c7312d0 | ||
|
|
c3537ae330 | ||
|
|
c483871df9 | ||
|
|
e66cbd70db | ||
|
|
19a1bbc002 | ||
|
|
ef57e3a7fe | ||
|
|
7eae250c2a | ||
|
|
33db389d59 | ||
|
|
61f3269a45 | ||
|
|
e5d897c663 | ||
|
|
a7ae665adc | ||
|
|
bea2267f42 | ||
|
|
a55d08fafc | ||
|
|
97702cb01b | ||
|
|
12160a1ac4 | ||
|
|
3dd50db272 | ||
|
|
7b92d33584 | ||
|
|
24a1c30502 | ||
|
|
7ce0863ae8 | ||
|
|
90ec67fb16 | ||
|
|
9cab8e46f6 | ||
|
|
03b39d76b5 | ||
|
|
d8b4a139ac | ||
|
|
9f4771bf26 | ||
|
|
552b97eec5 | ||
|
|
6c0f00170c | ||
|
|
e3b9a839f5 | ||
|
|
238b07b7dd | ||
|
|
9dc1e422ff | ||
|
|
ebe7427385 | ||
|
|
3e3bee2062 | ||
|
|
c93b6878a9 | ||
|
|
d2e26ef5d1 | ||
|
|
c41befae64 | ||
|
|
8b720750db | ||
|
|
ee317ec5aa | ||
|
|
d7c657e671 | ||
|
|
7a8a54c76b | ||
|
|
c29f98b6bc | ||
|
|
8d1847a8d1 | ||
|
|
194f4fabaf | ||
|
|
3f4a5e97fa | ||
|
|
3eb789d055 | ||
|
|
8a8f064864 | ||
|
|
58add05655 | ||
|
|
0a400d915f | ||
|
|
f615e7754c | ||
|
|
92717c8047 | ||
|
|
045b83f843 | ||
|
|
1dd3b76187 | ||
|
|
4b1dd6146c | ||
|
|
3601599ff1 | ||
|
|
6bf7678079 | ||
|
|
5396cae0f0 | ||
|
|
c5a24643fd | ||
|
|
59a651a205 | ||
|
|
f5cb94776a | ||
|
|
982c866899 | ||
|
|
9e8cef8da7 | ||
|
|
3b76a39060 | ||
|
|
e7d515de84 | ||
|
|
a4092f105a | ||
|
|
71a11cfbe3 | ||
|
|
f3c8f3409a | ||
|
|
8d8fc949cd | ||
|
|
0c2149b756 | ||
|
|
51d0011ed0 | ||
|
|
658fed75da | ||
|
|
186a2a8ba9 | ||
|
|
7653d7294c | ||
|
|
33ef8abcc8 | ||
|
|
ea134d046e | ||
|
|
c7f765df03 | ||
|
|
dcc3e72bcf | ||
|
|
241606d097 | ||
|
|
b7aa6a760d | ||
|
|
6b7bb0f460 | ||
|
|
90a97a11bf | ||
|
|
3598ceb97c | ||
|
|
dbcefb4b7b | ||
|
|
42719bb1d7 | ||
|
|
153002403b | ||
|
|
889c53857e | ||
|
|
5b8f3e12bc | ||
|
|
59299a2c48 | ||
|
|
885af7f786 | ||
|
|
55e88cd5b5 | ||
|
|
e0ad866256 | ||
|
|
cf3acd4182 | ||
|
|
a28aef8cf0 | ||
|
|
d2b8d9dd35 | ||
|
|
3802734ef0 | ||
|
|
1f478676f1 | ||
|
|
d19bbeee8f | ||
|
|
1e130b6140 | ||
|
|
e1c5992ff9 | ||
|
|
e879760678 | ||
|
|
2a11633171 | ||
|
|
b68d54ae62 | ||
|
|
9600630340 | ||
|
|
bae1909793 | ||
|
|
ee9e138034 | ||
|
|
5558671688 | ||
|
|
9a46ed5080 | ||
|
|
2c160d0f7f | ||
|
|
aafab62707 | ||
|
|
78126a57cb | ||
|
|
f5256ec7a2 | ||
|
|
6c1687c9a5 | ||
|
|
2c041115d0 | ||
|
|
987dc57309 | ||
|
|
614b18969d | ||
|
|
264ef7d069 | ||
|
|
e85cf4f1b2 | ||
|
|
e987da5460 | ||
|
|
a542639bc3 | ||
|
|
5d1928beb2 | ||
|
|
f1b657a53b | ||
|
|
6aa39e66ec |
122
.claude/skills/probe-win95/SKILL.md
Normal file
122
.claude/skills/probe-win95/SKILL.md
Normal 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. |
|
||||
151
.claude/skills/update-v86/SKILL.md
Normal file
151
.claude/skills/update-v86/SKILL.md
Normal 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
|
||||
6–9 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 3B0–3DF 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
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
text eol=lf
|
||||
BIN
.github/images/linux.png
vendored
Normal file
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
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
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
110
.github/workflows/build.yml
vendored
Normal 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
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,4 +1,21 @@
|
||||
node_modules
|
||||
out
|
||||
src/images
|
||||
.DS_Store
|
||||
|
||||
/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/
|
||||
|
||||
22
HELP.md
22
HELP.md
@@ -1,24 +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
|
||||
|
||||
Restart the application and click on the "Reset machine & delete state" button.
|
||||
You can find it in the lower left of the screen. Then, hit the "Start Windows 95"
|
||||
button to start your virtual machine again.
|
||||
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 Windows 10, macOS, or Linux, you can probably "mount" the
|
||||
virtual hard drive used by `windows95` to add files. Hit the "Show Disk Image"
|
||||
button in the lower right of the app, which will take you to the disk image.
|
||||
On both Windows 10 and macOS, double-click the disk image to open it.
|
||||
|
||||
On Linux, search the Internet for instructions on how to mount an `img` disk
|
||||
image on your distribution.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2018 Felix Rieseberg
|
||||
Copyright 2019 Felix Rieseberg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
123
README.md
123
README.md
@@ -2,51 +2,144 @@
|
||||
|
||||
This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes, it's the full thing. I'm sorry.
|
||||
|
||||
Interested in Electron? Join as at [CovalenceConf](http://covalenceconf.com) in San Francisco!
|
||||
|
||||
## Downloads
|
||||
| | Windows | macOS | Linux |
|
||||
|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Standalone Download | 📦[Standalone, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-1.4.0-win32-standalone-ia32.zip) <br /> 📦[Standalone, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-1.4.0-win32-standalone-x64.zip) | 📦[Standalone](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-macos-1.4.0.zip) | |
|
||||
| Installer | 💽[Setup, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-1.4.0-setup-win32-x64.exe) <br /> 💽[Setup, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-1.4.0-setup-win32-ia32.exe) | | 💽[deb, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-linux-1.4.0_amd64.deb) <br /> 💽[rpm, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v1.4.0/windows95-linux-1.4.0.x86_64.rpm) |
|
||||
|
||||

|
||||
<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.1/windows95-5.0.1-setup-ia32.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95-win32-ia32-5.0.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a>
|
||||
<br />
|
||||
<span>64-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95-5.0.1-setup-x64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95-win32-x64-5.0.1.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.1/windows95-darwin-arm64-5.0.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>Intel Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95-darwin-x64-5.0.1.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.1/windows95-5.0.1-1.x86_64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95_5.0.1_amd64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARM64</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95-5.0.1-1.arm64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95_5.0.1_arm64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARM</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95-5.0.1-1.armv7hl.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.1/windows95_5.0.1_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.
|
||||
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
|
||||
|
||||
## Should this have been a native app?
|
||||
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.
|
||||
|
||||
## How's the code?
|
||||
This only works well by accident and was mostly a joke. The code quality is accordingly. Thus it should not be used for anything other than personal amusement.
|
||||
|
||||
## Credits
|
||||
|
||||
99.999% 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
|
||||
|
||||
Before you can run this from source, you'll need the disk image. It's not part of the
|
||||
repository, but you can grab it using the `Show Disk Image` button from the packaged
|
||||
release, which does include the disk image.
|
||||
release, which does include the disk image. You can find that button in the
|
||||
`Modify C: Drive` section.
|
||||
|
||||
Unpack the `images` folder into the `src` folder, creating this layout:
|
||||
|
||||
```
|
||||
./src/images/windows95.img
|
||||
- /images/windows95.img
|
||||
- /images/default-state.bin
|
||||
- /assets/...
|
||||
- /bios/...
|
||||
- /docs/...
|
||||
```
|
||||
|
||||
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)
|
||||
* [Windows 95 is stuck in a bad state](./HELP.md#windows-95-is-stuck-in-a-bad-state)
|
||||
* [I want to install additional apps or games](./HELP.md#i-want-to-install-additional-apps-or-games)
|
||||
* [Running in Docker](./docs/docker-instructions.md)
|
||||
* [Running in an online VM with Kubernetes and Gitpod](./docs/docker-kubernetes-gitpod.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
BIN
assets/boot.gif
Normal file
BIN
assets/boot.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
16
assets/entitlements.plist
Normal file
16
assets/entitlements.plist
Normal 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>
|
||||
BIN
bios/seabios.bin
Normal file
BIN
bios/seabios.bin
Normal file
Binary file not shown.
BIN
bios/vgabios.bin
Normal file
BIN
bios/vgabios.bin
Normal file
Binary file not shown.
@@ -27,7 +27,13 @@ Note: You may need to run `xhost +` on your system to allow connections to the X
|
||||
* [XQuartz](https://www.xquartz.org/)
|
||||
* [Docker](http://docker.io)
|
||||
|
||||
1. Start XQuartz ,go to "Preferences -> Security " ,and check the box "allow connections from network clients"
|
||||
2. restart XQuartz
|
||||
3. In the terminal ,run "xhost +"
|
||||
4. run "docker run -it -e DISPLAY=host.docker.internal:1 toolboc/windows95"
|
||||
1. Start XQuartz, go to `Preferences` -> `Security`, and check the box `Allow connections from network clients`
|
||||
2. Restart XQuartz
|
||||
3. In the terminal, run
|
||||
```
|
||||
xhost +
|
||||
```
|
||||
4. run
|
||||
```
|
||||
docker run -it -e DISPLAY=host.docker.internal:0 toolboc/windows95
|
||||
```
|
||||
|
||||
4
docs/docker-kubernetes-gitpod.md
Normal file
4
docs/docker-kubernetes-gitpod.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Running an online version of windows95
|
||||
You can also run windows95 in Electron, in a virtual X server, in a JavaScript VNC client, in a Kubernetes workspace. What could go wrong?
|
||||
|
||||
[](https://gitpod.io/#https://github.com/felixrieseberg/windows95)
|
||||
48
docs/qemu.md
Normal file
48
docs/qemu.md
Normal 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
45
docs/smb-share.md
Normal 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
25
docs/v86-patches.md
Normal 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 6–9 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`.
|
||||
@@ -1,21 +1,86 @@
|
||||
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'),
|
||||
},
|
||||
packagerConfig: {
|
||||
asar: {
|
||||
unpack: '**/images/*.img'
|
||||
},
|
||||
asar: false,
|
||||
icon: path.resolve(__dirname, 'assets', 'icon'),
|
||||
appBundleId: 'com.felixrieseberg.windows95',
|
||||
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: [
|
||||
{
|
||||
@@ -28,16 +93,17 @@ 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
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platforms: ['darwin']
|
||||
platforms: ['darwin', 'win32']
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
@@ -46,10 +112,6 @@ module.exports = {
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
platforms: ['linux']
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-flatpak',
|
||||
platforms: ['linux']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
45
guest-tools/README.md
Normal file
45
guest-tools/README.md
Normal 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 6–9; 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).
|
||||
12
guest-tools/agent/Dockerfile
Normal file
12
guest-tools/agent/Dockerfile
Normal 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
|
||||
30
guest-tools/agent/Makefile
Normal file
30
guest-tools/agent/Makefile
Normal 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
|
||||
222
guest-tools/agent/w95tools.c
Normal file
222
guest-tools/agent/w95tools.c
Normal 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;
|
||||
}
|
||||
6
guest-tools/mouse-driver/LICENSE.TXT
Normal file
6
guest-tools/mouse-driver/LICENSE.TXT
Normal 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
|
||||
8
guest-tools/mouse-driver/OEMSETUP.INF
Normal file
8
guest-tools/mouse-driver/OEMSETUP.INF
Normal 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
|
||||
BIN
guest-tools/mouse-driver/VBMOUSE.DRV
Normal file
BIN
guest-tools/mouse-driver/VBMOUSE.DRV
Normal file
Binary file not shown.
54
guest-tools/mouse-driver/VBMOUSE.EN
Normal file
54
guest-tools/mouse-driver/VBMOUSE.EN
Normal 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
|
||||
13516
package-lock.json
generated
13516
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
53
package.json
53
package.json
@@ -1,15 +1,20 @@
|
||||
{
|
||||
"name": "windows95",
|
||||
"productName": "windows95",
|
||||
"version": "1.5.0",
|
||||
"version": "5.0.1",
|
||||
"description": "Windows 95, in an app. I'm sorry.",
|
||||
"main": "src/index.js",
|
||||
"main": "./dist/src/main/main.js",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "standard \"src/**/*.js\""
|
||||
"lint": "prettier --write src/**/*.{ts,tsx} && npm run check-links",
|
||||
"tsc": "tsc -p tsconfig.json --noEmit",
|
||||
"check-links": "node tools/check-links.js",
|
||||
"qemu": "qemu-system-i386 -m 128 -drive file=images/windows95.img,format=raw -device sb16 -M pc,acpi=off,vmport=on -cpu pentium -netdev user,id=mynet0 -device ne2k_isa,netdev=mynet0,iobase=0x300,irq=10",
|
||||
"qemu:cdrom": "npm run qemu -- -cdrom",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Felix Rieseberg, felix@felixrieseberg.com",
|
||||
@@ -17,30 +22,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": "^7.0.1",
|
||||
"update-electron-app": "^1.3.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.32",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.32",
|
||||
"@electron-forge/maker-flatpak": "^6.0.0-beta.32",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.32",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.32",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.32",
|
||||
"electron": "4.0.4",
|
||||
"node-abi": "^2.6.0",
|
||||
"standard": "^12.0.1"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
32
patches/@electron+packager+18.4.4.patch
Normal file
32
patches/@electron+packager+18.4.4.patch
Normal 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;
|
||||
25
src/cache.js
25
src/cache.js
@@ -1,25 +0,0 @@
|
||||
const { session } = require('electron')
|
||||
|
||||
const clearCaches = async () => {
|
||||
await clearCache()
|
||||
await clearStorageData()
|
||||
}
|
||||
|
||||
const clearCache = () => {
|
||||
return new Promise((resolve) => {
|
||||
session.defaultSession.clearCache(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
const clearStorageData = () => {
|
||||
return new Promise((resolve) => {
|
||||
session.defaultSession.clearStorageData({
|
||||
storages: 'appcache, cookies, filesystem, indexdb, localstorage, shadercache, websql, serviceworkers',
|
||||
quotas: 'temporary, persistent, syncable'
|
||||
}, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearCaches
|
||||
}
|
||||
31
src/cache.ts
Normal file
31
src/cache.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { session } from "electron";
|
||||
|
||||
export async function clearCaches() {
|
||||
await clearCache();
|
||||
await clearStorageData();
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
if (session.defaultSession) {
|
||||
await session.defaultSession.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStorageData() {
|
||||
if (!session.defaultSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await session.defaultSession.clearStorageData({
|
||||
storages: [
|
||||
"cookies",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
"localstorage",
|
||||
"shadercache",
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
],
|
||||
quotas: ["temporary"],
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
const { remote, app } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const _app = app || remote.app
|
||||
|
||||
const CONSTANTS = {
|
||||
IMAGE_PATH: path.join(__dirname, 'images/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')
|
||||
}
|
||||
|
||||
const IPC_COMMANDS = {
|
||||
TOGGLE_INFO: 'TOGGLE_INFO',
|
||||
MACHINE_RESTART: 'MACHINE_RESTART',
|
||||
MACHINE_RESET: 'MACHINE_RESET',
|
||||
MACHINE_CTRL_ALT_DEL: 'MACHINE_CTRL_ALT_DEL',
|
||||
SHOW_DISK_IMAGE: 'SHOW_DISK_IMAGE'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONSTANTS,
|
||||
IPC_COMMANDS
|
||||
}
|
||||
54
src/constants.ts
Normal file
54
src/constants.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as path from "path";
|
||||
|
||||
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 = {
|
||||
IMAGES_PATH,
|
||||
IMAGE_PATH: path.join(IMAGES_PATH, "windows95.img"),
|
||||
IMAGE_DEFAULT_SIZE: 1073741824, // 1GB
|
||||
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",
|
||||
// Machine instructions
|
||||
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",
|
||||
// Runtime media swap (main → renderer, payload = host path or undefined=eject)
|
||||
MACHINE_SET_FLOPPY: "MACHINE_SET_FLOPPY",
|
||||
MACHINE_SET_CDROM: "MACHINE_SET_CDROM",
|
||||
MACHINE_SET_SMB_SHARE: "MACHINE_SET_SMB_SHARE",
|
||||
// Machine events
|
||||
MACHINE_STARTED: "MACHINE_STARTED",
|
||||
MACHINE_STOPPED: "MACHINE_STOPPED",
|
||||
// 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
36
src/css/emulator.css
Normal 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;
|
||||
}
|
||||
}
|
||||
7
src/css/info.css
Normal file
7
src/css/info.css
Normal file
@@ -0,0 +1,7 @@
|
||||
#information {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
bottom: 50px;
|
||||
font-size: 18px;
|
||||
}
|
||||
78
src/css/root.css
Normal file
78
src/css/root.css
Normal 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
71
src/css/settings.css
Normal 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
117
src/css/start.css
Normal 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
55
src/css/status.css
Normal 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
2
src/css/vendor/98.css
vendored
Normal file
File diff suppressed because one or more lines are too long
21
src/css/vendor/LICENSE
vendored
Normal file
21
src/css/vendor/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Yoshi Mannaert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
BIN
src/css/vendor/ms_sans_serif.woff
vendored
Normal file
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
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
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
BIN
src/css/vendor/ms_sans_serif_bold.woff2
vendored
Normal file
Binary file not shown.
29
src/es6.js
29
src/es6.js
@@ -1,29 +0,0 @@
|
||||
const { protocol } = require('electron')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
const ES6_PATH = path.join(__dirname, 'renderer')
|
||||
|
||||
protocol.registerStandardSchemes(['es6'])
|
||||
|
||||
async function setupProtocol () {
|
||||
protocol.registerBufferProtocol('es6', async (req, cb) => {
|
||||
console.log(req)
|
||||
|
||||
try {
|
||||
const filePath = path.join(ES6_PATH, req.url.replace('es6://', ''))
|
||||
.replace('.js/', '.js')
|
||||
.replace('.js\\', '.js')
|
||||
|
||||
const fileContent = await fs.readFile(filePath)
|
||||
|
||||
cb({ mimeType: 'text/javascript', data: fileContent }) // eslint-disable-line
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setupProtocol
|
||||
}
|
||||
55
src/index.js
55
src/index.js
@@ -1,55 +0,0 @@
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const { createMenu } = require('./menu')
|
||||
const { setupProtocol } = require('./es6')
|
||||
|
||||
if (require('electron-squirrel-startup')) { // eslint-disable-line global-require
|
||||
app.quit()
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
require('update-electron-app')({
|
||||
repo: 'felixrieseberg/windows95',
|
||||
updateInterval: '1 hour'
|
||||
})
|
||||
}
|
||||
|
||||
let mainWindow
|
||||
|
||||
const createWindow = () => {
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.loadURL(`file://${__dirname}/renderer/index.html`)
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
app.on('ready', async () => {
|
||||
await setupProtocol()
|
||||
await createMenu()
|
||||
|
||||
createWindow()
|
||||
})
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
28
src/main/about-panel.ts
Normal file
28
src/main/about-panel.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { AboutPanelOptionsOptions, app } from "electron";
|
||||
|
||||
/**
|
||||
* Sets Fiddle's About panel options on Linux and macOS
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export function setupAboutPanel(): void {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const options: AboutPanelOptionsOptions = {
|
||||
applicationName: "windows95",
|
||||
applicationVersion: app.getVersion(),
|
||||
version: process.versions.electron,
|
||||
copyright: "Felix Rieseberg",
|
||||
};
|
||||
|
||||
switch (process.platform) {
|
||||
case "linux":
|
||||
options.website = "https://github.com/felixrieseberg/windows95";
|
||||
case "darwin":
|
||||
options.credits = "https://github.com/felixrieseberg/windows95";
|
||||
default:
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
app.setAboutPanelOptions(options);
|
||||
}
|
||||
64
src/main/fileserver/fileserver.ts
Normal file
64
src/main/fileserver/fileserver.ts
Normal 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
65
src/main/ipc.ts
Normal 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
3
src/main/logging.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function log(message: string, ...args: unknown[]) {
|
||||
console.log(`[${new Date().toLocaleString()}] ${message}`, ...args);
|
||||
}
|
||||
82
src/main/main.ts
Normal file
82
src/main/main.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { app } from "electron";
|
||||
|
||||
import { isDevMode } from "../utils/devmode";
|
||||
import { setupAboutPanel } from "./about-panel";
|
||||
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
|
||||
* the method that takes care of booting the application.
|
||||
*/
|
||||
export async function onReady() {
|
||||
if (!isDevMode()) process.env.NODE_ENV = "production";
|
||||
|
||||
setupSession();
|
||||
setupIpcListeners();
|
||||
getOrCreateWindow();
|
||||
setupAboutPanel();
|
||||
setupMenu();
|
||||
setupUpdates();
|
||||
setupFileServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "before-quit" event
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export function onBeforeQuit() {
|
||||
(global as any).isQuitting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* All windows have been closed, quit on anything but
|
||||
* macOS.
|
||||
*/
|
||||
export function onWindowsAllClosed() {
|
||||
// On OS X it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main method - and the first function to run
|
||||
* when Fiddle is launched.
|
||||
*
|
||||
* Exported for testing purposes.
|
||||
*/
|
||||
export function main() {
|
||||
// Handle creating/removing shortcuts on Windows when
|
||||
// installing/uninstalling.
|
||||
if (shouldQuit()) {
|
||||
app.quit();
|
||||
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");
|
||||
|
||||
// Launch
|
||||
app.on("ready", onReady);
|
||||
app.on("before-quit", onBeforeQuit);
|
||||
app.on("window-all-closed", onWindowsAllClosed);
|
||||
}
|
||||
|
||||
main();
|
||||
350
src/main/menu.ts
Normal file
350
src/main/menu.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { app, shell, Menu, BrowserWindow, ipcMain, dialog } from "electron";
|
||||
|
||||
import { clearCaches } from "../cache";
|
||||
import { IPC_COMMANDS } from "../constants";
|
||||
import { isDevMode } from "../utils/devmode";
|
||||
import { log } from "./logging";
|
||||
|
||||
const LINKS = {
|
||||
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",
|
||||
};
|
||||
|
||||
export async function setupMenu() {
|
||||
await createMenu();
|
||||
|
||||
ipcMain.on(IPC_COMMANDS.MACHINE_STARTED, () =>
|
||||
createMenu({ isRunning: true }),
|
||||
);
|
||||
ipcMain.on(IPC_COMMANDS.MACHINE_STOPPED, () =>
|
||||
createMenu({ isRunning: false }),
|
||||
);
|
||||
}
|
||||
|
||||
function send(cmd: string, ...args: unknown[]) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
|
||||
if (windows[0]) {
|
||||
log(`Sending "${cmd}"`);
|
||||
windows[0].webContents.send(cmd, ...args);
|
||||
} else {
|
||||
log(`Tried to send "${cmd}", but could not find window`);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile(filters: Electron.FileFilter[]) {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openFile"],
|
||||
filters,
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
}
|
||||
|
||||
async function pickFolder() {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
}
|
||||
|
||||
async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
const template: Array<Electron.MenuItemConstructorOptions> = [
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Toggle Full Screen",
|
||||
accelerator: (function () {
|
||||
if (process.platform === "darwin") {
|
||||
return "Ctrl+Command+F";
|
||||
} else {
|
||||
return "F11";
|
||||
}
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) {
|
||||
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Toggle Developer Tools",
|
||||
accelerator: (function () {
|
||||
if (process.platform === "darwin") {
|
||||
return "Alt+Command+I";
|
||||
} else {
|
||||
return "Ctrl+Shift+I";
|
||||
}
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow instanceof BrowserWindow) {
|
||||
focusedWindow.webContents.toggleDevTools();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Toggle Emulator Info",
|
||||
click: () => send(IPC_COMMANDS.TOGGLE_INFO),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
role: "reload",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "editMenu",
|
||||
visible: isDevMode(),
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
role: "window",
|
||||
submenu: [
|
||||
{
|
||||
label: "Minimize",
|
||||
accelerator: "CmdOrCtrl+M",
|
||||
role: "minimize",
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: "CmdOrCtrl+W",
|
||||
role: "close",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Zoom in",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_IN),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Zoom out",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_OUT),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Reset zoom",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_RESET),
|
||||
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,
|
||||
},
|
||||
{
|
||||
label: "Send Alt+F4",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ALT_F4),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Alt+Enter",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ALT_ENTER),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Esc",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ESC),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
// Hot-swap removable media. v86 always instantiates the FDC and the
|
||||
// ATAPI secondary-master, so insert/eject work even if nothing was
|
||||
// mounted at boot. The pre-boot Settings card covers the !isRunning
|
||||
// case, so these are runtime-only.
|
||||
{
|
||||
label: "Floppy Drive",
|
||||
enabled: isRunning,
|
||||
submenu: [
|
||||
{
|
||||
label: "Insert Disk Image…",
|
||||
click: async () => {
|
||||
const p = await pickFile([
|
||||
{ name: "Floppy image", extensions: ["img", "ima", "vfd"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
]);
|
||||
if (p) send(IPC_COMMANDS.MACHINE_SET_FLOPPY, p);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Eject",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_SET_FLOPPY, null),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "CD-ROM Drive",
|
||||
enabled: isRunning,
|
||||
submenu: [
|
||||
{
|
||||
label: "Insert Disc Image…",
|
||||
click: async () => {
|
||||
const p = await pickFile([
|
||||
{ name: "ISO image", extensions: ["iso"] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
]);
|
||||
if (p) send(IPC_COMMANDS.MACHINE_SET_CDROM, p);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Eject",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_SET_CDROM, null),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Change Shared Folder…",
|
||||
enabled: isRunning,
|
||||
click: async () => {
|
||||
const p = await pickFolder();
|
||||
if (p) send(IPC_COMMANDS.MACHINE_SET_SMB_SHARE, p);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Go to Disk Image",
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
role: "help",
|
||||
submenu: [
|
||||
{
|
||||
label: "Author",
|
||||
click: () => shell.openExternal(LINKS.homepage),
|
||||
},
|
||||
{
|
||||
label: "windows95 on GitHub",
|
||||
click: () => shell.openExternal(LINKS.repo),
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
click: () => shell.openExternal(LINKS.help),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Troubleshooting",
|
||||
submenu: [
|
||||
{
|
||||
label: "Clear Cache and Restart",
|
||||
async click() {
|
||||
await clearCaches();
|
||||
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
template.unshift({
|
||||
label: "windows95",
|
||||
submenu: [
|
||||
{
|
||||
role: "about",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
role: "services",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Hide windows95",
|
||||
accelerator: "Command+H",
|
||||
role: "hide",
|
||||
},
|
||||
{
|
||||
label: "Hide Others",
|
||||
accelerator: "Command+Shift+H",
|
||||
role: "hideothers",
|
||||
},
|
||||
{
|
||||
role: "unhide",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
accelerator: "Command+Q",
|
||||
click() {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
}
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template as any));
|
||||
}
|
||||
18
src/main/session.ts
Normal file
18
src/main/session.ts
Normal 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
63
src/main/settings.ts
Normal 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();
|
||||
3
src/main/squirrel.ts
Normal file
3
src/main/squirrel.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function shouldQuit() {
|
||||
return require("electron-squirrel-startup");
|
||||
}
|
||||
11
src/main/update.ts
Normal file
11
src/main/update.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { app } from "electron";
|
||||
|
||||
export function setupUpdates() {
|
||||
if (app.isPackaged) {
|
||||
const { updateElectronApp } = require("update-electron-app");
|
||||
updateElectronApp({
|
||||
repo: "felixrieseberg/windows95",
|
||||
updateInterval: "1 hour",
|
||||
});
|
||||
}
|
||||
}
|
||||
68
src/main/windows.ts
Normal file
68
src/main/windows.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
sandbox: 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);
|
||||
}
|
||||
}
|
||||
182
src/menu.js
182
src/menu.js
@@ -1,182 +0,0 @@
|
||||
const { app, shell, Menu, BrowserWindow } = require('electron')
|
||||
|
||||
const { clearCaches } = require('./cache')
|
||||
const { IPC_COMMANDS } = require('./constants')
|
||||
|
||||
const LINKS = {
|
||||
homepage: 'https://www.twitter.com/felixrieseberg',
|
||||
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'
|
||||
}
|
||||
|
||||
function send (cmd) {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
|
||||
if (windows[0]) {
|
||||
windows[0].webContents.send(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
async function createMenu () {
|
||||
const template = [
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: (function () {
|
||||
if (process.platform === 'darwin') { return 'Ctrl+Command+F' } else { return 'F11' }
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) { focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Toggle Developer Tools',
|
||||
accelerator: (function () {
|
||||
if (process.platform === 'darwin') { return 'Alt+Command+I' } else { return 'Ctrl+Shift+I' }
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) { focusedWindow.toggleDevTools() }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Toggle Emulator Info',
|
||||
click: () => send(IPC_COMMANDS.TOGGLE_INFO)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
role: 'window',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Minimize',
|
||||
accelerator: 'CmdOrCtrl+M',
|
||||
role: 'minimize'
|
||||
},
|
||||
{
|
||||
label: 'Close',
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
role: 'close'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Machine',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Send Ctrl+Alt+Del',
|
||||
click: () => send(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL)
|
||||
},
|
||||
{
|
||||
label: 'Restart',
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESTART)
|
||||
},
|
||||
{
|
||||
label: 'Reset',
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESET)
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Go to Disk Image',
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Author',
|
||||
click: () => shell.openExternal(LINKS.homepage)
|
||||
},
|
||||
{
|
||||
label: 'windows95 on GitHub',
|
||||
click: () => shell.openExternal(LINKS.repo)
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
click: () => shell.openExternal(LINKS.help)
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Troubleshooting',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Clear Cache and Restart',
|
||||
async click () {
|
||||
await clearCaches()
|
||||
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
template.unshift({
|
||||
label: 'windows95',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About windows95',
|
||||
role: 'about'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Services',
|
||||
role: 'services',
|
||||
submenu: []
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Hide windows95',
|
||||
accelerator: 'Command+H',
|
||||
role: 'hide'
|
||||
},
|
||||
{
|
||||
label: 'Hide Others',
|
||||
accelerator: 'Command+Shift+H',
|
||||
role: 'hideothers'
|
||||
},
|
||||
{
|
||||
label: 'Show All',
|
||||
role: 'unhide'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click () {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMenu
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
const { remote, shell, ipcRenderer } = require('electron')
|
||||
const path = require('path')
|
||||
const EventEmitter = require('events')
|
||||
|
||||
const { resetState, restoreState, saveState } = require('./state')
|
||||
const { getDiskImageSize } = require('./utils/disk-image-size')
|
||||
const { IPC_COMMANDS, CONSTANTS } = require('./constants')
|
||||
|
||||
class Windows95 extends EventEmitter {
|
||||
constructor () {
|
||||
super()
|
||||
|
||||
// Constants
|
||||
this.CONSTANTS = CONSTANTS
|
||||
this.IPC_COMMANDS = IPC_COMMANDS
|
||||
|
||||
// Methods
|
||||
this.getDiskImageSize = getDiskImageSize
|
||||
this.restoreState = restoreState
|
||||
this.resetState = resetState
|
||||
this.saveState = saveState
|
||||
|
||||
Object.keys(IPC_COMMANDS).forEach((command) => {
|
||||
ipcRenderer.on(command, (...args) => {
|
||||
this.emit(command, args)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
showDiskImage () {
|
||||
const imagePath = path.join(__dirname, 'images/windows95.img')
|
||||
.replace('app.asar', 'app.asar.unpacked')
|
||||
|
||||
shell.showItemInFolder(imagePath)
|
||||
}
|
||||
|
||||
quit () {
|
||||
remote.app.quit()
|
||||
}
|
||||
}
|
||||
|
||||
window.windows95 = new Windows95()
|
||||
@@ -1,9 +0,0 @@
|
||||
export function setupState () {
|
||||
window.appState = {
|
||||
isResetting: false,
|
||||
isQuitting: false,
|
||||
cursorCaptured: false,
|
||||
floppyFile: null,
|
||||
bootFresh: false
|
||||
}
|
||||
}
|
||||
45
src/renderer/app.tsx
Normal file
45
src/renderer/app.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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.
|
||||
*
|
||||
* @class App
|
||||
*/
|
||||
export class App {
|
||||
/**
|
||||
* Initial setup call, loading Monaco and kicking off the React
|
||||
* render process.
|
||||
*/
|
||||
public async setup(): Promise<void> {
|
||||
const React = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { Emulator } = await import("./emulator");
|
||||
|
||||
const className = `${process.platform}`;
|
||||
const app = (
|
||||
<div className={className}>
|
||||
<Emulator />
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById("app")!);
|
||||
root.render(app);
|
||||
}
|
||||
}
|
||||
|
||||
window.win95 = window.win95 || {
|
||||
app: new App(),
|
||||
};
|
||||
|
||||
window.win95.app.setup();
|
||||
1
src/renderer/bios/.gitignore
vendored
1
src/renderer/bios/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*.gz
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,44 +0,0 @@
|
||||
const $ = document.querySelector.bind(document)
|
||||
|
||||
export function setupButtons (start) {
|
||||
// Start
|
||||
$('.btn-start').addEventListener('click', () => start())
|
||||
|
||||
// Disk Image
|
||||
$('#show-disk-image').addEventListener('click', () => windows95.showDiskImage())
|
||||
|
||||
// Reset
|
||||
$('#reset').addEventListener('click', () => windows95.resetState())
|
||||
|
||||
$('#discard-state').addEventListener('click', () => {
|
||||
window.appState.bootFresh = true
|
||||
|
||||
start()
|
||||
})
|
||||
|
||||
// Floppy
|
||||
$('#floppy').addEventListener('click', () => {
|
||||
$('#file-input').click()
|
||||
})
|
||||
|
||||
// Floppy (Hidden Input)
|
||||
$('#file-input').addEventListener('change', (event) => {
|
||||
window.appState.floppyFile = event.target.files && event.target.files.length > 0
|
||||
? event.target.files[0]
|
||||
: null
|
||||
|
||||
if (window.appState.floppyFile) {
|
||||
$('#floppy-path').innerHTML = `Inserted Floppy Disk: ${window.appState.floppyFile.path}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleButtons (forceTo) {
|
||||
const buttonElements = $('#buttons')
|
||||
|
||||
if (buttonElements.style.display !== 'none' || forceTo === false) {
|
||||
buttonElements.style.display = 'none'
|
||||
} else {
|
||||
buttonElements.style.display = undefined
|
||||
}
|
||||
}
|
||||
314
src/renderer/card-settings.tsx
Normal file
314
src/renderer/card-settings.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import * as React from "react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class CardSettings extends React.Component<
|
||||
CardSettingsProps,
|
||||
CardSettingsState
|
||||
> {
|
||||
constructor(props: CardSettingsProps) {
|
||||
super(props);
|
||||
|
||||
this.onChangeFloppy = this.onChangeFloppy.bind(this);
|
||||
this.onChangeCdrom = this.onChangeCdrom.bind(this);
|
||||
this.onResetState = this.onResetState.bind(this);
|
||||
|
||||
this.state = {
|
||||
tab: "floppy",
|
||||
isStateReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tab } = this.state;
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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>Drive A:</legend>
|
||||
<input
|
||||
id="floppy-input"
|
||||
type="file"
|
||||
onChange={this.onChangeFloppy}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/floppy.png" />
|
||||
<p>
|
||||
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
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
? event.target.files[0]
|
||||
: null;
|
||||
|
||||
if (floppyFile) {
|
||||
this.props.setFloppy(floppyFile);
|
||||
} else {
|
||||
console.log(`Floppy: Input changed but no file selected`);
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
167
src/renderer/card-start.tsx
Normal file
167
src/renderer/card-start.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 (
|
||||
<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 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
62
src/renderer/clipboard.ts
Normal 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 6–9) 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);
|
||||
};
|
||||
}
|
||||
579
src/renderer/debug-harness.ts
Normal file
579
src/renderer/debug-harness.ts
Normal 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);
|
||||
}
|
||||
264
src/renderer/emulator-info.tsx
Normal file
264
src/renderer/emulator-info.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
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 tickInterval = -1;
|
||||
private diskReadBytes = 0;
|
||||
private diskWriteBytes = 0;
|
||||
private netRxBytes = 0;
|
||||
private netTxBytes = 0;
|
||||
|
||||
constructor(props: EmulatorInfoProps) {
|
||||
super(props);
|
||||
|
||||
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,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
netRx: 0,
|
||||
netTx: 0,
|
||||
lastCounter: 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, diskRead, diskWrite, netRx, netTx, history } = this.state;
|
||||
const { hidden, toggleInfo, settings } = this.props;
|
||||
const { showCpu, showDisk, showNet, showSparklines: spark } = settings;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.uninstallListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* The emulator starts whenever, so install or uninstall listeners
|
||||
* at the right time
|
||||
*
|
||||
* @param newProps
|
||||
*/
|
||||
public componentDidUpdate(prevProps: EmulatorInfoProps) {
|
||||
if (prevProps.emulator !== this.props.emulator) {
|
||||
if (this.props.emulator) {
|
||||
this.installListeners();
|
||||
} else {
|
||||
this.uninstallListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let's start listening to what the emulator is up to.
|
||||
*/
|
||||
private installListeners() {
|
||||
const { emulator } = this.props;
|
||||
|
||||
if (!emulator) {
|
||||
console.log(
|
||||
`Emulator info: Tried to install listeners, but emulator not defined yet.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tickInterval > -1) {
|
||||
clearInterval(this.tickInterval);
|
||||
}
|
||||
|
||||
// TypeScript think's we're using a Node.js setInterval. We're not.
|
||||
this.tickInterval = setInterval(this.tick, 500) as unknown as number;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening to the emulator.
|
||||
*/
|
||||
private uninstallListeners() {
|
||||
const { emulator } = this.props;
|
||||
|
||||
if (!emulator) {
|
||||
console.log(
|
||||
`Emulator info: Tried to uninstall listeners, but emulator not defined yet.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tickInterval > -1) {
|
||||
clearInterval(this.tickInterval);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per interval, compute CPU speed and I/O throughput.
|
||||
*/
|
||||
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;
|
||||
|
||||
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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
723
src/renderer/emulator.tsx
Normal file
723
src/renderer/emulator.tsx
Normal file
@@ -0,0 +1,723 @@
|
||||
import * as React from "react";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
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 { CardSettings } from "./card-settings";
|
||||
import { EmulatorInfo } from "./emulator-info";
|
||||
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: "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;
|
||||
private smbShare?: ReturnType<typeof setupSmbShare>;
|
||||
// Mirrors state.hasAbsoluteMouse but updated synchronously — setState is
|
||||
// batched, and the lock/unlock decisions can't wait for a render.
|
||||
private absoluteMouse = false;
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
|
||||
this.startEmulator = this.startEmulator.bind(this);
|
||||
this.stopEmulator = this.stopEmulator.bind(this);
|
||||
this.restartEmulator = this.restartEmulator.bind(this);
|
||||
this.resetEmulator = this.resetEmulator.bind(this);
|
||||
this.bootFromScratch = this.bootFromScratch.bind(this);
|
||||
|
||||
this.state = {
|
||||
isBootingFresh: PROBE,
|
||||
isCursorCaptured: false,
|
||||
hasAbsoluteMouse: false,
|
||||
isRunning: false,
|
||||
legacyStatePath: null,
|
||||
legacyRecovered: null,
|
||||
legacyRecoverBusy: false,
|
||||
legacyRecoverError: null,
|
||||
currentUiCard: "start",
|
||||
isInfoDisplayed: true,
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to capture and release the mouse at appropriate times.
|
||||
*/
|
||||
public setupInputListeners() {
|
||||
// ESC
|
||||
document.onkeydown = (evt) => {
|
||||
const { isCursorCaptured } = this.state;
|
||||
|
||||
evt = evt || window.event;
|
||||
|
||||
if (evt.keyCode === 27) {
|
||||
if (isCursorCaptured) {
|
||||
this.unlockMouse();
|
||||
} else {
|
||||
this.lockMouse();
|
||||
}
|
||||
|
||||
evt.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Click
|
||||
document.addEventListener("click", () => {
|
||||
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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the emulator's state to disk during exit.
|
||||
*/
|
||||
public setupUnloadListeners() {
|
||||
const handleClose = async () => {
|
||||
await this.saveState();
|
||||
|
||||
console.log(`Unload: Now done, quitting again.`);
|
||||
this.isQuitting = true;
|
||||
|
||||
setImmediate(() => {
|
||||
ipcRenderer.invoke(IPC_COMMANDS.APP_QUIT);
|
||||
});
|
||||
};
|
||||
|
||||
window.onbeforeunload = (event: Event) => {
|
||||
if (this.isQuitting || this.isResetting) {
|
||||
console.log(`Unload: Not preventing`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Unload: Preventing to first save state`);
|
||||
|
||||
handleClose();
|
||||
event.preventDefault();
|
||||
event.returnValue = false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the various IPC messages sent to the renderer
|
||||
* from the main process
|
||||
*/
|
||||
public setupIpcListeners() {
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL, () => {
|
||||
this.sendKeys([
|
||||
0x1d, // ctrl
|
||||
0x38, // alt
|
||||
0x53, // delete
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_F4, () => {
|
||||
this.sendKeys([
|
||||
0x38, // alt
|
||||
0x3e, // f4
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_ENTER, () => {
|
||||
this.sendKeys([
|
||||
0x38, // alt
|
||||
0, // enter
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ESC, () => {
|
||||
this.sendKeys([
|
||||
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, () => {
|
||||
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.SHOW_DISK_IMAGE, () => {
|
||||
this.showDiskImage();
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_FLOPPY, (_e, p: string | null) => {
|
||||
const emu = this.state.emulator;
|
||||
if (!emu) return;
|
||||
if (p) {
|
||||
// Floppies are ≤2.88MB — load whole image into memory and hand v86 a
|
||||
// plain ArrayBuffer so its SyncBuffer path is used.
|
||||
const buf = fs.readFileSync(p);
|
||||
const ab = buf.buffer.slice(
|
||||
buf.byteOffset,
|
||||
buf.byteOffset + buf.byteLength,
|
||||
);
|
||||
emu.set_fda({ buffer: ab });
|
||||
console.log(`💾 floppy ← ${p}`);
|
||||
} else {
|
||||
emu.eject_fda();
|
||||
console.log(`💾 floppy ejected`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_CDROM, (_e, p: string | null) => {
|
||||
const emu = this.state.emulator;
|
||||
// The public emu.set_cdrom() routes through v86's async loaders, which
|
||||
// re-introduce the ATAPI BSY race documented in sync-file-buffer.ts.
|
||||
// Go straight to the device with our fs-backed synchronous buffer —
|
||||
// same object the boot path hands to the `cdrom:` option.
|
||||
const dev = emu?.v86?.cpu?.devices?.cdrom;
|
||||
if (!dev) return;
|
||||
if (p) {
|
||||
dev.set_cdrom(new SyncFileBuffer(p));
|
||||
console.log(`💿 cdrom ← ${p}`);
|
||||
} else {
|
||||
dev.eject();
|
||||
console.log(`💿 cdrom ejected`);
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_SET_SMB_SHARE, (_e, p: string) => {
|
||||
this.smbShare?.setHostPath(p);
|
||||
this.setState({ smbSharePath: p });
|
||||
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, p);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_IN, () => {
|
||||
this.setScale(this.state.scale * 1.2);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_OUT, () => {
|
||||
this.setScale(this.state.scale * 0.8);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_RESET, () => {
|
||||
this.setScale(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the emulator isn't running, this is rendering the, erm, UI.
|
||||
*
|
||||
* 🤡
|
||||
*/
|
||||
public renderUI() {
|
||||
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 })}
|
||||
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 {
|
||||
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 <section>{card}</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yaknow, render things and stuff.
|
||||
*/
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
{this.renderInfo()}
|
||||
{this.renderUI()}
|
||||
<div
|
||||
id="emulator"
|
||||
className={this.state.hasAbsoluteMouse ? "seamless-mouse" : undefined}
|
||||
>
|
||||
<div id="emulator-text-screen"></div>
|
||||
<canvas id="emulator-canvas"></canvas>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the little info thingy
|
||||
*/
|
||||
public renderInfo() {
|
||||
return (
|
||||
<EmulatorInfo
|
||||
emulator={this.state.emulator}
|
||||
settings={this.state.infoBarSettings}
|
||||
hidden={!this.state.isInfoDisplayed}
|
||||
toggleInfo={() => {
|
||||
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the emulator without restoring state
|
||||
*/
|
||||
public async bootFromScratch() {
|
||||
await this.stopEmulator();
|
||||
this.setState({ isBootingFresh: true });
|
||||
this.startEmulator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the disk image on disk
|
||||
*/
|
||||
public showDiskImage() {
|
||||
// Contents/Resources/app/dist/static
|
||||
console.log(`Showing disk image in ${CONSTANTS.IMAGE_PATH}`);
|
||||
|
||||
shell.showItemInFolder(CONSTANTS.IMAGE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the actual emulator
|
||||
*/
|
||||
private async startEmulator() {
|
||||
document.body.classList.remove("paused");
|
||||
|
||||
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,
|
||||
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: path.join(__dirname, "../../bios/seabios.bin"),
|
||||
},
|
||||
vga_bios: {
|
||||
url: path.join(__dirname, "../../bios/vgabios.bin"),
|
||||
},
|
||||
hda: {
|
||||
url: CONSTANTS.IMAGE_PATH,
|
||||
async: true,
|
||||
size: await getDiskImageSize(CONSTANTS.IMAGE_PATH),
|
||||
},
|
||||
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);
|
||||
|
||||
// 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.
|
||||
// The hook is installed unconditionally so the Machine ▸ Change Shared
|
||||
// Folder menu can point it at a directory later without a restart.
|
||||
const smbRoot =
|
||||
process.env.WIN95_SMB_SHARE || this.state.smbSharePath || null;
|
||||
this.smbShare = setupSmbShare(
|
||||
window["emulator"],
|
||||
smbRoot,
|
||||
CONSTANTS.TOOLS_PATH,
|
||||
);
|
||||
|
||||
if (PROBE) {
|
||||
startProbe(window["emulator"]);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
ipcRenderer.send(IPC_COMMANDS.MACHINE_STARTED);
|
||||
|
||||
// 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) {
|
||||
await this.restoreState();
|
||||
}
|
||||
|
||||
window["emulator"].run();
|
||||
window["emulator"].screen_set_scale(this.state.scale);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart emulator
|
||||
*/
|
||||
private restartEmulator() {
|
||||
if (this.state.emulator && this.state.isRunning) {
|
||||
console.log(`🚜 Restarting emulator`);
|
||||
this.state.emulator.restart();
|
||||
} else {
|
||||
console.log(`🚜 Restarting emulator failed: Emulator not running`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the emulator
|
||||
*/
|
||||
private async stopEmulator() {
|
||||
const { emulator, isRunning } = this.state;
|
||||
|
||||
if (!emulator || !isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🚜 Stopping emulator`);
|
||||
|
||||
await this.saveState();
|
||||
this.unlockMouse();
|
||||
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
|
||||
*/
|
||||
private async resetEmulator() {
|
||||
this.isResetting = true;
|
||||
|
||||
await this.stopEmulator();
|
||||
await resetState();
|
||||
|
||||
document.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the emulators state and write it to disk. This is possibly
|
||||
* a fairly big file.
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
const { emulator } = this.state;
|
||||
const statePath = await getStatePath();
|
||||
|
||||
if (!emulator || !emulator.save_state) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 async restoreState(): Promise<boolean> {
|
||||
const { emulator, isBootingFresh } = this.state;
|
||||
const state = await this.getState();
|
||||
|
||||
if (isBootingFresh) {
|
||||
console.log(`restoreState: Booting fresh, not restoring.`);
|
||||
return true;
|
||||
} else if (!state) {
|
||||
console.log(`restoreState: No state present, not restoring.`);
|
||||
return false;
|
||||
} else if (!emulator) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.state.emulator.restore_state(state);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`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.
|
||||
*/
|
||||
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() {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
|
||||
private lockMouse() {
|
||||
const { emulator } = this.state;
|
||||
|
||||
if (emulator) {
|
||||
emulator.lock_mouse();
|
||||
} else {
|
||||
console.warn(
|
||||
`Emulator: Tried to lock mouse, but no emulator or not running`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the emulator's scale
|
||||
*
|
||||
* @param target
|
||||
*/
|
||||
private setScale(target: number) {
|
||||
const { emulator, isRunning } = this.state;
|
||||
|
||||
if (emulator && isRunning) {
|
||||
emulator.screen_set_scale(target);
|
||||
this.setState({ scale: target });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keys to the emulator (including the key-up),
|
||||
* if it's running
|
||||
*
|
||||
* @param {Array<number>} codes
|
||||
*/
|
||||
private sendKeys(codes: Array<number>) {
|
||||
if (this.state.emulator && this.state.isRunning) {
|
||||
const scancodes = codes;
|
||||
|
||||
// Push break codes (key-up)
|
||||
for (const scancode of scancodes) {
|
||||
scancodes.push(scancode | 0x80);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/renderer/global.d.ts
vendored
Normal file
2
src/renderer/global.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const V86: any;
|
||||
declare const win95: any;
|
||||
@@ -1,55 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Windows</title>
|
||||
<script src="./lib/libv86.js"></script>
|
||||
<link rel="stylesheet" href="style/style.css">
|
||||
</head>
|
||||
|
||||
<body class="paused">
|
||||
<div id="status">
|
||||
Disk: <span id="disk-status">Idle</span>
|
||||
| CPU Speed: <span id="cpu-status">0</span>
|
||||
| <a href="#" id="toggle-status">Hide</a>
|
||||
</div>
|
||||
<div id="buttons">
|
||||
<div id="start-buttons">
|
||||
<!-- <div class="btn" id="win98">Windows 98</div> -->
|
||||
<div class="btn btn-start" id="win95">
|
||||
Start Windows 95
|
||||
<br />
|
||||
|
||||
<small>Hit ESC to lock or unlock your mouse</small>
|
||||
</div>
|
||||
<!-- <div class="btn" id="win1">Windows 1</div> -->
|
||||
</div>
|
||||
<div id="other-buttons">
|
||||
<div class="btn" id="reset">Reset Machine & Delete State</div>
|
||||
<div class="btn" id="floppy">Insert Floppy Disk</div>
|
||||
<div class="btn" id="discard-state">Discard State & Boot From Scratch</div>
|
||||
<div class="btn" id="show-disk-image">Show Disk Image</div>
|
||||
<input id="file-input" type='file'>
|
||||
</div>
|
||||
<div id="information">
|
||||
<p id="floppy-path"></p>
|
||||
<p>You can insert a floppy disk image with the ".img" format.</p>
|
||||
<p>
|
||||
Boot the machine from scratch if you've inserted a new floppy disk
|
||||
or if you've changed the disk image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="emulator" style="height: 100vh; width: 100vw">
|
||||
<div style="white-space: pre; font: 14px monospace; line-height: 14px"></div>
|
||||
<canvas style="display: none"></canvas>
|
||||
</div>
|
||||
<script type="module">
|
||||
import("es6://renderer.js")
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
29
src/renderer/info-bar-settings.ts
Normal file
29
src/renderer/info-bar-settings.ts
Normal 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 {}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
const $ = document.querySelector.bind(document)
|
||||
const status = $('#status')
|
||||
const diskStatus = $('#disk-status')
|
||||
const cpuStatus = $('#cpu-status')
|
||||
const toggleStatus = $('#toggle-status')
|
||||
|
||||
let lastCounter = 0
|
||||
let lastTick = 0
|
||||
let infoInterval = null
|
||||
|
||||
const onIDEReadStart = () => {
|
||||
diskStatus.innerHTML = 'Read'
|
||||
}
|
||||
|
||||
const onIDEReadWriteEnd = () => {
|
||||
diskStatus.innerHTML = 'Idle'
|
||||
}
|
||||
|
||||
toggleStatus.onclick = toggleInfo
|
||||
|
||||
/**
|
||||
* Toggle the information display
|
||||
*/
|
||||
export function toggleInfo () {
|
||||
if (status.style.display !== 'none') {
|
||||
disableInfo()
|
||||
} else {
|
||||
enableInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start information gathering, but only if the panel is visible
|
||||
*/
|
||||
export function startInfoMaybe () {
|
||||
if (status.style.display !== 'none') {
|
||||
enableInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the gathering of information (and hide the little information tab)
|
||||
*/
|
||||
export function enableInfo () {
|
||||
// Show the info thingy
|
||||
status.style.display = 'block'
|
||||
|
||||
// We can only do the rest with an emulator
|
||||
if (!window.emulator.add_listener) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set listeners
|
||||
window.emulator.add_listener('ide-read-start', onIDEReadStart)
|
||||
window.emulator.add_listener('ide-read-end', onIDEReadWriteEnd)
|
||||
window.emulator.add_listener('ide-write-end', onIDEReadWriteEnd)
|
||||
window.emulator.add_listener('screen-set-size-graphical', console.log)
|
||||
|
||||
// Set an interval
|
||||
infoInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const instructionCounter = window.emulator.get_instruction_counter()
|
||||
const ips = instructionCounter - lastCounter
|
||||
const deltaTime = now - lastTick
|
||||
|
||||
lastTick = now
|
||||
lastCounter = instructionCounter
|
||||
|
||||
cpuStatus.innerHTML = Math.round(ips / deltaTime)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the gathering of information (and hide the little information tab)
|
||||
*/
|
||||
export function disableInfo () {
|
||||
// Hide the info thingy
|
||||
status.style.display = 'none'
|
||||
|
||||
// Clear the interval
|
||||
clearInterval(infoInterval)
|
||||
infoInterval = null
|
||||
|
||||
// We can only do the rest with an emulator
|
||||
if (!window.emulator.remove_listener) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unset the listeners
|
||||
window.emulator.remove_listener('ide-read-start', onIDEReadStart)
|
||||
window.emulator.remove_listener('ide-read-end', onIDEReadWriteEnd)
|
||||
window.emulator.remove_listener('ide-write-end', onIDEReadWriteEnd)
|
||||
window.emulator.remove_listener('screen-set-size-graphical', console.log)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { toggleInfo } from 'es6://info.js'
|
||||
|
||||
export function setupIpcListeners (start) {
|
||||
const { windows95 } = window
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.TOGGLE_INFO, () => {
|
||||
toggleInfo()
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.MACHINE_RESTART, () => {
|
||||
console.log(`Restarting machine`)
|
||||
|
||||
if (!window.emulator || !window.emulator.is_running) return
|
||||
|
||||
window.emulator.restart()
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.MACHINE_RESET, () => {
|
||||
console.log(`Resetting machine`)
|
||||
|
||||
window.appState.isResetting = true
|
||||
document.location.hash = `#AUTO_START`
|
||||
document.location.reload()
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.MACHINE_CTRL_ALT_DEL, () => {
|
||||
if (!window.emulator || !window.emulator.is_running) return
|
||||
|
||||
window.emulator.keyboard_send_scancodes([
|
||||
0x1D, // ctrl
|
||||
0x38, // alt
|
||||
0x53, // delete
|
||||
|
||||
// break codes
|
||||
0x1D | 0x80,
|
||||
0x38 | 0x80,
|
||||
0x53 | 0x80
|
||||
])
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.SHOW_DISK_IMAGE, () => {
|
||||
windows95.showDiskImage()
|
||||
})
|
||||
}
|
||||
@@ -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
BIN
src/renderer/lib/build/v86.wasm
Executable file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,47 +0,0 @@
|
||||
export function setupCloseListener () {
|
||||
window.appState.isQuitting = false
|
||||
|
||||
const handleClose = async () => {
|
||||
await windows95.saveState()
|
||||
window.appState.isQuitting = true
|
||||
windows95.quit()
|
||||
}
|
||||
|
||||
window.onbeforeunload = (event) => {
|
||||
if (window.appState.isQuitting) return
|
||||
if (window.appState.isResetting) return
|
||||
|
||||
handleClose()
|
||||
event.preventDefault()
|
||||
event.returnValue = false
|
||||
}
|
||||
}
|
||||
|
||||
export function setupEscListener () {
|
||||
document.onkeydown = function (evt) {
|
||||
evt = evt || window.event
|
||||
if (evt.keyCode === 27) {
|
||||
if (window.appState.cursorCaptured) {
|
||||
window.appState.cursorCaptured = false
|
||||
window.emulator.mouse_set_status(false)
|
||||
document.exitPointerLock()
|
||||
} else {
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick () {
|
||||
if (!window.appState.cursorCaptured) {
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.mouse_set_status(true)
|
||||
window.emulator.lock_mouse()
|
||||
}
|
||||
}
|
||||
|
||||
export function setupClickListener () {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
}
|
||||
81
src/renderer/net/dns-shim.ts
Normal file
81
src/renderer/net/dns-shim.ts
Normal 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;
|
||||
}
|
||||
153
src/renderer/net/tcp-relay.ts
Normal file
153
src/renderer/net/tcp-relay.ts
Normal 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");
|
||||
}
|
||||
118
src/renderer/net/tcp-trace.ts
Normal file
118
src/renderer/net/tcp-trace.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* We're using modern esm imports here */
|
||||
import { setupState } from 'es6://app-state.js'
|
||||
import { setupClickListener, setupEscListener, setupCloseListener } from 'es6://listeners.js'
|
||||
import { toggleButtons, setupButtons } from 'es6://buttons.js'
|
||||
import { startInfoMaybe } from 'es6://info.js'
|
||||
import { setupIpcListeners } from 'es6://ipc.js'
|
||||
|
||||
setupState()
|
||||
|
||||
/**
|
||||
* The main method executing the VM.
|
||||
*/
|
||||
async function main () {
|
||||
const imageSize = await window.windows95.getDiskImageSize()
|
||||
const options = {
|
||||
memory_size: 128 * 1024 * 1024,
|
||||
video_memory_size: 32 * 1024 * 1024,
|
||||
screen_container: document.getElementById('emulator'),
|
||||
bios: {
|
||||
url: './bios/seabios.bin'
|
||||
},
|
||||
vga_bios: {
|
||||
url: './bios/vgabios.bin'
|
||||
},
|
||||
hda: {
|
||||
url: '../images/windows95.img',
|
||||
async: true,
|
||||
size: imageSize
|
||||
},
|
||||
fda: {
|
||||
buffer: window.appState.floppyFile || undefined
|
||||
},
|
||||
boot_order: 0x132
|
||||
}
|
||||
|
||||
console.log(`Starting emulator with options`, options)
|
||||
|
||||
// New v86 instance
|
||||
window.emulator = new V86Starter(options)
|
||||
|
||||
// Restore state. We can't do this right away
|
||||
// and randomly chose 500ms as the appropriate
|
||||
// wait time (lol)
|
||||
setTimeout(async () => {
|
||||
if (!window.appState.bootFresh) {
|
||||
windows95.restoreState()
|
||||
}
|
||||
|
||||
startInfoMaybe()
|
||||
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
window.emulator.run()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function start () {
|
||||
document.body.className = ''
|
||||
|
||||
toggleButtons(false)
|
||||
setupClickListener()
|
||||
main()
|
||||
}
|
||||
|
||||
setupIpcListeners(start)
|
||||
setupEscListener()
|
||||
setupCloseListener()
|
||||
setupButtons(start)
|
||||
|
||||
if (document.location.hash.includes('AUTO_START')) {
|
||||
start()
|
||||
}
|
||||
103
src/renderer/smb/README.md
Normal file
103
src/renderer/smb/README.md
Normal 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`
|
||||
247
src/renderer/smb/index.ts
Normal file
247
src/renderer/smb/index.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// 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 { NetBIOSFramer, nbPositiveResponse, nbWrap } from "./netbios";
|
||||
import { setupNbns } from "./nbns";
|
||||
import { SmbSession, shareNameFor, TOOLS_SHARE } from "./server";
|
||||
|
||||
// Diagnostics tee — opt-in via WIN95_SMB_LOG. The console.log override and
|
||||
// per-frame counter below sit on the hot path; don't pay for them unless
|
||||
// someone is actually watching.
|
||||
const LOG_FILE = process.env.WIN95_SMB_LOG;
|
||||
if (LOG_FILE) {
|
||||
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
origLog(...args);
|
||||
const tag = String(args[0] ?? "");
|
||||
if (tag === "[smb]" || tag === "[nbns]") {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, args.map(a =>
|
||||
typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n");
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
interface TCPConnection {
|
||||
sport: number;
|
||||
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 | null, toolsRoot?: string) {
|
||||
// hostPath is read on every new TCP 139 connection, so the menu can re-aim
|
||||
// the share at a different folder without restarting. Existing SmbSessions
|
||||
// keep their old root until Win95 reconnects (close the Explorer window or
|
||||
// `net use z: /delete` then re-map).
|
||||
const announce = () => hostPath
|
||||
? log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
|
||||
`(+ \\\\HOST\\${TOOLS_SHARE}${toolsRoot ? ` ← ${toolsRoot}` : ""}) port 139`)
|
||||
: log(`port 139 hooked, no host folder shared yet`);
|
||||
announce();
|
||||
|
||||
if (LOG_FILE) {
|
||||
// Count every ethernet frame so we know if the NIC is emitting anything
|
||||
// at all. Logged on a timer so the absence of a tick proves the bus is
|
||||
// dead. Opt-in: this hook fires once per TX frame during a file copy.
|
||||
let frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
|
||||
emulator.bus.register("net0-send", (raw: unknown) => {
|
||||
const f = raw as Uint8Array;
|
||||
frameStats.total++;
|
||||
if (f.length < 14) { frameStats.other++; return; }
|
||||
const et = (f[12] << 8) | f[13];
|
||||
if (et === 0x0806) frameStats.arp++;
|
||||
else if (et === 0x0800) {
|
||||
frameStats.ip++;
|
||||
const proto = f[14 + 9];
|
||||
if (proto === 6) frameStats.tcp++;
|
||||
else if (proto === 17) frameStats.udp++;
|
||||
} else frameStats.other++;
|
||||
});
|
||||
setInterval(() => {
|
||||
if (frameStats.total > 0) {
|
||||
log("frames:", JSON.stringify(frameStats));
|
||||
frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Win95 won't even try TCP 139 until UDP 137 answers a Node Status query
|
||||
setupNbns(emulator as Parameters<typeof setupNbns>[0]);
|
||||
|
||||
// ─── 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}`);
|
||||
if (!hostPath) {
|
||||
// No folder picked yet — caller declines the SYN so the guest sees a
|
||||
// clean RST instead of a half-open NetBIOS session.
|
||||
log("no share configured → RST");
|
||||
return false;
|
||||
}
|
||||
const framer = new NetBIOSFramer();
|
||||
const session = new SmbSession(hostPath, toolsRoot);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// v86's TCP is stop-and-wait (one MSS, wait for ACK). The link is lossless
|
||||
// and has no retransmit anyway, so keep a small window in flight by sliding
|
||||
// the ring-buffer view under the original pump(). The burst lands in the
|
||||
// NE2000 RX ring before the guest CPU runs, so the cap is the ring (52–58
|
||||
// pages for Win95's driver) — 4×MSS + our ACK ≈ 36 pages, 8 overflowed it.
|
||||
const c = conn as any, sb = c.send_buffer;
|
||||
const mss: number = c.send_chunk_buf?.length ?? 1460;
|
||||
const pump1 = Object.getPrototypeOf(c)?.pump;
|
||||
if (pump1 && sb?.buffer) {
|
||||
let hi: number | undefined;
|
||||
c.pump = function () {
|
||||
if (this.pending || !sb.length) return pump1.call(this);
|
||||
const cap = sb.buffer.length, t0 = sb.tail, l0 = sb.length, s0 = this.seq;
|
||||
const win = 4 * mss;
|
||||
let off = hi === undefined ? 0 : Math.max(0, Math.min(hi - s0, l0));
|
||||
for (; off < l0 && off < win; off += Math.min(mss, l0 - off)) {
|
||||
sb.tail = (t0 + off) % cap; sb.length = l0 - off;
|
||||
this.seq = s0 + off; this.pending = false;
|
||||
pump1.call(this);
|
||||
}
|
||||
hi = s0 + off;
|
||||
sb.tail = t0; sb.length = l0; this.seq = s0; this.pending = true;
|
||||
};
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// New API: bus event (no-op on old v86 — event never fires)
|
||||
emulator.bus.register("tcp-connection", (c: unknown) => {
|
||||
const conn = c as TCPConnection;
|
||||
if (conn.sport !== 139) return;
|
||||
if (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";
|
||||
if (!wireConn(conn)) return false;
|
||||
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);
|
||||
}
|
||||
|
||||
return {
|
||||
setHostPath(p: string) {
|
||||
hostPath = p;
|
||||
announce();
|
||||
},
|
||||
};
|
||||
}
|
||||
258
src/renderer/smb/nbns.ts
Normal file
258
src/renderer/smb/nbns.ts
Normal 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
|
||||
}
|
||||
65
src/renderer/smb/netbios.ts
Normal file
65
src/renderer/smb/netbios.ts
Normal 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;
|
||||
}
|
||||
1465
src/renderer/smb/server.ts
Normal file
1465
src/renderer/smb/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
164
src/renderer/smb/smb.ts
Normal file
164
src/renderer/smb/smb.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// 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 } 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 {
|
||||
if (words.length % 2 !== 0) throw new Error("word block must be even");
|
||||
// Hot path for READ replies (bytes can be ~16K) — assemble directly instead
|
||||
// of pushing byte-by-byte through Writer.
|
||||
const out = new Uint8Array(32 + 1 + words.length + 2 + bytes.length);
|
||||
const v = new DataView(out.buffer);
|
||||
out[0] = 0xff; out[1] = 0x53; out[2] = 0x4d; out[3] = 0x42;
|
||||
out[4] = cmd;
|
||||
v.setUint32(5, status, true);
|
||||
out[9] = FLAGS_REPLY | FLAGS_CASELESS | FLAGS_CANONICAL;
|
||||
// mirror long-name capability so the client keeps sending long names; never
|
||||
// claim NT status or unicode (we reply in ASCII)
|
||||
v.setUint16(10, (overrides?.flags2 ?? req.flags2) & FLAGS2_LONG_NAMES, true);
|
||||
// 12 bytes reserved already zero
|
||||
v.setUint16(24, overrides?.tid ?? req.tid, true);
|
||||
v.setUint16(26, req.pid, true);
|
||||
v.setUint16(28, overrides?.uid ?? req.uid, true);
|
||||
v.setUint16(30, req.mid, true);
|
||||
out[32] = words.length / 2;
|
||||
out.set(words, 33);
|
||||
v.setUint16(33 + words.length, bytes.length, true);
|
||||
out.set(bytes, 35 + words.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dosError(errClass: number, errCode: number): number {
|
||||
// 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",
|
||||
};
|
||||
472
src/renderer/smb/test-standalone.ts
Normal file
472
src/renderer/smb/test-standalone.ts
Normal 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
57
src/renderer/smb/wire.ts
Normal 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); }
|
||||
}
|
||||
0
src/renderer/status.tsx
Normal file
0
src/renderer/status.tsx
Normal file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user