Theseus: bundle Pithos 0.3.10

This commit is contained in:
Local Dev 2026-10-04 03:29:32 +02:00
parent bdc8f951c6
commit 399e763745
4 changed files with 139 additions and 17 deletions

View file

@ -1,14 +1,18 @@
{ {
"id": "pithos", "id": "pithos",
"name": "Pithos", "name": "Pithos",
"version": "0.3.9", "version": "0.3.10",
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.", "description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
"author": "Silent Mode", "author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
"main": "index.js", "main": "index.js",
"capabilities": [ "capabilities": [
"sidebar-panel", "sidebar-panel",
"vault-derive" "vault-derive",
"site-route"
], ],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json" "updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json",
"siteRoutes": [
"pithos.sia"
]
} }

View file

@ -411,6 +411,31 @@ export async function createPithos(opts = {}) {
const STREAMED = Symbol('streamed'); const STREAMED = Symbol('streamed');
let boundPort = port; let boundPort = port;
// Site mode: Theseus relays pithos.sia/<path> here (header x-pithos-site).
// Pithos answers its own files, its pages and /<S3 user>/…; anything else
// is "not mine", so the pithos.sia website answers it instead.
const SITE_PAGES = new Set(['overview', 'drives', 'users', 'account', 'start', 'setup', 'settings', 'logs']);
const SITE_FILES = new Set(['app.js', 'app.css', 'icon.svg']);
let siteUsersCache = { at: 0, set: new Set() };
async function siteUsers() {
if (Date.now() - siteUsersCache.at > 10_000) {
try { siteUsersCache = { at: Date.now(), set: new Set(await cli.listUsers()) }; }
catch { siteUsersCache.at = Date.now(); } // keep the last good list
}
return siteUsersCache.set;
}
async function serveSite(req, res, url) {
const segs = url.pathname.split('/').filter(Boolean);
let first = '';
try { first = decodeURIComponent(segs[0] || ''); } catch {}
if (segs.length === 1 && SITE_FILES.has(first)) return serveStatic(req, res, url);
if (!(SITE_PAGES.has(first) || (await siteUsers()).has(first))) {
res.writeHead(404, { 'x-pithos-not-mine': '1' });
return res.end('not found');
}
return serveStatic(req, res, new URL('/', url), { base: '/' });
}
function hostAllowed(h) { function hostAllowed(h) {
if (!h) return false; if (!h) return false;
const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts]; const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts];
@ -501,6 +526,7 @@ export async function createPithos(opts = {}) {
throw new HttpError(404, 'no such endpoint'); throw new HttpError(404, 'no such endpoint');
} }
if (req.headers['x-pithos-site'] === '1') return await serveSite(req, res, url);
return serveStatic(req, res, url); return serveStatic(req, res, url);
} catch (err) { } catch (err) {
const status = err instanceof HttpError ? err.status const status = err instanceof HttpError ? err.status
@ -649,7 +675,7 @@ function describePolicy(policy) {
return 'custom'; return 'custom';
} }
function serveStatic(req, res, url) { function serveStatic(req, res, url, { base } = {}) {
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); }
let rel = decodeURIComponent(url.pathname); let rel = decodeURIComponent(url.pathname);
if (rel === '/' || !path.extname(rel)) rel = '/index.html'; if (rel === '/' || !path.extname(rel)) rel = '/index.html';
@ -662,6 +688,8 @@ function serveStatic(req, res, url) {
'cache-control': 'no-cache', 'cache-control': 'no-cache',
'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
}); });
// Pages under pithos.sia/<user>/<drive>/… load the app's files from the root.
if (base && file.endsWith('index.html')) data = Buffer.from(data.toString('utf8').replace('<head>', `<head><base href="${base}">`));
res.end(req.method === 'HEAD' ? undefined : data); res.end(req.method === 'HEAD' ? undefined : data);
}); });
} }

View file

@ -133,10 +133,52 @@ module.exports = {
}); });
api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; }); api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; });
// A full browser-tab view, for anyone who prefers it to the sidebar. // ---- pithos.sia/<user>/<drive>/<folder>: the app at its own address ----
api.onMessage("open-in-tab", async () => { // Theseus hands this add-on the requests for paths under pithos.sia (the
// root page stays the website). They go to the control server with the
// internal session, but only for pithos.sia's own requests and the
// address bar, never another site's fetch or form, and only while the
// vault is unlocked, the same as the sidebar panel.
const siteRoutes = typeof api.registerSiteRoute === "function";
if (siteRoutes) {
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
const FORWARD = ["content-type", "x-pithos", "range", "accept", "if-range"];
const DROP = new Set(["set-cookie", "connection", "keep-alive", "transfer-encoding"]);
api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => {
const u = new URL(request.url);
const fetchSite = request.headers.get("sec-fetch-site");
const origin = request.headers.get("origin");
const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none"
: !origin || /^(bns|https):\/\/pithos\.sia$/i.test(origin);
const st = await vaultStatus();
const locked = st.setup && st.prompt && !st.unlocked;
if (u.pathname === "/api/host/unlock") {
if (request.method !== "POST" || request.headers.get("x-pithos") !== "1" || !own) return json(403, { error: "forbidden" });
if (!locked) return json(200, { ok: true });
const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." });
return json(200, { ok: !!r.ok });
}
if (locked && u.pathname === "/api/session") return json(200, { authenticated: false, locked: true, passwordLogin: false, host: "theseus" });
const p = await ensureServer();
const headers = { "x-pithos-site": "1" };
for (const k of FORWARD) { const v = request.headers.get(k); if (v) headers[k] = v; }
if (own && !locked) headers.cookie = cookie;
const init = { method: request.method, headers, redirect: "manual" };
if (request.method !== "GET" && request.method !== "HEAD") init.body = Buffer.from(await request.arrayBuffer());
const res = await fetch(new URL(u.pathname + u.search, p.url), init);
if (res.status === 404 && res.headers.get("x-pithos-not-mine")) return null;
const out = new Headers();
for (const [k, v] of res.headers) if (!DROP.has(k.toLowerCase())) out.set(k, v);
return new Response(request.method === "HEAD" ? null : res.body, { status: res.status, headers: out });
} });
}
// Full page: Pithos at pithos.sia/<user>/<drive>/… in an ordinary tab.
// An older Theseus without site routes gets the loopback address.
api.onMessage("open-in-tab", async ({ path: appPath } = {}) => {
const p = await ensureServer(); const p = await ensureServer();
api.openTab(p.authUrl); const clean = /^\/[^\s]*$/.test(String(appPath || "")) && appPath !== "/" ? appPath : "/drives";
api.openTab(siteRoutes ? `https://pithos.sia${clean}` : p.authUrl);
return { ok: true }; return { ok: true };
}); });

View file

@ -32,6 +32,9 @@ function put(el, ...children) {
// server origin to fetch from: every call goes over the add-on IPC bridge // server origin to fetch from: every call goes over the add-on IPC bridge
// and the add-on forwards it to the control server. // and the add-on forwards it to the control server.
const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null; const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null;
// On pithos.sia (Theseus) pages have real addresses: pithos.sia/<user>/<drive>/<folder>,
// pithos.sia/settings… Elsewhere (sidebar, web console, desktop) they stay #/ routes.
const PATH_MODE = !bridge && (location.protocol === 'bns:' || location.hostname === 'pithos.sia');
async function api(method, path, body, { raw } = {}) { async function api(method, path, body, { raw } = {}) {
if (bridge) { if (bridge) {
@ -191,13 +194,43 @@ const views = { start: getStarted, account, overview, buckets, users, setup, set
// Pages that live under Account in the menu. // Pages that live under Account in the menu.
const NAV_OF = { start: 'account', setup: 'account' }; const NAV_OF = { start: 'account', setup: 'account' };
// [page, ...parts] for the current address. On pithos.sia the first part is a
// page name or an S3 user (whose drives the rest addresses).
function currentRoute() {
if (!PATH_MODE) return location.hash.replace(/^#\/?/, '').split('/').filter((x, i) => i > 0 || x);
const segs = location.pathname.split('/').filter(Boolean);
if (!segs.length) return [];
const first = decodeURIComponent(segs[0]);
if (first === 'drives') return ['buckets'];
if (views[first]) return segs;
if (state.selectedUser !== first) { state.selectedUser = first; try { localStorage.setItem('pithos.user', first); } catch {} }
return ['buckets', ...segs.slice(1)];
}
// '#/page/parts' → the address it has on pithos.sia.
function hashToPath(hash) {
const [name = 'overview', ...rest] = String(hash).replace(/^#\/?/, '').split('/');
if (name === 'buckets') {
const user = state.selectedUser;
if (!user) return '/drives';
return `/${encodeURIComponent(user)}${rest.length ? '/' + rest.join('/') : ''}`;
}
return `/${[name, ...rest].join('/')}`;
}
// Go to a page given as '#/page/parts', whichever kind of address this host uses.
function navTo(hash) {
if (!PATH_MODE) { location.hash = hash; return; }
const p = hashToPath(hash);
if (p !== location.pathname) history.pushState(null, '', p);
route();
}
function route() { function route() {
cleanup.forEach((fn) => fn()); cleanup.forEach((fn) => fn());
cleanup = []; cleanup = [];
logSink = null; logSink = null;
// First run lands on the guided setup until it has been finished once. // First run lands on the guided setup until it has been finished once.
const fallback = !setupDone() && !state.status?.registration?.registered ? 'account' : 'overview'; const fallback = !setupDone() && !state.status?.registration?.registered ? 'account' : 'overview';
const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/'); const [name = fallback, ...rest] = currentRoute();
const view = views[name] ? name : fallback; const view = views[name] ? name : fallback;
const navView = NAV_OF[view] || view; const navView = NAV_OF[view] || view;
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView); for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView);
@ -700,7 +733,7 @@ function setup(main) {
h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')), h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
h('small', {}, 'Pithos cannot see which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".')), h('small', {}, 'Pithos cannot see which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".')),
h('div', { class: 'row', style: 'margin-top:16px' }, h('div', { class: 'row', style: 'margin-top:16px' },
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'), daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'),
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'), h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')), h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')),
archivesBlock()); archivesBlock());
@ -793,7 +826,7 @@ function setup(main) {
await refreshStatus(); await refreshStatus();
await loadAccount(); await loadAccount();
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null }); Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null });
location.hash = '#/start'; navTo('#/start');
} catch (e) { fail(e); } } catch (e) { fail(e); }
} }
@ -857,7 +890,7 @@ function users(main) {
h('td', {}, h('strong', {}, u.name)), h('td', {}, h('strong', {}, u.name)),
h('td', {}, `${u.keys.length}`), h('td', {}, `${u.keys.length}`),
h('td', { class: 'actions' }, h('td', { class: 'actions' },
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Drives'), h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); navTo('#/buckets'); } }, 'Drives'),
' ', ' ',
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'),
' ', ' ',
@ -1102,7 +1135,7 @@ function itemMenu(items) {
}); });
return btn; return btn;
} }
const go = (hash) => () => { location.hash = hash; }; const go = (hash) => () => navTo(hash);
const ICONS = { const ICONS = {
download: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>', download: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>',
share: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-.9.9"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.5 8.8a2.6 2.6 0 0 0 3.7 3.7l.9-.9"/></svg>', share: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-.9.9"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.5 8.8a2.6 2.6 0 0 0 3.7 3.7l.9-.9"/></svg>',
@ -1142,7 +1175,7 @@ function buckets(main, [bucket, ...prefixParts]) {
main.append(head, body); main.append(head, body);
let users = []; let users = [];
const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); location.hash = '#/buckets'; route(); } }); const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); if (PATH_MODE) navTo('#/buckets'); else { location.hash = '#/buckets'; route(); } } });
(async () => { (async () => {
try { users = await api('GET', '/api/users'); } try { users = await api('GET', '/api/users'); }
@ -1302,10 +1335,10 @@ async function accessDialog(user, bucket) {
function objectBrowser(head, body, user, bucket, prefix, userSel) { function objectBrowser(head, body, user, bucket, prefix, userSel) {
if (prefix && !prefix.endsWith('/')) prefix += '/'; if (prefix && !prefix.endsWith('/')) prefix += '/';
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`; const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`;
const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; }; const go = (p) => navTo(`#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`);
const crumbs = h('div', { class: 'crumbs' }, const crumbs = h('div', { class: 'crumbs' },
h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Drives'), h('span', { class: 'sep' }, '/'), h('button', { onclick: () => navTo('#/buckets') }, 'Drives'), h('span', { class: 'sep' }, '/'),
h('button', { onclick: () => go('') }, bucket), h('button', { onclick: () => go('') }, bucket),
prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)])); prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)]));
@ -1789,7 +1822,7 @@ function addSidebarControls() {
bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {}); bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {});
const row = h('div', { class: 'host-controls' }, const row = h('div', { class: 'host-controls' },
h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => { h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => {
try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } try { await bridge.invoke('open-in-tab', { path: hashToPath(location.hash || '#/overview') }); await bridge.sidebar?.close?.(); } catch (e) { fail(e); }
} }, '⤢ Full page')); } }, '⤢ Full page'));
$('.sidebar').insertBefore(row, $('#daemon-pill')); $('.sidebar').insertBefore(row, $('#daemon-pill'));
addMenuAction('⤢ Open as full page', () => row.querySelector('button').click()); addMenuAction('⤢ Open as full page', () => row.querySelector('button').click());
@ -1828,6 +1861,10 @@ function watchVaultLock() {
} }
$('#unlock-btn').addEventListener('click', async () => { $('#unlock-btn').addEventListener('click', async () => {
if (PATH_MODE) {
try { const r = await api('POST', '/api/host/unlock'); if (r.ok) location.reload(); } catch (e) { fail(e); }
return;
}
if (await vaultGate()) { if (await vaultGate()) {
const first = $('#app').dataset.booted !== '1'; const first = $('#app').dataset.booted !== '1';
$('#app').hidden = false; $('#app').hidden = false;
@ -1939,6 +1976,8 @@ function showSignin() {
async function boot() { async function boot() {
const s = await api('GET', '/api/session'); const s = await api('GET', '/api/session');
// pithos.sia in Theseus: locked with the vault, like the sidebar panel.
if (s.locked) { $('#locked').hidden = false; return; }
if (!s.authenticated) { if (!s.authenticated) {
showSignin(); showSignin();
if (!s.passwordLogin) { if (!s.passwordLogin) {
@ -1962,7 +2001,16 @@ async function boot() {
setupFooter(); setupFooter();
if (!bridge && state.status?.host === 'theseus') addBackToSidebar(); if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
connectEvents(); connectEvents();
window.addEventListener('hashchange', route); if (PATH_MODE) {
window.addEventListener('popstate', route);
// In-app links are written as #/ routes; follow them as real addresses.
document.addEventListener('click', (e) => {
const a = e.target.closest?.('a[href^="#/"]');
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
navTo(a.getAttribute('href'));
});
} else window.addEventListener('hashchange', route);
route(); route();
setInterval(refreshStatus, 15000); setInterval(refreshStatus, 15000);
} }