JM Tech Power

jm-tech-power-official/Flash Download

A desktop download manager that opens up to 32 connections against one file and can send each of them out a different proxy route.

Public Uncategorized
Clone jm-tech-power-official/flash-download.jmproj
src/renderer/app.js
'use strict';

/* Flash Download renderer.
   The segment lane strip is the progress indicator everywhere — there is no
   aggregate progress bar, because the slowest lane sets the finish time. */

const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const el = (tag, cls) => { const n = document.createElement(tag); if (cls) n.className = cls; return n; };

const STEPS = [1, 2, 4, 8, 16, 32];
const isMac = navigator.platform.toLowerCase().includes('mac');
const MOD = isMac ? 'Cmd' : 'Ctrl';

const ui = { view: 'active', selected: null, state: null, probe: null, probeSeq: 0, seenStates: new Map() };

// ---- formatting -------------------------------------------------------------

const UNITS = [['B', 1], ['KB', 1024], ['MB', 1024 ** 2], ['GB', 1024 ** 3], ['TB', 1024 ** 4]];
function unitFor(n) {
  let u = UNITS[0];
  for (const candidate of UNITS) if (n >= candidate[1]) u = candidate;
  return u;
}
function size(n) {
  if (n == null) return '—';
  const [name, div] = unitFor(n);
  const v = n / div;
  return `${v.toFixed(div === 1 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0)} ${name}`;
}
function pair(a, b) {
  if (b == null) return size(a);
  const [name, div] = unitFor(b);
  const d = div === 1 ? 0 : 2;
  return `${(Math.min(a, b) / div).toFixed(d)} / ${(b / div).toFixed(d)} ${name}`;
}
function rate(n) {
  if (!n || n < 1) return '0 B/s';
  const [name, div] = unitFor(n);
  const v = n / div;
  return `${v.toFixed(v < 10 ? 2 : v < 100 ? 1 : 0)} ${name}/s`;
}
function eta(seconds) {
  if (seconds == null || !isFinite(seconds)) return '—';
  const s = Math.max(0, Math.round(seconds));
  const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
  return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`
           : `${m}:${String(sec).padStart(2, '0')}`;
}
const pct = (n) => (n == null ? 0 : Math.max(0, Math.min(100, n)));

// ---- status vocabulary ------------------------------------------------------

function statusOf(d) {
  switch (d.state) {
    case 'probing':   return { text: 'Checking the server', cls: 'idle' };
    case 'queued':    return { text: 'Queued', cls: 'idle' };
    case 'paused':    return { text: 'Paused', cls: 'idle' };
    case 'verifying': return { text: `Verifying ${Math.round(pct(d.verify.progress))}%`, cls: 'good' };
    case 'done':      return { text: d.verify && d.verify.hash ? 'Verified' : 'Finished', cls: 'good' };
    case 'cancelled': return { text: 'Cancelled', cls: 'idle' };
    case 'failed':    return { text: d.error || 'Failed', cls: 'bad' };
    default:          return { text: rate(d.rate), cls: '' };
  }
}

// ---- lane strip -------------------------------------------------------------

/** Lanes are keyed by connection id so a running fill is never torn down. */
function updateLanes(host, lanes, { compact = false } = {}) {
  host.classList.toggle('compact', compact);
  const keep = new Set();

  lanes.forEach((lane, index) => {
    keep.add(lane.key);
    let row = host._lanes && host._lanes.get(lane.key);
    if (!row) {
      const root = el('div', 'lane new');
      root.style.setProperty('animation-delay', `${index * 40}ms`);
      const id = el('span', 'lane-id');
      const track = el('div', 'lane-track');
      const fill = el('i', 'lane-fill');
      const rateEl = el('span', 'lane-rate');
      track.append(fill);
      root.append(id, track, rateEl);
      host.append(root);
      setTimeout(() => root.classList.remove('new'), 1000);
      row = { root, id, fill, rate: rateEl };
      (host._lanes ||= new Map()).set(lane.key, row);
    }
    row.root.className = `lane ${lane.cls}${row.root.classList.contains('new') ? ' new' : ''}`;
    if (row.id.textContent !== lane.label) row.id.textContent = lane.label;
    row.fill.style.width = `${pct(lane.pct)}%`;
    if (row.rate.textContent !== lane.right) row.rate.textContent = lane.right;
  });

  if (host._lanes) {
    for (const [key, row] of host._lanes) {
      if (keep.has(key)) continue;
      row.root.remove();
      host._lanes.delete(key);
    }
  }
}

/** Map a download to the lanes the strip should show. */
function lanesFor(d) {
  if (d.state === 'verifying') {
    return [{ key: 'verify', label: 'all', cls: 'verify', pct: pct(d.verify.progress), right: `${Math.round(pct(d.verify.progress))}%` }];
  }
  if (!d.lanes.length) {
    return [{ key: 'none', label: 'c1', cls: 'idle', pct: 0, right: '—' }];
  }
  return d.lanes.map((l) => ({
    key: l.id,
    label: `c${l.id}`,
    cls: l.state,
    pct: l.pct == null ? (d.state === 'done' ? 100 : 0) : l.pct,
    right: l.state === 'done' ? 'done'
      : l.state === 'stalled' || l.state === 'retrying' ? '0.0'
      : l.pct == null ? '—'
      : (l.rate / 1024 ** 2).toFixed(1),
  }));
}

// ---- download rows ----------------------------------------------------------

const rows = new Map();

function buildRow(d) {
  const root = el('article', 'dl');
  root.tabIndex = 0;
  root.dataset.id = d.id;

  const top = el('div', 'dl-top');
  const name = el('span', 'dl-name');
  const meta = el('div', 'dl-meta');
  const chipConn = el('span', 'chip chip-rt');
  const sizeEl = el('span', '');
  const rateEl = el('span', 'dl-rate');
  const etaEl = el('span', '');
  meta.append(chipConn, sizeEl, rateEl, etaEl);
  const acts = el('div', 'dl-acts');
  const primary = el('button', 'icbtn');
  const dismiss = el('button', 'icbtn');
  acts.append(primary, dismiss);
  top.append(name, meta, acts);

  const axis = el('div', 'axis');
  const axisBar = el('div', 'axis-bar');
  axis.append(el('span'), axisBar, el('span'));

  const lanes = el('div', 'lanes');
  const foot = el('div', 'axis');
  const sum = el('div', 'strip-sum');
  foot.append(sum);

  root.append(top, axis, lanes, foot);

  root.addEventListener('click', () => select(d.id));
  root.addEventListener('dblclick', () => window.flash.openFile(d.id));
  root.addEventListener('contextmenu', (event) => { event.preventDefault(); select(d.id); openMenu(event, d.id); });
  primary.addEventListener('click', (event) => { event.stopPropagation(); primaryAction(rows.get(d.id).data); });
  dismiss.addEventListener('click', (event) => { event.stopPropagation(); dismissAction(rows.get(d.id).data); });

  return { root, name, chipConn, sizeEl, rateEl, etaEl, primary, dismiss, axis, axisBar, lanes, sum, data: d };
}

const AXIS_TICKS = 4;

function updateRow(r, d) {
  r.data = d;
  r.root.classList.toggle('on', ui.selected === d.id);
  if (r.name.textContent !== (d.name || d.url)) r.name.textContent = d.name || d.url;

  const routes = d.routesInUse.length;
  const conn = d.rangeable
    ? `${d.connections} CONN${routes > 1 ? ` · ${routes} ROUTES` : routes === 1 ? ` · ${String(d.routesInUse[0]).toUpperCase()}` : ''}`
    : 'SINGLE STREAM';
  if (r.chipConn.textContent !== conn) r.chipConn.textContent = conn;
  r.chipConn.className = `chip ${d.rangeable ? 'chip-rt' : ''}`;

  r.sizeEl.textContent = d.state === 'done' ? size(d.size) : pair(d.received, d.size);

  const status = statusOf(d);
  r.rateEl.className = `dl-rate ${status.cls}`;
  if (r.rateEl.textContent !== status.text) r.rateEl.textContent = status.text;

  const showEta = d.state === 'active' && d.eta != null;
  r.etaEl.textContent = showEta ? `${eta(d.eta)} left` : '';

  // Buttons name what they do to this download, right now.
  const running = d.state === 'active' || d.state === 'probing';
  r.primary.textContent = running ? '❙❙' : d.state === 'verifying' ? '·' : d.state === 'done' ? '▤' : d.state === 'failed' ? '↻' : '▶';
  r.primary.setAttribute('aria-label',
    running ? `Pause ${d.name}` : d.state === 'done' ? `Show ${d.name} in folder` : d.state === 'failed' ? `Retry ${d.name}` : `Resume ${d.name}`);
  r.primary.disabled = d.state === 'verifying';
  r.dismiss.textContent = '✕';
  r.dismiss.setAttribute('aria-label', `Remove ${d.name} from the list`);

  // Byte-range axis, only where it means something.
  const wantAxis = d.rangeable && d.size && d.lanes.length > 1 && d.state !== 'done';
  r.axis.hidden = !wantAxis;
  if (wantAxis && r.axisBar.dataset.size !== String(d.size)) {
    r.axisBar.dataset.size = String(d.size);
    r.axisBar.textContent = '';
    for (let i = 0; i <= AXIS_TICKS; i++) {
      const tick = el('span');
      tick.textContent = i === 0 ? '0' : size((d.size * i) / AXIS_TICKS);
      r.axisBar.append(tick);
    }
  }

  updateLanes(r.lanes, lanesFor(d), { compact: ui.selected !== d.id });

  const parts = [];
  if (d.slowest && d.state === 'active') {
    const stalled = d.slowest.stalledMs > 0;
    parts.push(`slowest lane <b class="${stalled ? 'bad' : ''}">c${d.slowest.id}${stalled ? ` · stalled ${Math.round(d.slowest.stalledMs / 1000)}s` : ''}</b>`);
  }
  if (d.state === 'active' && d.lanes.length > 1) parts.push(`spread <b>±${d.spread.toFixed(1)}%</b>`);
  if (d.retries) parts.push(`retries <b>${d.retries}</b>`);
  if (d.rebalances) parts.push(`rebalanced <b>${d.rebalances}×</b>`);
  if (d.state === 'done' && d.verify && d.verify.hash) parts.push(`sha256 <b>${d.verify.hash.slice(0, 12)}…</b>`);
  const html = parts.join(' ');
  if (r.sum.dataset.html !== html) { r.sum.dataset.html = html; r.sum.innerHTML = html; }
  r.sum.parentElement.hidden = !parts.length;
}

function primaryAction(d) {
  if (d.state === 'active' || d.state === 'probing') return window.flash.pause(d.id);
  if (d.state === 'done') return window.flash.reveal(d.id);
  if (d.state === 'failed') return window.flash.retry(d.id);
  return window.flash.resume(d.id);
}
function dismissAction(d) {
  if (d.state === 'active' || d.state === 'probing' || d.state === 'verifying') return window.flash.cancel(d.id);
  return window.flash.remove(d.id, false);
}

const FILTERS = {
  active: (d) => ['active', 'probing', 'verifying'].includes(d.state),
  queued: (d) => d.state === 'queued',
  paused: (d) => d.state === 'paused',
  done: (d) => d.state === 'done',
  failed: (d) => d.state === 'failed' || d.state === 'cancelled',
};

function renderList(state) {
  const filter = FILTERS[ui.view] || (() => true);
  const list = $('#list');
  const visible = state.downloads.filter(filter);
  const keep = new Set();

  visible.forEach((d, index) => {
    keep.add(d.id);
    let r = rows.get(d.id);
    if (!r) { r = buildRow(d); rows.set(d.id, r); }
    updateRow(r, d);
    const at = list.children[index];
    if (at !== r.root) list.insertBefore(r.root, at || null);
  });

  for (const [id, r] of rows) {
    if (keep.has(id)) continue;
    r.root.remove();
    rows.delete(id);
  }

  const empty = $('#empty');
  empty.hidden = visible.length > 0;
  if (!visible.length) {
    const copy = {
      active: ['Nothing downloading', `Paste a link above, or copy one anywhere and Flash Download will offer it here.`],
      queued: ['Nothing queued', 'Downloads wait here when more are running than the limit allows.'],
      paused: ['Nothing paused', 'Paused downloads keep their byte ranges and resume exactly where they stopped.'],
      done: ['Nothing finished yet', 'Completed downloads are listed here with their checksum.'],
      failed: ['Nothing failed', 'Downloads that run out of retries land here with the reason.'],
    }[ui.view] || ['Nothing here', ''];
    $('#empty-title').textContent = copy[0];
    $('#empty-body').textContent = copy[1];
  }
}

function select(id) {
  ui.selected = id;
  if (ui.state) render(ui.state);
}

// ---- inspector --------------------------------------------------------------

function graphSvg(values, peak) {
  const w = 244, h = 78, left = 8, right = 236, top = 12, bottom = 62;
  if (!values.length) values = [0];
  const max = Math.max(peak, ...values, 1);
  const step = values.length > 1 ? (right - left) / (values.length - 1) : 0;
  const pt = (v, i) => [left + i * step, bottom - (v / max) * (bottom - top)];
  const points = values.map(pt);
  const line = points.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
  const last = points[points.length - 1];
  return `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" role="img" aria-label="Throughput over the last minute, peaking at ${rate(max)}.">
    <g stroke="#19222F" stroke-width="1">
      <line x1="${left}" y1="${top + 2}" x2="${right}" y2="${top + 2}"/>
      <line x1="${left}" y1="${(top + bottom) / 2}" x2="${right}" y2="${(top + bottom) / 2}"/>
      <line x1="${left}" y1="${bottom}" x2="${right}" y2="${bottom}"/>
    </g>
    <defs><linearGradient id="gq" x1="0" y1="0" x2="0" y2="1">
      <stop offset="0" stop-color="#FFB22C" stop-opacity=".30"/><stop offset="1" stop-color="#FFB22C" stop-opacity="0"/>
    </linearGradient></defs>
    <path d="M${line} L${last[0].toFixed(1)},${bottom} L${left},${bottom} Z" fill="url(#gq)" stroke="none"/>
    <polyline points="${line}" fill="none" stroke="#FFB22C" stroke-width="1.6" stroke-linejoin="round"/>
    <circle cx="${last[0].toFixed(1)}" cy="${last[1].toFixed(1)}" r="2.6" fill="#FFE4A8" stroke="#D9701A" stroke-width="1"/>
    <text x="${left}" y="74" fill="#5D718E" font-family="IBM Plex Mono, monospace" font-size="8">&#8722;60s</text>
    <text x="${right}" y="74" fill="#5D718E" font-family="IBM Plex Mono, monospace" font-size="8" text-anchor="end">${rate(values[values.length - 1])}</text>
  </svg>`;
}

function block(title, aside) {
  const root = el('div', 'insp-blk');
  const head = el('div', 'insp-h');
  const h = el('h5'); h.textContent = title;
  head.append(h);
  if (aside) { const s = el('span'); s.textContent = aside; head.append(s); }
  root.append(head);
  return root;
}
function kv(dt, dd, cls) {
  const row = el('div', 'kv');
  const a = el('dt'); a.textContent = dt;
  const b = el('dd'); b.textContent = dd;
  if (cls) b.style.color = cls;
  row.append(a, b);
  return row;
}

function renderInspector(state) {
  const host = $('#inspector');
  const d = state.downloads.find((x) => x.id === ui.selected)
    || state.downloads.find((x) => x.state === 'active')
    || state.downloads[0];

  if (!d) {
    if (host.dataset.mode !== 'empty') {
      host.dataset.mode = 'empty';
      host.textContent = '';
      const note = el('p', 'hint');
      note.textContent = 'Select a download to see its connections, routes and throughput.';
      host.append(note);
    }
    return;
  }
  host.dataset.mode = d.id;
  host.textContent = '';

  const peak = Math.max(0, ...d.graph);
  const g = block('Throughput · 60s', `peak ${rate(peak)}`);
  const holder = el('div');
  holder.innerHTML = graphSvg(d.graph.length ? d.graph : [0], peak);
  g.append(holder);
  host.append(g);

  const live = d.lanes.filter((l) => l.state !== 'done');
  const conn = block('Connections', `${live.length} open of ${d.lanes.length}`);
  const wrap = el('div', 'tbl-wrap');
  const table = el('table', 'tbl');
  const thead = el('thead');
  thead.innerHTML = '<tr><th>C</th><th>Range</th><th>Route</th><th>MB/s</th></tr>';
  const tbody = el('tbody');
  const shown = (live.length ? live : d.lanes).slice(0, 12);
  for (const lane of shown) {
    const tr = el('tr');
    const cells = [
      `c${lane.id}`,
      lane.end == null ? '—' : `${size(lane.start)}–${size(lane.end + 1)}`,
      lane.routeId || 'direct',
      lane.state === 'done' ? 'done' : (lane.rate / 1024 ** 2).toFixed(1),
    ];
    cells.forEach((text, i) => {
      const td = el('td');
      td.textContent = text;
      if (i === 2) td.className = 'route';
      if (i === 3 && (lane.state === 'stalled' || lane.state === 'retrying')) td.style.color = 'var(--red)';
      tr.append(td);
    });
    tbody.append(tr);
  }
  table.append(thead, tbody);
  wrap.append(table);
  conn.append(wrap);
  if (d.lanes.length > shown.length) {
    const more = el('p', 'hint');
    more.textContent = `${d.lanes.length - shown.length} more lanes not shown.`;
    conn.append(more);
  }
  host.append(conn);

  const server = block('Server');
  server.append(kv('Range requests', d.rangeable ? 'bytes · accepted' : 'not offered', d.rangeable ? 'var(--ok)' : 'var(--tx-2)'));
  if (d.etag) server.append(kv('ETag', d.etag.replace(/"/g, '')));
  server.append(kv('Size', size(d.size)));
  if (d.verify && d.verify.hash) server.append(kv('SHA256', `${d.verify.hash.slice(0, 16)}…`, 'var(--ok)'));
  if (d.filePath) server.append(kv('Saved to', d.filePath));
  host.append(server);

  if (d.state === 'active') {
    const btn = el('button', 'btn btn-sm');
    btn.type = 'button';
    btn.style.width = '100%';
    btn.style.marginTop = '14px';
    btn.textContent = 'Rebalance connections';
    btn.addEventListener('click', () => window.flash.rebalance(d.id));
    host.append(btn);
  }
}

// ---- rail, strip ------------------------------------------------------------

function renderRail(state) {
  const c = state.totals.counts;
  const map = { active: c.active, queued: c.queued, paused: c.paused, done: c.done, failed: c.failed, routes: state.routes.length };
  for (const [key, value] of Object.entries(map)) {
    const node = $(`[data-count="${key}"]`);
    if (node && node.textContent !== String(value)) node.textContent = String(value);
  }
  $$('#rail .nav').forEach((n) => n.classList.toggle('on', n.dataset.view === ui.view));
}

function renderStrip(state) {
  const t = state.totals;
  const strip = $('#strip');
  const bits = [
    `<b>${rate(t.rate)}</b> down`,
    `${t.openLanes} of ${t.maxLanes || 0} connections`,
    `routes <b>${t.routes.up} up</b>${t.routes.degraded ? ` · <span class="bad">${t.routes.degraded} degraded</span>` : ''}${t.routes.off ? ` · ${t.routes.off} off` : ''}`,
    t.speedLimit ? `limit <b>${rate(t.speedLimit)}</b>` : 'no speed limit',
  ];
  const html = bits.join('<span class="sep"></span>');
  if (strip.dataset.html !== html) { strip.dataset.html = html; strip.innerHTML = html; }

  // Colour and words have to agree: a red chip never reads "3 ROUTES UP".
  const health = $('#route-health');
  const text = t.routes.degraded
    ? `${t.routes.up} UP · ${t.routes.degraded} DEGRADED`
    : `${t.routes.up} ROUTE${t.routes.up === 1 ? '' : 'S'} UP`;
  if (health.textContent !== text) health.textContent = text;
  health.className = `chip ${t.routes.degraded ? 'chip-bad' : 'chip-ok'}`;
}

// ---- routes view ------------------------------------------------------------

function renderRoutes(state) {
  const body = $('#routes-table tbody');
  const key = JSON.stringify(state.routes);
  if (body.dataset.key === key) return;
  body.dataset.key = key;
  body.textContent = '';

  for (const r of state.routes) {
    const tr = el('tr');
    if (!r.enabled) tr.style.opacity = '.5';
    const cells = [
      r.label,
      r.type === 'direct' ? '—' : r.type.toUpperCase(),
      r.latencyMs == null ? '—' : `${r.latencyMs} ms`,
      String(r.lanes),
      r.lastError || '—',
    ];
    cells.forEach((text, i) => {
      const td = el('td');
      td.textContent = text;
      if (i === 3 && r.lanes > 0) td.style.color = 'var(--amber)';
      if (i === 4 && r.lastError) td.style.color = 'var(--red)';
      tr.append(td);
    });

    const stateTd = el('td');
    const chip = el('span', `chip ${!r.enabled ? 'chip-off' : r.health === 'degraded' ? 'chip-bad' : 'chip-ok'}`);
    chip.textContent = !r.enabled ? 'OFF' : r.health === 'degraded' ? 'DEGRADED' : 'UP';
    stateTd.append(chip);

    const actTd = el('td');
    const acts = el('div', 'dl-acts');
    const test = el('button', 'icbtn');
    test.type = 'button'; test.textContent = '↻'; test.setAttribute('aria-label', `Test ${r.label}`);
    test.addEventListener('click', () => window.flash.testRoute(r.id));
    acts.append(test);
    if (r.type !== 'direct') {
      const toggle = el('button', 'icbtn');
      toggle.type = 'button';
      toggle.textContent = r.enabled ? '◉' : '○';
      toggle.setAttribute('aria-label', r.enabled ? `Turn ${r.label} off` : `Turn ${r.label} on`);
      toggle.addEventListener('click', () => window.flash.updateRoute(r.id, { enabled: !r.enabled }));
      const del = el('button', 'icbtn');
      del.type = 'button'; del.textContent = '✕'; del.setAttribute('aria-label', `Remove ${r.label}`);
      del.addEventListener('click', () => window.flash.removeRoute(r.id));
      acts.append(toggle, del);
    }
    actTd.append(acts);
    tr.append(stateTd, actTd);
    body.append(tr);
  }
}

// ---- settings view ----------------------------------------------------------

function buildSteps(host, name, onPick) {
  host.textContent = '';
  for (const n of STEPS) {
    const input = el('input');
    input.type = 'radio'; input.name = name; input.id = `${name}-${n}`; input.value = String(n);
    const label = el('label');
    label.setAttribute('for', input.id);
    label.textContent = String(n);
    input.addEventListener('change', () => onPick(n));
    host.append(input, label);
  }
}
function setSteps(host, value) {
  const input = $(`input[value="${value}"]`, host) || $('input', host);
  if (input) input.checked = true;
}

function fillSettings(state) {
  const s = state.settings;
  setSteps($('#set-connections'), s.connections);
  $('#set-concurrent').value = s.maxConcurrent;
  $('#set-limit').value = s.speedLimit ? (s.speedLimit / 1024 ** 2).toFixed(1) : '';
  $('#set-dir').value = s.downloadDir || '';
  $('#set-verify').checked = !!s.verifyAfter;
  $('#set-clip').checked = !!s.captureClipboard;
  $('#set-drop').checked = !!s.dropFailingRoutes;
}

// ---- views ------------------------------------------------------------------

function setView(view) {
  ui.view = view;
  const isList = !!FILTERS[view];
  $('#view-list').hidden = !isList;
  $('#view-routes').hidden = view !== 'routes';
  $('#view-settings').hidden = view !== 'settings';

  $('#capture-box').hidden = !isList;
  $('#capture-kbd').hidden = !isList;
  $('#capture-start').hidden = !isList;
  $('#view-title').hidden = isList;
  $('#view-actions').hidden = isList;

  if (!isList) {
    $('#view-title').textContent = view === 'routes' ? 'Route pool' : 'Settings';
    const acts = $('#view-actions');
    acts.textContent = '';
    if (view === 'routes') {
      const test = el('button', 'btn btn-sm'); test.type = 'button'; test.textContent = 'Test all';
      test.addEventListener('click', () => window.flash.testAllRoutes());
      const add = el('button', 'btn btn-pri btn-sm'); add.type = 'button'; add.textContent = 'Add route';
      add.addEventListener('click', () => $('#route-dialog').showModal());
      acts.append(test, add);
    }
  }
  if (view === 'settings' && ui.state) fillSettings(ui.state);
  if (ui.state) render(ui.state);
}

function render(state) {
  ui.state = state;
  renderRail(state);
  renderStrip(state);
  if (FILTERS[ui.view]) renderList(state);
  if (ui.view === 'routes') renderRoutes(state);
  renderInspector(state);
  noticeFinished(state);
}

// ---- toasts -----------------------------------------------------------------

function toast({ title, body, kind }) {
  const node = el('div', `toast ${kind === 'bad' ? 'bad' : ''}`);
  const wrap = el('div');
  const h = el('h5'); h.textContent = title;
  wrap.append(h);
  if (body) { const p = el('p'); p.textContent = body; wrap.append(p); }
  node.append(wrap);
  $('#toasts').append(node);
  setTimeout(() => node.remove(), 6000);
}

/** A toast reports a finished fact; anything in progress belongs in the row. */
function noticeFinished(state) {
  for (const d of state.downloads) {
    const was = ui.seenStates.get(d.id);
    ui.seenStates.set(d.id, d.state);
    if (was === undefined || was === d.state) continue;
    if (d.state === 'done') {
      const took = d.startedAt && d.finishedAt ? ` in ${eta((d.finishedAt - d.startedAt) / 1000)}` : '';
      toast({
        title: `Finished ${d.name}`,
        body: d.verify && d.verify.hash
          ? `SHA256 ${d.verify.hash.slice(0, 16)}… · ${size(d.size)}${took}.`
          : `${size(d.size)}${took}.`,
      });
    } else if (d.state === 'failed') {
      toast({ kind: 'bad', title: `${d.name} stopped`, body: d.error || 'Out of retries.' });
    }
  }
}

// ---- context menu -----------------------------------------------------------

function openMenu(event, id) {
  const d = ui.state.downloads.find((x) => x.id === id);
  if (!d) return;
  const menu = $('#menu');
  menu.textContent = '';

  const running = d.state === 'active' || d.state === 'probing';
  const items = [
    running ? ['Pause', 'Space', () => window.flash.pause(id)]
            : d.state === 'done' ? null : ['Resume', 'Space', () => window.flash.resume(id)],
    d.state === 'done' ? ['Open file', `${MOD}+O`, () => window.flash.openFile(id)] : null,
    ['Show in folder', `${MOD}+Enter`, () => window.flash.reveal(id)],
    ['Copy source link', `${MOD}+C`, () => navigator.clipboard.writeText(d.url)],
    null,
    d.state === 'active' ? ['Rebalance connections', '', () => window.flash.rebalance(id)] : null,
    d.state === 'failed' ? ['Try again', '', () => window.flash.retry(id)] : null,
    null,
    ['Remove from list', 'Delete', () => window.flash.remove(id, false)],
    ['Delete file from disk', '', () => window.flash.remove(id, true), 'dgr'],
  ];

  for (const item of items) {
    if (!item) {
      if (menu.lastElementChild && menu.lastElementChild.tagName !== 'HR') menu.append(el('hr'));
      continue;
    }
    const [label, key, action, cls] = item;
    const btn = el('button', cls || '');
    btn.type = 'button';
    btn.textContent = label;
    if (key) { const s = el('span'); s.textContent = key; btn.append(s); }
    btn.addEventListener('click', () => { closeMenu(); action(); });
    menu.append(btn);
  }

  menu.hidden = false;
  const w = menu.offsetWidth, h = menu.offsetHeight;
  menu.style.left = `${Math.min(event.clientX, window.innerWidth - w - 8)}px`;
  menu.style.top = `${Math.min(event.clientY, window.innerHeight - h - 8)}px`;
}
const closeMenu = () => { $('#menu').hidden = true; };

// ---- add dialog -------------------------------------------------------------

function previewLanes(count, total) {
  const each = total ? size(total / count) : '—';
  updateLanes($('#add-preview'), Array.from({ length: count }, (_, i) => ({
    key: i + 1, label: `c${i + 1}`, cls: 'idle', pct: 0, right: each,
  })), { compact: true });
  $('#add-preview-note').textContent = total
    ? `${count} range${count === 1 ? '' : 's'} of ${each}. ${ui.probe && ui.probe.acceptsRanges ? 'The server accepts range requests, so all of them open at once.' : 'The server has not offered range requests, so this will use one connection.'}`
    : 'Checking what the server will allow…';
}

function connectionsPicked() {
  const checked = $('#add-connections input:checked');
  return checked ? Number(checked.value) : 8;
}

async function runProbe(url) {
  const seq = ++ui.probeSeq;
  const chip = $('#add-probe');
  chip.className = 'chip';
  chip.textContent = 'Checking…';
  $('#add-hint').textContent = '';
  $('#add-hint').className = 'hint';

  const res = await window.flash.probe(url);
  if (seq !== ui.probeSeq) return;

  if (!res.ok) {
    ui.probe = null;
    chip.className = 'chip chip-bad';
    chip.textContent = 'UNREACHABLE';
    $('#add-hint').className = 'hint bad';
    $('#add-hint').textContent = `${res.error}. Check the link, or pick a different route below.`;
    previewLanes(1, null);
    return;
  }

  ui.probe = res.value;
  chip.className = `chip ${res.value.acceptsRanges ? 'chip-ok' : 'chip-amber'}`;
  chip.textContent = res.value.acceptsRanges ? 'RANGE REQUESTS OK' : 'NO RANGE SUPPORT';
  $('#add-hint').textContent = `${size(res.value.size)}${res.value.contentType ? ` · ${res.value.contentType.split(';')[0]}` : ''}`;
  if (!$('#add-name').value) $('#add-name').value = res.value.filename;
  previewLanes(res.value.acceptsRanges ? connectionsPicked() : 1, res.value.size);
}

function openAdd(url) {
  const state = ui.state;
  ui.probe = null;
  $('#add-url').value = url || '';
  $('#add-name').value = '';
  $('#add-dir').value = state.settings.downloadDir || '';
  $('#add-verify').checked = !!state.settings.verifyAfter;
  setSteps($('#add-connections'), state.settings.connections);

  const select = $('#add-route');
  select.textContent = '';
  const spread = el('option'); spread.value = 'spread';
  spread.textContent = `Spread across pool — ${state.totals.routes.up} route${state.totals.routes.up === 1 ? '' : 's'} up`;
  select.append(spread);
  for (const r of state.routes) {
    if (!r.enabled) continue;
    const option = el('option');
    option.value = r.id;
    option.textContent = r.type === 'direct' ? 'Direct — no proxy' : `${r.label} · ${r.host}:${r.port}`;
    select.append(option);
  }
  select.value = state.settings.routeMode;

  previewLanes(state.settings.connections, null);
  $('#add-dialog').showModal();
  if (url) runProbe(url);
  else $('#add-url').focus();
}

async function submitAdd(startNow) {
  const url = $('#add-url').value.trim();
  if (!url) return;
  const res = await window.flash.add({
    url,
    name: $('#add-name').value.trim() || null,
    dir: $('#add-dir').value,
    connections: connectionsPicked(),
    routeMode: $('#add-route').value,
    verifyAfter: $('#add-verify').checked,
    startNow,
  });
  if (!res.ok) return toast({ kind: 'bad', title: 'Could not add that link', body: res.error });
  $('#add-dialog').close();
  $('#capture-url').value = '';
  setView(startNow ? 'active' : 'queued');
}

// ---- wiring -----------------------------------------------------------------

function wire() {
  $$('#rail .nav').forEach((n) => n.addEventListener('click', () => setView(n.dataset.view)));

  $$('#wctl button').forEach((b) => b.addEventListener('click', () => window.flash.win[b.dataset.win]()));

  $('#capture-start').addEventListener('click', () => openAdd($('#capture-url').value.trim()));
  $('#capture-url').addEventListener('keydown', (event) => {
    if (event.key === 'Enter') openAdd($('#capture-url').value.trim());
  });
  $('#empty-action').addEventListener('click', () => openAdd($('#capture-url').value.trim()));

  buildSteps($('#add-connections'), 'add-conn', (n) => previewLanes(ui.probe && !ui.probe.acceptsRanges ? 1 : n, ui.probe && ui.probe.size));
  buildSteps($('#set-connections'), 'set-conn', (n) => window.flash.updateSettings({ connections: n }));

  let probeTimer = null;
  $('#add-url').addEventListener('input', () => {
    clearTimeout(probeTimer);
    const url = $('#add-url').value.trim();
    if (!/^https?:\/\/\S+$/i.test(url)) return;
    probeTimer = setTimeout(() => runProbe(url), 400);
  });
  $('#add-dir-pick').addEventListener('click', async () => {
    const res = await window.flash.chooseFolder($('#add-dir').value);
    if (res.ok && res.value) $('#add-dir').value = res.value;
  });
  $('#add-cancel').addEventListener('click', () => $('#add-dialog').close());
  $('#add-queue').addEventListener('click', () => submitAdd(false));
  $('#add-form').addEventListener('submit', (event) => { event.preventDefault(); submitAdd(true); });

  $('#rt-cancel').addEventListener('click', () => $('#route-dialog').close());
  $('#route-form').addEventListener('submit', async (event) => {
    event.preventDefault();
    const res = await window.flash.addRoute({
      label: $('#rt-label').value.trim() || `${$('#rt-host').value}:${$('#rt-port').value}`,
      type: $('#rt-type').value,
      host: $('#rt-host').value.trim(),
      port: Number($('#rt-port').value),
      username: $('#rt-user').value.trim() || null,
      password: $('#rt-pass').value || null,
    });
    if (!res.ok) return toast({ kind: 'bad', title: 'Could not add that route', body: res.error });
    $('#route-form').reset();
    $('#route-dialog').close();
  });

  $('#set-concurrent').addEventListener('change', (e) => window.flash.updateSettings({ maxConcurrent: Math.max(1, Number(e.target.value) || 1) }));
  $('#set-limit').addEventListener('change', (e) => {
    const raw = e.target.value.trim().toLowerCase();
    const mb = raw === '' || raw === 'unlimited' ? 0 : parseFloat(raw);
    window.flash.updateSettings({ speedLimit: isFinite(mb) && mb > 0 ? Math.round(mb * 1024 ** 2) : 0 });
  });
  $('#set-dir-pick').addEventListener('click', async () => {
    const res = await window.flash.chooseFolder($('#set-dir').value);
    if (res.ok && res.value) { $('#set-dir').value = res.value; window.flash.updateSettings({ downloadDir: res.value }); }
  });
  $('#set-verify').addEventListener('change', (e) => window.flash.updateSettings({ verifyAfter: e.target.checked }));
  $('#set-clip').addEventListener('change', (e) => window.flash.updateSettings({ captureClipboard: e.target.checked }));
  $('#set-drop').addEventListener('change', (e) => window.flash.updateSettings({ dropFailingRoutes: e.target.checked }));

  window.addEventListener('click', closeMenu);
  window.addEventListener('blur', closeMenu);
  window.addEventListener('resize', closeMenu);

  document.addEventListener('keydown', (event) => {
    if (event.key === 'Escape') return closeMenu();
    const typing = ['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement.tagName);
    if (typing || !ui.selected || !ui.state) return;
    const d = ui.state.downloads.find((x) => x.id === ui.selected);
    if (!d) return;

    if (event.key === ' ') { event.preventDefault(); primaryAction(d); }
    else if (event.key === 'Delete' || event.key === 'Backspace') { event.preventDefault(); window.flash.remove(d.id, false); }
    else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); window.flash.reveal(d.id); }
    else if (event.key.toLowerCase() === 'c' && (event.metaKey || event.ctrlKey)) navigator.clipboard.writeText(d.url);
  });

  window.flash.onState(render);
  window.flash.onNotice((n) => toast(n));
  window.flash.onClipboardUrl((url) => {
    const input = $('#capture-url');
    if (input.value.trim()) return;        // never clobber something being typed
    input.value = url;
    input.classList.add('offered');
  });
  // A link handed over from the launcher or a second launch is an explicit
  // request, so it opens the dialog rather than waiting in the capture bar.
  window.flash.onOpenUrl((url) => {
    if (!FILTERS[ui.view]) setView('active');
    openAdd(url);
  });
  window.flash.onWinState(({ maximized }) => {
    const btn = $('#wctl [data-win="maximize"]');
    btn.textContent = maximized ? '❐' : '▢';
    btn.setAttribute('aria-label', maximized ? 'Restore' : 'Maximize');
  });
}

// ---- boot -------------------------------------------------------------------

(async () => {
  const info = await window.flash.win.info();
  if (info.ok && info.value.platform === 'darwin') document.body.classList.add('mac');
  $('#capture-kbd').textContent = `${MOD}+V to capture`;

  wire();
  const res = await window.flash.getState();
  if (res.ok) { render(res.value); fillSettings(res.value); }
  setView('active');
})();