feat: restructure settings into identity, server, backup and advanced tabs
Drops the per-server pin list down to the one server your account is bound to, adds an enforced network timeout, and replaces the global status toast inside settings with inline feedback and a reusable confirm dialog for rotate/log out. Mail-fetching preferences move into the server tab; the now-superseded claim/recall forms are gone since onboarding covers both.
This commit is contained in:
parent
fb0ca5c8cc
commit
84397a3fdd
6 changed files with 270 additions and 334 deletions
118
web/index.html
118
web/index.html
|
|
@ -152,9 +152,9 @@
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<!-- Tabs, not one scroll: the account, the server, a mailbox preference, the
|
<!-- Tabs, not one scroll: identity, server, the backup file and the
|
||||||
backup file and the irreversible operations have nothing to say to each
|
irreversible operations have nothing to say to each other, and reading
|
||||||
other, and reading all five at once was the overwhelming part. -->
|
all four at once was the overwhelming part. -->
|
||||||
<dialog id="settings">
|
<dialog id="settings">
|
||||||
<header class="dialog-head">
|
<header class="dialog-head">
|
||||||
<h2>settings</h2>
|
<h2>settings</h2>
|
||||||
|
|
@ -164,97 +164,85 @@
|
||||||
|
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
|
<nav id="settings-tabs" class="settings-tabs" aria-label="settings sections">
|
||||||
<button type="button" data-tab="account" class="active" aria-current="true">account</button>
|
<button type="button" data-tab="identity" class="active" aria-current="true">identity</button>
|
||||||
<button type="button" data-tab="server">server</button>
|
<button type="button" data-tab="server">server</button>
|
||||||
<button type="button" data-tab="mail">mail</button>
|
<button type="button" data-tab="backup">backup</button>
|
||||||
<button type="button" data-tab="data">backup</button>
|
|
||||||
<button type="button" data-tab="advanced">advanced</button>
|
<button type="button" data-tab="advanced">advanced</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div id="settings-panels" class="settings-panels">
|
<div id="settings-panels" class="settings-panels">
|
||||||
<section class="panel" data-panel="account">
|
<section class="panel" data-panel="identity">
|
||||||
<div id="account-card"></div>
|
<div id="identity-fields"></div>
|
||||||
|
|
||||||
<details id="account-claim">
|
|
||||||
<summary id="account-claim-summary">claim your address</summary>
|
|
||||||
<p class="hint">Pick a username on a server you have pinned. This is what people write to.</p>
|
|
||||||
<form id="register-form">
|
|
||||||
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
|
|
||||||
<label>invite token<input name="token" placeholder="only if your server asks"></label>
|
|
||||||
<button class="filled">register</button>
|
|
||||||
</form>
|
|
||||||
<p class="hint">Already registered this identity in another browser? Look the address up
|
|
||||||
instead — this checks it resolves to this identity and claims nothing.</p>
|
|
||||||
<form id="recall-form">
|
|
||||||
<label>address<input name="address" placeholder="alice@example.org" spellcheck="false" required></label>
|
|
||||||
<button>look it up</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<h3>recovery secret</h3>
|
<h3>recovery secret</h3>
|
||||||
<div id="identity-card"></div>
|
<div id="identity-secret"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" data-panel="server" hidden>
|
<section class="panel" data-panel="server" hidden>
|
||||||
<h3>your server</h3>
|
<h3>connection</h3>
|
||||||
<p class="hint">Pinning a server's public key is what lets this client tell your real server
|
<div id="server-connection"></div>
|
||||||
from anything pretending to be it. Ask its operator for the key over a channel you already
|
|
||||||
trust.</p>
|
|
||||||
<details>
|
|
||||||
<summary>why does this matter?</summary>
|
|
||||||
<p class="hint">Nothing else authenticates the server, so an unpinned host could be anyone —
|
|
||||||
registering, looking up an address and fetching all refuse to run against one.</p>
|
|
||||||
</details>
|
|
||||||
<ul id="pins" class="kv"></ul>
|
|
||||||
<form id="pin-form">
|
|
||||||
<label>host<input name="host" placeholder="example.org" spellcheck="false" required></label>
|
|
||||||
<label>public key<input name="key" placeholder="base32" spellcheck="false" required></label>
|
|
||||||
<button class="filled">pin</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel" data-panel="mail" hidden>
|
|
||||||
<h3>fetching</h3>
|
<h3>fetching</h3>
|
||||||
<label class="check-row">
|
<label class="check-row">
|
||||||
<input type="checkbox" id="leave-on-server">
|
<input type="checkbox" id="leave-on-server">
|
||||||
<span>
|
<span>
|
||||||
<span class="check-title">leave mail on server</span>
|
<span class="check-title">Leave mail on server</span>
|
||||||
<span class="hint">Keep a copy on the server after fetching, so another browser can
|
<span class="hint">Keep a copy on the server after fetching, so another client can fetch
|
||||||
fetch it too. Deleting a message here removes it from both.</span>
|
it too. When this is enabled, deleting a message removes it from this client and the
|
||||||
|
server.</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<details>
|
|
||||||
<summary>what happens by default?</summary>
|
|
||||||
<p class="hint">Fetching acknowledges each message once it has been verified, and the server
|
|
||||||
drops its copy — so mail you have read lives only in this browser. Back it up under
|
|
||||||
"backup", or turn the option above on.</p>
|
|
||||||
</details>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" data-panel="data" hidden>
|
<section class="panel" data-panel="backup" hidden>
|
||||||
<h3>backup & transfer</h3>
|
<p class="hint">One file holding your mail, contacts and server info for moving to another
|
||||||
<p class="hint">One file holding your mail, contacts and pinned servers — for moving to
|
client, or for keeping what the server no longer has. It is sealed to this identity and
|
||||||
another browser, or for keeping what the server no longer has.</p>
|
deliberately leaves the secret itself out.</p>
|
||||||
<div class="backup-row">
|
<div class="action-row">
|
||||||
<button type="button" id="export-data" class="filled">export</button>
|
<button type="button" id="export-data" class="filled">Export</button>
|
||||||
<button type="button" id="import-data-trigger" class="outline">import</button>
|
<span class="hint">Write a backup file</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-row">
|
||||||
|
<button type="button" id="import-data-trigger" class="outline">Import</button>
|
||||||
|
<span class="hint">Restore from a backup file</span>
|
||||||
<input id="import-data" type="file" accept="application/json" hidden>
|
<input id="import-data" type="file" accept="application/json" hidden>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
<span id="backup-error" class="error"></span>
|
||||||
<summary>what is in the file, and is it safe?</summary>
|
|
||||||
<p class="hint">It is sealed to this identity: only a browser with this secret restored can
|
|
||||||
open it. It deliberately leaves the secret itself out — that belongs in a password
|
|
||||||
manager, not in a file in your downloads folder.</p>
|
|
||||||
</details>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel" data-panel="advanced" hidden>
|
<section class="panel" data-panel="advanced" hidden>
|
||||||
<div id="advanced-card"></div>
|
<h3>rotations</h3>
|
||||||
|
<p id="rotations-description" class="hint">Issues a fresh key 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.</p>
|
||||||
|
<div id="rotations-count"></div>
|
||||||
|
<button type="button" id="rotate-identity" class="outline">Rotate identity</button>
|
||||||
|
<span id="rotate-error" class="error"></span>
|
||||||
|
|
||||||
|
<h3 class="danger-head">log out</h3>
|
||||||
|
<p id="logout-description" class="hint">Removes this identity from this client 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 "leave mail on
|
||||||
|
server" was on. Whatever is still on the server comes back on the first fetch after logging
|
||||||
|
back in.</p>
|
||||||
|
<button type="button" id="logout" class="danger">Log out and delete all data</button>
|
||||||
|
<span id="logout-error" class="error"></span>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="confirm">
|
||||||
|
<div class="confirm-body">
|
||||||
|
<h2 id="confirm-title"></h2>
|
||||||
|
<p id="confirm-text"></p>
|
||||||
|
</div>
|
||||||
|
<footer>
|
||||||
|
<button type="button" id="confirm-cancel" autofocus>Cancel</button>
|
||||||
|
<button type="button" id="confirm-ok"></button>
|
||||||
|
</footer>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
|
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
|
||||||
<script type="module" src="js/app.js"></script>
|
<script type="module" src="js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
396
web/js/app.js
396
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.
|
// 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) {
|
function copyButton(text, what) {
|
||||||
return el("button", { type: "button", className: "copy", textContent: "copy", onclick: async () => {
|
return el("button", { type: "button", className: "copy", title: `copy the ${what}`, textContent: "copy",
|
||||||
try {
|
onclick: async (event) => {
|
||||||
await navigator.clipboard.writeText(text);
|
const button = event.currentTarget;
|
||||||
status(`${what} copied to the clipboard`);
|
const resting = button.textContent;
|
||||||
} catch {
|
try {
|
||||||
status(`the clipboard is unavailable; select and copy ${what} by hand`, true);
|
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.
|
// The stored account as an address object, default port included when custom.
|
||||||
|
|
@ -136,8 +142,9 @@ async function connect(addr, { requirePin }) {
|
||||||
if (requirePin && !pinned)
|
if (requirePin && !pinned)
|
||||||
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
|
throw new SmolError(`no pinned key for ${addr.host}. Obtain it from the operator through a ` +
|
||||||
`trusted channel, then pin it under settings → server.`);
|
`trusted channel, then pin it under settings → server.`);
|
||||||
const stream = await connectStream(addr.host, addr.port);
|
const timeoutMs = store.timeoutSeconds() * 1000;
|
||||||
const opened = await openSession(stream, addr.host, pinned);
|
const stream = await connectStream(addr.host, addr.port, timeoutMs);
|
||||||
|
const opened = await openSession(stream, addr.host, pinned, timeoutMs);
|
||||||
if (!pinned)
|
if (!pinned)
|
||||||
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
status(`${addr.host} is not pinned; its key is ${b32encode(opened.serverStatic)}.\n` +
|
||||||
`Lookups from this session are UNVERIFIED.`, true);
|
`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
|
// 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
|
// fetch is that nothing is pinned yet — the toast would just sit over
|
||||||
// the button that is about to pin it.
|
// the button that is about to pin it.
|
||||||
const stream = await connectStream(host, DEFAULT_PORT);
|
const timeoutMs = store.timeoutSeconds() * 1000;
|
||||||
const opened = await openSession(stream, host, null);
|
const stream = await connectStream(host, DEFAULT_PORT, timeoutMs);
|
||||||
|
const opened = await openSession(stream, host, null, timeoutMs);
|
||||||
opened.session.stream.close();
|
opened.session.stream.close();
|
||||||
serverFetchedKey = { host, key: opened.serverStatic };
|
serverFetchedKey = { host, key: opened.serverStatic };
|
||||||
renderServerScreen();
|
renderServerScreen();
|
||||||
|
|
@ -1053,7 +1061,7 @@ function openContact(address) {
|
||||||
textContent: known.verified ? "verified key" : "key pinned on first use" })),
|
textContent: known.verified ? "verified key" : "key pinned on first use" })),
|
||||||
field("fingerprint", fingerprint(known.key)),
|
field("fingerprint", fingerprint(known.key)),
|
||||||
field("public key", b32encode(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("div", { className: "seed-card" },
|
||||||
el("h4", { textContent: "accept token" }),
|
el("h4", { textContent: "accept token" }),
|
||||||
el("p", { className: "hint", textContent: isAccepted
|
el("p", { className: "hint", textContent: isAccepted
|
||||||
|
|
@ -1181,7 +1189,7 @@ for (const button of settingsTabs) button.onclick = () => showTab(button.dataset
|
||||||
|
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
renderSettings();
|
renderSettings();
|
||||||
showTab("account");
|
showTab("identity");
|
||||||
if (!settings.open) settings.showModal();
|
if (!settings.open) settings.showModal();
|
||||||
// showModal() focuses the first focusable node, which is the close button;
|
// showModal() focuses the first focusable node, which is the close button;
|
||||||
// the current tab says where you are instead.
|
// 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.
|
// call this rather than reason about which panel it touched.
|
||||||
function renderSettings() {
|
function renderSettings() {
|
||||||
$("leave-on-server").checked = store.leaveOnServer();
|
$("leave-on-server").checked = store.leaveOnServer();
|
||||||
renderAccountPanel();
|
$("backup-error").textContent = "";
|
||||||
|
renderIdentityPanel();
|
||||||
renderServerPanel();
|
renderServerPanel();
|
||||||
renderAdvancedPanel();
|
renderAdvancedPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderServerPanel() {
|
// A reusable Cancel/confirm modal for the advanced panel's two irreversible
|
||||||
const pins = store.allPins();
|
// actions. <dialog>'s own cancel path (Escape, backdrop click) counts as
|
||||||
$("pins").replaceChildren(...pins.map(({ host, key }) =>
|
// Cancel, same as clicking the button.
|
||||||
el("li", {},
|
function confirmDialog({ title, body, confirmLabel, danger = false }) {
|
||||||
el("span", { textContent: host }),
|
return new Promise((resolve) => {
|
||||||
el("span", { className: "key", textContent: key }),
|
$("confirm-title").textContent = title;
|
||||||
el("button", { type: "button", textContent: "remove",
|
$("confirm-text").textContent = body;
|
||||||
onclick: () => { store.unpinServer(host); renderSettings(); } }))));
|
const ok = $("confirm-ok");
|
||||||
if (!pins.length)
|
ok.textContent = confirmLabel;
|
||||||
$("pins").append(el("li", { className: "placeholder", textContent: "no server pinned yet" }));
|
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 me = store.identity();
|
||||||
const account = store.account();
|
const account = store.account();
|
||||||
|
const fields = $("identity-fields");
|
||||||
// Unregistered, claiming an address is the whole point of this panel;
|
fields.replaceChildren();
|
||||||
// registered, it is a rarely wanted escape hatch, so it folds away.
|
const secret = $("identity-secret");
|
||||||
$("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");
|
|
||||||
secret.replaceChildren();
|
secret.replaceChildren();
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
|
||||||
const share = account ? accountAddress().uri(me.publicKey) : null;
|
const address = account ? `${account.user}@${account.host}` : "not registered yet";
|
||||||
card.append(
|
const key = b32encode(me.publicKey);
|
||||||
field("address", account ? `${account.user}@${account.host}` : "not registered yet", !account),
|
const uri = account ? accountAddress().uri(me.publicKey) : "claim an address to get one";
|
||||||
field("fingerprint", fingerprint(me.publicKey)),
|
fields.append(
|
||||||
field("share", share ?? "claim an address to get one", !share,
|
field("Address", address, !account, account ? copyButton(address, "address") : null),
|
||||||
share ? copyButton(share, "your 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();
|
const masterBytes = store.master();
|
||||||
if (!masterBytes) return;
|
if (!masterBytes) return;
|
||||||
// Revealed on request rather than on open: the panel a new user is sent to
|
// 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.
|
// first is a poor place to paint the only secret there is across the screen.
|
||||||
const masked = "\u2022".repeat(64);
|
const masked = "\u2022".repeat(64);
|
||||||
const master = el("div", { className: "seed", textContent: masked });
|
const seedText = hex(masterBytes);
|
||||||
const reveal = el("button", { type: "button", textContent: "reveal", onclick: () => {
|
const seed = el("div", { className: "seed", textContent: masked });
|
||||||
const showing = master.textContent !== masked;
|
const reveal = el("button", { type: "button", textContent: "reveal", title: "reveal the recovery secret",
|
||||||
master.textContent = showing ? masked : hex(masterBytes);
|
onclick: (event) => {
|
||||||
reveal.textContent = showing ? "reveal" : "hide";
|
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(
|
secret.append(el("div", { className: "seed-card" },
|
||||||
el("div", { className: "seed-card" },
|
el("div", { className: "row" }, seed, reveal, copyButton(seedText, "recovery secret")),
|
||||||
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 " +
|
||||||
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." })));
|
||||||
"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." }))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAdvancedPanel() {
|
function renderAdvancedPanel() {
|
||||||
const card = $("advanced-card");
|
$("rotations-count").replaceChildren(field("Completed", String(store.rotations())));
|
||||||
card.replaceChildren();
|
$("rotate-error").textContent = "";
|
||||||
const me = store.identity();
|
$("logout-error").textContent = "";
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pin is keyed by bare host — connect() strips the port before looking one
|
// A pin is keyed by bare host — connect() strips the port before looking one
|
||||||
|
|
@ -1316,30 +1312,6 @@ function cleanHost(hostText) {
|
||||||
return host;
|
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) => {
|
$("import-form").onsubmit = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.target;
|
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,
|
// §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
|
// so restoring resolves the address and walks indices 0..MAX_CHAIN until one
|
||||||
// derives the key RESOLVE returned, correcting restore-identity's default
|
// 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) {
|
async function recallAndDiscoverRotation(masterBytes, text) {
|
||||||
const addr = parseAddress(text.trim());
|
const addr = parseAddress(text.trim());
|
||||||
const opened = await connect(addr, { requirePin: true });
|
const opened = await connect(addr, { requirePin: true });
|
||||||
|
|
@ -1438,80 +1356,89 @@ async function recallAndDiscoverRotation(masterBytes, text) {
|
||||||
return addr;
|
return addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
$("recall-form").onsubmit = async (event) => {
|
// The File System Access API gives a real save dialog with a suggested name;
|
||||||
event.preventDefault();
|
// where it is unavailable (Firefox, Safari), a plain download is the
|
||||||
const form = event.target;
|
// equivalent a browser can offer. Either way nothing is shown on success —
|
||||||
const submit = form.querySelector("button");
|
// the file landing where the user put it is the confirmation.
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
$("export-data").onclick = async () => {
|
$("export-data").onclick = async () => {
|
||||||
|
$("backup-error").textContent = "";
|
||||||
try {
|
try {
|
||||||
const data = await store.exportData();
|
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);
|
const stamp = new Date(data.exportedAt).toISOString().slice(0, 10);
|
||||||
Object.assign(document.createElement("a"), { href: url, download: `gsmol-export-${stamp}.json` }).click();
|
const filename = `gsmol-export-${stamp}.json`;
|
||||||
URL.revokeObjectURL(url);
|
const text = JSON.stringify(data);
|
||||||
// No counts in the message: the file itself is sealed precisely so an
|
if (window.showSaveFilePicker) {
|
||||||
// observer learns nothing from it, and a summary here would just move
|
const handle = await window.showSaveFilePicker({ suggestedName: filename,
|
||||||
// that same leak from the file to the screen right next to it.
|
types: [{ description: "gsmol backup", accept: { "application/json": [".json"] } }] });
|
||||||
status("exported — sealed to this identity; only a browser with this master restored can open it");
|
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) {
|
} 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 <input type=file>
|
||||||
|
// 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) => {
|
$("import-data").onchange = async (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
event.target.value = ""; // same file re-selected twice must still fire change
|
event.target.value = ""; // same file re-selected twice must still fire change
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(await file.text());
|
await importBackupText(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();
|
|
||||||
} catch (err) {
|
} 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
|
// §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,
|
// signed certificate. The superseded key stays derivable from the master,
|
||||||
// since mail sealed to it stays readable with nothing else.
|
// since mail sealed to it stays readable with nothing else.
|
||||||
async function rotateIdentity(event) {
|
async function rotateIdentity() {
|
||||||
const me = store.identity();
|
const me = store.identity();
|
||||||
const master = store.master();
|
const master = store.master();
|
||||||
const addr = accountAddress();
|
const addr = accountAddress();
|
||||||
if (!me || !master || !addr) return status("rotate needs a registered account", true);
|
$("rotate-error").textContent = "";
|
||||||
if (!window.confirm("Rotate the identity key?\n\nContacts accept the change from the signed " +
|
if (!me || !master || !addr) {
|
||||||
"chain. The old key is kept, since mail sealed to it stays readable with nothing else."))
|
$("rotate-error").textContent = "rotate needs a registered account";
|
||||||
return;
|
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.disabled = true; // a round trip to the server takes a moment; say so rather than sit inert
|
||||||
button.textContent = "rotating…";
|
button.textContent = "rotating…";
|
||||||
try {
|
try {
|
||||||
|
|
@ -1527,27 +1454,32 @@ async function rotateIdentity(event) {
|
||||||
store.advanceRotation();
|
store.advanceRotation();
|
||||||
// The key set grew, so a cached "not one of our keys" failure may be stale.
|
// The key set grew, so a cached "not one of our keys" failure may be stale.
|
||||||
openedCache.clear();
|
openedCache.clear();
|
||||||
status(`rotated ${addr.short}\nnew key: ${b32encode(fresh.publicKey)}`);
|
|
||||||
renderAccount();
|
renderAccount();
|
||||||
renderSettings(); // rebuilds the advanced panel, this button included
|
renderSettings();
|
||||||
renderFolder();
|
renderFolder();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
$("rotate-error").textContent = err.message;
|
||||||
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
button.textContent = "rotate identity";
|
button.textContent = resting;
|
||||||
status(err.message, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$("rotate-identity").onclick = rotateIdentity;
|
||||||
|
|
||||||
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
// Unlike rotate, nothing is kept: this is the master's only local copy, gone.
|
||||||
async function logout() {
|
async function logout() {
|
||||||
if (!window.confirm(
|
$("logout-error").textContent = "";
|
||||||
"Log out and erase this browser?\n\n" +
|
const confirmed = await confirmDialog({ title: "Log out and delete all data?",
|
||||||
"This deletes the secret, pins, contacts and every cached message from this browser. Make sure " +
|
body: panelText("logout-description"), confirmLabel: "Log Out", danger: true });
|
||||||
"the secret is backed up elsewhere first — without it there is no way back in."
|
if (!confirmed) return;
|
||||||
)) return;
|
try {
|
||||||
await store.clearAll();
|
await store.clearAll();
|
||||||
location.reload();
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
$("logout-error").textContent = err.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
$("logout").onclick = logout;
|
||||||
|
|
||||||
// --- boot --------------------------------------------------------------------------
|
// --- boot --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -279,11 +279,12 @@ export function walkChain(username, pinned, current, chain) {
|
||||||
|
|
||||||
// Reassembles a byte stream (WebSocket or TCP) into exact-length reads.
|
// Reassembles a byte stream (WebSocket or TCP) into exact-length reads.
|
||||||
class ByteStream {
|
class ByteStream {
|
||||||
constructor(stream) {
|
constructor(stream, timeoutMs = 0) {
|
||||||
this.chunks = [];
|
this.chunks = [];
|
||||||
this.length = 0;
|
this.length = 0;
|
||||||
this.closed = null;
|
this.closed = null;
|
||||||
this.waiters = [];
|
this.waiters = [];
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); };
|
stream.onData = (data) => { this.chunks.push(data); this.length += data.length; this.wake(); };
|
||||||
stream.onClose = () => this.fail(new SmolError("server closed the connection"));
|
stream.onClose = () => this.fail(new SmolError("server closed the connection"));
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +306,18 @@ class ByteStream {
|
||||||
async readExact(n) {
|
async readExact(n) {
|
||||||
if (this.closed) throw this.closed;
|
if (this.closed) throw this.closed;
|
||||||
if (this.length < n) {
|
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;
|
if (this.closed) throw this.closed;
|
||||||
}
|
}
|
||||||
const out = new Uint8Array(n);
|
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
|
// Handshake plus §4 pinning. Returns the session, the server's static key as
|
||||||
// revealed by the handshake, and whether that key was already pinned.
|
// revealed by the handshake, and whether that key was already pinned.
|
||||||
export async function openSession(stream, host, pinned = null) {
|
export async function openSession(stream, host, pinned = null, timeoutMs = 0) {
|
||||||
const wire = new ByteStream(stream);
|
const wire = new ByteStream(stream, timeoutMs);
|
||||||
const nx = new NxInitiator();
|
const nx = new NxInitiator();
|
||||||
const m1 = nx.writeMessage1();
|
const m1 = nx.writeMessage1();
|
||||||
stream.send(concat(u16BE(m1.length), m1));
|
stream.send(concat(u16BE(m1.length), m1));
|
||||||
|
|
|
||||||
|
|
@ -141,12 +141,6 @@ export function pinServer(host, keyBytes) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unpinServer(host) {
|
|
||||||
update(state => {
|
|
||||||
delete (state.servers || {})[host];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function allPins() {
|
export function allPins() {
|
||||||
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
return Object.entries(load().servers || {}).map(([host, key]) => ({ host, key }));
|
||||||
}
|
}
|
||||||
|
|
@ -164,14 +158,17 @@ export function setLeaveOnServer(value) {
|
||||||
update(state => { state.leaveOnServer = value; });
|
update(state => { state.leaveOnServer = value; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user's own word that the master is written down somewhere else. Nothing
|
// Seconds a network call waits for a response before giving up. Applied per
|
||||||
// can verify it, so it is a settings-panel reminder, never a guard.
|
// read, not per operation, so a slow-but-trickling exchange is not cut off
|
||||||
export function masterSaved() {
|
// just because it spans several reads.
|
||||||
return load().masterSaved ?? false;
|
export function timeoutSeconds() {
|
||||||
|
return load().timeoutSeconds ?? 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setMasterSaved(value) {
|
export function setTimeoutSeconds(value) {
|
||||||
update(state => { state.masterSaved = 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",
|
// Undoes setIdentity()/restoreMaster(): onboarding's "back"/"start over",
|
||||||
|
|
@ -185,7 +182,6 @@ export function discardIdentity() {
|
||||||
delete state.syncOk;
|
delete state.syncOk;
|
||||||
delete state.afterTime;
|
delete state.afterTime;
|
||||||
delete state.afterId;
|
delete state.afterId;
|
||||||
delete state.masterSaved;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
const BRIDGE = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`);
|
const socket = new WebSocket(`${BRIDGE}/tcp/${host}/${port}`);
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
|
|
@ -14,8 +14,15 @@ export function connectStream(host, port) {
|
||||||
onData: null,
|
onData: null,
|
||||||
onClose: null,
|
onClose: null,
|
||||||
};
|
};
|
||||||
socket.onopen = () => resolve(stream);
|
// Settings' timeout only governs this; the WebSocket may otherwise never
|
||||||
socket.onerror = () => reject(new Error(`cannot reach the bridge at ${BRIDGE}`));
|
// 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.onmessage = event => stream.onData?.(new Uint8Array(event.data));
|
||||||
socket.onclose = () => stream.onClose?.();
|
socket.onclose = () => stream.onClose?.();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 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; }
|
#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". */
|
/* 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 { background: var(--accent); color: var(--on-accent); font-weight: 500; padding: 8px 20px; }
|
||||||
.filled:hover { background: var(--accent); box-shadow: var(--shadow); }
|
.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 label input { font-family: var(--mono); font-size: 13px; }
|
||||||
#settings details { margin: 12px 0 2px; }
|
#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
|
/* 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
|
sits at the label's cap height rather than centered against the full wrapped
|
||||||
block, which is what made it look adrift. */
|
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 .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 .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); }
|
.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; }
|
/* A button whose result is explained underneath rather than beside it — each
|
||||||
.kv li { display: flex; gap: 10px; padding: 5px 0; align-items: baseline; }
|
of export/import needs its own line, so side-by-side ran out of room. */
|
||||||
.kv li + li { border-top: 1px solid var(--divider); }
|
.action-row { margin: 14px 0; }
|
||||||
.kv .key { font-family: var(--mono); word-break: break-all; color: var(--muted); flex: 1; }
|
.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; }
|
.copy { font-size: 12px; padding: 4px 10px; }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue