Compare commits
105 Commits
v2.2.0
...
claude/sha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ed96deecc | ||
|
|
80505384c6 | ||
|
|
a0ee5a2f10 | ||
|
|
74fc2d291e | ||
|
|
55c4fbb27e | ||
|
|
c243ebbbcc | ||
|
|
85e25ed3ab | ||
|
|
e16afcb748 | ||
|
|
585fbc9624 | ||
|
|
45f5a136b2 | ||
|
|
2d34183e14 | ||
|
|
00943ae4da | ||
|
|
a6d57c6538 | ||
|
|
35f7c3362d | ||
|
|
94021edb61 | ||
|
|
6f2025ffc0 | ||
|
|
3a7b37fff0 | ||
|
|
16eb63e13b | ||
|
|
54fe721f4f | ||
|
|
6dee2f45a2 | ||
|
|
e7e047b0a0 | ||
|
|
5a334abb13 | ||
|
|
aacfae7ada | ||
|
|
9b87b77570 | ||
|
|
e6a0d931af | ||
|
|
973580d60b | ||
|
|
8fcf5eaed3 | ||
|
|
e15d918fb3 | ||
|
|
b442c6db08 | ||
|
|
5c946bbca4 | ||
|
|
c9e45a9f39 | ||
|
|
bc42ce3231 | ||
|
|
d91e72ccc5 | ||
|
|
bd40f00f8d | ||
|
|
1cbfca7451 | ||
|
|
7710c4b7af | ||
|
|
4cce1f0740 | ||
|
|
f8ae78f247 | ||
|
|
62f8eb2696 | ||
|
|
da4b0dd728 | ||
|
|
6cc05fa042 | ||
|
|
dda3707a23 | ||
|
|
a4bcd7fb61 | ||
|
|
17a8139346 | ||
|
|
489c7312d0 | ||
|
|
c3537ae330 | ||
|
|
c483871df9 | ||
|
|
e66cbd70db | ||
|
|
19a1bbc002 | ||
|
|
ef57e3a7fe | ||
|
|
7eae250c2a | ||
|
|
33db389d59 | ||
|
|
61f3269a45 | ||
|
|
e5d897c663 | ||
|
|
a7ae665adc | ||
|
|
bea2267f42 | ||
|
|
a55d08fafc | ||
|
|
97702cb01b | ||
|
|
12160a1ac4 | ||
|
|
3dd50db272 | ||
|
|
7b92d33584 | ||
|
|
24a1c30502 | ||
|
|
7ce0863ae8 | ||
|
|
90ec67fb16 | ||
|
|
9cab8e46f6 | ||
|
|
03b39d76b5 | ||
|
|
d8b4a139ac | ||
|
|
9f4771bf26 | ||
|
|
552b97eec5 | ||
|
|
6c0f00170c | ||
|
|
e3b9a839f5 | ||
|
|
238b07b7dd | ||
|
|
9dc1e422ff | ||
|
|
ebe7427385 | ||
|
|
3e3bee2062 | ||
|
|
c93b6878a9 | ||
|
|
d2e26ef5d1 | ||
|
|
c41befae64 | ||
|
|
8b720750db | ||
|
|
ee317ec5aa | ||
|
|
d7c657e671 | ||
|
|
7a8a54c76b | ||
|
|
c29f98b6bc | ||
|
|
8d1847a8d1 | ||
|
|
194f4fabaf | ||
|
|
3f4a5e97fa | ||
|
|
3eb789d055 | ||
|
|
8a8f064864 | ||
|
|
58add05655 | ||
|
|
0a400d915f | ||
|
|
f615e7754c | ||
|
|
92717c8047 | ||
|
|
045b83f843 | ||
|
|
1dd3b76187 | ||
|
|
4b1dd6146c | ||
|
|
3601599ff1 | ||
|
|
6bf7678079 | ||
|
|
5396cae0f0 | ||
|
|
c5a24643fd | ||
|
|
59a651a205 | ||
|
|
f5cb94776a | ||
|
|
982c866899 | ||
|
|
9e8cef8da7 | ||
|
|
3b76a39060 | ||
|
|
e7d515de84 |
@@ -1,45 +0,0 @@
|
||||
environment:
|
||||
matrix:
|
||||
- nodejs_version: "10"
|
||||
|
||||
init:
|
||||
- git config --global core.symlinks true
|
||||
|
||||
install:
|
||||
# Setup the code signing certificate
|
||||
- ps: >-
|
||||
if (Test-Path Env:\WINDOWS_CERTIFICATE_P12) {
|
||||
$workingDirectory = Convert-Path (Resolve-Path -path ".")
|
||||
$filename = "$workingDirectory\cert.p12"
|
||||
$bytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE_P12)
|
||||
[IO.File]::WriteAllBytes($filename, $bytes)
|
||||
}
|
||||
- ps: Install-Product node $env:nodejs_version x64
|
||||
- node --version
|
||||
- npm ci
|
||||
- ps: mkdir images
|
||||
- ps: cd images
|
||||
- ps: Start-FileDownload 'https://1drv.ws/u/s!AkfaAw_EaahOkulh8rA41x2phgfYXQ' -FileName images.zip -Timeout 600000
|
||||
- ps: 7z x images.zip -y -aoa
|
||||
- ps: Remove-Item images.zip
|
||||
- ps: Remove-Item __MACOSX -Recurse -ErrorAction Ignore
|
||||
- ps: cd ..
|
||||
- ps: Tree ./src /F
|
||||
- ps: Tree ./images /F
|
||||
|
||||
cache:
|
||||
- '%APPDATA%\npm-cache -> appveyor.yml'
|
||||
|
||||
test_script:
|
||||
- node --version
|
||||
- npm --version
|
||||
- npm run lint
|
||||
|
||||
artifacts:
|
||||
- path: 'out\make\squirrel.windows\**\*.exe'
|
||||
|
||||
build_script:
|
||||
- if %APPVEYOR_REPO_TAG% EQU false npm run make
|
||||
- if %APPVEYOR_REPO_TAG% EQU true npm run publish
|
||||
- if %APPVEYOR_REPO_TAG% EQU true npm run publish -- --arch=ia32
|
||||
- ps: Tree ./out/make /F
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
text eol=lf
|
||||
BIN
.github/images/linux.png
vendored
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
.github/images/macos.png
vendored
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
.github/images/windows.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
121
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
- name: Install
|
||||
run: yarn --frozen-lockfile
|
||||
- name: lint
|
||||
run: yarn lint
|
||||
build:
|
||||
needs: lint
|
||||
name: Build (${{ matrix.os }} - ${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
# Build for supported platforms
|
||||
# https://github.com/electron/electron-packager/blob/ebcbd439ff3e0f6f92fa880ff28a8670a9bcf2ab/src/targets.js#L9
|
||||
# 32-bit Linux unsupported as of 2019: https://www.electronjs.org/blog/linux-32bit-support
|
||||
os: [ macOS-latest, ubuntu-latest, windows-latest ]
|
||||
arch: [ x64, arm64 ]
|
||||
include:
|
||||
- os: windows-latest
|
||||
arch: ia32
|
||||
- os: ubuntu-latest
|
||||
arch: armv7l
|
||||
# Publishing artifacts for multiple Windows architectures has
|
||||
# a bug which can cause the wrong architecture to be downloaded
|
||||
# for an update, so until that is fixed, only build Windows x64
|
||||
exclude:
|
||||
- os: windows-latest
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 18.x
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
if: matrix.os != 'macOS-latest'
|
||||
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
- name: Set MacOS signing certs
|
||||
if: matrix.os == 'macOS-latest'
|
||||
run: chmod +x tools/add-macos-cert.sh && ./tools/add-macos-cert.sh
|
||||
env:
|
||||
MACOS_CERT_P12: ${{ secrets.MACOS_CERT_P12 }}
|
||||
MACOS_CERT_PASSWORD: ${{ secrets.MACOS_CERT_PASSWORD }}
|
||||
- name: Set Windows signing certificate
|
||||
if: matrix.os == 'windows-latest'
|
||||
continue-on-error: true
|
||||
id: write_file
|
||||
uses: timheuer/base64-to-file@v1
|
||||
with:
|
||||
fileName: 'win-certificate.pfx'
|
||||
encodedString: ${{ secrets.WINDOWS_CODESIGN_P12 }}
|
||||
- name: Download disk image (ps1)
|
||||
run: tools/download-disk.ps1
|
||||
if: matrix.os == 'windows-latest' && startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
DISK_URL: ${{ secrets.DISK_URL }}
|
||||
- name: Download disk image (sh)
|
||||
run: chmod +x tools/download-disk.sh && ./tools/download-disk.sh
|
||||
if: matrix.os != 'windows-latest' && startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
DISK_URL: ${{ secrets.DISK_URL }}
|
||||
- name: Install
|
||||
run: yarn
|
||||
- name: Make
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
run: yarn make --arch=${{ matrix.arch }}
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WINDOWS_CODESIGN_FILE: ${{ steps.write_file.outputs.filePath }}
|
||||
WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }}
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
draft: true
|
||||
files: |
|
||||
out/**/*.deb
|
||||
out/**/*.dmg
|
||||
out/**/*setup*.exe
|
||||
out/**/*.rpm
|
||||
out/**/*.zip
|
||||
18
.gitignore
vendored
@@ -1,6 +1,18 @@
|
||||
node_modules
|
||||
out
|
||||
src/images
|
||||
.DS_Store
|
||||
images
|
||||
dist
|
||||
|
||||
/images*/
|
||||
/helper-images/
|
||||
|
||||
dist
|
||||
!.github/images
|
||||
*.code-workspace
|
||||
*.pfx
|
||||
|
||||
Microsoft.Trusted.Signing.Client*
|
||||
trusted-signing-metadata.json
|
||||
.env
|
||||
electron-windows-sign.log
|
||||
.npmrc
|
||||
/.claude/
|
||||
|
||||
64
.travis.yml
@@ -1,64 +0,0 @@
|
||||
language: node_js
|
||||
node_js: "12"
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
dist: trusty
|
||||
osx_image: xcode8.3
|
||||
sudo: false
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
- $HOME/.cache/electron
|
||||
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
- fakeroot
|
||||
- rpm
|
||||
|
||||
branches:
|
||||
only:
|
||||
- master
|
||||
- /^v\d+\.\d+\.\d+/
|
||||
|
||||
install:
|
||||
- npm install
|
||||
- mkdir -p ./images
|
||||
- cd ./images
|
||||
- wget -O images.zip https://1drv.ws/u/s!AkfaAw_EaahOkulh8rA41x2phgfYXQ
|
||||
- unzip -o images.zip
|
||||
- rm images.zip
|
||||
- rm -r __MACOSX
|
||||
- cd ..
|
||||
- ls src
|
||||
- ls images
|
||||
- |
|
||||
if [[ "$TRAVIS_OS_NAME" == "osx" && "$TRAVIS_SECURE_ENV_VARS" == "true" ]]; then
|
||||
export CERTIFICATE_P12=cert.p12;
|
||||
echo $MACOS_CERT_P12 | base64 --decode > $CERTIFICATE_P12;
|
||||
export KEYCHAIN=build.keychain;
|
||||
# Create the keychain with a password
|
||||
security create-keychain -p travis $KEYCHAIN;
|
||||
# Make the custom keychain default, so xcodebuild will use it for signing
|
||||
security default-keychain -s $KEYCHAIN;
|
||||
# Unlock the keychain
|
||||
security unlock-keychain -p travis $KEYCHAIN;
|
||||
# Add certificates to keychain and allow codesign to access them
|
||||
# Apple Worldwide Developer Relations Certification Authority
|
||||
security import ./assets/certs/apple.cer -k ~/Library/Keychains/$KEYCHAIN -T /usr/bin/codesign
|
||||
# Developer Authentication Certification Authority
|
||||
security import ./assets/certs/dac.cer -k ~/Library/Keychains/$KEYCHAIN -T /usr/bin/codesign
|
||||
# Developer ID Felix
|
||||
security import $CERTIFICATE_P12 -k $KEYCHAIN -P $MACOS_CERT_PASSWORD -T /usr/bin/codesign 2>&1 >/dev/null;
|
||||
rm $CERTIFICATE_P12;
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k travis $KEYCHAIN
|
||||
# Echo the identity
|
||||
security find-identity -v -p codesigning
|
||||
fi
|
||||
script:
|
||||
- npm run lint
|
||||
- if test -z "$TRAVIS_TAG"; then npm run make; fi
|
||||
|
||||
after_success: if test -n "$TRAVIS_TAG"; then npm run publish; fi
|
||||
28
HELP.md
@@ -1,34 +1,14 @@
|
||||
# Help & Commonly Asked Questions
|
||||
|
||||
## MS-DOS seems to brick the screen
|
||||
Hit `Alt + Enter` to make the command screen "full screen" (as far as Windows 95 is
|
||||
## MS-DOS seems to mess up the screen
|
||||
Hit `Alt + Enter` to make the command screen "Full Screen" (as far as Windows 95 is
|
||||
concerned). This should restore the display from the garbled mess you see and allow
|
||||
you to access the command prompt. Press Alt-Enter again to leave full screen and go
|
||||
back to a window mode. (Thanks to @DisplacedGamer for that wisdom)
|
||||
you to access the Command Prompt. Press Alt-Enter again to leave Full Screen and go
|
||||
back to Window Mode. (Thanks to @DisplacedGamers for that wisdom)
|
||||
|
||||
## Windows 95 is stuck in a bad state
|
||||
|
||||
On the app's home screen, select "Settings" in the lower menu. Then, delete your
|
||||
machine's state before starting it again - this time hopefully without issues.
|
||||
|
||||
## I want to install additional apps or games
|
||||
|
||||
If you are running macOS, or Linux, you can probably "mount" the
|
||||
virtual hard drive used by `windows95` to add files. Hit the "Modify C: Drive"
|
||||
button, which will take you to the disk image.
|
||||
|
||||
On macOS, double-click the disk image to open it.
|
||||
|
||||
On Windows 10, Windows will _think_ that it can open up the image, but will
|
||||
actually fail to do so. Use a tool [like OSFMount][osfmount] to mount your
|
||||
disk image.
|
||||
|
||||
On Linux, search the Internet for instructions on how to mount an `img` disk
|
||||
image on your distribution. It's likely that you'll be able to run `mount`
|
||||
with the image as input.
|
||||
|
||||
[osfmount]: https://www.osforensics.com/tools/mount-disk-images.html
|
||||
|
||||
## What's the FrontPage Username and Password?
|
||||
Username: windows95
|
||||
Password: password
|
||||
|
||||
96
README.md
@@ -3,12 +3,92 @@
|
||||
This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes, it's the full thing. I'm sorry.
|
||||
|
||||
## Downloads
|
||||
| | Windows | macOS | Linux |
|
||||
|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Standalone Download | 📦[Standalone, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-win32-standalone-ia32.zip) <br /> 📦[Standalone, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-win32-standalone-x64.zip) | 📦[Standalone](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-macos-2.1.1.zip) | |
|
||||
| Installer | 💽[Setup, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-setup-win32-x64.exe) <br /> 💽[Setup, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-2.1.1-setup-win32-ia32.exe) | | 💽[deb, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-linux-2.1.1_amd64.deb) <br /> 💽[rpm, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.1.1/windows95-linux-2.1.1.x86_64.rpm) |
|
||||
|
||||

|
||||
<table class="is-fullwidth">
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="./.github/images/windows.png" width="24"><br />
|
||||
Windows
|
||||
</td>
|
||||
<td>
|
||||
<span>32-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-setup-ia32.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<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/v4.0.0/windows95-4.0.0-setup-x64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<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/v4.0.0/windows95-4.0.0-setup-arm64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<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? It's probably `x64`. To confirm, on your computer, hit Start, enter "processor" for info.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="./.github/images/macos.png" width="24"><br />
|
||||
macOS
|
||||
</td>
|
||||
<td>
|
||||
<span>Apple Silicon Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/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/v4.0.0/windows95-darwin-x64-4.0.0.zip">
|
||||
📦 Standalone Zip
|
||||
</a>
|
||||
<span>
|
||||
❓ Don't know what kind of chip you have? If you bought your computer after 2020, select "Apple Silicon". Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="./.github/images/linux.png" width="24"><br />
|
||||
Linux
|
||||
</td>
|
||||
<td>
|
||||
<span>64-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95-4.0.0-1.x86_64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v4.0.0/windows95_4.0.0_amd64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<table width="100%">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<img src="https://github.com/user-attachments/assets/43ab7126-765e-444b-ad14-27b1beadbc7c" width="100%" alt="Screenshot showing Windows 95">
|
||||
</td>
|
||||
<td width="50%">
|
||||
<img src="https://github.com/user-attachments/assets/7ac5dc36-cbd4-4455-a616-0e5cca314b34" width="100%" alt="Screenshot showing Windows 95">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Does it work?
|
||||
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
|
||||
@@ -17,13 +97,13 @@ Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this
|
||||
Absolutely.
|
||||
|
||||
## Does it run Doom (or my other favorite game)?
|
||||
You'll likely be better off with an actual virtualization app, but the short answer is yes. [Thanks to
|
||||
You'll likely be better off with an actual virtualization app, but the short answer is yes. In fact, a few games are already preinstalled - and more can be found on the Internet, for instance at [archive.org](https://www.archive.org). [Thanks to
|
||||
@DisplacedGamers](https://youtu.be/xDXqmdFxofM) I can recommend that you switch to a resolution of
|
||||
640x480 @ 256 colors before starting DOS games - just like in the good ol' days.
|
||||
|
||||
## Credits
|
||||
|
||||
99% of the work was done over at [v86](https://github.com/copy/v86/) by Copy.
|
||||
99% of the work was done over at [v86](https://github.com/copy/v86/) by Copy aka Fabian Hemmer and his contributors.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -44,6 +124,8 @@ Unpack the `images` folder into the `src` folder, creating this layout:
|
||||
|
||||
Once you've done so, run `npm install` and `npm start` to run your local build.
|
||||
|
||||
If you want to tinker with the image or make a new one, check out the [QEMU docs](./docs/qemu.md).
|
||||
|
||||
## Other Questions
|
||||
|
||||
* [MS-DOS seems to brick the screen](./HELP.md#ms-dos-seems-to-brick-the-screen)
|
||||
|
||||
BIN
assets/boot.gif
Normal file
|
After Width: | Height: | Size: 98 KiB |
16
assets/entitlements.plist
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
bios/seabios.bin
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
|
||||
```
|
||||
|
||||
48
docs/qemu.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# QEMU Instructions
|
||||
|
||||
The image built here was made with QEMU. In this doc, I'm keeping instructions
|
||||
around.
|
||||
|
||||
Disk image creation
|
||||
|
||||
```sh
|
||||
qemu-img create -f raw windows95_v4.raw 1G
|
||||
```
|
||||
|
||||
ISO CD image creation
|
||||
|
||||
```sh
|
||||
hdiutil makehybrid -o output.iso /path/to/folder -iso -joliet
|
||||
```
|
||||
|
||||
Installation
|
||||
```sh
|
||||
qemu-system-i386 \
|
||||
-cdrom Win95_OSR25.iso \
|
||||
-m 128 \
|
||||
-hda windows95.img \
|
||||
-device sb16 \
|
||||
-nic user,model=ne2k_pci \
|
||||
-fda Win95_boot.img \
|
||||
-boot a \
|
||||
-M pc,acpi=off \
|
||||
-cpu pentium
|
||||
```
|
||||
|
||||
- Boot from floppy
|
||||
- Run `fdisk` and `format c:`
|
||||
- Run `D:\setup.exe` with `24796-OEM-0014736-66386`
|
||||
- After completing setup and restarting your computer, you might get an IOS Windows protection error
|
||||
- Use `fix95cpu.ima` as a bootable floppy to fix
|
||||
- Use `vga-driver.iso` to install different video driver
|
||||
|
||||
```sh
|
||||
qemu-system-i386 \
|
||||
-m 128 \
|
||||
-hda images/windows95.img \
|
||||
-device sb16 \
|
||||
-M pc,acpi=off \
|
||||
-cpu pentium \
|
||||
-netdev user,id=mynet0 \
|
||||
-device ne2k_isa,netdev=mynet0,irq=10
|
||||
```
|
||||
@@ -1,9 +1,38 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const package = require('./package.json');
|
||||
|
||||
require('dotenv').config()
|
||||
|
||||
process.env.TEMP = process.env.TMP = `C:\\Users\\FelixRieseberg\\AppData\\Local\\Temp`
|
||||
|
||||
const FLAGS = {
|
||||
SIGNTOOL_PATH: process.env.SIGNTOOL_PATH,
|
||||
AZURE_CODE_SIGNING_DLIB: process.env.AZURE_CODE_SIGNING_DLIB || path.join(__dirname, 'Microsoft.Trusted.Signing.Client.1.0.60/bin/x64/Azure.CodeSigning.Dlib.dll'),
|
||||
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 = {
|
||||
hooks: {
|
||||
generateAssets: require('./tools/generateAssets')
|
||||
generateAssets: require('./tools/generateAssets'),
|
||||
},
|
||||
packagerConfig: {
|
||||
asar: false,
|
||||
@@ -12,22 +41,42 @@ module.exports = {
|
||||
appCategoryType: 'public.app-category.developer-tools',
|
||||
win32metadata: {
|
||||
CompanyName: 'Felix Rieseberg',
|
||||
OriginalFilename: 'windows95',
|
||||
OriginalFilename: 'windows95'
|
||||
},
|
||||
osxSign: {
|
||||
identity: 'Developer ID Application: Felix Rieseberg (LT94ZKYDCJ)'
|
||||
identity: 'Developer ID Application: Felix Rieseberg (LT94ZKYDCJ)',
|
||||
},
|
||||
osxNotarize: {
|
||||
appleId: FLAGS.APPLE_ID,
|
||||
appleIdPassword: FLAGS.APPLE_ID_PASSWORD,
|
||||
teamId: 'LT94ZKYDCJ'
|
||||
},
|
||||
windowsSign,
|
||||
ignore: [
|
||||
/\/assets(\/?)/,
|
||||
/\/docs(\/?)/,
|
||||
/\/tools(\/?)/,
|
||||
/\/src\/.*\.ts/,
|
||||
/\/test(\/?)/,
|
||||
/\/@types(\/?)/,
|
||||
/\/helper-images(\/?)/,
|
||||
/package-lock\.json/,
|
||||
/README\.md/,
|
||||
/tsconfig\.json/,
|
||||
/Dockerfile/,
|
||||
/issue_template\.md/,
|
||||
/HELP\.md/,
|
||||
/forge\.config\.js/,
|
||||
/\.github(\/?)/,
|
||||
/\.circleci(\/?)/,
|
||||
/\.vscode(\/?)/,
|
||||
/\.gitignore/,
|
||||
/\.gitattributes/,
|
||||
/\.eslintignore/,
|
||||
/\.eslintrc/,
|
||||
/\.prettierrc/,
|
||||
/\/Microsoft\.Trusted\.Signing\.Client.*/,
|
||||
/\/trusted-signing-metadata/,
|
||||
]
|
||||
},
|
||||
makers: [
|
||||
@@ -41,10 +90,11 @@ module.exports = {
|
||||
exe: 'windows95.exe',
|
||||
noMsi: true,
|
||||
remoteReleases: '',
|
||||
iconUrl: 'https://raw.githubusercontent.com/felixrieseberg/windows95/master/assets/icon.ico',
|
||||
loadingGif: './assets/boot.gif',
|
||||
setupExe: `windows95-${package.version}-setup-${arch}.exe`,
|
||||
setupIcon: path.resolve(__dirname, 'assets', 'icon.ico'),
|
||||
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
||||
certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD
|
||||
windowsSign
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -60,18 +110,5 @@ module.exports = {
|
||||
name: '@electron-forge/maker-rpm',
|
||||
platforms: ['linux']
|
||||
}
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
name: '@electron-forge/publisher-github',
|
||||
config: {
|
||||
repository: {
|
||||
owner: 'felixrieseberg',
|
||||
name: 'windows95'
|
||||
},
|
||||
draft: true,
|
||||
prerelease: true
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
25233
package-lock.json
generated
68
package.json
@@ -1,16 +1,19 @@
|
||||
{
|
||||
"name": "windows95",
|
||||
"productName": "windows95",
|
||||
"version": "2.2.0",
|
||||
"version": "4.0.0",
|
||||
"description": "Windows 95, in an app. I'm sorry.",
|
||||
"main": "./dist/src/main/main",
|
||||
"main": "./dist/src/main/main.js",
|
||||
"scripts": {
|
||||
"start": "rimraf ./dist && electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "prettier --write src/**/*.{ts,tsx}",
|
||||
"less": "node ./tools/lessc.js"
|
||||
"lint": "prettier --write src/**/*.{ts,tsx} && npm run check-links",
|
||||
"less": "node ./tools/lessc.js",
|
||||
"tsc": "tsc -p tsconfig.json --noEmit",
|
||||
"check-links": "node tools/check-links.js",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Felix Rieseberg, felix@felixrieseberg.com",
|
||||
@@ -18,43 +21,30 @@
|
||||
"config": {
|
||||
"forge": "./forge.config.js"
|
||||
},
|
||||
"standard": {
|
||||
"globals": [
|
||||
"appState",
|
||||
"V86Starter",
|
||||
"windows95"
|
||||
],
|
||||
"ignore": [
|
||||
"/src/renderer/lib/*.js"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"react": "^16.7.0",
|
||||
"react-dom": "^16.7.0",
|
||||
"tslib": "^1.10.0",
|
||||
"update-electron-app": "^1.5.0"
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"update-electron-app": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^6.0.0-beta.44",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.44",
|
||||
"@electron-forge/maker-flatpak": "^6.0.0-beta.44",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.44",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.44",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.44",
|
||||
"@electron-forge/publisher-github": "^6.0.0-beta.44",
|
||||
"@types/fs-extra": "^8.0.0",
|
||||
"@types/node": "^12.7.2",
|
||||
"@types/react": "^16.9.2",
|
||||
"@types/react-dom": "^16.8.5",
|
||||
"electron": "6.0.2",
|
||||
"less": "^3.10.1",
|
||||
"node-abi": "^2.11.0",
|
||||
"parcel-bundler": "^1.12.3",
|
||||
"prettier": "^1.18.2",
|
||||
"rimraf": "^3.0.0",
|
||||
"standard": "^13.1.0",
|
||||
"typescript": "^3.5.3"
|
||||
"@electron-forge/cli": "7.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",
|
||||
"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;
|
||||
32
src/cache.ts
@@ -1,8 +1,8 @@
|
||||
import { session } from 'electron';
|
||||
import { session } from "electron";
|
||||
|
||||
export async function clearCaches() {
|
||||
await clearCache()
|
||||
await clearStorageData()
|
||||
await clearCache();
|
||||
await clearStorageData();
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
@@ -11,15 +11,21 @@ export async function clearCache() {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStorageData() {
|
||||
return new Promise((resolve) => {
|
||||
if (!session.defaultSession) {
|
||||
return resolve();
|
||||
}
|
||||
export async function clearStorageData() {
|
||||
if (!session.defaultSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.defaultSession.clearStorageData({
|
||||
storages: [ 'appcache', 'cookies', 'filesystem', 'indexdb', 'localstorage', 'shadercache', 'websql', 'serviceworkers' ],
|
||||
quotas: [ 'temporary', 'persistent', 'syncable' ]
|
||||
}, resolve)
|
||||
})
|
||||
await session.defaultSession.clearStorageData({
|
||||
storages: [
|
||||
"cookies",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
"localstorage",
|
||||
"shadercache",
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
],
|
||||
quotas: ["temporary"],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import { remote, app } from 'electron';
|
||||
import * as path from 'path';
|
||||
import * as path from "path";
|
||||
|
||||
const _app = app || remote.app
|
||||
const IMAGES_PATH = path.join(__dirname, "../../images");
|
||||
|
||||
export const CONSTANTS = {
|
||||
IMAGE_PATH: path.join(__dirname, '../../images/windows95.img'),
|
||||
IMAGES_PATH,
|
||||
IMAGE_PATH: path.join(IMAGES_PATH, "windows95.img"),
|
||||
IMAGE_DEFAULT_SIZE: 1073741824, // 1GB
|
||||
DEFAULT_STATE_PATH: path.join(__dirname, '../../images/default-state.bin'),
|
||||
STATE_PATH: path.join(_app.getPath('userData'), 'state-v2.bin')
|
||||
}
|
||||
DEFAULT_STATE_PATH: path.join(IMAGES_PATH, "default-state.bin"),
|
||||
};
|
||||
|
||||
export const IPC_COMMANDS = {
|
||||
TOGGLE_INFO: 'TOGGLE_INFO',
|
||||
SHOW_DISK_IMAGE: 'SHOW_DISK_IMAGE',
|
||||
ZOOM_IN: 'ZOOM_IN',
|
||||
ZOOM_OUT: 'ZOOM_OUT',
|
||||
ZOOM_RESET: 'ZOOM_RESET',
|
||||
TOGGLE_INFO: "TOGGLE_INFO",
|
||||
SHOW_DISK_IMAGE: "SHOW_DISK_IMAGE",
|
||||
ZOOM_IN: "ZOOM_IN",
|
||||
ZOOM_OUT: "ZOOM_OUT",
|
||||
ZOOM_RESET: "ZOOM_RESET",
|
||||
// Machine instructions
|
||||
MACHINE_START: 'MACHINE_START',
|
||||
MACHINE_RESTART: 'MACHINE_RESTART',
|
||||
MACHINE_STOP: 'MACHINE_STOP',
|
||||
MACHINE_RESET: 'MACHINE_RESET',
|
||||
MACHINE_ALT_F4: 'MACHINE_ALT_F4',
|
||||
MACHINE_ESC: 'MACHINE_ESC',
|
||||
MACHINE_ALT_ENTER: 'MACHINE_ALT_ENTER',
|
||||
MACHINE_CTRL_ALT_DEL: 'MACHINE_CTRL_ALT_DEL',
|
||||
MACHINE_START: "MACHINE_START",
|
||||
MACHINE_RESTART: "MACHINE_RESTART",
|
||||
MACHINE_STOP: "MACHINE_STOP",
|
||||
MACHINE_RESET: "MACHINE_RESET",
|
||||
MACHINE_ALT_F4: "MACHINE_ALT_F4",
|
||||
MACHINE_ESC: "MACHINE_ESC",
|
||||
MACHINE_ALT_ENTER: "MACHINE_ALT_ENTER",
|
||||
MACHINE_CTRL_ALT_DEL: "MACHINE_CTRL_ALT_DEL",
|
||||
// Machine events
|
||||
MACHINE_STARTED: 'MACHINE_STARTED',
|
||||
MACHINE_STOPPED: 'MACHINE_STOPPED'
|
||||
}
|
||||
MACHINE_STARTED: "MACHINE_STARTED",
|
||||
MACHINE_STOPPED: "MACHINE_STOPPED",
|
||||
// Else
|
||||
APP_QUIT: "APP_QUIT",
|
||||
GET_STATE_PATH: "GET_STATE_PATH",
|
||||
GET_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,103 +1,77 @@
|
||||
@import "./status.less";
|
||||
@import "./emulator.less";
|
||||
@import "./info.less";
|
||||
@import "./settings.less";
|
||||
@import "./start.less";
|
||||
@import "./settings.less";
|
||||
|
||||
/* GENERAL RESETS */
|
||||
// 98.css uses the actual MS Sans Serif bitmap font and pixel-exact bevels.
|
||||
// Everything below is layout — the chrome comes from 98.css.
|
||||
|
||||
html, body {
|
||||
@win-teal: #008080;
|
||||
@win-silver: silver;
|
||||
@win-font: "Pixelated MS Sans Serif", Arial, sans-serif;
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.paused > #emulator {
|
||||
display: none;
|
||||
font-family: @win-font;
|
||||
-webkit-font-smoothing: none;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
body.paused {
|
||||
background: #008080;
|
||||
font-family: Courier;
|
||||
background: @win-teal;
|
||||
|
||||
> #emulator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
#buttons {
|
||||
user-select: none;
|
||||
button:not(:disabled),
|
||||
li[role="tab"],
|
||||
.title-bar-controls button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// 98.css renders button text via text-shadow (color: transparent) so the
|
||||
// bitmap font stays crisp; <img> children need their own alignment.
|
||||
button img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: @win-font;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 75%;
|
||||
max-width: 700px;
|
||||
min-width: 400px;
|
||||
|
||||
.card-title {
|
||||
img {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link > img,
|
||||
.btn > img {
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.windows95 {
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
nav .nav-link,
|
||||
nav .nav-logo {
|
||||
height: 37px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
nav .nav-logo img {
|
||||
margin-left: 2px;
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
nav .nav-logo > span {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 37px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
border-color: #fff #000 #000 #fff;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.btn.active:before,
|
||||
.btn:focus:before,
|
||||
button.active:before,
|
||||
button:focus:before,
|
||||
input[type=submit].active:before,
|
||||
input[type=submit]:focus:before {
|
||||
border-color: #dedede grey grey #dedede;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,71 @@
|
||||
#floppy-path {
|
||||
font-size: .6rem;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding-left: 8px;
|
||||
border-color: #000 #fff #fff #000;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
background-color: #c3c3c3;
|
||||
line-height: 27px;
|
||||
}
|
||||
.settings-window {
|
||||
width: 460px;
|
||||
|
||||
#file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings {
|
||||
legend > img {
|
||||
margin-right: 5px;
|
||||
> .window-body {
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 3px;
|
||||
|
||||
> .window-body {
|
||||
margin: 12px;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-window .field-row-stacked {
|
||||
margin-bottom: 12px;
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
font-family: @win-font;
|
||||
}
|
||||
|
||||
input[type="text"]:read-only {
|
||||
background-color: #fff;
|
||||
color: #222;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
button {
|
||||
min-width: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
|
||||
button {
|
||||
min-width: 75px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,99 @@
|
||||
#section-start {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// "Welcome to Windows" splash — modelled on the real first-boot dialog.
|
||||
|
||||
> small {
|
||||
margin-top: 25px;
|
||||
font-size: .8rem;
|
||||
.welcome {
|
||||
width: 540px;
|
||||
}
|
||||
|
||||
.welcome-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin: 4px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.welcome-stripe {
|
||||
width: 26px;
|
||||
background: linear-gradient(180deg, #000 0%, navy 60%, #1084d0 100%);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(180deg);
|
||||
writing-mode: vertical-rl;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-family: "Times New Roman", serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
margin: 0 0 14px;
|
||||
color: #000;
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
vertical-align: baseline;
|
||||
margin-left: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-tip {
|
||||
flex: 1;
|
||||
background: #ffffe1;
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf,
|
||||
inset 2px 2px #0a0a0a;
|
||||
padding: 12px 14px;
|
||||
|
||||
.welcome-tip-header {
|
||||
border-bottom: 1px solid grey;
|
||||
box-shadow: 0 1px 0 #fff;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
width: 130px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.welcome-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
#status-hotzone {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
#status {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
left: calc(50vw - 110px);
|
||||
left: 50vw;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
background: white;
|
||||
font-size: 10px;
|
||||
padding-bottom: 3px;
|
||||
@@ -13,4 +24,14 @@
|
||||
padding-right: 10px;
|
||||
max-height: 18px;
|
||||
top: 0;
|
||||
transition: transform 0.12s ease-out;
|
||||
|
||||
&.hidden {
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
#status-hotzone:hover + #status.hidden,
|
||||
#status.hidden:hover {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
BIN
src/less/vendor/95.ttf
vendored
1
src/less/vendor/95css.css
vendored
2
src/less/vendor/98.css
vendored
Normal file
BIN
src/less/vendor/bg-pattern.png
vendored
|
Before Width: | Height: | Size: 144 B |
BIN
src/less/vendor/dropdown.png
vendored
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
src/less/vendor/ms_sans_serif.woff
vendored
Normal file
BIN
src/less/vendor/ms_sans_serif.woff2
vendored
Normal file
BIN
src/less/vendor/ms_sans_serif_bold.woff
vendored
Normal file
BIN
src/less/vendor/ms_sans_serif_bold.woff2
vendored
Normal file
BIN
src/less/vendor/windows.woff
vendored
BIN
src/less/vendor/windows.woff2
vendored
@@ -12,7 +12,7 @@ export function setupAboutPanel(): void {
|
||||
applicationName: "windows95",
|
||||
applicationVersion: app.getVersion(),
|
||||
version: process.versions.electron,
|
||||
copyright: "Felix Rieseberg"
|
||||
copyright: "Felix Rieseberg",
|
||||
};
|
||||
|
||||
switch (process.platform) {
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
46
src/main/ipc.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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-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);
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { shouldQuit } from "./squirrel";
|
||||
import { setupUpdates } from "./update";
|
||||
import { getOrCreateWindow } from "./windows";
|
||||
import { setupMenu } from "./menu";
|
||||
import { setupIpcListeners } from "./ipc";
|
||||
import { setupSession } from "./session";
|
||||
import { setupFileServer } from "./fileserver/fileserver";
|
||||
|
||||
/**
|
||||
* Handle the app's "ready" event. This is essentially
|
||||
@@ -14,10 +17,13 @@ import { setupMenu } from "./menu";
|
||||
export async function onReady() {
|
||||
if (!isDevMode()) process.env.NODE_ENV = "production";
|
||||
|
||||
setupSession();
|
||||
setupIpcListeners();
|
||||
getOrCreateWindow();
|
||||
setupAboutPanel();
|
||||
setupMenu();
|
||||
setupUpdates();
|
||||
setupFileServer();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +61,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");
|
||||
|
||||
|
||||
144
src/main/menu.ts
@@ -1,25 +1,25 @@
|
||||
import { app, shell, Menu, BrowserWindow, ipcMain, webFrame } from "electron";
|
||||
import { app, shell, Menu, BrowserWindow, ipcMain, dialog } from "electron";
|
||||
|
||||
import { clearCaches } from "../cache";
|
||||
import { IPC_COMMANDS } from "../constants";
|
||||
import { isDevMode } from "../utils/devmode";
|
||||
import { getOrCreateWindow } from "./windows";
|
||||
import { log } from "./logging";
|
||||
|
||||
const LINKS = {
|
||||
homepage: "https://www.twitter.com/felixrieseberg",
|
||||
homepage: "https://www.felixrieseberg.com",
|
||||
repo: "https://github.com/felixrieseberg/windows95",
|
||||
credits: "https://github.com/felixrieseberg/windows95/blob/master/CREDITS.md",
|
||||
help: "https://github.com/felixrieseberg/windows95/blob/master/HELP.md"
|
||||
help: "https://github.com/felixrieseberg/windows95/blob/master/HELP.md",
|
||||
};
|
||||
|
||||
export async function setupMenu() {
|
||||
await createMenu();
|
||||
|
||||
ipcMain.on(IPC_COMMANDS.MACHINE_STARTED, () =>
|
||||
createMenu({ isRunning: true })
|
||||
createMenu({ isRunning: true }),
|
||||
);
|
||||
ipcMain.on(IPC_COMMANDS.MACHINE_STOPPED, () =>
|
||||
createMenu({ isRunning: false })
|
||||
createMenu({ isRunning: false }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ function send(cmd: string) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
|
||||
if (windows[0]) {
|
||||
console.log(`Sending "${cmd}"`);
|
||||
log(`Sending "${cmd}"`);
|
||||
windows[0].webContents.send(cmd);
|
||||
} else {
|
||||
console.log(`Tried to send "${cmd}", but could not find window`);
|
||||
log(`Tried to send "${cmd}", but could not find window`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,52 +41,52 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
submenu: [
|
||||
{
|
||||
label: "Toggle Full Screen",
|
||||
accelerator: (function() {
|
||||
accelerator: (function () {
|
||||
if (process.platform === "darwin") {
|
||||
return "Ctrl+Command+F";
|
||||
} else {
|
||||
return "F11";
|
||||
}
|
||||
})(),
|
||||
click: function(_item, focusedWindow) {
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) {
|
||||
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Toggle Developer Tools",
|
||||
accelerator: (function() {
|
||||
accelerator: (function () {
|
||||
if (process.platform === "darwin") {
|
||||
return "Alt+Command+I";
|
||||
} else {
|
||||
return "Ctrl+Shift+I";
|
||||
}
|
||||
})(),
|
||||
click: function(_item, focusedWindow) {
|
||||
if (focusedWindow) {
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow instanceof BrowserWindow) {
|
||||
focusedWindow.webContents.toggleDevTools();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Toggle Emulator Info",
|
||||
click: () => send(IPC_COMMANDS.TOGGLE_INFO)
|
||||
click: () => send(IPC_COMMANDS.TOGGLE_INFO),
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
role: "reload"
|
||||
}
|
||||
]
|
||||
role: "reload",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "editMenu",
|
||||
visible: isDevMode()
|
||||
visible: isDevMode(),
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
@@ -95,32 +95,32 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
{
|
||||
label: "Minimize",
|
||||
accelerator: "CmdOrCtrl+M",
|
||||
role: "minimize"
|
||||
role: "minimize",
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: "CmdOrCtrl+W",
|
||||
role: "close"
|
||||
role: "close",
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Zoom in",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_IN),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Zoom out",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_OUT),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Reset zoom",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_RESET),
|
||||
enabled: isRunning
|
||||
}
|
||||
]
|
||||
enabled: isRunning,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Machine",
|
||||
@@ -128,53 +128,67 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
{
|
||||
label: "Send Ctrl+Alt+Del",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Alt+F4",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ALT_F4),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Alt+Enter",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ALT_ENTER),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Esc",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ESC),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
isRunning
|
||||
? {
|
||||
label: "Stop",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_STOP)
|
||||
click: () => send(IPC_COMMANDS.MACHINE_STOP),
|
||||
}
|
||||
: {
|
||||
label: "Start",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_START)
|
||||
click: () => send(IPC_COMMANDS.MACHINE_START),
|
||||
},
|
||||
{
|
||||
label: "Restart",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESTART),
|
||||
enabled: isRunning
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Reset",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESET),
|
||||
enabled: isRunning
|
||||
click: async () => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
buttons: ["Reset", "Cancel"],
|
||||
defaultId: 1,
|
||||
title: "Reset Machine",
|
||||
message: "Are you sure you want to reset the machine?",
|
||||
detail:
|
||||
"This will delete the machine state, including all changes you have made.",
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
send(IPC_COMMANDS.MACHINE_RESET);
|
||||
}
|
||||
},
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Go to Disk Image",
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE)
|
||||
}
|
||||
]
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
@@ -182,18 +196,18 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
submenu: [
|
||||
{
|
||||
label: "Author",
|
||||
click: () => shell.openExternal(LINKS.homepage)
|
||||
click: () => shell.openExternal(LINKS.homepage),
|
||||
},
|
||||
{
|
||||
label: "windows95 on GitHub",
|
||||
click: () => shell.openExternal(LINKS.repo)
|
||||
click: () => shell.openExternal(LINKS.repo),
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
click: () => shell.openExternal(LINKS.help)
|
||||
click: () => shell.openExternal(LINKS.help),
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Troubleshooting",
|
||||
@@ -205,12 +219,12 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
@@ -218,41 +232,41 @@ async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
label: "windows95",
|
||||
submenu: [
|
||||
{
|
||||
role: "about"
|
||||
role: "about",
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
role: "services"
|
||||
role: "services",
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Hide windows95",
|
||||
accelerator: "Command+H",
|
||||
role: "hide"
|
||||
role: "hide",
|
||||
},
|
||||
{
|
||||
label: "Hide Others",
|
||||
accelerator: "Command+Shift+H",
|
||||
role: "hideothers"
|
||||
role: "hideothers",
|
||||
},
|
||||
{
|
||||
role: "unhide"
|
||||
role: "unhide",
|
||||
},
|
||||
{
|
||||
type: "separator"
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
accelerator: "Command+Q",
|
||||
click() {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
}
|
||||
|
||||
|
||||
18
src/main/session.ts
Normal file
@@ -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,9 +2,10 @@ import { app } from "electron";
|
||||
|
||||
export function setupUpdates() {
|
||||
if (app.isPackaged) {
|
||||
require("update-electron-app")({
|
||||
const { updateElectronApp } = require("update-electron-app");
|
||||
updateElectronApp({
|
||||
repo: "felixrieseberg/windows95",
|
||||
updateInterval: "1 hour"
|
||||
updateInterval: "1 hour",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BrowserWindow } from "electron";
|
||||
import { BrowserWindow, shell } from "electron";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
@@ -13,15 +13,27 @@ export function getOrCreateWindow(): BrowserWindow {
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
sandbox: false,
|
||||
webviewTag: false
|
||||
}
|
||||
webviewTag: false,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile("./dist/static/index.html");
|
||||
|
||||
mainWindow.webContents.on("will-navigate", (event, url) =>
|
||||
handleNavigation(event, url),
|
||||
);
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function handleNavigation(event: Electron.Event, url: string) {
|
||||
if (url.startsWith("http")) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"] || {
|
||||
app: new App()
|
||||
window.win95 = window.win95 || {
|
||||
app: new App(),
|
||||
};
|
||||
|
||||
window["win95"].app.setup();
|
||||
window.win95.app.setup();
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { shell } from "electron";
|
||||
|
||||
interface CardDriveProps {
|
||||
showDiskImage: () => void;
|
||||
}
|
||||
|
||||
interface CardDriveState {}
|
||||
|
||||
export class CardDrive extends React.Component<CardDriveProps, CardDriveState> {
|
||||
constructor(props: CardDriveProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
public render() {
|
||||
let advice: JSX.Element | null = null;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
advice = this.renderAdviceWindows();
|
||||
} else if (process.platform === "darwin") {
|
||||
advice = this.renderAdviceMac();
|
||||
} else {
|
||||
advice = this.renderAdviceLinux();
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="card settings">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<img src="../../static/drive.png" />
|
||||
Modify C: Drive
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<p>
|
||||
windows95 (this app) uses a raw disk image. Windows 95 (the
|
||||
operating system) is fragile, so adding or removing files is
|
||||
risky.
|
||||
</p>
|
||||
{advice}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
public renderAdviceWindows(): JSX.Element {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Changing the disk on Windows</legend>
|
||||
<p>
|
||||
Windows 10 cannot mount raw disk images (ironically, macOS and Linux
|
||||
can). However, tools exist that let you mount this drive, like the
|
||||
freeware tool{" "}
|
||||
<a
|
||||
href="#"
|
||||
onClick={() =>
|
||||
shell.openExternal(
|
||||
"https://www.osforensics.com/tools/mount-disk-images.html"
|
||||
)
|
||||
}
|
||||
>
|
||||
OSFMount
|
||||
</a>
|
||||
. I am not affiliated with it, so please use it at your own risk.
|
||||
</p>
|
||||
{this.renderMountButton("Windows Explorer")}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderAdviceMac(): JSX.Element {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Changing the disk on macOS</legend>
|
||||
<p>
|
||||
macOS can mount the disk image directly. Click the button below to see
|
||||
the disk image in Finder. Then, double-click the image to mount it.
|
||||
</p>
|
||||
{this.renderMountButton("Finder")}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderAdviceLinux(): JSX.Element {
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Changing the disk on Linux</legend>
|
||||
<p>
|
||||
There are plenty of tools that enable Linux users to mount and modify
|
||||
disk images. The disk image used by windows95 is a raw "img" disk
|
||||
image and can probably be mounted using the <code>mount</code> tool,
|
||||
which is likely installed on your machine.
|
||||
</p>
|
||||
{this.renderMountButton("file viewer")}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderMountButton(explorer: string) {
|
||||
return (
|
||||
<button className="btn" onClick={this.props.showDiskImage}>
|
||||
<img src="../../static/show-disk-image.png" />
|
||||
<span>Show disk image in {explorer}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import * as React from "react";
|
||||
import * as fs from "fs-extra";
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
import { resetState } from "./utils/reset-state";
|
||||
|
||||
// v86's IDE CD-ROM path is currently broken; flip this once it works again.
|
||||
const CDROM_ENABLED = false;
|
||||
|
||||
interface CardSettingsProps {
|
||||
bootFromScratch: () => void;
|
||||
setFloppy: (file: File) => void;
|
||||
setCdrom: (file: File) => void;
|
||||
setSmbSharePath: (path: string) => void;
|
||||
pickFolder: () => Promise<string | null>;
|
||||
navigate: (to: "start" | "settings") => void;
|
||||
floppy?: File;
|
||||
cdrom?: File;
|
||||
smbSharePath: string;
|
||||
}
|
||||
|
||||
type Tab = "floppy" | "cdrom" | "network" | "state";
|
||||
|
||||
interface CardSettingsState {
|
||||
tab: Tab;
|
||||
isStateReset: boolean;
|
||||
}
|
||||
|
||||
@@ -21,120 +32,214 @@ export class CardSettings extends React.Component<
|
||||
super(props);
|
||||
|
||||
this.onChangeFloppy = this.onChangeFloppy.bind(this);
|
||||
this.onChangeCdrom = this.onChangeCdrom.bind(this);
|
||||
this.onResetState = this.onResetState.bind(this);
|
||||
|
||||
this.state = {
|
||||
isStateReset: false
|
||||
tab: "floppy",
|
||||
isStateReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tab } = this.state;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="card settings">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<img src="../../static/settings.png" />
|
||||
Settings
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{this.renderFloppy()}
|
||||
<hr />
|
||||
{this.renderState()}
|
||||
<div className="window settings-window">
|
||||
<div className="title-bar">
|
||||
<div className="title-bar-text">windows95 Properties</div>
|
||||
<div className="title-bar-controls">
|
||||
<button aria-label="Help" disabled />
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={() => this.props.navigate("start")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div className="window-body">
|
||||
<menu role="tablist">
|
||||
{this.renderTab("floppy", "Floppy Drive")}
|
||||
{CDROM_ENABLED && this.renderTab("cdrom", "CD-ROM")}
|
||||
{this.renderTab("network", "Network Share")}
|
||||
{this.renderTab("state", "Machine State")}
|
||||
</menu>
|
||||
<div className="window settings-panel" role="tabpanel">
|
||||
<div className="window-body">
|
||||
{tab === "floppy" && this.renderFloppy()}
|
||||
{tab === "cdrom" && this.renderCdrom()}
|
||||
{tab === "network" && this.renderSmbShare()}
|
||||
{tab === "state" && this.renderState()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-footer">
|
||||
<button
|
||||
className="default"
|
||||
onClick={() => this.props.navigate("start")}
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
<button onClick={() => this.props.navigate("start")}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public renderFloppy() {
|
||||
private renderTab(id: Tab, label: string) {
|
||||
return (
|
||||
<li
|
||||
role="tab"
|
||||
aria-selected={this.state.tab === id}
|
||||
onClick={() => this.setState({ tab: id })}
|
||||
>
|
||||
<a href="#">{label}</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
private renderFloppy() {
|
||||
const { floppy } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/floppy.png" />
|
||||
Floppy
|
||||
</legend>
|
||||
<legend>Drive A:</legend>
|
||||
<input
|
||||
id="floppy-input"
|
||||
type="file"
|
||||
onChange={this.onChangeFloppy}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<p>
|
||||
windows95 comes with a virtual floppy drive. It can mount floppy disk
|
||||
images in the "img" format.
|
||||
</p>
|
||||
<p>
|
||||
Back in the 90s and before CD-ROMs became a popular, software was
|
||||
typically distributed on floppy disks. Some developers have since
|
||||
released their apps or games for free, usually on virtual floppy disks
|
||||
using the "img" format.
|
||||
</p>
|
||||
<p>
|
||||
Once you've mounted a disk image, you might have to boot your virtual
|
||||
windows95 machine from scratch.
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{floppy
|
||||
? `Inserted Floppy Disk: ${floppy.path}`
|
||||
: `No floppy mounted`}
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/select-floppy.png" />
|
||||
<span>Mount floppy disk</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderState() {
|
||||
const { isStateReset } = this.state;
|
||||
const { bootFromScratch } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/reset.png" />
|
||||
Reset machine state
|
||||
</legend>
|
||||
<div>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/floppy.png" />
|
||||
<p>
|
||||
windows95 stores changes to your machine (like saved files) in a
|
||||
state file. If you encounter any trouble, you can reset your state
|
||||
or boot Windows 95 from scratch.{" "}
|
||||
<strong>All your changes will be lost.</strong>
|
||||
windows95 ships with a virtual 3½" floppy drive. Mount an{" "}
|
||||
<code>.img</code> disk image here, then boot the machine to read it
|
||||
from inside Windows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="floppy-path">Mounted image</label>
|
||||
<input
|
||||
id="floppy-path"
|
||||
type="text"
|
||||
readOnly
|
||||
value={floppy ? floppy.name : "(No disk in drive)"}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={this.onResetState}
|
||||
disabled={isStateReset}
|
||||
style={{ marginRight: "5px" }}
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/reset-state.png" />
|
||||
{isStateReset ? "State reset" : "Reset state"}
|
||||
</button>
|
||||
<button className="btn" onClick={bootFromScratch}>
|
||||
<img src="../../static/boot-fresh.png" />
|
||||
Boot from scratch
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a change in the floppy input
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
private renderCdrom() {
|
||||
const { cdrom } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Drive D:</legend>
|
||||
<input
|
||||
id="cdrom-input"
|
||||
type="file"
|
||||
onChange={this.onChangeCdrom}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/cdrom.png" />
|
||||
<p>
|
||||
windows95 ships with a virtual CD-ROM drive. Mount an{" "}
|
||||
<code>.iso</code> image here, then boot the machine to read it from
|
||||
inside Windows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="cdrom-path">Mounted image</label>
|
||||
<input
|
||||
id="cdrom-path"
|
||||
type="text"
|
||||
readOnly
|
||||
value={cdrom ? cdrom.name : "(No disc in drive)"}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
onClick={() =>
|
||||
(document.querySelector("#cdrom-input") as any).click()
|
||||
}
|
||||
>
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSmbShare() {
|
||||
const { smbSharePath } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>\\HOST\HOST</legend>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/show-disk-image.png" />
|
||||
<p>
|
||||
A folder on your computer is exposed inside Windows 95 as a network
|
||||
drive. From inside Windows, open Start → Run and type{" "}
|
||||
<code>\\HOST\HOST</code> — or use Map Network Drive to give it a
|
||||
letter.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="smb-path">Shared folder</label>
|
||||
<input id="smb-path" type="text" readOnly value={smbSharePath} />
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const picked = await this.props.pickFolder();
|
||||
if (picked) this.props.setSmbSharePath(picked);
|
||||
}}
|
||||
>
|
||||
Choose folder...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderState() {
|
||||
const { isStateReset } = this.state;
|
||||
const { bootFromScratch } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Reset</legend>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/reset.png" />
|
||||
<p>
|
||||
Changes to your machine (saved files, installed programs) are stored
|
||||
in a state file. If something breaks, you can either discard that
|
||||
state or boot a fresh copy of Windows from scratch.{" "}
|
||||
<strong>All your changes will be lost.</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button onClick={this.onResetState} disabled={isStateReset}>
|
||||
{isStateReset ? "State has been reset" : "Reset state"}
|
||||
</button>
|
||||
<button onClick={bootFromScratch}>Boot from scratch</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private onChangeFloppy(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const floppyFile =
|
||||
event.target.files && event.target.files.length > 0
|
||||
@@ -148,14 +253,21 @@ export class CardSettings extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the state reset
|
||||
*/
|
||||
private async onResetState() {
|
||||
if (fs.existsSync(CONSTANTS.STATE_PATH)) {
|
||||
await fs.remove(CONSTANTS.STATE_PATH);
|
||||
}
|
||||
private onChangeCdrom(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const cdromFile =
|
||||
event.target.files && event.target.files.length > 0
|
||||
? event.target.files[0]
|
||||
: null;
|
||||
|
||||
if (cdromFile) {
|
||||
this.props.setCdrom(cdromFile);
|
||||
} else {
|
||||
console.log(`Cdrom: Input changed but no file selected`);
|
||||
}
|
||||
}
|
||||
|
||||
private async onResetState() {
|
||||
await resetState();
|
||||
this.setState({ isStateReset: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,64 @@ import * as React from "react";
|
||||
|
||||
export interface CardStartProps {
|
||||
startEmulator: () => void;
|
||||
navigate: (to: "start" | "settings") => void;
|
||||
}
|
||||
|
||||
export class CardStart extends React.Component<CardStartProps, {}> {
|
||||
const TIPS = [
|
||||
"Press the Escape key at any time to release or recapture your mouse cursor.",
|
||||
"You can mount a floppy image from Settings before booting to install vintage software.",
|
||||
"Map a host folder as a network drive: open Start → Run inside Windows and type \\\\HOST\\HOST.",
|
||||
"Your machine state is saved automatically when you quit. Reset it from Settings if things get weird.",
|
||||
"Use the Machine menu in the menubar to send Ctrl+Alt+Del and other special key combos.",
|
||||
];
|
||||
|
||||
export class CardStart extends React.Component<CardStartProps> {
|
||||
private tip = TIPS[Math.floor(Math.random() * TIPS.length)];
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<section id="section-start">
|
||||
<button className="btn" id="win95" onClick={this.props.startEmulator}>
|
||||
<img src="../../static/run.png" />
|
||||
<span>Start Windows 95</span>
|
||||
</button>
|
||||
<small>Hit ESC to lock or unlock your mouse</small>
|
||||
</section>
|
||||
<div className="window welcome" id="welcome-window">
|
||||
<div className="title-bar">
|
||||
<div className="title-bar-text">Welcome</div>
|
||||
<div className="title-bar-controls">
|
||||
<button aria-label="Minimize" disabled />
|
||||
<button aria-label="Maximize" disabled />
|
||||
<button aria-label="Close" disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className="window-body welcome-body">
|
||||
<aside className="welcome-stripe">
|
||||
<span>Windows 95</span>
|
||||
</aside>
|
||||
<div className="welcome-main">
|
||||
<h1 className="welcome-title">
|
||||
Welcome to <span>Windows</span>
|
||||
<small>95</small>
|
||||
</h1>
|
||||
|
||||
<div className="welcome-tip">
|
||||
<div className="welcome-tip-header">
|
||||
<strong>Did you know...</strong>
|
||||
</div>
|
||||
<p>{this.tip}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="welcome-actions">
|
||||
<button
|
||||
id="win95"
|
||||
className="default"
|
||||
onClick={this.props.startEmulator}
|
||||
>
|
||||
<u>S</u>tart Windows 95
|
||||
</button>
|
||||
<button onClick={() => this.props.navigate("settings")}>
|
||||
S<u>e</u>ttings...
|
||||
</button>
|
||||
<div className="welcome-spacer" />
|
||||
<button disabled>What's New</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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,21 +1,34 @@
|
||||
import * as React from "react";
|
||||
import * as fs from "fs-extra";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { ipcRenderer, remote, shell } from "electron";
|
||||
import { ipcRenderer, shell, webUtils } from "electron";
|
||||
|
||||
import { CONSTANTS, IPC_COMMANDS } from "../constants";
|
||||
import { getDiskImageSize } from "../utils/disk-image-size";
|
||||
import { CardStart } from "./card-start";
|
||||
import { StartMenu } from "./start-menu";
|
||||
import { CardSettings } from "./card-settings";
|
||||
import { EmulatorInfo } from "./emulator-info";
|
||||
import { CardDrive } from "./card-drive";
|
||||
import { 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;
|
||||
@@ -36,20 +49,29 @@ 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,
|
||||
scale: 1
|
||||
smbSharePath: "",
|
||||
// We can start pretty large
|
||||
// If it's too large, it'll just grow until it hits borders
|
||||
scale: 2,
|
||||
};
|
||||
|
||||
this.setupInputListeners();
|
||||
this.setupIpcListeners();
|
||||
this.setupUnloadListeners();
|
||||
|
||||
if (document.location.hash.includes("AUTO_START")) {
|
||||
this.startEmulator();
|
||||
ipcRenderer.invoke(IPC_COMMANDS.GET_SMB_SHARE_PATH).then((p: string) => {
|
||||
this.setState({ smbSharePath: p });
|
||||
});
|
||||
|
||||
if (PROBE) {
|
||||
// Skip the start card; boot fresh immediately. The 100ms delay
|
||||
// lets React mount the #emulator div first.
|
||||
setTimeout(() => this.bootFromScratch(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +80,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
*/
|
||||
public setupInputListeners() {
|
||||
// ESC
|
||||
document.onkeydown = evt => {
|
||||
document.onkeydown = (evt) => {
|
||||
const { isCursorCaptured } = this.state;
|
||||
|
||||
evt = evt || window.event;
|
||||
@@ -82,6 +104,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
this.lockMouse();
|
||||
}
|
||||
});
|
||||
|
||||
// Only forward mouse input to the VM while the pointer is actually
|
||||
// captured. Browsers can release pointer lock on their own (Esc, focus
|
||||
// loss), so we sync v86's mouse status off the real lock state instead of
|
||||
// assuming our lock/unlock calls succeeded.
|
||||
document.addEventListener("pointerlockchange", () => {
|
||||
const isCursorCaptured = !!document.pointerLockElement;
|
||||
this.setState({ isCursorCaptured });
|
||||
this.state.emulator?.mouse_set_status(isCursorCaptured);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,11 +127,11 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
this.isQuitting = true;
|
||||
|
||||
setImmediate(() => {
|
||||
remote.app.quit();
|
||||
ipcRenderer.invoke(IPC_COMMANDS.APP_QUIT);
|
||||
});
|
||||
};
|
||||
|
||||
window.onbeforeunload = event => {
|
||||
window.onbeforeunload = (event: Event) => {
|
||||
if (this.isQuitting || this.isResetting) {
|
||||
console.log(`Unload: Not preventing`);
|
||||
return;
|
||||
@@ -122,27 +154,27 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
this.sendKeys([
|
||||
0x1d, // ctrl
|
||||
0x38, // alt
|
||||
0x53 // delete
|
||||
0x53, // delete
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_F4, () => {
|
||||
this.sendKeys([
|
||||
0x38, // alt
|
||||
0x3e // f4
|
||||
0x3e, // f4
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_ENTER, () => {
|
||||
this.sendKeys([
|
||||
0x38, // alt
|
||||
0 // enter
|
||||
0, // enter
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ESC, () => {
|
||||
this.sendKeys([
|
||||
0x18 // alt
|
||||
0x18, // alt
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -178,36 +210,41 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
* 🤡
|
||||
*/
|
||||
public renderUI() {
|
||||
const { isRunning, currentUiCard, floppyFile } = this.state;
|
||||
const { isRunning, currentUiCard, floppyFile, cdromFile } = this.state;
|
||||
|
||||
if (isRunning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigate = (currentUiCard: "start" | "settings") =>
|
||||
this.setState({ currentUiCard });
|
||||
|
||||
let card;
|
||||
|
||||
if (currentUiCard === "settings") {
|
||||
card = (
|
||||
<CardSettings
|
||||
setFloppy={floppyFile => this.setState({ floppyFile })}
|
||||
setFloppy={(floppyFile) => this.setState({ floppyFile })}
|
||||
setCdrom={(cdromFile) => this.setState({ cdromFile })}
|
||||
setSmbSharePath={(smbSharePath) => {
|
||||
this.setState({ smbSharePath });
|
||||
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, smbSharePath);
|
||||
}}
|
||||
pickFolder={() => ipcRenderer.invoke(IPC_COMMANDS.PICK_FOLDER)}
|
||||
bootFromScratch={this.bootFromScratch}
|
||||
floppy={floppyFile}
|
||||
cdrom={cdromFile}
|
||||
smbSharePath={this.state.smbSharePath}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
} else if (currentUiCard === "drive") {
|
||||
card = <CardDrive showDiskImage={this.showDiskImage} />;
|
||||
} else {
|
||||
card = <CardStart startEmulator={this.startEmulator} />;
|
||||
card = (
|
||||
<CardStart startEmulator={this.startEmulator} navigate={navigate} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{card}
|
||||
<StartMenu
|
||||
navigate={target => this.setState({ currentUiCard: target })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <section>{card}</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,8 +256,8 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
{this.renderInfo()}
|
||||
{this.renderUI()}
|
||||
<div id="emulator">
|
||||
<div></div>
|
||||
<canvas></canvas>
|
||||
<div id="emulator-text-screen"></div>
|
||||
<canvas id="emulator-canvas"></canvas>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -230,13 +267,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
* Render the little info thingy
|
||||
*/
|
||||
public renderInfo() {
|
||||
if (!this.state.isInfoDisplayed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmulatorInfo
|
||||
emulator={this.state.emulator}
|
||||
hidden={!this.state.isInfoDisplayed}
|
||||
toggleInfo={() => {
|
||||
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
|
||||
}}
|
||||
@@ -257,12 +291,9 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
*/
|
||||
public showDiskImage() {
|
||||
// Contents/Resources/app/dist/static
|
||||
const imagePath = path
|
||||
.join(__dirname, "../../images/windows95.img");
|
||||
console.log(`Showing disk image in ${CONSTANTS.IMAGE_PATH}`);
|
||||
|
||||
console.log(`Showing disk image in ${imagePath}`);``
|
||||
|
||||
shell.showItemInFolder(imagePath);
|
||||
shell.showItemInFolder(CONSTANTS.IMAGE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,36 +302,76 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
private async startEmulator() {
|
||||
document.body.classList.remove("paused");
|
||||
|
||||
const imageSize = await getDiskImageSize();
|
||||
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,
|
||||
video_memory_size: 32 * 1024 * 1024,
|
||||
screen_container: document.getElementById("emulator"),
|
||||
vga_memory_size: 64 * 1024 * 1024,
|
||||
screen: {
|
||||
container: document.getElementById("emulator"),
|
||||
scale: 0,
|
||||
},
|
||||
preserve_mac_from_state_image: true,
|
||||
net_device: {
|
||||
relay_url: "fetch",
|
||||
type: "ne2k",
|
||||
},
|
||||
bios: {
|
||||
url: "../../bios/seabios.bin"
|
||||
url: path.join(__dirname, "../../bios/seabios.bin"),
|
||||
},
|
||||
vga_bios: {
|
||||
url: "../../bios/vgabios.bin"
|
||||
url: path.join(__dirname, "../../bios/vgabios.bin"),
|
||||
},
|
||||
hda: {
|
||||
url: "../../images/windows95.img",
|
||||
url: CONSTANTS.IMAGE_PATH,
|
||||
async: true,
|
||||
size: imageSize
|
||||
size: await getDiskImageSize(CONSTANTS.IMAGE_PATH),
|
||||
},
|
||||
fda: {
|
||||
buffer: this.state.floppyFile
|
||||
},
|
||||
boot_order: 0x132
|
||||
fda: this.state.floppyFile
|
||||
? {
|
||||
buffer: this.state.floppyFile,
|
||||
}
|
||||
: undefined,
|
||||
cdrom: cdromPath
|
||||
? {
|
||||
url: cdromPath,
|
||||
async: true,
|
||||
size: await getDiskImageSize(cdromPath),
|
||||
}
|
||||
: undefined,
|
||||
boot_order: 0x132,
|
||||
};
|
||||
|
||||
// PROBE_OPTS lets the outer harness override options without rebuilding
|
||||
// (e.g. WIN95_PROBE_OPTS='{"acpi":false,"disable_jit":true}')
|
||||
Object.assign(options, PROBE_OPTS);
|
||||
|
||||
console.log(`🚜 Starting emulator with options`, options);
|
||||
|
||||
window["emulator"] = new V86Starter(options);
|
||||
window["emulator"] = new V86(options);
|
||||
|
||||
// Serve a host folder over SMB on port 139. Read-only, traversal/symlink
|
||||
// guarded. In Win95: Start → Run → \\HOST\HOST. The env var wins so the
|
||||
// probe harness can point at a fixture dir without touching settings.
|
||||
const smbRoot = process.env.WIN95_SMB_SHARE || this.state.smbSharePath;
|
||||
if (smbRoot) {
|
||||
setupSmbShare(window["emulator"], smbRoot);
|
||||
}
|
||||
|
||||
if (PROBE) {
|
||||
startProbe(window["emulator"]);
|
||||
}
|
||||
|
||||
// New v86 instance
|
||||
// Mouse stays disabled until the pointerlockchange listener confirms the
|
||||
// cursor is actually captured.
|
||||
window["emulator"].mouse_set_status(false);
|
||||
this.setState({
|
||||
emulator: window["emulator"],
|
||||
isRunning: true
|
||||
isRunning: true,
|
||||
});
|
||||
|
||||
ipcRenderer.send(IPC_COMMANDS.MACHINE_STARTED);
|
||||
@@ -315,6 +386,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
|
||||
this.lockMouse();
|
||||
this.state.emulator.run();
|
||||
this.state.emulator.screen_set_scale(this.state.scale);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -344,19 +416,23 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
|
||||
await this.saveState();
|
||||
this.unlockMouse();
|
||||
emulator.stop();
|
||||
await emulator.stop();
|
||||
this.setState({ isRunning: false });
|
||||
this.resetCanvas();
|
||||
|
||||
document.body.classList.add("paused");
|
||||
ipcRenderer.send(IPC_COMMANDS.MACHINE_STOPPED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the emulator by reloading the whole page (lol)
|
||||
* Reset the emulator by reloading the whole page
|
||||
*/
|
||||
private async resetEmulator() {
|
||||
this.isResetting = true;
|
||||
document.location.hash = `#AUTO_START`;
|
||||
|
||||
await this.stopEmulator();
|
||||
await resetState();
|
||||
|
||||
document.location.reload();
|
||||
}
|
||||
|
||||
@@ -366,50 +442,47 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
const { emulator } = this.state;
|
||||
const statePath = await getStatePath();
|
||||
|
||||
return new Promise(resolve => {
|
||||
if (!emulator || !emulator.save_state) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return resolve();
|
||||
}
|
||||
if (!emulator || !emulator.save_state) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return;
|
||||
}
|
||||
|
||||
emulator.save_state(async (error: Error, newState: ArrayBuffer) => {
|
||||
if (error) {
|
||||
console.warn(`saveState: Could not save state`, error);
|
||||
return resolve();
|
||||
}
|
||||
|
||||
await fs.outputFile(CONSTANTS.STATE_PATH, Buffer.from(newState));
|
||||
|
||||
console.log(`saveState: Saved state to ${CONSTANTS.STATE_PATH}`);
|
||||
|
||||
resolve();
|
||||
try {
|
||||
const newState = await emulator.save_state();
|
||||
await fs.promises.writeFile(statePath, Buffer.from(newState), {
|
||||
flush: true,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`saveState: Could not save state`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores state to the emulator.
|
||||
*/
|
||||
private restoreState() {
|
||||
const { emulator } = this.state;
|
||||
const state = this.getState();
|
||||
private async restoreState() {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -420,27 +493,22 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
*
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
private getState(): ArrayBuffer | null {
|
||||
const statePath = fs.existsSync(CONSTANTS.STATE_PATH)
|
||||
? CONSTANTS.STATE_PATH
|
||||
private async getState(): Promise<ArrayBuffer | null> {
|
||||
const expectedStatePath = await getStatePath();
|
||||
const statePath = fs.existsSync(expectedStatePath)
|
||||
? expectedStatePath
|
||||
: CONSTANTS.DEFAULT_STATE_PATH;
|
||||
|
||||
if (fs.existsSync(statePath)) {
|
||||
return fs.readFileSync(statePath).buffer;
|
||||
} else {
|
||||
console.log(`getState: No state file found at ${statePath}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private unlockMouse() {
|
||||
const { emulator } = this.state;
|
||||
|
||||
this.setState({ isCursorCaptured: false });
|
||||
|
||||
if (emulator) {
|
||||
emulator.mouse_set_status(false);
|
||||
}
|
||||
|
||||
document.exitPointerLock();
|
||||
}
|
||||
|
||||
@@ -448,12 +516,10 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
const { emulator } = this.state;
|
||||
|
||||
if (emulator) {
|
||||
this.setState({ isCursorCaptured: true });
|
||||
emulator.mouse_set_status(true);
|
||||
emulator.lock_mouse();
|
||||
} else {
|
||||
console.warn(
|
||||
`Emulator: Tried to lock mouse, but no emulator or not running`
|
||||
`Emulator: Tried to lock mouse, but no emulator or not running`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -490,4 +556,16 @@ export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
this.state.emulator.keyboard_send_scancodes(scancodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the canvas
|
||||
*/
|
||||
private resetCanvas() {
|
||||
const canvas = document.getElementById("emulator-canvas");
|
||||
|
||||
if (canvas instanceof HTMLCanvasElement) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
src/renderer/global.d.ts
vendored
@@ -1,2 +1,2 @@
|
||||
declare const V86Starter: any;
|
||||
declare const V86: any;
|
||||
declare const win95: any;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2012-2018, Fabian Hemmer
|
||||
Copyright (c) 2012, The v86 contributors
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
@@ -19,8 +19,4 @@ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The views and conclusions contained in the software and documentation are those
|
||||
of the authors and should not be interpreted as representing official policies,
|
||||
either expressed or implied, of the FreeBSD Project.
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
BIN
src/renderer/lib/build/v86.wasm
Executable file
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`
|
||||
197
src/renderer/smb/index.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Glue: hook v86's TCP-connection bus event for port 139 and bridge it to
|
||||
// our SMB server. Windows 95 connects via NetBIOS-over-TCP — ethernet frame
|
||||
// → ne2k → fake_network's userspace TCP/IP → tcp-connection event with a
|
||||
// stream-like TCPConnection object.
|
||||
//
|
||||
// To use: in emulator.tsx after `new V86()`, call
|
||||
// setupSmbShare(window.emulator, "/Users/you/share")
|
||||
// Then inside Win95: Start → Run → \\192.168.86.1\host
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { NetBIOSFramer, nbPositiveResponse, nbWrap } from "./netbios";
|
||||
import { setupNbns } from "./nbns";
|
||||
import { SmbSession } from "./server";
|
||||
|
||||
// SPIKE diagnostics: tee everything to a file so we can debug without DevTools
|
||||
const LOG_FILE = path.join(os.tmpdir(), "windows95-smb.log");
|
||||
try { fs.writeFileSync(LOG_FILE, `--- ${new Date().toISOString()} ---\n`); } catch {}
|
||||
const origLog = console.log;
|
||||
console.log = (...args: unknown[]) => {
|
||||
origLog(...args);
|
||||
const tag = String(args[0] ?? "");
|
||||
if (tag === "[smb]" || tag === "[nbns]") {
|
||||
try {
|
||||
fs.appendFileSync(LOG_FILE, args.map(a =>
|
||||
typeof a === "string" ? a : JSON.stringify(a)).join(" ") + "\n");
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
interface TCPConnection {
|
||||
sport: number;
|
||||
tuple: string;
|
||||
state: string;
|
||||
net: unknown;
|
||||
on(event: "data", handler: (data: Uint8Array) => void): void;
|
||||
write(data: Uint8Array): void;
|
||||
accept(packet?: unknown): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
interface NetworkAdapter {
|
||||
tcp_conn: Record<string, TCPConnection>;
|
||||
on_tcp_connection?: (packet: any, tuple: string) => boolean;
|
||||
router_mac: Uint8Array;
|
||||
router_ip: Uint8Array;
|
||||
}
|
||||
|
||||
interface V86 {
|
||||
bus: {
|
||||
register(name: string, fn: (arg: unknown) => void, ctx?: unknown): void;
|
||||
};
|
||||
network_adapter?: NetworkAdapter;
|
||||
}
|
||||
|
||||
const log = (...a: unknown[]) => console.log("[smb]", ...a);
|
||||
|
||||
export function setupSmbShare(emulator: V86, hostPath: string) {
|
||||
log(`serving ${hostPath} on \\\\HOST\\host (port 139)`);
|
||||
|
||||
// SPIKE diagnostic: count every ethernet frame so we know if the NIC is
|
||||
// emitting anything at all (DHCP, ARP, anything). Logged on a timer so
|
||||
// we don't flood — and so the absence of a tick proves the bus is dead.
|
||||
let frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
|
||||
emulator.bus.register("net0-send", (raw: unknown) => {
|
||||
const f = raw as Uint8Array;
|
||||
frameStats.total++;
|
||||
if (f.length < 14) { frameStats.other++; return; }
|
||||
const et = (f[12] << 8) | f[13];
|
||||
if (et === 0x0806) frameStats.arp++;
|
||||
else if (et === 0x0800) {
|
||||
frameStats.ip++;
|
||||
const proto = f[14 + 9];
|
||||
if (proto === 6) frameStats.tcp++;
|
||||
else if (proto === 17) frameStats.udp++;
|
||||
} else frameStats.other++;
|
||||
});
|
||||
setInterval(() => {
|
||||
if (frameStats.total > 0) {
|
||||
log("frames:", JSON.stringify(frameStats));
|
||||
frameStats = { total: 0, arp: 0, ip: 0, udp: 0, tcp: 0, other: 0 };
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Win95 won't even try TCP 139 until UDP 137 answers a Node Status query
|
||||
setupNbns(emulator as Parameters<typeof setupNbns>[0]);
|
||||
|
||||
// ─── TCP 139 hook ───────────────────────────────────────────────────────
|
||||
// v86 has two APIs depending on age:
|
||||
// new (2025+): bus event "tcp-connection" with a pre-built conn
|
||||
// old (≤Feb 2025): adapter.on_tcp_connection(packet, tuple) callback
|
||||
// where we must construct TCPConnection ourselves
|
||||
// We can't `new TCPConnection()` directly (closure-scoped), so for the
|
||||
// old API we steal the constructor from the prototype of any existing
|
||||
// connection — which means we need a probe HTTP connection to fire first
|
||||
// (or we wait for one). The fetch adapter itself uses the constructor for
|
||||
// port 80, so as soon as anything in Win95 hits HTTP, we can steal it.
|
||||
|
||||
const wireConn = (conn: TCPConnection) => {
|
||||
log(`← TCP SYN ${conn.tuple}`);
|
||||
const framer = new NetBIOSFramer();
|
||||
const session = new SmbSession(hostPath);
|
||||
|
||||
const handler = (data: Uint8Array) => {
|
||||
for (const msg of framer.push(data)) {
|
||||
if (msg.type === 0x81) {
|
||||
log("← NB session request → +response");
|
||||
conn.write(nbPositiveResponse());
|
||||
} else if (msg.type === 0x00) {
|
||||
const reply = session.handle(msg.payload);
|
||||
if (reply) conn.write(nbWrap(reply));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// New v86 has .on(); old v86 had .on/.emit dead-code-eliminated by
|
||||
// Closure into a flat .on_data callback property. Check for the method
|
||||
// first, fall back to direct assignment.
|
||||
if (typeof (conn as any).on === "function") {
|
||||
conn.on("data", handler);
|
||||
} else {
|
||||
(conn as any).on_data = handler;
|
||||
}
|
||||
};
|
||||
|
||||
// New API: bus event (no-op on old v86 — event never fires)
|
||||
emulator.bus.register("tcp-connection", (c: unknown) => {
|
||||
const conn = c as TCPConnection;
|
||||
if (conn.sport !== 139) return;
|
||||
wireConn(conn);
|
||||
conn.accept();
|
||||
});
|
||||
|
||||
// Old API: monkey-patch adapter.on_tcp_connection. The adapter is created
|
||||
// inside V86's async init, so poll for it.
|
||||
//
|
||||
// Instead of stealing the TCPConnection constructor (closure-scoped, brittle
|
||||
// with new-on-stolen-ctor), we make the original handler build one for us
|
||||
// by handing it a port-80 SYN — then RECONFIGURE that connection for 139.
|
||||
// accept(packet) overwrites every routing field (sport/dport/hsrc/etc), and
|
||||
// .on("data") overwrites the HTTP handler. The probe's fake SYN-ACK is eaten
|
||||
// by shadowing adapter.receive (prototype method — `delete` to restore).
|
||||
const tryHook = () => {
|
||||
const adapter = emulator.network_adapter;
|
||||
if (!adapter || typeof adapter.on_tcp_connection !== "function") return false;
|
||||
|
||||
const orig = adapter.on_tcp_connection.bind(adapter);
|
||||
adapter.on_tcp_connection = function (packet: any, tuple: string): boolean {
|
||||
if (packet.tcp.dport !== 139) return orig(packet, tuple);
|
||||
// New v86 fires the tcp-connection bus event BEFORE this callback;
|
||||
// if our bus handler already accepted the conn, it's in tcp_conn —
|
||||
// claim it so the original (which would otherwise RST) doesn't run.
|
||||
if (adapter.tcp_conn[tuple]) return true;
|
||||
|
||||
const adapterAny = adapter as any;
|
||||
adapterAny.receive = () => {};
|
||||
let conn: TCPConnection | undefined;
|
||||
try {
|
||||
const fakeTuple = "__nbt__";
|
||||
orig({ ...packet, tcp: { ...packet.tcp, dport: 80 } }, fakeTuple);
|
||||
conn = adapter.tcp_conn[fakeTuple];
|
||||
delete adapter.tcp_conn[fakeTuple];
|
||||
} finally {
|
||||
delete adapterAny.receive;
|
||||
}
|
||||
|
||||
if (!conn) {
|
||||
log("⚠ probe didn't yield a connection; RST");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-aim it at port 139. accept() overwrites sport/dport/hsrc/psrc/seq/ack
|
||||
// from the packet; .on("data") replaces the HTTP handler (assignment, not
|
||||
// push). Only state needs explicit reset — the probe accept set it to
|
||||
// "established" and we want a fresh handshake.
|
||||
conn.tuple = tuple;
|
||||
conn.state = "syn-received";
|
||||
wireConn(conn);
|
||||
try {
|
||||
conn.accept(packet);
|
||||
} catch (e) {
|
||||
log("accept threw:", e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
}
|
||||
adapter.tcp_conn[tuple] = conn;
|
||||
return true;
|
||||
};
|
||||
log("hooked adapter.on_tcp_connection (old API, conn-recycling)");
|
||||
return true;
|
||||
};
|
||||
|
||||
if (!tryHook()) {
|
||||
const poll = setInterval(() => { if (tryHook()) clearInterval(poll); }, 100);
|
||||
setTimeout(() => clearInterval(poll), 10000);
|
||||
}
|
||||
}
|
||||
258
src/renderer/smb/nbns.ts
Normal file
@@ -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); }
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StartMenuProps {
|
||||
navigate: (to: string) => void;
|
||||
}
|
||||
|
||||
export class StartMenu extends React.Component<StartMenuProps, {}> {
|
||||
constructor(props: StartMenuProps) {
|
||||
super(props);
|
||||
|
||||
this.navigate = this.navigate.bind(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<nav className="nav nav-bottom">
|
||||
<a onClick={this.navigate} href="#" id="start" className="nav-link">
|
||||
<img src="../../static/start.png" alt="Start" />
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<div className="nav-menu">
|
||||
<a
|
||||
onClick={this.navigate}
|
||||
href="#"
|
||||
id="settings"
|
||||
className="nav-link"
|
||||
>
|
||||
<img src="../../static/settings.png" />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<a onClick={this.navigate} href="#" id="drive" className="nav-link">
|
||||
<img src="../../static/drive.png" />
|
||||
<span>Modify C: Drive</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
private navigate(event: React.SyntheticEvent<HTMLAnchorElement>) {
|
||||
this.props.navigate(event.currentTarget.id);
|
||||
}
|
||||
}
|
||||
13
src/renderer/utils/get-state-path.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
import { IPC_COMMANDS } from "../../constants";
|
||||
|
||||
let _statePath = "";
|
||||
|
||||
export async function getStatePath(): Promise<string> {
|
||||
if (_statePath) {
|
||||
return _statePath;
|
||||
}
|
||||
|
||||
const statePath = await ipcRenderer.invoke(IPC_COMMANDS.GET_STATE_PATH);
|
||||
return (_statePath = statePath);
|
||||
}
|
||||
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";
|
||||
|
||||
@@ -7,9 +7,9 @@ import { CONSTANTS } from "../constants";
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
export async function getDiskImageSize() {
|
||||
export async function getDiskImageSize(path: string) {
|
||||
try {
|
||||
const stats = await fs.stat(CONSTANTS.IMAGE_PATH);
|
||||
const stats = await fs.promises.stat(path);
|
||||
|
||||
if (stats) {
|
||||
return stats.size;
|
||||
|
||||
BIN
static/cdrom.png
Normal file
|
After Width: | Height: | Size: 672 B |
BIN
static/drive.png
|
Before Width: | Height: | Size: 1.9 KiB |
10
static/entitlements.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,9 +5,9 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>windows95</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="../src/less/vendor/95css.css">
|
||||
<link rel="stylesheet" href="../src/less/vendor/98.css">
|
||||
<link rel="stylesheet" href="../src/less/root.less">
|
||||
<script src="../src/renderer/lib/libv86.js"></script>
|
||||
<!-- libv86 -->
|
||||
</head>
|
||||
<body class="paused windows95">
|
||||
<div id="app"></div>
|
||||
|
||||
BIN
static/select-cdrom.png
Normal file
|
After Width: | Height: | Size: 519 B |
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>
|
||||