50 Commits

Author SHA1 Message Date
Felix Rieseberg
4ed96deecc Restore CD-ROM settings tab behind a feature flag
The tab, file picker, and prop wiring are all back and styled to match
the new Properties sheet, gated on CDROM_ENABLED=false until v86's IDE
CD path works again.
2026-04-11 09:01:29 -07:00
Felix Rieseberg
80505384c6 Remove unused 95css vendor bundle 2026-04-11 09:00:34 -07:00
Felix Rieseberg
a0ee5a2f10 Drop dead CD-ROM handler and tighten navigate typing 2026-04-11 08:51:08 -07:00
Felix Rieseberg
74fc2d291e Redesign launcher UI with 98.css
Replace the sparse start/settings cards with a Win95-styled launcher:

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

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

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

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

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

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

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

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

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

Also adds tools/update-v86.js to pull new builds from copy.sh, and exposes
the renderer DevTools protocol on localhost:9222 in dev.
2026-04-10 20:53:44 -07:00
Felix Rieseberg
00943ae4da Update all dependencies
React 19, Electron 41, TypeScript 6, electron-forge 7.8, plus everything
else to latest. Migrated to React 19 createRoot API, updated for Electron 41
session/window type changes, and adjusted tsconfig for TS 6 deprecations.
Regenerated @electron/packager patch for 18.4.4.
2026-04-10 20:34:28 -07:00
Felix Rieseberg
a6d57c6538 Update resedit 2025-05-02 09:03:39 -07:00
Felix Rieseberg
35f7c3362d Update Readme 2025-02-21 08:27:47 -08:00
Felix Rieseberg
94021edb61 Fix missing icon 2025-02-20 21:42:49 -08:00
Felix Rieseberg
6f2025ffc0 Improve reset 2025-02-20 21:19:55 -08:00
Felix Rieseberg
3a7b37fff0 Fix a few smaller build issues 2025-02-20 17:27:00 -08:00
Felix Rieseberg
16eb63e13b Add bg image 2025-02-20 17:16:51 -08:00
Felix Rieseberg
54fe721f4f Better gitignore 2025-02-20 17:16:31 -08:00
Felix Rieseberg
6dee2f45a2 Hide text screen when paused 2025-02-20 14:31:10 -08:00
Felix Rieseberg
e7e047b0a0 Deal with resedit segfaults 2025-02-20 10:54:02 -08:00
Felix Rieseberg
5a334abb13 Use dotenv 2025-02-20 07:57:57 -08:00
Felix Rieseberg
aacfae7ada Update forge config 2025-02-19 13:23:21 -08:00
Felix Rieseberg
9b87b77570 Update links 2025-02-18 23:03:37 -08:00
Felix Rieseberg
e6a0d931af Move out helper images 2025-02-18 23:03:08 -08:00
Felix Rieseberg
973580d60b Ignore more files 2025-02-18 23:01:44 -08:00
Felix Rieseberg
8fcf5eaed3 Remove old forge settings 2025-02-18 22:54:30 -08:00
Felix Rieseberg
e15d918fb3 Update version number to 4.0.0 2025-02-18 22:50:32 -08:00
Felix Rieseberg
b442c6db08 Update UI 2025-02-18 22:49:52 -08:00
Felix Rieseberg
5c946bbca4 Now with working network 2025-02-18 22:39:47 -08:00
Felix Rieseberg
c9e45a9f39 Update qemu docs 2025-02-18 22:39:35 -08:00
Felix Rieseberg
bc42ce3231 Remove fs-extra 2025-02-15 09:45:22 -08:00
Felix Rieseberg
d91e72ccc5 Upgrade TypeScript 2025-02-15 09:38:41 -08:00
Felix Rieseberg
bd40f00f8d Remove node-abi 2025-02-15 09:38:04 -08:00
Felix Rieseberg
1cbfca7451 Upgrade rimraf, node-abi, electron-squirrel-startup 2025-02-15 09:37:38 -08:00
Felix Rieseberg
7710c4b7af Upgrade prettier 2025-02-15 09:36:37 -08:00
Felix Rieseberg
4cce1f0740 Upgrade electron & electron-forge 2025-02-15 09:35:50 -08:00
Felix Rieseberg
f8ae78f247 Update v86 2025-02-15 09:34:41 -08:00
Felix Rieseberg
62f8eb2696 v3.1.2 2023-07-14 12:40:08 -07:00
Felix Rieseberg
da4b0dd728 Update v86 2023-07-14 12:39:57 -07:00
Felix Rieseberg
6cc05fa042 Upgrade dependencies 2023-07-14 11:52:30 -07:00
Felix Rieseberg
dda3707a23 Merge pull request #292 from fjbecerra/fix_docker_doc
fix docker mac doc
2023-03-15 11:10:24 -07:00
Francis Becerra
a4bcd7fb61 fix docker mac doc 2023-02-11 22:08:41 +00:00
Felix Rieseberg
17a8139346 Update links 2022-10-17 09:48:30 -07:00
Felix Rieseberg
489c7312d0 v3.1.1 2022-10-08 13:12:13 +02:00
Felix Rieseberg
c3537ae330 Handle promises 2022-10-08 13:12:08 +02:00
Felix Rieseberg
c483871df9 v3.1.0 2022-10-04 17:20:52 +02:00
Felix Rieseberg
e66cbd70db Update dependencies (Electron 18 > 21, Forge 63 > 66) 2022-10-04 17:07:41 +02:00
Felix Rieseberg
19a1bbc002 Update v86 to 5d02960 2022-10-04 10:09:26 +02:00
Felix Rieseberg
ef57e3a7fe Update links 2022-06-26 15:23:34 -07:00
92 changed files with 24073 additions and 17510 deletions

17
.gitignore vendored
View File

@@ -1,9 +1,18 @@
node_modules
out
src/images
.DS_Store
images
images_new
/images*/
/helper-images/
dist
!.github/images
*.code-workspace
*.code-workspace
*.pfx
Microsoft.Trusted.Signing.Client*
trusted-signing-metadata.json
.env
electron-windows-sign.log
.npmrc
/.claude/

21
HELP.md
View File

@@ -11,25 +11,4 @@ back to Window Mode. (Thanks to @DisplacedGamers for that wisdom)
On the app's home screen, select "Settings" in the lower menu. Then, delete your
machine's state before starting it again - this time hopefully without issues.
## I want to install additional apps or games
If you are running macOS, or Linux, you can probably "mount" the
virtual hard drive used by `windows95` to add files. Hit the "Modify C: Drive"
button, which will take you to the disk image.
On macOS, double-click the disk image to open it.
On Windows 10, Windows will _think_ that it can open up the image, but will
actually fail to do so. Use a tool [like OSFMount][osfmount] to mount your
disk image.
On Linux, search the Internet for instructions on how to mount an `img` disk
image on your distribution. It's likely that you'll be able to run `mount`
with the image as input.
[osfmount]: https://www.osforensics.com/tools/mount-disk-images.html
## What's the FrontPage Username and Password?
Username: windows95
Password: password

View File

@@ -15,29 +15,29 @@ 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/v2.3.0/windows95-2.3.0-setup-ia32.exe">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-ia32.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-win32-ia32-2.3.0.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-win32-ia32-4.0.0.zip">
📦 Standalone Zip
</a>
<br />
<span>64-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-2.3.0-setup-x64.exe">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-x64.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-win32-x64-2.3.0.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-win32-x64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>ARM64</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-2.3.0-setup-arm64.exe">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-arm64.exe">
💿 Installer
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-win32-arm64-2.3.0.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-win32-arm64-4.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 +47,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/v4.0.0/windows95-darwin-arm64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>Intel Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-darwin-x64-2.3.0.zip">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-darwin-x64-4.0.0.zip">
📦 Standalone Zip
</a><br />
<span>Apple M1 Processor</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-darwin-arm64-2.3.0.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>
@@ -66,41 +66,29 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
Linux
</td>
<td>
<span>32-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-2.3.0-1.i386.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95_2.3.0_i386.deb">
💿 deb
</a><br />
<span>64-bit</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-2.3.0-1.x86_64.rpm">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-1.x86_64.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95_2.3.0_amd64.deb">
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95_4.0.0_amd64.deb">
💿 deb
</a><br />
<span>ARM64</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-2.3.0-1.arm64.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95_2.3.0_arm64.deb">
💿 deb
</a><br />
<span>ARMv7 (armhf)</span>
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95-2.3.0-1.armv7hl.rpm">
💿 rpm
</a> |
<a href="https://github.com/felixrieseberg/windows95/releases/download/v2.3.0/windows95_2.3.0_armhf.deb">
💿 deb
</a>
</td>
</tr>
</table>
<hr />
![Screenshot](https://user-images.githubusercontent.com/1426799/44532591-4ceb3680-a6a8-11e8-8c2c-bc29f3bfdef7.png)
<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.
@@ -109,7 +97,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.

Binary file not shown.

Binary file not shown.

View File

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

View File

@@ -6,23 +6,43 @@ around.
Disk image creation
```sh
qemu-img create -f qcow2 win95.qcow2 1G
qemu-img create -f raw windows95_v4.raw 1G
```
ISO CD image creation
```sh
hdiutil makehybrid -o output.iso /path/to/folder -iso -joliet
```
Installation
```sh
qemu-system-i386 -netdev user,id=mynet0 -device ne2k_isa,netdev=mynet0 -hda win95.qcow2 -soundhw sb16 -m 128 -cpu pentium -device cirrus-vga,vgamem_mb=64 -fda boot_floppy.img -cdrom Win95_OSR25.iso -boot a -soundhw pcspk
qemu-system-i386 \
-cdrom Win95_OSR25.iso \
-m 128 \
-hda windows95.img \
-device sb16 \
-nic user,model=ne2k_pci \
-fda Win95_boot.img \
-boot a \
-M pc,acpi=off \
-cpu pentium
```
Running
- Boot from floppy
- Run `fdisk` and `format c:`
- Run `D:\setup.exe` with `24796-OEM-0014736-66386`
- After completing setup and restarting your computer, you might get an IOS Windows protection error
- Use `fix95cpu.ima` as a bootable floppy to fix
- Use `vga-driver.iso` to install different video driver
With `ne2k_isa`
```sh
qemu-system-i386 -netdev user,id=mynet0 -device ne2k_isa,netdev=mynet0 -drive file=win95.img,format=raw,index=0,media=disk -soundhw sb16 -m 128 -cpu pentium -device cirrus-vga,vgamem_mb=16 -soundhw pcspk -cdrom Win95_OSR25.iso
qemu-system-i386 \
-m 128 \
-hda images/windows95.img \
-device sb16 \
-M pc,acpi=off \
-cpu pentium \
-netdev user,id=mynet0 \
-device ne2k_isa,netdev=mynet0,irq=10
```
With `ne2k_pci`
```sh
qemu-system-i386 -net nic,model=ne2k_pci -net user -drive file=win95_ne2k_pci.img,format=raw,index=0,media=disk -soundhw sb16 -m 128 -cpu pentium -device cirrus-vga,vgamem_mb=16 -soundhw pcspk -cdrom Win95_OSR25.iso --enable-kvm
```

View File

@@ -2,13 +2,32 @@ const path = require('path');
const fs = require('fs');
const package = require('./package.json');
if (process.env['WINDOWS_CODESIGN_FILE']) {
const certPath = path.join(__dirname, 'win-certificate.pfx');
const certExists = fs.existsSync(certPath);
require('dotenv').config()
if (certExists) {
process.env['WINDOWS_CODESIGN_FILE'] = certPath;
}
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'),
AZURE_METADATA_JSON: process.env.AZURE_METADATA_JSON || path.resolve(__dirname, 'trusted-signing-metadata.json'),
AZURE_TENANT_ID: process.env.AZURE_TENANT_ID,
AZURE_CLIENT_ID: process.env.AZURE_CLIENT_ID,
AZURE_CLIENT_SECRET: process.env.AZURE_CLIENT_SECRET,
APPLE_ID: process.env.APPLE_ID,
APPLE_ID_PASSWORD: process.env.APPLE_ID_PASSWORD,
}
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"],
}
module.exports = {
@@ -26,29 +45,38 @@ module.exports = {
},
osxSign: {
identity: 'Developer ID Application: Felix Rieseberg (LT94ZKYDCJ)',
'hardened-runtime': true,
'gatekeeper-assess': false,
'entitlements': 'assets/entitlements.plist',
'entitlements-inherit': 'assets/entitlements.plist',
'signature-flags': 'library'
},
osxNotarize: {
appBundleId: 'com.felixrieseberg.macintoshjs',
appleId: process.env['APPLE_ID'],
appleIdPassword: process.env['APPLE_ID_PASSWORD'],
ascProvider: 'LT94ZKYDCJ'
appleId: FLAGS.APPLE_ID,
appleIdPassword: FLAGS.APPLE_ID_PASSWORD,
teamId: 'LT94ZKYDCJ'
},
windowsSign,
ignore: [
/\/assets(\/?)/,
/\/docs(\/?)/,
/\/tools(\/?)/,
/\/src\/.*\.ts/,
/\/test(\/?)/,
/\/@types(\/?)/,
/\/helper-images(\/?)/,
/package-lock\.json/,
/README\.md/,
/tsconfig\.json/,
/Dockerfile/,
/issue_template\.md/,
/HELP\.md/,
/forge\.config\.js/,
/\.github(\/?)/,
/\.circleci(\/?)/,
/\.vscode(\/?)/,
/\.gitignore/,
/\.gitattributes/,
/\.eslintignore/,
/\.eslintrc/,
/\.prettierrc/,
/\/Microsoft\.Trusted\.Signing\.Client.*/,
/\/trusted-signing-metadata/,
]
},
makers: [
@@ -66,8 +94,7 @@ module.exports = {
loadingGif: './assets/boot.gif',
setupExe: `windows95-${package.version}-setup-${arch}.exe`,
setupIcon: path.resolve(__dirname, 'assets', 'icon.ico'),
certificateFile: process.env['WINDOWS_CODESIGN_FILE'],
certificatePassword: process.env['WINDOWS_CODESIGN_PASSWORD'],
windowsSign
}
}
},

18748
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "windows95",
"productName": "windows95",
"version": "3.0.0",
"version": "4.0.0",
"description": "Windows 95, in an app. I'm sorry.",
"main": "./dist/src/main/main.js",
"scripts": {
@@ -12,7 +12,8 @@
"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"
"check-links": "node tools/check-links.js",
"postinstall": "patch-package"
},
"keywords": [],
"author": "Felix Rieseberg, felix@felixrieseberg.com",
@@ -21,31 +22,29 @@
"forge": "./forge.config.js"
},
"dependencies": {
"electron-squirrel-startup": "^1.0.0",
"fs-extra": "^10.1.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"tslib": "^2.3.1",
"update-electron-app": "^2.0.1"
"electron-squirrel-startup": "^1.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"update-electron-app": "^3.1.2"
},
"devDependencies": {
"@electron-forge/cli": "6.0.0-beta.63",
"@electron-forge/maker-deb": "6.0.0-beta.63",
"@electron-forge/maker-flatpak": "^6.0.0-beta.63",
"@electron-forge/maker-rpm": "^6.0.0-beta.63",
"@electron-forge/maker-squirrel": "^6.0.0-beta.63",
"@electron-forge/maker-zip": "^6.0.0-beta.63",
"@electron-forge/publisher-github": "^6.0.0-beta.63",
"@types/fs-extra": "^9.0.13",
"@types/node": "^12.19.9",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"electron": "18.1.0",
"less": "^3.13.0",
"node-abi": "^3.15.0",
"@electron-forge/cli": "7.8.3",
"@electron-forge/maker-deb": "7.8.3",
"@electron-forge/maker-flatpak": "^7.8.3",
"@electron-forge/maker-rpm": "^7.8.3",
"@electron-forge/maker-squirrel": "^7.8.3",
"@electron-forge/maker-zip": "^7.8.3",
"@electron-forge/publisher-github": "^7.8.3",
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.3.1",
"electron": "41.2.0",
"less": "^4.6.4",
"parcel-bundler": "^1.12.5",
"prettier": "^2.6.2",
"rimraf": "^3.0.2",
"typescript": "^4.6.3"
"patch-package": "^8.0.1",
"prettier": "^3.8.1",
"rimraf": "^6.1.3",
"typescript": "^6.0.2"
}
}

View File

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

View File

@@ -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"],
});
}

View File

@@ -1,9 +1,12 @@
import * as path from "path";
const IMAGES_PATH = path.join(__dirname, "../../images");
export const CONSTANTS = {
IMAGE_PATH: path.join(__dirname, "../../images/windows95.img"),
IMAGES_PATH,
IMAGE_PATH: path.join(IMAGES_PATH, "windows95.img"),
IMAGE_DEFAULT_SIZE: 1073741824, // 1GB
DEFAULT_STATE_PATH: path.join(__dirname, "../../images/default-state.bin"),
DEFAULT_STATE_PATH: path.join(IMAGES_PATH, "default-state.bin"),
};
export const IPC_COMMANDS = {
@@ -27,4 +30,7 @@ export const IPC_COMMANDS = {
// Else
APP_QUIT: "APP_QUIT",
GET_STATE_PATH: "GET_STATE_PATH",
GET_SMB_SHARE_PATH: "GET_SMB_SHARE_PATH",
SET_SMB_SHARE_PATH: "SET_SMB_SHARE_PATH",
PICK_FOLDER: "PICK_FOLDER",
};

View File

@@ -21,4 +21,9 @@
filter: blur(2px);
z-index: -100;
}
#emulator-text-screen {
display: none;
visibility: hidden;
}
}

View File

@@ -1,117 +1,77 @@
@import "./status.less";
@import "./emulator.less";
@import "./info.less";
@import "./settings.less";
@import "./start.less";
@import "./settings.less";
/* GENERAL RESETS */
// 98.css uses the actual MS Sans Serif bitmap font and pixel-exact bevels.
// Everything below is layout — the chrome comes from 98.css.
html, body {
@win-teal: #008080;
@win-silver: silver;
@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;
}
body.paused > #emulator {
display: none;
font-family: @win-font;
-webkit-font-smoothing: none;
image-rendering: pixelated;
}
body.paused {
background: #008080;
font-family: Courier;
background: @win-teal;
> #emulator {
display: none;
}
}
#buttons {
user-select: 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: @win-font;
font-size: 11px;
line-height: 1.5;
}
code {
font-family: "Courier New", monospace;
font-size: 11px;
}
section {
display: flex;
position: absolute;
width: 100vw;
height: 100vh;
inset: 0;
display: flex;
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;
}
}
}

View File

@@ -1,21 +1,71 @@
#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;
}
.settings-window {
width: 460px;
#file-input {
display: none;
}
.settings {
legend > img {
margin-right: 5px;
> .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: @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;
}
}

View File

@@ -1,9 +1,99 @@
#section-start {
display: flex;
flex-direction: column;
// "Welcome to Windows" splash — modelled on the real first-boot dialog.
> small {
margin-top: 25px;
font-size: .8rem;
.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-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;
}
}

View File

@@ -1,8 +1,19 @@
#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: calc(50vw - 110px);
left: 50vw;
transform: translateX(-50%);
white-space: nowrap;
background: white;
font-size: 10px;
padding-bottom: 3px;
@@ -13,4 +24,14 @@
padding-right: 10px;
max-height: 18px;
top: 0;
transition: transform 0.12s ease-out;
&.hidden {
transform: translateX(-50%) translateY(-100%);
}
}
#status-hotzone:hover + #status.hidden,
#status.hidden:hover {
transform: translateX(-50%) translateY(0);
}

BIN
src/less/vendor/95.ttf vendored

Binary file not shown.

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,15 @@
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">`;
}

View File

@@ -0,0 +1,164 @@
import { protocol } 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";
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];
export function setupFileServer() {
// Register protocol handler for our custom schema
protocol.handle("http", async (request) => {
if (!interceptedUrls.some((url) => request.url.startsWith(url))) {
return fetch(request.url, {
headers: request.headers,
method: request.method,
body: request.body,
});
}
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",
},
},
);
}
// 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 as NodeJS.ErrnoException).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;
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const errorPage = generateErrorPage(
"Internal Server Error",
`An error occurred while processing your request: ${message}`,
);
return new Response(errorPage, {
status: 500,
headers: {
"Content-Type": "text/html",
},
});
}
});
}
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 };
}
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,
},
});
}

View File

@@ -0,0 +1,71 @@
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;
}

View File

@@ -0,0 +1,126 @@
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);
try {
const stats = fs.statSync(fullPath);
return { name, fullPath, stats } as FileEntry;
} catch (error) {
log(`FileServer: Failed to get stats for ${fullPath}: ${error}`);
return null;
}
})
.filter(
(entry): entry is FileEntry => entry !== null && !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];
}

View File

@@ -0,0 +1,25 @@
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>
`;
}

View File

@@ -1,14 +1,46 @@
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 { settings } from "./settings";
export function setupIpcListeners() {
ipcMain.handle(IPC_COMMANDS.GET_STATE_PATH, () => {
return path.join(app.getPath("userData"), "state-v3.bin");
return path.join(app.getPath("userData"), "state-v4.bin");
});
ipcMain.handle(IPC_COMMANDS.APP_QUIT, () => {
app.quit();
});
ipcMain.handle(IPC_COMMANDS.GET_SMB_SHARE_PATH, () => {
return settings.get("smbSharePath");
});
ipcMain.handle(IPC_COMMANDS.SET_SMB_SHARE_PATH, (_e, p: unknown) => {
// The only legitimate caller is the folder picker, which can't return
// a non-existent path — but the renderer has nodeIntegration so any
// code there can call this IPC. Reject anything that isn't an existing
// directory; otherwise SmbSession's realpathSync throws inside a TCP
// callback on next launch and the share silently never connects.
if (typeof p !== "string") return false;
let real: string;
try {
real = fs.realpathSync(p);
if (!fs.statSync(real).isDirectory()) return false;
} catch {
return false;
}
settings.set("smbSharePath", real);
return true;
});
ipcMain.handle(IPC_COMMANDS.PICK_FOLDER, async (e) => {
const win = BrowserWindow.fromWebContents(e.sender);
const result = await dialog.showOpenDialog(win!, {
properties: ["openDirectory"],
});
return result.canceled ? null : result.filePaths[0];
});
}

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

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

View File

@@ -7,6 +7,8 @@ import { setupUpdates } from "./update";
import { getOrCreateWindow } from "./windows";
import { setupMenu } from "./menu";
import { setupIpcListeners } from "./ipc";
import { setupSession } from "./session";
import { setupFileServer } from "./fileserver/fileserver";
/**
* Handle the app's "ready" event. This is essentially
@@ -15,11 +17,13 @@ import { setupIpcListeners } from "./ipc";
export async function onReady() {
if (!isDevMode()) process.env.NODE_ENV = "production";
setupSession();
setupIpcListeners();
getOrCreateWindow();
setupAboutPanel();
setupMenu();
setupUpdates();
setupFileServer();
}
/**
@@ -57,6 +61,12 @@ 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", "9222");
}
// Set the app's name
app.setName("windows95");

View File

@@ -1,11 +1,12 @@
import { app, shell, Menu, BrowserWindow, ipcMain } from "electron";
import { app, shell, Menu, BrowserWindow, ipcMain, dialog } from "electron";
import { clearCaches } from "../cache";
import { IPC_COMMANDS } from "../constants";
import { isDevMode } from "../utils/devmode";
import { log } from "./logging";
const LINKS = {
homepage: "https://www.twitter.com/felixrieseberg",
homepage: "https://www.felixrieseberg.com",
repo: "https://github.com/felixrieseberg/windows95",
credits: "https://github.com/felixrieseberg/windows95/blob/master/CREDITS.md",
help: "https://github.com/felixrieseberg/windows95/blob/master/HELP.md",
@@ -15,10 +16,10 @@ export async function setupMenu() {
await createMenu();
ipcMain.on(IPC_COMMANDS.MACHINE_STARTED, () =>
createMenu({ isRunning: true })
createMenu({ isRunning: true }),
);
ipcMain.on(IPC_COMMANDS.MACHINE_STOPPED, () =>
createMenu({ isRunning: false })
createMenu({ isRunning: false }),
);
}
@@ -26,10 +27,10 @@ function send(cmd: string) {
const windows = BrowserWindow.getAllWindows();
if (windows[0]) {
console.log(`Sending "${cmd}"`);
log(`Sending "${cmd}"`);
windows[0].webContents.send(cmd);
} else {
console.log(`Tried to send "${cmd}", but could not find window`);
log(`Tried to send "${cmd}", but could not find window`);
}
}
@@ -63,7 +64,7 @@ async function createMenu({ isRunning } = { isRunning: false }) {
}
})(),
click: function (_item, focusedWindow) {
if (focusedWindow) {
if (focusedWindow instanceof BrowserWindow) {
focusedWindow.webContents.toggleDevTools();
}
},
@@ -163,7 +164,21 @@ async function createMenu({ isRunning } = { isRunning: false }) {
},
{
label: "Reset",
click: () => send(IPC_COMMANDS.MACHINE_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,
},
{

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

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

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

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

View File

@@ -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",
});

View File

@@ -18,14 +18,10 @@ export function getOrCreateWindow(): BrowserWindow {
},
});
// mainWindow.webContents.toggleDevTools();
mainWindow.loadFile("./dist/static/index.html");
mainWindow.webContents.on("will-navigate", (event, url) =>
handleNavigation(event, url)
);
mainWindow.webContents.on("new-window", (event, url) =>
handleNavigation(event, url)
handleNavigation(event, url),
);
mainWindow.on("closed", () => {

View File

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

View File

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

View File

@@ -1,17 +1,26 @@
import * as React from "react";
import * as fs from "fs-extra";
import { getStatePath } from "./utils/get-state-path";
import { resetState } from "./utils/reset-state";
// v86's IDE CD-ROM path is currently broken; flip this once it works again.
const CDROM_ENABLED = false;
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;
}
type Tab = "floppy" | "cdrom" | "network" | "state";
interface CardSettingsState {
tab: Tab;
isStateReset: boolean;
}
@@ -27,157 +36,210 @@ 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", "Network Share")}
{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 === "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.path}`
: `No floppy mounted`}
</p>
<button
className="btn"
onClick={() =>
(document.querySelector("#floppy-input") as any).click()
}
>
<img src="../../static/select-floppy.png" />
<span>Mount floppy disk</span>
</button>
</fieldset>
);
}
public renderState() {
const { isStateReset } = this.state;
const { bootFromScratch } = this.props;
return (
<fieldset>
<legend>
<img src="../../static/reset.png" />
Reset machine state
</legend>
<div>
<div className="settings-row">
<img className="settings-icon" src="../../static/floppy.png" />
<p>
windows95 stores changes to your machine (like saved files) in a
state file. If you encounter any trouble, you can reset your state
or boot Windows 95 from scratch.{" "}
<strong>All your changes will be lost.</strong>
windows95 ships with a virtual 3½" floppy drive. Mount an{" "}
<code>.img</code> disk image here, then boot the machine to read it
from inside Windows.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="floppy-path">Mounted image</label>
<input
id="floppy-path"
type="text"
readOnly
value={floppy ? floppy.name : "(No disk in drive)"}
/>
</div>
<div className="settings-buttons">
<button
className="btn"
onClick={this.onResetState}
disabled={isStateReset}
style={{ marginRight: "5px" }}
onClick={() =>
(document.querySelector("#floppy-input") as any).click()
}
>
<img src="../../static/reset-state.png" />
{isStateReset ? "State reset" : "Reset state"}
</button>
<button className="btn" onClick={bootFromScratch}>
<img src="../../static/boot-fresh.png" />
Boot from scratch
Mount image...
</button>
</div>
</fieldset>
);
}
/**
* Handle a change in the floppy input
*
* @param event
*/
private renderCdrom() {
const { cdrom } = this.props;
return (
<fieldset>
<legend>Drive D:</legend>
<input
id="cdrom-input"
type="file"
onChange={this.onChangeCdrom}
style={{ display: "none" }}
/>
<div className="settings-row">
<img className="settings-icon" src="../../static/cdrom.png" />
<p>
windows95 ships with a virtual CD-ROM drive. Mount an{" "}
<code>.iso</code> image here, then boot the machine to read it from
inside Windows.
</p>
</div>
<div className="field-row-stacked">
<label htmlFor="cdrom-path">Mounted image</label>
<input
id="cdrom-path"
type="text"
readOnly
value={cdrom ? cdrom.name : "(No disc in drive)"}
/>
</div>
<div className="settings-buttons">
<button
onClick={() =>
(document.querySelector("#cdrom-input") as any).click()
}
>
Mount image...
</button>
</div>
</fieldset>
);
}
private renderSmbShare() {
const { smbSharePath } = this.props;
return (
<fieldset>
<legend>\\HOST\HOST</legend>
<div className="settings-row">
<img className="settings-icon" src="../../static/show-disk-image.png" />
<p>
A folder on your computer is exposed inside Windows 95 as a network
drive. From inside Windows, open Start → Run and type{" "}
<code>\\HOST\HOST</code> — or use Map Network Drive to give it a
letter.
</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 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
@@ -191,34 +253,21 @@ 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() {
const statePath = await getStatePath();
if (fs.existsSync(statePath)) {
await fs.remove(statePath);
}
await resetState();
this.setState({ isStateReset: true });
}
}

View File

@@ -2,18 +2,64 @@ import * as React from "react";
export interface CardStartProps {
startEmulator: () => void;
navigate: (to: "start" | "settings") => 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.",
"Map a host folder as a network drive: open Start → Run inside Windows and type \\\\HOST\\HOST.",
"Your machine state is saved automatically when you quit. Reset it from Settings if things get weird.",
"Use the Machine menu in the menubar to send Ctrl+Alt+Del and other special key combos.",
];
export class CardStart extends React.Component<CardStartProps> {
private tip = TIPS[Math.floor(Math.random() * TIPS.length)];
public render() {
return (
<section id="section-start">
<button className="btn" id="win95" onClick={this.props.startEmulator}>
<img src="../../static/run.png" />
<span>Start Windows 95</span>
</button>
<small>Hit ESC to lock or unlock your mouse</small>
</section>
<div className="window welcome" id="welcome-window">
<div className="title-bar">
<div className="title-bar-text">Welcome</div>
<div className="title-bar-controls">
<button aria-label="Minimize" disabled />
<button aria-label="Maximize" disabled />
<button aria-label="Close" disabled />
</div>
</div>
<div className="window-body welcome-body">
<aside className="welcome-stripe">
<span>Windows&nbsp;95</span>
</aside>
<div className="welcome-main">
<h1 className="welcome-title">
Welcome to <span>Windows</span>
<small>95</small>
</h1>
<div className="welcome-tip">
<div className="welcome-tip-header">
<strong>Did you know...</strong>
</div>
<p>{this.tip}</p>
</div>
</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>
);
}
}

View File

@@ -0,0 +1,265 @@
// 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 = "/tmp/win95-probe.json";
const SCREEN_FILE = "/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],
};
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;
let scriptArmed = !!scriptCmd;
const tick = () => {
try {
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 {
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 {}
// 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;
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) verdict = "SUCCESS";
// Timeout
else if (uptimeSec > 180) verdict = "FAIL_OTHER";
return {
ts: new Date().toISOString(),
uptimeSec: Math.round(uptimeSec),
phase, cpuRunning: running,
instructionCounter: instr,
instructionDelta: instrDelta,
textScreen: textScreen.slice(0, 2000),
textHash, gfxW, gfxH, dominantColor,
verdict,
};
}
function hashStr(s: string): string {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
return (h >>> 0).toString(16);
}

View File

@@ -3,11 +3,15 @@ import * as React from "react";
interface EmulatorInfoProps {
toggleInfo: () => void;
emulator: any;
hidden: boolean;
}
interface EmulatorInfoState {
cpu: number;
disk: string;
diskRead: number;
diskWrite: number;
netRx: number;
netTx: number;
lastCounter: number;
lastTick: number;
}
@@ -16,33 +20,49 @@ 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,
};
}
public render() {
const { cpu, disk } = this.state;
const { cpu, diskRead, diskWrite, netRx, netTx } = this.state;
const { hidden, toggleInfo } = this.props;
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" : ""}>
CPU: <span>{cpu}M/s</span> | Disk:{" "}
<span>R {this.rate(diskRead)}</span>{" "}
<span>W {this.rate(diskWrite)}</span> | Net:{" "}
<span>{this.rate(netRx)}</span> <span>{this.rate(netTx)}</span> |{" "}
<a href="#" onClick={toggleInfo}>
{hidden ? "Pin" : "Hide"}
</a>
</div>
</>
);
}
@@ -74,26 +94,22 @@ export class EmulatorInfo extends React.Component<
if (!emulator) {
console.log(
`Emulator info: Tried to install listeners, but emulator not defined yet.`
`Emulator info: Tried to install listeners, but emulator not defined yet.`,
);
return;
}
// CPU
if (this.cpuInterval > -1) {
clearInterval(this.cpuInterval);
if (this.tickInterval > -1) {
clearInterval(this.tickInterval);
}
// TypeScript think's we're using a Node.js setInterval. We're not.
this.cpuInterval = setInterval(this.cpuCount, 500) as unknown as number;
this.tickInterval = setInterval(this.tick, 500) as unknown as number;
// Disk
emulator.add_listener("ide-read-start", this.onIDEReadStart);
emulator.add_listener("ide-read-end", this.onIDEReadWriteEnd);
emulator.add_listener("ide-write-end", this.onIDEReadWriteEnd);
// Screen
emulator.add_listener("screen-set-size-graphical", console.log);
emulator.add_listener("ide-read-end", this.onIDEReadEnd);
emulator.add_listener("ide-write-end", this.onIDEWriteEnd);
emulator.add_listener("eth-receive-end", this.onEthReceiveEnd);
emulator.add_listener("eth-transmit-end", this.onEthTransmitEnd);
}
/**
@@ -104,63 +120,72 @@ export class EmulatorInfo extends React.Component<
if (!emulator) {
console.log(
`Emulator info: Tried to uninstall listeners, but emulator not defined yet.`
`Emulator info: Tried to uninstall listeners, but emulator not defined yet.`,
);
return;
}
// CPU
if (this.cpuInterval > -1) {
clearInterval(this.cpuInterval);
if (this.tickInterval > -1) {
clearInterval(this.tickInterval);
}
// Disk
emulator.remove_listener("ide-read-start", this.onIDEReadStart);
emulator.remove_listener("ide-read-end", this.onIDEReadWriteEnd);
emulator.remove_listener("ide-write-end", this.onIDEReadWriteEnd);
emulator.remove_listener("ide-read-end", this.onIDEReadEnd);
emulator.remove_listener("ide-write-end", this.onIDEWriteEnd);
emulator.remove_listener("eth-receive-end", this.onEthReceiveEnd);
emulator.remove_listener("eth-transmit-end", this.onEthTransmitEnd);
}
// Screen
emulator.remove_listener("screen-set-size-graphical", console.log);
private onIDEReadEnd(args: number[]) {
this.diskReadBytes += args[1];
}
private onIDEWriteEnd(args: number[]) {
this.diskWriteBytes += args[1];
}
private onEthReceiveEnd(args: number[]) {
this.netRxBytes += args[0];
}
private onEthTransmitEnd(args: number[]) {
this.netTxBytes += args[0];
}
/**
* The virtual IDE is handling read (start).
* Format bytes/sec into a compact human string.
*/
private onIDEReadStart() {
this.requestIdle(() => this.setState({ disk: "Read" }));
private rate(bytesPerSec: number) {
if (bytesPerSec <= 0) return "0";
if (bytesPerSec < 1024) return `${bytesPerSec}B/s`;
if (bytesPerSec < 1024 * 1024) return `${Math.round(bytesPerSec / 1024)}K/s`;
return `${(bytesPerSec / 1024 / 1024).toFixed(1)}M/s`;
}
/**
* 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({
lastTick: now,
lastCounter: instructionCounter,
cpu: Math.round(ips / deltaTime),
cpu: Math.round(ips / deltaTime / 1000),
diskRead: Math.round(this.diskReadBytes / deltaSec),
diskWrite: Math.round(this.diskWriteBytes / deltaSec),
netRx: Math.round(this.netRxBytes / deltaSec),
netTx: Math.round(this.netTxBytes / deltaSec),
});
this.diskReadBytes = 0;
this.diskWriteBytes = 0;
this.netRxBytes = 0;
this.netTxBytes = 0;
}
}

View File

@@ -1,23 +1,34 @@
import * as React from "react";
import * as fs from "fs-extra";
import * as fs from "fs";
import * as path from "path";
import { ipcRenderer, shell } from "electron";
import { ipcRenderer, shell, webUtils } from "electron";
import { CONSTANTS, IPC_COMMANDS } from "../constants";
import { getDiskImageSize } from "../utils/disk-image-size";
import { CardStart } from "./card-start";
import { StartMenu } from "./start-menu";
import { CardSettings } from "./card-settings";
import { EmulatorInfo } from "./emulator-info";
import { CardDrive } from "./card-drive";
import { getStatePath } from "./utils/get-state-path";
import { Win95Window } from "./app";
import { resetState } from "./utils/reset-state";
import { setupSmbShare } from "./smb";
import { startProbe } from "./debug-harness";
const PROBE = process.env.WIN95_PROBE === "1";
const PROBE_OPTS: Record<string, unknown> = (() => {
try { return JSON.parse(process.env.WIN95_PROBE_OPTS || "{}"); }
catch { return {}; }
})();
declare let window: Win95Window;
export interface EmulatorState {
currentUiCard: string;
currentUiCard: "start" | "settings";
emulator?: any;
scale: number;
floppyFile?: File;
cdromFile?: File;
smbSharePath: string;
isBootingFresh: boolean;
isCursorCaptured: boolean;
isInfoDisplayed: boolean;
@@ -38,11 +49,12 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.bootFromScratch = this.bootFromScratch.bind(this);
this.state = {
isBootingFresh: false,
isBootingFresh: PROBE,
isCursorCaptured: false,
isRunning: false,
currentUiCard: "start",
isInfoDisplayed: true,
smbSharePath: "",
// We can start pretty large
// If it's too large, it'll just grow until it hits borders
scale: 2,
@@ -52,8 +64,14 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.setupIpcListeners();
this.setupUnloadListeners();
if (document.location.hash.includes("AUTO_START")) {
this.startEmulator();
ipcRenderer.invoke(IPC_COMMANDS.GET_SMB_SHARE_PATH).then((p: string) => {
this.setState({ smbSharePath: p });
});
if (PROBE) {
// Skip the start card; boot fresh immediately. The 100ms delay
// lets React mount the #emulator div first.
setTimeout(() => this.bootFromScratch(), 100);
}
}
@@ -86,6 +104,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.lockMouse();
}
});
// Only forward mouse input to the VM while the pointer is actually
// captured. 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);
});
}
/**
@@ -188,6 +216,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
return null;
}
const navigate = (currentUiCard: "start" | "settings") =>
this.setState({ currentUiCard });
let card;
if (currentUiCard === "settings") {
@@ -195,25 +226,25 @@ 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}
navigate={navigate}
/>
);
} else if (currentUiCard === "drive") {
card = <CardDrive showDiskImage={this.showDiskImage} />;
} else {
card = <CardStart startEmulator={this.startEmulator} />;
card = (
<CardStart startEmulator={this.startEmulator} navigate={navigate} />
);
}
return (
<>
{card}
<StartMenu
navigate={(target) => this.setState({ currentUiCard: target })}
/>
</>
);
return <section>{card}</section>;
}
/**
@@ -225,8 +256,8 @@ export class Emulator extends React.Component<{}, EmulatorState> {
{this.renderInfo()}
{this.renderUI()}
<div id="emulator">
<div></div>
<canvas></canvas>
<div id="emulator-text-screen"></div>
<canvas id="emulator-canvas"></canvas>
</div>
</>
);
@@ -236,13 +267,10 @@ 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}
hidden={!this.state.isInfoDisplayed}
toggleInfo={() => {
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
}}
@@ -263,11 +291,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
*/
public showDiskImage() {
// Contents/Resources/app/dist/static
const imagePath = path.join(__dirname, "../../images/windows95.img");
console.log(`Showing disk image in ${CONSTANTS.IMAGE_PATH}`);
console.log(`Showing disk image in ${imagePath}`);
shell.showItemInFolder(imagePath);
shell.showItemInFolder(CONSTANTS.IMAGE_PATH);
}
/**
@@ -276,19 +302,23 @@ export class Emulator extends React.Component<{}, EmulatorState> {
private async startEmulator() {
document.body.classList.remove("paused");
const cdrom: any = {};
const cdromFile: any = this.state.cdromFile;
if (cdromFile?.path) {
cdrom.url = cdromFile.path;
cdrom.async = true;
cdrom.size = await getDiskImageSize(cdromFile.path);
}
const cdromPath = this.state.cdromFile
? webUtils.getPathForFile(this.state.cdromFile)
: null;
const options = {
wasm_path: path.join(__dirname, "build/v86.wasm"),
memory_size: 128 * 1024 * 1024,
vga_memory_size: 32 * 1024 * 1024,
screen_container: document.getElementById("emulator"),
vga_memory_size: 64 * 1024 * 1024,
screen: {
container: document.getElementById("emulator"),
scale: 0,
},
preserve_mac_from_state_image: true,
net_device: {
relay_url: "fetch",
type: "ne2k",
},
bios: {
url: path.join(__dirname, "../../bios/seabios.bin"),
},
@@ -300,20 +330,45 @@ export class Emulator extends React.Component<{}, EmulatorState> {
async: true,
size: await getDiskImageSize(CONSTANTS.IMAGE_PATH),
},
fda: {
buffer: this.state.floppyFile,
},
cdrom: cdrom,
fda: this.state.floppyFile
? {
buffer: this.state.floppyFile,
}
: undefined,
cdrom: cdromPath
? {
url: cdromPath,
async: true,
size: await getDiskImageSize(cdromPath),
}
: undefined,
boot_order: 0x132,
// One day, maybe!
// network_relay_url: "ws://localhost:8080/"
};
// PROBE_OPTS lets the outer harness override options without rebuilding
// (e.g. WIN95_PROBE_OPTS='{"acpi":false,"disable_jit":true}')
Object.assign(options, PROBE_OPTS);
console.log(`🚜 Starting emulator with options`, options);
window["emulator"] = new V86Starter(options);
window["emulator"] = new V86(options);
// 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);
}
if (PROBE) {
startProbe(window["emulator"]);
}
// New v86 instance
// Mouse stays disabled until the pointerlockchange listener confirms the
// cursor is actually captured.
window["emulator"].mouse_set_status(false);
this.setState({
emulator: window["emulator"],
isRunning: true,
@@ -361,19 +416,23 @@ export class Emulator extends React.Component<{}, EmulatorState> {
await this.saveState();
this.unlockMouse();
emulator.stop();
await emulator.stop();
this.setState({ isRunning: false });
this.resetCanvas();
document.body.classList.add("paused");
ipcRenderer.send(IPC_COMMANDS.MACHINE_STOPPED);
}
/**
* Reset the emulator by reloading the whole page (lol)
* Reset the emulator by reloading the whole page
*/
private async resetEmulator() {
this.isResetting = true;
document.location.hash = `#AUTO_START`;
await this.stopEmulator();
await resetState();
document.location.reload();
}
@@ -385,49 +444,45 @@ export class Emulator extends React.Component<{}, EmulatorState> {
const { emulator } = this.state;
const statePath = await getStatePath();
return new Promise((resolve) => {
if (!emulator || !emulator.save_state) {
console.log(`restoreState: No emulator present`);
return resolve();
}
if (!emulator || !emulator.save_state) {
console.log(`restoreState: No emulator present`);
return;
}
emulator.save_state(async (error: Error, newState: ArrayBuffer) => {
if (error) {
console.warn(`saveState: Could not save state`, error);
return resolve();
}
await fs.outputFile(statePath, Buffer.from(newState));
console.log(`saveState: Saved state to ${statePath}`);
resolve();
try {
const newState = await emulator.save_state();
await fs.promises.writeFile(statePath, Buffer.from(newState), {
flush: true,
});
});
} catch (error) {
console.warn(`saveState: Could not save state`, error);
}
}
/**
* Restores state to the emulator.
*/
private async restoreState() {
const { emulator } = this.state;
const { emulator, isBootingFresh } = this.state;
const state = await this.getState();
// Nothing to do with if we don't have a state
if (!state) {
if (isBootingFresh) {
console.log(`restoreState: Booting fresh, not restoring.`);
return;
} else if (!state) {
console.log(`restoreState: No state present, not restoring.`);
}
if (!emulator) {
return;
} else if (!emulator) {
console.log(`restoreState: No emulator present`);
return;
}
try {
this.state.emulator.restore_state(state);
await this.state.emulator.restore_state(state);
} catch (error) {
console.log(
`State: Could not read state file. Maybe none exists?`,
error
`restoreState: Could not read state file. Maybe none exists?`,
error,
);
}
}
@@ -446,20 +501,14 @@ export class Emulator extends React.Component<{}, EmulatorState> {
if (fs.existsSync(statePath)) {
return fs.readFileSync(statePath).buffer;
} else {
console.log(`getState: No state file found at ${statePath}`);
}
return null;
}
private unlockMouse() {
const { emulator } = this.state;
this.setState({ isCursorCaptured: false });
if (emulator) {
emulator.mouse_set_status(false);
}
document.exitPointerLock();
}
@@ -467,12 +516,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
const { emulator } = this.state;
if (emulator) {
this.setState({ isCursorCaptured: true });
emulator.mouse_set_status(true);
emulator.lock_mouse();
} else {
console.warn(
`Emulator: Tried to lock mouse, but no emulator or not running`
`Emulator: Tried to lock mouse, but no emulator or not running`,
);
}
}
@@ -509,4 +556,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
this.state.emulator.keyboard_send_scancodes(scancodes);
}
}
/**
* Reset the canvas
*/
private resetCanvas() {
const canvas = document.getElementById("emulator-canvas");
if (canvas instanceof HTMLCanvasElement) {
const ctx = canvas.getContext("2d");
ctx?.clearRect(0, 0, canvas.width, canvas.height);
}
}
}

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
# 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: don't pick NT LM 0.12 unless you implement the NT response
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 send the
13-word LANMAN-style negotiate response. If you pick `NT LM 0.12` and send 13
words, Win95 silently drops the connection — it expects the 17-word NT response
with capability flags. Pick `DOS LANMAN2.1` instead.
### 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` — 35 protocol tests, full round-trips with real file I/O.
Run: `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`

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

@@ -0,0 +1,197 @@
// 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 } from "./server";
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
const LOG_FILE = 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) {
log(`serving ${hostPath} on \\\\HOST\\host (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);
const handler = (data: Uint8Array) => {
for (const msg of framer.push(data)) {
if (msg.type === 0x81) {
log("← NB session request → +response");
conn.write(nbPositiveResponse());
} else if (msg.type === 0x00) {
const reply = session.handle(msg.payload);
if (reply) conn.write(nbWrap(reply));
}
}
};
// New v86 has .on(); old v86 had .on/.emit dead-code-eliminated by
// Closure into a flat .on_data callback property. Check for the method
// first, fall back to direct assignment.
if (typeof (conn as any).on === "function") {
conn.on("data", handler);
} else {
(conn as any).on_data = handler;
}
};
// New API: bus event (no-op on old v86 — event never fires)
emulator.bus.register("tcp-connection", (c: unknown) => {
const conn = c as TCPConnection;
if (conn.sport !== 139) return;
wireConn(conn);
conn.accept();
});
// Old API: monkey-patch adapter.on_tcp_connection. The adapter is created
// inside V86's async init, so poll for it.
//
// Instead of stealing the TCPConnection constructor (closure-scoped, brittle
// with new-on-stolen-ctor), we make the original handler build one for us
// by handing it a port-80 SYN — then RECONFIGURE that connection for 139.
// accept(packet) overwrites every routing field (sport/dport/hsrc/etc), and
// .on("data") overwrites the HTTP handler. The probe's fake SYN-ACK is eaten
// by shadowing adapter.receive (prototype method — `delete` to restore).
const tryHook = () => {
const adapter = emulator.network_adapter;
if (!adapter || typeof adapter.on_tcp_connection !== "function") return false;
const orig = adapter.on_tcp_connection.bind(adapter);
adapter.on_tcp_connection = function (packet: any, tuple: string): boolean {
if (packet.tcp.dport !== 139) return orig(packet, tuple);
// New v86 fires the tcp-connection bus event BEFORE this callback;
// if our bus handler already accepted the conn, it's in tcp_conn —
// claim it so the original (which would otherwise RST) doesn't run.
if (adapter.tcp_conn[tuple]) return true;
const adapterAny = adapter as any;
adapterAny.receive = () => {};
let conn: TCPConnection | undefined;
try {
const fakeTuple = "__nbt__";
orig({ ...packet, tcp: { ...packet.tcp, dport: 80 } }, fakeTuple);
conn = adapter.tcp_conn[fakeTuple];
delete adapter.tcp_conn[fakeTuple];
} finally {
delete adapterAny.receive;
}
if (!conn) {
log("⚠ probe didn't yield a connection; RST");
return false;
}
// Re-aim it at port 139. accept() overwrites sport/dport/hsrc/psrc/seq/ack
// from the packet; .on("data") replaces the HTTP handler (assignment, not
// push). Only state needs explicit reset — the probe accept set it to
// "established" and we want a fresh handshake.
conn.tuple = tuple;
conn.state = "syn-received";
wireConn(conn);
try {
conn.accept(packet);
} catch (e) {
log("accept threw:", e instanceof Error ? e.message : String(e));
return false;
}
adapter.tcp_conn[tuple] = conn;
return true;
};
log("hooked adapter.on_tcp_connection (old API, conn-recycling)");
return true;
};
if (!tryHook()) {
const poll = setInterval(() => { if (tryHook()) clearInterval(poll); }, 100);
setTimeout(() => clearInterval(poll), 10000);
}
}

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,154 @@
// 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_ANDX = 0x2e;
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_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_CLOSE]: "CLOSE",
[CMD_TRANSACTION]: "TRANS(RAP)",
[CMD_TRANSACTION2]: "TRANS2",
[CMD_ECHO]: "ECHO",
[CMD_QUERY_INFORMATION]: "QUERY_INFO",
[CMD_QUERY_INFORMATION2]: "QUERY_INFO2",
[CMD_FIND_CLOSE2]: "FIND_CLOSE2",
[CMD_CHECK_DIRECTORY]: "CHECK_DIR",
[CMD_SEARCH]: "SEARCH",
};

View File

@@ -0,0 +1,308 @@
// Standalone test of the SMB stack — no v86, no Electron. Feeds canned
// requests through NetBIOSFramer + SmbSession and inspects responses.
// Run: npx ts-node src/renderer/smb/test-standalone.ts
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 === 13, "13-word LM response");
// word[0] = dialect index — we pick LANMAN2.1 (idx 3) since our 13-word
// response is the LANMAN format; picking NT LM 0.12 would require the
// 17-word NT response which we don't implement
const pickedIdx = parsed.words[0] | (parsed.words[1] << 8);
ok(pickedIdx === 3, `picked LANMAN2.1 (idx ${pickedIdx})`);
}
// ─── 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: . .. _MAPZ.BAT(virtual) hello.txt subdir = 5
ok(searchCount === 5, `found ${searchCount} entries (expect 5)`);
// Data block has the entries — just verify they're in there somewhere
const dataStr = String.fromCharCode(...parsed.bytes);
ok(dataStr.includes("_MAPZ.BAT"), "virtual _MAPZ.BAT in listing");
ok(dataStr.includes("hello.txt"), "hello.txt in listing");
ok(dataStr.includes("subdir"), "subdir in listing");
}
// ─── 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: open and read _MAPZ.BAT
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")], 1, 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)], [], 1, 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))}`);
}
{
// 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);

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

@@ -0,0 +1,50 @@
// 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;
}
get length() { return this.chunks.length; }
build() { return new Uint8Array(this.chunks); }
}

View File

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

View File

@@ -0,0 +1,14 @@
import fs from "fs";
import { getStatePath } from "./get-state-path";
export async function resetState() {
const statePath = await getStatePath();
if (fs.existsSync(statePath)) {
try {
await fs.promises.unlink(statePath);
} catch (error) {
console.error(`Failed to delete state file: ${error}`);
}
}
}

View File

@@ -1,4 +1,4 @@
import * as fs from "fs-extra";
import * as fs from "fs";
import { CONSTANTS } from "../constants";
@@ -9,7 +9,7 @@ import { CONSTANTS } from "../constants";
*/
export async function getDiskImageSize(path: string) {
try {
const stats = await fs.stat(path);
const stats = await fs.promises.stat(path);
if (stats) {
return stats.size;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -5,7 +5,7 @@
<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/vendor/98.css">
<link rel="stylesheet" href="../src/less/root.less">
<!-- libv86 -->
</head>

20
static/www/apps.htm Normal file
View File

@@ -0,0 +1,20 @@
<html>
<head>
<title>windows95 Help</title>
</head>
<body bgcolor="#C0C0C0">
<table width="100%" cellpadding="10" cellspacing="0">
<tr>
<td>
<font face="Arial" color="#000000">
<font size="5"><b>windows95 Apps & Games</b></font>
<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>
</font>
</td>
</tr>
</table>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
static/www/buttons/msie.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

31
static/www/credits.htm Normal file
View File

@@ -0,0 +1,31 @@
<html>
<head>
<title>Windows 95 Credits</title>
</head>
<body bgcolor="#C0C0C0">
<table width="100%" cellpadding="10" cellspacing="0">
<tr>
<td>
<font face="Arial" color="#000000">
<font size="5"><b>windows95 Credits</b></font>
<hr>
<h3>Emulation Engine</h3>
<p>
None of this would be possible without the people working on v86, in particular Fabian Hemmer aka copy.
You can visit his website at <a href="http://copy.sh" target="_blank">copy.sh</a>. It also wouldn't be
possible without the QEMU project. If you enjoy running old systems, you probably want QEMU - windows95
is merely a toy, QEMU lets you actually run old systems in a stable manner.
</p>
<h3>Electron</h3>
<p>
Electron is a framework for building desktop applications using web technologies. It's what powers windows95.
You can visit the project's website at electronjs.org (in a modern browser, not in windows95).
</p>
</font>
</td>
</tr>
</table>
</body>
</html>

34
static/www/help.htm Normal file
View File

@@ -0,0 +1,34 @@
<html>
<head>
<title>windows95 Help</title>
</head>
<body bgcolor="#C0C0C0">
<table width="100%" cellpadding="10" cellspacing="0">
<tr>
<td>
<font face="Arial" color="#000000">
<font size="5"><b>windows95 Help</b></font>
<hr>
<h3>MS-DOS Display Issues</h3>
<p>If MS-DOS seems to mess up the screen:</p>
<ol>
<li>Hit <code>Alt + Enter</code> to make the command screen "Full Screen" (as far as Windows 95 is concerned)</li>
<li>This should restore the display from the garbled mess you see and allow you to access the Command Prompt</li>
<li>Press Alt-Enter again to leave Full Screen and go back to Window Mode</li>
</ol>
<p><i>(Thanks to @DisplacedGamers for that wisdom)</i></p>
<h3>windows95 Stuck in Bad State</h3>
<p>If windows95 becomes unresponsive or stuck:</p>
<ol>
<li>On the app's home screen, select "Settings" in the lower menu</li>
<li>Delete your machine's state before starting it again</li>
<li>This should resolve the issue when you restart</li>
</ol>
</font>
</td>
</tr>
</table>
</body>
</html>

37
static/www/home.htm Normal file
View File

@@ -0,0 +1,37 @@
<html>
<head>
<title>Welcome to Windows 95!</title>
</head>
<body bgcolor="#C0C0C0">
<table width="100%" cellpadding="10" cellspacing="0">
<tr>
<td>
<center>
<marquee scrollamount="3">
<font face="Arial" size="6" color="#000000">
<blink>Welcome to Windows 95!</blink>
</font>
</marquee>
</center>
<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>
<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>
</font>
<center>
<font size="2" color="#000000">Last updated: 2025</font>
</center>
</td>
</tr>
</table>
</body>
</html>

BIN
static/www/images/bg.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

BIN
static/www/images/doc.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
static/www/images/help.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

BIN
static/www/images/ie.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 661 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 B

21
static/www/index.htm Normal file
View File

@@ -0,0 +1,21 @@
<html>
<head>
<title>Welcome to Windows 95!</title>
</head>
<frameset cols="200,*" border="0" framespacing="0" frameborder="NO">
<frame src="navigation.htm" name="nav" scrolling="auto" noresize>
<frame src="home.htm" name="main" scrolling="auto" noresize>
<noframes>
<body bgcolor="#000080">
<font face="Arial" color="#FFFFFF">
<h2>Frame Alert!</h2>
<p>This page uses frames, but your browser doesn't support them.</p>
<p>Please upgrade to Netscape Navigator 2.0 or Internet Explorer 3.0!</p>
</font>
</body>
</noframes>
</frameset>
</html>

46
static/www/navigation.htm Normal file
View File

@@ -0,0 +1,46 @@
<html>
<head>
<title>Navigation</title>
</head>
<body bgcolor="#C0C0C0" background="images/bg.gif">
<table width="100%" cellpadding="4" cellspacing="1" bgcolor="#000000">
<tr><td bgcolor="#C0C0C0">
<font face="Arial" size="2" color="#000000">
<img src="images/folder.gif" width="16" height="16" border="0" align="absmiddle"> <b>Navigation</b>
</font>
</td></tr>
<tr><td bgcolor="#C0C0C0">
<font face="Arial" size="2" color="#000000">
<img src="images/desktop.gif" width="16" height="16" border="0" align="absmiddle"> <a href="home.htm" target="main">Home</a>
</font>
</td></tr>
<tr><td bgcolor="#C0C0C0">
<font face="Arial" size="2" color="#000000">
<img src="images/programs.gif" width="16" height="16" border="0" align="absmiddle"> <a href="apps.htm" target="main">Apps & Games</a>
</font>
</td></tr>
<tr><td bgcolor="#C0C0C0">
<font face="Arial" size="2" color="#000000">
<img src="images/help.gif" width="16" height="16" border="0" align="absmiddle"> <a href="help.htm" target="main">Help</a>
</font>
</td></tr>
<tr><td bgcolor="#C0C0C0">
<font face="Arial" size="2" color="#000000">
<img src="images/doc.gif" width="16" height="16" border="0" align="absmiddle"> <a href="credits.htm" target="main">Credits</a>
</font>
</td></tr>
</table>
<br>
<center>
<p>
<font face="Arial" size="1" color="#000000">
Best viewed with<br>
Internet Explorer 5.5 and windows95
</font>
</p>
<img src="buttons/madewithelectron.gif" width="88" height="31" border="0" align="absmiddle">
<img src="buttons/macos.gif" width="88" height="31" border="0" align="absmiddle">
<img src="buttons/msie.gif" width="88" height="31" border="0" align="absmiddle">
</center>
</body>
</html>

80
tools/bisect-v86.sh Executable file
View 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

View File

@@ -2,25 +2,46 @@
const Bundler = require('parcel-bundler')
const path = require('path')
const fs = require('fs-extra')
const fs = require('fs')
// 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(__dirname, '../dist/static')
const lib = path.join(__dirname, '../src/renderer/lib')
const index = path.join(target, 'index.html')
// Copy in lib
await fs.copy(lib, target)
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')
const patchedLibv86 = libv86.replace('v86util.load_file="undefined"===typeof XMLHttpRequest', 'v86util.load_file="undefined"!==typeof XMLHttpRequest')
fs.writeFileSync(libv86path, patchedLibv86)
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`)
}
// Overwrite
const indexContents = fs.readFileSync(index, 'utf-8');
const replacedContents = indexContents.replace('<!-- libv86 -->', '<script src="libv86.js"></script>')
const replacedContents = indexContents.replace('<!-- libv86 -->', LIBV86_SHIM)
fs.writeFileSync(index, replacedContents)
}
@@ -48,7 +69,7 @@ async function compileParcel (options = {}) {
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: true, // Enable or disable sourcemaps, defaults to enabled (minified builds currently always create sourcemaps)
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
@@ -63,8 +84,6 @@ async function compileParcel (options = {}) {
await copyLib();
}
module.exports = {
compileParcel
}

76
tools/probe-boot.sh Executable file
View 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 (parcel only — forge's generateAssets does this too but we want
# direct control without the forge startup overhead)
rm -rf dist .cache
node tools/parcel-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

26
tools/resedit.js Normal file
View File

@@ -0,0 +1,26 @@
const path = require('path');
const resedit = require('../node_modules/@electron/packager/dist/resedit.js')
const package = require('../package.json');
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,
"iconPath": path.join(__dirname, "../assets/icon.ico"),
"win32Metadata": {
"FileDescription": package.productName,
"InternalName": package.name,
"OriginalFilename": `${package.name}.exe`,
"ProductName": package.productName,
"CompanyName": package.author
}
});
}
main();

189
tools/update-v86.js Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env node
/**
* Updates v86 by building the wasm from a local checkout. The libv86.js +
* v86.wasm pair MUST be ABI-matched — copy.sh historically rebuilds the JS
* without rebuilding the wasm, and a mismatch silently breaks fresh boot
* (state restore still works because the CPU snapshot is opaque, so you
* won't notice until Win95 BSODs at the splash screen with "Invalid VxD
* dynamic link call").
*
* Usage:
* node tools/update-v86.js [path/to/v86] # builds wasm from source
* node tools/update-v86.js --js-only # just download libv86.js
*
* The wasm build needs `rustup target add wasm32-unknown-unknown` and clang.
* libv86.js needs Java + Closure; if you don't have those, --js-only fetches
* from copy.sh and warns if its Last-Modified is far from your wasm build.
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { execSync } = require('child_process');
const LIB_DIR = path.join(__dirname, '../src/renderer/lib');
const V86_DIR = process.argv.find(a => a !== process.argv[0] && a !== process.argv[1] && !a.startsWith('--'))
|| path.resolve(__dirname, '../../v86');
const JS_ONLY = process.argv.includes('--js-only');
const SKEW_DAYS = 14;
function head(url) {
return new Promise((resolve, reject) => {
https.request(url, { method: 'HEAD' }, (res) => {
resolve({ status: res.statusCode, lastModified: res.headers['last-modified'] });
}).on('error', reject).end();
});
}
function download(url, dest) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
if (res.statusCode !== 200) return reject(new Error(`${url} → HTTP ${res.statusCode}`));
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
fs.writeFileSync(dest, buf);
console.log(` ${path.basename(dest)}: ${(buf.length / 1024).toFixed(0)} KB`);
resolve(res.headers['last-modified']);
});
}).on('error', reject);
});
}
/**
* v86 commit 1b90d2e7 (May 2025) changed ATA Command Block register writes
* to only target current_interface instead of both master and slave. Those
* registers (ports 0x1F1-0x1F6) are channel-shared per the ATA spec — both
* drives on the cable see the same register file. Win95's ESDI_506.PDR
* writes them, switches drive-select, expects them to still be there.
* Result: IDE IRQ never fires, splash screen hang.
*
* Found via JS-only bisect: prod wasm + freshly-built libv86.js, parent
* 3c944a02 boots, 1b90d2e7 hangs deterministically.
*/
function patchIdeSharedRegisters(ideJsPath) {
let s = fs.readFileSync(ideJsPath, 'utf-8');
const re = /this\.current_interface\.(\w+_reg) = \(this\.current_interface\.\1 << 8 \| data\) & 0xFFFF;/g;
const matches = [...s.matchAll(re)];
if (matches.length === 0) {
console.log(' ide.js: shared-register patch already applied or upstream fixed it');
return;
}
if (matches.length < 5) {
throw new Error(`ide.js: expected ≥5 register write sites, found ${matches.length} — pattern changed`);
}
s = s.replace(re, (_, reg) =>
`this.master.${reg} = (this.master.${reg} << 8 | data) & 0xFFFF;\n` +
` this.slave.${reg} = (this.slave.${reg} << 8 | data) & 0xFFFF;`
);
fs.writeFileSync(ideJsPath, s);
console.log(` ide.js: restored shared-register writes (${matches.length} sites)`);
}
async function main() {
const jsDest = path.join(LIB_DIR, 'libv86.js');
const wasmDest = path.join(LIB_DIR, 'build/v86.wasm');
// ─── source patch (before any build) ─────────────────────────────────────
if (!JS_ONLY) {
const ideJs = path.join(V86_DIR, 'src/ide.js');
if (fs.existsSync(ideJs)) {
patchIdeSharedRegisters(ideJs);
}
}
// ─── wasm ────────────────────────────────────────────────────────────────
let wasmDate;
if (JS_ONLY) {
if (!fs.existsSync(wasmDest)) {
throw new Error(`--js-only requires an existing wasm at ${wasmDest}`);
}
wasmDate = fs.statSync(wasmDest).mtime;
console.log(`Keeping existing wasm (${wasmDate.toISOString().slice(0, 10)})`);
} else {
if (!fs.existsSync(path.join(V86_DIR, 'Makefile'))) {
throw new Error(`No v86 checkout at ${V86_DIR}. Clone copy/v86 there or pass a path.`);
}
const head = execSync('git log -1 --format="%h %ci"', { cwd: V86_DIR }).toString().trim();
console.log(`Building wasm from ${V86_DIR} @ ${head}`);
execSync('make build/v86.wasm', { cwd: V86_DIR, stdio: 'inherit' });
fs.copyFileSync(path.join(V86_DIR, 'build/v86.wasm'), wasmDest);
wasmDate = new Date();
console.log(` v86.wasm: ${(fs.statSync(wasmDest).size / 1024).toFixed(0)} KB`);
}
// ─── libv86.js ───────────────────────────────────────────────────────────
// Build from source if Closure is available; otherwise fetch and check skew.
const hasClosure = !JS_ONLY && fs.existsSync(path.join(V86_DIR, 'closure-compiler/compiler.jar'));
if (hasClosure) {
console.log('Building libv86.js (Closure)…');
execSync('make build/libv86.js', { cwd: V86_DIR, stdio: 'inherit' });
fs.copyFileSync(path.join(V86_DIR, 'build/libv86.js'), jsDest);
console.log(` libv86.js: ${(fs.statSync(jsDest).size / 1024).toFixed(0)} KB`);
} else {
console.log('No Closure jar — fetching libv86.js from copy.sh');
const lm = await download('https://copy.sh/v86/build/libv86.js', jsDest);
const jsDate = new Date(lm);
const skew = Math.abs(jsDate - wasmDate) / 86400000;
console.log(` JS: ${jsDate.toISOString().slice(0, 10)}`);
console.log(` wasm: ${wasmDate.toISOString().slice(0, 10)}`);
if (skew > SKEW_DAYS) {
throw new Error(
`JS and wasm are ${skew.toFixed(0)} days apart. ` +
`Either install Closure (java + v86/closure-compiler/compiler.jar) ` +
`to build libv86.js from the same commit, or git-checkout v86 to a ` +
`commit near ${jsDate.toISOString().slice(0, 10)} and rebuild the wasm.`
);
}
}
// ─── BIOS ────────────────────────────────────────────────────────────────
// SeaBIOS sets up the interrupt controller for whatever the emulated
// hardware presents. New v86 + old BIOS = APIC never armed = IDE IRQs
// never fire = boot hangs at the splash screen with no disk activity.
if (!JS_ONLY) {
const biosDir = path.join(__dirname, '../bios');
for (const f of ['seabios.bin', 'vgabios.bin']) {
fs.copyFileSync(path.join(V86_DIR, 'bios', f), path.join(biosDir, f));
console.log(` ${f}: ${(fs.statSync(path.join(biosDir, f)).size / 1024).toFixed(0)} KB`);
}
}
// ─── patch: phantom slave drive ──────────────────────────────────────────
// v86 bug since 1b90d2e7 (May 2025 IDE refactor): cpu.js does
// ide_config[0][1] = { buffer: settings.hdb }
// unconditionally inside the `if(settings.hda)` block. When hdb is
// undefined this creates a phantom 0-size HD on primary slave; Win95's
// ESDI_506.PDR detects it, sends IDENTIFY, and spins forever waiting for
// DRQ from a drive that has no sectors. State restore skips driver init,
// so it only bites on fresh boot.
//
// The pattern is structurally stable: `buffer` and `hdb` are option keys
// (externed, not mangled), `[0][1]=` is literal.
let js = fs.readFileSync(jsDest, 'utf-8');
const phantom = /(\w+)\[0\]\[1\]=\{buffer:(\w+)\.hdb\}/g;
const matches = [...js.matchAll(phantom)];
if (matches.length !== 1) {
throw new Error(
`phantom-slave patch: expected exactly 1 match, found ${matches.length}. ` +
`Either v86 fixed this upstream (good — remove this patch) or the ` +
`pattern changed. Check src/cpu.js around ide_config[0][1].`
);
}
js = js.replace(phantom, '$2.hdb&&($1[0][1]={buffer:$2.hdb})');
fs.writeFileSync(jsDest, js);
console.log(' patched: phantom slave drive guard (1 site)');
// ─── sanity ──────────────────────────────────────────────────────────────
if (!js.includes('process.versions.node'))
throw new Error('libv86 lost the process.versions.node check (file loader regression)');
if (!/this\.fetch=\([^)]*\)=>fetch\(/.test(js))
throw new Error('libv86 lost the fetch arrow wrapper');
if (!js.includes('window.V86=') && !js.includes('module.exports.V86='))
throw new Error('libv86 export pattern changed — check the runtime shim');
console.log('✓ installed (sanity checks pass)');
}
main().catch((e) => { console.error('✗', e.message); process.exit(1); });

View File

@@ -8,27 +8,25 @@
"preserveConstEnums": true,
"sourceMap": true,
"lib": [
"es2017",
"es2023",
"dom"
],
"noImplicitAny": true,
"noImplicitReturns": true,
"suppressImplicitAnyIndexErrors": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noImplicitThis": true,
"noUnusedParameters": true,
"importHelpers": true,
"noEmitHelpers": false,
"module": "commonjs",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"pretty": true,
"target": "es2017",
"target": "es2023",
"jsx": "react",
"typeRoots": [
"./node_modules/@types"
],
"baseUrl": "."
]
},
"include": [
"src/**/*"

8732
yarn.lock

File diff suppressed because it is too large Load Diff