1092 lines
55 KiB
JavaScript
1092 lines
55 KiB
JavaScript
// wadamesh in the browser: the page side of web/platform/web_main.cpp.
|
|
//
|
|
// - checks the page is cross-origin isolated (the build uses wasm threads, which need
|
|
// SharedArrayBuffer, which needs COOP: same-origin + COEP: require-corp)
|
|
// - mounts IndexedDB (IDBFS) at /data and loads it BEFORE the UI boots
|
|
// - persists /data after writes settle, and on tab hide / page close
|
|
// - picks the canvas size (800x480 on a computer, the screen's own size on a phone or
|
|
// tablet) and scales it to the window (whole device pixels = crisp nearest when that
|
|
// costs little, smooth otherwise); a turn of the phone restarts the UI in the other layout
|
|
// - on a phone, the phone's own keyboard types into the UI (a hidden textarea)
|
|
// - pointer = finger, keyboard = the UI's external keyboard (CardKB codes)
|
|
// - Connect: the demo, or the visitor's own MeshCore node over USB (Web Serial),
|
|
// Bluetooth (Web Bluetooth) or Wi-Fi (WebSocket, the node's port 8765). The choice
|
|
// is kept in localStorage; each backend has its own data dir under /data
|
|
// (/data/demo, /data/node-<key>), and switching reboots the UI into it.
|
|
(function () {
|
|
'use strict';
|
|
// The canvas size = the board's resolution, picked once per boot from the window
|
|
// (web_main.cpp reads Module.wadaSize; WEB_BUILD.md "Phones and tablets").
|
|
// - A computer (fine pointer) in a landscape window: 800x480, the framed device, scaled
|
|
// to the window (the acceptance tours and the docs shots are at this size).
|
|
// - A phone or tablet (coarse pointer), or a computer window clearly taller than wide:
|
|
// the canvas IS the screen. It is sized from the space below the bar (safe areas
|
|
// out) so it fills that space edge to edge, with about one CSS pixel per canvas pixel
|
|
// (text as big as the page's own), preferring a width that is a whole number of
|
|
// device pixels per canvas pixel (crisp): iPhone 14 390x616 at 3:1, Pixel 7 360x692
|
|
// at 3:1, iPad 540x686 at 3:1. Portrait or landscape follows the window.
|
|
// Turning the phone restarts the UI in the other layout (see "orientation"); data stays.
|
|
var touchDevice = window.matchMedia && window.matchMedia('(pointer: coarse)').matches;
|
|
function wantPortrait() { // upright; a desktop window must be clearly taller than wide
|
|
return window.innerHeight > window.innerWidth * (touchDevice ? 1.0 : 1.15);
|
|
}
|
|
var portrait = wantPortrait();
|
|
var fill = touchDevice || portrait; // the canvas fills the stage (no device frame)
|
|
var bootWidth = window.innerWidth;
|
|
var W = 800, H = 480;
|
|
// The best logical length for `css` CSS pixels (dev = the same in device pixels):
|
|
// near `target`, within [lo, hi], a whole number of device pixels per canvas pixel
|
|
// when one is within 16 % of the target (a bit bigger is fine; smaller is not).
|
|
function pickLen(css, dev, target, lo, hi) {
|
|
var best = 0, bestErr = 1e9;
|
|
for (var k = 1; k <= 6; k++) {
|
|
var w = Math.round(dev / k);
|
|
if (w < lo || w > hi || Math.abs(dev / k - w) > 0.75) continue;
|
|
var err = (target - w) / target; // > 0: the UI is drawn bigger than 1:1
|
|
if (err < -0.02 || err > 0.16) continue;
|
|
if (Math.abs(err) < bestErr) { best = w; bestErr = Math.abs(err); }
|
|
}
|
|
return best || Math.max(lo, Math.min(hi, Math.round(target)));
|
|
}
|
|
// Measured now for the first frame, and again just before the UI boots: a home-screen
|
|
// app on iOS starts with a viewport that is not settled yet (taller than what it ends up
|
|
// as), which cut the UI's bottom bar off until a turn of the phone restarted it.
|
|
function pickSize() {
|
|
if (!fill) return;
|
|
document.body.classList.add('fill');
|
|
if (portrait) document.body.classList.add('portrait');
|
|
var st0 = document.getElementById('stage'), r0 = st0.getBoundingClientRect(), c0 = getComputedStyle(st0);
|
|
var aw0 = Math.max(240, r0.width - parseFloat(c0.paddingLeft) - parseFloat(c0.paddingRight));
|
|
var ah0 = Math.max(200, r0.height - parseFloat(c0.paddingTop) - parseFloat(c0.paddingBottom));
|
|
var dpr0 = window.devicePixelRatio || 1;
|
|
if (portrait) {
|
|
// width: the phone's CSS width (360-430 on phones); a tablet stays near 600 wide
|
|
W = pickLen(aw0, aw0 * dpr0, Math.min(aw0, 600), 300, 640);
|
|
H = Math.max(480, Math.min(1400, Math.floor(W * ah0 / aw0))); // never taller than the space
|
|
} else {
|
|
// height: 1:1 up to 600 (a phone on its side: ~330; a tablet: ~500-600)
|
|
H = pickLen(ah0, ah0 * dpr0, Math.min(ah0, 600), 280, 640);
|
|
W = Math.max(480, Math.min(1280, Math.round(H * aw0 / ah0)));
|
|
if (W * ah0 / aw0 < H) H = Math.floor(W * ah0 / aw0); // the width rules (layout): never taller
|
|
}
|
|
W -= W & 1; H -= H & 1;
|
|
}
|
|
pickSize();
|
|
var BUILD = '__WADA_BUILD__';
|
|
var DB_MOUNT = '/data'; // also the IndexedDB database name IDBFS uses
|
|
|
|
var $ = function (id) { return document.getElementById(id); };
|
|
var canvas = $('screen'), stage = $('stage'), overlay = $('overlay');
|
|
canvas.width = W; canvas.height = H;
|
|
var ctx = canvas.getContext('2d', { alpha: false });
|
|
var M = null; // the Emscripten module once created
|
|
var running = false; // the UI has presented its first frame
|
|
var resetting = false; // a "Reset demo data" reload is under way: write nothing more
|
|
|
|
// ---- which mesh: the demo or a node ----------------------------------------------
|
|
var CHOICE_KEY = 'wadamesh.backend'; // {kind, arg, key?, name?}: the node to connect to
|
|
var NODES_KEY = 'wadamesh.nodes'; // kind|arg -> the node key last seen there
|
|
var HOST_KEY = 'wadamesh.wsHost';
|
|
function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } }
|
|
function lsSet(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
|
|
var choice = lsGet(CHOICE_KEY);
|
|
if (!choice || !/^(web-serial|ble|ws)$/.test(choice.kind)) choice = null;
|
|
function nodeDir(c) { return DB_MOUNT + '/node-' + (c.key || 'new'); }
|
|
var dataDir = choice ? nodeDir(choice) : DB_MOUNT + '/demo';
|
|
var everUp = false;
|
|
|
|
// ---- status / overlay ------------------------------------------------------------
|
|
function setStatus(text, detail, busy) {
|
|
overlay.hidden = false;
|
|
overlay.className = busy ? 'busy' : '';
|
|
$('status').textContent = text;
|
|
$('detail').innerHTML = detail || '';
|
|
}
|
|
function fatal(text, detailHtml, withReload) {
|
|
overlay.hidden = false;
|
|
overlay.className = 'fatal';
|
|
$('status').textContent = text;
|
|
$('detail').innerHTML = detailHtml || '';
|
|
if (withReload) {
|
|
var b = document.createElement('button');
|
|
b.className = 'btn'; b.type = 'button'; b.textContent = 'Reload';
|
|
b.onclick = function () { location.reload(); };
|
|
overlay.appendChild(b);
|
|
}
|
|
}
|
|
var toastTimer = 0;
|
|
function toast(text, ms) {
|
|
var t = $('toast');
|
|
t.textContent = text; t.hidden = false;
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(function () { t.hidden = true; }, ms || 4200);
|
|
}
|
|
|
|
// ---- scaling -------------------------------------------------------------------------
|
|
function layout() {
|
|
var full = document.fullscreenElement || document.webkitFullscreenElement;
|
|
var r = stage.getBoundingClientRect();
|
|
var cs = getComputedStyle(stage);
|
|
var aw = r.width - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
|
var ah = r.height - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
|
|
if (!full) ah = Math.max(ah, 120);
|
|
if (fill && !full) { // the canvas was sized for this space: fill it edge to edge
|
|
// (the keyboard or a toolbar can shorten the space later: the width rules, and
|
|
// fitAboveKeyboard slides the screen up instead of shrinking it)
|
|
var f = aw / W, d = f * (window.devicePixelRatio || 1);
|
|
canvas.style.width = aw + 'px';
|
|
canvas.style.height = Math.round(H * f) + 'px';
|
|
canvas.classList.toggle('crisp', Math.abs(d - Math.round(d)) < 0.02 || d >= 3);
|
|
overflowCheck();
|
|
return;
|
|
}
|
|
var dpr = window.devicePixelRatio || 1;
|
|
var fit = Math.max(0.1, Math.min(aw / W, ah / H));
|
|
var dev = fit * dpr; // canvas pixel -> device pixels
|
|
var k = Math.floor(dev + 1e-6);
|
|
var css, crisp;
|
|
if (k >= 1 && k / dev >= 0.8) { css = k / dpr; crisp = true; } // whole device pixels: nearest is exact
|
|
else { css = fit; crisp = dev >= 3; } // fractional: smooth, unless big enough to hide it
|
|
canvas.style.width = Math.round(W * css) + 'px';
|
|
canvas.style.height = Math.round(H * css) + 'px';
|
|
canvas.classList.toggle('crisp', crisp);
|
|
}
|
|
window.addEventListener('resize', layout);
|
|
// The space got shorter than the UI after it booted, with no keyboard up (a viewport
|
|
// that settled late): the bottom of the UI is cut off, so boot it again at the right
|
|
// size. At most once every 30 s, so a browser that keeps changing its mind cannot loop.
|
|
var overflowTimer = 0;
|
|
// How far the UI's bottom edge sits below the visible window (the stage itself can grow
|
|
// with its content, so the window is the measure).
|
|
function overflowPx() {
|
|
var vh = window.innerHeight;
|
|
var r = canvas.getBoundingClientRect();
|
|
return r.bottom - vh;
|
|
}
|
|
function overflowCheck() {
|
|
clearTimeout(overflowTimer);
|
|
if (!running || overflowPx() <= 8) return;
|
|
overflowTimer = setTimeout(function () {
|
|
if (!running || resetting || imeOn || (document.activeElement && document.activeElement.id === 'ime')) return;
|
|
if (overflowPx() <= 8) return;
|
|
var last = 0;
|
|
try { last = +sessionStorage.getItem('wada.refit') || 0; } catch (e) {}
|
|
if (Date.now() - last < 30000) return;
|
|
try { sessionStorage.setItem('wada.refit', String(Date.now())); } catch (e) {}
|
|
restartUi('Fitting the screen');
|
|
}, 900);
|
|
}
|
|
// ---- orientation: the other layout needs a restart of the UI (data kept) -----------
|
|
// Debounced, only when the window's aspect really flipped AND its width changed (a phone
|
|
// keyboard or a collapsing toolbar only changes the height, which is no rotation), and
|
|
// never while the visitor is typing.
|
|
var flipTimer = 0;
|
|
function maybeFlip() {
|
|
clearTimeout(flipTimer);
|
|
flipTimer = setTimeout(function () {
|
|
if (!running || resetting || wantPortrait() === portrait) return;
|
|
if (Math.abs(window.innerWidth - bootWidth) < 40) return;
|
|
if (imeOn || (document.activeElement && document.activeElement.id === 'ime')) return;
|
|
restartUi(portrait ? 'Turning to landscape' : 'Turning to portrait');
|
|
}, 600);
|
|
}
|
|
// A keyboard that was open during the turn: check again once it has closed.
|
|
window.addEventListener('focusout', function () { setTimeout(maybeFlip, 400); });
|
|
window.addEventListener('resize', maybeFlip);
|
|
if (screen.orientation && screen.orientation.addEventListener) screen.orientation.addEventListener('change', maybeFlip);
|
|
document.addEventListener('fullscreenchange', layout);
|
|
document.addEventListener('webkitfullscreenchange', layout);
|
|
if (window.matchMedia) {
|
|
// a zoom / monitor change alters devicePixelRatio without a resize on some browsers
|
|
var watchDpr = function () {
|
|
var mq = window.matchMedia('(resolution: ' + (window.devicePixelRatio || 1) + 'dppx)');
|
|
var on = function () { layout(); watchDpr(); };
|
|
if (mq.addEventListener) mq.addEventListener('change', on, { once: true });
|
|
};
|
|
watchDpr();
|
|
}
|
|
layout();
|
|
|
|
// ---- presentation (called by web_main.cpp with the changed rectangle as RGBA) --------
|
|
var imgCache = {};
|
|
var stats = { frames: 0, presents: 0, presentedPx: 0, t0: performance.now() };
|
|
function present(view, x, y, w, h) {
|
|
var key = w + 'x' + h;
|
|
var img = imgCache[key];
|
|
if (!img) {
|
|
img = ctx.createImageData(w, h);
|
|
if (Object.keys(imgCache).length > 48) imgCache = {};
|
|
imgCache[key] = img;
|
|
}
|
|
img.data.set(view);
|
|
ctx.putImageData(img, x, y);
|
|
stats.presents++; stats.presentedPx += w * h;
|
|
if (!running) {
|
|
running = true;
|
|
if (fill) { overflowCheck(); setTimeout(overflowCheck, 1500); } // a viewport that settled while it booted
|
|
overlay.hidden = true;
|
|
canvas.focus({ preventScroll: true });
|
|
}
|
|
}
|
|
(function countFrames() { stats.frames++; requestAnimationFrame(countFrames); })();
|
|
|
|
// ---- location: the UI's GPS (web/platform/WebLocation.cpp) --------------------------------
|
|
// The UI asks for it only while its GPS is switched on (Module.wadaGeoStart / Stop).
|
|
// The browser's permission prompt is never shown on page load: when the visitor has not
|
|
// decided yet, the request waits for a tap (the GPS switch itself, opening the map, any
|
|
// tap). Refused or unavailable: the UI keeps "searching"; nothing asks again until the
|
|
// GPS is switched off and on. The position stays in this page unless the node's own
|
|
// "share position in adverts" setting is on (WEB_BUILD.md "Location").
|
|
var geo = { want: false, watch: null, waitingTap: false, lastTap: -1e9, toldDenied: false };
|
|
function geoUserActive() {
|
|
if (navigator.userActivation) return navigator.userActivation.isActive;
|
|
return performance.now() - geo.lastTap < 5000;
|
|
}
|
|
function geoPermission() {
|
|
if (!navigator.permissions || !navigator.permissions.query) return Promise.resolve('prompt');
|
|
return navigator.permissions.query({ name: 'geolocation' }).then(function (r) { return r.state; },
|
|
function () { return 'prompt'; });
|
|
}
|
|
function geoBegin() {
|
|
geo.waitingTap = false;
|
|
if (!geo.want || geo.watch !== null || !M) return;
|
|
if (!navigator.geolocation) { M._wadaWebGeoLost(2); return; }
|
|
geo.watch = navigator.geolocation.watchPosition(function (p) {
|
|
var c = p.coords, n = function (v) { return v == null ? NaN : v; };
|
|
if (M && geo.want) M._wadaWebGeoFix(c.latitude, c.longitude, n(c.accuracy), n(c.altitude),
|
|
n(c.speed), n(c.heading), p.timestamp / 1000);
|
|
}, function (err) {
|
|
if (err.code !== 1) return; // unavailable / timeout: the browser keeps trying by itself
|
|
geoEnd();
|
|
if (M) M._wadaWebGeoLost(1);
|
|
if (!geo.toldDenied) { geo.toldDenied = true; toast('The browser does not let this page use your location, so the GPS has no position. You can allow it in the site settings.'); }
|
|
}, { enableHighAccuracy: true, maximumAge: 30000 });
|
|
}
|
|
function geoEnd() {
|
|
if (geo.watch !== null && navigator.geolocation) navigator.geolocation.clearWatch(geo.watch);
|
|
geo.watch = null;
|
|
}
|
|
function geoStart() {
|
|
geo.want = true;
|
|
if (geo.watch !== null) return;
|
|
geoPermission().then(function (state) {
|
|
if (!geo.want || geo.watch !== null) return;
|
|
if (state === 'denied') { if (M) M._wadaWebGeoLost(1); return; }
|
|
if (state === 'granted' || geoUserActive()) geoBegin();
|
|
else geo.waitingTap = true; // switched on at boot: ask on the visitor's next tap
|
|
});
|
|
}
|
|
function geoStop() { geo.want = false; geo.waitingTap = false; geoEnd(); }
|
|
function geoTap() {
|
|
geo.lastTap = performance.now();
|
|
if (geo.waitingTap && geo.want) geoBegin();
|
|
}
|
|
|
|
// ---- input -----------------------------------------------------------------------------
|
|
var activePointer = null;
|
|
var lastPt = [0, 0]; // where the finger last was, so a forced release lands there
|
|
function toCanvas(e) {
|
|
var r = canvas.getBoundingClientRect();
|
|
return [Math.floor((e.clientX - r.left) * W / r.width), Math.floor((e.clientY - r.top) * H / r.height)];
|
|
}
|
|
function sendPointer(down, e) {
|
|
if (!M || !running) return;
|
|
var p = toCanvas(e);
|
|
lastPt = p;
|
|
M._wadaWebPointer(down ? 1 : 0, p[0], p[1]);
|
|
}
|
|
canvas.addEventListener('pointerdown', function (e) {
|
|
// One finger: the panel is single-touch. A mouse has only one pointer, so a new
|
|
// mouse press while one is "active" means its release was lost (released outside
|
|
// the window, a focus change): drop the stale one instead of ignoring input forever.
|
|
if (activePointer !== null) {
|
|
if (e.pointerType !== 'mouse') return;
|
|
activePointer = null;
|
|
if (M && running) M._wadaWebPointer(0, lastPt[0], lastPt[1]);
|
|
}
|
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
|
activePointer = e.pointerId;
|
|
geoTap();
|
|
try { canvas.setPointerCapture(e.pointerId); } catch (_) {}
|
|
// While the phone keyboard is up, a tap on the UI (Send, a bubble) leaves it up.
|
|
if (!(touchDevice && imeOn)) canvas.focus({ preventScroll: true });
|
|
sendPointer(true, e);
|
|
e.preventDefault();
|
|
});
|
|
canvas.addEventListener('pointermove', function (e) {
|
|
if (e.pointerId !== activePointer) return;
|
|
sendPointer(true, e);
|
|
});
|
|
function pointerEnd(e) {
|
|
if (e.pointerId !== activePointer) return;
|
|
activePointer = null;
|
|
sendPointer(false, e);
|
|
// A tap on a text field opens the phone's own keyboard, here, inside the gesture
|
|
// (iOS only shows it for a focus() made in the user's own event).
|
|
if (e.type === 'pointerup' && touchDevice && M && running) {
|
|
var p = toCanvas(e);
|
|
if (M._wadaWebTextareaAt(p[0], p[1])) imeOpen();
|
|
else if (imeOn && document.activeElement !== ime) ime.focus({ preventScroll: true }); // the edit goes on
|
|
}
|
|
}
|
|
canvas.addEventListener('pointerup', pointerEnd);
|
|
canvas.addEventListener('pointercancel', pointerEnd);
|
|
canvas.addEventListener('lostpointercapture', pointerEnd);
|
|
// Window loses focus mid-press (alt-tab, a dialog): release, or the UI keeps a
|
|
// phantom finger down.
|
|
window.addEventListener('blur', function () {
|
|
if (activePointer === null) return;
|
|
activePointer = null;
|
|
if (M && running) M._wadaWebPointer(0, lastPt[0], lastPt[1]);
|
|
});
|
|
canvas.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
|
// The mouse wheel / two-finger trackpad scroll: scrolls the list or page under the
|
|
// pointer (web/platform/WebLvglInput.cpp). Pixels, lines and pages all become canvas pixels.
|
|
var wheelRest = 0;
|
|
canvas.addEventListener('wheel', function (e) {
|
|
if (!M || !running || e.ctrlKey) return; // ctrl+wheel / pinch: the browser's zoom
|
|
e.preventDefault();
|
|
var r = canvas.getBoundingClientRect();
|
|
// deltaMode 0: CSS pixels (scaled to canvas pixels), 1: lines (16 px), 2: pages
|
|
var k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? H * 0.9 : H / r.height;
|
|
var dy = e.deltaY * k + wheelRest;
|
|
var whole = Math.trunc(dy);
|
|
wheelRest = dy - whole;
|
|
if (!whole) return;
|
|
var p = toCanvas(e);
|
|
M._wadaWebWheel(p[0], p[1], whole);
|
|
}, { passive: false });
|
|
|
|
// The computer keyboard is the UI's external keyboard (HAS_CARDKB): ASCII, Enter,
|
|
// Backspace, Esc (Back), Tab, arrows. Same codes as host_main.cpp's keyDown().
|
|
var KEYS = { Enter: 0x0D, Backspace: 0x08, Escape: 0x1B, Tab: 0x09,
|
|
ArrowLeft: 0xB4, ArrowUp: 0xB5, ArrowDown: 0xB6, ArrowRight: 0xB7 };
|
|
document.addEventListener('keydown', function (e) {
|
|
if (!M || !running) return;
|
|
if (e.ctrlKey || e.metaKey || e.altKey) return; // browser shortcuts stay the browser's
|
|
var t = e.target;
|
|
if ($('connect-dlg').open || (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return; // the page's own fields
|
|
if (t && t !== canvas && t !== document.body && /^(BUTTON|A)$/.test(t.tagName)) {
|
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Tab') return; // the bar's own buttons
|
|
}
|
|
var code = KEYS[e.key];
|
|
if (code === undefined && e.key.length === 1) {
|
|
var c = e.key.charCodeAt(0);
|
|
if (c >= 0x20 && c < 0x7F) code = c;
|
|
}
|
|
if (code === undefined && Array.from(e.key).length === 1 && e.key.codePointAt(0) >= 0xA0) {
|
|
e.preventDefault(); // any other script, typed on the computer: as text (UTF-8)
|
|
geoTap();
|
|
M.ccall('wadaWebText', null, ['string'], [e.key]);
|
|
return;
|
|
}
|
|
if (code === undefined) return;
|
|
e.preventDefault();
|
|
geoTap();
|
|
M._wadaWebKey(code);
|
|
});
|
|
|
|
// ---- the phone's own keyboard --------------------------------------------------------
|
|
// On a touch device the UI's on-screen keys stay down (the phone keyboard counts as an
|
|
// external keyboard: wadaWebNativeKeyboard) and a hidden <textarea> takes the typing:
|
|
// autocorrect, emoji, every language, voice. What changes in it goes to the bound LVGL
|
|
// field as text (UTF-8) and Backspaces, as a diff, so a composing / autocorrecting
|
|
// keyboard is followed word by word. A leading space is a sentinel the keyboard can
|
|
// delete, so Backspace works on an empty field too. Enter = the UI's Enter (send).
|
|
var ime = $('ime'), IME_PAD = ' ';
|
|
var imeLast = IME_PAD, imeOn = false, imeComposing = false, imeIdle = 0;
|
|
function imeReset() {
|
|
ime.value = IME_PAD; imeLast = IME_PAD;
|
|
try { ime.setSelectionRange(1, 1); } catch (e) {}
|
|
}
|
|
function imeOpen() {
|
|
if (!M || !running) return;
|
|
if (!imeOn) imeReset();
|
|
imeOn = true; imeIdle = 0;
|
|
ime.focus({ preventScroll: true });
|
|
}
|
|
function imeClose() {
|
|
imeOn = false;
|
|
if (document.activeElement === ime) ime.blur();
|
|
fitAboveKeyboard();
|
|
}
|
|
function imeKey(code) { if (M && running) M._wadaWebKey(code); }
|
|
function imeSync() {
|
|
if (!M || !running) return;
|
|
var a = Array.from(imeLast), b = Array.from(ime.value), p = 0;
|
|
while (p < a.length && p < b.length && a[p] === b[p]) p++;
|
|
for (var i = p; i < a.length; i++) imeKey(0x08);
|
|
var add = b.slice(p).join('');
|
|
if (add) {
|
|
var parts = add.split(/\r?\n/);
|
|
parts.forEach(function (t, j) {
|
|
if (t) M.ccall('wadaWebText', null, ['string'], [t]);
|
|
if (j < parts.length - 1) imeKey(0x0D);
|
|
});
|
|
}
|
|
imeLast = ime.value;
|
|
if (!imeComposing && (ime.value.indexOf(IME_PAD) !== 0 || ime.value.length > 400 || /\n/.test(ime.value))) imeReset();
|
|
}
|
|
ime.addEventListener('input', imeSync);
|
|
ime.addEventListener('compositionstart', function () { imeComposing = true; });
|
|
ime.addEventListener('compositionend', function () { imeComposing = false; imeSync(); });
|
|
ime.addEventListener('beforeinput', function (e) {
|
|
if (e.inputType === 'insertLineBreak' || e.inputType === 'insertParagraph') {
|
|
e.preventDefault(); imeKey(0x0D); imeReset();
|
|
}
|
|
});
|
|
ime.addEventListener('keydown', function (e) {
|
|
if (imeComposing || e.isComposing) return;
|
|
if (e.key === 'Enter') { e.preventDefault(); imeKey(0x0D); imeReset(); }
|
|
else if (e.key === 'Escape') { e.preventDefault(); imeKey(0x1B); }
|
|
else if (e.key === 'Backspace' && ime.value === '') { e.preventDefault(); imeKey(0x08); imeReset(); }
|
|
});
|
|
ime.addEventListener('blur', function () { imeOn = false; setTimeout(fitAboveKeyboard, 50); });
|
|
// iOS opens its keyboard only for a focus() made inside the touch's own handlers, and it
|
|
// is touchend (not every pointerup) it trusts for that: the same check, here too.
|
|
canvas.addEventListener('touchend', function (e) {
|
|
if (!touchDevice || !M || !running || !e.changedTouches.length) return;
|
|
var t = e.changedTouches[0], p = toCanvas(t);
|
|
if (M._wadaWebTextareaAt(p[0], p[1])) imeOpen();
|
|
});
|
|
// A field the UI focused by itself (a screen that opens with its text field active, the
|
|
// composer after a tap on a chat) has no tap of its own to open the phone's keyboard:
|
|
// a Keyboard button is that tap.
|
|
var kbPill = $('kb-pill');
|
|
kbPill.addEventListener('click', function () { kbPill.hidden = true; imeOpen(); });
|
|
setInterval(function () {
|
|
var want = touchDevice && !!M && running && !imeOn && !!M._wadaWebEditing();
|
|
if (kbPill.hidden === want) kbPill.hidden = !want;
|
|
}, 250);
|
|
// The edit ended in the UI (the chat closed, a field was left): the keyboard goes too.
|
|
setInterval(function () {
|
|
if (!imeOn || !M || !running) return;
|
|
imeIdle = M._wadaWebEditing() ? 0 : imeIdle + 1;
|
|
if (imeIdle >= 2) imeClose();
|
|
}, 300);
|
|
// While the keyboard is up, slide the screen up so its bottom (the composer) sits on
|
|
// the keyboard instead of under it.
|
|
function fitAboveKeyboard() {
|
|
var frame = $('frame'), vv = window.visualViewport;
|
|
if (!vv || !imeOn) { frame.style.transform = ''; return; }
|
|
frame.style.transform = '';
|
|
var r = canvas.getBoundingClientRect();
|
|
var visibleBottom = vv.offsetTop + vv.height;
|
|
var lift = Math.min(0, visibleBottom - r.bottom);
|
|
frame.style.transform = lift ? 'translateY(' + Math.round(lift) + 'px)' : '';
|
|
}
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', fitAboveKeyboard);
|
|
window.visualViewport.addEventListener('scroll', fitAboveKeyboard);
|
|
}
|
|
|
|
// ---- storage: IndexedDB behind /data -----------------------------------------------
|
|
var persistState = { inflight: false, again: false, waiters: [], lastGen: -1, savedGen: -1,
|
|
changedAt: 0, firstUnsaved: 0, saves: 0 };
|
|
// One IDBFS sync at a time; a request during a sync queues one more after it (and its
|
|
// callback waits for that one, which includes its writes).
|
|
function persist(done) {
|
|
var p = persistState;
|
|
if (done) p.waiters.push(done);
|
|
if (!M || !M.FS || resetting) { var w0 = p.waiters; p.waiters = []; w0.forEach(function (f) { f(); }); return; }
|
|
if (p.inflight) { p.again = true; return; }
|
|
var gen = M._wadaWebFsGeneration();
|
|
var mine = p.waiters; p.waiters = [];
|
|
p.inflight = true;
|
|
M.FS.syncfs(false, function (err) {
|
|
p.inflight = false;
|
|
if (err) console.warn('[web] saving to IndexedDB failed', err);
|
|
else { p.savedGen = gen; p.firstUnsaved = 0; p.saves++; }
|
|
mine.forEach(function (f) { f(); });
|
|
if (p.again) { p.again = false; persist(); }
|
|
});
|
|
}
|
|
// Persist once writes settle (0.8 s quiet), and at least every 5 s while they continue.
|
|
setInterval(function () {
|
|
if (!M || !running) return;
|
|
var p = persistState, now = performance.now();
|
|
var gen = M._wadaWebFsGeneration();
|
|
if (gen !== p.lastGen) { p.lastGen = gen; p.changedAt = now; if (!p.firstUnsaved) p.firstUnsaved = now; }
|
|
if (gen !== p.savedGen && !p.inflight && (now - p.changedAt > 800 || now - p.firstUnsaved > 5000)) persist();
|
|
}, 400);
|
|
function flushNow() {
|
|
if (!M || !running || resetting) return;
|
|
try { M._wadaWebFlush(); } catch (e) { console.warn(e); }
|
|
persist();
|
|
}
|
|
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushNow(); });
|
|
window.addEventListener('pagehide', flushNow);
|
|
|
|
// ---- the bar -------------------------------------------------------------------------
|
|
function setMode(cls, text, title) {
|
|
var b = $('mode');
|
|
b.className = 'badge' + (cls ? ' ' + cls : '');
|
|
b.textContent = text;
|
|
b.title = title || text;
|
|
}
|
|
// Save what the running UI has and boot it again (an orientation change).
|
|
function restartUi(why) {
|
|
running = false;
|
|
setStatus(why, '', true);
|
|
try { if (M && M._wadaWebFlush) M._wadaWebFlush(); } catch (e) {}
|
|
persist(function () { location.reload(); });
|
|
}
|
|
// Save what the running UI has, then boot the UI again into `next` (null = the demo).
|
|
function switchTo(next) {
|
|
lsSet(CHOICE_KEY, next);
|
|
running = false;
|
|
setStatus(next ? 'Connecting to your node' : 'Back to the demo', '', true);
|
|
try { if (M && M._wadaWebFlush) M._wadaWebFlush(); } catch (e) {}
|
|
persist(function () { location.reload(); });
|
|
}
|
|
function nodeKeyFor(kind, arg) { var m = lsGet(NODES_KEY) || {}; return m[kind + '|' + arg] || ''; }
|
|
function rememberNode(c) {
|
|
var m = lsGet(NODES_KEY) || {};
|
|
m[c.kind + '|' + c.arg] = c.key;
|
|
lsSet(NODES_KEY, m);
|
|
}
|
|
function connectTo(kind, arg) {
|
|
$('connect-dlg').close();
|
|
switchTo({ kind: kind, arg: arg, key: nodeKeyFor(kind, arg) });
|
|
}
|
|
|
|
// The chooser: only the transports this browser has.
|
|
var NUS = '6e400001-b5a3-f393-e0a9-e50e24dcca9e';
|
|
var hasSerial = !!navigator.serial, hasBle = !!navigator.bluetooth;
|
|
(function setupChooser() {
|
|
var dlg = $('connect-dlg');
|
|
dlg.querySelector('[data-kind="web-serial"]').hidden = !hasSerial;
|
|
dlg.querySelector('[data-kind="ble"]').hidden = !hasBle;
|
|
// The two copies of the page (WEB_BUILD.md "Two copies"): https has USB, Bluetooth and
|
|
// the location (secure context), http reaches a node's plain ws:// over Wi-Fi.
|
|
var local = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
|
|
var secure = window.isSecureContext;
|
|
var ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
var notes = [], links = [];
|
|
if (!secure) {
|
|
notes.push('This copy of the page connects over Wi-Fi.');
|
|
links.push(['For USB, Bluetooth and your location, use ', secureCopyUrl()]);
|
|
} else if (ios) notes.push('On iPhone and iPad the browser has no Bluetooth or USB: connect over Wi-Fi.');
|
|
else if (!hasSerial && !hasBle) notes.push('USB and Bluetooth need Chrome or Edge; this browser can connect over Wi-Fi.');
|
|
else if (!hasBle) notes.push('Bluetooth needs Chrome or Edge.');
|
|
else if (!hasSerial) notes.push('USB needs Chrome or Edge on a computer.');
|
|
var note = $('connect-note');
|
|
note.textContent = notes.join(' ');
|
|
links.forEach(function (l) {
|
|
var span = document.createElement('span');
|
|
span.className = 'link-line';
|
|
span.appendChild(document.createTextNode(l[0]));
|
|
var a = document.createElement('a');
|
|
a.href = l[1]; a.textContent = l[1].replace(/\/$/, '');
|
|
span.appendChild(a);
|
|
note.appendChild(span);
|
|
});
|
|
note.hidden = !notes.length && !links.length;
|
|
var host = $('ws-host');
|
|
host.value = lsGet(HOST_KEY) || '';
|
|
dlg.querySelector('[data-kind="web-serial"]').addEventListener('click', function () {
|
|
navigator.serial.requestPort().then(function (port) {
|
|
var i = port.getInfo ? port.getInfo() : {};
|
|
connectTo('web-serial', i.usbVendorId != null ? i.usbVendorId + ':' + i.usbProductId : '');
|
|
}, function () { /* the visitor closed the picker */ });
|
|
});
|
|
dlg.querySelector('[data-kind="ble"]').addEventListener('click', function () {
|
|
navigator.bluetooth.requestDevice({ filters: [{ services: [NUS] }, { namePrefix: 'MeshCore' }], optionalServices: [NUS] })
|
|
.then(function (dev) { connectTo('ble', dev.id); }, function () {});
|
|
});
|
|
var goWs = function () {
|
|
var h = host.value.trim();
|
|
if (!h) { host.focus(); return; }
|
|
lsSet(HOST_KEY, h);
|
|
var url = wsUrlFor(h);
|
|
// The https copy may not open a node's plain ws://: say so, then carry the address
|
|
// to the Wi-Fi (http) copy, which connects to it on arrival (#wifi=<address>).
|
|
if (location.protocol === 'https:' && !local && /^ws:/i.test(url)) { wifiHandoff(h); return; }
|
|
connectTo('ws', url);
|
|
};
|
|
function wifiHandoff(addr) {
|
|
var wifi = dlg.querySelector('.choice.wifi');
|
|
var old = $('wifi-handoff');
|
|
if (old) old.remove();
|
|
var box = document.createElement('div');
|
|
box.id = 'wifi-handoff';
|
|
var p = document.createElement('p');
|
|
p.textContent = 'Wi-Fi opens the Wi-Fi version of this page (' + wifiCopyUrl('').replace(/^http:\/\/|\/?#wifi=$/g, '') +
|
|
') and connects to ' + addr + ' there. Browsers only let a plain http page talk to a node on your ' +
|
|
'network; USB, Bluetooth and your location stay on this secure page.';
|
|
var row = document.createElement('div');
|
|
row.className = 'row';
|
|
var go = document.createElement('button');
|
|
go.type = 'button'; go.className = 'btn primary'; go.textContent = 'Continue';
|
|
go.addEventListener('click', function () { location.href = wifiCopyUrl(addr); });
|
|
var no = document.createElement('button');
|
|
no.type = 'button'; no.className = 'btn'; no.textContent = 'Cancel';
|
|
no.addEventListener('click', function () { box.remove(); host.focus(); });
|
|
row.appendChild(go); row.appendChild(no);
|
|
box.appendChild(p); box.appendChild(row);
|
|
wifi.appendChild(box);
|
|
go.focus();
|
|
}
|
|
dlg.querySelector('[data-kind="ws"]').addEventListener('click', goWs);
|
|
host.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); goWs(); } });
|
|
})();
|
|
|
|
// The hosted client has two names: app.wadamesh.com (https, behind Cloudflare) and
|
|
// wifi.app.wadamesh.com, which answers plain http only. Chrome upgrades a typed or linked
|
|
// http:// to https whenever the name answers https, which would bring Wi-Fi visitors
|
|
// straight back to the copy that may not open ws://; a name with no https stays http.
|
|
// (function declarations, not a var: the chooser above calls these before this runs)
|
|
function wifiHosts() { return { 'app.wadamesh.com': 'wifi.app.wadamesh.com' }; }
|
|
function wifiCopyUrl(addr) {
|
|
var h = wifiHosts()[location.hostname] || location.host;
|
|
return 'http://' + h + location.pathname + (addr ? '#wifi=' + encodeURIComponent(addr) : '#wifi=');
|
|
}
|
|
function wsUrlFor(h) {
|
|
return /^wss?:\/\//i.test(h) ? h : 'ws://' + (/:\d+$/.test(h) ? h : h + ':8765') + '/';
|
|
}
|
|
function secureCopyUrl() {
|
|
var m = wifiHosts();
|
|
for (var k in m) if (m[k] === location.hostname) return 'https://' + k + location.pathname;
|
|
return 'https://' + location.host + location.pathname;
|
|
}
|
|
// Arrived from the https copy with an address: the chooser, Wi-Fi, that address.
|
|
(function wifiFromHash() {
|
|
var m = /^#wifi=(.*)$/.exec(location.hash);
|
|
if (!m) return;
|
|
history.replaceState(null, '', location.pathname + location.search);
|
|
if (choice) return; // already on a node
|
|
var addr = decodeURIComponent(m[1] || '');
|
|
if (!addr) { setTimeout(function () { $('btn-connect').click(); $('ws-host').focus(); }, 0); return; }
|
|
$('ws-host').value = addr; lsSet(HOST_KEY, addr);
|
|
try { sessionStorage.setItem('wada.wifiNotice', '1'); } catch (e) {}
|
|
setTimeout(function () { connectTo('ws', wsUrlFor(addr)); }, 0);
|
|
})();
|
|
// Just handed over from the https copy (the connect reloads the page): say where we are.
|
|
(function wifiNotice() {
|
|
if (!choice || choice.kind !== 'ws') return; // still the demo: the connect reloads into the node
|
|
var shown = null;
|
|
try { shown = sessionStorage.getItem('wada.wifiNotice'); sessionStorage.removeItem('wada.wifiNotice'); } catch (e) {}
|
|
if (shown)
|
|
toast('This is the Wi-Fi version of the web client: plain http, so it can reach your node. ' +
|
|
'For USB, Bluetooth and your location use ' + secureCopyUrl().replace(/^https:\/\/|\/$/g, '') + '.', 9000);
|
|
})();
|
|
$('btn-connect').addEventListener('click', function () {
|
|
if (choice) { switchTo(null); return; } // Disconnect: back to the demo
|
|
var dlg = $('connect-dlg');
|
|
if (dlg.showModal) dlg.showModal(); else dlg.setAttribute('open', '');
|
|
if (!hasSerial && !hasBle) $('ws-host').focus();
|
|
});
|
|
// Web Serial / Bluetooth without a device this page may open by itself: one click.
|
|
$('btn-pair').addEventListener('click', function () {
|
|
$('btn-pair').hidden = true;
|
|
var wc = M && M.WadaCompanion;
|
|
if (wc) wc.connect().catch(function (e) { toast(String(e && e.message || e)); });
|
|
});
|
|
if (choice) {
|
|
$('btn-connect').textContent = 'Disconnect';
|
|
$('btn-connect').classList.remove('primary');
|
|
$('btn-reset').hidden = true;
|
|
setMode('wait', 'Connecting');
|
|
var hint = document.querySelector('.hint');
|
|
if (hint) hint.textContent = 'Mouse or finger = touch. Your keyboard types into the focused field, Enter sends, Esc goes back. ' +
|
|
'Messages go out through your node; this browser keeps a copy of the chats.';
|
|
}
|
|
// From web_main.cpp (BackendSelect.h g_wada_link_hook): state 1 = up, 0 = (re)connecting.
|
|
function onLink(state, name, via, key) {
|
|
if (!choice) return;
|
|
if (state === 1) {
|
|
everUp = true;
|
|
$('btn-pair').hidden = true;
|
|
setMode('up', 'Connected: ' + name + ' over ' + via);
|
|
if (key && choice.key !== key) {
|
|
var first = !choice.key;
|
|
choice.key = key; choice.name = name;
|
|
lsSet(CHOICE_KEY, choice);
|
|
rememberNode(choice);
|
|
// A different node on the same cable / address: its own data dir, so the two
|
|
// nodes' contacts and messages never mix. (The first time, the data this
|
|
// session wrote moves there on the next start.)
|
|
if (!first) { toast('This is a different node: opening its own data.'); switchTo(choice); }
|
|
} else if (name && choice.name !== name) {
|
|
choice.name = name; lsSet(CHOICE_KEY, choice);
|
|
}
|
|
} else {
|
|
setMode('wait', everUp ? 'Reconnecting' : 'Connecting', linkDetail || undefined);
|
|
}
|
|
}
|
|
var linkDetail = '';
|
|
// A node that never answered gets a plain explanation instead of an endless
|
|
// "Connecting": the likely causes, and the demo one click away. (Kaj's V4 sat on
|
|
// another VLAN, 192.168.20.x, that this computer could not route to.)
|
|
var linkFails = 0;
|
|
function linkTrouble(up, detail) {
|
|
var box = $('link-help');
|
|
if (up) { linkFails = 0; if (box && !box.hidden) { box.hidden = true; layout(); } return; }
|
|
if (/^paused/.test(detail || '')) return; // this page was out of sight and let the node go
|
|
// The node dropped us for another client (companion-transports.js): worth saying even
|
|
// after the link was up once, since it looks like a connection that never finishes.
|
|
var taken = !!choice && choice.kind === 'ws' && /another app or browser/.test(detail || '');
|
|
if ((everUp && !taken) || !choice) return;
|
|
var mixed = choice.kind === 'ws' && /mixed content/.test(detail || '');
|
|
if (++linkFails < 2 && !mixed && !taken) return; // one miss can be a slow start (a blocked ws:// cannot)
|
|
setMode('bad', taken ? 'Node in use elsewhere' : "Can't reach your node", detail || undefined);
|
|
if (!box) {
|
|
box = document.createElement('div');
|
|
box.id = 'link-help';
|
|
box.className = 'link-help';
|
|
var bar = document.querySelector('.bar');
|
|
bar.parentNode.insertBefore(box, bar.nextSibling);
|
|
}
|
|
var how = taken ? 'Your node serves one Wi-Fi app at a time, and something else just took it: another tab or ' +
|
|
'window of this page, the home-screen app, another phone or computer, or the MeshCore app over Wi-Fi. Close that ' +
|
|
'one and this page takes over again; it keeps trying, more slowly.'
|
|
: mixed ? 'This secure (https) copy of the page may not open a plain ws:// connection to your node. Use the http copy for Wi-Fi: ' + wifiCopyUrl('').replace(/#wifi=$/, '')
|
|
: choice.kind === 'ws'
|
|
? 'Check that the node has Wi-Fi on and is on the same network as this computer (a guest or IoT network often cannot reach the rest), and that you typed its address right: ' + choice.arg.replace(/^wss?:\/\//, '').replace(/\/$/, '') + '.'
|
|
: choice.kind === 'web-serial'
|
|
? 'Check the cable (a charge-only cable carries no data) and that no other app has the port open.'
|
|
: 'Check that the node is on, in range, and has Bluetooth on.';
|
|
box.innerHTML = '';
|
|
var p = document.createElement('p');
|
|
p.textContent = mixed || taken ? how : 'No answer from your node yet. ' + how + ' Still trying every few seconds.';
|
|
// A ws:// that never connected from the https copy (blocked, or a silent timeout): the
|
|
// http copy is the way, with this address.
|
|
if (choice.kind === 'ws' && location.protocol === 'https:' && /^ws:/i.test(choice.arg)) {
|
|
var addr = choice.arg.replace(/^wss?:\/\//, '').replace(/\/$/, '');
|
|
var copy = document.createElement('button');
|
|
copy.type = 'button'; copy.className = 'btn primary'; copy.textContent = 'Open the Wi-Fi version';
|
|
copy.addEventListener('click', function () { lsSet(CHOICE_KEY, null); location.href = wifiCopyUrl(addr); });
|
|
box.appendChild(p); box.appendChild(copy);
|
|
p = null;
|
|
}
|
|
var back = document.createElement('button');
|
|
back.type = 'button'; back.className = 'btn'; back.textContent = 'Back to the demo';
|
|
back.addEventListener('click', function () { switchTo(null); });
|
|
if (p) box.appendChild(p);
|
|
box.appendChild(back);
|
|
box.hidden = false;
|
|
layout(); // the box takes height from the stage: refit the screen below it
|
|
}
|
|
$('btn-reset2').hidden = !!choice;
|
|
$('btn-reset2').addEventListener('click', function () { menuOpen(false); $('btn-reset').click(); });
|
|
$('btn-reset').addEventListener('click', function () {
|
|
if (!confirm('Reset the demo? Your messages, contacts and settings in this browser are deleted and the demo starts fresh.')) return;
|
|
resetting = true;
|
|
var u = new URL(location.href);
|
|
u.searchParams.set('reset', '1');
|
|
location.replace(u.toString());
|
|
});
|
|
// ---- files: the UI's downloads + file picker (web/platform/WebFiles.cpp) -------------
|
|
function download(bytes, name, type) {
|
|
var url = URL.createObjectURL(new Blob([bytes], { type: type || 'application/octet-stream' }));
|
|
var a = document.createElement('a');
|
|
a.href = url; a.download = name; a.rel = 'noopener';
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
setTimeout(function () { URL.revokeObjectURL(url); }, 30000);
|
|
}
|
|
// The UI asked for a file (Settings > Backups > Import from this computer): the chosen
|
|
// file goes into this backend's SPIFFS root, then the UI's own import takes it.
|
|
var pickInput = $('file-pick');
|
|
var pickFor = null; // 'ui' (into the data dir) or 'restore' (a browser backup)
|
|
function pickFile(accept, purpose) {
|
|
pickFor = purpose;
|
|
pickInput.value = '';
|
|
pickInput.accept = accept || '';
|
|
pickInput.click();
|
|
}
|
|
pickInput.addEventListener('change', function () {
|
|
var f = pickInput.files && pickInput.files[0];
|
|
if (!f) return;
|
|
f.arrayBuffer().then(function (buf) {
|
|
var bytes = new Uint8Array(buf);
|
|
if (pickFor === 'restore') { restoreBrowser(bytes, f.name); return; }
|
|
if (!M || !M.FS) return;
|
|
var name = f.name.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '').slice(0, 48) || 'import.json';
|
|
if (!/\.json$/i.test(name)) name += '.json';
|
|
M.FS.writeFile(dataDir + '/spiffs/' + name, bytes);
|
|
persist();
|
|
M.ccall('wadaWebFilePicked', null, ['string'], ['/' + name]);
|
|
});
|
|
});
|
|
|
|
// ---- "Back up this browser" / "Restore": the active backend's whole data dir --------
|
|
// One tar file (ustar), so it moves to another browser or computer. The map tile cache
|
|
// is left out (it downloads again). A marker file names what it is.
|
|
var TAR_MARK = 'wadamesh-browser-backup.json';
|
|
function tarFiles(entries) { // [{path, data:Uint8Array}] -> Uint8Array
|
|
var enc = new TextEncoder(), blocks = [], total = 0;
|
|
var oct = function (n, len) { var t = n.toString(8); while (t.length < len - 1) t = '0' + t; return t + '\0'; };
|
|
entries.forEach(function (e) {
|
|
var h = new Uint8Array(512), put = function (str, off) { h.set(enc.encode(str), off); };
|
|
var name = e.path, prefix = '';
|
|
if (name.length > 99) { var cut = name.lastIndexOf('/', 154); prefix = name.slice(0, cut); name = name.slice(cut + 1); }
|
|
put(name, 0); put('0000644\0', 100); put(oct(0, 8), 108); put(oct(0, 8), 116);
|
|
put(oct(e.data.length, 12), 124); put(oct(Math.floor(Date.now() / 1000), 12), 136);
|
|
put(' ', 148); put('0', 156); put('ustar\0', 257); put('00', 263); put(prefix, 345);
|
|
var sum = 0; for (var i = 0; i < 512; i++) sum += h[i];
|
|
put(oct(sum, 7) + ' ', 148);
|
|
blocks.push(h); total += 512;
|
|
var pad = new Uint8Array(Math.ceil(e.data.length / 512) * 512); pad.set(e.data);
|
|
blocks.push(pad); total += pad.length;
|
|
});
|
|
blocks.push(new Uint8Array(1024)); total += 1024;
|
|
var out = new Uint8Array(total), o = 0;
|
|
blocks.forEach(function (b) { out.set(b, o); o += b.length; });
|
|
return out;
|
|
}
|
|
function untar(bytes) { // -> [{path, data}]
|
|
var dec = new TextDecoder(), out = [], o = 0;
|
|
var str = function (a, b) { var s = dec.decode(bytes.subarray(a, b)); var z = s.indexOf('\0'); return z >= 0 ? s.slice(0, z) : s; };
|
|
while (o + 512 <= bytes.length) {
|
|
if (bytes[o] === 0) break;
|
|
var name = str(o, o + 100), size = parseInt(str(o + 124, o + 136).trim() || '0', 8), type = str(o + 156, o + 157);
|
|
var prefix = str(o + 345, o + 500);
|
|
if (prefix) name = prefix + '/' + name;
|
|
o += 512;
|
|
if (type === '0' || type === '') out.push({ path: name, data: bytes.slice(o, o + size) });
|
|
o += Math.ceil(size / 512) * 512;
|
|
}
|
|
return out;
|
|
}
|
|
function walk(FS, dir, rel, out) {
|
|
FS.readdir(dir).forEach(function (n) {
|
|
if (n === '.' || n === '..') return;
|
|
var p = dir + '/' + n, r = rel ? rel + '/' + n : n, st = FS.stat(p);
|
|
if (FS.isDir(st.mode)) { if (r !== 'littlefs/tiles') walk(FS, p, r, out); }
|
|
else out.push({ path: r, data: FS.readFile(p) });
|
|
});
|
|
}
|
|
function backupBrowser() {
|
|
if (!M || !M.FS || !running) return;
|
|
try { M._wadaWebFlush(); } catch (e) {}
|
|
var files = [];
|
|
walk(M.FS, dataDir, '', files);
|
|
var who = choice ? (choice.name || 'node') : 'demo';
|
|
files.unshift({ path: TAR_MARK, data: new TextEncoder().encode(JSON.stringify({
|
|
kind: 'wadamesh-browser-backup', version: 1, backend: choice ? choice.kind : 'demo', node: choice ? (choice.name || '') : '',
|
|
dataDir: dataDir, created: new Date().toISOString(), build: BUILD, files: files.length }, null, 1)) });
|
|
var day = new Date().toISOString().slice(0, 10);
|
|
download(tarFiles(files), 'wadamesh-browser-' + who.replace(/[^A-Za-z0-9_-]+/g, '-') + '-' + day + '.tar', 'application/x-tar');
|
|
toast('Saved ' + (files.length - 1) + ' files. Restore them from this menu in any browser.');
|
|
}
|
|
function restoreBrowser(bytes, fname) {
|
|
var files = untar(bytes), mark = null;
|
|
files.forEach(function (f) { if (f.path === TAR_MARK) { try { mark = JSON.parse(new TextDecoder().decode(f.data)); } catch (e) {} } });
|
|
if (!mark || mark.kind !== 'wadamesh-browser-backup') { toast(fname + ' is not a wadamesh browser backup.'); return; }
|
|
var into = choice ? 'your node\'s data in this browser' : 'the demo';
|
|
if (!confirm('Restore ' + fname + '? It replaces ' + into + ' (messages, contacts, settings, apps), then restarts.')) return;
|
|
if (!M || !M.FS) return;
|
|
var FS = M.FS;
|
|
resetting = true; // nothing else writes or saves from here
|
|
rmrf(FS, dataDir);
|
|
FS.mkdir(dataDir);
|
|
files.forEach(function (f) {
|
|
if (f.path === TAR_MARK || /(^|\/)\.\.(\/|$)/.test(f.path) || f.path[0] === '/') return;
|
|
var parts = f.path.split('/'), d = dataDir;
|
|
for (var i = 0; i < parts.length - 1; i++) { d += '/' + parts[i]; if (!exists(FS, d)) FS.mkdir(d); }
|
|
FS.writeFile(dataDir + '/' + f.path, f.data);
|
|
});
|
|
setStatus('Restoring', '', true);
|
|
FS.syncfs(false, function () { location.reload(); });
|
|
}
|
|
var moreBtn = $('btn-more'), moreMenu = $('more-menu');
|
|
function menuOpen(on) { moreMenu.hidden = !on; moreBtn.setAttribute('aria-expanded', on ? 'true' : 'false'); }
|
|
moreBtn.addEventListener('click', function (e) { e.stopPropagation(); menuOpen(moreMenu.hidden); });
|
|
document.addEventListener('click', function (e) { if (!moreMenu.hidden && !moreMenu.contains(e.target)) menuOpen(false); });
|
|
$('btn-backup').addEventListener('click', function () { menuOpen(false); backupBrowser(); });
|
|
$('btn-restore').addEventListener('click', function () { menuOpen(false); pickFile('.tar,application/x-tar', 'restore'); });
|
|
|
|
var fsBtn = $('btn-full');
|
|
var canFull = stage.requestFullscreen || stage.webkitRequestFullscreen;
|
|
if (!canFull) fsBtn.hidden = true;
|
|
fsBtn.addEventListener('click', function () {
|
|
var full = document.fullscreenElement || document.webkitFullscreenElement;
|
|
if (full) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
|
|
else (stage.requestFullscreen || stage.webkitRequestFullscreen).call(stage);
|
|
});
|
|
|
|
// ---- boot --------------------------------------------------------------------------------
|
|
if (typeof WebAssembly !== 'object') {
|
|
fatal('This browser cannot run wadamesh', 'It needs WebAssembly. Any current Chrome, Edge, Firefox or Safari has it.');
|
|
return;
|
|
}
|
|
// Two bundles (WEB_BUILD.md "Two copies"): with cross-origin isolation (https, or a
|
|
// localhost dev server) the threaded one next to the page; without it (the http copy,
|
|
// which reaches a node's ws:// over Wi-Fi) the no-threads one in nt/.
|
|
var isolated = !!window.crossOriginIsolated && typeof SharedArrayBuffer !== 'undefined';
|
|
// ?nt forces the no-threads build (tests: the http copy's engine on any page)
|
|
var BUNDLE = isolated && !/[?&]nt\b/.test(location.search) ? '' : 'nt/';
|
|
var devHost = /^(localhost|127\.0\.0\.1|\[::1\])$/.test(location.hostname);
|
|
function cannotLoad() {
|
|
if (devHost) {
|
|
fatal('Could not load wadamesh', 'The ' + (isolated ? 'threaded' : 'no-threads') + ' bundle (<code>' + BUNDLE +
|
|
'wadamesh.js</code>) did not load. <code>web/build.sh web</code> builds both; <code>web/build.sh serve</code> serves ' +
|
|
'the no-threads one at <code>nt/</code>.', true);
|
|
} else {
|
|
fatal('wadamesh could not start here', 'This browser blocks something the web client needs. Try a current ' +
|
|
'Chrome, Edge, Firefox or Safari.', true);
|
|
}
|
|
}
|
|
|
|
var params = new URLSearchParams(location.search);
|
|
var wantReset = params.get('reset') === '1';
|
|
if (wantReset) {
|
|
params.delete('reset');
|
|
var q = params.toString();
|
|
history.replaceState(null, '', location.pathname + (q ? '?' + q : '') + location.hash);
|
|
}
|
|
|
|
// Each backend boots on its own directory under /data: /data/demo, /data/node-<key>.
|
|
function exists(FS, p) { try { FS.stat(p); return true; } catch (e) { return false; } }
|
|
function rmrf(FS, p) {
|
|
var st; try { st = FS.stat(p); } catch (e) { return; }
|
|
if (FS.isDir(st.mode)) {
|
|
FS.readdir(p).forEach(function (n) { if (n !== '.' && n !== '..') rmrf(FS, p + '/' + n); });
|
|
FS.rmdir(p);
|
|
} else FS.unlink(p);
|
|
}
|
|
function prepareDataDirs(FS) {
|
|
var demo = DB_MOUNT + '/demo';
|
|
// Before the node backends the demo lived at the top of /data: move it down.
|
|
if (!exists(FS, demo)) {
|
|
var old = FS.readdir(DB_MOUNT).filter(function (n) { return n !== '.' && n !== '..' && !/^(demo|node-.*)$/.test(n); });
|
|
if (old.length) {
|
|
FS.mkdir(demo);
|
|
old.forEach(function (n) { FS.rename(DB_MOUNT + '/' + n, demo + '/' + n); });
|
|
}
|
|
}
|
|
if (wantReset) rmrf(FS, demo); // "Reset demo data": only the demo's
|
|
// A node connected the first time ran on node-new until its key was known.
|
|
if (choice && choice.key) {
|
|
var fresh = DB_MOUNT + '/node-new', mine = nodeDir(choice);
|
|
if (exists(FS, fresh)) { if (exists(FS, mine)) rmrf(FS, fresh); else FS.rename(fresh, mine); }
|
|
}
|
|
if (!exists(FS, dataDir)) FS.mkdir(dataDir);
|
|
}
|
|
|
|
function mountStorage(Mod) {
|
|
var FS = Mod.FS;
|
|
FS.mkdir(DB_MOUNT);
|
|
FS.mount(Mod.IDBFS || FS.filesystems.IDBFS, {}, DB_MOUNT);
|
|
Mod.addRunDependency('wada-idbfs');
|
|
var load = function () {
|
|
FS.syncfs(true, function (err) {
|
|
if (err) {
|
|
console.warn('[web] IndexedDB unavailable, running without saving', err);
|
|
toast('This browser does not allow saving here (private window?). The demo works, but resets on reload.');
|
|
}
|
|
try { prepareDataDirs(FS); } catch (e) { console.warn('[web] data dirs', e); }
|
|
Mod.removeRunDependency('wada-idbfs');
|
|
});
|
|
};
|
|
load();
|
|
}
|
|
|
|
setStatus('Loading wadamesh', '', true);
|
|
var script = document.createElement('script');
|
|
script.src = BUNDLE + 'wadamesh.js?v=' + BUILD;
|
|
script.onerror = cannotLoad;
|
|
script.onload = function () {
|
|
var t0 = performance.now();
|
|
if (fill) { // the viewport has had the time to settle: size the UI from it
|
|
pickSize();
|
|
canvas.width = W; canvas.height = H;
|
|
layout();
|
|
}
|
|
var Mod = {
|
|
locateFile: function (path) { return BUNDLE + path + '?v=' + BUILD; },
|
|
print: function (t) { console.log(t); },
|
|
printErr: function (t) { console.warn(t); },
|
|
preRun: [function () { setStatus('Starting', '', true); mountStorage(Mod); }],
|
|
onRuntimeInitialized: function () {
|
|
stats.bootMs = Math.round(performance.now() - t0);
|
|
// The map's tile downloads need the network (web/compat/wada_http.h wadaNetOnline).
|
|
if (touchDevice) Mod._wadaWebNativeKeyboard(1); // the phone's keyboard, not the UI's keys
|
|
var online = function () { try { Mod._wadaWebOnline(navigator.onLine === false ? 0 : 1); } catch (e) {} };
|
|
online();
|
|
window.addEventListener('online', online);
|
|
window.addEventListener('offline', online);
|
|
},
|
|
onAbort: function (what) {
|
|
fatal('wadamesh stopped', 'Something went wrong: <code>' + String(what).replace(/</g, '<') + '</code>', true);
|
|
},
|
|
wadaPresent: present,
|
|
wadaSize: [W, H],
|
|
wadaDownload: function (bytes, name) { download(bytes, name, /\.json$/i.test(name) ? 'application/json' : undefined); },
|
|
wadaPickFile: function (accept) { pickFile(accept, 'ui'); },
|
|
wadaGeoStart: geoStart,
|
|
wadaGeoStop: geoStop,
|
|
// which mesh to boot on (web_main.cpp reads it before the UI starts)
|
|
wadaBackend: choice ? { kind: choice.kind, arg: choice.arg, dataDir: dataDir }
|
|
: { kind: 'demo', dataDir: dataDir },
|
|
wadaLink: function (state, name, via, key) { onLink(state, name, via, key); },
|
|
wadaCompanionState: function (up, detail) { if (!up) linkDetail = detail; linkTrouble(up, detail); },
|
|
wadaCompanionNeedsGesture: function (kind) {
|
|
$('btn-pair').hidden = false;
|
|
$('btn-pair').textContent = kind === 'ble' ? 'Pair again' : 'Reconnect';
|
|
setMode('wait', kind === 'ble' ? 'Bluetooth: tap Pair again' : 'USB: tap Reconnect');
|
|
},
|
|
wadaReboot: function () {
|
|
// esp_restart(): the UI flushed what a device flushes; save, then start over.
|
|
running = false;
|
|
setStatus('Restarting', '', true);
|
|
persist(function () { location.reload(); });
|
|
},
|
|
};
|
|
createWadamesh(Mod).then(function (m) { M = m; }, function (e) {
|
|
fatal('wadamesh could not start', String(e).replace(/</g, '<'), true);
|
|
});
|
|
// The module's own functions are reachable once its runtime is up (main runs then).
|
|
M = Mod;
|
|
};
|
|
document.head.appendChild(script);
|
|
|
|
// For tests and the curious: window.wadamesh.stats, .persist(), .idle()
|
|
window.wadamesh = {
|
|
build: BUILD,
|
|
threads: function () { return isolated; },
|
|
stats: stats,
|
|
storage: persistState,
|
|
backend: function () { return choice; },
|
|
dataDir: dataDir,
|
|
persist: function () { return new Promise(function (res) { persist(res); }); },
|
|
flush: function () { return new Promise(function (res) { flushNow(); persist(res); }); },
|
|
running: function () { return running; },
|
|
geo: function () { return { want: geo.want, watching: geo.watch !== null, waitingTap: geo.waitingTap }; },
|
|
_m: function () { return M; }, // the module, for tests
|
|
// Is `needle` (text, compared as UTF-8 bytes) in any saved file under /data? (the
|
|
// persistence tests)
|
|
findInStorage: function (needle) {
|
|
if (!M || !M.FS) return false;
|
|
var FS = M.FS, hits = [], nb = new TextEncoder().encode(needle);
|
|
var has = function (b) {
|
|
outer: for (var i = 0; i + nb.length <= b.length; i++) {
|
|
if (b[i] !== nb[0]) continue;
|
|
for (var j = 1; j < nb.length; j++) if (b[i + j] !== nb[j]) continue outer;
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
(function walk(dir) {
|
|
FS.readdir(dir).forEach(function (n) {
|
|
if (n === '.' || n === '..') return;
|
|
var p = dir + '/' + n, st = FS.stat(p);
|
|
if (FS.isDir(st.mode)) walk(p);
|
|
else if (has(FS.readFile(p))) hits.push(p);
|
|
});
|
|
})(DB_MOUNT);
|
|
return hits;
|
|
},
|
|
// The middle of the first visible LVGL label showing `text` (canvas pixels), or null:
|
|
// lets the phone tests tap by name at any canvas size (web/platform/WebLvglInput.cpp).
|
|
findText: function (text) {
|
|
if (!M || !running) return null;
|
|
var v = M.ccall('wadaWebFindText', 'number', ['string'], [text]);
|
|
return v < 0 ? null : [v >> 16, v & 0xffff];
|
|
},
|
|
// The text of the LVGL field being edited (tests: what the phone keyboard typed).
|
|
editText: function () { return M && running ? M.ccall('wadaWebEditText', 'string', [], []) : ''; },
|
|
size: function () { return [W, H]; },
|
|
};
|
|
})();
|