Files

708 lines
36 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WADAMESH Files</title>
<meta name="description" content="Browse, upload and download the files on your wadamesh device over a USB cable.">
<link rel="icon" href="https://wadamesh.com/wadamesh-badge.svg">
<meta name="theme-color" content="#ffffff">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@600;700&display=swap" rel="stylesheet">
<script>
if (location.protocol === 'http:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1')
location.replace(location.href.replace(/^http:/, 'https:'));
</script>
<style>
:root{--bg:#fff;--bg2:#f4f6f2;--ink:#15181e;--mut:#5d6770;--line:#e5e8e2;--teal:#15b6a6;--cyan:#19d6c2;
--teal-ink:#0e8d80;--warn:#b4542d;--warn-bg:#fdf1ec;--risk:#b3261e;--risk-bg:#fdecea;color-scheme:light}
*{box-sizing:border-box;margin:0;padding:0}
[hidden]{display:none!important}
body{font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);
min-height:100vh;display:flex;flex-direction:column;align-items:center;-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;inset:-25% -10% auto -10%;height:60vh;z-index:-1;
background:radial-gradient(44vw 36vh at 50% 0%,rgba(25,214,194,.14),transparent 62%)}
a{color:var(--teal-ink);text-decoration:none}
.mono{font-family:'JetBrains Mono',ui-monospace,monospace}
header{text-align:center;padding:44px 20px 4px}
header svg{width:92px;height:auto}
h1{font-family:'JetBrains Mono',ui-monospace,monospace;font-weight:700;letter-spacing:3px;font-size:clamp(1.5rem,5vw,2.1rem);margin-top:6px}
h1 span{color:var(--teal)}
.tag{color:var(--mut);margin-top:6px}
main{width:100%;max-width:880px;padding:22px 16px 60px}
.card{border:1px solid var(--line);border-radius:14px;background:#fff;padding:22px;box-shadow:0 18px 40px -30px rgba(21,24,30,.35)}
.steps{padding-left:22px;margin:0 0 18px;color:var(--ink)}
.steps li{margin:4px 0}
button{font:inherit;cursor:pointer;border-radius:9px;border:1px solid var(--line);background:#fff;color:var(--ink);
padding:8px 14px;transition:border-color .15s,background .15s,color .15s}
button:hover:not(:disabled){border-color:var(--teal);color:var(--teal-ink)}
button:disabled{opacity:.45;cursor:default}
button.primary{background:var(--teal);border-color:var(--teal);color:#fff;font-weight:600;padding:10px 22px}
button.primary:hover:not(:disabled){background:var(--teal-ink);border-color:var(--teal-ink);color:#fff}
button.small{padding:4px 10px;font-size:.85rem}
button.danger:hover:not(:disabled){border-color:var(--warn);color:var(--warn)}
.linkish{background:none;border:none;padding:0;color:var(--teal-ink);text-decoration:underline;font-size:.88rem}
.status{margin-top:12px;color:var(--mut);min-height:1.5em}
.status.err{color:var(--warn)}
.notice{border:1px solid #f3c9b8;background:var(--warn-bg);color:var(--warn);border-radius:12px;padding:14px 16px;margin-bottom:16px}
.devbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.devbar .who{flex:1;min-width:200px}
.devbar .who b{font-size:1.05rem}
.devbar .who span{color:var(--mut);font-size:.88rem;margin-left:8px}
.roots{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
.roots button{border-radius:100px;padding:6px 16px}
.roots button.on{background:var(--ink);border-color:var(--ink);color:#fff}
.roots button.off{color:var(--mut)}
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.crumbs{flex:1;min-width:220px;font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.9rem;word-break:break-all}
.crumbs button{border:none;background:none;padding:2px 3px;color:var(--teal-ink)}
.crumbs button:last-child{color:var(--ink);font-weight:600}
.opts{display:flex;align-items:center;gap:6px;color:var(--mut);font-size:.88rem}
.hint{color:var(--mut);font-size:.86rem;margin:2px 0 10px}
.space{display:flex;align-items:center;gap:10px;color:var(--mut);font-size:.86rem;margin-bottom:10px}
.meter{flex:0 0 160px;height:6px;border-radius:6px;background:var(--bg2);overflow:hidden}
.meter i{display:block;height:100%;background:var(--teal)}
.list{border:1px solid var(--line);border-radius:12px;overflow:hidden;position:relative;min-height:120px;background:#fff}
.list.drag{outline:2px dashed var(--teal);outline-offset:-6px;background:#f5fbfa}
.row{display:grid;grid-template-columns:26px 1fr auto auto;gap:10px;align-items:center;padding:8px 12px;border-top:1px solid var(--line)}
.row:first-child{border-top:none}
.row:hover{background:#fafbf9}
.row .ico{text-align:center;color:var(--mut)}
.row .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.row .name button{border:none;background:none;padding:0;color:var(--ink);font-weight:600;text-align:left}
.row .name button:hover{color:var(--teal-ink)}
.row .size{color:var(--mut);font-size:.85rem;font-variant-numeric:tabular-nums;text-align:right;min-width:70px}
.row .acts{display:flex;gap:6px;justify-content:flex-end}
.row .lock{color:var(--mut);font-size:.8rem;white-space:nowrap}
.empty{padding:28px;text-align:center;color:var(--mut)}
.xfer{margin-top:14px}
.xfer .line{display:flex;justify-content:space-between;gap:10px;font-size:.9rem;margin-bottom:6px}
.xfer .line span:last-child{color:var(--mut);font-variant-numeric:tabular-nums}
.bar{height:8px;border-radius:8px;background:var(--bg2);overflow:hidden}
.bar i{display:block;height:100%;width:0;background:var(--teal);transition:width .15s}
.xfer .foot{display:flex;justify-content:space-between;align-items:center;margin-top:8px;color:var(--mut);font-size:.86rem}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ink);color:#fff;padding:10px 16px;
border-radius:10px;font-size:.9rem;max-width:min(560px,92vw);box-shadow:0 12px 30px -12px rgba(0,0,0,.4);opacity:0;
pointer-events:none;transition:opacity .2s}
.toast.show{opacity:1}
.toast.err{background:var(--warn)}
.row.risk .name button{color:var(--risk)}
.risk-tag{display:inline-block;margin-left:8px;padding:1px 8px;border-radius:100px;background:var(--risk-bg);
color:var(--risk);border:1px solid #f3c1bc;font-size:.72rem;font-weight:600;vertical-align:1px;cursor:help}
button.risky{border-color:#f3c1bc;color:var(--risk)}
button.risky:hover:not(:disabled){background:var(--risk);border-color:var(--risk);color:#fff}
dialog.risk{border:none;border-radius:14px;padding:0;max-width:min(470px,92vw);margin:auto;
box-shadow:0 30px 60px -20px rgba(0,0,0,.45)}
dialog.risk::backdrop{background:rgba(21,24,30,.5)}
dialog.risk .hd{background:var(--risk);color:#fff;padding:14px 18px;font-weight:700}
dialog.risk .bd{padding:16px 18px 6px}
dialog.risk .bd p{margin:0 0 10px}
dialog.risk .ft{display:flex;justify-content:flex-end;gap:8px;padding:6px 18px 16px}
.boards{color:var(--mut);font-size:.85rem;line-height:1.45;margin:0 0 14px}
footer{color:var(--mut);font-size:.85rem;padding:0 20px 36px;text-align:center}
@media(max-width:560px){.row{grid-template-columns:22px 1fr auto}.row .size{display:none}}
</style>
</head>
<body>
<header>
<svg viewBox="0 0 180 120" fill="none" aria-hidden="true">
<path d="M10 60 L50 108 L90 60 L130 108 L170 60" stroke="#15181E" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 60 L50 12 L90 60 L130 12 L170 60" stroke="#15181E" stroke-width="9" stroke-linecap="round" stroke-linejoin="round"/>
<g fill="#19d6c2"><circle cx="10" cy="60" r="9"/><circle cx="50" cy="12" r="9"/><circle cx="50" cy="108" r="9"/>
<circle cx="90" cy="60" r="9"/><circle cx="130" cy="12" r="9"/><circle cx="130" cy="108" r="9"/><circle cx="170" cy="60" r="9"/></g>
</svg>
<h1>WADAMESH <span>FILES</span></h1>
<p class="tag">The SD card and internal storage of your device, over a USB cable.</p>
</header>
<main>
<div id="unsupported" class="notice" hidden>
This page needs <b>Chrome, Edge or Opera on a computer</b>. Your browser cannot talk to a USB serial device (no Web Serial).
</div>
<section id="start" class="card">
<ol class="steps">
<li>On the device, open <b>USB Files</b> in the app drawer.</li>
<li>Connect it to this computer with a USB cable.</li>
<li>Click <b>Connect</b> and choose the device.</li>
</ol>
<p class="boards">Works on devices whose USB socket is the ESP32-S3's own: LilyGo T-Deck and T-Deck Plus, Heltec V4 and V4-R8, T-LoRa Pager, RAK WisMesh Tap V2 and Wio Tracker L2 (wadamesh beta_99 or later for all but the T-Deck and Heltec V4). Not yet on devices with a separate USB-serial chip (ThinkNode M9, CrowPanel, SenseCAP Indicator, Attaky) or on the T-Deck Pro and Max, T-Display P4 and Tanmatsu; on most of those the Transfer app does the same over Wi-Fi.</p>
<button id="connect" class="primary">Connect</button>
<button id="connect-any" class="linkish" style="margin-left:12px">My device is not in the list</button>
<p id="start-status" class="status"></p>
</section>
<section id="browser" class="card" hidden>
<div class="devbar">
<div class="who"><b id="dev-name"></b><span id="dev-meta"></span></div>
<button id="disconnect" class="small">Disconnect</button>
</div>
<div class="roots" id="roots"></div>
<div class="toolbar">
<div class="crumbs" id="crumbs"></div>
<button id="up-files" class="small">Upload files</button>
<button id="up-folder" class="small">Upload folder</button>
<button id="new-folder" class="small">New folder</button>
<button id="refresh" class="small">Refresh</button>
</div>
<div class="toolbar">
<label class="opts"><input type="checkbox" id="replace"> Replace files that already exist</label>
</div>
<p class="hint" id="hint"></p>
<div class="space" id="space" hidden><div class="meter"><i id="space-bar"></i></div><span id="space-text"></span></div>
<div class="list" id="list"></div>
<div class="xfer" id="xfer" hidden>
<div class="line"><span id="xfer-name"></span><span id="xfer-bytes"></span></div>
<div class="bar"><i id="xfer-bar"></i></div>
<div class="foot"><span id="xfer-count"></span><button id="xfer-cancel" class="small danger">Cancel</button></div>
</div>
</section>
</main>
<footer>Open source (GPL-3.0) · <a href="https://github.com/ALLFATHER-BV/wadamesh">github.com/ALLFATHER-BV/wadamesh</a> · <a href="https://wadamesh.com">wadamesh.com</a></footer>
<input type="file" id="pick-files" multiple hidden>
<input type="file" id="pick-folder" webkitdirectory multiple hidden>
<div class="toast" id="toast"></div>
<dialog class="risk" id="risk">
<div class="hd">&#9888; Risky file: likely Windows malware</div>
<div class="bd">
<p><b id="risk-name"></b> is <b id="risk-kind"></b>, the kind of file SD Scan on the device removes.</p>
<p>Files like this are how SD card worms spread: opening one on a Windows PC can infect that PC. A mesh radio never uses them.</p>
<p>Safer: delete it here, or scan the card with SD Scan on the device.</p>
</div>
<div class="ft"><button id="risk-go" class="small risky">Download anyway</button><button id="risk-cancel" class="primary">Don't download</button></div>
</dialog>
<script>
'use strict';
// USB Files protocol (src/helpers/esp32/UsbFilesProtocol.h in the firmware repo).
// Frame: E7 5A | type | seq u16 | len u16 | payload | crc32 u32, little-endian.
const T = {HELLO:1, LIST:2, STAT:3, READ:4, WBEGIN:5, WDATA:6, WEND:7, REMOVE:8, MKDIR:9, RENAME:10,
SPACE:11, ABORT:12, PING:13, BEACON:0x40, BYE:0x41, REPLY:0x80};
const S = {OK:0, NOT_FOUND:2, EXISTS:3, PROTECTED:4, NO_SPACE:5, BAD_PATH:7, BAD_OFFSET:11, NO_MEDIA:13, NOT_EMPTY:14};
const STATUS_TEXT = {1:'bad request', 2:'not found', 3:'already exists', 4:'read-only on the device', 5:'not enough space',
6:'storage error', 7:'name not allowed', 8:'busy', 9:'too big', 10:'checksum mismatch', 11:'out of step',
12:'not supported', 13:'no SD card', 14:'folder not empty'};
const MAX_PAYLOAD = 4096 + 256;
// SD Scan's verdicts (SdThreat::Kind in the firmware), as the device reports them.
const RISK = {1: 'an autorun file', 2: 'a Windows program', 3: 'a Windows script', 4: 'a Windows shortcut', 5: 'a renamed Windows program'};
const enc = new TextEncoder(), dec = new TextDecoder();
const $ = id => document.getElementById(id);
const CRC = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); CRC[n] = c >>> 0; }
const crcUpdate = (c, a) => { for (let i = 0; i < a.length; i++) c = CRC[(c ^ a[i]) & 255] ^ (c >>> 8); return c >>> 0; };
const crc32 = a => (crcUpdate(0xFFFFFFFF, a) ^ 0xFFFFFFFF) >>> 0;
function frame(type, seq, payload) {
const out = new Uint8Array(7 + payload.length + 4), dv = new DataView(out.buffer);
out[0] = 0xE7; out[1] = 0x5A; out[2] = type; dv.setUint16(3, seq, true); dv.setUint16(5, payload.length, true);
out.set(payload, 7);
dv.setUint32(7 + payload.length, crc32(out.subarray(2, 7 + payload.length)), true);
return out;
}
function concat(...parts) {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0; for (const p of parts) { out.set(p, o); o += p.length; } return out;
}
const le32 = v => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v, true); return b; };
const le16 = v => { const b = new Uint8Array(2); new DataView(b.buffer).setUint16(0, v, true); return b; };
class DeviceError extends Error {
constructor(status, body) {
super(status === S.BAD_OFFSET ? STATUS_TEXT[status] : (dec.decode(body) || STATUS_TEXT[status] || 'error ' + status));
this.status = status; this.body = body;
}
}
class Link {
constructor(port) {
this.port = port; this.buf = new Uint8Array(0); this.seq = 1 + Math.floor(Math.random() * 60000);
this.pending = null; this.chain = Promise.resolve(); this.closed = false; this.lastIo = 0;
this.onBeacon = () => {}; this.onBye = () => {}; this.onClose = () => {};
}
async open() {
await this.port.open({baudRate: 115200, bufferSize: 1 << 16});
// DTR and RTS together, in one request: the chip's own USB serial resets the
// board on RTS without DTR, and TinyUSB only sends once DTR is up.
try { await this.port.setSignals({dataTerminalReady: true, requestToSend: true}); } catch (_) {}
this.writer = this.port.writable.getWriter();
this.readLoop();
}
async readLoop() {
try {
this.reader = this.port.readable.getReader();
for (;;) {
const {value, done} = await this.reader.read();
if (done) break;
if (value && value.length) this.feed(value);
}
} catch (_) { /* cable pulled or port closed */ }
this.shutdown();
}
feed(bytes) {
this.buf = this.buf.length ? concat(this.buf, bytes) : bytes;
for (;;) {
const b = this.buf;
let i = 0;
while (i + 1 < b.length && !(b[i] === 0xE7 && b[i + 1] === 0x5A)) i++;
if (i + 1 >= b.length) { this.buf = b.length && b[b.length - 1] === 0xE7 ? b.slice(-1) : new Uint8Array(0); return; }
if (i) { this.buf = b.slice(i); continue; }
if (b.length < 7) return;
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
const len = dv.getUint16(5, true);
if (len > MAX_PAYLOAD) { this.buf = b.slice(1); continue; }
if (b.length < 11 + len) return;
if (dv.getUint32(7 + len, true) !== crc32(b.subarray(2, 7 + len))) { this.buf = b.slice(1); continue; }
const type = b[2], seq = dv.getUint16(3, true), payload = b.slice(7, 7 + len);
this.buf = b.slice(11 + len);
this.dispatch(type, seq, payload);
}
}
dispatch(type, seq, payload) {
this.lastIo = Date.now();
if (type === T.BEACON) { this.gone = false; try { this.onBeacon(JSON.parse(dec.decode(payload))); } catch (_) {} return; }
if (type === T.BYE) {
// From here on the port belongs to the companion link again: send nothing
// until the device announces USB Files anew.
this.gone = true;
if (this.pending) { this.pending.reject(new Error('USB Files was closed on the device')); this.pending = null; }
this.onBye(); return;
}
const p = this.pending;
if (p && type === (p.type | T.REPLY) && seq === p.seq) { this.pending = null; p.resolve(payload); }
}
// One request at a time: the device's USB serial drops bytes it has no room for.
request(type, payload = new Uint8Array(0), {timeout = 3000, retries = 3} = {}) {
const run = async () => {
if (this.closed) throw new Error('Disconnected');
if (this.gone) throw new Error('USB Files was closed on the device');
this.seq = this.seq % 65535 + 1;
const seq = this.seq, bytes = frame(type, seq, payload);
for (let attempt = 0; attempt <= retries; attempt++) {
const reply = new Promise((resolve, reject) => { this.pending = {type, seq, resolve, reject}; });
this.lastIo = Date.now();
await this.writer.write(bytes);
const timer = new Promise(r => setTimeout(() => r(null), timeout));
const got = await Promise.race([reply, timer]);
if (got) {
if (got[0] !== S.OK) throw new DeviceError(got[0], got.subarray(1));
return got.subarray(1);
}
if (this.closed || this.gone) break;
}
this.pending = null;
throw new Error('The device stopped answering');
};
const p = this.chain.then(run, run);
this.chain = p.catch(() => {});
return p;
}
shutdown() {
if (this.closed) return;
this.closed = true;
if (this.pending) { this.pending.reject(new Error('Disconnected')); this.pending = null; }
this.onClose();
}
async close() {
this.closed = true;
try { await this.reader?.cancel(); } catch (_) {}
try { this.reader?.releaseLock(); } catch (_) {}
try { this.writer?.releaseLock(); } catch (_) {}
try { await this.port.close(); } catch (_) {}
}
// ---- operations ----
async hello() { return JSON.parse(dec.decode(await this.request(T.HELLO, new Uint8Array([1])))); }
async list(path) {
const out = [], seen = new Set();
for (let start = 0; start >= 0;) {
const r = JSON.parse(dec.decode(await this.request(T.LIST, concat(le32(start), le16(64), enc.encode(path)), {timeout: 8000})));
for (const [name, dir, size, w, t] of r.e) {
const key = name + (dir ? '/' : '');
if (!seen.has(key)) { seen.add(key); out.push({name, dir: !!dir, size, w: !!w, t: t | 0}); }
}
start = r.n;
}
return out;
}
async stat(path) { return JSON.parse(dec.decode(await this.request(T.STAT, enc.encode(path)))); }
async space(root) { return JSON.parse(dec.decode(await this.request(T.SPACE, enc.encode(root)))); }
remove(path) { return this.request(T.REMOVE, enc.encode(path), {timeout: 8000}); }
mkdir(path) { return this.request(T.MKDIR, enc.encode(path)); }
rename(from, to) { const f = enc.encode(from); return this.request(T.RENAME, concat(le16(f.length), f, enc.encode(to)), {timeout: 8000}); }
abort() { return this.gone ? Promise.resolve() : this.request(T.ABORT).catch(() => {}); }
}
// ---- page state ----
let link = null, info = null, root = null, cwd = '/', entries = [], busy = false, cancelled = false, pingTimer = 0;
const rootsById = {};
const HINTS = {
sd: 'The meshcomod folder is the device\'s own data. You can download it, but not change it here. The one exception is meshcomod/apps, which takes Lua apps (.lua and .json files).',
internal: 'Files the device keeps in use (identity, contacts, settings, chat history) are download-only. Put your own files in the transfer folder; settings backups (meshcore-*.json) go in the top folder, Lua apps (.lua and .json) in the apps folder.',
tiles: 'Offline map tiles, stored as zoom/x/y.png. Upload a tile folder to use it on the map.',
};
function toast(text, err = false) {
const t = $('toast'); t.textContent = text; t.className = 'toast show' + (err ? ' err' : '');
clearTimeout(t._h); t._h = setTimeout(() => { t.className = 'toast'; }, err ? 5200 : 2800);
}
function setStart(text, err = false) { $('start-status').textContent = text; $('start-status').className = 'status' + (err ? ' err' : ''); }
function human(n) {
if (n < 1024) return n + ' B';
const u = ['KB', 'MB', 'GB']; let i = -1; do { n /= 1024; i++; } while (n >= 1024 && i < 2);
return n.toFixed(n < 10 ? 1 : 0) + ' ' + u[i];
}
const join = (dir, name) => (dir === '/' ? '' : dir) + '/' + name;
const vpath = rel => '/' + root + (rel === '/' ? '' : rel);
const errText = e => (e && e.message) || String(e);
function showStart() { $('start').hidden = false; $('browser').hidden = true; }
function showBrowser() { $('start').hidden = true; $('browser').hidden = false; }
if (!('serial' in navigator)) {
$('unsupported').hidden = false; $('connect').disabled = true; $('connect-any').hidden = true;
}
async function connect(filtered) {
let port;
try {
port = await navigator.serial.requestPort(filtered ? {filters: [{usbVendorId: 0x303A}, {usbVendorId: 0x1A86}, {usbVendorId: 0x10C4}]} : {});
} catch (_) { return; } // picker cancelled
if (link) await link.close();
link = new Link(port);
link.onBeacon = beacon => { if (!info && !link.helloing) startSession(beacon); };
link.onBye = () => { stopPing(); info = null; showStart(); setStart('USB Files was closed on the device. Open it again to continue.'); };
link.onClose = () => { stopPing(); info = null; showStart(); setStart('The device was disconnected.', true); };
try { await link.open(); }
catch (e) { setStart('Could not open the port: ' + errText(e) + '. Is another program (the flasher, a serial monitor) using it?', true); link = null; return; }
$('connect').textContent = 'Reconnect';
setStart('Connected to the port. Waiting for USB Files: open it on the device (app drawer).');
}
async function startSession(beacon) {
link.helloing = true;
try {
info = await link.hello();
} catch (e) { setStart('The device did not answer: ' + errText(e), true); link.helloing = false; return; }
link.helloing = false;
$('dev-name').textContent = info.name || 'wadamesh';
$('dev-meta').textContent = [info.board, info.fw].filter(Boolean).join(' · ');
const box = $('roots'); box.textContent = '';
for (const r of info.roots) {
rootsById[r.id] = r;
const b = document.createElement('button');
b.textContent = r.label + (r.ok ? '' : ' (not available)');
b.dataset.id = r.id; b.className = r.ok ? '' : 'off';
b.onclick = () => { if (!busy) openRoot(r.id); };
box.appendChild(b);
}
showBrowser();
startPing();
const first = info.roots.find(r => r.ok);
if (first) openRoot(first.id); else { $('list').innerHTML = '<div class="empty">No storage is available on the device right now.</div>'; }
}
function startPing() {
stopPing();
pingTimer = setInterval(() => {
if (link && !link.closed && !busy && Date.now() - link.lastIo > 2000) link.request(T.PING).catch(() => {});
}, 1000);
}
function stopPing() { clearInterval(pingTimer); pingTimer = 0; }
async function openRoot(id) {
root = id; cwd = '/';
for (const b of $('roots').children) b.classList.toggle('on', b.dataset.id === id);
$('hint').textContent = HINTS[id] || '';
await refresh();
updateSpace();
}
async function updateSpace() {
try {
const s = await link.space('/' + root);
$('space').hidden = false;
$('space-bar').style.width = Math.min(100, s.u / s.t * 100).toFixed(1) + '%';
$('space-text').textContent = human(s.u) + ' used of ' + human(s.t) + (s.r ? ' (' + human(s.r) + ' kept free for the device)' : '');
} catch (_) { $('space').hidden = true; }
}
function renderCrumbs() {
const c = $('crumbs'); c.textContent = '';
const parts = cwd === '/' ? [] : cwd.slice(1).split('/');
const add = (label, rel) => {
const b = document.createElement('button'); b.textContent = label;
b.onclick = () => { if (!busy) { cwd = rel; refresh(); } };
c.appendChild(b);
};
add((rootsById[root] || {}).label || root, '/');
let acc = '';
for (const p of parts) { acc += '/' + p; c.appendChild(document.createTextNode(' / ')); add(p, acc); }
}
async function refresh() {
renderCrumbs();
const list = $('list');
list.innerHTML = '<div class="empty">Reading…</div>';
try {
entries = await link.list(vpath(cwd));
} catch (e) {
list.innerHTML = ''; const d = document.createElement('div'); d.className = 'empty';
d.textContent = 'Could not read this folder: ' + errText(e); list.appendChild(d); return;
}
entries.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}));
renderList();
}
function renderList() {
const list = $('list'); list.textContent = '';
if (cwd !== '/') {
const up = row('↰', '..', '', true);
up.querySelector('.name button').onclick = () => { if (!busy) { cwd = cwd.slice(0, cwd.lastIndexOf('/')) || '/'; refresh(); } };
list.appendChild(up);
}
if (!entries.length) {
const d = document.createElement('div'); d.className = 'empty';
d.textContent = 'This folder is empty. Drop files here, or use Upload.'; list.appendChild(d); return;
}
for (const e of entries) {
const r = row(e.dir ? '📁' : '📄', e.name, e.dir ? '' : human(e.size), e.dir);
const rel = join(cwd, e.name);
if (e.dir) r.querySelector('.name button').onclick = () => { if (!busy) { cwd = rel; refresh(); } };
else r.querySelector('.name button').onclick = () => download(rel, e.name, e.size);
const acts = r.querySelector('.acts');
if (e.t) {
r.classList.add('risk');
const tag = document.createElement('span'); tag.className = 'risk-tag'; tag.textContent = '\u26A0 Malware risk';
tag.title = 'SD Scan flags this as ' + (RISK[e.t] || 'a Windows file') + '. Risky to download: it can infect a Windows PC.';
r.querySelector('.name').appendChild(tag);
}
if (!e.dir) acts.appendChild(btn('Download', () => download(rel, e.name, e.size), e.t ? 'risky' : ''));
if (e.w) {
acts.appendChild(btn('Rename', () => renameEntry(e)));
acts.appendChild(btn('Delete', () => deleteEntry(e), 'danger'));
} else {
const l = document.createElement('span'); l.className = 'lock'; l.textContent = '🔒 in use';
l.title = 'The device keeps this in use. You can download it, but not change it here.';
acts.appendChild(l);
}
list.appendChild(r);
}
}
function row(icon, name, size, isDir) {
const r = document.createElement('div'); r.className = 'row';
r.innerHTML = '<div class="ico"></div><div class="name"><button></button></div><div class="size"></div><div class="acts"></div>';
r.querySelector('.ico').textContent = icon;
r.querySelector('.name button').textContent = name + (isDir && name !== '..' ? '/' : '');
r.querySelector('.size').textContent = size;
return r;
}
function btn(label, fn, cls = '') {
const b = document.createElement('button'); b.className = 'small ' + cls; b.textContent = label;
b.onclick = () => { if (!busy) fn(); }; return b;
}
// ---- transfers ----
function xferShow(on) { $('xfer').hidden = !on; for (const id of ['up-files', 'up-folder', 'new-folder', 'refresh']) $(id).disabled = on; }
function xferUpdate(name, done, total, count) {
$('xfer-name').textContent = name;
$('xfer-bytes').textContent = human(done) + ' / ' + human(total);
$('xfer-bar').style.width = (total ? done / total * 100 : 100).toFixed(1) + '%';
$('xfer-count').textContent = count || '';
}
$('xfer-cancel').onclick = () => { cancelled = true; };
function riskDialog(name, kind) {
return new Promise(resolve => {
const d = $('risk');
$('risk-name').textContent = name;
$('risk-kind').textContent = RISK[kind] || 'a Windows file';
const done = ok => { d.close(); resolve(ok); };
$('risk-cancel').onclick = () => done(false);
$('risk-go').onclick = () => done(true);
d.oncancel = ev => { ev.preventDefault(); done(false); };
d.showModal();
$('risk-cancel').focus();
});
}
async function download(rel, name, size) {
if (busy) return;
// The device checks the name AND the first bytes, so a Windows program under a
// harmless name is caught here too, before a single byte reaches this computer.
try {
const st = await link.stat(vpath(rel));
size = st.s;
if (st.t && !(await riskDialog(name, st.t))) return;
} catch (e) { toast(errText(e), true); return; }
if (busy) return;
busy = true; cancelled = false; xferShow(true);
const chunk = info.chunk || 4096, parts = [];
let got = 0;
try {
while (got < size) {
if (cancelled) throw new Error('Download cancelled');
const body = await link.request(T.READ, concat(le32(got), le16(chunk), enc.encode(vpath(rel))));
const off = new DataView(body.buffer, body.byteOffset).getUint32(0, true);
if (off !== got) throw new Error('The device sent the wrong part of the file');
const data = body.slice(4);
if (!data.length) break;
parts.push(data); got += data.length;
xferUpdate(name, got, size, 'Downloading');
}
const url = URL.createObjectURL(new Blob(parts));
const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 30000);
toast('Downloaded ' + name);
} catch (e) { toast(errText(e), true); }
busy = false; xferShow(false);
}
// items: [{rel: path under cwd ("a/b.png"), file: File}], dirs: set of relative folder paths
async function upload(items, dirs) {
if (busy || !items.length) return;
busy = true; cancelled = false; xferShow(true);
const replace = $('replace').checked, chunk = info.chunk || 4096;
const total = items.reduce((n, it) => n + it.file.size, 0);
let doneBytes = 0, ok = 0, skipped = 0, failed = 0, firstError = '';
// Folders first, parents before children. Internal storage has no real
// folders; the device answers mkdir there without creating anything.
for (const d of [...dirs].sort((a, b) => a.split('/').length - b.split('/').length)) {
if (cancelled) break;
try { await link.mkdir(vpath(join(cwd, d))); } catch (e) { if (e.status !== S.EXISTS) { failed++; firstError = firstError || d + ': ' + errText(e); } }
}
for (let i = 0; i < items.length && !cancelled; i++) {
const {rel, file} = items[i];
const target = vpath(join(cwd, rel));
const count = 'File ' + (i + 1) + ' of ' + items.length + (skipped ? ', ' + skipped + ' skipped' : '');
try {
await link.request(T.WBEGIN, concat(le32(file.size), new Uint8Array([replace ? 1 : 0]), enc.encode(target)));
} catch (e) {
if (e.status === S.EXISTS && !replace) skipped++;
else { failed++; firstError = firstError || rel + ': ' + errText(e); }
doneBytes += file.size; continue;
}
try {
const data = new Uint8Array(await file.arrayBuffer());
let off = 0, crc = 0xFFFFFFFF;
while (off < data.length) {
if (cancelled) throw new Error('cancelled');
const piece = data.subarray(off, Math.min(off + chunk, data.length));
let body;
try { body = await link.request(T.WDATA, concat(le32(off), piece)); }
catch (e) {
if (e.status !== S.BAD_OFFSET || e.body.length < 4) throw e;
body = e.body; // the device says where it wants to continue
off = new DataView(body.buffer, body.byteOffset).getUint32(0, true);
crc = crcUpdate(0xFFFFFFFF, data.subarray(0, off));
continue;
}
crc = crcUpdate(crc, piece);
off = new DataView(body.buffer, body.byteOffset).getUint32(0, true);
xferUpdate(rel, doneBytes + off, total, count);
}
await link.request(T.WEND, le32((crc ^ 0xFFFFFFFF) >>> 0), {timeout: 15000});
ok++;
} catch (e) {
await link.abort();
if (!cancelled) { failed++; firstError = firstError || rel + ': ' + errText(e); }
}
doneBytes += file.size;
xferUpdate(rel, doneBytes, total, count);
}
busy = false; xferShow(false);
const summary = [ok + ' uploaded', skipped && skipped + ' already there (tick Replace to overwrite)', failed && failed + ' failed'].filter(Boolean).join(', ');
if (cancelled) toast('Upload cancelled. ' + summary, true);
else if (failed) toast(summary + '. ' + firstError, true);
else toast(summary);
await refresh(); updateSpace();
}
function filesToItems(files) {
const items = [], dirs = new Set();
for (const f of files) {
const rel = f.webkitRelativePath || f.name;
items.push({rel, file: f});
const parts = rel.split('/'); parts.pop();
for (let i = 1; i <= parts.length; i++) dirs.add(parts.slice(0, i).join('/'));
}
return {items, dirs};
}
$('up-files').onclick = () => $('pick-files').click();
$('up-folder').onclick = () => $('pick-folder').click();
$('pick-files').onchange = e => { const {items, dirs} = filesToItems(e.target.files); e.target.value = ''; upload(items, dirs); };
$('pick-folder').onchange = e => { const {items, dirs} = filesToItems(e.target.files); e.target.value = ''; upload(items, dirs); };
$('refresh').onclick = () => { if (!busy) { refresh(); updateSpace(); } };
$('disconnect').onclick = async () => { stopPing(); info = null; if (link) { await link.close(); link = null; } showStart(); setStart('Disconnected.'); $('connect').textContent = 'Connect'; };
$('connect').onclick = () => connect(true);
$('connect-any').onclick = () => connect(false);
$('new-folder').onclick = async () => {
const name = prompt('New folder name');
if (!name) return;
try { await link.mkdir(vpath(join(cwd, name.trim()))); await refresh(); }
catch (e) { toast('Could not create the folder: ' + errText(e), true); }
};
async function renameEntry(e) {
const name = prompt('New name for ' + e.name, e.name);
if (!name || name === e.name) return;
try { await link.rename(vpath(join(cwd, e.name)), vpath(join(cwd, name.trim()))); await refresh(); }
catch (err) { toast('Could not rename: ' + errText(err), true); }
}
async function deleteEntry(e) {
const rel = join(cwd, e.name);
if (!e.dir) {
if (!confirm('Delete ' + e.name + ' from the device?')) return;
try { await link.remove(vpath(rel)); } catch (err) { toast('Could not delete: ' + errText(err), true); }
await refresh(); updateSpace(); return;
}
if (!confirm('Delete the folder ' + e.name + ' and everything in it?')) return;
busy = true; cancelled = false; xferShow(true);
let n = 0;
const wipe = async dir => {
for (const c of await link.list(vpath(dir))) {
if (cancelled) throw new Error('Delete cancelled');
const p = join(dir, c.name);
if (c.dir) await wipe(p); else { await link.remove(vpath(p)); n++; xferUpdate('Deleting ' + c.name, n, n, n + ' deleted'); }
}
await link.remove(vpath(dir));
};
try { await wipe(rel); toast('Deleted ' + e.name); }
catch (err) { toast(errText(err), true); }
busy = false; xferShow(false);
await refresh(); updateSpace();
}
// Drag and drop, folders included.
const listEl = $('list');
listEl.addEventListener('dragover', ev => { ev.preventDefault(); if (!busy) listEl.classList.add('drag'); });
listEl.addEventListener('dragleave', () => listEl.classList.remove('drag'));
listEl.addEventListener('drop', async ev => {
ev.preventDefault(); listEl.classList.remove('drag');
if (busy || !info) return;
const items = [], dirs = new Set();
const readAll = reader => new Promise(res => { const all = []; const step = () => reader.readEntries(b => { if (!b.length) res(all); else { all.push(...b); step(); } }, () => res(all)); step(); });
const walk = async (entry, prefix) => {
if (entry.isFile) {
const file = await new Promise((res, rej) => entry.file(res, rej));
items.push({rel: prefix + entry.name, file});
} else if (entry.isDirectory) {
dirs.add(prefix + entry.name);
for (const child of await readAll(entry.createReader())) await walk(child, prefix + entry.name + '/');
}
};
const roots = [...ev.dataTransfer.items].map(i => i.webkitGetAsEntry && i.webkitGetAsEntry()).filter(Boolean);
if (roots.length) { for (const r of roots) await walk(r, ''); }
else for (const f of ev.dataTransfer.files) items.push({rel: f.name, file: f});
upload(items, dirs);
});
if ('serial' in navigator) navigator.serial.addEventListener('disconnect', ev => { if (link && ev.target === link.port) link.shutdown(); });
</script>
</body>
</html>