import Logger from "../Logger"; import { Translator } from "../i18n"; import { OVERLAY_CSS } from "./overlay.styles"; import { placeCard, revealTarget } from "./Placement"; import { Spotlight } from "./Spotlight"; import { stepsFor } from "./TutorialContent"; import { Detection, StepDefinition, StepIcon } from "./types"; const ICON_STROKE = "fill=\"none\" stroke=\"#1c0810\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\""; const ICONS: Record = { url: ``, lock: ``, form: ``, card: ``, clock: ``, download: ``, link: ``, shield: ``, permission: ``, mic: ``, webcam: ``, location: ``, clipboard: ``, extension: ``, command: ``, consent: ``, done: ``, }; const ARROW_ICON = ""; export class Tutorial { private host: HTMLElement | null = null; private onResize: (() => void) | null = null; private root: ShadowRoot | null = null; private spotlight: Spotlight | null = null; private steps: StepDefinition[] = []; private currentStep = 0; private placed = false; private finished = false; constructor( private readonly t: Translator, private readonly skippable: boolean, private readonly onComplete: () => void, private readonly logger: Logger, ) {} get isOpen(): boolean { return this.host !== null; } /** * Whether the overlay is genuinely on screen. The pages this runs on are * attacker-authored, so being mounted is not the same as being visible: * a CSS rule can hide the host, a MutationObserver can detach it, and a * strict CSP can drop the injected styles. * * @returns {boolean} - True when the card is rendered and sized. */ get isUsable(): boolean { if (!this.host || !this.host.isConnected || !this.root) { return false; } const card = this.root.querySelector(".card") as HTMLElement; if (!card) { return false; } const rect = card.getBoundingClientRect(); if (rect.width < 80 || rect.height < 60) { return false; } const style = window.getComputedStyle(this.host); return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"; } /** * Opens the tutorial overlay for the given detection, walking the user * through every dangerous item found on the page. Calls onComplete * (which performs the redirect) when the user finishes or skips. * * @param {Detection} detection - The result of scanning the page. */ open(detection: Detection): void { if (this.isOpen) { return; } // If the tutorial already ran — for instance the user cancelled the // navigation in a beforeunload prompt — don't rebuild it, just leave. if (this.finished) { this.onComplete(); return; } this.steps = stepsFor(detection); this.currentStep = 0; this.mount(); this.renderStep(); this.logger.info(`Tutorial opened (${detection.type}, ${this.steps.length} steps).`); } private mount(): void { this.host = document.createElement("div"); this.host.setAttribute("data-ignore", "true"); this.root = this.host.attachShadow({ mode: "closed" }); const style = document.createElement("style"); style.textContent = OVERLAY_CSS; this.root.appendChild(style); const overlay = document.createElement("div"); overlay.className = "overlay"; overlay.innerHTML = `

`; this.root.appendChild(overlay); ["click", "mousedown", "mouseup", "submit", "input", "keydown", "keyup", "touchstart", "touchend"].forEach(type => { overlay.addEventListener(type, event => event.stopPropagation()); }); this.query(".label").textContent = this.t("heading"); this.query(".arrow-text").textContent = this.t("arrow_hint"); this.query(".back").textContent = this.t("back"); const skip = this.query(".skip"); if (this.skippable) { skip.textContent = this.t("skip"); skip.addEventListener("click", () => this.complete()); } else { skip.style.display = "none"; } this.query(".back").addEventListener("click", () => this.move(-1)); this.query(".next").addEventListener("click", () => { if (this.currentStep >= this.steps.length - 1) { this.complete(); } else { this.move(1); } }); this.spotlight = new Spotlight(this.query(".spotlight")); this.onResize = () => this.renderStep(); window.addEventListener("resize", this.onResize); document.documentElement.appendChild(this.host); } private query(selector: string): HTMLElement { return this.root.querySelector(selector) as HTMLElement; } private move(delta: number): void { this.currentStep = Math.min(Math.max(this.currentStep + delta, 0), this.steps.length - 1); this.renderStep(); } private renderStep(): void { const step = this.steps[this.currentStep]; const isFirst = this.currentStep === 0; const isLast = this.currentStep === this.steps.length - 1; this.query(".title").textContent = this.t(step.titleKey, step.vars); this.query(".body").textContent = this.t(step.bodyKey, step.vars); this.renderUrlBox(step); const reason = this.query(".reason"); const reasonKey = step.reasonKey || step.quoteKey; if (reasonKey) { reason.textContent = this.t(reasonKey, step.vars); reason.style.display = "block"; } else { reason.style.display = "none"; } this.query(".icon").innerHTML = ICONS[step.icon]; // Numerals read the same in every language; the translated wording // stays available to screen readers. const pad = (value: number): string => (value < 10 ? `0${value}` : `${value}`); const count = this.query(".count"); count.textContent = `${pad(this.currentStep + 1)} / ${pad(this.steps.length)}`; count.setAttribute("aria-label", this.t("progress", { n: this.currentStep + 1, total: this.steps.length })); this.query(".rail-fill").style.width = `${((this.currentStep + 1) / this.steps.length) * 100}%`; this.query(".back").style.display = isFirst ? "none" : "block"; this.query(".next").textContent = isLast ? this.t("continue") : this.t("next"); this.query(".skip").style.display = this.skippable && !isLast ? "block" : "none"; this.query(".arrow").style.display = step.target === "url_bar" ? "flex" : "none"; const backdrop = this.query(".backdrop"); if (step.target === "element" && step.element && step.element.isConnected) { backdrop.style.display = "none"; revealTarget(step.element.getBoundingClientRect(), window.innerWidth < 560 ? window.innerHeight * 0.72 : 0); this.spotlight.show(step.element, rect => this.place(rect)); } else { backdrop.style.display = "block"; this.spotlight.hide(); this.place(null); } } /** * Positions the card next to the highlighted element, or centres it when * no element is highlighted. * * @param {DOMRect | null} rect - The highlighted element's rect, if any. */ private place(rect: DOMRect | null): void { const card = this.query(".card"); // The first placement must not animate, otherwise the card visibly // slides in from the top-left corner it starts at. if (!this.placed) { card.style.transition = "none"; } card.setAttribute("data-placement", placeCard(card, rect)); if (!this.placed) { void card.offsetHeight; card.style.transition = ""; this.placed = true; } } /** * Shows the address the user was actually on, with the registrable * domain — the part that decides where you really are — highlighted. * * @param {StepDefinition} step - The step being rendered. */ private renderUrlBox(step: StepDefinition): void { const box = this.query(".url-box"); if (!step.url) { box.style.display = "none"; return; } box.style.display = "block"; this.query(".url-label").textContent = this.t("url_actual_label"); const value = this.query(".url-value"); value.textContent = ""; const append = (text: string, className?: string) => { if (!text) { return; } const span = document.createElement("span"); span.textContent = text; if (className) { span.className = className; } value.appendChild(span); }; append(step.url.scheme); append(step.url.subdomain ? `${step.url.subdomain}.` : ""); append(step.url.domain, "domain"); append(step.url.path); } private complete(): void { if (this.finished) { return; } this.finished = true; this.logger.info("Tutorial finished, redirecting..."); if (this.spotlight) { this.spotlight.destroy(); } if (this.onResize) { window.removeEventListener("resize", this.onResize); this.onResize = null; } if (this.host) { this.host.remove(); this.host = null; this.root = null; } this.onComplete(); } }