diff --git a/Makefile b/Makefile index ac1104c..b18d742 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev web server migrate ext ext-watch ext-test test typecheck build prod down logs +.PHONY: help dev web server migrate ext ext-watch ext-publish ext-test test typecheck build prod down logs help: @echo "make dev — postgres + API in docker, web panel on :5173" @@ -6,6 +6,7 @@ @echo "make migrate — apply alembic migrations (docker)" @echo "make ext — build the extension (chrome + firefox)" @echo "make ext-watch — rebuild the extension on change" + @echo "make ext-publish — publish the overlay module to the server (LTT_EMAIL/LTT_PASSWORD)" @echo "make ext-test — e2e smoke test of the built extension (needs dev stack up)" @echo "make test — server tests" @echo "make typecheck — vue-tsc for web + extension" @@ -31,6 +32,11 @@ ext-watch: npm run watch:chrome -w @ltt/extension +# publishing the hot overlay is manual: it needs credentials and a reachable server +ext-publish: + LTT_SERVER=${LTT_SERVER:-http://localhost:8001} LTT_EMAIL=$(LTT_EMAIL) LTT_PASSWORD=$(LTT_PASSWORD) \ + npm run publish -w @ltt/extension + ext-test: node packages/extension/e2e.mjs diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6ee62fa..e2c7df7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,6 +29,7 @@ environment: DATABASE_URL: postgresql+asyncpg://ltt:${POSTGRES_PASSWORD:-ltt}@postgres:5432/ltt FILES_DIR: /srv/data/files + EXT_ASSETS_DIR: /srv/data/ext WEB_DIST_DIR: /srv/web # optional: public origin for absolute og:image/og:url links in share # previews; by default it is derived from the request host diff --git a/docker-compose.yml b/docker-compose.yml index adf7bd9..2946f0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,7 @@ environment: DATABASE_URL: postgresql+asyncpg://ltt:ltt@postgres:5432/ltt FILES_DIR: /srv/data/files + EXT_ASSETS_DIR: /srv/data/ext CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]' depends_on: postgres: diff --git a/packages/extension/e2e.mjs b/packages/extension/e2e.mjs index 81e129e..bca855b 100644 --- a/packages/extension/e2e.mjs +++ b/packages/extension/e2e.mjs @@ -8,6 +8,7 @@ import { createServer } from "node:http"; import { mkdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir } from "node:os"; @@ -429,6 +430,9 @@ console.log("panel relay ping:", relayOk); if (relayOk) { // replay through the exact path the panel's "Start replay" button uses + // 1:1 pacing means the wall time must track the recorded track length — + // measured and logged as a sanity signal for the timing work + const replayStartedAt = Date.now(); const resultPromise = panelPage.evaluate( (token) => new Promise((resolve) => { @@ -491,7 +495,137 @@ ? await replayPage.evaluate(() => document.querySelector("#name-input")?.value ?? null).catch(() => null) : null; replayResultOk = result?.ok === true; - console.log("panel replay:", JSON.stringify({ result, cursorOk, jsHoverOk, cssHoverOk })); + console.log( + "panel replay:", + JSON.stringify({ result, cursorOk, jsHoverOk, cssHoverOk, wallSeconds: Number(((Date.now() - replayStartedAt) / 1000).toFixed(1)) }) + ); + } + + // --- hot overlay: publish a modified overlay from the seeded user, and the + // content script must pick the server version up without reinstalling --- + let hotShaOk = false; + let remoteOverlayOk = false; + let updateBannerOk = false; + let bundledFallbackOk = false; + let extAssetsOk = false; + try { + // the manifest endpoint is public and always lists the stable downloads + const manifestBefore = await api("/api/ext/manifest"); + if (!manifestBefore.downloads?.chrome) throw new Error("manifest missing stable downloads"); + + const overlayJs = await readFile(join(root, "dist/chrome/assets/overlay.js"), "utf8"); + const overlayCss = await readFile(join(root, "dist/chrome/assets/overlay.css"), "utf8"); + const publish = async (version, files) => + api("/api/ext/publish", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${login.token}`, + "X-Client": "extension", + }, + body: JSON.stringify({ + version, + files: Object.fromEntries( + Object.entries(files).map(([name, text]) => [name, Buffer.from(text, "utf8").toString("base64")]) + ), + }), + }); + + // publish a marked-up overlay under a much newer version; the content + // script never downgrades, so the server version must be >= the installed + const modified = { + "overlay.js": `// e2e-marker 9.9.9\n${overlayJs}`, + "overlay.css": `/* e2e-marker 9.9.9 */\n${overlayCss}`, + }; + await publish("9.9.9", modified); + const manifestHot = await api("/api/ext/manifest"); + hotShaOk = + manifestHot.version === "9.9.9" && + manifestHot.assets?.overlay_js?.sha256 === createHash("sha256").update(modified["overlay.js"]).digest("hex") && + manifestHot.assets?.overlay_css?.sha256 === createHash("sha256").update(modified["overlay.css"]).digest("hex"); + console.log("hot publish:", JSON.stringify({ version: manifestHot.version, hotShaOk })); + + if (hotShaOk) { + // reload the target page: the fresh content script must load the + // overlay module from the server (mounted overlay exposes the source) + await page.bringToFront(); // __lttTriggerAction acts on the *active* tab + await page.reload(); + await page.waitForTimeout(1000); + await worker.evaluate(() => self.__lttTriggerAction()); // opens the picker → mounts the overlay + try { + await page.waitForFunction( + () => document.getElementById("ltt-overlay-host")?.getAttribute("data-overlay-source") === "remote", + null, + { timeout: 15000 } + ); + remoteOverlayOk = true; + } catch {} + await page.keyboard.press("Escape"); // close the picker again + console.log("remote overlay:", remoteOverlayOk); + + // popup banner: the server version is newer than the installed one + const popupHot = await context.newPage(); + await popupHot.goto(`chrome-extension://${extensionId}/src/popup/popup.html`); + try { + await popupHot.waitForSelector(".popup-update", { timeout: 10000 }); + const bannerText = await popupHot.locator(".popup-update-text").textContent(); + updateBannerOk = Boolean(bannerText && bannerText.includes("9.9.9")); + } catch {} + // reuse the same popup for the settings patch (an extension page runs in + // the extension world — the target page's main world has no chrome.runtime) + const saved = await popupHot.evaluate(() => chrome.runtime.sendMessage({ type: "settings_get" })); + const originalServerUrl = saved?.data?.serverUrl; + updateBannerOk = updateBannerOk && Boolean(originalServerUrl); + console.log("update banner:", updateBannerOk, "serverUrl:", originalServerUrl); + + // fallback: an unreachable serverUrl must land on the bundled overlay + await popupHot.evaluate(() => + chrome.runtime.sendMessage({ type: "settings_save", patch: { serverUrl: "http://localhost:9" } }) + ); + await popupHot.close(); + await page.bringToFront(); // the active tab after the relay section drifts + await page.reload(); + await page.waitForTimeout(1000); + await worker.evaluate(() => self.__lttTriggerAction()); + try { + await page.waitForFunction( + () => document.getElementById("ltt-overlay-host")?.getAttribute("data-overlay-source") === "bundled", + null, + { timeout: 15000 } + ); + bundledFallbackOk = true; + } catch {} + await page.keyboard.press("Escape"); + console.log("bundled fallback:", bundledFallbackOk); + + // restore the real serverUrl (the delete flow below needs it); Node + // variables must be passed into evaluate as arguments, not closed over + const popupRestore = await context.newPage(); + await popupRestore.goto(`chrome-extension://${extensionId}/src/popup/popup.html`); + await popupRestore.waitForSelector("text=Element note", { timeout: 10000 }); + await popupRestore.evaluate( + (url) => chrome.runtime.sendMessage({ type: "settings_save", patch: { serverUrl: url } }), + originalServerUrl + ); + await popupRestore.waitForTimeout(300); + await popupRestore.close(); + + // cleanup: republish the pristine dist assets at the real version, so + // the dev server is left pointing at the real overlay (this also + // verifies the no-diff publish round trip) + const extVersion = JSON.parse(await readFile(join(root, "dist/chrome/manifest.json"), "utf8")).version; + await publish(extVersion, { "overlay.js": overlayJs, "overlay.css": overlayCss }); + const manifestClean = await api("/api/ext/manifest"); + extAssetsOk = + remoteOverlayOk && + updateBannerOk && + bundledFallbackOk && + manifestClean.version === extVersion && + manifestClean.assets?.overlay_js?.sha256 === createHash("sha256").update(overlayJs).digest("hex"); + console.log("ext assets cleanup:", JSON.stringify({ version: manifestClean.version, ok: extAssetsOk })); + } + } catch (error) { + console.log("ext assets section FAILED:", String(error).slice(0, 300)); } // --- delete the latest report from the popup (two-step: arm, confirm) --- @@ -506,7 +640,7 @@ console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk: popupOk, deleteOk })); const ok = - noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk && composerOk && discardOk && switcherOk; + noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk && jsHoverOk && videoOk && consoleOk && composerOk && discardOk && switcherOk && extAssetsOk; console.log("background log:", JSON.stringify(await worker.evaluate(() => self.__lttDebug()), null, 1)); console.log(noteOk ? "note flow OK" : "note flow MISMATCH"); console.log(recorderOk ? "recorder flow OK" : "recorder flow MISMATCH"); @@ -518,6 +652,11 @@ console.log(composerOk ? "recording composer OK" : "recording composer MISMATCH"); console.log(discardOk ? "recording discard OK" : "recording discard MISMATCH"); console.log(switcherOk ? "project switcher OK" : "project switcher MISMATCH"); + console.log(hotShaOk ? "hot overlay publish OK" : "hot overlay publish MISMATCH"); + console.log(remoteOverlayOk ? "remote overlay OK" : "remote overlay MISMATCH"); + console.log(updateBannerOk ? "update banner OK" : "update banner MISMATCH"); + console.log(bundledFallbackOk ? "bundled fallback OK" : "bundled fallback MISMATCH"); + console.log(extAssetsOk ? "ext assets OK" : "ext assets MISMATCH"); console.log(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH"); console.log(popupOk ? "popup OK" : "popup MISMATCH"); console.log(relayOk && replayResultOk ? "panel relay OK" : "panel relay MISMATCH"); diff --git a/packages/extension/package.json b/packages/extension/package.json index cfc6f57..5e0ccb7 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -8,6 +8,7 @@ "build:chrome": "node scripts/build.mjs chrome", "build:firefox": "node scripts/build.mjs firefox", "package": "node scripts/package.mjs", + "publish": "node scripts/publish.mjs", "watch:chrome": "node scripts/build.mjs chrome --watch", "watch:firefox": "node scripts/build.mjs firefox --watch", "typecheck": "vue-tsc --noEmit", diff --git a/packages/extension/scripts/build-manifest.mjs b/packages/extension/scripts/build-manifest.mjs index 332f577..35c4e13 100644 --- a/packages/extension/scripts/build-manifest.mjs +++ b/packages/extension/scripts/build-manifest.mjs @@ -15,7 +15,8 @@ const template = JSON.parse(readFileSync(join(root, "..", "manifest.template.json"), "utf8")); const manifest = structuredClone(template); - manifest.version = "0.1.0"; + // version lives in the root package.json — the single source of truth + manifest.version = JSON.parse(readFileSync(join(root, "..", "..", "..", "package.json"), "utf8")).version; if (target === "chrome") { // bundles are IIFE (self-contained), so the worker stays classic diff --git a/packages/extension/scripts/build.mjs b/packages/extension/scripts/build.mjs index d95090d..f654504 100644 --- a/packages/extension/scripts/build.mjs +++ b/packages/extension/scripts/build.mjs @@ -20,9 +20,11 @@ process.exit(1); } -// order matters: every pass writes assets/style.css (cssCodeSplit:false), so +// order matters: every pass writes its own named CSS (cssCodeSplit:false), so // the content pass — whose stylesheet is the union of all styles — runs last -const entries = ["background", "options", "popup", "relay", "console-tap", "offscreen", "content"]; +// and owns assets/style.css (the bundled overlay fallback's stylesheet). The +// overlay pass ships the publishable ES module with its own overlay.css. +const entries = ["background", "options", "popup", "relay", "console-tap", "offscreen", "content", "overlay"]; function run(entry) { return new Promise((resolve, reject) => { diff --git a/packages/extension/scripts/package.mjs b/packages/extension/scripts/package.mjs index c2c34fd..3447881 100644 --- a/packages/extension/scripts/package.mjs +++ b/packages/extension/scripts/package.mjs @@ -30,5 +30,10 @@ // zip the directory contents (manifest at the archive root) so the unpacked // folder can be loaded directly via "Load unpacked" execSync(`zip -qr ${JSON.stringify(out)} .`, { cwd: dist, stdio: "inherit" }); + // a version-named copy too — links that carry the version stay valid for + // shared chat messages even after the stable name is overwritten + const versioned = join(outDir, `bugtrail-${target}-${version}.zip`); + rmSync(versioned, { force: true }); + execSync(`cp ${JSON.stringify(out)} ${JSON.stringify(versioned)}`); console.log(`packaged bugtrail-${target} v${version} -> ${out}`); } \ No newline at end of file diff --git a/packages/extension/scripts/publish.mjs b/packages/extension/scripts/publish.mjs new file mode 100644 index 0000000..a727af4 --- /dev/null +++ b/packages/extension/scripts/publish.mjs @@ -0,0 +1,93 @@ +/** + * Publishes the overlay module (JS + CSS) to the server so installed + * extensions hot-update their UI without reinstalling. + * + * Usage: + * LTT_EMAIL=... LTT_PASSWORD=... node scripts/publish.mjs + * LTT_TOKEN=... LTT_SERVER=https://bugtrail.example node scripts/publish.mjs + * + * LTT_SERVER defaults to http://localhost:8001. Requires a prior + * `node scripts/build.mjs chrome` (and firefox for the sha check). + */ +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const root = dirname(fileURLToPath(import.meta.url)); +const extDir = join(root, ".."); +const version = require(join(extDir, "..", "..", "package.json")).version; + +const server = (process.env.LTT_SERVER ?? "http://localhost:8001").replace(/\/+$/, ""); + +function readAsset(name) { + const path = join(extDir, "dist", "chrome", "assets", name); + if (!existsSync(path)) { + console.error(`publish: missing ${path} — run \`node scripts/build.mjs chrome\` first`); + process.exit(1); + } + const content = readFileSync(path); + // chrome and firefox builds share the overlay module; a mismatch means the + // targets were built from different sources — warn but keep going + const firefoxPath = join(extDir, "dist", "firefox", "assets", name); + if (existsSync(firefoxPath)) { + const firefoxSha = createHash("sha256").update(readFileSync(firefoxPath)).digest("hex"); + const chromeSha = createHash("sha256").update(content).digest("hex"); + if (firefoxSha !== chromeSha) { + console.warn(`publish: warning — ${name} differs between chrome and firefox builds`); + } + } + return content; +} + +const overlayJs = readAsset("overlay.js"); +const overlayCss = readAsset("overlay.css"); + +async function main() { + let token = process.env.LTT_TOKEN; + if (!token) { + const email = process.env.LTT_EMAIL; + const password = process.env.LTT_PASSWORD; + if (!email || !password) { + console.error("publish: set LTT_TOKEN or LTT_EMAIL+LTT_PASSWORD (and optionally LTT_SERVER)"); + process.exit(1); + } + const loginResponse = await fetch(`${server}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Client": "extension" }, + body: JSON.stringify({ email, password }), + }); + if (!loginResponse.ok) { + console.error(`publish: login failed (${loginResponse.status})`); + process.exit(1); + } + token = (await loginResponse.json()).token; + } + + const response = await fetch(`${server}/api/ext/publish`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + version, + files: { + "overlay.js": overlayJs.toString("base64"), + "overlay.css": overlayCss.toString("base64"), + }, + }), + }); + if (!response.ok) { + const detail = await response.json().catch(() => null); + console.error(`publish failed (${response.status}):`, JSON.stringify(detail)); + process.exit(1); + } + const manifest = await response.json(); + console.log(`published overlay ${manifest.version} -> ${server}/api/ext/manifest`); + console.log(JSON.stringify(manifest.assets, null, 2)); +} + +main().catch((error) => { + console.error("publish failed:", error.message); + process.exit(1); +}); \ No newline at end of file diff --git a/packages/extension/src/background/index.ts b/packages/extension/src/background/index.ts index 5eb5c22..f1527ac 100644 --- a/packages/extension/src/background/index.ts +++ b/packages/extension/src/background/index.ts @@ -2,7 +2,8 @@ import type { Runtime } from "webextension-polyfill"; import type { ReportDetail } from "@ltt/shared"; import { submitNoteReport, submitRecordingReport, updateRecordingReport } from "./api"; -import { getSettings, saveSettings, login, logout, getHttpClient, panelBase } from "./settings"; +import { getSettings, saveSettings, login, logout, getHttpClient, panelBase, type Settings } from "./settings"; +import { checkAfterSettingsChange, checkForUpdate, maybeCheckForUpdate } from "./version"; import { buildGif, buildCursorGif, type VideoFrame } from "./gif"; import type { BackgroundResponse, @@ -661,13 +662,16 @@ async function runReplaySteps(tabId: number, report: ReportDetail): Promise { const steps = report.steps ?? []; - const startedAt = Date.now(); + // 1:1 pacing: replays exactly as recorded (replay duration ≈ recording + // duration, plus page-load times after navigations) + let startedAt = Date.now(); const failures: ReplayFailure[] = []; let played = 0; for (const step of steps) { const stepIndex = step.step_index ?? played; - // reproduce the recorded pacing, but cap each gap so stale timings don't stall - const wait = Math.min(Math.max(step.offset_ms - (Date.now() - startedAt), 0), 2000); + // absolute clock: wait until offset_ms has elapsed since the (re-anchored) + // start; per-step execution overhead makes later waits shrink to 0 + const wait = Math.max(step.offset_ms - (Date.now() - startedAt), 0); if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); // the tab may have been closed mid-replay (by the user or the page itself) const alive = await browser.tabs.get(tabId).then( @@ -680,6 +684,9 @@ if (!(await waitTabComplete(tabId))) { throw new Error(`Page did not load within 20 s: ${step.data.to_url}`); } + // page-load time is not recorded time — re-anchor so the next step fires + // one recorded gap after the load finishes (otherwise it would play early) + startedAt = Date.now() - step.offset_ms; // the navigation destroyed the page's content script and cursor — // reinstall both, resuming the cursor path from the current step await ensureContentScript(tabId); @@ -700,6 +707,9 @@ viewport: (report.environment as { viewport?: { w: number; h: number } } | undefined)?.viewport, }) .catch(() => {}); + // let the page repaint before the next step resolves its element — the + // recorded scroll was smooth/human-paced, this one is instant + await new Promise((resolve) => setTimeout(resolve, 200)); played++; } else if (step.type === "click" || step.type === "input") { const response = (await browser.tabs.sendMessage(tabId, { type: "replay_step", data: step.data }).catch(() => null)) as @@ -714,7 +724,7 @@ // note/screenshot/console steps have no page effect — still count as played played++; } - // keep the cursor clock aligned with the (gap-capped) step clock so the + // keep the cursor clock aligned with the step clock so the // virtual mouse doesn't drift behind the actions as the replay proceeds await browser.tabs.sendMessage(tabId, { type: "replay_sync", recorded_ms: step.offset_ms }).catch(() => {}); } @@ -828,6 +838,9 @@ } }); +// throttled update check on service-worker start (runs at most every 12h) +void maybeCheckForUpdate(); + browser.runtime.onMessage.addListener(async (message: unknown, sender: Runtime.MessageSender) => { const msg = message as { type: string; [key: string]: unknown }; const tabId = sender.tab?.id; @@ -1045,9 +1058,15 @@ case "settings_save": { await saveSettings(msg.patch as Record); + await checkAfterSettingsChange(msg.patch as Partial); return { ok: true }; } + case "update_check": { + const result = await checkForUpdate(); + return { ok: true, data: result }; + } + case "login": { try { const user = await login(msg.email as string, msg.password as string); diff --git a/packages/extension/src/background/settings.ts b/packages/extension/src/background/settings.ts index 6244d90..4b7d801 100644 --- a/packages/extension/src/background/settings.ts +++ b/packages/extension/src/background/settings.ts @@ -16,6 +16,12 @@ videoCapture: boolean; /** attach console.error/warn and uncaught exceptions to recordings */ consoleCapture: boolean; + /** last /api/ext/manifest version seen by the update check */ + remoteVersion: string | null; + /** server publishes a newer extension version than the installed one */ + updateAvailable: boolean; + /** last successful update check (Date.now ms) */ + lastVersionCheckAt: number; } const DEFAULTS: Settings = { @@ -29,6 +35,9 @@ hoverReplay: true, videoCapture: true, consoleCapture: true, + remoteVersion: null, + updateAvailable: false, + lastVersionCheckAt: 0, }; export async function getSettings(): Promise { diff --git a/packages/extension/src/background/version.ts b/packages/extension/src/background/version.ts new file mode 100644 index 0000000..088627e --- /dev/null +++ b/packages/extension/src/background/version.ts @@ -0,0 +1,68 @@ +/** + * Extension update check: the server publishes the current version at + * /api/ext/manifest; when it is newer than the installed extension, the popup + * shows an "update available" banner (the core background/content scripts are + * not hot-updatable in MV3 — updating means installing a new zip). + */ +import browser from "webextension-polyfill"; +import { compareVersions } from "@ltt/shared"; +import { getSettings, saveSettings, type Settings } from "./settings"; + +const CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000; // 12h + +interface ExtManifest { + version?: string | null; + assets?: unknown; +} + +/** GET /api/ext/manifest with a hard timeout; null when unreachable. */ +export async function fetchExtManifest(serverUrl: string, timeoutMs = 5000): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(`${serverUrl.replace(/\/+$/, "")}/api/ext/manifest`, { + signal: controller.signal, + }); + if (!response.ok) return null; + return (await response.json()) as ExtManifest; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Fetches the server manifest and stores the update state. Never throws — + * a check is a background nicety, not a feature the UI waits on. + */ +export async function checkForUpdate(): Promise<{ remoteVersion: string | null; updateAvailable: boolean }> { + try { + const settings = await getSettings(); + const manifest = await fetchExtManifest(settings.serverUrl); + const remoteVersion = typeof manifest?.version === "string" ? manifest.version : null; + const updateAvailable = Boolean( + remoteVersion && compareVersions(remoteVersion, browser.runtime.getManifest().version) > 0 + ); + await saveSettings({ remoteVersion, updateAvailable, lastVersionCheckAt: Date.now() }); + return { remoteVersion, updateAvailable }; + } catch { + return { remoteVersion: null, updateAvailable: false }; + } +} + +/** Check on service-worker start, throttled to CHECK_INTERVAL_MS. */ +export async function maybeCheckForUpdate(): Promise { + try { + const settings = await getSettings(); + if (Date.now() - (settings.lastVersionCheckAt ?? 0) < CHECK_INTERVAL_MS) return; + await checkForUpdate(); + } catch { + // storage may be unavailable early in the SW lifecycle — ignore + } +} + +/** Re-check when the server (or panel) URL changes in settings. */ +export async function checkAfterSettingsChange(patch: Partial): Promise { + if ("serverUrl" in patch || "panelUrl" in patch) await checkForUpdate(); +} \ No newline at end of file diff --git a/packages/extension/src/content/index.ts b/packages/extension/src/content/index.ts index cbfbab0..470b6a8 100644 --- a/packages/extension/src/content/index.ts +++ b/packages/extension/src/content/index.ts @@ -1,18 +1,110 @@ /** * Content script bootstrap — deliberately minimal: - * no UI, no network; mounts the shadow-DOM overlay, relays DOM events - * and (while recording) captures clicks/inputs for the background buffer. + * no UI of its own; mounts the shadow-DOM overlay (server version first, + * bundled copy as fallback), relays DOM events and (while recording) captures + * clicks/inputs for the background buffer. */ import browser from "webextension-polyfill"; -import type { ElementContext } from "@ltt/shared"; -import { mountOverlay } from "./overlay/mount"; +import { compareVersions, type ElementContext } from "@ltt/shared"; +import { mountOverlay, type Overlay, type MountOverlayOptions } from "./overlay/mount"; import { buildElementContext, collectEnvironment } from "../lib/selector"; import { pushUiHidden, popUiHidden } from "./uiVisibility"; -let overlay: ReturnType | null = null; +// ---------- overlay loading (remote-first, bundled fallback) ---------- -function getOverlay() { - if (!overlay) overlay = mountOverlay(); +type OverlayFactory = (options?: MountOverlayOptions) => Overlay; + +let overlay: Overlay | null = null; +let overlayFactory: Promise | null = null; + +/** + * Resolves the overlay factory once: try the server-published overlay module + * (hot-updated UI without reinstalling the extension), fall back to the copy + * statically bundled in this file on any failure — offline, server absent, + * restrictive CSP, module timeout, or a server version older than the + * installed extension (never downgrade the UI below the core). + * + * The module is imported straight from the server URL: the manifest and asset + * endpoints answer with Access-Control-Allow-Origin: * (they are public, + * static assets), and a cross-origin import is the only module-loading path + * that works in a content script's isolated world — blob and data: URL + * imports are rejected by the MV3 script CSP, so there is no + * fetch-verify-then-execute shortcut; the import is validated by contract + * (typeof mountOverlay === "function") instead of by hash. + */ +async function resolveOverlayFactory(): Promise { + try { + const reply = (await browser.runtime.sendMessage({ type: "settings_get" })) as { + ok?: boolean; + data?: { serverUrl?: string }; + }; + const serverUrl = reply?.data?.serverUrl?.replace(/\/+$/, ""); + if (serverUrl) { + const manifest = await fetchExtManifest(serverUrl); + const asset = manifest?.assets?.overlay_js?.url; + const remoteVersion = typeof manifest?.version === "string" ? manifest.version : null; + const selfVersion = browser.runtime.getManifest().version; + if (asset && remoteVersion && compareVersions(remoteVersion, selfVersion) >= 0) { + const mod = (await importWithTimeout(`${serverUrl}${asset}?v=${remoteVersion}`)) as { + mountOverlay?: unknown; + } | null; + const mount = mod?.mountOverlay; + if (typeof mount === "function") { + const cssUrl = manifest?.assets?.overlay_css?.url + ? `${serverUrl}${manifest.assets.overlay_css.url}?v=${remoteVersion}` + : null; + const factory: OverlayFactory = (options) => (mount as typeof mountOverlay)({ ...options, cssUrl }); + return factory; + } + } + } + } catch { + // any failure lands on the bundled overlay — the UI must always come up + } + return mountOverlay; +} + +/** GET /api/ext/manifest with a hard timeout; null when unreachable. */ +async function fetchExtManifest(serverUrl: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(`${serverUrl}/api/ext/manifest`, { signal: controller.signal }); + if (!response.ok) return null; + return (await response.json()) as ExtManifest; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +interface ExtManifest { + version?: string | null; + assets?: { + overlay_js?: { url?: string; sha256?: string }; + overlay_css?: { url?: string; sha256?: string }; + } | null; +} + +/** Dynamic import that can't hang: resolves null after 5s. */ +function importWithTimeout(url: string): Promise { + return Promise.race([ + import(/* @vite-ignore */ url).catch(() => null), + new Promise((resolve) => setTimeout(() => resolve(null), 5000)), + ]); +} + +async function getOverlay(): Promise { + if (!overlayFactory) overlayFactory = resolveOverlayFactory(); + const factory = await overlayFactory; + if (!overlay) { + overlay = factory(); + // observable marker for e2e/telemetry: which overlay source is in use + document + .getElementById("ltt-overlay-host") + ?.setAttribute("data-overlay-source", factory === mountOverlay ? "bundled" : "remote"); + } return overlay; } @@ -335,31 +427,35 @@ return true; case "start_picker": - getOverlay().startPicker(); - sendResponse({ ok: true }); + void (async () => { + (await getOverlay()).startPicker(); + sendResponse({ ok: true }); + })(); return true; case "recorder_state": { const recording = Boolean((message as { recording?: unknown }).recording); const pending = Boolean((message as { pending?: unknown }).pending); const reportToken = (message as { report_token?: string | null }).report_token ?? null; - getOverlay().setRecorderState(recording, pending, reportToken); - if (recording) { - moveSamples = []; - lastMove = null; - attachCaptureListeners(); - void browser.runtime - .sendMessage({ type: "recorder_environment", environment: collectEnvironment() }) - .catch(() => {}); - } else { - detachCaptureListeners(); - // hand the tail of the track to the background before it submits - const points = takeMoveSamples(); - if (points.length) { - void browser.runtime.sendMessage({ type: "recorder_move_batch", points }).catch(() => {}); + void (async () => { + (await getOverlay()).setRecorderState(recording, pending, reportToken); + if (recording) { + moveSamples = []; + lastMove = null; + attachCaptureListeners(); + void browser.runtime + .sendMessage({ type: "recorder_environment", environment: collectEnvironment() }) + .catch(() => {}); + } else { + detachCaptureListeners(); + // hand the tail of the track to the background before it submits + const points = takeMoveSamples(); + if (points.length) { + void browser.runtime.sendMessage({ type: "recorder_move_batch", points }).catch(() => {}); + } } - } - sendResponse({ ok: true }); + sendResponse({ ok: true }); + })(); return true; } @@ -398,8 +494,9 @@ return true; case "replay_sync": { - // background nudges the cursor clock to the (gap-capped) step clock - // after every executed step, so the cursor doesn't drift behind + // background nudges the cursor clock to the step clock after every + // executed step; with 1:1 pacing the drift stays small, the clamp is + // just a safety net against slow message round-trips if (cursor && typeof msg.recorded_ms === "number") { const drift = msg.recorded_ms - (performance.now() - cursor.startedAt); cursor.startedAt -= Math.max(Math.min(drift, 400), -400); @@ -409,8 +506,10 @@ } case "show_flash": - getOverlay().setFlash(typeof msg.text === "string" ? msg.text : null); - sendResponse({ ok: true }); + void (async () => { + (await getOverlay()).setFlash(typeof msg.text === "string" ? msg.text : null); + sendResponse({ ok: true }); + })(); return true; case "set_ui_hidden": diff --git a/packages/extension/src/content/overlay/index.ts b/packages/extension/src/content/overlay/index.ts new file mode 100644 index 0000000..1573a7c --- /dev/null +++ b/packages/extension/src/content/overlay/index.ts @@ -0,0 +1,9 @@ +/** + * Public surface of the overlay module. This file is the build entry for the + * publishable remote overlay (assets/overlay.js, an ES module) — content.js + * dynamically imports the server version of this module and falls back to its + * statically bundled copy on any failure. No side effects at module scope: + * nothing runs until mountOverlay() is called. + */ +export { mountOverlay } from "./mount"; +export type { Overlay, OverlayState, MountOverlayOptions } from "./mount"; \ No newline at end of file diff --git a/packages/extension/src/content/overlay/mount.ts b/packages/extension/src/content/overlay/mount.ts index 5af4662..62f00a0 100644 --- a/packages/extension/src/content/overlay/mount.ts +++ b/packages/extension/src/content/overlay/mount.ts @@ -26,6 +26,12 @@ flash: string | null; } +/** Options for mountOverlay — lets the remote module reuse the caller's CSS. */ +export interface MountOverlayOptions { + /** absolute CSS URL to fetch; default: the bundled assets/style.css */ + cssUrl?: string | null; +} + export interface Overlay { startPicker: () => void; setRecorderState: (recording: boolean, pending?: boolean, reportToken?: string | null) => void; @@ -55,7 +61,7 @@ "--color-danger": "#f7768e", }; -export function mountOverlay(): Overlay { +export function mountOverlay(options: MountOverlayOptions = {}): Overlay { const host = document.createElement("div"); host.id = "ltt-overlay-host"; host.style.cssText = "all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none;"; @@ -75,11 +81,13 @@ let flashTimer: number | null = null; - // styles load asynchronously; the overlay works unstyled until then + // styles load asynchronously; the overlay works unstyled until then. The + // CSS source is the bundled build by default — the remotely loaded overlay + // module passes its own server URL (fonts still come from this extension). void (async () => { + const cssUrl = options.cssUrl ?? browser.runtime.getURL("assets/style.css"); try { - const url = browser.runtime.getURL("assets/style.css"); - const text = await (await fetch(url)).text(); + const text = await (await fetch(cssUrl)).text(); const sheet = new CSSStyleSheet(); sheet.replaceSync(rewriteKitAssetUrls(text)); shadowRoot.adoptedStyleSheets = [...shadowRoot.adoptedStyleSheets, sheet]; diff --git a/packages/extension/src/lib/messages.ts b/packages/extension/src/lib/messages.ts index dba839f..b5c08b0 100644 --- a/packages/extension/src/lib/messages.ts +++ b/packages/extension/src/lib/messages.ts @@ -22,6 +22,7 @@ | { type: "recorder_state"; recording: boolean; pending?: boolean; report_token?: string | null } | { type: "recorder_edit"; token: string; title: string; description: string } | { type: "recorder_discard"; token: string } + | { type: "update_check" } | { type: "settings_changed" }; export interface SubmitNotePayload { diff --git a/packages/extension/src/popup/Popup.vue b/packages/extension/src/popup/Popup.vue index 976c768..99198d4 100644 --- a/packages/extension/src/popup/Popup.vue +++ b/packages/extension/src/popup/Popup.vue @@ -30,11 +30,19 @@ stepCount: number; } +interface UpdateCheck { + remoteVersion: string | null; + updateAvailable: boolean; +} + const settings = ref(null); const latest = ref(null); const projectToken = ref(null); const projects = ref([]); const recorder = ref({ recording: false, stepCount: 0 }); +/** server publishes a newer extension version — banner with a download link */ +const update = ref(null); +const updateDismissed = ref(false); const busy = ref(false); const error = ref(null); /** two-step delete: first click arms the button, second deletes */ @@ -51,6 +59,8 @@ try { settings.value = await sendMessage({ type: "settings_get" }); if (!settings.value.token) return; + // a newer version on the server → banner with the download link + update.value = await sendMessage({ type: "update_check" }); // the tester may work across several projects — switch right from here projects.value = await sendMessage({ type: "list_projects" }); if (!settings.value.defaultProjectId) return; @@ -182,6 +192,22 @@