dosmqtt / app.js
HelloSun's picture
Real DOS networking: FreeDOS + NE2000 + mTCP MQTT clients on v86, TCP-to-WSS bridge
c722e6d verified
Raw History Blame Contribute Delete
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);
});
})();