Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a28aef8cf0 | ||
|
|
d2b8d9dd35 | ||
|
|
3802734ef0 | ||
|
|
1f478676f1 | ||
|
|
d19bbeee8f | ||
|
|
1e130b6140 | ||
|
|
e1c5992ff9 | ||
|
|
e879760678 | ||
|
|
2a11633171 | ||
|
|
b68d54ae62 | ||
|
|
9600630340 | ||
|
|
bae1909793 | ||
|
|
ee9e138034 | ||
|
|
5558671688 | ||
|
|
9a46ed5080 | ||
|
|
2c160d0f7f | ||
|
|
aafab62707 | ||
|
|
78126a57cb | ||
|
|
f5256ec7a2 | ||
|
|
6c1687c9a5 | ||
|
|
2c041115d0 | ||
|
|
987dc57309 | ||
|
|
614b18969d | ||
|
|
264ef7d069 | ||
|
|
e85cf4f1b2 | ||
|
|
e987da5460 | ||
|
|
a542639bc3 | ||
|
|
5d1928beb2 | ||
|
|
f1b657a53b | ||
|
|
6aa39e66ec | ||
|
|
ed42ea8e0e | ||
|
|
0779f18071 | ||
|
|
a9c4e38386 | ||
|
|
62b0909cb4 | ||
|
|
873cb75241 | ||
|
|
6467acb0c8 | ||
|
|
ed1bd0a1e0 | ||
|
|
ac84f4164e | ||
|
|
77569d4ce6 | ||
|
|
68b7c181ad | ||
|
|
293491477b | ||
|
|
7eb750752b | ||
|
|
f1488cedc2 | ||
|
|
9f366063eb | ||
|
|
55135f052e | ||
|
|
95fd8e4925 | ||
|
|
b794954da4 | ||
|
|
18a73c45a0 | ||
|
|
b83914060f | ||
|
|
93955564d9 | ||
|
|
d31920aaf4 | ||
|
|
cdfe47d92b | ||
|
|
b8259784e7 | ||
|
|
b70b9fabd5 | ||
|
|
f2c1fc4142 | ||
|
|
aeba364a7a | ||
|
|
a34ce54b56 | ||
|
|
e1477bfc05 | ||
|
|
71d6f16318 |
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
|
||||
14
.gitignore
vendored
@@ -1,4 +1,16 @@
|
||||
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
|
||||
|
||||
20
HELP.md
@@ -1,18 +1,14 @@
|
||||
# Help & Commonly Asked Questions
|
||||
|
||||
## 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 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 Windows 10, 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 both Windows 10 and macOS, double-click the disk image to open it.
|
||||
|
||||
On Linux, search the Internet for instructions on how to mount an `img` disk
|
||||
image on your distribution.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
115
README.md
@@ -1,13 +1,102 @@
|
||||
# windows95
|
||||
|
||||
This is Windows 95, running in an Electron app. Yes, it's the full thing. I'm sorry.
|
||||
This is Windows 95, running in an [Electron](https://electronjs.org/) app. Yes, it's the full thing. I'm sorry.
|
||||
|
||||
## 💿⏬ [Download it here](https://github.com/felixrieseberg/windows95/releases).
|
||||
## Downloads
|
||||
|
||||
<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/v3.1.1/windows95-3.1.1-setup-ia32.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-ia32-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a>
|
||||
<br />
|
||||
<span>64-bit</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-x64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-x64-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>ARM64</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-setup-arm64.exe">
|
||||
💿 Installer
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-win32-arm64-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>
|
||||
❓ Don't know what kind of chip you have? Hit start, enter "processor" for info.
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="./.github/images/macos.png" width="24"><br />
|
||||
macOS
|
||||
</td>
|
||||
<td>
|
||||
<span>Intel Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-darwin-x64-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>Apple M1 Processor</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-darwin-arm64-3.1.1.zip">
|
||||
📦 Standalone Zip
|
||||
</a><br />
|
||||
<span>
|
||||
❓ Don't know what kind of chip you have? Learn more at <a href="https://support.apple.com/en-us/HT211814">apple.com</a>.
|
||||
</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/v3.1.1/windows95-3.1.1-1.x86_64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_amd64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARM64</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.arm64.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_arm64.deb">
|
||||
💿 deb
|
||||
</a><br />
|
||||
<span>ARMv7 (armhf)</span>
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95-3.1.1-1.armv7hl.rpm">
|
||||
💿 rpm
|
||||
</a> |
|
||||
<a href="https://github.com/felixrieseberg/windows95/releases/download/v3.1.1/windows95_3.1.1_armhf.deb">
|
||||
💿 deb
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||

|
||||
|
||||
## Does it work?
|
||||
Yes! Quite well, actually.
|
||||
Yes! Quite well, actually - on macOS, Windows, and Linux. Bear in mind that this is written entirely in JavaScript, so please adjust your expectations.
|
||||
|
||||
## Should this have been a native app?
|
||||
Absolutely.
|
||||
@@ -17,30 +106,38 @@ You'll likely be better off with an actual virtualization app, but the short ans
|
||||
@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.
|
||||
|
||||
## How's the code?
|
||||
This only works well by accident and was mostly a joke. The code quality is accordingly.
|
||||
|
||||
## 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)
|
||||
* [Windows 95 is stuck in a bad state](./HELP.md#windows-95-is-stuck-in-a-bad-state)
|
||||
* [I want to install additional apps or games](./HELP.md#i-want-to-install-additional-apps-or-games)
|
||||
* [Running in Docker](./docs/docker-instructions.md)
|
||||
* [Running in an online VM with Kubernetes and Gitpod](./docs/docker-kubernetes-gitpod.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
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
@@ -1,4 +1,4 @@
|
||||
# Running Windows 95 in Docker
|
||||
# Running windows95 in Docker
|
||||
|
||||
## Display using a volume mount of the host X11 Unix Socket (Linux Only):
|
||||
|
||||
@@ -18,8 +18,22 @@ Note: You may need to run `xhost +` on your system to allow connections to the X
|
||||
* [Docker](http://docker.io)
|
||||
|
||||
1. Start the Xming X11 Server
|
||||
2. Obtain the ip of the host machine running the Xming server
|
||||
3. Edit X0.hosts (Located in the install directory of Xming) by adding the ip of the host machine obtained in step 2
|
||||
4. Run the command below and replace the `<XmingServerHostIp>` placeholder with the ip from step 2
|
||||
2. Run the command below:
|
||||
|
||||
docker run -it -e DISPLAY=<XmingServerHostIp> --name windows95 toolboc/windows95
|
||||
docker run -e DISPLAY=host.docker.internal:0 --name windows95 toolboc/windows95
|
||||
|
||||
## Display using the host XQuartz Server (MacOS Only):
|
||||
**Requirements:**
|
||||
* [XQuartz](https://www.xquartz.org/)
|
||||
* [Docker](http://docker.io)
|
||||
|
||||
1. Start XQuartz, go to `Preferences` -> `Security`, and check the box `Allow connections from network clients`
|
||||
2. Restart XQuartz
|
||||
3. In the terminal, run
|
||||
```
|
||||
xhost +
|
||||
```
|
||||
4. run
|
||||
```
|
||||
docker run -it -e DISPLAY=host.docker.internal:0 toolboc/windows95
|
||||
```
|
||||
|
||||
4
docs/docker-kubernetes-gitpod.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## Running an online version of windows95
|
||||
You can also run windows95 in Electron, in a virtual X server, in a JavaScript VNC client, in a Kubernetes workspace. What could go wrong?
|
||||
|
||||
[](https://gitpod.io/#https://github.com/felixrieseberg/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
|
||||
```
|
||||
101
forge.config.js
@@ -1,41 +1,106 @@
|
||||
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: {
|
||||
unpack: '**/images/*.img'
|
||||
},
|
||||
asar: false,
|
||||
icon: path.resolve(__dirname, 'assets', 'icon'),
|
||||
appBundleId: 'com.felixrieseberg.windows95',
|
||||
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: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
platforms: ['win32'],
|
||||
config: {
|
||||
name: 'windows95',
|
||||
authors: 'Felix Rieseberg',
|
||||
exe: 'windows95.exe',
|
||||
noMsi: true,
|
||||
remoteReleases: '',
|
||||
setupExe: `windows95-${package.version}-setup-${process.arch}.exe`,
|
||||
setupIcon: path.resolve(__dirname, 'assets', 'icon.ico'),
|
||||
certificateFile: process.env.WINDOWS_CERTIFICATE_FILE,
|
||||
certificatePassword: process.env.WINDOWS_CERTIFICATE_PASSWORD
|
||||
config: (arch) => {
|
||||
return {
|
||||
name: 'windows95',
|
||||
authors: 'Felix Rieseberg',
|
||||
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'),
|
||||
windowsSign
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platforms: ['darwin']
|
||||
platforms: ['darwin', 'win32']
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
@@ -44,10 +109,6 @@ module.exports = {
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
platforms: ['linux']
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-flatpak',
|
||||
platforms: ['linux']
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
21938
package-lock.json
generated
56
package.json
@@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "windows95",
|
||||
"productName": "windows95",
|
||||
"version": "1.3.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,30 +21,30 @@
|
||||
"config": {
|
||||
"forge": "./forge.config.js"
|
||||
},
|
||||
"standard": {
|
||||
"globals": [
|
||||
"appState",
|
||||
"V86Starter",
|
||||
"windows95"
|
||||
],
|
||||
"ignore": [
|
||||
"/src/renderer/lib/*.js"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-default-menu": "^1.0.1",
|
||||
"electron-squirrel-startup": "^1.0.0",
|
||||
"fs-extra": "^7.0.0",
|
||||
"update-electron-app": "^1.3.0"
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"update-electron-app": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^6.0.0-beta.22",
|
||||
"@electron-forge/maker-deb": "^6.0.0-beta.22",
|
||||
"@electron-forge/maker-flatpak": "^6.0.0-beta.22",
|
||||
"@electron-forge/maker-rpm": "^6.0.0-beta.22",
|
||||
"@electron-forge/maker-squirrel": "^6.0.0-beta.22",
|
||||
"@electron-forge/maker-zip": "^6.0.0-beta.22",
|
||||
"electron": "3.0.0-beta.6",
|
||||
"standard": "^11.0.1"
|
||||
"@electron-forge/cli": "7.6.1",
|
||||
"@electron-forge/maker-deb": "7.6.1",
|
||||
"@electron-forge/maker-flatpak": "^7.6.1",
|
||||
"@electron-forge/maker-rpm": "^7.6.1",
|
||||
"@electron-forge/maker-squirrel": "^7.6.1",
|
||||
"@electron-forge/maker-zip": "^7.6.1",
|
||||
"@electron-forge/publisher-github": "^7.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"electron": "34.2.0",
|
||||
"less": "^3.13.0",
|
||||
"parcel-bundler": "^1.12.5",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.5.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
|
||||
32
patches/@electron+packager+18.3.6.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 5399b3e..f3b6e88 100644
|
||||
--- a/node_modules/@electron/packager/dist/win32.js
|
||||
+++ b/node_modules/@electron/packager/dist/win32.js
|
||||
@@ -57,7 +57,26 @@ class WindowsApp extends platform_1.App {
|
||||
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
|
||||
}
|
||||
32
src/cache.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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: [
|
||||
"appcache",
|
||||
"cookies",
|
||||
"filesystem",
|
||||
"indexdb",
|
||||
"localstorage",
|
||||
"shadercache",
|
||||
"websql",
|
||||
"serviceworkers",
|
||||
],
|
||||
quotas: ["temporary", "persistent", "syncable"],
|
||||
});
|
||||
}
|
||||
33
src/constants.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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",
|
||||
};
|
||||
29
src/es6.js
@@ -1,29 +0,0 @@
|
||||
const { protocol } = require('electron')
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
const ES6_PATH = path.join(__dirname, 'renderer')
|
||||
|
||||
protocol.registerStandardSchemes(['es6'])
|
||||
|
||||
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
|
||||
}
|
||||
57
src/index.js
@@ -1,57 +0,0 @@
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const { clearCaches } = require('./cache')
|
||||
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: 1280,
|
||||
height: 800,
|
||||
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()
|
||||
await clearCaches()
|
||||
|
||||
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;
|
||||
}
|
||||
117
src/less/root.less
Normal file
@@ -0,0 +1,117 @@
|
||||
@import "./status.less";
|
||||
@import "./emulator.less";
|
||||
@import "./info.less";
|
||||
@import "./settings.less";
|
||||
@import "./start.less";
|
||||
|
||||
/* GENERAL RESETS */
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
body.paused > #emulator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.paused {
|
||||
background: #008080;
|
||||
font-family: Courier;
|
||||
}
|
||||
|
||||
#buttons {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 75%;
|
||||
max-width: 700px;
|
||||
min-width: 400px;
|
||||
|
||||
.card-title {
|
||||
img {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link > img,
|
||||
.btn > img {
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.windows95 {
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
nav .nav-link,
|
||||
nav .nav-logo {
|
||||
height: 37px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
nav .nav-logo img {
|
||||
margin-left: 2px;
|
||||
max-height: 20px;
|
||||
}
|
||||
|
||||
nav .nav-logo > span {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
left: 37px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 40px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.btn:focus {
|
||||
border-color: #fff #000 #000 #fff;
|
||||
outline: 5px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.btn.active:before,
|
||||
.btn:focus:before,
|
||||
button.active:before,
|
||||
button:focus:before,
|
||||
input[type=submit].active:before,
|
||||
input[type=submit]:focus:before {
|
||||
border-color: #dedede grey grey #dedede;
|
||||
}
|
||||
|
||||
.card {
|
||||
// Fix link colors
|
||||
.link, .link:active, .link:link, .link:visited, a, a:active, a:link, a:visited {
|
||||
color: #008080;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Ensure a-elements in fieldsets receive click events
|
||||
fieldset:before {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/less/settings.less
Normal file
@@ -0,0 +1,21 @@
|
||||
#floppy-path {
|
||||
font-size: .6rem;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding-left: 8px;
|
||||
border-color: #000 #fff #fff #000;
|
||||
border-style: solid;
|
||||
border-width: 2px;
|
||||
background-color: #c3c3c3;
|
||||
line-height: 27px;
|
||||
}
|
||||
|
||||
#file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings {
|
||||
legend > img {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
9
src/less/start.less
Normal file
@@ -0,0 +1,9 @@
|
||||
#section-start {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> small {
|
||||
margin-top: 25px;
|
||||
font-size: .8rem;
|
||||
}
|
||||
}
|
||||
16
src/less/status.less
Normal file
@@ -0,0 +1,16 @@
|
||||
#status {
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
left: calc(50vw - 110px);
|
||||
background: white;
|
||||
font-size: 10px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom-left-radius: 15px;
|
||||
border-bottom-right-radius: 15px;
|
||||
overflow: hidden;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
max-height: 18px;
|
||||
top: 0;
|
||||
}
|
||||
BIN
src/less/vendor/95.ttf
vendored
Normal file
1
src/less/vendor/95css.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/bg-pattern.png
vendored
Normal file
|
After Width: | Height: | Size: 144 B |
BIN
src/less/vendor/dropdown.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/less/vendor/windows.woff
vendored
Normal file
BIN
src/less/vendor/windows.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">`;
|
||||
}
|
||||
157
src/main/fileserver/fileserver.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { protocol, net } from 'electron';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { generateDirectoryListing } from './page-directory-listing';
|
||||
import { generateErrorPage } from './page-error';
|
||||
import { log } from '../logging';
|
||||
|
||||
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.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 errorPage = generateErrorPage(
|
||||
'Internal Server Error',
|
||||
`An error occurred while processing your request: ${error.message}`
|
||||
);
|
||||
return new Response(errorPage, {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'text/html'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
72
src/main/fileserver/hide-files.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Stats } from "fs";
|
||||
import { settings } from "../settings";
|
||||
import { FileEntry } from "./fileserver";
|
||||
|
||||
const FILES_TO_HIDE_ON_DARWIN: string[] = [
|
||||
'.DS_Store',
|
||||
'.localized',
|
||||
'.Trashes',
|
||||
'.fseventsd',
|
||||
'.Spotlight-V100',
|
||||
'.file',
|
||||
'.hotfiles.btree',
|
||||
'.DocumentRevisions-V100',
|
||||
'.TemporaryItems',
|
||||
'.file (resource fork files)',
|
||||
'.VolumeIcon.icns',
|
||||
];
|
||||
|
||||
const FILES_TO_HIDE_ON_WINDOWS: string[] = [
|
||||
'desktop.ini',
|
||||
'Thumbs.db',
|
||||
'ehthumbs.db',
|
||||
'ehthumbs.db-shm',
|
||||
'ehthumbs.db-wal',
|
||||
];
|
||||
|
||||
const FILES_TO_HIDE_ON_LINUX: string[] = [];
|
||||
|
||||
export function shouldHideFile(file: FileEntry) {
|
||||
if (isHiddenFile(file) && !settings.get('isFileServerShowingHiddenFiles')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isSystemHiddenFile(file) && !settings.get('isFileServerShowingSystemHiddenFiles')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isHiddenFile(file: FileEntry) {
|
||||
if (process.platform === 'win32') {
|
||||
return (file.stats.mode & 0x2) === 0x2;
|
||||
} else {
|
||||
return file.name.startsWith('.');
|
||||
}
|
||||
}
|
||||
|
||||
export function isSystemHiddenFile(file: FileEntry) {
|
||||
return getFilesToHide().some(hiddenFile => file.name.endsWith(hiddenFile));
|
||||
}
|
||||
|
||||
let _filesToHide: string[];
|
||||
|
||||
function getFilesToHide() {
|
||||
if (_filesToHide) {
|
||||
return _filesToHide;
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
_filesToHide = FILES_TO_HIDE_ON_DARWIN;
|
||||
} else if (process.platform === 'win32') {
|
||||
_filesToHide = FILES_TO_HIDE_ON_WINDOWS;
|
||||
} else {
|
||||
_filesToHide = FILES_TO_HIDE_ON_LINUX;
|
||||
}
|
||||
|
||||
return _filesToHide;
|
||||
}
|
||||
|
||||
|
||||
|
||||
120
src/main/fileserver/page-directory-listing.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
import { APP_INTERCEPT, FileEntry, MY_COMPUTER_INTERCEPT } from "./fileserver";
|
||||
import { shouldHideFile } from "./hide-files";
|
||||
import { encode, getEncoding } from "./encoding";
|
||||
import { log } from "console";
|
||||
import { app } from "electron";
|
||||
|
||||
export function generateDirectoryListing(currentPath: string, files: string[]): string {
|
||||
const parentPath = path.dirname(currentPath || '/');
|
||||
const title = currentPath === '/' ? 'My Host Computer' : `Directory: ${encode(currentPath)}`;
|
||||
|
||||
// Get file info and sort (directories first, then alphabetically)
|
||||
const items = files
|
||||
.map(name => {
|
||||
const fullPath = path.join(currentPath, name);
|
||||
let stats: fs.Stats;
|
||||
try {
|
||||
stats = fs.statSync(fullPath);
|
||||
} catch (error) {
|
||||
log(`FileServer: Failed to get stats for ${fullPath}: ${error}`);
|
||||
stats = new fs.Stats();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
fullPath,
|
||||
stats
|
||||
} as FileEntry;
|
||||
})
|
||||
.filter(entry => entry.stats && !shouldHideFile(entry))
|
||||
.sort((a, b) => {
|
||||
if (a.stats.isDirectory() !== b.stats.isDirectory()) {
|
||||
return a.stats.isDirectory() ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map(getFileLiHtml)
|
||||
.join('')
|
||||
|
||||
// Generate very simple HTML that works in IE 5.5
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
${getEncoding()}
|
||||
<title>${title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>${title}</h2>
|
||||
<p>${getParentFolderLinkHtml(parentPath)} | ${getDesktopLinkHtml()} | ${getDownloadsLinkHtml()}</p>
|
||||
<p>
|
||||
<ul>
|
||||
${items}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
function getParentFolderLinkHtml(parentPath: string) {
|
||||
return `
|
||||
${getIconHtml('folder.gif')}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(parentPath)}">
|
||||
[Parent Directory]
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function getDesktopLinkHtml() {
|
||||
const desktopPath = app.getPath('desktop');
|
||||
|
||||
return `
|
||||
${getIconHtml('desktop.gif')}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(desktopPath)}">
|
||||
Desktop
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function getDownloadsLinkHtml() {
|
||||
const downloadsPath = app.getPath('downloads');
|
||||
|
||||
return `
|
||||
${getIconHtml('network.gif')}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodeURI(downloadsPath)}">
|
||||
Downloads
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
function getIconHtml(icon: string) {
|
||||
return `<img src="${APP_INTERCEPT}images/${icon}" style="vertical-align: middle; margin-right: 5px;" width="16" height="16">`;
|
||||
}
|
||||
|
||||
function getFileLiHtml(entry: FileEntry) {
|
||||
const encodedPath = encodeURI(entry.fullPath);
|
||||
const sizeDisplay = entry.stats.isDirectory() ? '' : ` (${formatFileSize(entry.stats.size)})`;
|
||||
const icon = entry.stats.isDirectory() ? getIconHtml('folder.gif') : getIconHtml('doc.gif');
|
||||
|
||||
return `<li>
|
||||
${icon}
|
||||
<a href="${MY_COMPUTER_INTERCEPT}${encodedPath}">
|
||||
${getDisplayName(entry)}
|
||||
</a>
|
||||
${sizeDisplay}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function getDisplayName(entry: FileEntry) {
|
||||
return encode(entry.stats.isDirectory() ? `[${entry.name}]` : entry.name);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
22
src/main/fileserver/page-error.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
14
src/main/ipc.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ipcMain, app } from "electron";
|
||||
import * as path from "path";
|
||||
|
||||
import { IPC_COMMANDS } from "../constants";
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
73
src/main/main.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
273
src/main/menu.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
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) {
|
||||
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));
|
||||
}
|
||||
20
src/main/session.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
72
src/main/settings.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { app } from 'electron';
|
||||
|
||||
export interface Settings {
|
||||
isFileServerEnabled: boolean;
|
||||
isFileServerShowingHiddenFiles: boolean;
|
||||
isFileServerShowingSystemHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: Settings = {
|
||||
isFileServerEnabled: true,
|
||||
isFileServerShowingHiddenFiles: false,
|
||||
isFileServerShowingSystemHiddenFiles: false,
|
||||
};
|
||||
|
||||
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(key: keyof Settings, value: any): 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");
|
||||
}
|
||||
10
src/main/update.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { app } from "electron";
|
||||
|
||||
export function setupUpdates() {
|
||||
if (app.isPackaged) {
|
||||
require("update-electron-app")({
|
||||
repo: "felixrieseberg/windows95",
|
||||
updateInterval: "1 hour",
|
||||
});
|
||||
}
|
||||
}
|
||||
40
src/main/windows.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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.webContents.toggleDevTools();
|
||||
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);
|
||||
}
|
||||
}
|
||||
92
src/menu.js
@@ -1,92 +0,0 @@
|
||||
const { app, shell, Menu, BrowserWindow } = require('electron')
|
||||
const defaultMenu = require('electron-default-menu')
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function send (cmd) {
|
||||
const windows = BrowserWindow.getAllWindows()
|
||||
|
||||
if (windows[0]) {
|
||||
windows[0].webContents.send(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
async function createMenu () {
|
||||
const menu = defaultMenu(app, shell)
|
||||
.map((item) => {
|
||||
if (item.label === 'View') {
|
||||
item.submenu = item.submenu.filter((subItem) => {
|
||||
return subItem.label !== 'Reload'
|
||||
})
|
||||
}
|
||||
|
||||
if (item.label === 'Help') {
|
||||
item.submenu = [
|
||||
{
|
||||
label: 'Author',
|
||||
click() {
|
||||
shell.openExternal(LINKS.homepage)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Learn More',
|
||||
click() {
|
||||
shell.openExternal(LINKS.repo)
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
click() {
|
||||
shell.openExternal(LINKS.help)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Credits',
|
||||
click() {
|
||||
shell.openExternal(LINKS.credits)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return item
|
||||
})
|
||||
.filter((item) => {
|
||||
return item.label !== 'Edit'
|
||||
})
|
||||
|
||||
menu.splice(1, 0, {
|
||||
label: 'Machine',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Send Ctrl+Alt+Del',
|
||||
click: () => send('ctrlaltdel')
|
||||
},
|
||||
{
|
||||
label: 'Restart',
|
||||
click: () => send('restart')
|
||||
},
|
||||
{
|
||||
type: 'separator'
|
||||
},
|
||||
{
|
||||
label: 'Go to Disk Image',
|
||||
click: () => send('disk-image')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(menu))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMenu
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
const { remote, shell, ipcRenderer } = require('electron')
|
||||
const path = require('path')
|
||||
|
||||
const { STATE_PATH, resetState, restoreState, saveState } = require('./state')
|
||||
|
||||
window.windows95 = {
|
||||
STATE_PATH,
|
||||
restoreState,
|
||||
resetState,
|
||||
saveState,
|
||||
|
||||
showDiskImage () {
|
||||
const imagePath = path.join(__dirname, 'images/windows95.img')
|
||||
.replace('app.asar', 'app.asar.unpacked')
|
||||
|
||||
shell.showItemInFolder(imagePath)
|
||||
},
|
||||
|
||||
quit: () => remote.app.quit()
|
||||
}
|
||||
|
||||
ipcRenderer.on('ctrlaltdel', () => {
|
||||
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
|
||||
])
|
||||
})
|
||||
|
||||
ipcRenderer.on('restart', () => {
|
||||
if (!window.emulator || !window.emulator.is_running) return
|
||||
|
||||
window.emulator.restart()
|
||||
})
|
||||
|
||||
ipcRenderer.on('disk-image', () => {
|
||||
windows95.showDiskImage()
|
||||
})
|
||||
@@ -1,8 +0,0 @@
|
||||
export function setupState () {
|
||||
window.appState = {
|
||||
cursorCaptured: false,
|
||||
floppyFile: null,
|
||||
bootFresh: false,
|
||||
infoInterval: null
|
||||
}
|
||||
}
|
||||
43
src/renderer/app.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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 | Element> {
|
||||
const React = await import("react");
|
||||
const { render } = await import("react-dom");
|
||||
const { Emulator } = await import("./emulator");
|
||||
|
||||
const className = `${process.platform}`;
|
||||
const app = (
|
||||
<div className={className}>
|
||||
<Emulator />
|
||||
</div>
|
||||
);
|
||||
|
||||
const rendered = render(app, document.getElementById("app"));
|
||||
|
||||
return rendered;
|
||||
}
|
||||
}
|
||||
|
||||
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('win95')
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
218
src/renderer/card-settings.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { resetState } from "./utils/reset-state";
|
||||
|
||||
interface CardSettingsProps {
|
||||
bootFromScratch: () => void;
|
||||
setFloppy: (file: File) => void;
|
||||
setCdrom: (cdrom: File) => void;
|
||||
floppy?: File;
|
||||
cdrom?: File;
|
||||
}
|
||||
|
||||
interface CardSettingsState {
|
||||
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 = {
|
||||
isStateReset: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<section>
|
||||
<div className="card settings">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">
|
||||
<img src="../../static/settings.png" />
|
||||
Settings
|
||||
</h2>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{this.renderCdrom()}
|
||||
<hr />
|
||||
{this.renderFloppy()}
|
||||
<hr />
|
||||
{this.renderState()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
public renderCdrom() {
|
||||
// CD is currently not working, so.. let's return nothing.
|
||||
return null;
|
||||
|
||||
const { cdrom } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/cdrom.png" />
|
||||
CD-ROM
|
||||
</legend>
|
||||
<input
|
||||
id="cdrom-input"
|
||||
type="file"
|
||||
onChange={this.onChangeCdrom}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<p>
|
||||
windows95 comes with a virtual CD drive. It can mount images in the
|
||||
"iso" format.
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{cdrom ? `Inserted CD: ${cdrom?.path}` : `No CD mounted`}
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
(document.querySelector("#cdrom-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/select-cdrom.png" />
|
||||
<span>Mount CD</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderFloppy() {
|
||||
const { floppy } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/floppy.png" />
|
||||
Floppy
|
||||
</legend>
|
||||
<input
|
||||
id="floppy-input"
|
||||
type="file"
|
||||
onChange={this.onChangeFloppy}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<p>
|
||||
windows95 comes with a virtual floppy drive. It can mount floppy disk
|
||||
images in the "img" format.
|
||||
</p>
|
||||
<p>
|
||||
Back in the 90s and before CD-ROMs became a popular, software was
|
||||
typically distributed on floppy disks. Some developers have since
|
||||
released their apps or games for free, usually on virtual floppy disks
|
||||
using the "img" format.
|
||||
</p>
|
||||
<p>
|
||||
Once you've mounted a disk image, you might have to boot your virtual
|
||||
windows95 machine from scratch.
|
||||
</p>
|
||||
<p id="floppy-path">
|
||||
{floppy
|
||||
? `Inserted Floppy Disk: ${floppy.name}`
|
||||
: `No floppy mounted`}
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() =>
|
||||
(document.querySelector("#floppy-input") as any).click()
|
||||
}
|
||||
>
|
||||
<img src="../../static/select-floppy.png" />
|
||||
<span>Mount floppy disk</span>
|
||||
</button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
public renderState() {
|
||||
const { isStateReset } = this.state;
|
||||
const { bootFromScratch } = this.props;
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>
|
||||
<img src="../../static/reset.png" />
|
||||
Reset machine state
|
||||
</legend>
|
||||
<div>
|
||||
<p>
|
||||
windows95 stores changes to your machine (like saved files) in a
|
||||
state file. If you encounter any trouble, you can reset your state
|
||||
or boot Windows 95 from scratch.{" "}
|
||||
<strong>All your changes will be lost.</strong>
|
||||
</p>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={this.onResetState}
|
||||
disabled={isStateReset}
|
||||
style={{ marginRight: "5px" }}
|
||||
>
|
||||
<img src="../../static/reset-state.png" />
|
||||
{isStateReset ? "State reset" : "Reset state"}
|
||||
</button>
|
||||
<button className="btn" onClick={bootFromScratch}>
|
||||
<img src="../../static/boot-fresh.png" />
|
||||
Boot from scratch
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a change in the floppy input
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a change in the cdrom input
|
||||
*
|
||||
* @param event
|
||||
*/
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the state reset
|
||||
*/
|
||||
private async onResetState() {
|
||||
await resetState();
|
||||
this.setState({ isStateReset: true });
|
||||
}
|
||||
}
|
||||
19
src/renderer/card-start.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface CardStartProps {
|
||||
startEmulator: () => void;
|
||||
}
|
||||
|
||||
export class CardStart extends React.Component<CardStartProps, {}> {
|
||||
public render() {
|
||||
return (
|
||||
<section id="section-start">
|
||||
<button className="btn" id="win95" onClick={this.props.startEmulator}>
|
||||
<img src="../../static/run.png" />
|
||||
<span>Start Windows 95</span>
|
||||
</button>
|
||||
<small>Hit ESC to lock or unlock your mouse</small>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
166
src/renderer/emulator-info.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface EmulatorInfoProps {
|
||||
toggleInfo: () => void;
|
||||
emulator: any;
|
||||
}
|
||||
|
||||
interface EmulatorInfoState {
|
||||
cpu: number;
|
||||
disk: string;
|
||||
lastCounter: number;
|
||||
lastTick: number;
|
||||
}
|
||||
|
||||
export class EmulatorInfo extends React.Component<
|
||||
EmulatorInfoProps,
|
||||
EmulatorInfoState
|
||||
> {
|
||||
private cpuInterval = -1;
|
||||
|
||||
constructor(props: EmulatorInfoProps) {
|
||||
super(props);
|
||||
|
||||
this.cpuCount = this.cpuCount.bind(this);
|
||||
this.onIDEReadStart = this.onIDEReadStart.bind(this);
|
||||
this.onIDEReadWriteEnd = this.onIDEReadWriteEnd.bind(this);
|
||||
|
||||
this.state = {
|
||||
cpu: 0,
|
||||
disk: "Idle",
|
||||
lastCounter: 0,
|
||||
lastTick: 0,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { cpu, disk } = this.state;
|
||||
|
||||
return (
|
||||
<div id="status">
|
||||
Disk: <span>{disk}</span> | CPU Speed: <span>{cpu}</span> |{" "}
|
||||
<a href="#" onClick={this.props.toggleInfo}>
|
||||
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;
|
||||
}
|
||||
|
||||
// CPU
|
||||
if (this.cpuInterval > -1) {
|
||||
clearInterval(this.cpuInterval);
|
||||
}
|
||||
|
||||
// TypeScript think's we're using a Node.js setInterval. We're not.
|
||||
this.cpuInterval = setInterval(this.cpuCount, 500) as unknown as number;
|
||||
|
||||
// Disk
|
||||
emulator.add_listener("ide-read-start", this.onIDEReadStart);
|
||||
emulator.add_listener("ide-read-end", this.onIDEReadWriteEnd);
|
||||
emulator.add_listener("ide-write-end", this.onIDEReadWriteEnd);
|
||||
|
||||
// Screen
|
||||
emulator.add_listener("screen-set-size-graphical", console.log);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// CPU
|
||||
if (this.cpuInterval > -1) {
|
||||
clearInterval(this.cpuInterval);
|
||||
}
|
||||
|
||||
// Disk
|
||||
emulator.remove_listener("ide-read-start", this.onIDEReadStart);
|
||||
emulator.remove_listener("ide-read-end", this.onIDEReadWriteEnd);
|
||||
emulator.remove_listener("ide-write-end", this.onIDEReadWriteEnd);
|
||||
|
||||
// Screen
|
||||
emulator.remove_listener("screen-set-size-graphical", console.log);
|
||||
}
|
||||
|
||||
/**
|
||||
* The virtual IDE is handling read (start).
|
||||
*/
|
||||
private onIDEReadStart() {
|
||||
this.requestIdle(() => this.setState({ disk: "Read" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The virtual IDE is handling read/write (end).
|
||||
*/
|
||||
private onIDEReadWriteEnd() {
|
||||
this.requestIdle(() => this.setState({ disk: "Idle" }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an idle callback with a 3s timeout.
|
||||
*
|
||||
* @param fn
|
||||
*/
|
||||
private requestIdle(fn: () => void) {
|
||||
(window as any).requestIdleCallback(fn, { timeout: 3000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates what's up with the virtual cpu.
|
||||
*/
|
||||
private cpuCount() {
|
||||
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;
|
||||
|
||||
this.setState({
|
||||
lastTick: now,
|
||||
lastCounter: instructionCounter,
|
||||
cpu: Math.round(ips / deltaTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
531
src/renderer/emulator.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
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 { StartMenu } from "./start-menu";
|
||||
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";
|
||||
|
||||
declare let window: Win95Window;
|
||||
|
||||
export interface EmulatorState {
|
||||
currentUiCard: "start" | "settings";
|
||||
emulator?: any;
|
||||
scale: number;
|
||||
floppyFile?: File;
|
||||
cdromFile?: File;
|
||||
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: false,
|
||||
isCursorCaptured: false,
|
||||
isRunning: false,
|
||||
currentUiCard: "start",
|
||||
isInfoDisplayed: true,
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
let card;
|
||||
|
||||
if (currentUiCard === "settings") {
|
||||
card = (
|
||||
<CardSettings
|
||||
setFloppy={(floppyFile) => this.setState({ floppyFile })}
|
||||
setCdrom={(cdromFile) => this.setState({ cdromFile })}
|
||||
bootFromScratch={this.bootFromScratch}
|
||||
floppy={floppyFile}
|
||||
cdrom={cdromFile}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
card = <CardStart startEmulator={this.startEmulator} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{card}
|
||||
<StartMenu
|
||||
navigate={(target) => this.setState({ currentUiCard: target as "start" | "settings" })}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
if (!this.state.isInfoDisplayed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EmulatorInfo
|
||||
emulator={this.state.emulator}
|
||||
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,
|
||||
};
|
||||
|
||||
console.log(`🚜 Starting emulator with options`, options);
|
||||
|
||||
window["emulator"] = new V86(options);
|
||||
|
||||
// New v86 instance
|
||||
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() {
|
||||
const { emulator } = this.state;
|
||||
|
||||
this.setState({ isCursorCaptured: false });
|
||||
|
||||
if (emulator) {
|
||||
emulator.mouse_set_status(false);
|
||||
}
|
||||
|
||||
document.exitPointerLock();
|
||||
}
|
||||
|
||||
private lockMouse() {
|
||||
const { emulator } = this.state;
|
||||
|
||||
if (emulator) {
|
||||
this.setState({ isCursorCaptured: true });
|
||||
emulator.mouse_set_status(true);
|
||||
emulator.lock_mouse();
|
||||
} else {
|
||||
console.warn(
|
||||
`Emulator: Tried to lock mouse, but no emulator or not running`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 onclick="document.querySelector('#status').style.display='none'">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,36 +0,0 @@
|
||||
const $ = document.querySelector.bind(document)
|
||||
|
||||
export function setupInfo () {
|
||||
const diskStatus = $('#disk-status')
|
||||
const cpuStatus = $('#cpu-status')
|
||||
let lastCounter = 0
|
||||
let lastTick = 0
|
||||
|
||||
window.emulator.add_listener('ide-read-start', () => {
|
||||
diskStatus.innerHTML = 'Read'
|
||||
})
|
||||
|
||||
window.emulator.add_listener('ide-read-end', () => {
|
||||
diskStatus.innerHTML = 'Idle'
|
||||
})
|
||||
|
||||
window.emulator.add_listener('ide-write-end', () => {
|
||||
diskStatus.innerHTML = 'Idle'
|
||||
})
|
||||
|
||||
window.emulator.add_listener('screen-set-size-graphical', (...args) => {
|
||||
console.log(...args)
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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,41 +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
|
||||
|
||||
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
|
||||
document.exitPointerLock()
|
||||
} else {
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupClickListener () {
|
||||
document.addEventListener('click', () => {
|
||||
if (!window.appState.cursorCaptured) {
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { setupState } from 'es6://app-state.js'
|
||||
import { setupClickListener, setupEscListener, setupCloseListener } from 'es6://listeners.js'
|
||||
import { toggleButtons, setupButtons } from 'es6://buttons.js'
|
||||
import { setupInfo } from 'es6://info.js'
|
||||
|
||||
setupState()
|
||||
|
||||
/**
|
||||
* The main method executing the VM.
|
||||
*/
|
||||
async function main () {
|
||||
// New v86 instance
|
||||
window.emulator = new V86Starter({
|
||||
memory_size: 64 * 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: 242049024
|
||||
},
|
||||
fda: {
|
||||
buffer: window.appState.floppyFile || undefined
|
||||
},
|
||||
boot_order: 0x132
|
||||
})
|
||||
|
||||
// High DPI support
|
||||
if (navigator.userAgent.includes('Windows')) {
|
||||
const scale = window.devicePixelRatio
|
||||
|
||||
window.emulator.screen_adapter.set_scale(scale, scale)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
setupInfo()
|
||||
|
||||
window.appState.cursorCaptured = true
|
||||
window.emulator.lock_mouse()
|
||||
window.emulator.run()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function start () {
|
||||
document.body.className = ''
|
||||
|
||||
toggleButtons(false)
|
||||
setupClickListener()
|
||||
main()
|
||||
}
|
||||
|
||||
setupEscListener()
|
||||
setupCloseListener()
|
||||
setupButtons(start)
|
||||
39
src/renderer/start-menu.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface StartMenuProps {
|
||||
navigate: (to: string) => void;
|
||||
}
|
||||
|
||||
export class StartMenu extends React.Component<StartMenuProps, {}> {
|
||||
constructor(props: StartMenuProps) {
|
||||
super(props);
|
||||
|
||||
this.navigate = this.navigate.bind(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<nav className="nav nav-bottom">
|
||||
<a onClick={this.navigate} href="#" id="start" className="nav-link">
|
||||
<img src="../../static/start.png" alt="Start" />
|
||||
<span>Start</span>
|
||||
</a>
|
||||
<div className="nav-menu">
|
||||
<a
|
||||
onClick={this.navigate}
|
||||
href="#"
|
||||
id="settings"
|
||||
className="nav-link"
|
||||
>
|
||||
<img src="../../static/settings.png" />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
private navigate(event: React.SyntheticEvent<HTMLAnchorElement>) {
|
||||
this.props.navigate(event.currentTarget.id);
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/state.js
@@ -1,76 +0,0 @@
|
||||
const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
const { remote } = require('electron')
|
||||
|
||||
const DEFAULT_PATH = path.join(__dirname, 'images/default-state.bin')
|
||||
const STATE_PATH = path.join(remote.app.getPath('userData'), 'state.bin')
|
||||
|
||||
/**
|
||||
* Returns the current machine's state - either what
|
||||
* we have saved or alternatively the default state.
|
||||
*
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function getState () {
|
||||
const statePath = fs.existsSync(STATE_PATH)
|
||||
? STATE_PATH
|
||||
: DEFAULT_PATH
|
||||
|
||||
return fs.readFileSync(statePath).buffer
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a saved state by simply deleting it.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function resetState () {
|
||||
if (fs.existsSync(STATE_PATH)) {
|
||||
return fs.remove(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.log(error)
|
||||
return
|
||||
}
|
||||
|
||||
await fs.outputFile(STATE_PATH, Buffer.from(newState))
|
||||
|
||||
console.log(`Saved state to ${STATE_PATH}`)
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the VM's state.
|
||||
*/
|
||||
function restoreState () {
|
||||
try {
|
||||
window.emulator.restore_state(getState())
|
||||
} catch (error) {
|
||||
console.log(`Could not read state file. Maybe none exists?`, error)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATE_PATH,
|
||||
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;
|
||||
}
|
||||
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/95css.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>
|
||||
BIN
static/reset-state.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
static/reset.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/run.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/select-cdrom.png
Normal file
|
After Width: | Height: | Size: 519 B |
BIN
static/select-floppy.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/settings.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
static/show-disk-image.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/start.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
20
static/www/apps.htm
Normal file
@@ -0,0 +1,20 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>windows95 Help</title>
|
||||
</head>
|
||||
<body bgcolor="#C0C0C0">
|
||||
<table width="100%" cellpadding="10" cellspacing="0">
|
||||
<tr>
|
||||
<td>
|
||||
<font face="Arial" color="#000000">
|
||||
<font size="5"><b>windows95 Apps & Games</b></font>
|
||||
<hr>
|
||||
|
||||
<p>I've installed a few apps and games for you to try out. Check out the Games folder on the desktop!</p>
|
||||
<p>If you want to try other games, I recommend trying to find them on the Internet Archive. On your host computer, visit https://archive.org, then find the "Classic PC Games" category. Once downloaded, you can import them into windows95 from <a href="http://my-computer">your host's Download folder</a>.</p>
|
||||
</font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||