File size: 11,688 Bytes
20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d e734f1e c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d 20321f1 c722e6d e734f1e c722e6d 20321f1 c722e6d 20321f1 c722e6d e734f1e c722e6d e734f1e c722e6d e734f1e c722e6d e734f1e c722e6d e734f1e c722e6d 20321f1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 | (() => {
'use strict';
const $ = (id) => document.getElementById(id);
const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
const pad3 = (n) => String(n).padStart(3, '0');
const hostClock = () => new Date().toLocaleTimeString('zh-TW', { hour12: false });
$('topic').value = `dosmqtt/demo/${Math.random().toString(36).slice(2, 9)}`;
$('clock').textContent = hostClock();
window.setInterval(() => { $('clock').textContent = hostClock(); }, 1000);
const B_INTERVAL_MS = 5000;
const machines = {}; // 'A' | 'B' -> { vm, ready, ip }
let bTimer;
let running = false;
let runs = 0;
const seen = new Set(); // de-duplicate lines read from the DOS screens
const sentAt = new Map(); // B payload -> host time first seen
const matched = new Set();
const counts = { rx: 0, echo: 0, sent: 0 };
let toastTimer;
// ---------- helpers ----------
function setNet(text, on) {
$('netText').textContent = text;
$('liveDot').classList.toggle('on', !!on);
}
function toast(text) {
$('toast').textContent = text;
$('toast').classList.add('show');
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => $('toast').classList.remove('show'), 1800);
}
function logRow(src, text, cls = '') {
const log = $('log');
const empty = log.querySelector('.log-empty');
if (empty) empty.remove();
const row = document.createElement('div');
row.className = `log-row ${cls}`;
const t = document.createElement('span'); t.className = 't'; t.textContent = hostClock();
const s = document.createElement('span'); s.className = 'src'; s.textContent = src;
const m = document.createElement('span'); m.className = 'msg'; m.textContent = text;
row.append(t, s, m);
log.append(row);
while (log.children.length > 200) log.firstElementChild.remove();
log.scrollTop = log.scrollHeight;
}
async function gunzipBase64(b64) {
const bin = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const stream = new Blob([bin]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).arrayBuffer();
}
const screenLines = (vm) => vm.screen_adapter.get_text_screen().map((l) => l.replace(/\s+$/, ''));
const screenText = (vm) => screenLines(vm).join('\n');
const lastLine = (vm) => {
const lines = screenLines(vm).filter((l) => l.trim() !== '');
return lines.length ? lines[lines.length - 1] : '';
};
const atPrompt = (vm) => /^[A-Z]:\\>$/.test(lastLine(vm).trim());
// Type into the real DOS keyboard buffer (slowly enough that FreeCOM keeps up).
async function typeLine(name, text) {
await machines[name].vm.keyboard_send_text(`${text}\n`, 25);
await sleep(250);
}
// ---------- booting the two DOS machines ----------
async function boot() {
const assets = window.DOSMQTT_ASSETS;
if (!window.V86 || !assets || !window.MqttBridge) {
setNet('LOAD ERROR', false);
$('note').textContent = 'v86 或資產載入失敗,請重新整理頁面。';
return;
}
if (typeof DecompressionStream !== 'function') {
setNet('UNSUPPORTED', false);
$('note').textContent = '這個瀏覽器不支援 DecompressionStream,請改用較新的 Chrome / Edge / Safari / Firefox。';
return;
}
window.MqttBridge.install();
window.MqttBridge.onEvent = (machine, text) => logRow('BRIDGE', `${machine}: ${text}`, 'bridge');
const [wasmBytes, bios, vga, img] = await Promise.all(
['wasm', 'bios', 'vga', 'img'].map((k) => gunzipBase64(assets[k])));
const wasmModule = await WebAssembly.compile(wasmBytes);
for (const name of ['A', 'B']) {
const vm = new window.V86({
wasm_fn: async (imports) => (await WebAssembly.instantiate(wasmModule, imports)).exports,
memory_size: 16 * 1024 * 1024,
vga_memory_size: 2 * 1024 * 1024,
screen_container: $(`screen${name}`),
bios: { buffer: bios.slice(0) },
vga_bios: { buffer: vga.slice(0) },
fda: { buffer: img.slice(0) },
net_device: { type: 'ne2k', relay_url: window.MqttBridge.relayUrl(name), dns_method: 'doh' },
autostart: true,
disable_speaker: true,
});
machines[name] = { vm, ready: false, ip: '' };
vm.keyboard_set_status(false);
vm.add_listener('screen-set-size', () => window.setTimeout(() => fitScreen(name), 50));
$(`screen${name}`).addEventListener('mousedown', () => focusMachine(name));
$(`screen${name}`).addEventListener('touchstart', () => focusMachine(name), { passive: true });
}
watchBoot();
}
// In text mode v86 renders an 80x25 DOM text layer at a fixed font size; in graphics
// modes it draws a canvas. Scale whichever one is visible to the width of its card.
function fitScreen(name) {
const box = $(`screen${name}`);
const text = box.firstElementChild;
const canvas = box.querySelector('canvas');
if (!box.clientWidth) return;
const useCanvas = canvas && getComputedStyle(canvas).display !== 'none' && canvas.width > 0;
const layer = useCanvas ? canvas : text;
const naturalW = useCanvas ? canvas.width : text.offsetWidth;
const naturalH = useCanvas ? canvas.height : text.offsetHeight;
if (!naturalW || !naturalH) return;
const k = box.clientWidth / naturalW;
layer.style.transformOrigin = '0 0';
layer.style.transform = `scale(${k})`;
box.style.height = `${Math.round(naturalH * k)}px`;
}
window.addEventListener('resize', () => { fitScreen('A'); fitScreen('B'); });
function focusMachine(name) {
for (const key of Object.keys(machines)) {
machines[key].vm.keyboard_set_status(key === name);
$(`screen${key}`).classList.toggle('focus', key === name);
}
}
function watchBoot() {
const timer = window.setInterval(() => {
for (const name of ['A', 'B']) {
const m = machines[name];
if (m.ready) continue;
const text = screenText(m.vm);
const ip = /IPADDR\s+([\d.]+)/.exec(text);
if (ip) m.ip = ip[1];
if (/TCP\/IP is up/.test(text)) {
m.ready = true;
$(`${name.toLowerCase()}State`).textContent = 'NET UP';
$(`${name.toLowerCase()}State`).classList.add('on');
$(`${name.toLowerCase()}Ip`).textContent = `IP ${m.ip || '192.168.86.x'}`;
}
}
if (machines.A.ready && machines.B.ready) {
window.clearInterval(timer);
setNet('DOS NET UP', true);
$('bootTag').textContent = '2 × FREEDOS READY';
$('startButton').disabled = false;
$('startButton').innerHTML = '<span class="button-icon">▶</span>開始(啟動 A,B 每 5 秒執行)';
$('note').textContent = '兩台 DOS 都已取得 DHCP 位址。按「開始」會在 DOS 內設定環境變數、啟動 A.EXE,之後每 5 秒執行 B.EXE。';
}
}, 500);
}
// ---------- running the demo ----------
async function runB() {
const b = machines.B;
if (!running) return;
if (!atPrompt(b.vm)) { logRow('B', '上一次 B.EXE 尚未結束,略過這一次', 'err'); return; }
runs += 1;
$('runCount').textContent = pad3(runs);
await typeLine('B', 'B');
}
async function start() {
const url = $('broker').value.trim();
const topic = $('topic').value.trim();
let host;
try { host = new URL(url).hostname; } catch { host = ''; }
if (!/^wss:\/\//i.test(url) || !host || !topic || /[+#\s%&|<>^"]/.test(topic)) {
toast('請輸入有效的 wss:// broker 與不含空白、+、# 的 topic');
return;
}
$('startButton').disabled = true;
$('startButton').innerHTML = '<span class="button-icon">◌</span>啟動中…';
window.MqttBridge.brokerUrl = url;
running = true;
logRow('SYS', `broker ${url} topic ${topic}`);
for (const name of ['A', 'B']) {
await typeLine(name, `SET MQTT_HOST=${host}`);
await typeLine(name, 'SET MQTT_PORT=1883');
await typeLine(name, `SET MQTT_TOPIC=${topic}`);
await typeLine(name, 'CLS');
}
await typeLine('A', 'A');
$('aState').textContent = 'A.EXE';
// Wait until A reports that it subscribed, then start B on its 5 second schedule.
const deadline = Date.now() + 45000;
while (Date.now() < deadline && !/subscribed to/.test(screenText(machines.A.vm))) await sleep(400);
if (!/subscribed to/.test(screenText(machines.A.vm))) {
logRow('A', 'A.EXE 沒有在 45 秒內完成訂閱(broker 無回應?)', 'err');
toast('A 無法訂閱,請檢查 broker');
}
$('bState').textContent = 'B EVERY 5s';
$('bState').classList.add('on');
await runB();
bTimer = window.setInterval(runB, B_INTERVAL_MS);
$('startButton').disabled = false;
$('startButton').innerHTML = '<span class="button-icon">■</span>停止並重新開機';
$('note').textContent = '運作中:B.EXE 每 5 秒由網頁在機器 B 的 DOS 提示字元執行一次。';
}
function stop() {
running = false;
window.clearInterval(bTimer);
bTimer = undefined;
for (const name of ['A', 'B']) {
const m = machines[name];
m.ready = false;
$(`${name.toLowerCase()}State`).textContent = 'REBOOT';
$(`${name.toLowerCase()}State`).classList.remove('on');
m.vm.restart();
}
setNet('REBOOTING', false);
$('startButton').disabled = true;
$('startButton').innerHTML = '<span class="button-icon">◌</span>重新開機中…';
watchBoot();
}
// ---------- reading results from the DOS screens ----------
function scan() {
if (!machines.A || !machines.A.vm) return;
for (const line of screenLines(machines.A.vm)) {
const rx = /^\[(\d\d:\d\d:\d\d)\] A rx: (.+)$/.exec(line.trim());
const echo = /^\[(\d\d:\d\d:\d\d)\] A echo: (.+)$/.exec(line.trim());
if (rx && !seen.has(line)) {
seen.add(line); counts.rx += 1; $('rxCount').textContent = pad3(counts.rx);
logRow('A', `收到 ${rx[2]}`);
}
if (echo && !seen.has(line)) {
seen.add(line); counts.echo += 1; $('echoCount').textContent = pad3(counts.echo);
const t0 = sentAt.get(echo[2]);
const ms = t0 ? ` (距 B 送出 ${((Date.now() - t0) / 1000).toFixed(1)} 秒)` : '';
if (t0) matched.add(echo[2]);
logRow('A', `echo ${echo[2]}${ms}`, 'ok');
$('matchTag').textContent = `${matched.size} / ${counts.sent} MATCHED`;
}
}
if (machines.B && machines.B.vm) {
for (const line of screenLines(machines.B.vm)) {
const sent = /^\[(\d\d:\d\d:\d\d)\] B sent: (.+)$/.exec(line.trim());
if (sent && !seen.has(`B${line}`)) {
seen.add(`B${line}`); counts.sent += 1; $('sentCount').textContent = pad3(counts.sent);
sentAt.set(sent[2], Date.now());
logRow('B', `送出 ${sent[2]}`, 'b');
$('matchTag').textContent = `${matched.size} / ${counts.sent} MATCHED`;
}
}
}
}
window.setInterval(() => { scan(); fitScreen('A'); fitScreen('B'); }, 400);
$('startButton').addEventListener('click', () => { if (running) stop(); else start(); });
$('copyButton').addEventListener('click', async () => {
const topic = $('topic').value.trim();
try { await navigator.clipboard.writeText(topic); toast('Topic 已複製到剪貼簿'); }
catch { $('topic').select(); document.execCommand('copy'); toast(`Topic: ${topic}`); }
});
boot().catch((error) => {
setNet('BOOT ERROR', false);
$('note').textContent = `啟動失敗:${error && error.message ? error.message : error}`;
console.error(error);
});
})();
|