Compare commits
38 Commits
v3.1.0
...
claude/kin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e25ed3ab | ||
|
|
45f5a136b2 | ||
|
|
2d34183e14 | ||
|
|
00943ae4da | ||
|
|
a6d57c6538 | ||
|
|
35f7c3362d | ||
|
|
94021edb61 | ||
|
|
6f2025ffc0 | ||
|
|
3a7b37fff0 | ||
|
|
16eb63e13b | ||
|
|
54fe721f4f | ||
|
|
6dee2f45a2 | ||
|
|
e7e047b0a0 | ||
|
|
5a334abb13 | ||
|
|
aacfae7ada | ||
|
|
9b87b77570 | ||
|
|
e6a0d931af | ||
|
|
973580d60b | ||
|
|
8fcf5eaed3 | ||
|
|
e15d918fb3 | ||
|
|
b442c6db08 | ||
|
|
5c946bbca4 | ||
|
|
c9e45a9f39 | ||
|
|
bc42ce3231 | ||
|
|
d91e72ccc5 | ||
|
|
bd40f00f8d | ||
|
|
1cbfca7451 | ||
|
|
7710c4b7af | ||
|
|
4cce1f0740 | ||
|
|
f8ae78f247 | ||
|
|
62f8eb2696 | ||
|
|
da4b0dd728 | ||
|
|
6cc05fa042 | ||
|
|
dda3707a23 | ||
|
|
a4bcd7fb61 | ||
|
|
17a8139346 | ||
|
|
489c7312d0 | ||
|
|
c3537ae330 |
15
.gitignore
vendored
@@ -1,10 +1,17 @@
|
||||
node_modules
|
||||
out
|
||||
src/images
|
||||
.DS_Store
|
||||
images
|
||||
images_new
|
||||
|
||||
/images*/
|
||||
/helper-images/
|
||||
|
||||
dist
|
||||
!.github/images
|
||||
*.code-workspace
|
||||
*.pfx
|
||||
*.pfx
|
||||
|
||||
Microsoft.Trusted.Signing.Client*
|
||||
trusted-signing-metadata.json
|
||||
.env
|
||||
electron-windows-sign.log
|
||||
.npmrc
|
||||
|
||||
21
HELP.md
@@ -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
|
||||
|
||||
59
README.md
@@ -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/v3.0.0/windows95-3.0.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/v3.0.0/windows95-win32-ia32-3.0.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/v3.0.0/windows95-3.0.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/v3.0.0/windows95-win32-x64-3.0.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/v3.0.0/windows95-3.0.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/v3.0.0/windows95-win32-arm64-3.0.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/v3.0.0/windows95-darwin-x64-3.0.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/v3.0.0/windows95-darwin-arm64-3.0.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>
|
||||
@@ -67,33 +67,28 @@ This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes,
|
||||
</td>
|
||||
<td>
|
||||
<span>64-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.0.0/windows95-3.0.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/v3.0.0/windows95_3.0.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/v3.0.0/windows95-3.0.0-1.arm64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.0.0/windows95_3.0.0_arm64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARMv7 (armhf)</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.0.0/windows95-3.0.0-1.armv7hl.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.0.0/windows95_3.0.0_armhf.deb">
|
||||
💿 deb
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||

|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="https://github.com/user-attachments/assets/43ab7126-765e-444b-ad14-27b1beadbc7c" width="100%" alt="Screenshot showing Windows 95">
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="https://github.com/user-attachments/assets/7ac5dc36-cbd4-4455-a616-0e5cca314b34" width="100%" alt="Screenshot showing Windows 95">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Does it work?
|
||||
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
|
||||
@@ -102,7 +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.
|
||||
|
||||
|
||||
BIN
bios/seabios.bin
BIN
bios/vgabios.bin
@@ -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
|
||||
```
|
||||
|
||||
42
docs/qemu.md
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
49
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "windows95",
|
||||
"productName": "windows95",
|
||||
"version": "3.1.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.4.0",
|
||||
"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.66",
|
||||
"@electron-forge/maker-deb": "6.0.0-beta.66",
|
||||
"@electron-forge/maker-flatpak": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.66",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.66",
|
||||
"@electron-forge/publisher-github": "^6.0.0-beta.66",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/node": "^12.19.9",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"electron": "21.0.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
32
patches/@electron+packager+18.4.4.patch
Normal file
@@ -0,0 +1,32 @@
|
||||
diff --git a/node_modules/@electron/packager/dist/win32.js b/node_modules/@electron/packager/dist/win32.js
|
||||
index d318f6c..bfde740 100644
|
||||
--- a/node_modules/@electron/packager/dist/win32.js
|
||||
+++ b/node_modules/@electron/packager/dist/win32.js
|
||||
@@ -65,7 +65,26 @@ class WindowsApp extends platform_1.App {
|
||||
resOpts.iconPath = icon;
|
||||
}
|
||||
(0, common_1.debug)(`Running resedit with the options ${JSON.stringify(resOpts)}`);
|
||||
- await (0, resedit_1.resedit)(this.electronBinaryPath, resOpts);
|
||||
+
|
||||
+ // This causes segmentation faults for me on multiple machines
|
||||
+ // It's unclear why exactly but this spawn hack fixes it
|
||||
+ // await (0, resedit_1.resedit)(this.electronBinaryPath, resOpts);
|
||||
+
|
||||
+ const { spawnSync } = require('child_process');
|
||||
+ const resEditProcess = spawnSync('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;
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -21,4 +21,9 @@
|
||||
filter: blur(2px);
|
||||
z-index: -100;
|
||||
}
|
||||
|
||||
#emulator-text-screen {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
15
src/main/fileserver/encoding.ts
Normal 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">`;
|
||||
}
|
||||
164
src/main/fileserver/fileserver.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
71
src/main/fileserver/hide-files.ts
Normal 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;
|
||||
}
|
||||
126
src/main/fileserver/page-directory-listing.ts
Normal 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];
|
||||
}
|
||||
25
src/main/fileserver/page-error.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
export function log(message: string, ...args: unknown[]) {
|
||||
console.log(`[${new Date().toLocaleString()}] ${message}`, ...args);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import * as React from "react";
|
||||
import * as fs from "fs-extra";
|
||||
|
||||
import { getStatePath } from "./utils/get-state-path";
|
||||
import { resetState } from "./utils/reset-state";
|
||||
|
||||
interface CardSettingsProps {
|
||||
bootFromScratch: () => void;
|
||||
setFloppy: (file: File) => void;
|
||||
setCdrom: (cdrom: File) => void;
|
||||
setSmbSharePath: (path: string) => void;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
floppy?: File;
|
||||
cdrom?: File;
|
||||
smbSharePath: string;
|
||||
}
|
||||
|
||||
interface CardSettingsState {
|
||||
@@ -46,6 +48,8 @@ export class CardSettings extends React.Component<
|
||||
<hr />
|
||||
{this.renderFloppy()}
|
||||
<hr />
|
||||
{this.renderSmbShare()}
|
||||
<hr />
|
||||
{this.renderState()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +80,7 @@ export class CardSettings extends React.Component<
|
||||
"iso" format.
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{cdrom ? `Inserted CD: ${cdrom?.path}` : `No CD mounted`}
|
||||
{cdrom ? `Inserted CD: ${cdrom?.name}` : `No CD mounted`}
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
@@ -91,6 +95,34 @@ export class CardSettings extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
public renderSmbShare() {
|
||||
const { smbSharePath } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Network Share</legend>
|
||||
<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> to browse it, or use Map Network Drive to
|
||||
give it a drive letter.
|
||||
</p>
|
||||
<p>
|
||||
Shared folder: <code>{smbSharePath}</code>
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={async () => {
|
||||
const picked = await this.props.pickFolder();
|
||||
if (picked) this.props.setSmbSharePath(picked);
|
||||
}}
|
||||
>
|
||||
<span>Choose folder</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderFloppy() {
|
||||
const { floppy } = this.props;
|
||||
|
||||
@@ -122,7 +154,7 @@ export class CardSettings extends React.Component<
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{floppy
|
||||
? `Inserted Floppy Disk: ${floppy.path}`
|
||||
? `Inserted Floppy Disk: ${floppy.name}`
|
||||
: `No floppy mounted`}
|
||||
</p>
|
||||
<button
|
||||
@@ -213,12 +245,7 @@ export class CardSettings extends React.Component<
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
|
||||
265
src/renderer/debug-harness.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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";
|
||||
@@ -9,15 +9,27 @@ 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 +50,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 +65,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,13 +214,17 @@ 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}
|
||||
/>
|
||||
);
|
||||
} else if (currentUiCard === "drive") {
|
||||
card = <CardDrive showDiskImage={this.showDiskImage} />;
|
||||
} else {
|
||||
card = <CardStart startEmulator={this.startEmulator} />;
|
||||
}
|
||||
@@ -210,7 +233,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
<>
|
||||
{card}
|
||||
<StartMenu
|
||||
navigate={(target) => this.setState({ currentUiCard: target })}
|
||||
navigate={(target) =>
|
||||
this.setState({ currentUiCard: target as "start" | "settings" })
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -225,8 +250,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 +261,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 +285,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 +296,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,18 +324,40 @@ 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
|
||||
this.setState({
|
||||
@@ -361,19 +407,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 +435,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,6 +492,8 @@ 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;
|
||||
@@ -472,7 +520,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
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 +557,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
src/renderer/global.d.ts
vendored
@@ -1,2 +1,2 @@
|
||||
declare const V86Starter: any;
|
||||
declare const V86: any;
|
||||
declare const win95: any;
|
||||
|
||||
7219
src/renderer/lib/_capstone-x86.min.js
vendored
BIN
src/renderer/lib/build/v86.wasm
Normal file → Executable file
91
src/renderer/smb/README.md
Normal 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`
|
||||
193
src/renderer/smb/index.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// 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);
|
||||
|
||||
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
@@ -0,0 +1,258 @@
|
||||
// NetBIOS Name Service (RFC 1002, UDP 137). Win95 won't connect to
|
||||
// \\192.168.86.1 until this answers — even with an IP address it sends a
|
||||
// Node Status Request to learn our NetBIOS name for the session-layer
|
||||
// "called name" field.
|
||||
//
|
||||
// fake_network.js handles DNS/DHCP/NTP/echo and silently drops everything
|
||||
// else. We tap net0-send to see raw ethernet frames, parse UDP 137 ourselves,
|
||||
// and inject replies via net0-receive.
|
||||
|
||||
const ETHERTYPE_IPV4 = 0x0800;
|
||||
const IPPROTO_UDP = 17;
|
||||
const NBNS_PORT = 137;
|
||||
|
||||
const NB_NAME = "HOST"; // what shows up in Network Neighborhood
|
||||
const NB_WORKGROUP = "WORKGROUP";
|
||||
|
||||
const log = (...a: unknown[]) => console.log("[nbns]", ...a);
|
||||
|
||||
interface V86 {
|
||||
bus: {
|
||||
register(name: string, fn: (data: Uint8Array) => void): void;
|
||||
send(name: string, data: Uint8Array): void;
|
||||
};
|
||||
network_adapter?: {
|
||||
router_mac: Uint8Array;
|
||||
router_ip: Uint8Array;
|
||||
vm_mac: Uint8Array;
|
||||
vm_ip: Uint8Array;
|
||||
};
|
||||
}
|
||||
|
||||
export function setupNbns(emulator: V86) {
|
||||
emulator.bus.register("net0-send", (frame: Uint8Array) => {
|
||||
const r = parseUdp(frame);
|
||||
if (!r || r.dport !== NBNS_PORT) return;
|
||||
|
||||
const reply = handleNbns(r.payload, emulator);
|
||||
if (reply) {
|
||||
const eth = buildUdpFrame(emulator, r, NBNS_PORT, r.sport, reply);
|
||||
emulator.bus.send("net0-receive", eth);
|
||||
}
|
||||
});
|
||||
log(`listening on UDP 137 — answering as "${NB_NAME}"`);
|
||||
}
|
||||
|
||||
// ─── Packet parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
interface UdpPacket {
|
||||
srcMac: Uint8Array; dstMac: Uint8Array;
|
||||
srcIp: Uint8Array; dstIp: Uint8Array;
|
||||
sport: number; dport: number;
|
||||
payload: Uint8Array;
|
||||
}
|
||||
|
||||
function parseUdp(frame: Uint8Array): UdpPacket | null {
|
||||
if (frame.length < 42) return null;
|
||||
const ethertype = (frame[12] << 8) | frame[13];
|
||||
if (ethertype !== ETHERTYPE_IPV4) return null;
|
||||
|
||||
const ip = 14;
|
||||
const ihl = (frame[ip] & 0x0f) * 4;
|
||||
if (frame[ip + 9] !== IPPROTO_UDP) return null;
|
||||
|
||||
const udp = ip + ihl;
|
||||
const sport = (frame[udp] << 8) | frame[udp + 1];
|
||||
const dport = (frame[udp + 2] << 8) | frame[udp + 3];
|
||||
const len = (frame[udp + 4] << 8) | frame[udp + 5];
|
||||
|
||||
return {
|
||||
srcMac: frame.slice(6, 12),
|
||||
dstMac: frame.slice(0, 6),
|
||||
srcIp: frame.slice(ip + 12, ip + 16),
|
||||
dstIp: frame.slice(ip + 16, ip + 20),
|
||||
sport, dport,
|
||||
payload: frame.slice(udp + 8, udp + len),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── NBNS protocol ───────────────────────────────────────────────────────────
|
||||
// Format is DNS-like. Names are encoded by splitting each byte into two
|
||||
// nibbles, adding 'A' (0x41) to each — so "HOST " becomes 32 chars.
|
||||
|
||||
const TYPE_NB = 0x0020; // name query → IP
|
||||
const TYPE_NBSTAT = 0x0021; // node status → name list
|
||||
const CLASS_IN = 0x0001;
|
||||
|
||||
function handleNbns(data: Uint8Array, emulator: V86): Uint8Array | null {
|
||||
if (data.length < 12) return null;
|
||||
const txid = (data[0] << 8) | data[1];
|
||||
const flags = (data[2] << 8) | data[3];
|
||||
const opcode = (flags >> 11) & 0x0f;
|
||||
const qdcount = (data[4] << 8) | data[5];
|
||||
|
||||
if (opcode !== 0 || qdcount < 1) return null; // not a query
|
||||
|
||||
// Parse first question. Name is L1-encoded: length byte (always 32), then
|
||||
// 32 chars, then 0x00, then type(2) + class(2).
|
||||
let p = 12;
|
||||
const nameLen = data[p++];
|
||||
if (nameLen !== 32) return null;
|
||||
const encoded = data.slice(p, p + 32);
|
||||
p += 32;
|
||||
if (data[p++] !== 0) return null; // scope terminator
|
||||
const qtype = (data[p] << 8) | data[p + 1]; p += 2;
|
||||
/* qclass */ p += 2;
|
||||
|
||||
const name = decodeNbName(encoded);
|
||||
const adapter = emulator.network_adapter;
|
||||
if (!adapter) { log("no adapter yet"); return null; }
|
||||
|
||||
log(`← query type=0x${qtype.toString(16)} name="${name}" txid=${txid}`);
|
||||
|
||||
if (qtype === TYPE_NBSTAT) {
|
||||
// Node Status: "what names are registered on this node?"
|
||||
// RDATA = num_names(1) + (name(15) + suffix(1) + flags(2)) * N + stats(46)
|
||||
const names = [
|
||||
{ name: NB_NAME, suffix: 0x00, flags: 0x0400 }, // workstation, unique, active
|
||||
{ name: NB_NAME, suffix: 0x20, flags: 0x0400 }, // file server, unique, active
|
||||
{ name: NB_WORKGROUP, suffix: 0x00, flags: 0x8400 }, // workgroup, group, active
|
||||
];
|
||||
const rdata: number[] = [names.length];
|
||||
for (const n of names) {
|
||||
const padded = n.name.padEnd(15, " ");
|
||||
for (let i = 0; i < 15; i++) rdata.push(padded.charCodeAt(i));
|
||||
rdata.push(n.suffix);
|
||||
rdata.push((n.flags >> 8) & 0xff, n.flags & 0xff);
|
||||
}
|
||||
// 46-byte statistics block: 6-byte MAC + 40 bytes of zeros
|
||||
for (const b of adapter.router_mac) rdata.push(b);
|
||||
for (let i = 0; i < 40; i++) rdata.push(0);
|
||||
|
||||
return buildNbnsAnswer(txid, encoded, TYPE_NBSTAT, new Uint8Array(rdata));
|
||||
}
|
||||
|
||||
if (qtype === TYPE_NB) {
|
||||
// Name Query: "what IP has this name?" — answer if it's us or wildcard
|
||||
const trimmed = name.trim().toUpperCase();
|
||||
if (trimmed !== NB_NAME && trimmed !== "*") {
|
||||
return null; // not us — drop, let it time out
|
||||
}
|
||||
// RDATA = flags(2) + ip(4)
|
||||
const rdata = new Uint8Array([
|
||||
0x00, 0x00, // unique, B-node
|
||||
...adapter.router_ip,
|
||||
]);
|
||||
return buildNbnsAnswer(txid, encoded, TYPE_NB, rdata);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNbnsAnswer(txid: number, encodedName: Uint8Array, type: number,
|
||||
rdata: Uint8Array): Uint8Array {
|
||||
const out: number[] = [];
|
||||
const u16 = (v: number) => out.push((v >> 8) & 0xff, v & 0xff);
|
||||
const u32 = (v: number) => { u16((v >>> 16) & 0xffff); u16(v & 0xffff); };
|
||||
|
||||
u16(txid);
|
||||
u16(0x8400); // response + authoritative, opcode=0, rcode=0
|
||||
u16(0); // qdcount
|
||||
u16(1); // ancount
|
||||
u16(0); u16(0); // ns/ar
|
||||
|
||||
// answer RR: name(L1-encoded) + type + class + ttl + rdlen + rdata
|
||||
out.push(32); for (const b of encodedName) out.push(b); out.push(0);
|
||||
u16(type);
|
||||
u16(CLASS_IN);
|
||||
u32(300); // TTL 5min
|
||||
u16(rdata.length);
|
||||
for (const b of rdata) out.push(b);
|
||||
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
function decodeNbName(enc: Uint8Array): string {
|
||||
// Each pair of bytes encodes one byte: ((b1-'A')<<4) | (b2-'A')
|
||||
let s = "";
|
||||
for (let i = 0; i < 30; i += 2) {
|
||||
const hi = enc[i] - 0x41;
|
||||
const lo = enc[i + 1] - 0x41;
|
||||
s += String.fromCharCode((hi << 4) | lo);
|
||||
}
|
||||
return s; // 15 chars, space-padded; 16th byte (suffix) ignored here
|
||||
}
|
||||
|
||||
// ─── Ethernet frame building ─────────────────────────────────────────────────
|
||||
|
||||
function buildUdpFrame(emulator: V86, req: UdpPacket, sport: number,
|
||||
dport: number, payload: Uint8Array): Uint8Array {
|
||||
const a = emulator.network_adapter!;
|
||||
// For broadcast queries, reply unicast from router_ip → vm_ip; for
|
||||
// unicast, just swap. Either way the dest MAC/IP come from the request.
|
||||
const srcMac = a.router_mac;
|
||||
const dstMac = req.srcMac;
|
||||
const srcIp = a.router_ip;
|
||||
const dstIp = req.srcIp;
|
||||
|
||||
const udpLen = 8 + payload.length;
|
||||
const ipLen = 20 + udpLen;
|
||||
const total = 14 + ipLen;
|
||||
const f = new Uint8Array(total);
|
||||
|
||||
// Ethernet
|
||||
f.set(dstMac, 0);
|
||||
f.set(srcMac, 6);
|
||||
f[12] = ETHERTYPE_IPV4 >> 8; f[13] = ETHERTYPE_IPV4 & 0xff;
|
||||
|
||||
// IPv4 (offset 14)
|
||||
const ip = 14;
|
||||
f[ip] = 0x45; // v4, IHL=5
|
||||
f[ip + 1] = 0; // DSCP/ECN
|
||||
f[ip + 2] = ipLen >> 8; f[ip + 3] = ipLen & 0xff;
|
||||
f[ip + 4] = 0; f[ip + 5] = 0; // ID
|
||||
f[ip + 6] = 0x40; f[ip + 7] = 0; // DF, no fragment
|
||||
f[ip + 8] = 64; // TTL
|
||||
f[ip + 9] = IPPROTO_UDP;
|
||||
f[ip + 10] = 0; f[ip + 11] = 0; // checksum placeholder
|
||||
f.set(srcIp, ip + 12);
|
||||
f.set(dstIp, ip + 16);
|
||||
const ipck = ipChecksum(f.subarray(ip, ip + 20));
|
||||
f[ip + 10] = ipck >> 8; f[ip + 11] = ipck & 0xff;
|
||||
|
||||
// UDP (offset 34)
|
||||
const udp = ip + 20;
|
||||
f[udp] = sport >> 8; f[udp + 1] = sport & 0xff;
|
||||
f[udp + 2] = dport >> 8; f[udp + 3] = dport & 0xff;
|
||||
f[udp + 4] = udpLen >> 8; f[udp + 5] = udpLen & 0xff;
|
||||
f[udp + 6] = 0; f[udp + 7] = 0; // checksum placeholder
|
||||
f.set(payload, udp + 8);
|
||||
const uck = udpChecksum(srcIp, dstIp, f.subarray(udp, udp + udpLen));
|
||||
f[udp + 6] = uck >> 8; f[udp + 7] = uck & 0xff;
|
||||
|
||||
return f;
|
||||
}
|
||||
|
||||
function ipChecksum(hdr: Uint8Array): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < hdr.length; i += 2) {
|
||||
sum += (hdr[i] << 8) | hdr[i + 1];
|
||||
}
|
||||
while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16);
|
||||
return (~sum) & 0xffff;
|
||||
}
|
||||
|
||||
function udpChecksum(srcIp: Uint8Array, dstIp: Uint8Array, udp: Uint8Array): number {
|
||||
// pseudo-header: src(4) + dst(4) + zero(1) + proto(1) + udplen(2)
|
||||
let sum = 0;
|
||||
const add = (hi: number, lo: number) => { sum += (hi << 8) | lo; };
|
||||
add(srcIp[0], srcIp[1]); add(srcIp[2], srcIp[3]);
|
||||
add(dstIp[0], dstIp[1]); add(dstIp[2], dstIp[3]);
|
||||
add(0, IPPROTO_UDP);
|
||||
add(udp.length >> 8, udp.length & 0xff);
|
||||
for (let i = 0; i < udp.length - 1; i += 2) add(udp[i], udp[i + 1]);
|
||||
if (udp.length & 1) add(udp[udp.length - 1], 0);
|
||||
while (sum >> 16) sum = (sum & 0xffff) + (sum >> 16);
|
||||
const ck = (~sum) & 0xffff;
|
||||
return ck === 0 ? 0xffff : ck; // UDP: zero means "no checksum", so flip
|
||||
}
|
||||
65
src/renderer/smb/netbios.ts
Normal file
@@ -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
154
src/renderer/smb/smb.ts
Normal 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",
|
||||
};
|
||||
308
src/renderer/smb/test-standalone.ts
Normal 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
@@ -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); }
|
||||
}
|
||||
@@ -28,10 +28,6 @@ export class StartMenu extends React.Component<StartMenuProps, {}> {
|
||||
<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>
|
||||
);
|
||||
|
||||
14
src/renderer/utils/reset-state.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
BIN
static/drive.png
|
Before Width: | Height: | Size: 1.9 KiB |
20
static/www/apps.htm
Normal 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>
|
||||
BIN
static/www/buttons/macos.gif
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
static/www/buttons/madewithelectron.gif
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
static/www/buttons/msie.gif
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
31
static/www/credits.htm
Normal 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
@@ -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
@@ -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
|
After Width: | Height: | Size: 9.0 KiB |
BIN
static/www/images/desktop.gif
Normal file
|
After Width: | Height: | Size: 500 B |
BIN
static/www/images/doc.gif
Normal file
|
After Width: | Height: | Size: 103 B |
BIN
static/www/images/folder.gif
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
static/www/images/help.gif
Normal file
|
After Width: | Height: | Size: 265 B |
BIN
static/www/images/ie.gif
Normal file
|
After Width: | Height: | Size: 661 B |
BIN
static/www/images/network.gif
Normal file
|
After Width: | Height: | Size: 605 B |
BIN
static/www/images/programs.gif
Normal file
|
After Width: | Height: | Size: 361 B |
21
static/www/index.htm
Normal 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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
151
tools/update-v86.js
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/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);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const jsDest = path.join(LIB_DIR, 'libv86.js');
|
||||
const wasmDest = path.join(LIB_DIR, 'build/v86.wasm');
|
||||
|
||||
// ─── 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); });
|
||||
@@ -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/**/*"
|
||||
|
||||