mirror of
https://github.com/felixrieseberg/windows95.git
synced 2026-05-14 18:31:59 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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`.
|
||||
79
.github/workflows/build.yml
vendored
79
.github/workflows/build.yml
vendored
@@ -12,30 +12,22 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
node-version: 20.x
|
||||
cache: npm
|
||||
- name: Install
|
||||
run: yarn --frozen-lockfile
|
||||
run: npm ci
|
||||
- name: lint
|
||||
run: yarn 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
|
||||
@@ -55,59 +47,56 @@ jobs:
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
if: matrix.os != 'macOS-latest'
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
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 Windows signing certificate
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
id: write_file
|
||||
uses: timheuer/base64-to-file@v1
|
||||
with:
|
||||
fileName: 'win-certificate.pfx'
|
||||
encodedString: ${{ secrets.WINDOWS_CODESIGN_P12 }}
|
||||
- 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_URL: ${{ secrets.DISK_URL }}
|
||||
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_URL: ${{ secrets.DISK_URL }}
|
||||
DISK_REPO: ${{ vars.DISK_REPO }}
|
||||
DISK_TAG: ${{ vars.DISK_TAG }}
|
||||
GH_TOKEN: ${{ secrets.IMAGES_REPO_TOKEN }}
|
||||
- name: Install
|
||||
run: yarn
|
||||
run: npm ci
|
||||
- name: Make
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: yarn make --arch=${{ matrix.arch }}
|
||||
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 }}
|
||||
WINDOWS_CODESIGN_FILE: ${{ steps.write_file.outputs.filePath }}
|
||||
WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }}
|
||||
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@v1
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -118,4 +107,4 @@ jobs:
|
||||
out/**/*.dmg
|
||||
out/**/*setup*.exe
|
||||
out/**/*.rpm
|
||||
out/**/*.zip
|
||||
out/**/*.zip
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,3 +14,8 @@ 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/
|
||||
|
||||
60
README.md
60
README.md
@@ -15,29 +15,22 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
|
||||
</td>
|
||||
<td>
|
||||
<span>32-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-ia32.exe">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-setup-ia32.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-ia32-3.1.1.zip">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-win32-ia32-5.0.0.zip">
|
||||
📦 Standalone Zip
|
||||
</a>
|
||||
<br />
|
||||
<span>64-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-x64.exe">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-setup-x64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-x64-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>ARM64</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-arm64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-arm64-3.1.1.zip">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-win32-x64-5.0.0.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>
|
||||
❓ Don't know what kind of chip you have? Hit start, enter "processor" for info.
|
||||
❓ 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>
|
||||
@@ -47,16 +40,16 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
|
||||
macOS
|
||||
</td>
|
||||
<td>
|
||||
<span>Apple Silicon Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-darwin-arm64-5.0.0.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>Intel Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-darwin-x64-3.1.1.zip">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-darwin-x64-5.0.0.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>Apple M1 Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-darwin-arm64-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
</a>
|
||||
<span>
|
||||
❓ Don't know what kind of chip you have? Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
|
||||
❓ 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>
|
||||
@@ -67,33 +60,42 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
|
||||
</td>
|
||||
<td>
|
||||
<span>64-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.x86_64.rpm">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-1.x86_64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_amd64.deb">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95_5.0.0_amd64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARM64</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.arm64.rpm">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-1.arm64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_arm64.deb">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95_5.0.0_arm64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARMv7 (armhf)</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.armv7hl.rpm">
|
||||
<span>ARM</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95-5.0.0-1.armv7hl.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_armhf.deb">
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v5.0.0/windows95_5.0.0_armhf.deb">
|
||||
💿 deb
|
||||
</a>
|
||||
</a><br />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||

|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="https://github.com/user-attachments/assets/43ab7126-765e-444b-ad14-27b1beadbc7c" width="100%" alt="Screenshot showing Windows 95">
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="https://github.com/user-attachments/assets/7ac5dc36-cbd4-4455-a616-0e5cca314b34" width="100%" alt="Screenshot showing Windows 95">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Does it work?
|
||||
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
|
||||
@@ -102,7 +104,7 @@ Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this
|
||||
Absolutely.
|
||||
|
||||
## Does it run Doom (or my other favorite game)?
|
||||
You'll likely be better off with an actual virtualization app, but the short answer is yes. [Thanks to
|
||||
You'll likely be better off with an actual virtualization app, but the short answer is yes. In fact, a few games are already preinstalled - and more can be found on the Internet, for instance at [archive.org](https://www.archive.org). [Thanks to
|
||||
@DisplacedGamers](https://youtu.be/xDXqmdFxofM) I can recommend that you switch to a resolution of
|
||||
640x480 @ 256 colors before starting DOS games - just like in the good ol' days.
|
||||
|
||||
|
||||
BIN
bios/seabios.bin
BIN
bios/seabios.bin
Binary file not shown.
BIN
bios/vgabios.bin
BIN
bios/vgabios.bin
Binary file not shown.
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`.
|
||||
@@ -4,8 +4,6 @@ const package = require('./package.json');
|
||||
|
||||
require('dotenv').config()
|
||||
|
||||
process.env.TEMP = process.env.TMP = `C:\\Users\\FelixRieseberg\\AppData\\Local\\Temp`
|
||||
|
||||
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'),
|
||||
@@ -17,17 +15,22 @@ const FLAGS = {
|
||||
APPLE_ID_PASSWORD: process.env.APPLE_ID_PASSWORD,
|
||||
}
|
||||
|
||||
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));
|
||||
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));
|
||||
|
||||
const 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"],
|
||||
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 = {
|
||||
|
||||
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
|
||||
15507
package-lock.json
generated
15507
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "windows95",
|
||||
"productName": "windows95",
|
||||
"version": "4.0.0",
|
||||
"version": "5.0.1",
|
||||
"description": "Windows 95, in an app. I'm sorry.",
|
||||
"main": "./dist/src/main/main.js",
|
||||
"scripts": {
|
||||
"start": "rimraf ./dist && electron-forge start",
|
||||
"start": "electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "prettier --write src/**/*.{ts,tsx} && npm run check-links",
|
||||
"less": "node ./tools/lessc.js",
|
||||
"tsc": "tsc -p tsconfig.json --noEmit",
|
||||
"check-links": "node tools/check-links.js",
|
||||
"postinstall": "patch-package"
|
||||
@@ -23,28 +22,26 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"update-electron-app": "^2.0.1"
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"update-electron-app": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "7.6.1",
|
||||
"@electron-forge/maker-deb": "7.6.1",
|
||||
"@electron-forge/maker-flatpak": "^7.6.1",
|
||||
"@electron-forge/maker-rpm": "^7.6.1",
|
||||
"@electron-forge/maker-squirrel": "^7.6.1",
|
||||
"@electron-forge/maker-zip": "^7.6.1",
|
||||
"@electron-forge/publisher-github": "^7.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"electron": "34.2.0",
|
||||
"less": "^3.13.0",
|
||||
"parcel-bundler": "^1.12.5",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.5.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.3"
|
||||
"@electron-forge/cli": "7.11.1",
|
||||
"@electron-forge/maker-deb": "7.11.1",
|
||||
"@electron-forge/maker-flatpak": "7.11.1",
|
||||
"@electron-forge/maker-rpm": "7.11.1",
|
||||
"@electron-forge/maker-squirrel": "7.11.1",
|
||||
"@electron-forge/maker-zip": "7.11.1",
|
||||
"@electron-forge/publisher-github": "7.11.1",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"electron": "41.2.0",
|
||||
"patch-package": "^8.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
diff --git a/node_modules/@electron/packager/dist/win32.js b/node_modules/@electron/packager/dist/win32.js
|
||||
index 5399b3e..f3b6e88 100644
|
||||
index d318f6c..bfde740 100644
|
||||
--- a/node_modules/@electron/packager/dist/win32.js
|
||||
+++ b/node_modules/@electron/packager/dist/win32.js
|
||||
@@ -57,7 +57,26 @@ class WindowsApp extends platform_1.App {
|
||||
@@ -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)}`);
|
||||
@@ -13,8 +13,8 @@ index 5399b3e..f3b6e88 100644
|
||||
+ // await (0, resedit_1.resedit)(this.electronBinaryPath, resOpts);
|
||||
+
|
||||
+ const { spawnSync } = require('child_process');
|
||||
+ const resEditProcess = spawnSync('node', [
|
||||
+ 'C:\\Users\\FelixRieseberg\\Code\\windows95\\tools\\resedit.js',
|
||||
+ const resEditProcess = spawnSync(process.execPath, [
|
||||
+ require('path').resolve(process.cwd(), 'tools', 'resedit.js'),
|
||||
+ this.electronBinaryPath
|
||||
+ ], {
|
||||
+ stdio: 'inherit'
|
||||
@@ -18,7 +18,6 @@ export async function clearStorageData() {
|
||||
|
||||
await session.defaultSession.clearStorageData({
|
||||
storages: [
|
||||
"appcache",
|
||||
"cookies",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
@@ -27,6 +26,6 @@ export async function clearStorageData() {
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
],
|
||||
quotas: ["temporary", "persistent", "syncable"],
|
||||
quotas: ["temporary"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,11 +2,22 @@ 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 = {
|
||||
@@ -17,6 +28,7 @@ export const IPC_COMMANDS = {
|
||||
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",
|
||||
@@ -30,4 +42,9 @@ export const IPC_COMMANDS = {
|
||||
// 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",
|
||||
};
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
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 {
|
||||
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
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.
@@ -1,117 +0,0 @@
|
||||
@import "./status.less";
|
||||
@import "./emulator.less";
|
||||
@import "./info.less";
|
||||
@import "./settings.less";
|
||||
@import "./start.less";
|
||||
|
||||
/* GENERAL RESETS */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.paused > #emulator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.paused {
|
||||
background: #008080;
|
||||
font-family: Courier;
|
||||
}
|
||||
|
||||
#buttons {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 75%;
|
||||
max-width: 700px;
|
||||
min-width: 400px;
|
||||
|
||||
.card-title {
|
||||
img {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link > img,
|
||||
.btn > img {
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.windows95 {
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
nav .nav-link,
|
||||
nav .nav-logo {
|
||||
height: 37px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
nav .nav-logo img {
|
||||
margin-left: 2px;
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
nav .nav-logo > span {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 37px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
border-color: #fff #000 #000 #fff;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.btn.active:before,
|
||||
.btn:focus:before,
|
||||
button.active:before,
|
||||
button:focus:before,
|
||||
input[type=submit].active:before,
|
||||
input[type=submit]:focus:before {
|
||||
border-color: #dedede grey grey #dedede;
|
||||
}
|
||||
|
||||
.card {
|
||||
// Fix link colors
|
||||
.link, .link:active, .link:link, .link:visited, a, a:active, a:link, a:visited {
|
||||
color: #008080;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Ensure a-elements in fieldsets receive click events
|
||||
fieldset:before {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#floppy-path {
|
||||
font-size: .6rem;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding-left: 8px;
|
||||
border-color: #000 #fff #fff #000;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
background-color: #c3c3c3;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
#file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings {
|
||||
legend > img {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#section-start {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> small {
|
||||
margin-top: 25px;
|
||||
font-size: .8rem;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
#status {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
left: calc(50vw - 110px);
|
||||
background: white;
|
||||
font-size: 10px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
overflow: hidden;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
max-height: 18px;
|
||||
top: 0;
|
||||
}
|
||||
BIN
src/less/vendor/95.ttf
vendored
BIN
src/less/vendor/95.ttf
vendored
Binary file not shown.
1
src/less/vendor/95css.css
vendored
1
src/less/vendor/95css.css
vendored
File diff suppressed because one or more lines are too long
BIN
src/less/vendor/bg-pattern.png
vendored
BIN
src/less/vendor/bg-pattern.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 144 B |
BIN
src/less/vendor/dropdown.png
vendored
BIN
src/less/vendor/dropdown.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
src/less/vendor/windows.woff
vendored
BIN
src/less/vendor/windows.woff
vendored
Binary file not shown.
BIN
src/less/vendor/windows.woff2
vendored
BIN
src/less/vendor/windows.woff2
vendored
Binary file not shown.
@@ -1,15 +0,0 @@
|
||||
export function encode(text: string) {
|
||||
// Convert to windows-1252 compatible string by removing unsupported chars
|
||||
let result = text.replaceAll(/[^\x00-\xFF]/g, '');
|
||||
|
||||
// If result would be empty, return original
|
||||
if (!result.trim()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getEncoding() {
|
||||
return `<meta http-equiv="Content-Type" content="text/html; charset=windows-1252">`;
|
||||
}
|
||||
@@ -1,28 +1,16 @@
|
||||
import { protocol, net } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { generateDirectoryListing } from './page-directory-listing';
|
||||
import { generateErrorPage } from './page-error';
|
||||
import { log } from '../logging';
|
||||
import { protocol } from "electron";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { log } from "../logging";
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
fullPath: string;
|
||||
stats: fs.Stats;
|
||||
}
|
||||
|
||||
export const APP_INTERCEPT = 'http://windows95/';
|
||||
export const MY_COMPUTER_INTERCEPT = 'http://my-computer/';
|
||||
|
||||
const interceptedUrls = [
|
||||
MY_COMPUTER_INTERCEPT,
|
||||
APP_INTERCEPT
|
||||
];
|
||||
// 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() {
|
||||
// Register protocol handler for our custom schema
|
||||
protocol.handle('http', async (request) => {
|
||||
if (!interceptedUrls.some(url => request.url.startsWith(url))) {
|
||||
protocol.handle("http", async (request) => {
|
||||
if (!request.url.startsWith(APP_INTERCEPT)) {
|
||||
return fetch(request.url, {
|
||||
headers: request.headers,
|
||||
method: request.method,
|
||||
@@ -31,127 +19,46 @@ export function setupFileServer() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { fullPath, decodedPath } = getFilePath(request.url);
|
||||
|
||||
log(`FileServer: Handling request for ${request.url}`, { fullPath, decodedPath });
|
||||
|
||||
// Check if path exists
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return new Response(generateErrorPage(
|
||||
'File or Directory Not Found',
|
||||
decodedPath
|
||||
), {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
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}`);
|
||||
|
||||
// Check if it's a directory
|
||||
const stats = await fs.promises.stat(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
// If we're in an app-intercept, check if there's an index.htm file in the directory
|
||||
if (request.url.startsWith(APP_INTERCEPT)) {
|
||||
const indexHtmlPath = path.join(fullPath, 'index.htm');
|
||||
if (fs.existsSync(indexHtmlPath)) {
|
||||
return serveFile(indexHtmlPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate directory listing
|
||||
const files = await fs.promises.readdir(fullPath);
|
||||
const listing = generateDirectoryListing(fullPath, files);
|
||||
return new Response(listing, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
return await serveFile(fullPath);
|
||||
} catch (error) {
|
||||
// Handle specific file read errors
|
||||
if (error.code === 'EACCES') {
|
||||
return new Response(generateErrorPage(
|
||||
'Access Denied',
|
||||
'You do not have permission to access this file'
|
||||
), {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-throw other errors to be caught by outer try-catch
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (stats.isDirectory()) fullPath = path.join(fullPath, "index.htm");
|
||||
return await serveFile(fullPath);
|
||||
} catch (error) {
|
||||
const errorPage = generateErrorPage(
|
||||
'Internal Server Error',
|
||||
`An error occurred while processing your request: ${error.message}`
|
||||
);
|
||||
return new Response(errorPage, {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getFilePath(url: string) {
|
||||
let urlPath: string;
|
||||
let fullPath: string;
|
||||
let decodedPath: string;
|
||||
|
||||
if (url.startsWith(APP_INTERCEPT)) {
|
||||
fullPath = path.resolve(__dirname, '../../../static/www', url.replace(APP_INTERCEPT, ''));
|
||||
decodedPath = '.';
|
||||
} else if (url.startsWith(MY_COMPUTER_INTERCEPT)) {
|
||||
urlPath = url.replace(MY_COMPUTER_INTERCEPT, '');
|
||||
decodedPath = decodeURIComponent(urlPath);
|
||||
fullPath = path.join('/', decodedPath);
|
||||
} else {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
return { fullPath, decodedPath };
|
||||
}
|
||||
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);
|
||||
|
||||
// Determine content type based on file extension
|
||||
const ext = path.extname(fullPath).toLowerCase();
|
||||
let contentType = 'application/octet-stream';
|
||||
|
||||
// Common content types
|
||||
const contentTypes: 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'
|
||||
};
|
||||
|
||||
if (ext in contentTypes) {
|
||||
contentType = contentTypes[ext];
|
||||
}
|
||||
|
||||
return new Response(fileData, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType
|
||||
}
|
||||
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Stats } from "fs";
|
||||
import { settings } from "../settings";
|
||||
import { FileEntry } from "./fileserver";
|
||||
|
||||
const FILES_TO_HIDE_ON_DARWIN: string[] = [
|
||||
'.DS_Store',
|
||||
'.localized',
|
||||
'.Trashes',
|
||||
'.fseventsd',
|
||||
'.Spotlight-V100',
|
||||
'.file',
|
||||
'.hotfiles.btree',
|
||||
'.DocumentRevisions-V100',
|
||||
'.TemporaryItems',
|
||||
'.file (resource fork files)',
|
||||
'.VolumeIcon.icns',
|
||||
];
|
||||
|
||||
const FILES_TO_HIDE_ON_WINDOWS: string[] = [
|
||||
'desktop.ini',
|
||||
'Thumbs.db',
|
||||
'ehthumbs.db',
|
||||
'ehthumbs.db-shm',
|
||||
'ehthumbs.db-wal',
|
||||
];
|
||||
|
||||
const FILES_TO_HIDE_ON_LINUX: string[] = [];
|
||||
|
||||
export function shouldHideFile(file: FileEntry) {
|
||||
if (isHiddenFile(file) && !settings.get('isFileServerShowingHiddenFiles')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSystemHiddenFile(file) && !settings.get('isFileServerShowingSystemHiddenFiles')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isHiddenFile(file: FileEntry) {
|
||||
if (process.platform === 'win32') {
|
||||
return (file.stats.mode & 0x2) === 0x2;
|
||||
} else {
|
||||
return file.name.startsWith('.');
|
||||
}
|
||||
}
|
||||
|
||||
export function isSystemHiddenFile(file: FileEntry) {
|
||||
return getFilesToHide().some(hiddenFile => file.name.endsWith(hiddenFile));
|
||||
}
|
||||
|
||||
let _filesToHide: string[];
|
||||
|
||||
function getFilesToHide() {
|
||||
if (_filesToHide) {
|
||||
return _filesToHide;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
_filesToHide = FILES_TO_HIDE_ON_DARWIN;
|
||||
} else if (process.platform === 'win32') {
|
||||
_filesToHide = FILES_TO_HIDE_ON_WINDOWS;
|
||||
} else {
|
||||
_filesToHide = FILES_TO_HIDE_ON_LINUX;
|
||||
}
|
||||
|
||||
return _filesToHide;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
import { APP_INTERCEPT, FileEntry, MY_COMPUTER_INTERCEPT } from "./fileserver";
|
||||
import { shouldHideFile } from "./hide-files";
|
||||
import { encode, getEncoding } from "./encoding";
|
||||
import { log } from "console";
|
||||
import { app } from "electron";
|
||||
|
||||
export function generateDirectoryListing(currentPath: string, files: string[]): string {
|
||||
const parentPath = path.dirname(currentPath || '/');
|
||||
const title = currentPath === '/' ? 'My Host Computer' : `Directory: ${encode(currentPath)}`;
|
||||
|
||||
// Get file info and sort (directories first, then alphabetically)
|
||||
const items = files
|
||||
.map(name => {
|
||||
const fullPath = path.join(currentPath, name);
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(fullPath);
|
||||
} catch (error) {
|
||||
log(`FileServer: Failed to get stats for ${fullPath}: ${error}`);
|
||||
stats = new fs.Stats();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
fullPath,
|
||||
stats
|
||||
} as FileEntry;
|
||||
})
|
||||
.filter(entry => entry.stats && !shouldHideFile(entry))
|
||||
.sort((a, b) => {
|
||||
if (a.stats.isDirectory() !== b.stats.isDirectory()) {
|
||||
return a.stats.isDirectory() ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map(getFileLiHtml)
|
||||
.join('')
|
||||
|
||||
// Generate very simple HTML that works in IE 5.5
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
${getEncoding()}
|
||||
<title>${title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${title}</h2>
|
||||
<p>${getParentFolderLinkHtml(parentPath)} | ${getDesktopLinkHtml()} | ${getDownloadsLinkHtml()}</p>
|
||||
<p>
|
||||
<ul>
|
||||
${items}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function getParentFolderLinkHtml(parentPath: string) {
|
||||
return `
|
||||
${getIconHtml('folder.gif')}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(parentPath)}">
|
||||
[Parent Directory]
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function getDesktopLinkHtml() {
|
||||
const desktopPath = app.getPath('desktop');
|
||||
|
||||
return `
|
||||
${getIconHtml('desktop.gif')}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(desktopPath)}">
|
||||
Desktop
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function getDownloadsLinkHtml() {
|
||||
const downloadsPath = app.getPath('downloads');
|
||||
|
||||
return `
|
||||
${getIconHtml('network.gif')}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(downloadsPath)}">
|
||||
Downloads
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function getIconHtml(icon: string) {
|
||||
return `<img src="${APP_INTERCEPT}images/${icon}" style="vertical-align: middle; margin-right: 5px;" width="16" height="16">`;
|
||||
}
|
||||
|
||||
function getFileLiHtml(entry: FileEntry) {
|
||||
const encodedPath = encodeURI(entry.fullPath);
|
||||
const sizeDisplay = entry.stats.isDirectory() ? '' : ` (${formatFileSize(entry.stats.size)})`;
|
||||
const icon = entry.stats.isDirectory() ? getIconHtml('folder.gif') : getIconHtml('doc.gif');
|
||||
|
||||
return `<li>
|
||||
${icon}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodedPath}">
|
||||
${getDisplayName(entry)}
|
||||
</a>
|
||||
${sizeDisplay}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function getDisplayName(entry: FileEntry) {
|
||||
return encode(entry.stats.isDirectory() ? `[${entry.name}]` : entry.name);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getEncoding } from "./encoding";
|
||||
import { MY_COMPUTER_INTERCEPT } from "./fileserver";
|
||||
|
||||
export function generateErrorPage(errorMessage: string, requestedPath: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
${getEncoding()}
|
||||
<title>Error - File Not Found</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Error: ${errorMessage}</h2>
|
||||
<p>windows95 failed to find the file or directory on your host computer: <code>${requestedPath}</code></p>
|
||||
<p>Options:</p>
|
||||
<ul>
|
||||
<li><a href="${MY_COMPUTER_INTERCEPT}">Return to root directory</a></li>
|
||||
<li><a href="javascript:history.back()">Go back to previous page</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -1,14 +1,65 @@
|
||||
import { ipcMain, app } from "electron";
|
||||
import { ipcMain, app, dialog, BrowserWindow } from "electron";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
import { IPC_COMMANDS } from "../constants";
|
||||
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 path.join(app.getPath("userData"), "state-v4.bin");
|
||||
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];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getOrCreateWindow } from "./windows";
|
||||
import { setupMenu } from "./menu";
|
||||
import { setupIpcListeners } from "./ipc";
|
||||
import { setupSession } from "./session";
|
||||
import { setupFileServer } from './fileserver/fileserver';
|
||||
import { setupFileServer } from "./fileserver/fileserver";
|
||||
|
||||
/**
|
||||
* Handle the app's "ready" event. This is essentially
|
||||
@@ -61,6 +61,15 @@ export function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
// Renderer DevTools Protocol — connect Chrome to chrome://inspect
|
||||
// or attach a debugger to localhost:9222
|
||||
app.commandLine.appendSwitch(
|
||||
"remote-debugging-port",
|
||||
process.env.WIN95_DEBUG_PORT || "9222",
|
||||
);
|
||||
}
|
||||
|
||||
// Set the app's name
|
||||
app.setName("windows95");
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
}
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) {
|
||||
if (focusedWindow instanceof BrowserWindow) {
|
||||
focusedWindow.webContents.toggleDevTools();
|
||||
}
|
||||
},
|
||||
@@ -125,6 +125,46 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
{
|
||||
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),
|
||||
@@ -148,41 +188,6 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
isRunning
|
||||
? {
|
||||
label: "Stop",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_STOP),
|
||||
}
|
||||
: {
|
||||
label: "Start",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_START),
|
||||
},
|
||||
{
|
||||
label: "Restart",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESTART),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Reset",
|
||||
click: 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: "Go to Disk Image",
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),
|
||||
|
||||
@@ -3,16 +3,14 @@ import { session } from "electron";
|
||||
export function setupSession() {
|
||||
const s = session.defaultSession;
|
||||
|
||||
s.webRequest.onBeforeSendHeaders(
|
||||
(details, callback) => {
|
||||
callback({ requestHeaders: { Origin: '*', ...details.requestHeaders } });
|
||||
},
|
||||
);
|
||||
s.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
callback({ requestHeaders: { Origin: "*", ...details.requestHeaders } });
|
||||
});
|
||||
|
||||
s.webRequest.onHeadersReceived((details, callback) => {
|
||||
callback({
|
||||
responseHeaders: {
|
||||
'Access-Control-Allow-Origin': ['*'],
|
||||
"Access-Control-Allow-Origin": ["*"],
|
||||
...details.responseHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { app } from "electron";
|
||||
|
||||
export interface Settings {
|
||||
isFileServerEnabled: boolean;
|
||||
isFileServerShowingHiddenFiles: boolean;
|
||||
isFileServerShowingSystemHiddenFiles: boolean;
|
||||
smbSharePath: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
isFileServerEnabled: true,
|
||||
isFileServerShowingHiddenFiles: false,
|
||||
isFileServerShowingSystemHiddenFiles: false,
|
||||
smbSharePath: app.getPath("downloads"),
|
||||
};
|
||||
|
||||
class SettingsManager {
|
||||
@@ -19,14 +15,14 @@ class SettingsManager {
|
||||
private data: Settings;
|
||||
|
||||
constructor() {
|
||||
this.filePath = path.join(app.getPath('userData'), 'settings.json');
|
||||
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 fileContent = fs.readFileSync(this.filePath, "utf8");
|
||||
const parsed = JSON.parse(fileContent);
|
||||
|
||||
return {
|
||||
@@ -35,7 +31,7 @@ class SettingsManager {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
console.error("Error loading settings:", error);
|
||||
}
|
||||
|
||||
return DEFAULT_SETTINGS;
|
||||
@@ -45,7 +41,7 @@ class SettingsManager {
|
||||
try {
|
||||
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
console.error("Error saving settings:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,16 +49,11 @@ class SettingsManager {
|
||||
return this.data[key];
|
||||
}
|
||||
|
||||
set(key: keyof Settings, value: any): void {
|
||||
set<K extends keyof Settings>(key: K, value: Settings[K]): void {
|
||||
this.data[key] = value;
|
||||
this.save();
|
||||
}
|
||||
|
||||
delete(key: keyof Settings): void {
|
||||
delete this.data[key];
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.data = DEFAULT_SETTINGS;
|
||||
this.save();
|
||||
|
||||
@@ -2,7 +2,8 @@ import { app } from "electron";
|
||||
|
||||
export function setupUpdates() {
|
||||
if (app.isPackaged) {
|
||||
require("update-electron-app")({
|
||||
const { updateElectronApp } = require("update-electron-app");
|
||||
updateElectronApp({
|
||||
repo: "felixrieseberg/windows95",
|
||||
updateInterval: "1 hour",
|
||||
});
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
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;
|
||||
|
||||
@@ -18,7 +39,14 @@ export function getOrCreateWindow(): BrowserWindow {
|
||||
},
|
||||
});
|
||||
|
||||
// mainWindow.webContents.toggleDevTools();
|
||||
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) =>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import "../css/vendor/98.css";
|
||||
import "../css/root.css";
|
||||
|
||||
export interface Win95Window extends Window {
|
||||
emulator: any;
|
||||
win95: {
|
||||
@@ -18,9 +21,9 @@ export class App {
|
||||
* Initial setup call, loading Monaco and kicking off the React
|
||||
* render process.
|
||||
*/
|
||||
public async setup(): Promise<void | Element> {
|
||||
public async setup(): Promise<void> {
|
||||
const React = await import("react");
|
||||
const { render } = await import("react-dom");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { Emulator } = await import("./emulator");
|
||||
|
||||
const className = `${process.platform}`;
|
||||
@@ -30,9 +33,8 @@ export class App {
|
||||
</div>
|
||||
);
|
||||
|
||||
const rendered = render(app, document.getElementById("app"));
|
||||
|
||||
return rendered;
|
||||
const root = createRoot(document.getElementById("app")!);
|
||||
root.render(app);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
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: (cdrom: 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;
|
||||
}
|
||||
|
||||
@@ -26,157 +38,249 @@ export class CardSettings extends React.Component<
|
||||
this.onResetState = this.onResetState.bind(this);
|
||||
|
||||
this.state = {
|
||||
tab: "floppy",
|
||||
isStateReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tab } = this.state;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="card settings">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<img src="../../static/settings.png" />
|
||||
Settings
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{this.renderCdrom()}
|
||||
<hr />
|
||||
{this.renderFloppy()}
|
||||
<hr />
|
||||
{this.renderState()}
|
||||
<div className="window settings-window">
|
||||
<div className="title-bar">
|
||||
<div className="title-bar-text">windows95 Properties</div>
|
||||
<div className="title-bar-controls">
|
||||
<button aria-label="Help" disabled />
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={() => this.props.navigate("start")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="window-body">
|
||||
<menu role="tablist">
|
||||
{this.renderTab("floppy", "Floppy Drive")}
|
||||
{CDROM_ENABLED && this.renderTab("cdrom", "CD-ROM")}
|
||||
{this.renderTab("network", "Shared Folder")}
|
||||
{this.renderTab("interface", "Interface")}
|
||||
{this.renderTab("state", "Machine State")}
|
||||
</menu>
|
||||
<div className="window settings-panel" role="tabpanel">
|
||||
<div className="window-body">
|
||||
{tab === "floppy" && this.renderFloppy()}
|
||||
{tab === "cdrom" && this.renderCdrom()}
|
||||
{tab === "network" && this.renderSmbShare()}
|
||||
{tab === "interface" && this.renderInterface()}
|
||||
{tab === "state" && this.renderState()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-footer">
|
||||
<button
|
||||
className="default"
|
||||
onClick={() => this.props.navigate("start")}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button onClick={() => this.props.navigate("start")}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderCdrom() {
|
||||
// CD is currently not working, so.. let's return nothing.
|
||||
return null;
|
||||
|
||||
const { cdrom } = this.props;
|
||||
|
||||
private renderTab(id: Tab, label: string) {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/cdrom.png" />
|
||||
CD-ROM
|
||||
</legend>
|
||||
<input
|
||||
id="cdrom-input"
|
||||
type="file"
|
||||
onChange={this.onChangeCdrom}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<p>
|
||||
windows95 comes with a virtual CD drive. It can mount images in the
|
||||
"iso" format.
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{cdrom ? `Inserted CD: ${cdrom?.path}` : `No CD mounted`}
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
(document.querySelector("#cdrom-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/select-cdrom.png" />
|
||||
<span>Mount CD</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
<li
|
||||
role="tab"
|
||||
aria-selected={this.state.tab === id}
|
||||
onClick={() => this.setState({ tab: id })}
|
||||
>
|
||||
<a href="#">{label}</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
public renderFloppy() {
|
||||
private renderFloppy() {
|
||||
const { floppy } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/floppy.png" />
|
||||
Floppy
|
||||
</legend>
|
||||
<legend>Drive A:</legend>
|
||||
<input
|
||||
id="floppy-input"
|
||||
type="file"
|
||||
onChange={this.onChangeFloppy}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<p>
|
||||
windows95 comes with a virtual floppy drive. It can mount floppy disk
|
||||
images in the "img" format.
|
||||
</p>
|
||||
<p>
|
||||
Back in the 90s and before CD-ROMs became a popular, software was
|
||||
typically distributed on floppy disks. Some developers have since
|
||||
released their apps or games for free, usually on virtual floppy disks
|
||||
using the "img" format.
|
||||
</p>
|
||||
<p>
|
||||
Once you've mounted a disk image, you might have to boot your virtual
|
||||
windows95 machine from scratch.
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{floppy
|
||||
? `Inserted Floppy Disk: ${floppy.name}`
|
||||
: `No floppy mounted`}
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/select-floppy.png" />
|
||||
<span>Mount floppy disk</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderState() {
|
||||
const { isStateReset } = this.state;
|
||||
const { bootFromScratch } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/reset.png" />
|
||||
Reset machine state
|
||||
</legend>
|
||||
<div>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/floppy.png" />
|
||||
<p>
|
||||
windows95 stores changes to your machine (like saved files) in a
|
||||
state file. If you encounter any trouble, you can reset your state
|
||||
or boot Windows 95 from scratch.{" "}
|
||||
<strong>All your changes will be lost.</strong>
|
||||
windows95 ships with a virtual 3½" floppy drive. Mount an{" "}
|
||||
<code>.img</code> disk image here, then boot the machine to read it
|
||||
from inside Windows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="floppy-path">Mounted image</label>
|
||||
<input
|
||||
id="floppy-path"
|
||||
type="text"
|
||||
readOnly
|
||||
value={floppy ? floppy.name : "(No disk in drive)"}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={this.onResetState}
|
||||
disabled={isStateReset}
|
||||
style={{ marginRight: "5px" }}
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/reset-state.png" />
|
||||
{isStateReset ? "State reset" : "Reset state"}
|
||||
</button>
|
||||
<button className="btn" onClick={bootFromScratch}>
|
||||
<img src="../../static/boot-fresh.png" />
|
||||
Boot from scratch
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a change in the floppy input
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
private renderCdrom() {
|
||||
const { cdrom } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Drive D:</legend>
|
||||
<input
|
||||
id="cdrom-input"
|
||||
type="file"
|
||||
onChange={this.onChangeCdrom}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/cdrom.png" />
|
||||
<p>
|
||||
windows95 ships with a virtual CD-ROM drive. Mount an{" "}
|
||||
<code>.iso</code> image here, then boot the machine to read it from
|
||||
inside Windows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="cdrom-path">Mounted image</label>
|
||||
<input
|
||||
id="cdrom-path"
|
||||
type="text"
|
||||
readOnly
|
||||
value={cdrom ? cdrom.name : "(No disc in drive)"}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
onClick={() =>
|
||||
(document.querySelector("#cdrom-input") as any).click()
|
||||
}
|
||||
>
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSmbShare() {
|
||||
const { smbSharePath } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Drive Z:</legend>
|
||||
<div className="settings-row">
|
||||
<img
|
||||
className="settings-icon"
|
||||
src="../../static/show-disk-image.png"
|
||||
/>
|
||||
<p>
|
||||
A folder on your computer is mounted inside Windows 95 as drive{" "}
|
||||
<code>Z:</code>. Open My Computer inside Windows to find it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="smb-path">Shared folder</label>
|
||||
<input id="smb-path" type="text" readOnly value={smbSharePath} />
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const picked = await this.props.pickFolder();
|
||||
if (picked) this.props.setSmbSharePath(picked);
|
||||
}}
|
||||
>
|
||||
Choose folder...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderInterface() {
|
||||
const { infoBarSettings, setInfoBarSettings } = this.props;
|
||||
|
||||
const checkbox = (key: keyof InfoBarSettings, label: string) => (
|
||||
<div className="field-row">
|
||||
<input
|
||||
id={`ibs-${key}`}
|
||||
type="checkbox"
|
||||
checked={infoBarSettings[key]}
|
||||
onChange={(e) =>
|
||||
setInfoBarSettings({ ...infoBarSettings, [key]: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`ibs-${key}`}>{label}</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Info bar</legend>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/settings.png" />
|
||||
<p>
|
||||
The bar at the top of the emulator shows live machine stats. Choose
|
||||
which metrics to display and whether to draw sparkline graphs next
|
||||
to them.
|
||||
</p>
|
||||
</div>
|
||||
{checkbox("showCpu", "Show CPU speed")}
|
||||
{checkbox("showDisk", "Show disk throughput")}
|
||||
{checkbox("showNet", "Show network throughput")}
|
||||
{checkbox("showSparklines", "Show sparklines")}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderState() {
|
||||
const { isStateReset } = this.state;
|
||||
const { bootFromScratch } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Reset</legend>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/reset.png" />
|
||||
<p>
|
||||
Changes to your machine (saved files, installed programs) are stored
|
||||
in a state file. If something breaks, you can either discard that
|
||||
state or boot a fresh copy of Windows from scratch.{" "}
|
||||
<strong>All your changes will be lost.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button onClick={this.onResetState} disabled={isStateReset}>
|
||||
{isStateReset ? "State has been reset" : "Reset state"}
|
||||
</button>
|
||||
<button onClick={bootFromScratch}>Boot from scratch</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private onChangeFloppy(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const floppyFile =
|
||||
event.target.files && event.target.files.length > 0
|
||||
@@ -190,27 +294,19 @@ export class CardSettings extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a change in the cdrom input
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
private onChangeCdrom(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const CdromFile =
|
||||
const cdromFile =
|
||||
event.target.files && event.target.files.length > 0
|
||||
? event.target.files[0]
|
||||
: null;
|
||||
|
||||
if (CdromFile) {
|
||||
this.props.setCdrom(CdromFile);
|
||||
if (cdromFile) {
|
||||
this.props.setCdrom(cdromFile);
|
||||
} else {
|
||||
console.log(`Cdrom: Input changed but no file selected`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the state reset
|
||||
*/
|
||||
private async onResetState() {
|
||||
await resetState();
|
||||
this.setState({ isStateReset: true });
|
||||
|
||||
@@ -2,18 +2,166 @@ import * as React from "react";
|
||||
|
||||
export interface CardStartProps {
|
||||
startEmulator: () => void;
|
||||
navigate: (to: "start" | "settings") => void;
|
||||
legacyStatePath: string | null;
|
||||
legacyRecovered: { dir: string; files: number } | null;
|
||||
legacyRecoverBusy: boolean;
|
||||
legacyRecoverError: string | null;
|
||||
recoverLegacy: () => void;
|
||||
showRecovered: () => void;
|
||||
discardLegacy: () => void;
|
||||
}
|
||||
|
||||
export class CardStart extends React.Component<CardStartProps, {}> {
|
||||
const TIPS = [
|
||||
"Press the Escape key at any time to release or recapture your mouse cursor.",
|
||||
"You can mount a floppy image from Settings before booting to install vintage software.",
|
||||
"A folder from your real computer is mounted as drive Z: — open My Computer inside Windows to find it.",
|
||||
"Your machine state is saved automatically when you quit. Reset it from Settings if things get weird.",
|
||||
"Use the Machine menu in the menubar to send Ctrl+Alt+Del and other special key combos.",
|
||||
];
|
||||
|
||||
export class CardStart extends React.Component<CardStartProps> {
|
||||
private tip = TIPS[Math.floor(Math.random() * TIPS.length)];
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<section id="section-start">
|
||||
<button className="btn" id="win95" onClick={this.props.startEmulator}>
|
||||
<img src="../../static/run.png" />
|
||||
<span>Start Windows 95</span>
|
||||
</button>
|
||||
<small>Hit ESC to lock or unlock your mouse</small>
|
||||
</section>
|
||||
<div className="window welcome" id="welcome-window">
|
||||
<div className="title-bar">
|
||||
<div className="title-bar-text">Welcome</div>
|
||||
<div className="title-bar-controls">
|
||||
<button aria-label="Minimize" disabled />
|
||||
<button aria-label="Maximize" disabled />
|
||||
<button aria-label="Close" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className="window-body welcome-body">
|
||||
<aside className="welcome-stripe">
|
||||
<span>Windows 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);
|
||||
}
|
||||
@@ -1,48 +1,120 @@
|
||||
import * as React from "react";
|
||||
import { InfoBarSettings } from "./info-bar-settings";
|
||||
|
||||
interface EmulatorInfoProps {
|
||||
toggleInfo: () => void;
|
||||
emulator: any;
|
||||
hidden: boolean;
|
||||
settings: InfoBarSettings;
|
||||
}
|
||||
|
||||
interface EmulatorInfoState {
|
||||
cpu: number;
|
||||
disk: string;
|
||||
diskRead: number;
|
||||
diskWrite: number;
|
||||
netRx: number;
|
||||
netTx: number;
|
||||
lastCounter: number;
|
||||
lastTick: number;
|
||||
history: {
|
||||
cpu: number[];
|
||||
diskRead: number[];
|
||||
diskWrite: number[];
|
||||
netRx: number[];
|
||||
netTx: number[];
|
||||
};
|
||||
}
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
function Sparkline({ data }: { data: number[] }) {
|
||||
const w = 20;
|
||||
const h = 12;
|
||||
const max = Math.max(1, ...data);
|
||||
const step = data.length > 1 ? w / (data.length - 1) : 0;
|
||||
const points = data
|
||||
.map((v, i) => `${i * step},${h - (v / max) * h}`)
|
||||
.join(" ");
|
||||
return (
|
||||
<svg className="spark" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
||||
<polyline points={points} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export class EmulatorInfo extends React.Component<
|
||||
EmulatorInfoProps,
|
||||
EmulatorInfoState
|
||||
> {
|
||||
private cpuInterval = -1;
|
||||
private tickInterval = -1;
|
||||
private diskReadBytes = 0;
|
||||
private diskWriteBytes = 0;
|
||||
private netRxBytes = 0;
|
||||
private netTxBytes = 0;
|
||||
|
||||
constructor(props: EmulatorInfoProps) {
|
||||
super(props);
|
||||
|
||||
this.cpuCount = this.cpuCount.bind(this);
|
||||
this.onIDEReadStart = this.onIDEReadStart.bind(this);
|
||||
this.onIDEReadWriteEnd = this.onIDEReadWriteEnd.bind(this);
|
||||
this.tick = this.tick.bind(this);
|
||||
this.onIDEReadEnd = this.onIDEReadEnd.bind(this);
|
||||
this.onIDEWriteEnd = this.onIDEWriteEnd.bind(this);
|
||||
this.onEthReceiveEnd = this.onEthReceiveEnd.bind(this);
|
||||
this.onEthTransmitEnd = this.onEthTransmitEnd.bind(this);
|
||||
|
||||
this.state = {
|
||||
cpu: 0,
|
||||
disk: "Idle",
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
netRx: 0,
|
||||
netTx: 0,
|
||||
lastCounter: 0,
|
||||
lastTick: 0,
|
||||
history: {
|
||||
cpu: new Array(HISTORY_LEN).fill(0),
|
||||
diskRead: new Array(HISTORY_LEN).fill(0),
|
||||
diskWrite: new Array(HISTORY_LEN).fill(0),
|
||||
netRx: new Array(HISTORY_LEN).fill(0),
|
||||
netTx: new Array(HISTORY_LEN).fill(0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { cpu, disk } = this.state;
|
||||
const { cpu, diskRead, diskWrite, netRx, netTx, history } = this.state;
|
||||
const { hidden, toggleInfo, settings } = this.props;
|
||||
const { showCpu, showDisk, showNet, showSparklines: spark } = settings;
|
||||
|
||||
return (
|
||||
<div id="status">
|
||||
Disk: <span>{disk}</span> | CPU Speed: <span>{cpu}</span> |{" "}
|
||||
<a href="#" onClick={this.props.toggleInfo}>
|
||||
Hide
|
||||
</a>
|
||||
</div>
|
||||
<>
|
||||
<div id="status-hotzone" />
|
||||
<div id="status" className={hidden ? "hidden" : ""}>
|
||||
{showCpu && (
|
||||
<>
|
||||
CPU: {spark && <Sparkline data={history.cpu} />}
|
||||
{this.fmt(cpu, ["M", "G"])}/s |{" "}
|
||||
</>
|
||||
)}
|
||||
{showDisk && (
|
||||
<>
|
||||
Disk: {spark && <Sparkline data={history.diskRead} />}R{" "}
|
||||
{this.fmt(diskRead, ["B", "K", "M", "G"])}/s{" "}
|
||||
{spark && <Sparkline data={history.diskWrite} />}W{" "}
|
||||
{this.fmt(diskWrite, ["B", "K", "M", "G"])}/s |{" "}
|
||||
</>
|
||||
)}
|
||||
{showNet && (
|
||||
<>
|
||||
Net: {spark && <Sparkline data={history.netRx} />}↓
|
||||
{this.fmt(netRx, ["B", "K", "M", "G"])}/s{" "}
|
||||
{spark && <Sparkline data={history.netTx} />}↑
|
||||
{this.fmt(netTx, ["B", "K", "M", "G"])}/s |{" "}
|
||||
</>
|
||||
)}
|
||||
<a href="#" className="toggle" onClick={toggleInfo}>
|
||||
{hidden ? "Pin" : "Hide"}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,21 +151,17 @@ export class EmulatorInfo extends React.Component<
|
||||
return;
|
||||
}
|
||||
|
||||
// CPU
|
||||
if (this.cpuInterval > -1) {
|
||||
clearInterval(this.cpuInterval);
|
||||
if (this.tickInterval > -1) {
|
||||
clearInterval(this.tickInterval);
|
||||
}
|
||||
|
||||
// TypeScript think's we're using a Node.js setInterval. We're not.
|
||||
this.cpuInterval = setInterval(this.cpuCount, 500) as unknown as number;
|
||||
this.tickInterval = setInterval(this.tick, 500) as unknown as number;
|
||||
|
||||
// Disk
|
||||
emulator.add_listener("ide-read-start", this.onIDEReadStart);
|
||||
emulator.add_listener("ide-read-end", this.onIDEReadWriteEnd);
|
||||
emulator.add_listener("ide-write-end", this.onIDEReadWriteEnd);
|
||||
|
||||
// Screen
|
||||
emulator.add_listener("screen-set-size-graphical", console.log);
|
||||
emulator.add_listener("ide-read-end", this.onIDEReadEnd);
|
||||
emulator.add_listener("ide-write-end", this.onIDEWriteEnd);
|
||||
emulator.add_listener("eth-receive-end", this.onEthReceiveEnd);
|
||||
emulator.add_listener("eth-transmit-end", this.onEthTransmitEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,58 +177,88 @@ export class EmulatorInfo extends React.Component<
|
||||
return;
|
||||
}
|
||||
|
||||
// CPU
|
||||
if (this.cpuInterval > -1) {
|
||||
clearInterval(this.cpuInterval);
|
||||
if (this.tickInterval > -1) {
|
||||
clearInterval(this.tickInterval);
|
||||
}
|
||||
|
||||
// Disk
|
||||
emulator.remove_listener("ide-read-start", this.onIDEReadStart);
|
||||
emulator.remove_listener("ide-read-end", this.onIDEReadWriteEnd);
|
||||
emulator.remove_listener("ide-write-end", this.onIDEReadWriteEnd);
|
||||
emulator.remove_listener("ide-read-end", this.onIDEReadEnd);
|
||||
emulator.remove_listener("ide-write-end", this.onIDEWriteEnd);
|
||||
emulator.remove_listener("eth-receive-end", this.onEthReceiveEnd);
|
||||
emulator.remove_listener("eth-transmit-end", this.onEthTransmitEnd);
|
||||
}
|
||||
|
||||
// Screen
|
||||
emulator.remove_listener("screen-set-size-graphical", console.log);
|
||||
private onIDEReadEnd(args: number[]) {
|
||||
this.diskReadBytes += args[1];
|
||||
}
|
||||
|
||||
private onIDEWriteEnd(args: number[]) {
|
||||
this.diskWriteBytes += args[1];
|
||||
}
|
||||
|
||||
private onEthReceiveEnd(args: number[]) {
|
||||
this.netRxBytes += args[0];
|
||||
}
|
||||
|
||||
private onEthTransmitEnd(args: number[]) {
|
||||
this.netTxBytes += args[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The virtual IDE is handling read (start).
|
||||
* Format a value as "N.NU" by walking the unit ladder until it fits in
|
||||
* one digit before the decimal. Always exactly 4 chars (e.g. "0.0B",
|
||||
* "3.2K", "9.9G") so the bar width never changes.
|
||||
*/
|
||||
private onIDEReadStart() {
|
||||
this.requestIdle(() => this.setState({ disk: "Read" }));
|
||||
private fmt(value: number, units: string[]) {
|
||||
let v = Math.max(0, value);
|
||||
let u = 0;
|
||||
while (v >= 10 && u < units.length - 1) {
|
||||
v /= 1000;
|
||||
u++;
|
||||
}
|
||||
if (v >= 9.95) v = 9.9;
|
||||
return `${v.toFixed(1)}${units[u]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The virtual IDE is handling read/write (end).
|
||||
* Once per interval, compute CPU speed and I/O throughput.
|
||||
*/
|
||||
private onIDEReadWriteEnd() {
|
||||
this.requestIdle(() => this.setState({ disk: "Idle" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an idle callback with a 3s timeout.
|
||||
*
|
||||
* @param fn
|
||||
*/
|
||||
private requestIdle(fn: () => void) {
|
||||
(window as any).requestIdleCallback(fn, { timeout: 3000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates what's up with the virtual cpu.
|
||||
*/
|
||||
private cpuCount() {
|
||||
private tick() {
|
||||
const { lastCounter, lastTick } = this.state;
|
||||
|
||||
const now = Date.now();
|
||||
const instructionCounter = this.props.emulator.get_instruction_counter();
|
||||
const ips = instructionCounter - lastCounter;
|
||||
const deltaTime = now - lastTick;
|
||||
const deltaSec = deltaTime / 1000;
|
||||
|
||||
this.setState({
|
||||
const cpu = Math.round(ips / deltaTime / 1000);
|
||||
const diskRead = Math.round(this.diskReadBytes / deltaSec);
|
||||
const diskWrite = Math.round(this.diskWriteBytes / deltaSec);
|
||||
const netRx = Math.round(this.netRxBytes / deltaSec);
|
||||
const netTx = Math.round(this.netTxBytes / deltaSec);
|
||||
|
||||
const push = (arr: number[], v: number) => [...arr, v].slice(-HISTORY_LEN);
|
||||
|
||||
this.setState((s) => ({
|
||||
lastTick: now,
|
||||
lastCounter: instructionCounter,
|
||||
cpu: Math.round(ips / deltaTime),
|
||||
});
|
||||
cpu,
|
||||
diskRead,
|
||||
diskWrite,
|
||||
netRx,
|
||||
netTx,
|
||||
history: {
|
||||
cpu: push(s.history.cpu, cpu),
|
||||
diskRead: push(s.history.diskRead, diskRead),
|
||||
diskWrite: push(s.history.diskWrite, diskWrite),
|
||||
netRx: push(s.history.netRx, netRx),
|
||||
netTx: push(s.history.netTx, netTx),
|
||||
},
|
||||
}));
|
||||
|
||||
this.diskReadBytes = 0;
|
||||
this.diskWriteBytes = 0;
|
||||
this.netRxBytes = 0;
|
||||
this.netTxBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,33 @@ import { ipcRenderer, shell, webUtils } from "electron";
|
||||
import { CONSTANTS, IPC_COMMANDS } from "../constants";
|
||||
import { getDiskImageSize } from "../utils/disk-image-size";
|
||||
import { CardStart } from "./card-start";
|
||||
import { StartMenu } from "./start-menu";
|
||||
import { CardSettings } from "./card-settings";
|
||||
import { EmulatorInfo } from "./emulator-info";
|
||||
import { getStatePath } from "./utils/get-state-path";
|
||||
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;
|
||||
|
||||
@@ -21,15 +42,25 @@ export interface EmulatorState {
|
||||
scale: number;
|
||||
floppyFile?: File;
|
||||
cdromFile?: File;
|
||||
smbSharePath: string;
|
||||
isBootingFresh: boolean;
|
||||
isCursorCaptured: boolean;
|
||||
hasAbsoluteMouse: boolean;
|
||||
isInfoDisplayed: boolean;
|
||||
isRunning: boolean;
|
||||
infoBarSettings: InfoBarSettings;
|
||||
legacyStatePath: string | null;
|
||||
legacyRecovered: { dir: string; files: number } | null;
|
||||
legacyRecoverBusy: boolean;
|
||||
legacyRecoverError: string | null;
|
||||
}
|
||||
|
||||
export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
private isQuitting = false;
|
||||
private isResetting = false;
|
||||
// Mirrors state.hasAbsoluteMouse but updated synchronously — setState is
|
||||
// batched, and the lock/unlock decisions can't wait for a render.
|
||||
private absoluteMouse = false;
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
@@ -41,11 +72,18 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
this.bootFromScratch = this.bootFromScratch.bind(this);
|
||||
|
||||
this.state = {
|
||||
isBootingFresh: false,
|
||||
isBootingFresh: PROBE,
|
||||
isCursorCaptured: false,
|
||||
hasAbsoluteMouse: false,
|
||||
isRunning: false,
|
||||
legacyStatePath: null,
|
||||
legacyRecovered: null,
|
||||
legacyRecoverBusy: false,
|
||||
legacyRecoverError: null,
|
||||
currentUiCard: "start",
|
||||
isInfoDisplayed: true,
|
||||
smbSharePath: "",
|
||||
infoBarSettings: loadInfoBarSettings(),
|
||||
// We can start pretty large
|
||||
// If it's too large, it'll just grow until it hits borders
|
||||
scale: 2,
|
||||
@@ -54,6 +92,18 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,12 +129,25 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
|
||||
// Click
|
||||
document.addEventListener("click", () => {
|
||||
const { isRunning } = this.state;
|
||||
|
||||
if (isRunning) {
|
||||
if (this.state.isRunning && !this.absoluteMouse) {
|
||||
this.lockMouse();
|
||||
}
|
||||
});
|
||||
|
||||
// Only forward mouse input to the VM while the pointer is actually
|
||||
// captured (or while the guest's absolute-pointer driver is active —
|
||||
// VBMOUSE/vmwmouse via the VMware backdoor — in which case the guest
|
||||
// cursor tracks the host cursor 1:1 and we don't need pointer lock at
|
||||
// all). Browsers can release pointer lock on their own (Esc, focus loss),
|
||||
// so we sync v86's mouse status off the real lock state instead of
|
||||
// assuming our lock/unlock calls succeeded.
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
const isCursorCaptured = !!document.pointerLockElement;
|
||||
this.setState({ isCursorCaptured });
|
||||
this.state.emulator?.mouse_set_status(
|
||||
isCursorCaptured || this.absoluteMouse,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,6 +215,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
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, () => {
|
||||
@@ -187,6 +254,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigate = (currentUiCard: "start" | "settings") =>
|
||||
this.setState({ currentUiCard });
|
||||
|
||||
let card;
|
||||
|
||||
if (currentUiCard === "settings") {
|
||||
@@ -194,23 +264,69 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
<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} />;
|
||||
card = (
|
||||
<CardStart
|
||||
startEmulator={this.startEmulator}
|
||||
navigate={navigate}
|
||||
legacyStatePath={this.state.legacyStatePath}
|
||||
legacyRecovered={this.state.legacyRecovered}
|
||||
legacyRecoverBusy={this.state.legacyRecoverBusy}
|
||||
legacyRecoverError={this.state.legacyRecoverError}
|
||||
recoverLegacy={async () => {
|
||||
const p = this.state.legacyStatePath;
|
||||
if (!p) return;
|
||||
this.setState({
|
||||
legacyRecoverBusy: true,
|
||||
legacyRecoverError: null,
|
||||
});
|
||||
try {
|
||||
const downloads =
|
||||
process.env.WIN95_RECOVER_DIR ||
|
||||
(await ipcRenderer.invoke(IPC_COMMANDS.GET_DOWNLOADS_PATH));
|
||||
const outDir = path.join(downloads, "Recovered C Drive");
|
||||
const out = await recoverLegacyDisk(p, outDir);
|
||||
this.setState({ legacyRecovered: out });
|
||||
} catch (e) {
|
||||
console.error("recoverLegacy:", e);
|
||||
this.setState({
|
||||
legacyRecoverError: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
this.setState({ legacyRecoverBusy: false });
|
||||
}
|
||||
}}
|
||||
showRecovered={() =>
|
||||
this.state.legacyRecovered &&
|
||||
shell.openPath(this.state.legacyRecovered.dir)
|
||||
}
|
||||
discardLegacy={async () => {
|
||||
const p = this.state.legacyStatePath;
|
||||
if (p) await fs.promises.unlink(p).catch(() => {});
|
||||
this.setState({ legacyStatePath: null });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{card}
|
||||
<StartMenu
|
||||
navigate={(target) => this.setState({ currentUiCard: target as "start" | "settings" })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <section>{card}</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,7 +337,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
<>
|
||||
{this.renderInfo()}
|
||||
{this.renderUI()}
|
||||
<div id="emulator">
|
||||
<div
|
||||
id="emulator"
|
||||
className={this.state.hasAbsoluteMouse ? "seamless-mouse" : undefined}
|
||||
>
|
||||
<div id="emulator-text-screen"></div>
|
||||
<canvas id="emulator-canvas"></canvas>
|
||||
</div>
|
||||
@@ -233,13 +352,11 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
* Render the little info thingy
|
||||
*/
|
||||
public renderInfo() {
|
||||
if (!this.state.isInfoDisplayed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmulatorInfo
|
||||
emulator={this.state.emulator}
|
||||
settings={this.state.infoBarSettings}
|
||||
hidden={!this.state.isInfoDisplayed}
|
||||
toggleInfo={() => {
|
||||
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
|
||||
}}
|
||||
@@ -250,7 +367,8 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
/**
|
||||
* Boot the emulator without restoring state
|
||||
*/
|
||||
public bootFromScratch() {
|
||||
public async bootFromScratch() {
|
||||
await this.stopEmulator();
|
||||
this.setState({ isBootingFresh: true });
|
||||
this.startEmulator();
|
||||
}
|
||||
@@ -271,9 +389,11 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
private async startEmulator() {
|
||||
document.body.classList.remove("paused");
|
||||
|
||||
const cdromPath = this.state.cdromFile
|
||||
? webUtils.getPathForFile(this.state.cdromFile)
|
||||
: null;
|
||||
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"),
|
||||
@@ -281,12 +401,15 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
vga_memory_size: 64 * 1024 * 1024,
|
||||
screen: {
|
||||
container: document.getElementById("emulator"),
|
||||
scale: 0
|
||||
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"),
|
||||
@@ -304,21 +427,54 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
buffer: this.state.floppyFile,
|
||||
}
|
||||
: undefined,
|
||||
cdrom: cdromPath
|
||||
? {
|
||||
url: cdromPath,
|
||||
async: true,
|
||||
size: await getDiskImageSize(cdromPath),
|
||||
}
|
||||
: 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.
|
||||
const smbRoot = process.env.WIN95_SMB_SHARE || this.state.smbSharePath;
|
||||
if (smbRoot) {
|
||||
setupSmbShare(window["emulator"], smbRoot, CONSTANTS.TOOLS_PATH);
|
||||
}
|
||||
|
||||
if (PROBE) {
|
||||
startProbe(window["emulator"]);
|
||||
}
|
||||
|
||||
// Host ↔ guest text clipboard. The guest side is W95TOOLS.EXE on the
|
||||
// TOOLS share; until that's running this is a no-op poller.
|
||||
setupClipboardSync(window["emulator"]);
|
||||
|
||||
// New v86 instance
|
||||
// Mouse stays disabled until either the pointer is captured or the guest's
|
||||
// VMware-backdoor mouse driver requests absolute mode.
|
||||
window["emulator"].mouse_set_status(false);
|
||||
window["emulator"].add_listener("vmware-absolute-mouse", (on: boolean) => {
|
||||
this.absoluteMouse = on;
|
||||
this.setState({ hasAbsoluteMouse: on });
|
||||
window["emulator"].mouse_set_status(on || !!document.pointerLockElement);
|
||||
if (on && document.pointerLockElement) {
|
||||
this.unlockMouse();
|
||||
}
|
||||
});
|
||||
this.setState({
|
||||
emulator: window["emulator"],
|
||||
isRunning: true,
|
||||
@@ -326,18 +482,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
|
||||
ipcRenderer.send(IPC_COMMANDS.MACHINE_STARTED);
|
||||
|
||||
// Restore state. We can't do this right away
|
||||
// and randomly chose 500ms as the appropriate
|
||||
// wait time (lol)
|
||||
setTimeout(async () => {
|
||||
// Wait for v86 to finish loading wasm/bios/hda before restoring — calling
|
||||
// restore_state on an uninitialized cpu throws and we'd silently cold-boot.
|
||||
window["emulator"].add_listener("emulator-loaded", async () => {
|
||||
if (!this.state.isBootingFresh) {
|
||||
this.restoreState();
|
||||
await this.restoreState();
|
||||
}
|
||||
|
||||
this.lockMouse();
|
||||
this.state.emulator.run();
|
||||
this.state.emulator.screen_set_scale(this.state.scale);
|
||||
}, 500);
|
||||
window["emulator"].run();
|
||||
window["emulator"].screen_set_scale(this.state.scale);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,7 +556,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
try {
|
||||
const newState = await emulator.save_state();
|
||||
await fs.promises.writeFile(statePath, Buffer.from(newState), {
|
||||
flush: true
|
||||
flush: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`saveState: Could not save state`, error);
|
||||
@@ -412,36 +566,36 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
/**
|
||||
* Restores state to the emulator.
|
||||
*/
|
||||
private async restoreState() {
|
||||
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;
|
||||
return true;
|
||||
} else if (!state) {
|
||||
console.log(`restoreState: No state present, not restoring.`);
|
||||
return;
|
||||
return false;
|
||||
} else if (!emulator) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return;
|
||||
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.
|
||||
*
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
private async getState(): Promise<ArrayBuffer | null> {
|
||||
const expectedStatePath = await getStatePath();
|
||||
@@ -459,14 +613,6 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
}
|
||||
|
||||
private unlockMouse() {
|
||||
const { emulator } = this.state;
|
||||
|
||||
this.setState({ isCursorCaptured: false });
|
||||
|
||||
if (emulator) {
|
||||
emulator.mouse_set_status(false);
|
||||
}
|
||||
|
||||
document.exitPointerLock();
|
||||
}
|
||||
|
||||
@@ -474,8 +620,6 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
const { emulator } = this.state;
|
||||
|
||||
if (emulator) {
|
||||
this.setState({ isCursorCaptured: true });
|
||||
emulator.mouse_set_status(true);
|
||||
emulator.lock_mouse();
|
||||
} else {
|
||||
console.warn(
|
||||
@@ -524,7 +668,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
const canvas = document.getElementById("emulator-canvas");
|
||||
|
||||
if (canvas instanceof HTMLCanvasElement) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
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);
|
||||
}
|
||||
}
|
||||
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`
|
||||
198
src/renderer/smb/index.ts
Normal file
198
src/renderer/smb/index.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// Glue: hook v86's TCP-connection bus event for port 139 and bridge it to
|
||||
// our SMB server. Windows 95 connects via NetBIOS-over-TCP — ethernet frame
|
||||
// → ne2k → fake_network's userspace TCP/IP → tcp-connection event with a
|
||||
// stream-like TCPConnection object.
|
||||
//
|
||||
// To use: in emulator.tsx after `new V86()`, call
|
||||
// setupSmbShare(window.emulator, "/Users/you/share")
|
||||
// Then inside Win95: Start → Run → \\192.168.86.1\host
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { NetBIOSFramer, nbPositiveResponse, nbWrap } from "./netbios";
|
||||
import { setupNbns } from "./nbns";
|
||||
import { SmbSession, shareNameFor, TOOLS_SHARE } from "./server";
|
||||
|
||||
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
|
||||
const LOG_FILE = process.env.WIN95_SMB_LOG || path.join(os.tmpdir(), "windows95-smb.log");
|
||||
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
origLog(...args);
|
||||
const tag = String(args[0] ?? "");
|
||||
if (tag === "[smb]" || tag === "[nbns]") {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, args.map(a =>
|
||||
typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n");
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
interface TCPConnection {
|
||||
sport: number;
|
||||
tuple: string;
|
||||
state: string;
|
||||
net: unknown;
|
||||
on(event: "data", handler: (data: Uint8Array) => void): void;
|
||||
write(data: Uint8Array): void;
|
||||
accept(packet?: unknown): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface NetworkAdapter {
|
||||
tcp_conn: Record<string, TCPConnection>;
|
||||
on_tcp_connection?: (packet: any, tuple: string) => boolean;
|
||||
router_mac: Uint8Array;
|
||||
router_ip: Uint8Array;
|
||||
}
|
||||
|
||||
interface V86 {
|
||||
bus: {
|
||||
register(name: string, fn: (arg: unknown) => void, ctx?: unknown): void;
|
||||
};
|
||||
network_adapter?: NetworkAdapter;
|
||||
}
|
||||
|
||||
const log = (...a: unknown[]) => console.log("[smb]", ...a);
|
||||
|
||||
export function setupSmbShare(emulator: V86, hostPath: string, toolsRoot?: string) {
|
||||
log(`serving ${hostPath} on \\\\HOST\\${shareNameFor(hostPath)} ` +
|
||||
`(+ \\\\HOST\\${TOOLS_SHARE}${toolsRoot ? ` ← ${toolsRoot}` : ""}) port 139`);
|
||||
|
||||
// SPIKE diagnostic: count every ethernet frame so we know if the NIC is
|
||||
// emitting anything at all (DHCP, ARP, anything). Logged on a timer so
|
||||
// we don't flood — and so the absence of a tick proves the bus is dead.
|
||||
let frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
|
||||
emulator.bus.register("net0-send", (raw: unknown) => {
|
||||
const f = raw as Uint8Array;
|
||||
frameStats.total++;
|
||||
if (f.length < 14) { frameStats.other++; return; }
|
||||
const et = (f[12] << 8) | f[13];
|
||||
if (et === 0x0806) frameStats.arp++;
|
||||
else if (et === 0x0800) {
|
||||
frameStats.ip++;
|
||||
const proto = f[14 + 9];
|
||||
if (proto === 6) frameStats.tcp++;
|
||||
else if (proto === 17) frameStats.udp++;
|
||||
} else frameStats.other++;
|
||||
});
|
||||
setInterval(() => {
|
||||
if (frameStats.total > 0) {
|
||||
log("frames:", JSON.stringify(frameStats));
|
||||
frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Win95 won't even try TCP 139 until UDP 137 answers a Node Status query
|
||||
setupNbns(emulator as Parameters<typeof setupNbns>[0]);
|
||||
|
||||
// ─── TCP 139 hook ───────────────────────────────────────────────────────
|
||||
// v86 has two APIs depending on age:
|
||||
// new (2025+): bus event "tcp-connection" with a pre-built conn
|
||||
// old (≤Feb 2025): adapter.on_tcp_connection(packet, tuple) callback
|
||||
// where we must construct TCPConnection ourselves
|
||||
// We can't `new TCPConnection()` directly (closure-scoped), so for the
|
||||
// old API we steal the constructor from the prototype of any existing
|
||||
// connection — which means we need a probe HTTP connection to fire first
|
||||
// (or we wait for one). The fetch adapter itself uses the constructor for
|
||||
// port 80, so as soon as anything in Win95 hits HTTP, we can steal it.
|
||||
|
||||
const wireConn = (conn: TCPConnection) => {
|
||||
log(`← TCP SYN ${conn.tuple}`);
|
||||
const framer = new NetBIOSFramer();
|
||||
const session = new SmbSession(hostPath, toolsRoot);
|
||||
|
||||
const handler = (data: Uint8Array) => {
|
||||
for (const msg of framer.push(data)) {
|
||||
if (msg.type === 0x81) {
|
||||
log("← NB session request → +response");
|
||||
conn.write(nbPositiveResponse());
|
||||
} else if (msg.type === 0x00) {
|
||||
const reply = session.handle(msg.payload);
|
||||
if (reply) conn.write(nbWrap(reply));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// New v86 has .on(); old v86 had .on/.emit dead-code-eliminated by
|
||||
// Closure into a flat .on_data callback property. Check for the method
|
||||
// first, fall back to direct assignment.
|
||||
if (typeof (conn as any).on === "function") {
|
||||
conn.on("data", handler);
|
||||
} else {
|
||||
(conn as any).on_data = handler;
|
||||
}
|
||||
};
|
||||
|
||||
// New API: bus event (no-op on old v86 — event never fires)
|
||||
emulator.bus.register("tcp-connection", (c: unknown) => {
|
||||
const conn = c as TCPConnection;
|
||||
if (conn.sport !== 139) return;
|
||||
wireConn(conn);
|
||||
conn.accept();
|
||||
});
|
||||
|
||||
// Old API: monkey-patch adapter.on_tcp_connection. The adapter is created
|
||||
// inside V86's async init, so poll for it.
|
||||
//
|
||||
// Instead of stealing the TCPConnection constructor (closure-scoped, brittle
|
||||
// with new-on-stolen-ctor), we make the original handler build one for us
|
||||
// by handing it a port-80 SYN — then RECONFIGURE that connection for 139.
|
||||
// accept(packet) overwrites every routing field (sport/dport/hsrc/etc), and
|
||||
// .on("data") overwrites the HTTP handler. The probe's fake SYN-ACK is eaten
|
||||
// by shadowing adapter.receive (prototype method — `delete` to restore).
|
||||
const tryHook = () => {
|
||||
const adapter = emulator.network_adapter;
|
||||
if (!adapter || typeof adapter.on_tcp_connection !== "function") return false;
|
||||
|
||||
const orig = adapter.on_tcp_connection.bind(adapter);
|
||||
adapter.on_tcp_connection = function (packet: any, tuple: string): boolean {
|
||||
if (packet.tcp.dport !== 139) return orig(packet, tuple);
|
||||
// New v86 fires the tcp-connection bus event BEFORE this callback;
|
||||
// if our bus handler already accepted the conn, it's in tcp_conn —
|
||||
// claim it so the original (which would otherwise RST) doesn't run.
|
||||
if (adapter.tcp_conn[tuple]) return true;
|
||||
|
||||
const adapterAny = adapter as any;
|
||||
adapterAny.receive = () => {};
|
||||
let conn: TCPConnection | undefined;
|
||||
try {
|
||||
const fakeTuple = "__nbt__";
|
||||
orig({ ...packet, tcp: { ...packet.tcp, dport: 80 } }, fakeTuple);
|
||||
conn = adapter.tcp_conn[fakeTuple];
|
||||
delete adapter.tcp_conn[fakeTuple];
|
||||
} finally {
|
||||
delete adapterAny.receive;
|
||||
}
|
||||
|
||||
if (!conn) {
|
||||
log("⚠ probe didn't yield a connection; RST");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-aim it at port 139. accept() overwrites sport/dport/hsrc/psrc/seq/ack
|
||||
// from the packet; .on("data") replaces the HTTP handler (assignment, not
|
||||
// push). Only state needs explicit reset — the probe accept set it to
|
||||
// "established" and we want a fresh handshake.
|
||||
conn.tuple = tuple;
|
||||
conn.state = "syn-received";
|
||||
wireConn(conn);
|
||||
try {
|
||||
conn.accept(packet);
|
||||
} catch (e) {
|
||||
log("accept threw:", e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
}
|
||||
adapter.tcp_conn[tuple] = conn;
|
||||
return true;
|
||||
};
|
||||
log("hooked adapter.on_tcp_connection (old API, conn-recycling)");
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!tryHook()) {
|
||||
const poll = setInterval(() => { if (tryHook()) clearInterval(poll); }, 100);
|
||||
setTimeout(() => clearInterval(poll), 10000);
|
||||
}
|
||||
}
|
||||
258
src/renderer/smb/nbns.ts
Normal file
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;
|
||||
}
|
||||
1461
src/renderer/smb/server.ts
Normal file
1461
src/renderer/smb/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
161
src/renderer/smb/smb.ts
Normal file
161
src/renderer/smb/smb.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// Minimal SMB1/CIFS implementation — just enough for Windows 95 to map a
|
||||
// drive and read files. Spec: [MS-CIFS] / [MS-SMB].
|
||||
//
|
||||
// SMB1 message = 32-byte header + word block + byte block.
|
||||
// Header is at a fixed offset; word/byte blocks vary by command.
|
||||
|
||||
import { Reader, Writer } from "./wire";
|
||||
|
||||
export const SMB_MAGIC = [0xff, 0x53, 0x4d, 0x42]; // \xFF SMB
|
||||
|
||||
// Commands we handle
|
||||
export const CMD_NEGOTIATE = 0x72;
|
||||
export const CMD_SESSION_SETUP_ANDX = 0x73;
|
||||
export const CMD_TREE_CONNECT_ANDX = 0x75;
|
||||
export const CMD_TREE_DISCONNECT = 0x71;
|
||||
export const CMD_LOGOFF_ANDX = 0x74;
|
||||
export const CMD_NT_CREATE_ANDX = 0xa2;
|
||||
export const CMD_OPEN_ANDX = 0x2d;
|
||||
export const CMD_READ = 0x0a;
|
||||
export const CMD_READ_RAW = 0x1a;
|
||||
export const CMD_READ_ANDX = 0x2e;
|
||||
export const CMD_SEEK = 0x12;
|
||||
export const CMD_CLOSE = 0x04;
|
||||
export const CMD_TRANSACTION = 0x25;
|
||||
export const CMD_TRANSACTION2 = 0x32;
|
||||
export const CMD_ECHO = 0x2b;
|
||||
export const CMD_QUERY_INFORMATION = 0x08;
|
||||
export const CMD_QUERY_INFORMATION2 = 0x23;
|
||||
export const CMD_FIND_CLOSE2 = 0x34;
|
||||
export const CMD_CHECK_DIRECTORY = 0x10;
|
||||
export const CMD_SEARCH = 0x81;
|
||||
|
||||
// TRANS2 subcommands
|
||||
export const TRANS2_FIND_FIRST2 = 0x01;
|
||||
export const TRANS2_FIND_NEXT2 = 0x02;
|
||||
export const TRANS2_QUERY_FS_INFO = 0x03;
|
||||
export const TRANS2_QUERY_PATH_INFO = 0x05;
|
||||
export const TRANS2_QUERY_FILE_INFO = 0x07;
|
||||
|
||||
// Status codes (DOS-style, not NT)
|
||||
export const STATUS_OK = 0x00000000;
|
||||
export const ERRDOS = 0x01;
|
||||
export const ERRSRV = 0x02;
|
||||
export const ERR_BADFILE = 0x0002; // file not found
|
||||
export const ERR_BADPATH = 0x0003; // path not found
|
||||
export const ERR_NOACCESS = 0x0005;
|
||||
export const ERR_BADFID = 0x0006;
|
||||
export const ERR_NOFILES = 0x0012; // no more files
|
||||
export const ERR_BADFUNC = 0x0001; // unsupported
|
||||
|
||||
// Flags
|
||||
const FLAGS_REPLY = 0x80;
|
||||
const FLAGS_CASELESS = 0x08;
|
||||
const FLAGS_CANONICAL = 0x10;
|
||||
|
||||
// Flags2 (we only echo LONG_NAMES; never claim NT_STATUS or UNICODE)
|
||||
const FLAGS2_LONG_NAMES = 0x0001;
|
||||
|
||||
export interface SmbHeader {
|
||||
cmd: number;
|
||||
status: number;
|
||||
flags: number;
|
||||
flags2: number;
|
||||
tid: number;
|
||||
pid: number;
|
||||
uid: number;
|
||||
mid: number;
|
||||
wordCount: number;
|
||||
words: Uint8Array; // raw parameter words (wordCount*2 bytes)
|
||||
byteCount: number;
|
||||
bytes: Uint8Array; // raw data bytes
|
||||
}
|
||||
|
||||
export function parseSmb(buf: Uint8Array): SmbHeader | null {
|
||||
if (buf.length < 33) return null;
|
||||
if (buf[0] !== 0xff || buf[1] !== 0x53 || buf[2] !== 0x4d || buf[3] !== 0x42) {
|
||||
return null;
|
||||
}
|
||||
const r = new Reader(buf, 4);
|
||||
const cmd = r.u8();
|
||||
const status = r.u32();
|
||||
const flags = r.u8();
|
||||
const flags2 = r.u16();
|
||||
r.skip(12); // PIDHigh(2) + SecurityFeatures(8) + Reserved(2)
|
||||
const tid = r.u16();
|
||||
const pid = r.u16();
|
||||
const uid = r.u16();
|
||||
const mid = r.u16();
|
||||
const wordCount = r.u8();
|
||||
const words = r.bytes(wordCount * 2);
|
||||
const byteCount = r.u16();
|
||||
const bytes = r.bytes(byteCount);
|
||||
return { cmd, status, flags, flags2, tid, pid, uid, mid, wordCount, words, byteCount, bytes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SMB1 reply. The reply echoes tid/pid/uid/mid from the request and
|
||||
* sets the reply flag. Status uses DOS error class/code in the low bytes
|
||||
* (we don't set FLAGS2_NT_STATUS).
|
||||
*/
|
||||
export function buildSmb(
|
||||
req: SmbHeader,
|
||||
cmd: number,
|
||||
status: number,
|
||||
words: Uint8Array,
|
||||
bytes: Uint8Array,
|
||||
overrides?: { tid?: number; uid?: number; flags2?: number }
|
||||
): Uint8Array {
|
||||
const w = new Writer();
|
||||
w.bytes(SMB_MAGIC);
|
||||
w.u8(cmd);
|
||||
w.u32(status);
|
||||
w.u8(FLAGS_REPLY | FLAGS_CASELESS | FLAGS_CANONICAL);
|
||||
// mirror long-name capability so the client keeps sending long names; never
|
||||
// claim NT status or unicode (we reply in ASCII)
|
||||
w.u16((overrides?.flags2 ?? req.flags2) & FLAGS2_LONG_NAMES);
|
||||
w.zero(12);
|
||||
w.u16(overrides?.tid ?? req.tid);
|
||||
w.u16(req.pid);
|
||||
w.u16(overrides?.uid ?? req.uid);
|
||||
w.u16(req.mid);
|
||||
if (words.length % 2 !== 0) throw new Error("word block must be even");
|
||||
w.u8(words.length / 2);
|
||||
w.bytes(words);
|
||||
w.u16(bytes.length);
|
||||
w.bytes(bytes);
|
||||
return w.build();
|
||||
}
|
||||
|
||||
export function dosError(errClass: number, errCode: number): number {
|
||||
// DOS-style: byte 0 = class, byte 1 = reserved, bytes 2-3 = code (LE)
|
||||
return errClass | (errCode << 16);
|
||||
}
|
||||
|
||||
/** AndX: most replies have a 4-byte AndX header at the start of words */
|
||||
export function andxNone(): number[] {
|
||||
return [0xff, 0x00, 0x00, 0x00]; // AndXCommand=0xFF (none), reserved, offset=0
|
||||
}
|
||||
|
||||
export const cmdName: Record<number, string> = {
|
||||
[CMD_NEGOTIATE]: "NEGOTIATE",
|
||||
[CMD_SESSION_SETUP_ANDX]: "SESSION_SETUP",
|
||||
[CMD_TREE_CONNECT_ANDX]: "TREE_CONNECT",
|
||||
[CMD_TREE_DISCONNECT]: "TREE_DISCONNECT",
|
||||
[CMD_LOGOFF_ANDX]: "LOGOFF",
|
||||
[CMD_NT_CREATE_ANDX]: "NT_CREATE",
|
||||
[CMD_OPEN_ANDX]: "OPEN",
|
||||
[CMD_READ_ANDX]: "READ",
|
||||
[CMD_READ_RAW]: "READ_RAW",
|
||||
[CMD_READ]: "READ",
|
||||
[CMD_SEEK]: "SEEK",
|
||||
[CMD_CLOSE]: "CLOSE",
|
||||
[CMD_TRANSACTION]: "TRANS(RAP)",
|
||||
[CMD_TRANSACTION2]: "TRANS2",
|
||||
[CMD_ECHO]: "ECHO",
|
||||
[CMD_QUERY_INFORMATION]: "QUERY_INFO",
|
||||
[CMD_QUERY_INFORMATION2]: "QUERY_INFO2",
|
||||
[CMD_FIND_CLOSE2]: "FIND_CLOSE2",
|
||||
[CMD_CHECK_DIRECTORY]: "CHECK_DIR",
|
||||
[CMD_SEARCH]: "SEARCH",
|
||||
};
|
||||
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); }
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StartMenuProps {
|
||||
navigate: (to: string) => void;
|
||||
}
|
||||
|
||||
export class StartMenu extends React.Component<StartMenuProps, {}> {
|
||||
constructor(props: StartMenuProps) {
|
||||
super(props);
|
||||
|
||||
this.navigate = this.navigate.bind(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<nav className="nav nav-bottom">
|
||||
<a onClick={this.navigate} href="#" id="start" className="nav-link">
|
||||
<img src="../../static/start.png" alt="Start" />
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<div className="nav-menu">
|
||||
<a
|
||||
onClick={this.navigate}
|
||||
href="#"
|
||||
id="settings"
|
||||
className="nav-link"
|
||||
>
|
||||
<img src="../../static/settings.png" />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
private navigate(event: React.SyntheticEvent<HTMLAnchorElement>) {
|
||||
this.props.navigate(event.currentTarget.id);
|
||||
}
|
||||
}
|
||||
1
src/renderer/styles.d.ts
vendored
Normal file
1
src/renderer/styles.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
50
src/renderer/sync-file-buffer.ts
Normal file
50
src/renderer/sync-file-buffer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
/**
|
||||
* v86 disk buffer backed by synchronous fs reads.
|
||||
*
|
||||
* v86's stock async loaders (AsyncXHRBuffer / AsyncFileBuffer) return from
|
||||
* .get() immediately and resolve the data on a later event-loop turn. For an
|
||||
* ATAPI PIO READ(10) that means atapi_read() leaves the drive in BSY while the
|
||||
* emulated CPU keeps running. Win95's ESDI_506/CDVSD path checks status twice
|
||||
* after pushing the CDB, sees BSY both times, and issues DEVICE RESET (08h) —
|
||||
* which cancels the in-flight read. Net effect: D: shows up but the volume
|
||||
* never mounts. Serving the bytes synchronously closes that window.
|
||||
*
|
||||
* The hard disk doesn't hit this because ESDI_506 drives it via bus-master
|
||||
* DMA, which is purely IRQ-driven on the host side.
|
||||
*/
|
||||
export class SyncFileBuffer {
|
||||
public byteLength: number;
|
||||
public onload: undefined | ((e: { buffer?: ArrayBuffer }) => void);
|
||||
public onprogress: undefined | (() => void);
|
||||
|
||||
private fd: number;
|
||||
|
||||
constructor(path: string) {
|
||||
this.fd = fs.openSync(path, "r");
|
||||
this.byteLength = fs.fstatSync(this.fd).size;
|
||||
this.onload = undefined;
|
||||
this.onprogress = undefined;
|
||||
}
|
||||
|
||||
load() {
|
||||
this.onload?.({});
|
||||
}
|
||||
|
||||
get(start: number, len: number, fn: (data: Uint8Array) => void) {
|
||||
const buf = Buffer.alloc(len);
|
||||
fs.readSync(this.fd, buf, 0, len, start);
|
||||
fn(new Uint8Array(buf.buffer, buf.byteOffset, len));
|
||||
}
|
||||
|
||||
set(_start: number, _slice: Uint8Array, fn: () => void) {
|
||||
fn();
|
||||
}
|
||||
|
||||
get_state() {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
set_state(_state: unknown) {}
|
||||
}
|
||||
132
src/renderer/utils/fat32-extract.ts
Normal file
132
src/renderer/utils/fat32-extract.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
/**
|
||||
* Minimal read-only FAT32 walker. Just enough to pull user files out of the
|
||||
* recovered overlay+base view — no writes, no FAT12/16, no exFAT.
|
||||
*
|
||||
* `readSector(lba)` returns 512 bytes at absolute LBA from the *full disk*
|
||||
* (MBR at LBA 0). `isDirty(lba)` reports whether that sector came from the
|
||||
* guest's write overlay; we use it to skip files the user never touched so
|
||||
* the output isn't 200 MB of possibly-mismatched OS binaries.
|
||||
*/
|
||||
export async function extractFat32(
|
||||
readSector: (lba: number) => Buffer,
|
||||
isDirty: (lba: number) => boolean,
|
||||
outDir: string,
|
||||
): Promise<number> {
|
||||
// First partition from the MBR.
|
||||
const mbr = readSector(0);
|
||||
const partLba = mbr.readUInt32LE(0x1be + 8);
|
||||
|
||||
const bpb = readSector(partLba);
|
||||
const bytesPerSec = bpb.readUInt16LE(11);
|
||||
const secPerClus = bpb.readUInt8(13);
|
||||
const rsvd = bpb.readUInt16LE(14);
|
||||
const nFats = bpb.readUInt8(16);
|
||||
const secPerFat = bpb.readUInt32LE(36);
|
||||
const rootClus = bpb.readUInt32LE(44);
|
||||
if (bytesPerSec !== 512) throw new Error("unexpected sector size");
|
||||
|
||||
const fatLba = partLba + rsvd;
|
||||
const dataLba = partLba + rsvd + nFats * secPerFat;
|
||||
const clusLba = (c: number) => dataLba + (c - 2) * secPerClus;
|
||||
|
||||
const fatSecCache = new Map<number, Buffer>();
|
||||
const nextCluster = (c: number) => {
|
||||
const off = c * 4;
|
||||
const sec = fatLba + (off >> 9);
|
||||
let b = fatSecCache.get(sec);
|
||||
if (!b) fatSecCache.set(sec, (b = readSector(sec)));
|
||||
return b.readUInt32LE(off & 511) & 0x0fffffff;
|
||||
};
|
||||
|
||||
const chain = (c: number) => {
|
||||
const out: number[] = [];
|
||||
while (c >= 2 && c < 0x0ffffff8 && out.length < 1 << 20) {
|
||||
out.push(c);
|
||||
c = nextCluster(c);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readClusters = (clusters: number[]) => {
|
||||
const buf = Buffer.allocUnsafe(clusters.length * secPerClus * 512);
|
||||
let o = 0;
|
||||
for (const c of clusters) {
|
||||
const base = clusLba(c);
|
||||
for (let s = 0; s < secPerClus; s++)
|
||||
readSector(base + s).copy(buf, o + s * 512);
|
||||
o += secPerClus * 512;
|
||||
}
|
||||
return buf;
|
||||
};
|
||||
|
||||
const anyDirty = (clusters: number[]) => {
|
||||
for (const c of clusters) {
|
||||
const base = clusLba(c);
|
||||
for (let s = 0; s < secPerClus; s++) if (isDirty(base + s)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const safe = (n: string) =>
|
||||
n.replace(/[\\/:*?"<>|]/g, "_").replace(/[. ]+$/, "") || "_";
|
||||
|
||||
let files = 0;
|
||||
const walk = async (clus: number, hostDir: string) => {
|
||||
const raw = readClusters(chain(clus));
|
||||
let lfn = "";
|
||||
for (let i = 0; i + 32 <= raw.length; i += 32) {
|
||||
const e = raw.subarray(i, i + 32);
|
||||
if (e[0] === 0) break;
|
||||
if (e[0] === 0xe5) {
|
||||
lfn = "";
|
||||
continue;
|
||||
}
|
||||
const attr = e[11];
|
||||
if ((attr & 0x3f) === 0x0f) {
|
||||
// VFAT LFN entries arrive last-first; each carries 13 UCS-2 chars.
|
||||
let part = "";
|
||||
for (const o of [1, 3, 5, 7, 9, 14, 16, 18, 20, 22, 24, 28, 30]) {
|
||||
const ch = e.readUInt16LE(o);
|
||||
if (ch === 0 || ch === 0xffff) break;
|
||||
part += String.fromCharCode(ch);
|
||||
}
|
||||
lfn = part + lfn;
|
||||
continue;
|
||||
}
|
||||
if (attr & 0x08) {
|
||||
lfn = "";
|
||||
continue; // volume label
|
||||
}
|
||||
const short =
|
||||
e.toString("latin1", 0, 8).trimEnd() +
|
||||
(e[8] !== 0x20
|
||||
? "." + e.toString("latin1", 8, 11).trimEnd()
|
||||
: "");
|
||||
const name = lfn || short;
|
||||
lfn = "";
|
||||
if (name === "." || name === "..") continue;
|
||||
const start = (e.readUInt16LE(20) << 16) | e.readUInt16LE(26);
|
||||
if (attr & 0x10) {
|
||||
if (start >= 2) await walk(start, path.join(hostDir, safe(name)));
|
||||
} else {
|
||||
const size = e.readUInt32LE(28);
|
||||
if (size === 0 || start < 2) continue;
|
||||
const cl = chain(start);
|
||||
if (!anyDirty(cl)) continue;
|
||||
await fs.promises.mkdir(hostDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(hostDir, safe(name)),
|
||||
readClusters(cl).subarray(0, size),
|
||||
);
|
||||
files++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await fs.promises.mkdir(outDir, { recursive: true });
|
||||
await walk(rootClus, outDir);
|
||||
return files;
|
||||
}
|
||||
@@ -11,3 +11,7 @@ export async function getStatePath(): Promise<string> {
|
||||
const statePath = await ipcRenderer.invoke(IPC_COMMANDS.GET_STATE_PATH);
|
||||
return (_statePath = statePath);
|
||||
}
|
||||
|
||||
export function getLegacyStatePath(): Promise<string | null> {
|
||||
return ipcRenderer.invoke(IPC_COMMANDS.GET_LEGACY_STATE_PATH);
|
||||
}
|
||||
|
||||
88
src/renderer/utils/recover-legacy-disk.ts
Normal file
88
src/renderer/utils/recover-legacy-disk.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
import { CONSTANTS } from "../../constants";
|
||||
import { getDiskImageSize } from "../../utils/disk-image-size";
|
||||
import { extractFat32 } from "./fat32-extract";
|
||||
|
||||
declare const V86: any;
|
||||
|
||||
/**
|
||||
* Reconstruct the user's old C:\ from a legacy state-vN.bin and extract any
|
||||
* file the guest ever wrote to a host folder — without booting Windows.
|
||||
*
|
||||
* v86's async-hda buffer serialises every 256-byte block the guest *wrote*
|
||||
* (libv86 xa.get_state). We spin up a throwaway v86 (autostart:false),
|
||||
* restore the legacy state to populate that block cache, then walk the
|
||||
* FAT32 tree reading each sector as overlay-if-present-else-base.
|
||||
*
|
||||
* The base supplies the partition table / BPB (which Windows only reads,
|
||||
* never writes) — see STATE_VERSION in constants.ts for the geometry
|
||||
* constraint that keeps that valid across releases.
|
||||
*/
|
||||
export async function recoverLegacyDisk(
|
||||
legacyStatePath: string,
|
||||
outDir: string,
|
||||
): Promise<{ dir: string; files: number }> {
|
||||
const emulator = new V86({
|
||||
wasm_path: path.join(__dirname, "build/v86.wasm"),
|
||||
memory_size: 128 * 1024 * 1024,
|
||||
vga_memory_size: 64 * 1024 * 1024,
|
||||
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),
|
||||
},
|
||||
autostart: false,
|
||||
disable_keyboard: true,
|
||||
disable_mouse: true,
|
||||
disable_speaker: true,
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
emulator.add_listener("emulator-loaded", resolve),
|
||||
);
|
||||
|
||||
let files = 0;
|
||||
const baseFd = fs.openSync(CONSTANTS.IMAGE_PATH, "r");
|
||||
try {
|
||||
const state = fs.readFileSync(legacyStatePath);
|
||||
await emulator.restore_state(state.buffer);
|
||||
|
||||
const buf = emulator.v86?.cpu?.devices?.ide?.primary?.master?.buffer as {
|
||||
block_cache: Map<number, Uint8Array>;
|
||||
block_cache_is_write: Set<number>;
|
||||
};
|
||||
if (!buf?.block_cache) {
|
||||
throw new Error("hda block cache not reachable after restore");
|
||||
}
|
||||
|
||||
// v86 caches in 256-byte blocks; FAT works in 512-byte sectors.
|
||||
const sec = Buffer.allocUnsafe(512);
|
||||
const readSector = (lba: number) => {
|
||||
const lo = buf.block_cache_is_write.has(lba * 2)
|
||||
? buf.block_cache.get(lba * 2)
|
||||
: undefined;
|
||||
const hi = buf.block_cache_is_write.has(lba * 2 + 1)
|
||||
? buf.block_cache.get(lba * 2 + 1)
|
||||
: undefined;
|
||||
if (lo && hi) return Buffer.concat([lo, hi]);
|
||||
fs.readSync(baseFd, sec, 0, 512, lba * 512);
|
||||
if (lo) sec.set(lo, 0);
|
||||
if (hi) sec.set(hi, 256);
|
||||
return Buffer.from(sec);
|
||||
};
|
||||
const isDirty = (lba: number) =>
|
||||
buf.block_cache_is_write.has(lba * 2) ||
|
||||
buf.block_cache_is_write.has(lba * 2 + 1);
|
||||
|
||||
files = await extractFat32(readSector, isDirty, outDir);
|
||||
} finally {
|
||||
fs.closeSync(baseFd);
|
||||
await emulator.destroy();
|
||||
}
|
||||
|
||||
return { dir: outDir, files };
|
||||
}
|
||||
@@ -5,12 +5,11 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>windows95</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="../src/less/vendor/95css.css">
|
||||
<link rel="stylesheet" href="../src/less/root.less">
|
||||
<link rel="stylesheet" href="../renderer.css">
|
||||
<!-- libv86 -->
|
||||
</head>
|
||||
<body class="paused windows95">
|
||||
<div id="app"></div>
|
||||
<script src="../src/renderer/app.tsx"></script>
|
||||
<script src="../renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,7 +11,7 @@
|
||||
<hr>
|
||||
|
||||
<p>I've installed a few apps and games for you to try out. Check out the Games folder on the desktop!</p>
|
||||
<p>If you want to try other games, I recommend trying to find them on the Internet Archive. On your host computer, visit https://archive.org, then find the "Classic PC Games" category. Once downloaded, you can import them into windows95 from <a href="http://my-computer">your host's Download folder</a>.</p>
|
||||
<p>If you want to try other games, I recommend trying to find them on the Internet Archive. On your host computer, visit https://archive.org, then find the "Classic PC Games" category. Once downloaded, you can import them into windows95 from drive <b>Z:</b> in <b>My Computer</b> — that's the shared folder from your host machine.</p>
|
||||
</font>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
<font face="Arial" color="#000000">
|
||||
<p>Hi, I'm Felix, the maker behind windows95. I hope you're having fun!</p>
|
||||
|
||||
<p>Reach out to me in a modern browser (as in: not in windows95) on <font color="#0000FF">felixrieseberg.com</font> or find me on Bluesky at <font color="#0000FF">@felixrieseberg</font>.</p>
|
||||
<p>Reach out to me in a modern browser (as in: not in windows95) on <font color="#0000FF">felixrieseberg.com</font> or find me on Twitter at <font color="#0000FF">@felixrieseberg</font>.</p>
|
||||
|
||||
<hr width="75%">
|
||||
<a name="internet"></a>
|
||||
<font size="5" color="#000000"><img src="images/ie.gif" width="16" height="16" border="0" align="absmiddle"> <b>The Internet!</b></font>
|
||||
<hr width="75%">
|
||||
|
||||
<p>In a major update since the last version, windows95 now has working Internet! That said, most modern websites will not work, so brace yourself. I recommend using <a href="http://theoldnet.com/" target="_blank">The Old Net</a> to travel back in time.</p>
|
||||
<p>windows95 has working Internet! That said, most modern websites will not work, so brace yourself. I recommend using <a href="http://theoldnet.com/" target="_blank">The Old Net</a> to travel back in time.</p>
|
||||
</font>
|
||||
|
||||
<center>
|
||||
<font size="2" color="#000000">Last updated: 2025</font>
|
||||
<font size="2" color="#000000">Last updated: 2026</font>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
80
tools/bisect-v86.sh
Executable file
80
tools/bisect-v86.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
# Bisect harness: checkout v86 to a commit, rebuild wasm, probe boot.
|
||||
# Logs to /tmp/win95-bisect.log
|
||||
#
|
||||
# Usage:
|
||||
# tools/bisect-v86.sh <commit-ish> # test one commit
|
||||
# tools/bisect-v86.sh <commit-ish> '{"acpi":false}' # with options
|
||||
|
||||
set -e
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
V86="${V86_DIR:-$ROOT/../v86}"
|
||||
LOG=/tmp/win95-bisect.log
|
||||
|
||||
COMMIT="$1"
|
||||
OPTS="${2:-{}}"
|
||||
|
||||
[ -z "$COMMIT" ] && { echo "usage: $0 <commit> [opts-json]"; exit 1; }
|
||||
|
||||
cd "$V86"
|
||||
SAVED_HEAD=$(git rev-parse HEAD)
|
||||
trap "cd '$V86' && git checkout -q '$SAVED_HEAD' 2>/dev/null" EXIT
|
||||
|
||||
echo "─── checkout $COMMIT ───"
|
||||
git checkout -q "$COMMIT" 2>&1 | head -3
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
SUBJ=$(git log -1 --format='%s' | head -c 60)
|
||||
DATE=$(git log -1 --format='%ci' | cut -d' ' -f1)
|
||||
|
||||
export PATH="/opt/homebrew/opt/openjdk/bin:$PATH"
|
||||
|
||||
echo "─── build wasm + libv86.js @ $HASH ($DATE) ───"
|
||||
rm -f build/v86.wasm build/libv86.js
|
||||
make build/v86.wasm 2>&1 | tail -3
|
||||
[ -f build/v86.wasm ] || { echo "WASM BUILD FAILED"; exit 1; }
|
||||
make build/libv86.js 2>&1 | tail -3
|
||||
[ -f build/libv86.js ] || { echo "LIBV86 BUILD FAILED"; exit 1; }
|
||||
|
||||
WASM_SIZE=$(stat -f%z build/v86.wasm)
|
||||
JS_SIZE=$(stat -f%z build/libv86.js)
|
||||
|
||||
cp build/v86.wasm "$ROOT/src/renderer/lib/build/v86.wasm"
|
||||
cp build/libv86.js "$ROOT/src/renderer/lib/libv86.js"
|
||||
|
||||
# Re-apply phantom-slave patch (it's a v86 bug from May 2025 onwards;
|
||||
# harmless before that since the pattern won't match)
|
||||
node -e '
|
||||
const fs=require("fs");
|
||||
let s=fs.readFileSync(process.argv[1],"utf8");
|
||||
const re=/(\w+)\[0\]\[1\]=\{buffer:(\w+)\.hdb\}/g;
|
||||
const n=[...s.matchAll(re)].length;
|
||||
if(n===1){s=s.replace(re,"$2.hdb&&($1[0][1]={buffer:$2.hdb})");fs.writeFileSync(process.argv[1],s);console.log("phantom-slave: patched")}
|
||||
else console.log("phantom-slave: skip ("+n+" matches)");
|
||||
' "$ROOT/src/renderer/lib/libv86.js"
|
||||
|
||||
# Win95 has sporadic bluescreens on all v86 versions — a single FAIL doesn't
|
||||
# mean the commit is bad. Probe up to 3 times; one SUCCESS = good commit.
|
||||
echo "─── probe (up to 3 attempts) ───"
|
||||
cd "$ROOT"
|
||||
VERDICT="UNKNOWN"
|
||||
for ATTEMPT in 1 2 3; do
|
||||
echo " attempt $ATTEMPT/3"
|
||||
set +e
|
||||
tools/probe-boot.sh "$OPTS" 2>&1 | tee /tmp/win95-probe-out.log | tail -10
|
||||
set -e
|
||||
V=$(cat /tmp/win95-probe.done 2>/dev/null || echo "UNKNOWN")
|
||||
if [ "$V" = "SUCCESS" ]; then
|
||||
VERDICT="SUCCESS"
|
||||
break
|
||||
fi
|
||||
VERDICT="$V" # keep the last failure mode
|
||||
[ "$ATTEMPT" -lt 3 ] && sleep 3
|
||||
done
|
||||
GFX=$(python3 -c "import json;s=json.load(open('/tmp/win95-probe.json'));print(f\"{s.get('gfxW',0)}x{s.get('gfxH',0)} {s.get('dominantColor','')}\")" 2>/dev/null || echo "?")
|
||||
|
||||
LINE="$HASH $DATE | wasm=${WASM_SIZE} opts=$OPTS | $VERDICT $GFX | $SUBJ"
|
||||
echo "$LINE" >> "$LOG"
|
||||
echo ""
|
||||
echo "═══ $LINE ═══"
|
||||
|
||||
exit $RESULT
|
||||
@@ -11,13 +11,24 @@ async function main() {
|
||||
let failed = false
|
||||
|
||||
for (const link of links) {
|
||||
// Release download URLs are chicken-and-egg: README is updated to point at
|
||||
// the new version before the release build that creates those assets has
|
||||
// run (and lint gates that build). Skip them.
|
||||
if (/\/releases\/download\//.test(link)) {
|
||||
console.log(`⏭️ ${link} (release asset, skipped)`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const response = await fetch(link, { method: 'HEAD' })
|
||||
|
||||
if (!response.ok) {
|
||||
// If we're inside GitHub's release asset server, we just ran into AWS not allowing
|
||||
// HEAD requests, which is different from a 404.
|
||||
if (!response.url.startsWith('https://github-production-release-asset')) {
|
||||
// GitHub's release-asset and user-attachments CDNs reject anonymous HEAD
|
||||
// requests (403), which is different from a 404.
|
||||
const isGithubCdn =
|
||||
response.url.startsWith('https://github-production-release-asset') ||
|
||||
response.url.startsWith('https://github-production-user-asset') ||
|
||||
link.startsWith('https://github.com/user-attachments/')
|
||||
if (!isGithubCdn) {
|
||||
throw new Error (`HTTP Error Response: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
mkdir images
|
||||
cd images
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$wc = New-Object System.Net.WebClient
|
||||
$wc.DownloadFile($env:DISK_URL, "$(Resolve-Path .)\images.zip")
|
||||
# Pulls the disk image from a private GitHub release.
|
||||
# Requires DISK_REPO, DISK_TAG, GH_TOKEN.
|
||||
|
||||
if (-not $env:DISK_REPO) { Write-Host "::error::DISK_REPO not set"; exit 1 }
|
||||
if (-not $env:DISK_TAG) { Write-Host "::error::DISK_TAG not set"; exit 1 }
|
||||
if (-not $env:GH_TOKEN) { Write-Host "::error::GH_TOKEN not set"; exit 1 }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path images | Out-Null
|
||||
Set-Location images
|
||||
|
||||
gh release download $env:DISK_TAG -R $env:DISK_REPO -p '*.zip' -O images.zip --clobber
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
7z t images.zip | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$size = (Get-Item images.zip).Length
|
||||
Write-Host "::error::Downloaded file is not a valid zip (size: $size bytes)."
|
||||
exit 1
|
||||
}
|
||||
|
||||
7z x images.zip -y -aoa
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
Remove-Item images.zip
|
||||
Remove-Item __MACOSX -Recurse -ErrorAction Ignore
|
||||
cd ..
|
||||
Tree ./ /F
|
||||
Set-Location ..
|
||||
|
||||
if (-not (Test-Path images/windows95.img)) {
|
||||
Write-Host "::error::images/windows95.img not found after extraction"
|
||||
Get-ChildItem images
|
||||
exit 1
|
||||
}
|
||||
|
||||
Get-ChildItem images
|
||||
|
||||
35
tools/download-disk.sh
Normal file → Executable file
35
tools/download-disk.sh
Normal file → Executable file
@@ -1,10 +1,35 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Pulls the disk image from a private GitHub release.
|
||||
# Requires:
|
||||
# DISK_REPO - e.g. felixrieseberg/windows95-images
|
||||
# DISK_TAG - e.g. v5
|
||||
# GH_TOKEN - a token with read access to DISK_REPO (set by the workflow)
|
||||
|
||||
: "${DISK_REPO:?DISK_REPO not set}"
|
||||
: "${DISK_TAG:?DISK_TAG not set}"
|
||||
: "${GH_TOKEN:?GH_TOKEN not set}"
|
||||
|
||||
mkdir -p ./images
|
||||
cd ./images
|
||||
wget -O images.zip $DISK_URL
|
||||
|
||||
gh release download "$DISK_TAG" -R "$DISK_REPO" -p '*.zip' -O images.zip --clobber
|
||||
|
||||
if ! unzip -tq images.zip > /dev/null; then
|
||||
echo "::error::Downloaded file is not a valid zip (size: $(wc -c < images.zip) bytes)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
unzip -o images.zip
|
||||
rm images.zip
|
||||
rm -r __MACOSX
|
||||
rm -f images.zip
|
||||
rm -rf __MACOSX
|
||||
cd -
|
||||
ls images
|
||||
|
||||
if [ ! -f images/windows95.img ]; then
|
||||
echo "::error::images/windows95.img not found after extraction"
|
||||
ls -la images/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ls -la images/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* tslint:disable */
|
||||
|
||||
const { compileParcel } = require('./parcel-build')
|
||||
const { compileVite } = require('./vite-build')
|
||||
|
||||
module.exports = async () => {
|
||||
await Promise.all([compileParcel()])
|
||||
await compileVite()
|
||||
}
|
||||
|
||||
9
tools/pack-disk.sh
Executable file
9
tools/pack-disk.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
# Produce the images zip that CI's DISK_URL secret should point at.
|
||||
# Inverse of download-disk.sh: flat archive of windows95.img + default-state.bin.
|
||||
set -e
|
||||
OUT="${1:-images_$(date +%Y%m%d).zip}"
|
||||
cd images
|
||||
zip -9 "../$OUT" windows95.img default-state.bin
|
||||
cd -
|
||||
ls -lh "$OUT"
|
||||
@@ -1,74 +0,0 @@
|
||||
/* tslint:disable */
|
||||
|
||||
const Bundler = require('parcel-bundler')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
async function copyLib() {
|
||||
const target = path.join(__dirname, '../dist/static')
|
||||
const lib = path.join(__dirname, '../src/renderer/lib')
|
||||
const index = path.join(target, 'index.html')
|
||||
|
||||
// Copy in lib
|
||||
await fs.promises.cp(lib, target, { recursive: true });
|
||||
|
||||
// Patch so that fs.read is used
|
||||
const libv86path = path.join(target, 'libv86.js')
|
||||
const libv86 = fs.readFileSync(libv86path, 'utf-8')
|
||||
|
||||
let patchedLibv86 = libv86.replace('k.load_file="undefined"===typeof XMLHttpRequest?pa:qa', 'k.load_file=pa')
|
||||
patchedLibv86 = patchedLibv86.replace('H.exportSymbol=function(a,b){"undefined"!==typeof module&&"undefined"!==typeof module.exports?module.exports[a]=b:"undefined"!==typeof window?window[a]=b:"function"===typeof importScripts&&(self[a]=b)}', 'H.exportSymbol=function(a,b){"undefined"!==typeof window?window[a]=b:"undefined"!==typeof module&&"undefined"!==typeof module.exports?module.exports[a]=b:"function"===typeof importScripts&&(self[a]=b)}')
|
||||
patchedLibv86 = patchedLibv86.replace('this.fetch=fetch;', 'this.fetch=(...args)=>fetch(...args);')
|
||||
|
||||
fs.writeFileSync(libv86path, patchedLibv86)
|
||||
|
||||
// Overwrite
|
||||
const indexContents = fs.readFileSync(index, 'utf-8');
|
||||
const replacedContents = indexContents.replace('<!-- libv86 -->', '<script src="libv86.js"></script>')
|
||||
fs.writeFileSync(index, replacedContents)
|
||||
}
|
||||
|
||||
async function compileParcel (options = {}) {
|
||||
const entryFiles = [
|
||||
path.join(__dirname, '../static/index.html'),
|
||||
path.join(__dirname, '../src/main/main.ts')
|
||||
]
|
||||
|
||||
const bundlerOptions = {
|
||||
outDir: './dist', // The out directory to put the build files in, defaults to dist
|
||||
outFile: undefined, // The name of the outputFile
|
||||
publicUrl: '../', // The url to server on, defaults to dist
|
||||
watch: false, // whether to watch the files and rebuild them on change, defaults to process.env.NODE_ENV !== 'production'
|
||||
cache: false, // Enabled or disables caching, defaults to true
|
||||
cacheDir: '.cache', // The directory cache gets put in, defaults to .cache
|
||||
contentHash: false, // Disable content hash from being included on the filename
|
||||
minify: false, // Minify files, enabled if process.env.NODE_ENV === 'production'
|
||||
scopeHoist: false, // turn on experimental scope hoisting/tree shaking flag, for smaller production bundles
|
||||
target: 'electron', // browser/node/electron, defaults to browser
|
||||
// https: { // Define a custom {key, cert} pair, use true to generate one or false to use http
|
||||
// cert: './ssl/c.crt', // path to custom certificate
|
||||
// key: './ssl/k.key' // path to custom key
|
||||
// },
|
||||
logLevel: 3, // 3 = log everything, 2 = log warnings & errors, 1 = log errors
|
||||
hmr: false, // Enable or disable HMR while watching
|
||||
hmrPort: 0, // The port the HMR socket runs on, defaults to a random free port (0 in node.js resolves to a random free port)
|
||||
sourceMaps: false, // Enable or disable sourcemaps, defaults to enabled (minified builds currently always create sourcemaps)
|
||||
hmrHostname: '', // A hostname for hot module reload, default to ''
|
||||
detailedReport: false, // Prints a detailed report of the bundles, assets, filesizes and times, defaults to false, reports are only printed if watch is disabled,
|
||||
...options
|
||||
}
|
||||
|
||||
const bundler = new Bundler(entryFiles, bundlerOptions)
|
||||
|
||||
// Run the bundler, this returns the main bundle
|
||||
// Use the events if you're using watch mode as this promise will only trigger once and not for every rebuild
|
||||
await bundler.bundle()
|
||||
|
||||
await copyLib();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compileParcel
|
||||
}
|
||||
|
||||
if (require.main === module) compileParcel()
|
||||
@@ -1,11 +0,0 @@
|
||||
const { compileParcel } = require('./parcel-build')
|
||||
|
||||
async function watchParcel () {
|
||||
return compileParcel({ watch: true })
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
watchParcel
|
||||
}
|
||||
|
||||
if (require.main === module) watchParcel()
|
||||
76
tools/probe-boot.sh
Executable file
76
tools/probe-boot.sh
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
# Single boot probe: build → launch → wait for verdict → kill → report.
|
||||
# Usage: tools/probe-boot.sh [json-options]
|
||||
# tools/probe-boot.sh '{"acpi":false}'
|
||||
# tools/probe-boot.sh '{"disable_jit":true}'
|
||||
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
OPTS="${1:-{}}"
|
||||
STATUS=/tmp/win95-probe.json
|
||||
DONE=/tmp/win95-probe.done
|
||||
SCREEN=/tmp/win95-screen.png
|
||||
TIMEOUT=200
|
||||
|
||||
echo "═══ probe: opts=$OPTS ═══"
|
||||
|
||||
# clean slate
|
||||
rm -f "$STATUS" "$DONE" "$SCREEN"
|
||||
pkill -f "windows95/node_modules/electron" 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# build (vite only — forge's generateAssets does this too but we want
|
||||
# direct control without the forge startup overhead)
|
||||
rm -rf dist
|
||||
node tools/vite-build.js > /tmp/win95-build.log 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "BUILD FAILED"
|
||||
tail -20 /tmp/win95-build.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# launch electron directly (skip forge to avoid double-build)
|
||||
WIN95_PROBE=1 WIN95_PROBE_OPTS="$OPTS" \
|
||||
./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 &
|
||||
PID=$!
|
||||
echo "electron pid=$PID, waiting for verdict (timeout ${TIMEOUT}s)..."
|
||||
|
||||
# poll
|
||||
for i in $(seq 1 $TIMEOUT); do
|
||||
if [ -f "$DONE" ]; then
|
||||
VERDICT=$(cat "$DONE")
|
||||
echo "verdict at ${i}s: $VERDICT"
|
||||
break
|
||||
fi
|
||||
if ! kill -0 $PID 2>/dev/null; then
|
||||
echo "electron died at ${i}s"
|
||||
tail -30 /tmp/win95-electron.log
|
||||
VERDICT="CRASHED"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -z "$VERDICT" ]; then
|
||||
echo "TIMEOUT at ${TIMEOUT}s"
|
||||
VERDICT="TIMEOUT"
|
||||
fi
|
||||
|
||||
# capture final state
|
||||
echo "─── final status ───"
|
||||
[ -f "$STATUS" ] && python3 -c "
|
||||
import json
|
||||
s=json.load(open('$STATUS'))
|
||||
print(f\"phase={s['phase']} cpu={s['cpuRunning']} instr_delta={s['instructionDelta']:,}\")
|
||||
print(f\"uptime={s['uptimeSec']}s\")
|
||||
t=s['textScreen'].strip()
|
||||
if t: print('text:'); print(' ' + t.replace(chr(10), chr(10)+' ')[:500])
|
||||
" || echo "(no status file)"
|
||||
|
||||
# kill
|
||||
kill $PID 2>/dev/null || true
|
||||
wait $PID 2>/dev/null || true
|
||||
|
||||
echo "═══ $VERDICT ═══"
|
||||
[ "$VERDICT" = "SUCCESS" ] && exit 0 || exit 1
|
||||
65
tools/probe-tcp.sh
Executable file
65
tools/probe-tcp.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# Probe the tcp-relay recv() path: boot Win95, telnet to a fake upstream
|
||||
# on port 7777, and dump the per-frame trace. PASS = guest ACKs the async
|
||||
# banner (i.e., recv() returned).
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PORT=7777
|
||||
TRACE=/tmp/win95-tcp-trace.log
|
||||
RELAY="${TMPDIR:-/tmp}/win95-tcp-relay.log"
|
||||
STATUS=/tmp/win95-probe.json
|
||||
TIMEOUT=${TIMEOUT:-90}
|
||||
|
||||
pkill -9 -f "windows95.*electron" 2>/dev/null || true
|
||||
sleep 1
|
||||
rm -f "$HOME/Library/Application Support/windows95/state-v4.bin"
|
||||
rm -f "$STATUS" /tmp/win95-probe.done /tmp/win95-screen.png "$TRACE" "$RELAY"
|
||||
|
||||
rm -rf dist
|
||||
node tools/vite-build.js > /tmp/win95-build.log 2>&1 || {
|
||||
echo "BUILD FAILED"; tail -30 /tmp/win95-build.log; exit 1
|
||||
}
|
||||
|
||||
WIN95_PROBE=1 \
|
||||
WIN95_PROBE_RUN="${RUN:-ping -t 8.8.8.8}" \
|
||||
WIN95_PROBE_RUN2="${RUN2:-telnet 1.1.1.1 $PORT}" \
|
||||
WIN95_PROBE_RUN2_WAIT="${RUN2_WAIT:-3000}" \
|
||||
WIN95_PROBE_RUN_AFTER="${RUN_AFTER:-}" \
|
||||
WIN95_PROBE_RUN_WAIT="${RUN_WAIT:-6000}" \
|
||||
WIN95_TCP_TEST_PORT=$PORT \
|
||||
WIN95_TCP_TEST_MODE="${MODE:-banner}" \
|
||||
WIN95_TCP_TEST_DELAY="${DELAY:-50}" \
|
||||
WIN95_TCP_TEST_BYTES="${BYTES:-3000}" \
|
||||
WIN95_TCP_TRACE=$PORT \
|
||||
WIN95_SMB_SHARE="$HOME/Downloads" \
|
||||
./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 &
|
||||
PID=$!
|
||||
echo "electron pid=$PID, waiting up to ${TIMEOUT}s…"
|
||||
|
||||
VERDICT=TIMEOUT
|
||||
for i in $(seq 1 "$TIMEOUT"); do
|
||||
kill -0 $PID 2>/dev/null || { VERDICT=CRASHED; break; }
|
||||
if [ -f /tmp/win95-probe.done ] && grep -q FAIL /tmp/win95-probe.done; then
|
||||
VERDICT="BOOT_$(cat /tmp/win95-probe.done)"; break
|
||||
fi
|
||||
if [ -f "$TRACE" ] && grep -q '→ guest .* banner' "$RELAY" 2>/dev/null; then
|
||||
# Banner was written; give guest 8 s to ACK, then decide.
|
||||
sleep 8
|
||||
if grep -Eq 'guest→.* ack=(279[8-9]|2[89][0-9]{2}|[3-9][0-9]{3}|[1-9][0-9]{4,}) ' "$TRACE"; then
|
||||
VERDICT=PASS
|
||||
else
|
||||
VERDICT=FAIL
|
||||
fi
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
kill $PID 2>/dev/null || true
|
||||
wait $PID 2>/dev/null || true
|
||||
|
||||
echo "─── relay ───"; [ -f "$RELAY" ] && cat "$RELAY"
|
||||
echo "─── trace ───"; [ -f "$TRACE" ] && cat "$TRACE"
|
||||
echo "═══ $VERDICT ═══"
|
||||
[ "$VERDICT" = PASS ]
|
||||
@@ -8,12 +8,11 @@ const exePath = process.argv[process.argv.length - 1]
|
||||
console.log(exePath)
|
||||
|
||||
async function main() {
|
||||
|
||||
await resedit.resedit(exePath, {
|
||||
"productVersion": package.version,
|
||||
"fileVersion": package.version,
|
||||
"productName": package.productName,
|
||||
"icon": path.join(__dirname, "../assets/icon.ico"),
|
||||
"iconPath": path.join(__dirname, "../assets/icon.ico"),
|
||||
"win32Metadata": {
|
||||
"FileDescription": package.productName,
|
||||
"InternalName": package.name,
|
||||
|
||||
166
tools/update-v86.js
Normal file
166
tools/update-v86.js
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Build and install v86 (wasm + libv86.js + BIOS) from a local checkout.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/update-v86.js [path/to/v86]
|
||||
*
|
||||
* Defaults to ../v86 relative to this repo. Expects the checkout to be on
|
||||
* `fork/windows95-base` (or a branch with both bug fixes applied):
|
||||
*
|
||||
* - `electron-renderer-fs-loader` — file loader uses require() instead of
|
||||
* dynamic import (needed for Electron renderer, PR #1540)
|
||||
* - `ide-shared-registers` — ATA Command Block register writes hit both
|
||||
* master and slave, as the spec says they should (fixes Win95/98 boot
|
||||
* on disks >535MiB, PR #1541)
|
||||
*
|
||||
* If either PR is merged into upstream, rebase windows95-base and drop it.
|
||||
*
|
||||
* Prereqs (all must be installed — no fallbacks):
|
||||
* cargo + rustup target add wasm32-unknown-unknown
|
||||
* clang
|
||||
* java (e.g. brew install openjdk)
|
||||
* <v86>/closure-compiler/compiler.jar (v20210601 — pinned by v86's Makefile)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const WINDOWS95_DIR = path.resolve(__dirname, '..');
|
||||
const V86_DIR = process.argv[2]
|
||||
? path.resolve(process.argv[2])
|
||||
: path.resolve(WINDOWS95_DIR, '../v86');
|
||||
|
||||
const LIB_DIR = path.join(WINDOWS95_DIR, 'src/renderer/lib');
|
||||
const BIOS_DIR = path.join(WINDOWS95_DIR, 'bios');
|
||||
|
||||
const JAVA_BIN = '/opt/homebrew/opt/openjdk/bin/java';
|
||||
|
||||
function require_tool(cmd, desc) {
|
||||
try {
|
||||
execFileSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' });
|
||||
} catch {
|
||||
throw new Error(`Missing prerequisite: ${desc} (${cmd} not on PATH)`);
|
||||
}
|
||||
}
|
||||
|
||||
function run(cmd, args, opts = {}) {
|
||||
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
|
||||
}
|
||||
|
||||
function check_prereqs() {
|
||||
require_tool('cargo', 'rust/cargo');
|
||||
require_tool('clang', 'clang');
|
||||
|
||||
// cargo needs the wasm32 target
|
||||
const targets = execFileSync('rustup', ['target', 'list', '--installed']).toString();
|
||||
if (!targets.includes('wasm32-unknown-unknown')) {
|
||||
throw new Error('Missing rust target. Run: rustup target add wasm32-unknown-unknown');
|
||||
}
|
||||
|
||||
// Java comes from homebrew openjdk on macOS — the v86 Makefile invokes `java`
|
||||
// directly, so we have to put the homebrew java on PATH for its make calls
|
||||
// (or install openjdk into the system). We check for an explicit binary so
|
||||
// the error is clear.
|
||||
if (!fs.existsSync(JAVA_BIN)) {
|
||||
throw new Error(`Missing java at ${JAVA_BIN}. Install with: brew install openjdk`);
|
||||
}
|
||||
|
||||
const closureJar = path.join(V86_DIR, 'closure-compiler', 'compiler.jar');
|
||||
if (!fs.existsSync(closureJar)) {
|
||||
throw new Error(
|
||||
`Missing Closure compiler at ${closureJar}.\n` +
|
||||
`Download v20210601 (pinned by v86's Makefile):\n` +
|
||||
` mkdir -p ${path.dirname(closureJar)}\n` +
|
||||
` curl -sL https://repo1.maven.org/maven2/com/google/javascript/closure-compiler/v20210601/closure-compiler-v20210601.jar -o ${closureJar}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(V86_DIR, 'Makefile'))) {
|
||||
throw new Error(`No v86 checkout at ${V86_DIR}. Pass a path as the first argument or clone copy/v86 there.`);
|
||||
}
|
||||
}
|
||||
|
||||
function build_v86() {
|
||||
const env = { ...process.env, PATH: `/opt/homebrew/opt/openjdk/bin:${process.env.PATH}` };
|
||||
console.log('Building v86.wasm…');
|
||||
run('make', ['build/v86.wasm'], { cwd: V86_DIR, env });
|
||||
console.log('Building libv86.js…');
|
||||
run('make', ['build/libv86.js'], { cwd: V86_DIR, env });
|
||||
}
|
||||
|
||||
function install() {
|
||||
const copies = [
|
||||
['build/v86.wasm', 'build/v86.wasm'],
|
||||
['build/libv86.js', 'libv86.js'],
|
||||
];
|
||||
for (const [src, dest] of copies) {
|
||||
fs.copyFileSync(path.join(V86_DIR, src), path.join(LIB_DIR, dest));
|
||||
const size = fs.statSync(path.join(LIB_DIR, dest)).size;
|
||||
console.log(` ${dest}: ${(size / 1024).toFixed(0)} KB`);
|
||||
}
|
||||
|
||||
for (const bios of ['seabios.bin', 'vgabios.bin']) {
|
||||
fs.copyFileSync(path.join(V86_DIR, 'bios', bios), path.join(BIOS_DIR, bios));
|
||||
}
|
||||
console.log(' seabios.bin + vgabios.bin');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check the installed files for the invariants our SMB integration
|
||||
* and Electron renderer depend on. If any of these fail, v86 changed under us
|
||||
* and src/renderer/smb/index.ts probably needs updating — see the README at
|
||||
* src/renderer/smb/README.md for why.
|
||||
*/
|
||||
function sanity_check() {
|
||||
const js = fs.readFileSync(path.join(LIB_DIR, 'libv86.js'), 'utf-8');
|
||||
|
||||
const checks = [
|
||||
// The electron-renderer-fs-loader fix: don't use dynamic import for fs
|
||||
[!/await import\("node:/.test(js),
|
||||
'libv86.js uses `await import("node:...")` — the Electron renderer fs loader PR was reverted?'],
|
||||
|
||||
// The ide-shared-registers fix: writes go to both master and slave
|
||||
// (minified has no spaces: `this.master.features_reg=(this.master...`)
|
||||
[/this\.master\.features_reg=\(this\.master\.features_reg/.test(js),
|
||||
'libv86.js ide.js did not get the shared-register fix — is the windows95-base branch still in sync?'],
|
||||
|
||||
// Export pattern still shims the way vite-build expects
|
||||
[js.includes('module.exports') && js.includes('window'),
|
||||
'libv86.js export pattern changed — check the runtime shim in vite-build.js'],
|
||||
|
||||
// SMB integration needs the tcp-connection bus event (new API path in index.ts)
|
||||
[js.includes('tcp-connection'),
|
||||
'libv86.js no longer fires the tcp-connection bus event — SMB will fall back to the old-API theft hack'],
|
||||
|
||||
// Old-API fallback still present for defense in depth
|
||||
[js.includes('on_tcp_connection'),
|
||||
'libv86.js no longer has on_tcp_connection — harmless but surprising'],
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
for (const [ok, msg] of checks) {
|
||||
if (ok) passed++;
|
||||
else console.warn(' WARN:', msg);
|
||||
}
|
||||
console.log(` sanity: ${passed}/${checks.length} checks passed`);
|
||||
}
|
||||
|
||||
function main() {
|
||||
console.log(`v86 checkout: ${V86_DIR}`);
|
||||
const head = execFileSync('git', ['log', '-1', '--format=%h %s'], { cwd: V86_DIR }).toString().trim();
|
||||
console.log(` ${head}`);
|
||||
|
||||
check_prereqs();
|
||||
build_v86();
|
||||
install();
|
||||
sanity_check();
|
||||
console.log('done');
|
||||
}
|
||||
|
||||
try { main(); }
|
||||
catch (e) {
|
||||
console.error('✗', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
113
tools/vite-build.js
Normal file
113
tools/vite-build.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const { build } = require('vite')
|
||||
const { builtinModules } = require('module')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const root = path.join(__dirname, '..')
|
||||
const nodeExternals = ['electron', ...builtinModules, ...builtinModules.map(m => `node:${m}`)]
|
||||
|
||||
// libv86 checks `typeof module.exports` before `typeof window` when deciding
|
||||
// where to export V86. In an Electron renderer with nodeIntegration both exist,
|
||||
// so it ends up on module.exports instead of window. This shim copies it over.
|
||||
const LIBV86_SHIM = `<script src="libv86.js"></script>
|
||||
<script>if (typeof module !== "undefined" && module.exports && module.exports.V86) window.V86 = module.exports.V86;</script>`
|
||||
|
||||
// v86's node-path file loader used `await import("node:...")` until d4c5fa86
|
||||
// switched it to require(). Dynamic import of node: URLs doesn't work in an
|
||||
// Electron renderer — only require() does. The literals are stable across
|
||||
// Closure builds; if they're absent the build is post-d4c5fa86 and already
|
||||
// uses require, so a no-op is correct.
|
||||
const V86_NODE_IMPORTS = [
|
||||
['await import("node:fs/promises")', 'require("fs").promises'],
|
||||
['await import("node:"+"fs/promises")', 'require("fs").promises'],
|
||||
['await import("node:crypto")', 'require("crypto")'],
|
||||
];
|
||||
|
||||
async function copyLib() {
|
||||
const target = path.join(root, 'dist/static')
|
||||
const lib = path.join(root, 'src/renderer/lib')
|
||||
const indexSrc = path.join(root, 'static/index.html')
|
||||
const indexOut = path.join(target, 'index.html')
|
||||
|
||||
await fs.promises.cp(lib, target, { recursive: true });
|
||||
|
||||
const libv86path = path.join(target, 'libv86.js')
|
||||
let libv86 = fs.readFileSync(libv86path, 'utf-8')
|
||||
let patchCount = 0;
|
||||
for (const [from, to] of V86_NODE_IMPORTS) {
|
||||
const next = libv86.split(from).join(to);
|
||||
if (next !== libv86) { patchCount++; libv86 = next; }
|
||||
}
|
||||
if (patchCount > 0) {
|
||||
fs.writeFileSync(libv86path, libv86)
|
||||
console.log(`libv86: ${patchCount} dynamic-import → require`)
|
||||
}
|
||||
|
||||
const indexContents = fs.readFileSync(indexSrc, 'utf-8');
|
||||
const replacedContents = indexContents.replace('<!-- libv86 -->', LIBV86_SHIM)
|
||||
fs.writeFileSync(indexOut, replacedContents)
|
||||
}
|
||||
|
||||
function mainConfig(watch) {
|
||||
return {
|
||||
root,
|
||||
configFile: false,
|
||||
build: {
|
||||
outDir: 'dist/src/main',
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
target: 'node22',
|
||||
lib: { entry: 'src/main/main.ts', formats: ['cjs'], fileName: () => 'main.js' },
|
||||
rollupOptions: {
|
||||
external: [...nodeExternals, 'electron-squirrel-startup', 'update-electron-app'],
|
||||
},
|
||||
watch: watch ? {} : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function rendererConfig(watch) {
|
||||
return {
|
||||
root,
|
||||
configFile: false,
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: false,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
target: 'es2023',
|
||||
lib: {
|
||||
entry: 'src/renderer/app.tsx',
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'renderer.js',
|
||||
cssFileName: 'renderer',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: nodeExternals,
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
assetFileNames: '[name][extname]',
|
||||
// Electron renderer <script> with nodeIntegration has module/require
|
||||
// but not a bare `exports` global; alias it so Rollup's CJS prelude works.
|
||||
banner: 'var exports = module.exports;',
|
||||
},
|
||||
},
|
||||
watch: watch ? {} : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function compileVite(options = {}) {
|
||||
if (!options.watch) {
|
||||
await fs.promises.rm(path.join(root, 'dist'), { recursive: true, force: true })
|
||||
}
|
||||
await fs.promises.mkdir(path.join(root, 'dist/static'), { recursive: true })
|
||||
await copyLib()
|
||||
await build(mainConfig(options.watch))
|
||||
await build(rendererConfig(options.watch))
|
||||
}
|
||||
|
||||
module.exports = { compileVite }
|
||||
|
||||
if (require.main === module) compileVite()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user