Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Cloudflare Pages 自動部署設置指南

## 概述

本專案已配置 GitHub Actions,當推送到 `main` 或 `master` 分支時,會自動構建並部署到 Cloudflare Pages。

## 設置步驟

### 1. 獲取 Cloudflare API Token

1. 登入 [Cloudflare Dashboard](https://dash.cloudflare.com/)
2. 點擊右上角的用戶頭像 → **My Profile**
3. 點擊 **API Tokens** 標籤
4. 點擊 **Create Token**
5. 使用 **Custom token** 模板
6. 設置權限:
- **Account** - `Cloudflare Pages:Edit`
- **Zone** - `Zone:Read`(如果需要自定義域名)
7. 點擊 **Continue to summary** → **Create Token**
8. **複製並保存此 token**(只會顯示一次)

### 2. 獲取 Cloudflare Account ID

1. 在 [Cloudflare Dashboard](https://dash.cloudflare.com/) 首頁
2. 右側欄位中找到 **Account ID**
3. 點擊 **Click to copy** 複製

### 3. 在 GitHub 設置 Secrets

1. 前往你的 GitHub repo
2. 點擊 **Settings** → **Secrets and variables** → **Actions**
3. 點擊 **New repository secret**
4. 添加以下兩個 secrets:

- **Name**: `CLOUDFLARE_API_TOKEN`
**Value**: 步驟 1 獲取的 API Token

- **Name**: `CLOUDFLARE_ACCOUNT_ID`
**Value**: 步驟 2 獲取的 Account ID

### 4. 推送代碼觸發部署

完成上述設置後,每次推送到 `main` 或 `master` 分支時,GitHub Actions 會自動:

1. 安裝 Node.js 依賴
2. 安裝 Emscripten
3. 編譯 WASM
4. 構建 Vite 專案
5. 部署到 Cloudflare Pages

## 手動觸發部署

你也可以在 GitHub Actions 頁面手動觸發部署:

1. 前往 **Actions** 標籤
2. 選擇 **Deploy to Cloudflare Pages** workflow
3. 點擊 **Run workflow**

## 檢查部署狀態

- **GitHub**: 前往 **Actions** 標籤查看 workflow 運行狀態
- **Cloudflare**: 前往 [Cloudflare Dashboard](https://dash.cloudflare.com/) → **Pages** → **web-a2e** 查看部署歷史

## 故障排除

### WASM 編譯失敗
- 確認 Emscripten 版本兼容性
- 檢查 `CMakeLists.txt` 配置

### 部署失敗
- 確認 API Token 權限正確
- 確認 Account ID 正確
- 檢查 GitHub Actions 日誌獲取詳細錯誤信息

### 構建時間過長
- 考慮使用 GitHub Actions cache 來緩存 Emscripten 和 node_modules
- 可以在 workflow 中添加 cache 步驟

## 相關連結

- [Cloudflare Pages 文檔](https://developers.cloudflare.com/pages/)
- [Wrangler CLI 文檔](https://developers.cloudflare.com/workers/wrangler/)
- [GitHub Actions 文檔](https://docs.github.com/en/actions)
72 changes: 72 additions & 0 deletions .github/workflows/cloudflare-pages-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Deploy to Cloudflare Pages (optional)

# Optional Cloudflare Pages deployment for the CORS proxy / URL media feature.
#
# This workflow is opt-in per repository and is skipped unless the repository
# sets the CLOUDFLARE_PAGES_ENABLED variable to "true". A maintainer who keeps
# the existing VPS / static-server deployment never sets it, and nothing here
# runs — the standard deployment is untouched.
#
# To enable Cloudflare Pages:
# Repository Settings → Secrets and variables → Actions
# Variable: CLOUDFLARE_PAGES_ENABLED = true
# Variable: CLOUDFLARE_PAGES_PROJECT (optional; defaults to web-a2e)
# Secret: CLOUDFLARE_API_TOKEN (Account · Cloudflare Pages:Edit)
# Secret: CLOUDFLARE_ACCOUNT_ID
#
# The Cloudflare Pages project must already exist in the account.

on:
push:
branches:
- main
- master
workflow_dispatch:

jobs:
deploy:
# Opt-in: skipped unless the repo owner enables it.
if: vars.CLOUDFLARE_PAGES_ENABLED == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Setup Emscripten
uses: mymindstorm/setup-emsdk@v14
with:
version: 'latest'

- name: Install dependencies
run: npm ci

- name: Build project
run: npm run build

- name: Check Secrets
run: |
if [ -z "${{ secrets.CLOUDFLARE_API_TOKEN }}" ]; then
echo "::error::GitHub Secret 'CLOUDFLARE_API_TOKEN' is missing. See the workflow comments."
exit 1
fi

- name: Deploy to Cloudflare Pages
run: |
PROJECT="${CLOUDFLARE_PAGES_PROJECT:-web-a2e}"
npx -y wrangler pages project create "$PROJECT" --production-branch "${{ github.ref_name }}" || true
npx -y wrangler pages deploy dist --project-name="$PROJECT" --branch "${{ github.ref_name }}"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_PAGES_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }}
WRANGLER_LOG: debug
55 changes: 55 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
name: Deploy to Cloudflare Pages

on:
push:
branches:
- main
- master
workflow_dispatch: # 允許手動觸發

jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Setup Emscripten
uses: mymindstorm/setup-emsdk@v14
with:
version: 'latest'

- name: Install dependencies
run: npm ci

- name: Build project
run: npm run build

- name: Check Secrets
run: |
if [ -z "${{ secrets.CLOUDFLARE_API_TOKEN }}" ]; then
echo "::error::GitHub Secret 'CLOUDFLARE_API_TOKEN' is missing! Please check Repository Settings > Secrets > Actions."
exit 1
fi

- name: Deploy to Cloudflare Pages
run: |
npx -y wrangler pages project create web-a2e --production-branch ${{ github.ref_name }} || true
# Zip the large h32mb.2mg and swap it (Cloudflare will see it as a 19MB file named .2mg)
zip -j dist/disks/h32mb.2mg.zip dist/disks/h32mb.2mg
mv -f dist/disks/h32mb.2mg.zip dist/disks/h32mb.2mg
npx -y wrangler pages deploy dist --project-name=web-a2e --branch ${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
WRANGLER_LOG: debug
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ video-outline.md
# Local tool state
.codex/
.lean-ctx/
.wrangler/

# Deploy target configuration (see .env.deploy.example)
.env.deploy
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,10 +689,14 @@ fringing from the real signal, so a shader knob for it would only double-count.

### URL Media Parameters

`?disk=`, `?disk1=`, `?disk2=`, `?hd=`, `?hd2=`, `?name=` and `?autostart=` let a link open with images already inserted. Two modules:
`?disk=`, `?disk1=`, `?disk2=`, `?hd=`, `?hd2=`, `?name=` and `?autostart=` let a link open with images already inserted. `?disk=` targets the Disk II floppy drives (formats `.dsk`/`.do`/`.po`/`.woz`); `?hd=` targets the SmartPort block devices (formats `.2mg`/`.hdv`). Three modules:

- `src/js/utils/url-params.js` — pure parsing and URL validation (http/https only; relative paths resolve against the page). Unit-tested in `tests/js/utils/url-params.test.js`.
- `src/js/disk-manager/url-media-loader.js` — fetches (`credentials: "omit"`, size-capped) and inserts.
- `functions/proxy/[[path]].js` — a Cloudflare Pages Function serving the same-origin CORS proxy (`/proxy/url/<encodeURIComponent(target)>`), returning the fetched file with permissive CORS headers. Deployed by the optional `.github/workflows/cloudflare-pages-deploy.yml`, which is opt-in per repository (`vars.CLOUDFLARE_PAGES_ENABLED == 'true'`) so a maintainer who keeps their existing host is unaffected. On a non-Cloudflare host the same `/proxy/url` endpoint must be provided another way (a reverse proxy) for the fallback to work.
- `plugins/dev-proxy-plugin.js` — Vite `configureServer` middleware serving that same route during `npm run dev`, so development behaves like production. Must call `next()` for non-proxy paths — skipping it stalls every other request on the whole server.

`fetchImage` in `url-media-loader.js` first tries a direct fetch; when the browser raises the opaque TypeError that signals a CORS refusal, it automatically retries through `/proxy/url/…`. Hosts that already send `Access-Control-Allow-Origin` are never routed through the proxy. Note the dev-server middleware must call `next()` for non-proxy paths — skipping it stalls every other request on the whole server.

`main.js` parses the URL *before* `DiskManager.init()` / `HardDriveManager.init()` and populates `urlOwnedDrives` / `urlOwnedDevices`, which those managers use to skip restoring persisted images into units a link is about to claim — otherwise the two loads race.

Expand Down Expand Up @@ -890,6 +894,11 @@ public/ # Static assets, built WASM files, shaders
├── shaders/ # CRT vertex/fragment shaders
├── assets/ # Images and sounds
└── index.html # Main HTML entry point
functions/
└── proxy/ # Cloudflare Pages Function — CORS proxy for URL-loaded media
plugins/
├── dev-proxy-plugin.js # Vite dev-server middleware serving /proxy/url (mirrors the Pages Function)
├── serial-proxy-plugin.js # WebSocket-to-TCP proxy
tests/
├── unit/ # Catch2 unit tests (CPU, cards, disk, audio, etc.)
├── integration/ # Catch2 integration tests (full emulator)
Expand Down
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Two machines are modelled: the **Apple //e Enhanced** and the **Apple II Plus**.
- **Expansion cards** — Mockingboard sound card, Thunderclock Plus, Apple Mouse Interface Card, SmartPort hard drive, Super Serial Card, Parallel Card (Centronics), Microsoft Z-80 SoftCard, No-Slot Clock (DS1215)
- **Virtual dot-matrix printer** — ImageWriter II (colour), ImageWriter I, Epson FX-80, and Apple DMP with period-correct fonts, sounds, and PNG/PDF export, plus a standalone font editor at `/printers/rom-editor.html`
- **File explorer** — Browse DOS 3.3 and ProDOS disk contents with BASIC detokenizer and disassembler
- **Shareable links** — Pass a disk image URL in the address (`?disk=`) to open the emulator with it already loaded
- **Shareable links** — Pass a disk image URL in the address (`?disk=` floppy or `?hd=` hard drive) to open the emulator with it already loaded; a built-in CORS proxy handles hosts that send no `Access-Control-Allow-Origin`
- **Save states** — Autosave slot plus 5 manual save slots, stored in IndexedDB
- **Built-in debugger** — CPU debugger, memory browser, heat map, soft switch monitor, BASIC conditional breakpoints, and more
- **Light/Dark/System themes** — Switchable colour scheme with Apple rainbow logo accent palette
Expand Down Expand Up @@ -175,21 +175,24 @@ A disk image URL can be passed in the address so a link opens with the disk alre
https://your-emulator/?disk=https://example.com/demo.dsk
```

Formats supported per device:

| Parameter | Device | Formats |
| --------- | ------ | ------- |
| `?disk=` | Disk II (floppy) | `.dsk` `.do` `.po` `.woz` |
| `?hd=` | SmartPort (hard drive) | `.2mg` `.hdv` |

Relative paths work too, for images hosted alongside the emulator: `?disk=/disks/demo.dsk`

A path on your own machine (`/Users/you/Downloads/demo.dsk`) will not work — a web page cannot read local files. Use **Insert** or drag the file onto a drive for those.

Notes:
- Disks loaded this way are **not** written to browser storage or the Recent list, and autosave pauses for the session. A link someone shares can't replace the disks in your own drives — reopen the plain address and your session is intact.
- **The host must send `Access-Control-Allow-Origin`.** This is the main practical limit, and it is decided by the host, not the emulator — a browser cannot read a file the server declines to share, even though `curl` downloads it fine.

| Works | Doesn't |
| ----- | ------- |
| GitHub raw / Pages, Google Drive, Dropbox | Asimov (`asimov.applefritter.com`) |
| Anything hosted alongside the emulator (`?disk=/disks/x.dsk`) | Most classic archive mirrors and plain Apache/nginx sites |

To share something from an archive that refuses, re-host the image somewhere CORS-friendly and link that.
- `?name=` is required for `.nib` and `.2mg` images behind extensionless URLs, since those formats can't be identified from their contents.
- **CORS is handled automatically.** The browser first tries to fetch the URL directly; if the host sends no `Access-Control-Allow-Origin` (the common case for archive mirrors and plain web servers), the request is retried through the emulator's own same-origin proxy (`/proxy/url/…`), which fetches the file server-side and returns it with permissive CORS headers. The proxy is served two ways:
- **Cloudflare Pages** — a Pages Function at `functions/proxy/`, deployed by the optional `cloudflare-pages-deploy.yml` workflow (opt-in via the `CLOUDFLARE_PAGES_ENABLED` repo variable).
- **Any other static host** — the server needs its own `/proxy/url/<encoded>` endpoint. A simple reverse proxy (e.g. an nginx `location /proxy/url` block, or the Cloudflare Pages Function copied to your host) is enough; on the VPS `npm run deploy` setup, provide that endpoint and the fallback works there too.
The local Vite dev server serves the same route via a plugin, so development behaves like production. Either way the visitor sees no distinction — the load just succeeds.
- `?name=` is required for `.nib` and `.2mg` images behind extensionless URLs (e.g. `download?id=…`), since those formats can't be identified from their contents. Floppy formats are identified either from the extension or, failing that, by content sniffing (WOZ magic / 143360-byte DSK), so they seldom need `?name=`.
- `?autostart` boots the machine on load, with nothing to click. The one thing a browser will not allow before a gesture is **sound**, so an autostarted machine runs silent until the visitor clicks or types anything, at which point the speaker joins in. Without `?autostart`, the visitor clicks Power as usual.

### File Explorer
Expand Down Expand Up @@ -522,6 +525,9 @@ web-a2e/
│ ├── shaders/ # CRT vertex/fragment shaders
│ ├── assets/ # Images and sounds
│ └── index.html # Main HTML entry point
├── functions/ # Cloudflare Pages Functions
│ └── proxy/ # CORS proxy for URL-loaded media
├── plugins/ # Vite plugins (dev CORS proxy, serial)
├── roms/ # ROM files (not included)
├── tests/
│ ├── klaus/ # Klaus Dormann CPU compliance tests
Expand Down Expand Up @@ -573,7 +579,6 @@ Requires WebAssembly, WebGL 2.0, Web Audio API (AudioWorklet), IndexedDB, and Se

### Platform
- **Disk image library** — Browse and load from a curated online software archive
- **URL disk loading** — Load disk images directly from a URL parameter
- **Mobile touch controls** — On-screen keyboard and virtual joystick optimized for touch devices

## License
Expand Down
77 changes: 77 additions & 0 deletions functions/proxy/[[path]].js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Cloudflare Pages Function - CORS proxy for URL-loaded media
*
* Serves /proxy/url/<encodeURIComponent(targetUrl)>. Fetches the target and
* returns it with permissive CORS headers, which lets the emulator read disk
* images hosted on servers that send no Access-Control-Allow-Origin of their
* own. The frontend only uses this as a fallback when a direct fetch is
* refused, so hosts that already allow cross-origin reads are never routed
* through here.
*/

function isValidUrl(urlString) {
try {
const url = new URL(urlString);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}

export async function onRequest(context) {
const { request, params } = context;
const pathSegments = params.path || [];

// CORS preflight.
if (request.method === "OPTIONS") {
return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}

if (request.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}

const [category, ...rest] = pathSegments;
if (category !== "url" || rest.length < 1) {
return new Response("Invalid proxy route", { status: 404 });
}

const fullUrl = decodeURIComponent(rest.join("/"));
if (!isValidUrl(fullUrl)) {
return new Response("Invalid URL format", { status: 400 });
}

try {
const upstream = await fetch(fullUrl, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; Apple2-Emulator/1.0)" },
redirect: "follow",
});

if (!upstream.ok) {
return new Response("Upstream error", { status: upstream.status });
}

const buffer = await upstream.arrayBuffer();

const headers = new Headers({
"Content-Type":
upstream.headers.get("content-type") || "application/octet-stream",
"Content-Length": buffer.byteLength.toString(),
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=86400",
});

return new Response(buffer, { headers });
} catch {
return new Response("Proxy error", { status: 502 });
}
}
Loading