Download app.js from HelloSun/dosmqtt: direct link, hf CLI and curl.
- Browser
- Download file 11.7 kB
-
https://huggingface.co/spaces/HelloSun/dosmqtt/resolve/main/app.js
- Command line
-
hf download hf://spaces/HelloSun/dosmqtt/app.js
-
curl -L -o app.js https://huggingface.co/spaces/HelloSun/dosmqtt/resolve/main/app.js
11.7 kB
| (() => { | |
| '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); | |
| }); | |
| })(); | |