(() => { '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 = '▶開始(啟動 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 = '◌啟動中…'; 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 = '■停止並重新開機'; $('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 = '◌重新開機中…'; 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); }); })();