diff --git a/web/index.html b/web/index.html index fedd027..41abf16 100644 --- a/web/index.html +++ b/web/index.html @@ -152,9 +152,9 @@ - +

settings

@@ -164,97 +164,85 @@
-
-
- -
- claim your address -

Pick a username on a server you have pinned. This is what people write to.

-
- - - -
-

Already registered this identity in another browser? Look the address up - instead — this checks it resolves to this identity and claims nothing.

-
- - -
-
+
+

recovery secret

-
+
+

connection

+
- -
+ +
+

+

+
+ +
+ diff --git a/web/js/app.js b/web/js/app.js index 681e21f..6017a0c 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -80,15 +80,21 @@ const fmtRowTime = (seconds) => { }; // A copy button beats asking the user to select 64 hex characters by hand. +// Flashes its own label rather than a toast — settings' rows need feedback +// that doesn't float away from the row that caused it. function copyButton(text, what) { - return el("button", { type: "button", className: "copy", textContent: "copy", onclick: async () => { - try { - await navigator.clipboard.writeText(text); - status(`${what} copied to the clipboard`); - } catch { - status(`the clipboard is unavailable; select and copy ${what} by hand`, true); - } - } }); + return el("button", { type: "button", className: "copy", title: `copy the ${what}`, textContent: "copy", + onclick: async (event) => { + const button = event.currentTarget; + const resting = button.textContent; + try { + await navigator.clipboard.writeText(text); + button.textContent = "copied"; + } catch { + button.textContent = "failed"; + } + setTimeout(() => { button.textContent = resting; }, 1500); + } }); } // The stored account as an address object, default port included when custom. @@ -136,8 +142,9 @@ async function connect(addr, { requirePin }) { if (requirePin && !pinned) throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` + `trusted channel, then pin it under settings → server.`); - const stream = await connectStream(addr.host, addr.port); - const opened = await openSession(stream, addr.host, pinned); + const timeoutMs = store.timeoutSeconds() * 1000; + const stream = await connectStream(addr.host, addr.port, timeoutMs); + const opened = await openSession(stream, addr.host, pinned, timeoutMs); if (!pinned) status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` + `Lookups from this session are UNVERIFIED.`, true); @@ -305,8 +312,9 @@ $("server-form").onsubmit = async (event) => { // else a session proceeds unpinned, but here the whole point of the // fetch is that nothing is pinned yet — the toast would just sit over // the button that is about to pin it. - const stream = await connectStream(host, DEFAULT_PORT); - const opened = await openSession(stream, host, null); + const timeoutMs = store.timeoutSeconds() * 1000; + const stream = await connectStream(host, DEFAULT_PORT, timeoutMs); + const opened = await openSession(stream, host, null, timeoutMs); opened.session.stream.close(); serverFetchedKey = { host, key: opened.serverStatic }; renderServerScreen(); @@ -1053,7 +1061,7 @@ function openContact(address) { textContent: known.verified ? "verified key" : "key pinned on first use" })), field("fingerprint", fingerprint(known.key)), field("public key", b32encode(known.key)), - field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "the address")), + field("address", addr.uri(known.key), false, copyButton(addr.uri(known.key), "address")), el("div", { className: "seed-card" }, el("h4", { textContent: "accept token" }), el("p", { className: "hint", textContent: isAccepted @@ -1181,7 +1189,7 @@ for (const button of settingsTabs) button.onclick = () => showTab(button.dataset function openSettings() { renderSettings(); - showTab("account"); + showTab("identity"); if (!settings.open) settings.showModal(); // showModal() focuses the first focusable node, which is the close button; // the current tab says where you are instead. @@ -1199,106 +1207,94 @@ $("leave-on-server").onchange = (event) => store.setLeaveOnServer(event.target.c // call this rather than reason about which panel it touched. function renderSettings() { $("leave-on-server").checked = store.leaveOnServer(); - renderAccountPanel(); + $("backup-error").textContent = ""; + renderIdentityPanel(); renderServerPanel(); renderAdvancedPanel(); } -function renderServerPanel() { - const pins = store.allPins(); - $("pins").replaceChildren(...pins.map(({ host, key }) => - el("li", {}, - el("span", { textContent: host }), - el("span", { className: "key", textContent: key }), - el("button", { type: "button", textContent: "remove", - onclick: () => { store.unpinServer(host); renderSettings(); } })))); - if (!pins.length) - $("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" })); +// A reusable Cancel/confirm modal for the advanced panel's two irreversible +// actions. 's own cancel path (Escape, backdrop click) counts as +// Cancel, same as clicking the button. +function confirmDialog({ title, body, confirmLabel, danger = false }) { + return new Promise((resolve) => { + $("confirm-title").textContent = title; + $("confirm-text").textContent = body; + const ok = $("confirm-ok"); + ok.textContent = confirmLabel; + ok.className = danger ? "danger" : "filled"; + const dialog = $("confirm"); + let decided = false; + const finish = (result) => { decided = true; resolve(result); dialog.close(); }; + $("confirm-cancel").onclick = () => finish(false); + ok.onclick = () => finish(true); + dialog.addEventListener("close", () => { if (!decided) resolve(false); }, { once: true }); + dialog.showModal(); + }); } -function renderAccountPanel() { +function renderServerPanel() { + const account = store.account(); + const host = account?.host ?? null; + const key = host ? store.serverPin(host) : null; + $("server-connection").replaceChildren( + field("Host", host ?? "not registered yet", !host, host ? copyButton(host, "host") : null), + field("Public Key", key ? b32encode(key) : "not pinned yet", !key, + key ? copyButton(b32encode(key), "public key") : null), + el("div", { className: "spin-row" }, + el("div", { className: "spin-text" }, + el("span", { className: "check-title", textContent: "Timeout" }), + el("span", { className: "hint", textContent: "Seconds before a network call gives up" })), + el("input", { type: "number", min: 1, max: 600, step: 1, value: store.timeoutSeconds(), + onchange: (event) => { + store.setTimeoutSeconds(event.target.value); + event.target.value = store.timeoutSeconds(); // reflects clamping or an ignored non-numeric edit + } }))); +} + +function renderIdentityPanel() { const me = store.identity(); const account = store.account(); - - // Unregistered, claiming an address is the whole point of this panel; - // registered, it is a rarely wanted escape hatch, so it folds away. - $("account-claim").open = !account; - $("account-claim-summary").textContent = account ? "use a different address" : "claim your address"; - - const card = $("account-card"); - card.replaceChildren(); - const secret = $("identity-card"); + const fields = $("identity-fields"); + fields.replaceChildren(); + const secret = $("identity-secret"); secret.replaceChildren(); if (!me) return; - const share = account ? accountAddress().uri(me.publicKey) : null; - card.append( - field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account), - field("fingerprint", fingerprint(me.publicKey)), - field("share", share ?? "claim an address to get one", !share, - share ? copyButton(share, "your address") : null)); + const address = account ? `${account.user}@${account.host}` : "not registered yet"; + const key = b32encode(me.publicKey); + const uri = account ? accountAddress().uri(me.publicKey) : "claim an address to get one"; + fields.append( + field("Address", address, !account, account ? copyButton(address, "address") : null), + field("Key", key, false, copyButton(key, "key")), + field("Fingerprint", fingerprint(me.publicKey), false, copyButton(fingerprint(me.publicKey), "fingerprint")), + field("URI", uri, !account, account ? copyButton(uri, "uri") : null)); const masterBytes = store.master(); if (!masterBytes) return; // Revealed on request rather than on open: the panel a new user is sent to // first is a poor place to paint the only secret there is across the screen. const masked = "\u2022".repeat(64); - const master = el("div", { className: "seed", textContent: masked }); - const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => { - const showing = master.textContent !== masked; - master.textContent = showing ? masked : hex(masterBytes); - reveal.textContent = showing ? "reveal" : "hide"; - } }); + const seedText = hex(masterBytes); + const seed = el("div", { className: "seed", textContent: masked }); + const reveal = el("button", { type: "button", textContent: "reveal", title: "reveal the recovery secret", + onclick: (event) => { + const showing = seed.textContent !== masked; + seed.textContent = showing ? masked : seedText; + event.currentTarget.textContent = showing ? "reveal" : "hide"; + event.currentTarget.title = showing ? "reveal the recovery secret" : "hide the recovery secret"; + } }); - secret.append( - el("div", { className: "seed-card" }, - el("div", { className: "row" }, master, reveal, copyButton(hex(masterBytes), "the secret")), - el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " + - "it loses the address with it — there is no reset. Keep it in a password manager." })), - el("label", { className: "check-row" }, - el("input", { type: "checkbox", checked: store.masterSaved(), onchange: (event) => { - store.setMasterSaved(event.target.checked); - } }), - el("span", {}, - el("span", { className: "check-title", textContent: "I have saved it somewhere safe" }), - el("span", { className: "hint", textContent: "Only ticks the setup checklist off — " + - "nothing here can check, so say so once it is true." })))); + secret.append(el("div", { className: "seed-card" }, + el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")), + el("p", { textContent: "Anyone who holds this can read your mail and write as you, and losing " + + "it loses the address with it — there is no reset. Keep it in a password manager." }))); } function renderAdvancedPanel() { - const card = $("advanced-card"); - card.replaceChildren(); - const me = store.identity(); - if (!me) return; - const rotations = store.rotations(); - const acceptedCount = store.allAccepted().filter(a => a.active).length; - - card.append(...[ - el("h3", { textContent: "this identity" }), - field("rotations", String(rotations)), - field("accepted", String(acceptedCount) + - (store.syncOk() ? "" : " (not yet pushed to the server — accept them again)")), - rotations ? el("p", { className: "hint", textContent: - "Earlier keys are re-derived on demand from your secret, so mail sealed to a superseded key " + - "stays readable." }) : null, - - el("h3", { textContent: "rotate" }), - el("p", { className: "hint", textContent: - "Issues a fresh key — the next one derivable from your secret — with a signed " + - "certificate proving it replaces this one. Contacts accept the change from that chain, and " + - "the old key stays readable. Rotation is not revocation." }), - el("button", { type: "button", className: "outline", textContent: "rotate identity", - onclick: rotateIdentity }), - - el("h3", { className: "danger-head", textContent: "log out" }), - el("p", { className: "hint", textContent: - "Removes this identity from this browser entirely: the secret, pins, contacts and every " + - "cached message. Without a backup of the secret there is no way back in. Mail already " + - "fetched here may have no other copy anywhere, unless \u201cleave mail on server\u201d was on; " + - "whatever is still on the server comes back on the first fetch after logging back in." }), - el("button", { type: "button", className: "danger", textContent: "log out & erase this browser", - onclick: logout }), - ].filter(node => node != null)); + $("rotations-count").replaceChildren(field("Completed", String(store.rotations()))); + $("rotate-error").textContent = ""; + $("logout-error").textContent = ""; } // A pin is keyed by bare host — connect() strips the port before looking one @@ -1316,30 +1312,6 @@ function cleanHost(hostText) { return host; } -// Direct, deliberate entry of a specific key (unlike importData's -// conflict-averse merge), so overwrite is the expected behavior here, not -// something to guard against. -function pinServerFromText(hostText, keyText) { - const host = cleanHost(hostText); - const key = b32decode(keyText.trim()); - if (key.length !== 32) throw new SmolError(`server key is ${key.length} bytes, expected 32`); - store.pinServer(host, key); - return host; -} - -$("pin-form").onsubmit = (event) => { - event.preventDefault(); - const form = event.target; - try { - const host = pinServerFromText(form.host.value, form.key.value); - status(`pinned ${host}`); - renderSettings(); - form.reset(); - } catch (err) { - status(err.message, true); - } -}; - $("import-form").onsubmit = (event) => { event.preventDefault(); const form = event.target; @@ -1356,64 +1328,10 @@ $("import-form").onsubmit = (event) => { } }; -$("register-form").onsubmit = async (event) => { - event.preventDefault(); - const form = event.target; - const submit = form.querySelector("button"); - const resting = submit.textContent; - submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert - submit.textContent = "registering…"; - try { - const me = store.identity(); - if (!me) throw new SmolError("no identity yet"); - const addr = parseAddress(form.address.value.trim()); - const opened = await connect(addr, { requirePin: true }); - try { - await registerOp(opened.session, opened.serverStatic, - { username: addr.user, identity: me, token: form.token.value.trim() }); - } finally { - opened.session.stream.close(); - } - store.setAccount(addr); - status(`registered ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); - renderAccount(); - renderSettings(); - form.reset(); - } catch (err) { - status(err.message, true); - } finally { - submit.disabled = false; - submit.textContent = resting; - } -}; - -// Restoring a master brings back the identity, not the memory of what address a -// *different* browser registered it under — "account" is local-only state, -// never asked of the server. This binds it without REGISTER: RESOLVE the -// address and require it name this exact key, so a typo or someone else's -// address can't silently misfile outgoing Reply-To / fetch under it. Shared -// by the settings recall form and the optional address field on restore. -async function recallAccount(text) { - const me = store.identity(); - if (!me) throw new SmolError("no identity yet"); - const addr = parseAddress(text.trim()); - const opened = await connect(addr, { requirePin: true }); - let current; - try { - ({ identity: current } = await resolveOp(opened.session, addr.user)); - } finally { - opened.session.stream.close(); - } - if (!timingSafeEqual(current, me.publicKey)) - throw new SmolError(`${addr.short} resolves to a different key — not this identity`); - store.setAccount(addr); - return addr; -} - // §2: a master alone does not say which rotation index a server has bound, // so restoring resolves the address and walks indices 0..MAX_CHAIN until one // derives the key RESOLVE returned, correcting restore-identity's default -// guess of index 0. Also binds "account", like recallAccount(). +// guess of index 0. Also binds "account" — onboarding's only path to it now. async function recallAndDiscoverRotation(masterBytes, text) { const addr = parseAddress(text.trim()); const opened = await connect(addr, { requirePin: true }); @@ -1438,80 +1356,89 @@ async function recallAndDiscoverRotation(masterBytes, text) { return addr; } -$("recall-form").onsubmit = async (event) => { - event.preventDefault(); - const form = event.target; - const submit = form.querySelector("button"); - const resting = submit.textContent; - submit.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert - submit.textContent = "recalling…"; - try { - const me = store.identity(); - const addr = await recallAccount(form.address.value); - status(`recalled ${addr.short}\nshare: ${addr.uri(me.publicKey)}`); - renderAccount(); - renderSettings(); - form.reset(); - } catch (err) { - status(err.message, true); - } finally { - submit.disabled = false; - submit.textContent = resting; - } -}; - +// The File System Access API gives a real save dialog with a suggested name; +// where it is unavailable (Firefox, Safari), a plain download is the +// equivalent a browser can offer. Either way nothing is shown on success — +// the file landing where the user put it is the confirmation. $("export-data").onclick = async () => { + $("backup-error").textContent = ""; try { const data = await store.exportData(); - const blob = new Blob([JSON.stringify(data)], { type: "application/json" }); - const url = URL.createObjectURL(blob); const stamp = new Date(data.exportedAt).toISOString().slice(0, 10); - Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click(); - URL.revokeObjectURL(url); - // No counts in the message: the file itself is sealed precisely so an - // observer learns nothing from it, and a summary here would just move - // that same leak from the file to the screen right next to it. - status("exported — sealed to this identity; only a browser with this master restored can open it"); + const filename = `gsmol-export-${stamp}.json`; + const text = JSON.stringify(data); + if (window.showSaveFilePicker) { + const handle = await window.showSaveFilePicker({ suggestedName: filename, + types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] }); + const writable = await handle.createWritable(); + await writable.write(text); + await writable.close(); + } else { + const blob = new Blob([text], { type: "application/json" }); + const url = URL.createObjectURL(blob); + Object.assign(document.createElement("a"), { href: url, download: filename }).click(); + URL.revokeObjectURL(url); + } } catch (err) { - status(err.message, true); + if (err.name !== "AbortError") $("backup-error").textContent = err.message; // AbortError: picker cancelled } }; -$("import-data-trigger").onclick = () => $("import-data").click(); +// Shared by both the File System Access path and the +// fallback it reduces to where that API is unavailable. +async function importBackupText(text) { + if (!text.trim()) return; // empty or whitespace-only content does nothing + await store.importData(JSON.parse(text)); + renderSettings(); + renderFolder(); +} + +$("import-data-trigger").onclick = async () => { + $("backup-error").textContent = ""; + if (!window.showOpenFilePicker) return $("import-data").click(); + try { + const [handle] = await window.showOpenFilePicker({ + types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] }); + await importBackupText(await (await handle.getFile()).text()); + } catch (err) { + if (err.name !== "AbortError") $("backup-error").textContent = err.message; + } +}; $("import-data").onchange = async (event) => { const file = event.target.files[0]; event.target.value = ""; // same file re-selected twice must still fire change if (!file) return; try { - const data = JSON.parse(await file.text()); - const s = await store.importData(data); - const conflicts = s.pinsConflicted + s.contactsConflicted; - status([ - `imported ${s.mailAdded} message(s), ${s.pinsAdded} server key(s), ${s.contactsAdded} contact(s)`, - conflicts ? `${conflicts} entr${conflicts === 1 ? "y" : "ies"} already known under a ` + - "different key — kept what was already here, skipped the import's version" : null, - s.malformed ? `${s.malformed} malformed entr${s.malformed === 1 ? "y" : "ies"} skipped` : null, - ].filter(Boolean).join("\n"), Boolean(conflicts || s.malformed)); - renderSettings(); - renderFolder(); + await importBackupText(await file.text()); } catch (err) { - status(`import failed: ${err.message}`, true); + $("backup-error").textContent = err.message; } }; +// A confirm dialog's body repeats its group's description verbatim, read +// straight from the panel rather than duplicated here so the two can never +// drift apart. textContent carries the HTML source's line-wrap whitespace, +// which this collapses back to plain prose. +const panelText = (id) => $(id).textContent.replace(/\s+/g, " ").trim(); + // §7: rotate to the next index's derived key and rebind the account with a // signed certificate. The superseded key stays derivable from the master, // since mail sealed to it stays readable with nothing else. -async function rotateIdentity(event) { +async function rotateIdentity() { const me = store.identity(); const master = store.master(); const addr = accountAddress(); - if (!me || !master || !addr) return status("rotate needs a registered account", true); - if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " + - "chain. The old key is kept, since mail sealed to it stays readable with nothing else.")) + $("rotate-error").textContent = ""; + if (!me || !master || !addr) { + $("rotate-error").textContent = "rotate needs a registered account"; return; - const button = event.target; + } + const confirmed = await confirmDialog({ + title: "Rotate the identity?", body: panelText("rotations-description"), confirmLabel: "Rotate" }); + if (!confirmed) return; + const button = $("rotate-identity"); + const resting = button.textContent; button.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert button.textContent = "rotating…"; try { @@ -1527,27 +1454,32 @@ async function rotateIdentity(event) { store.advanceRotation(); // The key set grew, so a cached "not one of our keys" failure may be stale. openedCache.clear(); - status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`); renderAccount(); - renderSettings(); // rebuilds the advanced panel, this button included + renderSettings(); renderFolder(); } catch (err) { + $("rotate-error").textContent = err.message; + } finally { button.disabled = false; - button.textContent = "rotate identity"; - status(err.message, true); + button.textContent = resting; } } +$("rotate-identity").onclick = rotateIdentity; // Unlike rotate, nothing is kept: this is the master's only local copy, gone. async function logout() { - if (!window.confirm( - "Log out and erase this browser?\n\n" + - "This deletes the secret, pins, contacts and every cached message from this browser. Make sure " + - "the secret is backed up elsewhere first — without it there is no way back in." - )) return; - await store.clearAll(); - location.reload(); + $("logout-error").textContent = ""; + const confirmed = await confirmDialog({ title: "Log out and delete all data?", + body: panelText("logout-description"), confirmLabel: "Log Out", danger: true }); + if (!confirmed) return; + try { + await store.clearAll(); + location.reload(); + } catch (err) { + $("logout-error").textContent = err.message; + } } +$("logout").onclick = logout; // --- boot -------------------------------------------------------------------------- diff --git a/web/js/proto.js b/web/js/proto.js index e8ed6ec..7499268 100644 --- a/web/js/proto.js +++ b/web/js/proto.js @@ -279,11 +279,12 @@ export function walkChain(username, pinned, current, chain) { // Reassembles a byte stream (WebSocket or TCP) into exact-length reads. class ByteStream { - constructor(stream) { + constructor(stream, timeoutMs = 0) { this.chunks = []; this.length = 0; this.closed = null; this.waiters = []; + this.timeoutMs = timeoutMs; stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); }; stream.onClose = () => this.fail(new SmolError("server closed the connection")); } @@ -305,7 +306,18 @@ class ByteStream { async readExact(n) { if (this.closed) throw this.closed; if (this.length < n) { - await new Promise((resolve, reject) => this.waiters.push({ need: n, resolve, reject })); + await new Promise((resolve, reject) => { + const waiter = { need: n, resolve, reject }; + this.waiters.push(waiter); + if (!this.timeoutMs) return; + // Settings' timeout, applied per read: a stalled handshake or request + // otherwise waits here forever, since nothing else ever rejects it. + setTimeout(() => { + if (!this.waiters.includes(waiter)) return; // already settled elsewhere + this.waiters = this.waiters.filter(w => w !== waiter); + reject(new SmolError(`no response from the server within ${this.timeoutMs / 1000}s`)); + }, this.timeoutMs); + }); if (this.closed) throw this.closed; } const out = new Uint8Array(n); @@ -369,8 +381,8 @@ export class Session { // Handshake plus §4 pinning. Returns the session, the server's static key as // revealed by the handshake, and whether that key was already pinned. -export async function openSession(stream, host, pinned = null) { - const wire = new ByteStream(stream); +export async function openSession(stream, host, pinned = null, timeoutMs = 0) { + const wire = new ByteStream(stream, timeoutMs); const nx = new NxInitiator(); const m1 = nx.writeMessage1(); stream.send(concat(u16BE(m1.length), m1)); diff --git a/web/js/store.js b/web/js/store.js index 3bb8c4d..25d43b9 100644 --- a/web/js/store.js +++ b/web/js/store.js @@ -141,12 +141,6 @@ export function pinServer(host, keyBytes) { }); } -export function unpinServer(host) { - update(state => { - delete (state.servers || {})[host]; - }); -} - export function allPins() { return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key })); } @@ -164,14 +158,17 @@ export function setLeaveOnServer(value) { update(state => { state.leaveOnServer = value; }); } -// The user's own word that the master is written down somewhere else. Nothing -// can verify it, so it is a settings-panel reminder, never a guard. -export function masterSaved() { - return load().masterSaved ?? false; +// Seconds a network call waits for a response before giving up. Applied per +// read, not per operation, so a slow-but-trickling exchange is not cut off +// just because it spans several reads. +export function timeoutSeconds() { + return load().timeoutSeconds ?? 30; } -export function setMasterSaved(value) { - update(state => { state.masterSaved = value; }); +export function setTimeoutSeconds(value) { + const n = Math.trunc(Number(value)); + if (!Number.isFinite(n)) return; // non-numeric input is ignored, not an error + update(state => { state.timeoutSeconds = Math.min(600, Math.max(1, n)); }); } // Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over", @@ -185,7 +182,6 @@ export function discardIdentity() { delete state.syncOk; delete state.afterTime; delete state.afterId; - delete state.masterSaved; }); } diff --git a/web/js/transport.js b/web/js/transport.js index 7d0e7b0..669c178 100644 --- a/web/js/transport.js +++ b/web/js/transport.js @@ -4,7 +4,7 @@ const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`; -export function connectStream(host, port) { +export function connectStream(host, port, timeoutMs = 0) { return new Promise((resolve, reject) => { const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`); socket.binaryType = "arraybuffer"; @@ -14,8 +14,15 @@ export function connectStream(host, port) { onData: null, onClose: null, }; - socket.onopen = () => resolve(stream); - socket.onerror = () => reject(new Error(`cannot reach the bridge at ${BRIDGE}`)); + // Settings' timeout only governs this; the WebSocket may otherwise never + // fire open, error, or close if something between here and the bridge + // just drops packets. + const timer = timeoutMs ? setTimeout(() => { + socket.close(); + reject(new Error(`could not reach ${host}:${port} within ${timeoutMs / 1000}s`)); + }, timeoutMs) : null; + socket.onopen = () => { clearTimeout(timer); resolve(stream); }; + socket.onerror = () => { clearTimeout(timer); reject(new Error(`cannot reach the bridge at ${BRIDGE}`)); }; socket.onmessage = event => stream.onData?.(new Uint8Array(event.data)); socket.onclose = () => stream.onClose?.(); }); diff --git a/web/style.css b/web/style.css index 783b520..485bcd3 100644 --- a/web/style.css +++ b/web/style.css @@ -437,6 +437,12 @@ dialog::backdrop { background: var(--backdrop); } #compose footer { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding-top: 4px; flex-wrap: wrap; } #compose .anon { font-size: 13px; color: var(--muted); margin-right: auto; display: flex; align-items: center; gap: 6px; white-space: nowrap; } +#confirm { max-width: min(420px, calc(100vw - 32px)); } +#confirm .confirm-body { padding: 22px 22px 4px; } +#confirm .confirm-body h2 { margin: 0 0 8px; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; } +#confirm .confirm-body p { margin: 0; font-size: 13px; color: var(--muted); white-space: pre-wrap; } +#confirm footer { display: flex; gap: 8px; justify-content: flex-end; padding: 18px 22px 22px; } + /* Without this every action read exactly like "cancel" and "close". */ .filled { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; } .filled:hover { background: var(--accent); box-shadow: var(--shadow); } @@ -504,18 +510,6 @@ dialog::backdrop { background: var(--backdrop); } #settings label input { font-family: var(--mono); font-size: 13px; } #settings details { margin: 12px 0 2px; } -/* Unregistered this is the panel's whole point, so it is a card rather than a - footnote; registered, the same block collapses to one line. */ -#account-claim { - margin: 18px 0 4px; - padding: 12px 14px; - background: var(--raised); - border: 1px solid var(--border); - border-radius: var(--radius); -} -#account-claim > summary { font-size: 13px; font-weight: 600; color: var(--accent); } -#account-claim form + .hint { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); } - /* A checkbox with a multi-line label, e.g. "leave mail on server" — the box sits at the label's cap height rather than centered against the full wrapped block, which is what made it look adrift. */ @@ -536,12 +530,19 @@ dialog::backdrop { background: var(--backdrop); } .seed-card .row { display: flex; align-items: center; gap: 8px; } .seed-card .seed { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12px; word-break: break-all; } .seed-card p { margin: 10px 0 0; font-size: 12px; color: var(--muted); } -.backup-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; } -.kv { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; } -.kv li { display: flex; gap: 10px; padding: 5px 0; align-items: baseline; } -.kv li + li { border-top: 1px solid var(--divider); } -.kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; } +/* A button whose result is explained underneath rather than beside it — each + of export/import needs its own line, so side-by-side ran out of room. */ +.action-row { margin: 14px 0; } +.action-row .hint { margin: 4px 0 0; } + +/* A title/subtitle pair with a control at the end, e.g. the timeout spinner — + same shape as .check-row but for a value instead of a toggle. */ +.spin-row { display: flex; align-items: center; gap: 10px; margin: 14px 0; } +.spin-row .spin-text { flex: 1; min-width: 0; } +.spin-row .spin-text .check-title { display: block; } +.spin-row .spin-text .hint { display: block; margin: 2px 0 0; } +.spin-row input[type="number"] { width: 72px; font-family: var(--mono); font-size: 13px; text-align: right; } .copy { font-size: 12px; padding: 4px 10px; }