==============================================================================
=== web/app.ts ===
==============================================================================
// FileKey reference web app.
// UI/flow is a faithful reproduction of filekey v1 (source.txt): typewriter chat
// (char_speed = characters per animation frame), the filekey "dp" badge on each
// message, two-step auth (generate -> "Now tap to unlock"), animated
// Encrypting/Decrypting status, upload-right / download-left cards with Share+Save,
// the hamburger ("chiz") menu with v1's verbatim content panels.
//
// FILE SEMANTICS follow the new spec (user-confirmed), NOT v1:
// - route lock/unlock by the FKEY magic header, not the filename extension
// - shared files are named ".shared.filekey" (spec §10), not ".shared_filekey"
// - Save uses the native save picker where available
// Other spec deltas are marked // SPEC-DELTA.
import {
Namespace, NamespaceSet,
deriveIdentityFromPrf, masterPrkFromPrfSecret,
encodeShareKey, decodeShareKey, identityFingerprint,
encodeRecoveryBip39,
encryptToSelf, selfEncryptStream, decrypt, encryptStream, decryptStream,
parseHeader, FileKeyError, HEADER_LEN,
type Identity, type Metadata, type ByteSource,
} from "../src/index.js";
import { checkSupport, enrollPasskey, getPrfSecret, prfBrowserSupport, deploymentRpId } from "./webauthn.js";
import * as Contacts from "./contacts.js";
import { collectFromInput, collectFromDrop, zipBundleToBlob, bundleName, type BundleItem } from "./bundle.js";
import qrcode from "qrcode-generator"; // tiny, dependency-free QR encoder — bundled offline (no CDN; see send-me-a-file spec §8)
import versionManifest from "./version.json"; // user-facing version + release notes; re-fetched at runtime to surface a newer deploy
// ---- v1 icons (verbatim SVG paths from source.txt) ----
const SVG = {
logo: ``,
filekey: ``,
file: ``,
plus: ``,
share: ``,
copy: ``,
save: ``,
check: ``,
edit: ``,
outbound: ``,
import: ``,
// Contacts-footer actions. export = arrow up out of the tray (mirror of import); trash = Clear all.
// Stroke-based with currentColor so they follow the link color (and its hover light-up).
export: ``,
trash: ``,
// Cancel's "✗", the counterpart to Confirm's check — only used where a text Cancel sits beside an iconed Confirm.
close: ``,
// "Show QR" toggle on the receive-a-file link. Filled QR glyph (three finder rings + a few modules);
// fill-rule evenodd carves the ring holes; fill via currentColor so it follows the link color on hover.
qr: ``,
// Unlock button's "touch to unlock" biometric affordance (a fingerprint). Stroke-based so it inherits
// the button's text color via currentColor — white on the solid-blue Unlock button. This is a biometric
// cue, NOT the identity-verification fingerprint deliberately left unbuilt (see DESIGN.md Appendix A).
fingerprint: ``,
// Update-notice action icons (stroke + currentColor, same family as import/export/trash/close):
// download = install the update; doc = open the changelog; clock = "Later" (defer).
download: ``,
doc: ``,
clock: ``,
};
// External-link affordance: the menu's box-arrow glyph, inline, inheriting the link's color.
const EXT_ICON = SVG.outbound.replace("outbound_link", "ext_icon");
const extLink = (href: string, text: string) =>
`${text}${EXT_ICON}`;
// Same, but ending a sentence: keeps the link + its period on one line so the "." never wraps alone
// onto a new line (the external-link icon is an atomic inline, which would otherwise allow a break).
const extLinkDot = (href: string, text: string) => `${extLink(href, text)}.`;
const RP_ID = deploymentRpId();
const NS = new Namespace(RP_ID);
const SET = new NamespaceSet([RP_ID]);
const APP_VERSION = versionManifest.current; // the version string this bundle shipped as
const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const $ = (id: string) => document.getElementById(id)!;
const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
// Strip Unicode bidi override/embedding/isolate controls (U+202A–202E, U+2066–2069). A sender-controlled
// filename like "photogpj.exe" otherwise displays/saves as "photo.jpg" but lands on disk as .exe.
// Override/isolate chars only — NOT the LRM/RLM marks (U+200E/200F) that legitimate RTL filenames use.
const stripBidi = (s: string) => s.replace(/[\u202a-\u202e\u2066-\u2069]/g, "");
let mainInner: HTMLElement;
let identity: Identity | null = null;
let allowAutoScroll = false; // suppressed during the intro so the page doesn't auto-scroll on load (mobile)
let createdThisSession = false; // true once a filekey is created this page-load, so the recovery reminder only fires on a genuine return-visit authenticate
let statusCount = 0;
// Share model: each Share opens its OWN recipient prompt bound to that file, so different files
// in one session can go to different recipients (no single sticky global recipient).
type ShareFile = { plaintext: Blob; name: string; mime: string };
// v1 scrollToBottom (only scrolls once the feed fills ~3/4 of the viewport).
function scrollToBottom() {
if (!allowAutoScroll) return; // hold the page still during the intro (no jump on mobile load)
const three_quarters = document.body.clientHeight * 0.75;
if (mainInner.clientHeight >= three_quarters) window.scroll(0, document.body.scrollHeight + document.body.scrollHeight / 10);
}
const setIcon = (el: Element, cls: string) => el.querySelector("svg")!.setAttribute("class", cls);
// ---- typewriter: char_speed = characters per animation frame (v1 std_fillTextBoxAnimation) ----
type Seg = string | { t: string; b?: boolean } | { link: string; onClick: () => void } | { html: string };
function typeInto(el: HTMLElement, text: string, perFrame: number): Promise {
if (REDUCED) { el.textContent = text; scrollToBottom(); return Promise.resolve(); }
return new Promise((resolve) => {
let i = 0;
const frame = () => { i += perFrame; el.textContent = text.slice(0, i); scrollToBottom(); i < text.length ? requestAnimationFrame(frame) : resolve(); };
requestAnimationFrame(frame);
});
}
// Typewriter for rich HTML (menu panels, requirements, inline links): rebuilds the DOM
// incrementally — cloning each element as the cursor reaches it and typing each text node
// char-by-char — so structured content reveals progressively, like plain messages.
function typeHtmlInto(dest: HTMLElement, html: string, perFrame: number): Promise {
const src = document.createElement("div");
src.innerHTML = html;
if (REDUCED) { dest.innerHTML = html; scrollToBottom(); return Promise.resolve(); }
const typeText = (tn: Text, text: string) => new Promise((resolve) => {
let i = 0;
const frame = () => { i += perFrame; tn.data = text.slice(0, i); scrollToBottom(); i < text.length ? requestAnimationFrame(frame) : resolve(); };
requestAnimationFrame(frame);
});
const walk = async (from: Node, to: Node): Promise => {
for (const child of Array.from(from.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const tn = document.createTextNode("");
to.appendChild(tn);
await typeText(tn, (child as Text).data);
} else if (child.nodeType === Node.ELEMENT_NODE) {
const clone = (child as Element).cloneNode(false);
to.appendChild(clone);
await walk(child, clone);
}
}
};
return walk(src, dest);
}
function appShell(dp = "std_dp", icon = "filekey_icon"): HTMLElement {
const outer = document.createElement("div");
outer.className = "std_outer";
outer.innerHTML = `
${SVG.filekey}
`;
setIcon(outer.querySelector(`.${dp}`)!, icon);
mainInner.appendChild(outer);
return outer.querySelector(".std_msg") as HTMLElement;
}
async function appMsg(segs: Seg[], opts: { speed?: number; dp?: string; icon?: string } = {}): Promise {
const speed = opts.speed ?? 8;
const msg = appShell(opts.dp ?? "std_dp", opts.icon ?? "filekey_icon");
scrollToBottom();
for (const seg of segs) {
if (typeof seg === "string") { const s = document.createElement("span"); msg.appendChild(s); await typeInto(s, seg, speed); }
else if ("t" in seg) { const s = document.createElement(seg.b ? "strong" : "span"); msg.appendChild(s); await typeInto(s, seg.t, speed); }
else if ("link" in seg) { const a = document.createElement("span"); a.className = "msg_clickable no_select"; a.textContent = seg.link; a.addEventListener("click", seg.onClick); msg.appendChild(a); if (!REDUCED) await new Promise((r) => setTimeout(r, 40)); }
else { const s = document.createElement("span"); msg.appendChild(s); await typeHtmlInto(s, seg.html, speed); }
}
scrollToBottom();
return msg; // returned so actionRow() can attach a choice row INSIDE this same bubble
}
// A choice row attached inside a chat-message bubble (pass the element appMsg returns) — distinct actions as
// blue primary + muted secondary chips, the Confirm/Cancel + Save/Skip vocabulary. Use this instead of
// cramming multiple action links into one appMsg separated by a "·".
function actionRow(host: HTMLElement, actions: { label: string; muted?: boolean; icon?: string; onClick: () => void }[]): void {
const row = document.createElement("div");
row.className = "msg_actions";
for (const a of actions) {
const s = document.createElement("span");
s.className = `${a.muted ? "cancel_pub_key" : "confirm_pub_key"} no_select`;
if (a.icon) s.innerHTML = `${a.icon}${esc(a.label)}`;
else s.textContent = a.label;
s.addEventListener("click", a.onClick);
row.appendChild(s);
}
host.appendChild(row);
scrollToBottom();
}
// Like actionRow, but renders REAL buttons (the .dc_btn vocabulary: solid-blue primary + ghost outline)
// with an optional leading icon — for the first-run moment that earns a tappable button over an inline
// link (Unlock / Create). DESIGN.md: distinct choices belong in a row, never two inline links.
function buttonRow(host: HTMLElement, buttons: { label: string; icon?: string; ghost?: boolean; onClick: () => void }[]): void {
const row = document.createElement("div");
row.className = "auth_row";
for (const b of buttons) {
const btn = document.createElement("button");
btn.className = `dc_btn auth_btn ${b.ghost ? "dc_btn_ghost" : "dc_btn_primary"} no_select`;
btn.innerHTML = `${b.icon ?? ""}${esc(b.label)}`;
btn.addEventListener("click", b.onClick);
row.appendChild(btn);
}
host.appendChild(row);
scrollToBottom();
}
const ERR = { speed: 4, dp: "failed_dp", icon: "failed_filekey_icon" }; // v1 getErrorParams
const WARN = { speed: 2, dp: "warning_dp", icon: "warning_filekey_icon" }; // v1 getWarningParams
// ---- animated "Encrypting…/Decrypting…" status (v1 set3dotStatusAnimation) ----
class StatusMsg {
msg: string; el: HTMLElement; cancelEl: HTMLElement; outer: HTMLElement; active = true; cancelled = false; start = performance.now();
constructor(label: boolean | string) {
this.msg = typeof label === "string" ? label : label ? "Encrypting" : "Decrypting";
this.outer = document.createElement("div");
this.outer.className = "std_status_outer";
this.outer.innerHTML = `
${SVG.filekey}·Cancel
`;
setIcon(this.outer.querySelector(".std_dp")!, "filekey_icon");
mainInner.appendChild(this.outer);
this.el = this.outer.querySelector(".std_status") as HTMLElement;
this.cancelEl = this.outer.querySelector(".std_status_cancel") as HTMLElement;
scrollToBottom();
const tick = () => { if (!this.active) return; const s = Math.round((performance.now() - this.start) / 1000) % 3; this.el.textContent = this.msg + (s === 0 ? "." : s === 1 ? ".." : "..."); requestAnimationFrame(tick); };
tick();
}
/** Show the Cancel affordance (streaming ops only). Main-thread loops poll `cancelled`; worker jobs
* pass `onCancel` to terminate the worker. Both bail before any partial output is saved. */
enableCancel(onCancel?: () => void) {
this.cancelEl.style.display = "";
(this.cancelEl.querySelector(".fk_cancel_act") as HTMLElement).addEventListener("click", () => { this.cancel(); onCancel?.(); });
}
cancel() {
if (this.cancelled) return;
this.cancelled = true;
this.active = false;
this.el.textContent = `${this.msg}… Cancelled`;
this.cancelEl.style.display = "none";
}
progress(done: number, total: number) {
if (this.cancelled) return; // keep the "Cancelled" text; ignore in-flight progress from the last chunk
this.active = false; // halt the cycling-dots animation; show byte progress instead
this.el.textContent = `${this.msg}… ${fmtBytes(done)} of ${fmtBytes(total)}`;
}
finish(label: string) { this.active = false; this.el.textContent = label; this.cancelEl.style.display = "none"; }
done() { this.finish(this.msg + "... Done!"); }
fail() { this.active = false; this.outer.remove(); }
}
// ---- file cards (v1 html_newFileUpload / html_newDownload) ----
// Middle-ellipsis for filenames: pin the tail (extension) and ellipsize the head, so a long name
// like "Screenshot ….png.filekey" stays one clean line instead of breaking mid-word on mobile.
function fnameHtml(filename: string): string {
filename = stripBidi(filename); // neutralize bidi-override extension spoofing before display
const safe = esc(filename);
if (filename.length <= 16) return `${safe}`;
const tailLen = Math.min(12, Math.ceil(filename.length * 0.35));
const head = esc(filename.slice(0, filename.length - tailLen));
const tail = esc(filename.slice(filename.length - tailLen));
return `${head}${tail}`;
}
function uploadCard(filename: string, typeLabel: string, isEncrypted: boolean) {
const outer = document.createElement("div");
outer.className = "std_upload_outer";
outer.innerHTML = `