Skip to content

Commit 6c3641c

Browse files
chapterjasonclaude
andcommitted
Add Linux (v86) example, move HTML into src/
Adds a second example page that boots linux4.iso (busybox) in a browser-side x86 emulator (v86) and pipes serial0 through xterm.js, driven by the terminal output adapter. BIOS + ISO are fetched from copy.sh at install/build time into public/v86/ (gitignored), and cached in CI. Vite root moved to src/ so the project root only holds config files. Build emits dist/index.html + dist/linux.html flat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e040102 commit 6c3641c

9 files changed

Lines changed: 206 additions & 4 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ jobs:
3030

3131
- run: npm ci
3232

33+
- uses: actions/cache@v4
34+
with:
35+
path: public/v86
36+
key: v86-assets-${{ hashFiles('scripts/fetch-v86-assets.mjs') }}
37+
3338
- run: npm run build
3439
env:
3540
BASE_PATH: ${{ steps.pages.outputs.base_path }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
node_modules
22
dist
33
.vite
4+
public/v86/
45
*.log
56
.DS_Store

package-lock.json

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44
"version": "0.0.0",
55
"type": "module",
66
"scripts": {
7-
"dev": "vite",
8-
"build": "tsc && vite build",
7+
"dev": "npm run fetch-v86 && vite",
8+
"build": "npm run fetch-v86 && tsc && vite build",
99
"preview": "vite preview",
10-
"typecheck": "tsc --noEmit"
10+
"typecheck": "tsc --noEmit",
11+
"fetch-v86": "node scripts/fetch-v86-assets.mjs"
1112
},
1213
"devDependencies": {
14+
"@xterm/xterm": "^6.0.0",
1315
"sass": "^1.99.0",
1416
"typescript": "^6.0.3",
17+
"v86": "^0.5.330",
1518
"vite": "^8.0.8"
1619
}
1720
}

scripts/fetch-v86-assets.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createWriteStream, existsSync, mkdirSync, statSync } from "node:fs";
2+
import { dirname, join } from "node:path";
3+
import { pipeline } from "node:stream/promises";
4+
import { fileURLToPath } from "node:url";
5+
6+
const HOST = "https://copy.sh/v86/";
7+
8+
const files = [
9+
{ url: HOST + "bios/seabios.bin", out: "public/v86/bios/seabios.bin" },
10+
{ url: HOST + "bios/vgabios.bin", out: "public/v86/bios/vgabios.bin" },
11+
{ url: HOST + "images/linux4.iso", out: "public/v86/images/linux.iso" },
12+
];
13+
14+
const root = dirname(dirname(fileURLToPath(import.meta.url)));
15+
16+
const download = async (url, outRel) => {
17+
const out = join(root, outRel);
18+
if (existsSync(out) && statSync(out).size > 0) {
19+
console.log(`✓ cached ${outRel}`);
20+
return;
21+
}
22+
mkdirSync(dirname(out), { recursive: true });
23+
console.log(`↓ ${url}`);
24+
const res = await fetch(url);
25+
if (!res.ok || !res.body) throw new Error(`${url}${res.status}`);
26+
await pipeline(res.body, createWriteStream(out));
27+
console.log(` saved ${outRel} (${statSync(out).size} bytes)`);
28+
};
29+
30+
for (const f of files) {
31+
try {
32+
await download(f.url, f.out);
33+
} catch (e) {
34+
console.error(`failed ${f.url}:`, e.message);
35+
process.exitCode = 1;
36+
}
37+
}

index.html renamed to src/index.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
<style>
99
html, body { height: 100%; margin: 0; font-family: system-ui, sans-serif; }
1010
body { display: flex; flex-direction: column; }
11+
.nav { padding: 10px 16px; font-size: 14px; background: #f3f4f6; border-bottom: 1px solid #e5e7eb; }
12+
.nav a { color: #2563eb; text-decoration: none; margin-right: 12px; }
13+
.nav a:hover { text-decoration: underline; }
1114
.demo { flex: 1; padding: 16px; display: flex; flex-direction: column; gap: 12px; }
1215
.demo textarea {
1316
flex: 1; min-height: 120px; padding: 12px; font-size: 16px;
@@ -17,6 +20,10 @@
1720
</style>
1821
</head>
1922
<body>
23+
<div class="nav">
24+
<span>Native input</span>
25+
<a href="./linux.html">Linux (v86) →</a>
26+
</div>
2027
<div class="demo">
2128
<label for="demo-input">Tap into the field, then use the keyboard below:</label>
2229
<textarea id="demo-input" autofocus placeholder="Type here...">Lorem ipsum dolor sit amet,
@@ -26,6 +33,6 @@
2633
Ut enim ad minim veniam.</textarea>
2734
</div>
2835
<virtual-keyboard></virtual-keyboard>
29-
<script type="module" src="/src/main.ts"></script>
36+
<script type="module" src="./main.ts"></script>
3037
</body>
3138
</html>

src/linux.html

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6+
<meta name="mobile-web-app-capable" content="yes" />
7+
<title>Virtual Keyboard — Linux (v86)</title>
8+
<style>
9+
html, body { height: 100%; margin: 0; background: #0b1220; color: #cbd5e1; font-family: system-ui, sans-serif; }
10+
body { display: flex; flex-direction: column; }
11+
.nav { padding: 10px 16px; font-size: 14px; background: #111827; border-bottom: 1px solid #1f2937; display: flex; gap: 12px; align-items: center; }
12+
.nav a { color: #93c5fd; text-decoration: none; }
13+
.nav a:hover { text-decoration: underline; }
14+
.nav .status { margin-left: auto; color: #94a3b8; font-size: 12px; }
15+
.demo { flex: 1; padding: 12px; display: flex; flex-direction: column; min-height: 0; }
16+
#term { flex: 1; min-height: 0; }
17+
virtual-keyboard { flex: none; }
18+
</style>
19+
</head>
20+
<body>
21+
<div class="nav">
22+
<a href="./index.html">← Native</a>
23+
<span>Linux (busybox on v86 · x86 WASM)</span>
24+
<span class="status" id="status">booting…</span>
25+
</div>
26+
<div class="demo">
27+
<div id="term"></div>
28+
</div>
29+
<virtual-keyboard></virtual-keyboard>
30+
<script type="module" src="./linux.ts"></script>
31+
</body>
32+
</html>

src/linux.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Terminal } from "@xterm/xterm";
2+
import "@xterm/xterm/css/xterm.css";
3+
// @ts-expect-error — v86 ships no types
4+
import V86 from "v86";
5+
import wasmUrl from "v86/build/v86.wasm?url";
6+
import "./keyboard/virtual-keyboard";
7+
import { terminalAdapter } from "./keyboard/output";
8+
9+
type V86Instance = {
10+
add_listener(event: string, cb: (...args: unknown[]) => void): void;
11+
serial0_send(data: string): void;
12+
stop(): Promise<void>;
13+
restart(): void;
14+
};
15+
16+
type V86Ctor = new (options: Record<string, unknown>) => V86Instance;
17+
18+
const V86Constructor = V86 as V86Ctor;
19+
20+
const HOST = import.meta.env.BASE_URL + "v86/";
21+
22+
const term = new Terminal({
23+
cursorBlink: true,
24+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
25+
fontSize: 14,
26+
theme: {
27+
background: "#0b1220",
28+
foreground: "#cbd5e1",
29+
cursor: "#10b981",
30+
},
31+
});
32+
33+
const host = document.getElementById("term")!;
34+
term.open(host);
35+
36+
const status = document.getElementById("status")!;
37+
const setStatus = (text: string): void => {
38+
status.textContent = text;
39+
};
40+
41+
term.writeln("\x1b[1;36mLinux on v86\x1b[0m — x86 emulator in WebAssembly");
42+
term.writeln("Booting from linux4.iso (busybox)…\r\n");
43+
44+
const emulator = new V86Constructor({
45+
wasm_path: wasmUrl,
46+
memory_size: 512 * 1024 * 1024,
47+
vga_memory_size: 8 * 1024 * 1024,
48+
bios: { url: HOST + "bios/seabios.bin" },
49+
vga_bios: { url: HOST + "bios/vgabios.bin" },
50+
cdrom: { url: HOST + "images/linux.iso" },
51+
autostart: true,
52+
acpi: true,
53+
disable_keyboard: true,
54+
disable_mouse: true,
55+
disable_speaker: true,
56+
});
57+
58+
emulator.add_listener("emulator-ready", () => setStatus("ready"));
59+
emulator.add_listener("emulator-started", () => setStatus("running"));
60+
emulator.add_listener("download-progress", ((e: unknown) => {
61+
const p = e as { loaded: number; total?: number; file_name?: string };
62+
if (p.total) {
63+
const pct = Math.round((p.loaded / p.total) * 100);
64+
setStatus(`downloading ${p.file_name ?? "image"} ${pct}%`);
65+
}
66+
}) as (...args: unknown[]) => void);
67+
68+
const send = (data: string): void => {
69+
emulator.serial0_send(data);
70+
};
71+
72+
emulator.add_listener("serial0-output-byte", ((byte: unknown) => {
73+
term.write(Uint8Array.from([byte as number]));
74+
}) as (...args: unknown[]) => void);
75+
76+
term.onData(send);
77+
78+
const kb = document.querySelector("virtual-keyboard")!;
79+
kb.setAdapter(terminalAdapter(send));
80+
81+
term.focus();

vite.config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,32 @@ const reinstateBase = (base: string): Plugin => ({
1818
},
1919
});
2020

21+
const common = {
22+
root: "src",
23+
publicDir: "../public",
24+
} as const;
25+
2126
export default defineConfig(({ command }) => {
2227
if (command === "build") {
2328
return {
29+
...common,
2430
base: process.env["BASE_PATH"] ?? "/",
31+
build: {
32+
outDir: "../dist",
33+
emptyOutDir: true,
34+
rollupOptions: {
35+
input: {
36+
main: "src/index.html",
37+
linux: "src/linux.html",
38+
},
39+
},
40+
},
2541
};
2642
}
2743

2844
const base = parsed ? `${parsed.pathname}/` : "/";
2945
return {
46+
...common,
3047
base,
3148
plugins: [reinstateBase(base)],
3249
server: {

0 commit comments

Comments
 (0)