Compare commits
133 Commits
v2.1.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 | ||
|
|
a4092f105a | ||
|
|
71a11cfbe3 | ||
|
|
f3c8f3409a | ||
|
|
8d8fc949cd | ||
|
|
0c2149b756 | ||
|
|
51d0011ed0 | ||
|
|
658fed75da | ||
|
|
186a2a8ba9 | ||
|
|
7653d7294c | ||
|
|
33ef8abcc8 | ||
|
|
ea134d046e | ||
|
|
c7f765df03 | ||
|
|
dcc3e72bcf | ||
|
|
241606d097 | ||
|
|
b7aa6a760d | ||
|
|
6b7bb0f460 | ||
|
|
90a97a11bf | ||
|
|
3598ceb97c | ||
|
|
dbcefb4b7b | ||
|
|
42719bb1d7 | ||
|
|
153002403b | ||
|
|
889c53857e | ||
|
|
5b8f3e12bc | ||
|
|
59299a2c48 | ||
|
|
885af7f786 | ||
|
|
55e88cd5b5 | ||
|
|
e0ad866256 | ||
|
|
cf3acd4182 |
@@ -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: cd ./src/
|
||||
- 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
|
||||
|
||||
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
|
||||
16
.gitignore
vendored
@@ -1,4 +1,18 @@
|
||||
node_modules
|
||||
out
|
||||
src/images
|
||||
.DS_Store
|
||||
|
||||
/images*/
|
||||
/helper-images/
|
||||
|
||||
dist
|
||||
!.github/images
|
||||
*.code-workspace
|
||||
*.pfx
|
||||
|
||||
Microsoft.Trusted.Signing.Client*
|
||||
trusted-signing-metadata.json
|
||||
.env
|
||||
electron-windows-sign.log
|
||||
.npmrc
|
||||
/.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 ./src/images
|
||||
- cd ./src/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 src/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 ./tools/certs/apple.cer -k ~/Library/Keychains/$KEYCHAIN -T /usr/bin/codesign
|
||||
# Developer Authentication Certification Authority
|
||||
security import ./tools/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
|
||||
33
HELP.md
@@ -1,35 +1,14 @@
|
||||
# Help & Commonly Asked Questions
|
||||
|
||||
## MS-DOS seems to brick the screen
|
||||
Hit `Alt + Enter` to make the command screen "full screen" (as far as Windows 95 is
|
||||
## MS-DOS seems to mess up the screen
|
||||
Hit `Alt + Enter` to make the command screen "Full Screen" (as far as Windows 95 is
|
||||
concerned). This should restore the display from the garbled mess you see and allow
|
||||
you to access the command prompt. Press Alt-Enter again to leave full screen and go
|
||||
back to a window mode. (Thanks to @DisplacedGamer for that wisdom)
|
||||
you to access the Command Prompt. Press Alt-Enter again to leave Full Screen and go
|
||||
back to Window Mode. (Thanks to @DisplacedGamers for that wisdom)
|
||||
|
||||
## Windows 95 is stuck in a bad state
|
||||
|
||||
Restart the application and click on the "Reset machine & delete state" button.
|
||||
You can find it in the lower left of the screen. Then, hit the "Start Windows 95"
|
||||
button to start your virtual machine again.
|
||||
On the app's home screen, select "Settings" in the lower menu. Then, delete your
|
||||
machine's state before starting it again - this time hopefully without issues.
|
||||
|
||||
## I want to install additional apps or games
|
||||
|
||||
If you are running macOS, or Linux, you can probably "mount" the
|
||||
virtual hard drive used by `windows95` to add files. Hit the "Show Disk Image"
|
||||
button in the lower right of the app, which will take you to the disk image.
|
||||
|
||||
On 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright 2018 Felix Rieseberg
|
||||
Copyright 2019 Felix Rieseberg
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
105
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.0.0/windows95-2.0.0-win32-standalone-ia32.zip) <br /> 📦[Standalone, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.0.0/windows95-2.0.0-win32-standalone-x64.zip) | 📦[Standalone](https://github.com/felixrieseberg/windows95/releases/download/v2.0.0/windows95-macos-2.0.0.zip) | |
|
||||
| Installer | 💽[Setup, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.0.0/windows95-2.0.0-setup-win32-x64.exe) <br /> 💽[Setup, 32-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.0.0/windows95-2.0.0-setup-win32-ia32.exe) | | 💽[deb, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.0.0/windows95-linux-2.0.0_amd64.deb) <br /> 💽[rpm, 64-bit](https://github.com/felixrieseberg/windows95/releases/download/v2.0.0/windows95-linux-2.0.0.x86_64.rpm) |
|
||||
|
||||

|
||||
<table class="is-fullwidth">
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="./.github/images/windows.png" width="24"><br />
|
||||
Windows
|
||||
</td>
|
||||
<td>
|
||||
<span>32-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/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,28 +97,35 @@ 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.999% of the work was done over at [v86](https://github.com/copy/v86/) by Copy.
|
||||
99% of the work was done over at [v86](https://github.com/copy/v86/) by Copy aka Fabian Hemmer and his contributors.
|
||||
|
||||
## Contributing
|
||||
|
||||
Before you can run this from source, you'll need the disk image. It's not part of the
|
||||
repository, but you can grab it using the `Show Disk Image` button from the packaged
|
||||
release, which does include the disk image.
|
||||
release, which does include the disk image. You can find that button in the
|
||||
`Modify C: Drive` section.
|
||||
|
||||
Unpack the `images` folder into the `src` folder, creating this layout:
|
||||
|
||||
```
|
||||
./src/images/windows95.img
|
||||
- /images/windows95.img
|
||||
- /images/default-state.bin
|
||||
- /assets/...
|
||||
- /bios/...
|
||||
- /docs/...
|
||||
```
|
||||
|
||||
Once you've done so, run `npm install` and `npm start` to run your local build.
|
||||
|
||||
If you want to tinker with the image or make a new one, check out the [QEMU docs](./docs/qemu.md).
|
||||
|
||||
## Other Questions
|
||||
|
||||
* [MS-DOS seems to brick the screen](./HELP.md#ms-dos-seems-to-brick-the-screen)
|
||||
|
||||
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
Normal file
BIN
bios/vgabios.bin
Normal file
@@ -35,5 +35,5 @@ xhost +
|
||||
```
|
||||
4. run
|
||||
```
|
||||
docker run -it -e DISPLAY=host.docker.internal:1 toolboc/windows95
|
||||
docker run -it -e DISPLAY=host.docker.internal:0 toolboc/windows95
|
||||
```
|
||||
|
||||
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,7 +1,39 @@
|
||||
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'),
|
||||
},
|
||||
packagerConfig: {
|
||||
asar: false,
|
||||
icon: path.resolve(__dirname, 'assets', 'icon'),
|
||||
@@ -9,11 +41,43 @@ 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: [
|
||||
{
|
||||
@@ -26,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
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -45,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
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
23554
package-lock.json
generated
57
package.json
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "windows95",
|
||||
"productName": "windows95",
|
||||
"version": "2.1.0",
|
||||
"version": "4.0.0",
|
||||
"description": "Windows 95, in an app. I'm sorry.",
|
||||
"main": "src/index.js",
|
||||
"main": "./dist/src/main/main.js",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"start": "rimraf ./dist && electron-forge start",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make",
|
||||
"publish": "electron-forge publish",
|
||||
"lint": "standard \"src/**/*.js\""
|
||||
"lint": "prettier --write src/**/*.{ts,tsx} && npm run check-links",
|
||||
"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",
|
||||
@@ -17,31 +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.0.1",
|
||||
"update-electron-app": "^1.3.0"
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"update-electron-app": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^6.0.0-beta.34",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.34",
|
||||
"@electron-forge/maker-flatpak": "^6.0.0-beta.34",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.34",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.34",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.34",
|
||||
"@electron-forge/publisher-github": "^6.0.0-beta.34",
|
||||
"electron": "5.0.1",
|
||||
"node-abi": "^2.8.0",
|
||||
"standard": "^12.0.1"
|
||||
"@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;
|
||||
25
src/cache.js
@@ -1,25 +0,0 @@
|
||||
const { session } = require('electron')
|
||||
|
||||
const clearCaches = async () => {
|
||||
await clearCache()
|
||||
await clearStorageData()
|
||||
}
|
||||
|
||||
const clearCache = () => {
|
||||
return new Promise((resolve) => {
|
||||
session.defaultSession.clearCache(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
const clearStorageData = () => {
|
||||
return new Promise((resolve) => {
|
||||
session.defaultSession.clearStorageData({
|
||||
storages: 'appcache, cookies, filesystem, indexdb, localstorage, shadercache, websql, serviceworkers',
|
||||
quotas: 'temporary, persistent, syncable'
|
||||
}, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearCaches
|
||||
}
|
||||
31
src/cache.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { session } from "electron";
|
||||
|
||||
export async function clearCaches() {
|
||||
await clearCache();
|
||||
await clearStorageData();
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
if (session.defaultSession) {
|
||||
await session.defaultSession.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearStorageData() {
|
||||
if (!session.defaultSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await session.defaultSession.clearStorageData({
|
||||
storages: [
|
||||
"cookies",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
"localstorage",
|
||||
"shadercache",
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
],
|
||||
quotas: ["temporary"],
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
const { remote, app } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const _app = app || remote.app
|
||||
|
||||
const CONSTANTS = {
|
||||
IMAGE_PATH: path.join(__dirname, 'images/windows95.img'),
|
||||
IMAGE_DEFAULT_SIZE: 1073741824, // 1GB
|
||||
DEFAULT_STATE_PATH: path.join(__dirname, 'images/default-state.bin'),
|
||||
STATE_PATH: path.join(_app.getPath('userData'), 'state-v2.bin')
|
||||
}
|
||||
|
||||
const IPC_COMMANDS = {
|
||||
TOGGLE_INFO: 'TOGGLE_INFO',
|
||||
MACHINE_RESTART: 'MACHINE_RESTART',
|
||||
MACHINE_RESET: 'MACHINE_RESET',
|
||||
MACHINE_CTRL_ALT_DEL: 'MACHINE_CTRL_ALT_DEL',
|
||||
SHOW_DISK_IMAGE: 'SHOW_DISK_IMAGE'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONSTANTS,
|
||||
IPC_COMMANDS
|
||||
}
|
||||
36
src/constants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as path from "path";
|
||||
|
||||
const IMAGES_PATH = path.join(__dirname, "../../images");
|
||||
|
||||
export const CONSTANTS = {
|
||||
IMAGES_PATH,
|
||||
IMAGE_PATH: path.join(IMAGES_PATH, "windows95.img"),
|
||||
IMAGE_DEFAULT_SIZE: 1073741824, // 1GB
|
||||
DEFAULT_STATE_PATH: path.join(IMAGES_PATH, "default-state.bin"),
|
||||
};
|
||||
|
||||
export const IPC_COMMANDS = {
|
||||
TOGGLE_INFO: "TOGGLE_INFO",
|
||||
SHOW_DISK_IMAGE: "SHOW_DISK_IMAGE",
|
||||
ZOOM_IN: "ZOOM_IN",
|
||||
ZOOM_OUT: "ZOOM_OUT",
|
||||
ZOOM_RESET: "ZOOM_RESET",
|
||||
// Machine instructions
|
||||
MACHINE_START: "MACHINE_START",
|
||||
MACHINE_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",
|
||||
// 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",
|
||||
};
|
||||
36
src/es6.js
@@ -1,36 +0,0 @@
|
||||
const { protocol } = require('electron')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
const ES6_PATH = path.join(__dirname, 'renderer')
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'es6',
|
||||
privileges: {
|
||||
standard: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
async function setupProtocol () {
|
||||
protocol.registerBufferProtocol('es6', async (req, cb) => {
|
||||
console.log(req)
|
||||
|
||||
try {
|
||||
const filePath = path.join(ES6_PATH, req.url.replace('es6://', ''))
|
||||
.replace('.js/', '.js')
|
||||
.replace('.js\\', '.js')
|
||||
|
||||
const fileContent = await fs.readFile(filePath)
|
||||
|
||||
cb({ mimeType: 'text/javascript', data: fileContent }) // eslint-disable-line
|
||||
} catch (error) {
|
||||
console.warn(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setupProtocol
|
||||
}
|
||||
55
src/index.js
@@ -1,55 +0,0 @@
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const { createMenu } = require('./menu')
|
||||
const { setupProtocol } = require('./es6')
|
||||
|
||||
if (require('electron-squirrel-startup')) { // eslint-disable-line global-require
|
||||
app.quit()
|
||||
}
|
||||
|
||||
if (app.isPackaged) {
|
||||
require('update-electron-app')({
|
||||
repo: 'felixrieseberg/windows95',
|
||||
updateInterval: '1 hour'
|
||||
})
|
||||
}
|
||||
|
||||
let mainWindow
|
||||
|
||||
const createWindow = () => {
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.loadURL(`file://${__dirname}/renderer/index.html`)
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
app.on('ready', async () => {
|
||||
await setupProtocol()
|
||||
await createMenu()
|
||||
|
||||
createWindow()
|
||||
})
|
||||
|
||||
// Quit when all windows are closed.
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow === null) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
29
src/less/emulator.less
Normal file
@@ -0,0 +1,29 @@
|
||||
#emulator {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
|
||||
> div {
|
||||
white-space: pre;
|
||||
font: 14px monospace;
|
||||
line-height: 14px
|
||||
}
|
||||
|
||||
> canvas {
|
||||
display: none;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.paused {
|
||||
canvas {
|
||||
opacity: 0.2;
|
||||
filter: blur(2px);
|
||||
z-index: -100;
|
||||
}
|
||||
|
||||
#emulator-text-screen {
|
||||
display: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
7
src/less/info.less
Normal file
@@ -0,0 +1,7 @@
|
||||
#information {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
bottom: 50px;
|
||||
font-size: 18px;
|
||||
}
|
||||
77
src/less/root.less
Normal file
@@ -0,0 +1,77 @@
|
||||
@import "./status.less";
|
||||
@import "./emulator.less";
|
||||
@import "./info.less";
|
||||
@import "./start.less";
|
||||
@import "./settings.less";
|
||||
|
||||
// 98.css uses the actual MS Sans Serif bitmap font and pixel-exact bevels.
|
||||
// Everything below is layout — the chrome comes from 98.css.
|
||||
|
||||
@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;
|
||||
font-family: @win-font;
|
||||
-webkit-font-smoothing: none;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
body.paused {
|
||||
background: @win-teal;
|
||||
|
||||
> #emulator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
li[role="tab"],
|
||||
.title-bar-controls button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
// 98.css renders button text via text-shadow (color: transparent) so the
|
||||
// bitmap font stays crisp; <img> children need their own alignment.
|
||||
button img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
vertical-align: -3px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: @win-font;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
section {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
71
src/less/settings.less
Normal file
@@ -0,0 +1,71 @@
|
||||
.settings-window {
|
||||
width: 460px;
|
||||
|
||||
> .window-body {
|
||||
margin: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
padding: 3px;
|
||||
|
||||
> .window-body {
|
||||
margin: 12px;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.settings-window .field-row-stacked {
|
||||
margin-bottom: 12px;
|
||||
|
||||
input[type="text"] {
|
||||
width: 100%;
|
||||
font-family: @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;
|
||||
}
|
||||
}
|
||||
99
src/less/start.less
Normal file
@@ -0,0 +1,99 @@
|
||||
// "Welcome to Windows" splash — modelled on the real first-boot dialog.
|
||||
|
||||
.welcome {
|
||||
width: 540px;
|
||||
}
|
||||
|
||||
.welcome-body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
margin: 4px;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.welcome-stripe {
|
||||
width: 26px;
|
||||
background: linear-gradient(180deg, #000 0%, navy 60%, #1084d0 100%);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
|
||||
span {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(180deg);
|
||||
writing-mode: vertical-rl;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 4px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-family: "Times New Roman", serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: 400;
|
||||
font-size: 24px;
|
||||
margin: 0 0 14px;
|
||||
color: #000;
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
small {
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
vertical-align: baseline;
|
||||
margin-left: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-tip {
|
||||
flex: 1;
|
||||
background: #ffffe1;
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf,
|
||||
inset 2px 2px #0a0a0a;
|
||||
padding: 12px 14px;
|
||||
|
||||
.welcome-tip-header {
|
||||
border-bottom: 1px solid grey;
|
||||
box-shadow: 0 1px 0 #fff;
|
||||
padding-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-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;
|
||||
}
|
||||
}
|
||||
37
src/less/status.less
Normal file
@@ -0,0 +1,37 @@
|
||||
#status-hotzone {
|
||||
position: absolute;
|
||||
z-index: 99;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
#status {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
left: 50vw;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
background: white;
|
||||
font-size: 10px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
overflow: hidden;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
max-height: 18px;
|
||||
top: 0;
|
||||
transition: transform 0.12s ease-out;
|
||||
|
||||
&.hidden {
|
||||
transform: translateX(-50%) translateY(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
#status-hotzone:hover + #status.hidden,
|
||||
#status.hidden:hover {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
2
src/less/vendor/98.css
vendored
Normal file
21
src/less/vendor/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 Yoshi Mannaert
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
BIN
src/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
28
src/main/about-panel.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { AboutPanelOptionsOptions, app } from "electron";
|
||||
|
||||
/**
|
||||
* Sets Fiddle's About panel options on Linux and macOS
|
||||
*
|
||||
* @returns
|
||||
*/
|
||||
export function setupAboutPanel(): void {
|
||||
if (process.platform === "win32") return;
|
||||
|
||||
const options: AboutPanelOptionsOptions = {
|
||||
applicationName: "windows95",
|
||||
applicationVersion: app.getVersion(),
|
||||
version: process.versions.electron,
|
||||
copyright: "Felix Rieseberg",
|
||||
};
|
||||
|
||||
switch (process.platform) {
|
||||
case "linux":
|
||||
options.website = "https://github.com/felixrieseberg/windows95";
|
||||
case "darwin":
|
||||
options.credits = "https://github.com/felixrieseberg/windows95";
|
||||
default:
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
app.setAboutPanelOptions(options);
|
||||
}
|
||||
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);
|
||||
}
|
||||
79
src/main/main.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { app } from "electron";
|
||||
|
||||
import { isDevMode } from "../utils/devmode";
|
||||
import { setupAboutPanel } from "./about-panel";
|
||||
import { shouldQuit } from "./squirrel";
|
||||
import { setupUpdates } from "./update";
|
||||
import { getOrCreateWindow } from "./windows";
|
||||
import { setupMenu } from "./menu";
|
||||
import { setupIpcListeners } from "./ipc";
|
||||
import { setupSession } from "./session";
|
||||
import { setupFileServer } from "./fileserver/fileserver";
|
||||
|
||||
/**
|
||||
* Handle the app's "ready" event. This is essentially
|
||||
* the method that takes care of booting the application.
|
||||
*/
|
||||
export async function onReady() {
|
||||
if (!isDevMode()) process.env.NODE_ENV = "production";
|
||||
|
||||
setupSession();
|
||||
setupIpcListeners();
|
||||
getOrCreateWindow();
|
||||
setupAboutPanel();
|
||||
setupMenu();
|
||||
setupUpdates();
|
||||
setupFileServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the "before-quit" event
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export function onBeforeQuit() {
|
||||
(global as any).isQuitting = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* All windows have been closed, quit on anything but
|
||||
* macOS.
|
||||
*/
|
||||
export function onWindowsAllClosed() {
|
||||
// On OS X it is common for applications and their menu bar
|
||||
// to stay active until the user quits explicitly with Cmd + Q
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The main method - and the first function to run
|
||||
* when Fiddle is launched.
|
||||
*
|
||||
* Exported for testing purposes.
|
||||
*/
|
||||
export function main() {
|
||||
// Handle creating/removing shortcuts on Windows when
|
||||
// installing/uninstalling.
|
||||
if (shouldQuit()) {
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDevMode()) {
|
||||
// Renderer DevTools Protocol — connect Chrome to chrome://inspect
|
||||
// or attach a debugger to localhost:9222
|
||||
app.commandLine.appendSwitch("remote-debugging-port", "9222");
|
||||
}
|
||||
|
||||
// Set the app's name
|
||||
app.setName("windows95");
|
||||
|
||||
// Launch
|
||||
app.on("ready", onReady);
|
||||
app.on("before-quit", onBeforeQuit);
|
||||
app.on("window-all-closed", onWindowsAllClosed);
|
||||
}
|
||||
|
||||
main();
|
||||
274
src/main/menu.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { app, shell, Menu, BrowserWindow, ipcMain, dialog } from "electron";
|
||||
|
||||
import { clearCaches } from "../cache";
|
||||
import { IPC_COMMANDS } from "../constants";
|
||||
import { isDevMode } from "../utils/devmode";
|
||||
import { log } from "./logging";
|
||||
|
||||
const LINKS = {
|
||||
homepage: "https://www.felixrieseberg.com",
|
||||
repo: "https://github.com/felixrieseberg/windows95",
|
||||
credits: "https://github.com/felixrieseberg/windows95/blob/master/CREDITS.md",
|
||||
help: "https://github.com/felixrieseberg/windows95/blob/master/HELP.md",
|
||||
};
|
||||
|
||||
export async function setupMenu() {
|
||||
await createMenu();
|
||||
|
||||
ipcMain.on(IPC_COMMANDS.MACHINE_STARTED, () =>
|
||||
createMenu({ isRunning: true }),
|
||||
);
|
||||
ipcMain.on(IPC_COMMANDS.MACHINE_STOPPED, () =>
|
||||
createMenu({ isRunning: false }),
|
||||
);
|
||||
}
|
||||
|
||||
function send(cmd: string) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
|
||||
if (windows[0]) {
|
||||
log(`Sending "${cmd}"`);
|
||||
windows[0].webContents.send(cmd);
|
||||
} else {
|
||||
log(`Tried to send "${cmd}", but could not find window`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createMenu({ isRunning } = { isRunning: false }) {
|
||||
const template: Array<Electron.MenuItemConstructorOptions> = [
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{
|
||||
label: "Toggle Full Screen",
|
||||
accelerator: (function () {
|
||||
if (process.platform === "darwin") {
|
||||
return "Ctrl+Command+F";
|
||||
} else {
|
||||
return "F11";
|
||||
}
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) {
|
||||
focusedWindow.setFullScreen(!focusedWindow.isFullScreen());
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Toggle Developer Tools",
|
||||
accelerator: (function () {
|
||||
if (process.platform === "darwin") {
|
||||
return "Alt+Command+I";
|
||||
} else {
|
||||
return "Ctrl+Shift+I";
|
||||
}
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow instanceof BrowserWindow) {
|
||||
focusedWindow.webContents.toggleDevTools();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Toggle Emulator Info",
|
||||
click: () => send(IPC_COMMANDS.TOGGLE_INFO),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
role: "reload",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "editMenu",
|
||||
visible: isDevMode(),
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
role: "window",
|
||||
submenu: [
|
||||
{
|
||||
label: "Minimize",
|
||||
accelerator: "CmdOrCtrl+M",
|
||||
role: "minimize",
|
||||
},
|
||||
{
|
||||
label: "Close",
|
||||
accelerator: "CmdOrCtrl+W",
|
||||
role: "close",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Zoom in",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_IN),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Zoom out",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_OUT),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Reset zoom",
|
||||
click: () => send(IPC_COMMANDS.ZOOM_RESET),
|
||||
enabled: isRunning,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Machine",
|
||||
submenu: [
|
||||
{
|
||||
label: "Send Ctrl+Alt+Del",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Alt+F4",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ALT_F4),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Alt+Enter",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ALT_ENTER),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Send Esc",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_ESC),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
isRunning
|
||||
? {
|
||||
label: "Stop",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_STOP),
|
||||
}
|
||||
: {
|
||||
label: "Start",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_START),
|
||||
},
|
||||
{
|
||||
label: "Restart",
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESTART),
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
label: "Reset",
|
||||
click: async () => {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "warning",
|
||||
buttons: ["Reset", "Cancel"],
|
||||
defaultId: 1,
|
||||
title: "Reset Machine",
|
||||
message: "Are you sure you want to reset the machine?",
|
||||
detail:
|
||||
"This will delete the machine state, including all changes you have made.",
|
||||
});
|
||||
|
||||
if (result.response === 0) {
|
||||
send(IPC_COMMANDS.MACHINE_RESET);
|
||||
}
|
||||
},
|
||||
enabled: isRunning,
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Go to Disk Image",
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
role: "help",
|
||||
submenu: [
|
||||
{
|
||||
label: "Author",
|
||||
click: () => shell.openExternal(LINKS.homepage),
|
||||
},
|
||||
{
|
||||
label: "windows95 on GitHub",
|
||||
click: () => shell.openExternal(LINKS.repo),
|
||||
},
|
||||
{
|
||||
label: "Help",
|
||||
click: () => shell.openExternal(LINKS.help),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Troubleshooting",
|
||||
submenu: [
|
||||
{
|
||||
label: "Clear Cache and Restart",
|
||||
async click() {
|
||||
await clearCaches();
|
||||
|
||||
app.relaunch();
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
template.unshift({
|
||||
label: "windows95",
|
||||
submenu: [
|
||||
{
|
||||
role: "about",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
role: "services",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Hide windows95",
|
||||
accelerator: "Command+H",
|
||||
role: "hide",
|
||||
},
|
||||
{
|
||||
label: "Hide Others",
|
||||
accelerator: "Command+Shift+H",
|
||||
role: "hideothers",
|
||||
},
|
||||
{
|
||||
role: "unhide",
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
{
|
||||
label: "Quit",
|
||||
accelerator: "Command+Q",
|
||||
click() {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
}
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template as any));
|
||||
}
|
||||
18
src/main/session.ts
Normal file
@@ -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();
|
||||
3
src/main/squirrel.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function shouldQuit() {
|
||||
return require("electron-squirrel-startup");
|
||||
}
|
||||
11
src/main/update.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { app } from "electron";
|
||||
|
||||
export function setupUpdates() {
|
||||
if (app.isPackaged) {
|
||||
const { updateElectronApp } = require("update-electron-app");
|
||||
updateElectronApp({
|
||||
repo: "felixrieseberg/windows95",
|
||||
updateInterval: "1 hour",
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/main/windows.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { BrowserWindow, shell } from "electron";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
export function getOrCreateWindow(): BrowserWindow {
|
||||
if (mainWindow) return mainWindow;
|
||||
|
||||
// Create the browser window.
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1024,
|
||||
height: 768,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
sandbox: false,
|
||||
webviewTag: false,
|
||||
contextIsolation: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile("./dist/static/index.html");
|
||||
|
||||
mainWindow.webContents.on("will-navigate", (event, url) =>
|
||||
handleNavigation(event, url),
|
||||
);
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function handleNavigation(event: Electron.Event, url: string) {
|
||||
if (url.startsWith("http")) {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
182
src/menu.js
@@ -1,182 +0,0 @@
|
||||
const { app, shell, Menu, BrowserWindow } = require('electron')
|
||||
|
||||
const { clearCaches } = require('./cache')
|
||||
const { IPC_COMMANDS } = require('./constants')
|
||||
|
||||
const LINKS = {
|
||||
homepage: 'https://www.twitter.com/felixrieseberg',
|
||||
repo: 'https://github.com/felixrieseberg/windows95',
|
||||
credits: 'https://github.com/felixrieseberg/windows95/blob/master/CREDITS.md',
|
||||
help: 'https://github.com/felixrieseberg/windows95/blob/master/HELP.md'
|
||||
}
|
||||
|
||||
function send (cmd) {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
|
||||
if (windows[0]) {
|
||||
windows[0].webContents.send(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
async function createMenu () {
|
||||
const template = [
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: (function () {
|
||||
if (process.platform === 'darwin') { return 'Ctrl+Command+F' } else { return 'F11' }
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) { focusedWindow.setFullScreen(!focusedWindow.isFullScreen()) }
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Toggle Developer Tools',
|
||||
accelerator: (function () {
|
||||
if (process.platform === 'darwin') { return 'Alt+Command+I' } else { return 'Ctrl+Shift+I' }
|
||||
})(),
|
||||
click: function (_item, focusedWindow) {
|
||||
if (focusedWindow) { focusedWindow.toggleDevTools() }
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Toggle Emulator Info',
|
||||
click: () => send(IPC_COMMANDS.TOGGLE_INFO)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
role: 'window',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Minimize',
|
||||
accelerator: 'CmdOrCtrl+M',
|
||||
role: 'minimize'
|
||||
},
|
||||
{
|
||||
label: 'Close',
|
||||
accelerator: 'CmdOrCtrl+W',
|
||||
role: 'close'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Machine',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Send Ctrl+Alt+Del',
|
||||
click: () => send(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL)
|
||||
},
|
||||
{
|
||||
label: 'Restart',
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESTART)
|
||||
},
|
||||
{
|
||||
label: 'Reset',
|
||||
click: () => send(IPC_COMMANDS.MACHINE_RESET)
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Go to Disk Image',
|
||||
click: () => send(IPC_COMMANDS.SHOW_DISK_IMAGE)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
role: 'help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Author',
|
||||
click: () => shell.openExternal(LINKS.homepage)
|
||||
},
|
||||
{
|
||||
label: 'windows95 on GitHub',
|
||||
click: () => shell.openExternal(LINKS.repo)
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
click: () => shell.openExternal(LINKS.help)
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Troubleshooting',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Clear Cache and Restart',
|
||||
async click () {
|
||||
await clearCaches()
|
||||
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
template.unshift({
|
||||
label: 'windows95',
|
||||
submenu: [
|
||||
{
|
||||
label: 'About windows95',
|
||||
role: 'about'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Services',
|
||||
role: 'services',
|
||||
submenu: []
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Hide windows95',
|
||||
accelerator: 'Command+H',
|
||||
role: 'hide'
|
||||
},
|
||||
{
|
||||
label: 'Hide Others',
|
||||
accelerator: 'Command+Shift+H',
|
||||
role: 'hideothers'
|
||||
},
|
||||
{
|
||||
label: 'Show All',
|
||||
role: 'unhide'
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
accelerator: 'Command+Q',
|
||||
click () {
|
||||
app.quit()
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMenu
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
const { remote, shell, ipcRenderer } = require('electron')
|
||||
const path = require('path')
|
||||
const EventEmitter = require('events')
|
||||
|
||||
const { resetState, restoreState, saveState } = require('./state')
|
||||
const { getDiskImageSize } = require('./utils/disk-image-size')
|
||||
const { IPC_COMMANDS, CONSTANTS } = require('./constants')
|
||||
|
||||
class Windows95 extends EventEmitter {
|
||||
constructor () {
|
||||
super()
|
||||
|
||||
// Constants
|
||||
this.CONSTANTS = CONSTANTS
|
||||
this.IPC_COMMANDS = IPC_COMMANDS
|
||||
|
||||
// Methods
|
||||
this.getDiskImageSize = getDiskImageSize
|
||||
this.restoreState = restoreState
|
||||
this.resetState = resetState
|
||||
this.saveState = saveState
|
||||
|
||||
Object.keys(IPC_COMMANDS).forEach((command) => {
|
||||
ipcRenderer.on(command, (...args) => {
|
||||
this.emit(command, args)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
showDiskImage () {
|
||||
const imagePath = path.join(__dirname, 'images/windows95.img')
|
||||
.replace('app.asar', 'app.asar.unpacked')
|
||||
|
||||
shell.showItemInFolder(imagePath)
|
||||
}
|
||||
|
||||
quit () {
|
||||
remote.app.quit()
|
||||
}
|
||||
}
|
||||
|
||||
window.windows95 = new Windows95()
|
||||
@@ -1,9 +0,0 @@
|
||||
export function setupState () {
|
||||
window.appState = {
|
||||
isResetting: false,
|
||||
isQuitting: false,
|
||||
cursorCaptured: false,
|
||||
floppyFile: null,
|
||||
bootFresh: false
|
||||
}
|
||||
}
|
||||
42
src/renderer/app.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface Win95Window extends Window {
|
||||
emulator: any;
|
||||
win95: {
|
||||
app: App;
|
||||
};
|
||||
}
|
||||
|
||||
declare let window: Win95Window;
|
||||
|
||||
/**
|
||||
* The top-level class controlling the whole app. This is *not* a React component,
|
||||
* but it does eventually render all components.
|
||||
*
|
||||
* @class App
|
||||
*/
|
||||
export class App {
|
||||
/**
|
||||
* Initial setup call, loading Monaco and kicking off the React
|
||||
* render process.
|
||||
*/
|
||||
public async setup(): Promise<void> {
|
||||
const React = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { Emulator } = await import("./emulator");
|
||||
|
||||
const className = `${process.platform}`;
|
||||
const app = (
|
||||
<div className={className}>
|
||||
<Emulator />
|
||||
</div>
|
||||
);
|
||||
|
||||
const root = createRoot(document.getElementById("app")!);
|
||||
root.render(app);
|
||||
}
|
||||
}
|
||||
|
||||
window.win95 = window.win95 || {
|
||||
app: new App(),
|
||||
};
|
||||
|
||||
window.win95.app.setup();
|
||||
1
src/renderer/bios/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
*.gz
|
||||
@@ -1,44 +0,0 @@
|
||||
const $ = document.querySelector.bind(document)
|
||||
|
||||
export function setupButtons (start) {
|
||||
// Start
|
||||
$('.btn-start').addEventListener('click', () => start())
|
||||
|
||||
// Disk Image
|
||||
$('#show-disk-image').addEventListener('click', () => windows95.showDiskImage())
|
||||
|
||||
// Reset
|
||||
$('#reset').addEventListener('click', () => windows95.resetState())
|
||||
|
||||
$('#discard-state').addEventListener('click', () => {
|
||||
window.appState.bootFresh = true
|
||||
|
||||
start()
|
||||
})
|
||||
|
||||
// Floppy
|
||||
$('#floppy').addEventListener('click', () => {
|
||||
$('#file-input').click()
|
||||
})
|
||||
|
||||
// Floppy (Hidden Input)
|
||||
$('#file-input').addEventListener('change', (event) => {
|
||||
window.appState.floppyFile = event.target.files && event.target.files.length > 0
|
||||
? event.target.files[0]
|
||||
: null
|
||||
|
||||
if (window.appState.floppyFile) {
|
||||
$('#floppy-path').innerHTML = `Inserted Floppy Disk: ${window.appState.floppyFile.path}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleButtons (forceTo) {
|
||||
const buttonElements = $('#buttons')
|
||||
|
||||
if (buttonElements.style.display !== 'none' || forceTo === false) {
|
||||
buttonElements.style.display = 'none'
|
||||
} else {
|
||||
buttonElements.style.display = undefined
|
||||
}
|
||||
}
|
||||
273
src/renderer/card-settings.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import * as React from "react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class CardSettings extends React.Component<
|
||||
CardSettingsProps,
|
||||
CardSettingsState
|
||||
> {
|
||||
constructor(props: CardSettingsProps) {
|
||||
super(props);
|
||||
|
||||
this.onChangeFloppy = this.onChangeFloppy.bind(this);
|
||||
this.onChangeCdrom = this.onChangeCdrom.bind(this);
|
||||
this.onResetState = this.onResetState.bind(this);
|
||||
|
||||
this.state = {
|
||||
tab: "floppy",
|
||||
isStateReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { tab } = this.state;
|
||||
|
||||
return (
|
||||
<div className="window settings-window">
|
||||
<div className="title-bar">
|
||||
<div className="title-bar-text">windows95 Properties</div>
|
||||
<div className="title-bar-controls">
|
||||
<button aria-label="Help" disabled />
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={() => this.props.navigate("start")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="window-body">
|
||||
<menu role="tablist">
|
||||
{this.renderTab("floppy", "Floppy Drive")}
|
||||
{CDROM_ENABLED && this.renderTab("cdrom", "CD-ROM")}
|
||||
{this.renderTab("network", "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>
|
||||
);
|
||||
}
|
||||
|
||||
private renderTab(id: Tab, label: string) {
|
||||
return (
|
||||
<li
|
||||
role="tab"
|
||||
aria-selected={this.state.tab === id}
|
||||
onClick={() => this.setState({ tab: id })}
|
||||
>
|
||||
<a href="#">{label}</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
private renderFloppy() {
|
||||
const { floppy } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Drive A:</legend>
|
||||
<input
|
||||
id="floppy-input"
|
||||
type="file"
|
||||
onChange={this.onChangeFloppy}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/floppy.png" />
|
||||
<p>
|
||||
windows95 ships with a virtual 3½" floppy drive. Mount an{" "}
|
||||
<code>.img</code> disk image here, then boot the machine to read it
|
||||
from inside Windows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="floppy-path">Mounted image</label>
|
||||
<input
|
||||
id="floppy-path"
|
||||
type="text"
|
||||
readOnly
|
||||
value={floppy ? floppy.name : "(No disk in drive)"}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderCdrom() {
|
||||
const { cdrom } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>Drive D:</legend>
|
||||
<input
|
||||
id="cdrom-input"
|
||||
type="file"
|
||||
onChange={this.onChangeCdrom}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<div className="settings-row">
|
||||
<img className="settings-icon" src="../../static/cdrom.png" />
|
||||
<p>
|
||||
windows95 ships with a virtual CD-ROM drive. Mount an{" "}
|
||||
<code>.iso</code> image here, then boot the machine to read it from
|
||||
inside Windows.
|
||||
</p>
|
||||
</div>
|
||||
<div className="field-row-stacked">
|
||||
<label htmlFor="cdrom-path">Mounted image</label>
|
||||
<input
|
||||
id="cdrom-path"
|
||||
type="text"
|
||||
readOnly
|
||||
value={cdrom ? cdrom.name : "(No disc in drive)"}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-buttons">
|
||||
<button
|
||||
onClick={() =>
|
||||
(document.querySelector("#cdrom-input") as any).click()
|
||||
}
|
||||
>
|
||||
Mount image...
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
private renderSmbShare() {
|
||||
const { smbSharePath } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>\\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
|
||||
? event.target.files[0]
|
||||
: null;
|
||||
|
||||
if (floppyFile) {
|
||||
this.props.setFloppy(floppyFile);
|
||||
} else {
|
||||
console.log(`Floppy: Input changed but no file selected`);
|
||||
}
|
||||
}
|
||||
|
||||
private onChangeCdrom(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const cdromFile =
|
||||
event.target.files && event.target.files.length > 0
|
||||
? event.target.files[0]
|
||||
: null;
|
||||
|
||||
if (cdromFile) {
|
||||
this.props.setCdrom(cdromFile);
|
||||
} else {
|
||||
console.log(`Cdrom: Input changed but no file selected`);
|
||||
}
|
||||
}
|
||||
|
||||
private async onResetState() {
|
||||
await resetState();
|
||||
this.setState({ isStateReset: true });
|
||||
}
|
||||
}
|
||||
65
src/renderer/card-start.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface CardStartProps {
|
||||
startEmulator: () => void;
|
||||
navigate: (to: "start" | "settings") => void;
|
||||
}
|
||||
|
||||
const TIPS = [
|
||||
"Press the Escape key at any time to release or recapture your mouse cursor.",
|
||||
"You can mount a floppy image from Settings before booting to install vintage software.",
|
||||
"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 (
|
||||
<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);
|
||||
}
|
||||
191
src/renderer/emulator-info.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface EmulatorInfoProps {
|
||||
toggleInfo: () => void;
|
||||
emulator: any;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
interface EmulatorInfoState {
|
||||
cpu: number;
|
||||
diskRead: number;
|
||||
diskWrite: number;
|
||||
netRx: number;
|
||||
netTx: number;
|
||||
lastCounter: number;
|
||||
lastTick: number;
|
||||
}
|
||||
|
||||
export class EmulatorInfo extends React.Component<
|
||||
EmulatorInfoProps,
|
||||
EmulatorInfoState
|
||||
> {
|
||||
private tickInterval = -1;
|
||||
private diskReadBytes = 0;
|
||||
private diskWriteBytes = 0;
|
||||
private netRxBytes = 0;
|
||||
private netTxBytes = 0;
|
||||
|
||||
constructor(props: EmulatorInfoProps) {
|
||||
super(props);
|
||||
|
||||
this.tick = this.tick.bind(this);
|
||||
this.onIDEReadEnd = this.onIDEReadEnd.bind(this);
|
||||
this.onIDEWriteEnd = this.onIDEWriteEnd.bind(this);
|
||||
this.onEthReceiveEnd = this.onEthReceiveEnd.bind(this);
|
||||
this.onEthTransmitEnd = this.onEthTransmitEnd.bind(this);
|
||||
|
||||
this.state = {
|
||||
cpu: 0,
|
||||
diskRead: 0,
|
||||
diskWrite: 0,
|
||||
netRx: 0,
|
||||
netTx: 0,
|
||||
lastCounter: 0,
|
||||
lastTick: 0,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { cpu, diskRead, diskWrite, netRx, netTx } = this.state;
|
||||
const { hidden, toggleInfo } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
this.uninstallListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* The emulator starts whenever, so install or uninstall listeners
|
||||
* at the right time
|
||||
*
|
||||
* @param newProps
|
||||
*/
|
||||
public componentDidUpdate(prevProps: EmulatorInfoProps) {
|
||||
if (prevProps.emulator !== this.props.emulator) {
|
||||
if (this.props.emulator) {
|
||||
this.installListeners();
|
||||
} else {
|
||||
this.uninstallListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let's start listening to what the emulator is up to.
|
||||
*/
|
||||
private installListeners() {
|
||||
const { emulator } = this.props;
|
||||
|
||||
if (!emulator) {
|
||||
console.log(
|
||||
`Emulator info: Tried to install listeners, but emulator not defined yet.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tickInterval > -1) {
|
||||
clearInterval(this.tickInterval);
|
||||
}
|
||||
|
||||
// TypeScript think's we're using a Node.js setInterval. We're not.
|
||||
this.tickInterval = setInterval(this.tick, 500) as unknown as number;
|
||||
|
||||
emulator.add_listener("ide-read-end", this.onIDEReadEnd);
|
||||
emulator.add_listener("ide-write-end", this.onIDEWriteEnd);
|
||||
emulator.add_listener("eth-receive-end", this.onEthReceiveEnd);
|
||||
emulator.add_listener("eth-transmit-end", this.onEthTransmitEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening to the emulator.
|
||||
*/
|
||||
private uninstallListeners() {
|
||||
const { emulator } = this.props;
|
||||
|
||||
if (!emulator) {
|
||||
console.log(
|
||||
`Emulator info: Tried to uninstall listeners, but emulator not defined yet.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tickInterval > -1) {
|
||||
clearInterval(this.tickInterval);
|
||||
}
|
||||
|
||||
emulator.remove_listener("ide-read-end", this.onIDEReadEnd);
|
||||
emulator.remove_listener("ide-write-end", this.onIDEWriteEnd);
|
||||
emulator.remove_listener("eth-receive-end", this.onEthReceiveEnd);
|
||||
emulator.remove_listener("eth-transmit-end", this.onEthTransmitEnd);
|
||||
}
|
||||
|
||||
private onIDEReadEnd(args: number[]) {
|
||||
this.diskReadBytes += args[1];
|
||||
}
|
||||
|
||||
private onIDEWriteEnd(args: number[]) {
|
||||
this.diskWriteBytes += args[1];
|
||||
}
|
||||
|
||||
private onEthReceiveEnd(args: number[]) {
|
||||
this.netRxBytes += args[0];
|
||||
}
|
||||
|
||||
private onEthTransmitEnd(args: number[]) {
|
||||
this.netTxBytes += args[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes/sec into a compact human string.
|
||||
*/
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once per interval, compute CPU speed and I/O throughput.
|
||||
*/
|
||||
private tick() {
|
||||
const { lastCounter, lastTick } = this.state;
|
||||
|
||||
const now = Date.now();
|
||||
const instructionCounter = this.props.emulator.get_instruction_counter();
|
||||
const ips = instructionCounter - lastCounter;
|
||||
const deltaTime = now - lastTick;
|
||||
const deltaSec = deltaTime / 1000;
|
||||
|
||||
this.setState({
|
||||
lastTick: now,
|
||||
lastCounter: instructionCounter,
|
||||
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;
|
||||
}
|
||||
}
|
||||
571
src/renderer/emulator.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import * as React from "react";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { ipcRenderer, shell, webUtils } from "electron";
|
||||
|
||||
import { CONSTANTS, IPC_COMMANDS } from "../constants";
|
||||
import { getDiskImageSize } from "../utils/disk-image-size";
|
||||
import { CardStart } from "./card-start";
|
||||
import { CardSettings } from "./card-settings";
|
||||
import { EmulatorInfo } from "./emulator-info";
|
||||
import { 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: "start" | "settings";
|
||||
emulator?: any;
|
||||
scale: number;
|
||||
floppyFile?: File;
|
||||
cdromFile?: File;
|
||||
smbSharePath: string;
|
||||
isBootingFresh: boolean;
|
||||
isCursorCaptured: boolean;
|
||||
isInfoDisplayed: boolean;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export class Emulator extends React.Component<{}, EmulatorState> {
|
||||
private isQuitting = false;
|
||||
private isResetting = false;
|
||||
|
||||
constructor(props: {}) {
|
||||
super(props);
|
||||
|
||||
this.startEmulator = this.startEmulator.bind(this);
|
||||
this.stopEmulator = this.stopEmulator.bind(this);
|
||||
this.restartEmulator = this.restartEmulator.bind(this);
|
||||
this.resetEmulator = this.resetEmulator.bind(this);
|
||||
this.bootFromScratch = this.bootFromScratch.bind(this);
|
||||
|
||||
this.state = {
|
||||
isBootingFresh: PROBE,
|
||||
isCursorCaptured: false,
|
||||
isRunning: false,
|
||||
currentUiCard: "start",
|
||||
isInfoDisplayed: true,
|
||||
smbSharePath: "",
|
||||
// We can start pretty large
|
||||
// If it's too large, it'll just grow until it hits borders
|
||||
scale: 2,
|
||||
};
|
||||
|
||||
this.setupInputListeners();
|
||||
this.setupIpcListeners();
|
||||
this.setupUnloadListeners();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to capture and release the mouse at appropriate times.
|
||||
*/
|
||||
public setupInputListeners() {
|
||||
// ESC
|
||||
document.onkeydown = (evt) => {
|
||||
const { isCursorCaptured } = this.state;
|
||||
|
||||
evt = evt || window.event;
|
||||
|
||||
if (evt.keyCode === 27) {
|
||||
if (isCursorCaptured) {
|
||||
this.unlockMouse();
|
||||
} else {
|
||||
this.lockMouse();
|
||||
}
|
||||
|
||||
evt.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Click
|
||||
document.addEventListener("click", () => {
|
||||
const { isRunning } = this.state;
|
||||
|
||||
if (isRunning) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the emulator's state to disk during exit.
|
||||
*/
|
||||
public setupUnloadListeners() {
|
||||
const handleClose = async () => {
|
||||
await this.saveState();
|
||||
|
||||
console.log(`Unload: Now done, quitting again.`);
|
||||
this.isQuitting = true;
|
||||
|
||||
setImmediate(() => {
|
||||
ipcRenderer.invoke(IPC_COMMANDS.APP_QUIT);
|
||||
});
|
||||
};
|
||||
|
||||
window.onbeforeunload = (event: Event) => {
|
||||
if (this.isQuitting || this.isResetting) {
|
||||
console.log(`Unload: Not preventing`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Unload: Preventing to first save state`);
|
||||
|
||||
handleClose();
|
||||
event.preventDefault();
|
||||
event.returnValue = false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the various IPC messages sent to the renderer
|
||||
* from the main process
|
||||
*/
|
||||
public setupIpcListeners() {
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_CTRL_ALT_DEL, () => {
|
||||
this.sendKeys([
|
||||
0x1d, // ctrl
|
||||
0x38, // alt
|
||||
0x53, // delete
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_F4, () => {
|
||||
this.sendKeys([
|
||||
0x38, // alt
|
||||
0x3e, // f4
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ALT_ENTER, () => {
|
||||
this.sendKeys([
|
||||
0x38, // alt
|
||||
0, // enter
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_ESC, () => {
|
||||
this.sendKeys([
|
||||
0x18, // alt
|
||||
]);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_STOP, this.stopEmulator);
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_RESET, this.resetEmulator);
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_START, this.startEmulator);
|
||||
ipcRenderer.on(IPC_COMMANDS.MACHINE_RESTART, this.restartEmulator);
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.TOGGLE_INFO, () => {
|
||||
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.SHOW_DISK_IMAGE, () => {
|
||||
this.showDiskImage();
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_IN, () => {
|
||||
this.setScale(this.state.scale * 1.2);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_OUT, () => {
|
||||
this.setScale(this.state.scale * 0.8);
|
||||
});
|
||||
|
||||
ipcRenderer.on(IPC_COMMANDS.ZOOM_RESET, () => {
|
||||
this.setScale(1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If the emulator isn't running, this is rendering the, erm, UI.
|
||||
*
|
||||
* 🤡
|
||||
*/
|
||||
public renderUI() {
|
||||
const { isRunning, currentUiCard, floppyFile, cdromFile } = this.state;
|
||||
|
||||
if (isRunning) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigate = (currentUiCard: "start" | "settings") =>
|
||||
this.setState({ currentUiCard });
|
||||
|
||||
let card;
|
||||
|
||||
if (currentUiCard === "settings") {
|
||||
card = (
|
||||
<CardSettings
|
||||
setFloppy={(floppyFile) => this.setState({ floppyFile })}
|
||||
setCdrom={(cdromFile) => this.setState({ cdromFile })}
|
||||
setSmbSharePath={(smbSharePath) => {
|
||||
this.setState({ smbSharePath });
|
||||
ipcRenderer.invoke(IPC_COMMANDS.SET_SMB_SHARE_PATH, smbSharePath);
|
||||
}}
|
||||
pickFolder={() => ipcRenderer.invoke(IPC_COMMANDS.PICK_FOLDER)}
|
||||
bootFromScratch={this.bootFromScratch}
|
||||
floppy={floppyFile}
|
||||
cdrom={cdromFile}
|
||||
smbSharePath={this.state.smbSharePath}
|
||||
navigate={navigate}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
card = (
|
||||
<CardStart startEmulator={this.startEmulator} navigate={navigate} />
|
||||
);
|
||||
}
|
||||
|
||||
return <section>{card}</section>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yaknow, render things and stuff.
|
||||
*/
|
||||
public render() {
|
||||
return (
|
||||
<>
|
||||
{this.renderInfo()}
|
||||
{this.renderUI()}
|
||||
<div id="emulator">
|
||||
<div id="emulator-text-screen"></div>
|
||||
<canvas id="emulator-canvas"></canvas>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the little info thingy
|
||||
*/
|
||||
public renderInfo() {
|
||||
return (
|
||||
<EmulatorInfo
|
||||
emulator={this.state.emulator}
|
||||
hidden={!this.state.isInfoDisplayed}
|
||||
toggleInfo={() => {
|
||||
this.setState({ isInfoDisplayed: !this.state.isInfoDisplayed });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the emulator without restoring state
|
||||
*/
|
||||
public bootFromScratch() {
|
||||
this.setState({ isBootingFresh: true });
|
||||
this.startEmulator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the disk image on disk
|
||||
*/
|
||||
public showDiskImage() {
|
||||
// Contents/Resources/app/dist/static
|
||||
console.log(`Showing disk image in ${CONSTANTS.IMAGE_PATH}`);
|
||||
|
||||
shell.showItemInFolder(CONSTANTS.IMAGE_PATH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the actual emulator
|
||||
*/
|
||||
private async startEmulator() {
|
||||
document.body.classList.remove("paused");
|
||||
|
||||
const cdromPath = this.state.cdromFile
|
||||
? webUtils.getPathForFile(this.state.cdromFile)
|
||||
: null;
|
||||
|
||||
const options = {
|
||||
wasm_path: path.join(__dirname, "build/v86.wasm"),
|
||||
memory_size: 128 * 1024 * 1024,
|
||||
vga_memory_size: 64 * 1024 * 1024,
|
||||
screen: {
|
||||
container: document.getElementById("emulator"),
|
||||
scale: 0,
|
||||
},
|
||||
preserve_mac_from_state_image: true,
|
||||
net_device: {
|
||||
relay_url: "fetch",
|
||||
type: "ne2k",
|
||||
},
|
||||
bios: {
|
||||
url: path.join(__dirname, "../../bios/seabios.bin"),
|
||||
},
|
||||
vga_bios: {
|
||||
url: path.join(__dirname, "../../bios/vgabios.bin"),
|
||||
},
|
||||
hda: {
|
||||
url: CONSTANTS.IMAGE_PATH,
|
||||
async: true,
|
||||
size: await getDiskImageSize(CONSTANTS.IMAGE_PATH),
|
||||
},
|
||||
fda: this.state.floppyFile
|
||||
? {
|
||||
buffer: this.state.floppyFile,
|
||||
}
|
||||
: undefined,
|
||||
cdrom: cdromPath
|
||||
? {
|
||||
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 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,
|
||||
});
|
||||
|
||||
ipcRenderer.send(IPC_COMMANDS.MACHINE_STARTED);
|
||||
|
||||
// Restore state. We can't do this right away
|
||||
// and randomly chose 500ms as the appropriate
|
||||
// wait time (lol)
|
||||
setTimeout(async () => {
|
||||
if (!this.state.isBootingFresh) {
|
||||
this.restoreState();
|
||||
}
|
||||
|
||||
this.lockMouse();
|
||||
this.state.emulator.run();
|
||||
this.state.emulator.screen_set_scale(this.state.scale);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart emulator
|
||||
*/
|
||||
private restartEmulator() {
|
||||
if (this.state.emulator && this.state.isRunning) {
|
||||
console.log(`🚜 Restarting emulator`);
|
||||
this.state.emulator.restart();
|
||||
} else {
|
||||
console.log(`🚜 Restarting emulator failed: Emulator not running`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the emulator
|
||||
*/
|
||||
private async stopEmulator() {
|
||||
const { emulator, isRunning } = this.state;
|
||||
|
||||
if (!emulator || !isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🚜 Stopping emulator`);
|
||||
|
||||
await this.saveState();
|
||||
this.unlockMouse();
|
||||
await emulator.stop();
|
||||
this.setState({ isRunning: false });
|
||||
this.resetCanvas();
|
||||
|
||||
document.body.classList.add("paused");
|
||||
ipcRenderer.send(IPC_COMMANDS.MACHINE_STOPPED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the emulator by reloading the whole page
|
||||
*/
|
||||
private async resetEmulator() {
|
||||
this.isResetting = true;
|
||||
|
||||
await this.stopEmulator();
|
||||
await resetState();
|
||||
|
||||
document.location.reload();
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the emulators state and write it to disk. This is possibly
|
||||
* a fairly big file.
|
||||
*/
|
||||
private async saveState(): Promise<void> {
|
||||
const { emulator } = this.state;
|
||||
const statePath = await getStatePath();
|
||||
|
||||
if (!emulator || !emulator.save_state) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const newState = await emulator.save_state();
|
||||
await fs.promises.writeFile(statePath, Buffer.from(newState), {
|
||||
flush: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(`saveState: Could not save state`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores state to the emulator.
|
||||
*/
|
||||
private async restoreState() {
|
||||
const { emulator, isBootingFresh } = this.state;
|
||||
const state = await this.getState();
|
||||
|
||||
if (isBootingFresh) {
|
||||
console.log(`restoreState: Booting fresh, not restoring.`);
|
||||
return;
|
||||
} else if (!state) {
|
||||
console.log(`restoreState: No state present, not restoring.`);
|
||||
return;
|
||||
} else if (!emulator) {
|
||||
console.log(`restoreState: No emulator present`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.state.emulator.restore_state(state);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`restoreState: Could not read state file. Maybe none exists?`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current machine's state - either what
|
||||
* we have saved or alternatively the default state.
|
||||
*
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
private async getState(): Promise<ArrayBuffer | null> {
|
||||
const expectedStatePath = await getStatePath();
|
||||
const statePath = fs.existsSync(expectedStatePath)
|
||||
? expectedStatePath
|
||||
: CONSTANTS.DEFAULT_STATE_PATH;
|
||||
|
||||
if (fs.existsSync(statePath)) {
|
||||
return fs.readFileSync(statePath).buffer;
|
||||
} else {
|
||||
console.log(`getState: No state file found at ${statePath}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private unlockMouse() {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
|
||||
private lockMouse() {
|
||||
const { emulator } = this.state;
|
||||
|
||||
if (emulator) {
|
||||
emulator.lock_mouse();
|
||||
} else {
|
||||
console.warn(
|
||||
`Emulator: Tried to lock mouse, but no emulator or not running`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the emulator's scale
|
||||
*
|
||||
* @param target
|
||||
*/
|
||||
private setScale(target: number) {
|
||||
const { emulator, isRunning } = this.state;
|
||||
|
||||
if (emulator && isRunning) {
|
||||
emulator.screen_set_scale(target);
|
||||
this.setState({ scale: target });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send keys to the emulator (including the key-up),
|
||||
* if it's running
|
||||
*
|
||||
* @param {Array<number>} codes
|
||||
*/
|
||||
private sendKeys(codes: Array<number>) {
|
||||
if (this.state.emulator && this.state.isRunning) {
|
||||
const scancodes = codes;
|
||||
|
||||
// Push break codes (key-up)
|
||||
for (const scancode of scancodes) {
|
||||
scancodes.push(scancode | 0x80);
|
||||
}
|
||||
|
||||
this.state.emulator.keyboard_send_scancodes(scancodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the canvas
|
||||
*/
|
||||
private resetCanvas() {
|
||||
const canvas = document.getElementById("emulator-canvas");
|
||||
|
||||
if (canvas instanceof HTMLCanvasElement) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/renderer/global.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const V86: any;
|
||||
declare const win95: any;
|
||||
@@ -1,55 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title>Windows</title>
|
||||
<script src="./lib/libv86.js"></script>
|
||||
<link rel="stylesheet" href="style/style.css">
|
||||
</head>
|
||||
|
||||
<body class="paused">
|
||||
<div id="status">
|
||||
Disk: <span id="disk-status">Idle</span>
|
||||
| CPU Speed: <span id="cpu-status">0</span>
|
||||
| <a href="#" id="toggle-status">Hide</a>
|
||||
</div>
|
||||
<div id="buttons">
|
||||
<div id="start-buttons">
|
||||
<!-- <div class="btn" id="win98">Windows 98</div> -->
|
||||
<div class="btn btn-start" id="win95">
|
||||
Start Windows 95
|
||||
<br />
|
||||
|
||||
<small>Hit ESC to lock or unlock your mouse</small>
|
||||
</div>
|
||||
<!-- <div class="btn" id="win1">Windows 1</div> -->
|
||||
</div>
|
||||
<div id="other-buttons">
|
||||
<div class="btn" id="reset">Reset Machine & Delete State</div>
|
||||
<div class="btn" id="floppy">Insert Floppy Disk</div>
|
||||
<div class="btn" id="discard-state">Discard State & Boot From Scratch</div>
|
||||
<div class="btn" id="show-disk-image">Show Disk Image</div>
|
||||
<input id="file-input" type='file'>
|
||||
</div>
|
||||
<div id="information">
|
||||
<p id="floppy-path"></p>
|
||||
<p>You can insert a floppy disk image with the ".img" format.</p>
|
||||
<p>
|
||||
Boot the machine from scratch if you've inserted a new floppy disk
|
||||
or if you've changed the disk image.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="emulator" style="height: 100vh; width: 100vw">
|
||||
<div style="white-space: pre; font: 14px monospace; line-height: 14px"></div>
|
||||
<canvas style="display: none"></canvas>
|
||||
</div>
|
||||
<script type="module">
|
||||
import("es6://renderer.js")
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,94 +0,0 @@
|
||||
const $ = document.querySelector.bind(document)
|
||||
const status = $('#status')
|
||||
const diskStatus = $('#disk-status')
|
||||
const cpuStatus = $('#cpu-status')
|
||||
const toggleStatus = $('#toggle-status')
|
||||
|
||||
let lastCounter = 0
|
||||
let lastTick = 0
|
||||
let infoInterval = null
|
||||
|
||||
const onIDEReadStart = () => {
|
||||
diskStatus.innerHTML = 'Read'
|
||||
}
|
||||
|
||||
const onIDEReadWriteEnd = () => {
|
||||
diskStatus.innerHTML = 'Idle'
|
||||
}
|
||||
|
||||
toggleStatus.onclick = toggleInfo
|
||||
|
||||
/**
|
||||
* Toggle the information display
|
||||
*/
|
||||
export function toggleInfo () {
|
||||
if (status.style.display !== 'none') {
|
||||
disableInfo()
|
||||
} else {
|
||||
enableInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start information gathering, but only if the panel is visible
|
||||
*/
|
||||
export function startInfoMaybe () {
|
||||
if (status.style.display !== 'none') {
|
||||
enableInfo()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the gathering of information (and hide the little information tab)
|
||||
*/
|
||||
export function enableInfo () {
|
||||
// Show the info thingy
|
||||
status.style.display = 'block'
|
||||
|
||||
// We can only do the rest with an emulator
|
||||
if (!window.emulator.add_listener) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set listeners
|
||||
window.emulator.add_listener('ide-read-start', onIDEReadStart)
|
||||
window.emulator.add_listener('ide-read-end', onIDEReadWriteEnd)
|
||||
window.emulator.add_listener('ide-write-end', onIDEReadWriteEnd)
|
||||
window.emulator.add_listener('screen-set-size-graphical', console.log)
|
||||
|
||||
// Set an interval
|
||||
infoInterval = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const instructionCounter = window.emulator.get_instruction_counter()
|
||||
const ips = instructionCounter - lastCounter
|
||||
const deltaTime = now - lastTick
|
||||
|
||||
lastTick = now
|
||||
lastCounter = instructionCounter
|
||||
|
||||
cpuStatus.innerHTML = Math.round(ips / deltaTime)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the gathering of information (and hide the little information tab)
|
||||
*/
|
||||
export function disableInfo () {
|
||||
// Hide the info thingy
|
||||
status.style.display = 'none'
|
||||
|
||||
// Clear the interval
|
||||
clearInterval(infoInterval)
|
||||
infoInterval = null
|
||||
|
||||
// We can only do the rest with an emulator
|
||||
if (!window.emulator.remove_listener) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unset the listeners
|
||||
window.emulator.remove_listener('ide-read-start', onIDEReadStart)
|
||||
window.emulator.remove_listener('ide-read-end', onIDEReadWriteEnd)
|
||||
window.emulator.remove_listener('ide-write-end', onIDEReadWriteEnd)
|
||||
window.emulator.remove_listener('screen-set-size-graphical', console.log)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { toggleInfo } from 'es6://info.js'
|
||||
|
||||
export function setupIpcListeners (start) {
|
||||
const { windows95 } = window
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.TOGGLE_INFO, () => {
|
||||
toggleInfo()
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.MACHINE_RESTART, () => {
|
||||
console.log(`Restarting machine`)
|
||||
|
||||
if (!window.emulator || !window.emulator.is_running) return
|
||||
|
||||
window.emulator.restart()
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.MACHINE_RESET, () => {
|
||||
console.log(`Resetting machine`)
|
||||
|
||||
window.appState.isResetting = true
|
||||
document.location.hash = `#AUTO_START`
|
||||
document.location.reload()
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.MACHINE_CTRL_ALT_DEL, () => {
|
||||
if (!window.emulator || !window.emulator.is_running) return
|
||||
|
||||
window.emulator.keyboard_send_scancodes([
|
||||
0x1D, // ctrl
|
||||
0x38, // alt
|
||||
0x53, // delete
|
||||
|
||||
// break codes
|
||||
0x1D | 0x80,
|
||||
0x38 | 0x80,
|
||||
0x53 | 0x80
|
||||
])
|
||||
})
|
||||
|
||||
windows95.addListener(windows95.IPC_COMMANDS.SHOW_DISK_IMAGE, () => {
|
||||
windows95.showDiskImage()
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2012-2018, Fabian Hemmer
|
||||
Copyright (c) 2012, The v86 contributors
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
@@ -19,8 +19,4 @@ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The views and conclusions contained in the software and documentation are those
|
||||
of the authors and should not be interpreted as representing official policies,
|
||||
either expressed or implied, of the FreeBSD Project.
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
BIN
src/renderer/lib/build/v86.wasm
Executable file
@@ -1,47 +0,0 @@
|
||||
export function setupCloseListener () {
|
||||
window.appState.isQuitting = false
|
||||
|
||||
const handleClose = async () => {
|
||||
await windows95.saveState()
|
||||
window.appState.isQuitting = true
|
||||
windows95.quit()
|
||||
}
|
||||
|
||||
window.onbeforeunload = (event) => {
|
||||
if (window.appState.isQuitting) return
|
||||
if (window.appState.isResetting) return
|
||||
|
||||
handleClose()
|
||||
event.preventDefault()
|
||||
event.returnValue = false
|
||||
}
|
||||
}
|
||||
|
||||
export function setupEscListener () {
|
||||
document.onkeydown = function (evt) {
|
||||
evt = evt || window.event
|
||||
if (evt.keyCode === 27) {
|
||||
if (window.appState.cursorCaptured) {
|
||||
window.appState.cursorCaptured = false
|
||||
window.emulator.mouse_set_status(false)
|
||||
document.exitPointerLock()
|
||||
} else {
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentClick () {
|
||||
if (!window.appState.cursorCaptured) {
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.mouse_set_status(true)
|
||||
window.emulator.lock_mouse()
|
||||
}
|
||||
}
|
||||
|
||||
export function setupClickListener () {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* We're using modern esm imports here */
|
||||
import { setupState } from 'es6://app-state.js'
|
||||
import { setupClickListener, setupEscListener, setupCloseListener } from 'es6://listeners.js'
|
||||
import { toggleButtons, setupButtons } from 'es6://buttons.js'
|
||||
import { startInfoMaybe } from 'es6://info.js'
|
||||
import { setupIpcListeners } from 'es6://ipc.js'
|
||||
|
||||
setupState()
|
||||
|
||||
/**
|
||||
* The main method executing the VM.
|
||||
*/
|
||||
async function main () {
|
||||
const imageSize = await window.windows95.getDiskImageSize()
|
||||
const options = {
|
||||
memory_size: 128 * 1024 * 1024,
|
||||
video_memory_size: 32 * 1024 * 1024,
|
||||
screen_container: document.getElementById('emulator'),
|
||||
bios: {
|
||||
url: './bios/seabios.bin'
|
||||
},
|
||||
vga_bios: {
|
||||
url: './bios/vgabios.bin'
|
||||
},
|
||||
hda: {
|
||||
url: '../images/windows95.img',
|
||||
async: true,
|
||||
size: imageSize
|
||||
},
|
||||
fda: {
|
||||
buffer: window.appState.floppyFile || undefined
|
||||
},
|
||||
boot_order: 0x132
|
||||
}
|
||||
|
||||
console.log(`Starting emulator with options`, options)
|
||||
|
||||
// New v86 instance
|
||||
window.emulator = new V86Starter(options)
|
||||
|
||||
// Restore state. We can't do this right away
|
||||
// and randomly chose 500ms as the appropriate
|
||||
// wait time (lol)
|
||||
setTimeout(async () => {
|
||||
if (!window.appState.bootFresh) {
|
||||
windows95.restoreState()
|
||||
}
|
||||
|
||||
startInfoMaybe()
|
||||
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
window.emulator.run()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function start () {
|
||||
document.body.className = ''
|
||||
|
||||
toggleButtons(false)
|
||||
setupClickListener()
|
||||
main()
|
||||
}
|
||||
|
||||
setupIpcListeners(start)
|
||||
setupEscListener()
|
||||
setupCloseListener()
|
||||
setupButtons(start)
|
||||
|
||||
if (document.location.hash.includes('AUTO_START')) {
|
||||
start()
|
||||
}
|
||||
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); }
|
||||
}
|
||||
0
src/renderer/status.tsx
Normal file
@@ -1,99 +0,0 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.paused > #emulator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.paused {
|
||||
background: #008080;
|
||||
font-family: Courier;
|
||||
}
|
||||
|
||||
#buttons {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#status {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
left: calc(50vw - 110px);
|
||||
background: white;
|
||||
text-align: center;
|
||||
font-family: Courier;
|
||||
font-size: 10px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
overflow: hidden;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
max-height: 18px;
|
||||
}
|
||||
|
||||
#floppy-path {
|
||||
background: beige;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
#information {
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
bottom: 50px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#emulator {
|
||||
display: flex
|
||||
}
|
||||
|
||||
#emulator canvas {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#other-buttons {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
bottom: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#start-buttons {
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-family: Courier;
|
||||
cursor: pointer;
|
||||
background: #ffd2fd;
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
cursor: pointer;
|
||||
background: #ff95fa;
|
||||
margin: 10px;
|
||||
padding: 5px;
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/state.js
@@ -1,81 +0,0 @@
|
||||
const fs = require('fs-extra')
|
||||
|
||||
const { CONSTANTS } = require('./constants')
|
||||
|
||||
/**
|
||||
* Returns the current machine's state - either what
|
||||
* we have saved or alternatively the default state.
|
||||
*
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function getState () {
|
||||
const statePath = fs.existsSync(CONSTANTS.STATE_PATH)
|
||||
? CONSTANTS.STATE_PATH
|
||||
: CONSTANTS.DEFAULT_STATE_PATH
|
||||
|
||||
if (fs.existsSync(statePath)) {
|
||||
return fs.readFileSync(statePath).buffer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a saved state by simply deleting it.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function resetState () {
|
||||
if (fs.existsSync(CONSTANTS.STATE_PATH)) {
|
||||
return fs.remove(CONSTANTS.STATE_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current VM's state.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function saveState () {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.emulator || !window.emulator.save_state) {
|
||||
return resolve()
|
||||
}
|
||||
|
||||
window.emulator.save_state(async (error, newState) => {
|
||||
if (error) {
|
||||
console.warn(`State: Could not save state`, error)
|
||||
return
|
||||
}
|
||||
|
||||
await fs.outputFile(CONSTANTS.STATE_PATH, Buffer.from(newState))
|
||||
|
||||
console.log(`State: Saved state to ${CONSTANTS.STATE_PATH}`)
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the VM's state.
|
||||
*/
|
||||
function restoreState () {
|
||||
const state = getState()
|
||||
|
||||
// Nothing to do with if we don't have a state
|
||||
if (!state) {
|
||||
console.log(`State: No state present, not restoring.`)
|
||||
}
|
||||
|
||||
try {
|
||||
window.emulator.restore_state(state)
|
||||
} catch (error) {
|
||||
console.log(`State: Could not read state file. Maybe none exists?`, error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveState,
|
||||
restoreState,
|
||||
resetState,
|
||||
getState
|
||||
}
|
||||
8
src/utils/devmode.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Are we currently running in development mode?
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isDevMode() {
|
||||
return !!process.defaultApp;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
const fs = require('fs-extra')
|
||||
|
||||
const { CONSTANTS } = require('../constants')
|
||||
|
||||
/**
|
||||
* Get the size of the disk image
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
async function getDiskImageSize () {
|
||||
try {
|
||||
const stats = await fs.stat(CONSTANTS.IMAGE_PATH)
|
||||
|
||||
if (stats) {
|
||||
return stats.size
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not determine image size`, error)
|
||||
}
|
||||
|
||||
return CONSTANTS.IMAGE_DEFAULT_SIZE
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDiskImageSize
|
||||
}
|
||||
22
src/utils/disk-image-size.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
import { CONSTANTS } from "../constants";
|
||||
|
||||
/**
|
||||
* Get the size of the disk image
|
||||
*
|
||||
* @returns {number}
|
||||
*/
|
||||
export async function getDiskImageSize(path: string) {
|
||||
try {
|
||||
const stats = await fs.promises.stat(path);
|
||||
|
||||
if (stats) {
|
||||
return stats.size;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Could not determine image size`, error);
|
||||
}
|
||||
|
||||
return CONSTANTS.IMAGE_DEFAULT_SIZE;
|
||||
}
|
||||
BIN
static/boot-fresh.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/cdrom.png
Normal file
|
After Width: | Height: | Size: 672 B |
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>
|
||||
BIN
static/floppy.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
16
static/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<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/98.css">
|
||||
<link rel="stylesheet" href="../src/less/root.less">
|
||||
<!-- libv86 -->
|
||||
</head>
|
||||
<body class="paused windows95">
|
||||
<div id="app"></div>
|
||||
<script src="../src/renderer/app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||