diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index 43226a4f354..80c71833d55 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -18,7 +18,7 @@ import { createKeyboardLayoutRouter } from "./keyboardLayout"; import { createMenuRouter } from "./menu"; import { createMigrationRouter } from "./migration"; import { createNotificationsRouter } from "./notifications"; -import { createPageContentRouter } from "./page-content"; +import { createPageRouter } from "./page"; import { createPermissionsRouter } from "./permissions"; import { createPluginsRouter } from "./plugins"; import { createPortForwardsRouter } from "./port-forwards"; @@ -51,7 +51,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => { changes: createChangesRouter(), filesystem: createFilesystemRouter(), notifications: createNotificationsRouter(getWindow), - pageContent: createPageContentRouter(), + page: createPageRouter(), permissions: createPermissionsRouter(), plugins: createPluginsRouter(), ports: createPortsRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/page-content/index.ts b/apps/desktop/src/lib/trpc/routers/page-content/index.ts deleted file mode 100644 index 7e09cbc802f..00000000000 --- a/apps/desktop/src/lib/trpc/routers/page-content/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createPageContentRouter } from "./page-content"; diff --git a/apps/desktop/src/lib/trpc/routers/page-content/page-content.ts b/apps/desktop/src/lib/trpc/routers/page/content.ts similarity index 100% rename from apps/desktop/src/lib/trpc/routers/page-content/page-content.ts rename to apps/desktop/src/lib/trpc/routers/page/content.ts diff --git a/apps/desktop/src/lib/trpc/routers/page/index.ts b/apps/desktop/src/lib/trpc/routers/page/index.ts new file mode 100644 index 00000000000..328724125ba --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/page/index.ts @@ -0,0 +1 @@ +export { createPageRouter } from "./page"; diff --git a/apps/desktop/src/lib/trpc/routers/page/page.ts b/apps/desktop/src/lib/trpc/routers/page/page.ts new file mode 100644 index 00000000000..8629da7ed39 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/page/page.ts @@ -0,0 +1,10 @@ +import { router } from "../.."; +import { createPageContentRouter } from "./content"; +import { createPageThumbnailRouter } from "./thumbnail"; + +export const createPageRouter = () => { + return router({ + content: createPageContentRouter(), + thumbnail: createPageThumbnailRouter(), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/page/thumbnail.ts b/apps/desktop/src/lib/trpc/routers/page/thumbnail.ts new file mode 100644 index 00000000000..98664c4e17a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/page/thumbnail.ts @@ -0,0 +1,21 @@ +import { ensureThumbnail, peekThumbnail } from "main/lib/pageThumbnails"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +const thumbnailKey = z.object({ + accountId: z.string(), + pageId: z.string(), + version: z.string(), +}); + +export const createPageThumbnailRouter = () => { + return router({ + peek: publicProcedure + .input(thumbnailKey) + .query(({ input }) => peekThumbnail(input)), + + ensure: publicProcedure + .input(thumbnailKey.extend({ html: z.string() })) + .mutation(({ input }) => ensureThumbnail(input)), + }); +}; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 1616c6abff5..f7537a6bcbb 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -38,6 +38,10 @@ import { localDb } from "./lib/local-db"; import { requestLocalNetworkAccess } from "./lib/local-network-permission"; import { menuEmitter } from "./lib/menu-events"; import { PAGE_SCHEME, pageProtocolHandler } from "./lib/pageContent"; +import { + THUMBNAIL_SCHEME, + thumbnailProtocolHandler, +} from "./lib/pageThumbnails"; import { initTanstackDbPersistence, shutdownTanstackDbPersistence, @@ -382,6 +386,15 @@ protocol.registerSchemesAsPrivileged([ secure: true, }, }, + { + scheme: THUMBNAIL_SCHEME, + privileges: { + standard: true, + secure: true, + bypassCSP: true, + supportFetchAPI: true, + }, + }, ]); const gotTheLock = app.requestSingleInstanceLock(); @@ -449,6 +462,11 @@ if (!gotTheLock) { .fromPartition("persist:superset") .protocol.handle(PAGE_SCHEME, pageProtocolHandler); + protocol.handle(THUMBNAIL_SCHEME, thumbnailProtocolHandler); + session + .fromPartition("persist:superset") + .protocol.handle(THUMBNAIL_SCHEME, thumbnailProtocolHandler); + // Serve system fonts (e.g. SF Mono on macOS) via custom protocol // so the renderer can use @font-face with font-src 'self' CSP if (process.platform === "darwin") { diff --git a/apps/desktop/src/main/lib/pageThumbnails/index.ts b/apps/desktop/src/main/lib/pageThumbnails/index.ts new file mode 100644 index 00000000000..42ef34e3767 --- /dev/null +++ b/apps/desktop/src/main/lib/pageThumbnails/index.ts @@ -0,0 +1,8 @@ +export { + ensureThumbnail, + peekThumbnail, + THUMBNAIL_SCHEME, + type ThumbnailKey, + thumbnailProtocolHandler, + thumbnailUrl, +} from "./pageThumbnails"; diff --git a/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.test.ts b/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.test.ts new file mode 100644 index 00000000000..d65d2b4af12 --- /dev/null +++ b/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, rm, stat, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const USER_DATA = join(tmpdir(), "superset-test"); + +mock.module("electron", () => ({ + app: { + getPath: () => USER_DATA, + getName: () => "test-app", + getVersion: () => "1.0.0", + getAppPath: () => USER_DATA, + isPackaged: false, + }, + BrowserWindow: mock(() => ({ + webContents: { send: mock(), setAudioMuted: mock() }, + loadURL: mock(), + isDestroyed: () => false, + destroy: mock(), + on: mock(), + })), + session: { + fromPartition: () => ({ + protocol: { handle: mock(), isProtocolHandled: () => false }, + }), + }, + protocol: { + handle: mock(), + isProtocolHandled: () => false, + registerSchemesAsPrivileged: mock(), + }, + dialog: { + showMessageBox: mock(), + showOpenDialog: mock(), + showSaveDialog: mock(), + }, + ipcMain: { handle: mock(), on: mock() }, + webContents: { fromId: () => null }, + shell: { openExternal: mock(), openPath: mock() }, + clipboard: { writeText: mock(), readText: () => "", writeImage: mock() }, + Menu: { + buildFromTemplate: mock(() => ({ popup: mock() })), + setApplicationMenu: mock(), + }, + Notification: mock(() => ({ show: mock(), on: mock() })), +})); + +const { + ensureThumbnail, + peekThumbnail, + THUMBNAIL_SCHEME, + thumbnailProtocolHandler, + thumbnailUrl, +} = await import("./pageThumbnails"); + +const CACHE_DIR = join(USER_DATA, "page-thumbnails"); + +const ACCOUNT_A = "11111111-1111-1111-1111-111111111111"; +const ACCOUNT_B = "22222222-2222-2222-2222-222222222222"; +const PAGE = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + +const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x01]); + +function pathFor(accountId: string, pageId: string, version: string): string { + return join(CACHE_DIR, accountId, `${pageId}-${version}.jpg`); +} + +async function seed( + accountId: string, + pageId: string, + version: string, +): Promise { + const path = pathFor(accountId, pageId, version); + await mkdir(join(CACHE_DIR, accountId), { recursive: true }); + await writeFile(path, JPEG_BYTES); + return path; +} + +function serve(url: string): Promise { + return thumbnailProtocolHandler(new Request(url)); +} + +beforeEach(async () => { + await rm(CACHE_DIR, { recursive: true, force: true }); +}); + +describe("thumbnailUrl", () => { + test("scopes the url to the account", () => { + expect( + thumbnailUrl({ accountId: ACCOUNT_A, pageId: PAGE, version: "3" }), + ).toBe(`${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/3`); + }); + + test("distinguishes versions of the same page", () => { + const v3 = thumbnailUrl({ + accountId: ACCOUNT_A, + pageId: PAGE, + version: "3", + }); + const v4 = thumbnailUrl({ + accountId: ACCOUNT_A, + pageId: PAGE, + version: "4", + }); + expect(v3).not.toBe(v4); + }); +}); + +describe("peekThumbnail", () => { + test("returns null when nothing is cached", async () => { + await expect( + peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "1" }), + ).resolves.toBeNull(); + }); + + test("returns the url once a thumbnail exists", async () => { + await seed(ACCOUNT_A, PAGE, "1"); + await expect( + peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "1" }), + ).resolves.toBe( + thumbnailUrl({ accountId: ACCOUNT_A, pageId: PAGE, version: "1" }), + ); + }); + + test("does not surface another account's thumbnail", async () => { + await seed(ACCOUNT_A, PAGE, "1"); + await expect( + peekThumbnail({ accountId: ACCOUNT_B, pageId: PAGE, version: "1" }), + ).resolves.toBeNull(); + }); + + test("misses a version that has not been captured", async () => { + await seed(ACCOUNT_A, PAGE, "3"); + await expect( + peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "4" }), + ).resolves.toBeNull(); + }); + + test("cannot escape the account directory via the page id", async () => { + await seed(ACCOUNT_B, PAGE, "1"); + await expect( + peekThumbnail({ + accountId: ACCOUNT_A, + pageId: `../${ACCOUNT_B}/${PAGE}`, + version: "1", + }), + ).resolves.toBeNull(); + }); + + test("cannot escape the account directory via the account id", async () => { + await seed(ACCOUNT_B, PAGE, "1"); + await expect( + peekThumbnail({ + accountId: `../page-thumbnails/${ACCOUNT_B}`, + pageId: PAGE, + version: "1", + }), + ).resolves.toBeNull(); + }); + + test("rejects a version that is not a number", async () => { + await seed(ACCOUNT_A, PAGE, "1"); + await expect( + peekThumbnail({ accountId: ACCOUNT_A, pageId: PAGE, version: "1.jpg" }), + ).resolves.toBeNull(); + }); +}); + +describe("thumbnailProtocolHandler", () => { + test("serves a cached thumbnail as an immutable jpeg", async () => { + await seed(ACCOUNT_A, PAGE, "1"); + const response = await serve( + `${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/1`, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/jpeg"); + expect(response.headers.get("Cache-Control")).toContain("immutable"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array(JPEG_BYTES), + ); + }); + + test("404s when the thumbnail is absent", async () => { + const response = await serve( + `${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/1`, + ); + expect(response.status).toBe(404); + }); + + test("will not serve one account's thumbnail to another", async () => { + await seed(ACCOUNT_A, PAGE, "1"); + const response = await serve( + `${THUMBNAIL_SCHEME}://${ACCOUNT_B}/${PAGE}/1`, + ); + expect(response.status).toBe(404); + }); + + test("refuses segments that are not plain identifiers", async () => { + await seed(ACCOUNT_A, PAGE, "1"); + const dotted = await serve( + `${THUMBNAIL_SCHEME}://${ACCOUNT_A}/..secrets/1`, + ); + const encoded = await serve( + `${THUMBNAIL_SCHEME}://${ACCOUNT_A}/%2e%2e%2fsecrets/1`, + ); + expect(dotted.status).toBe(404); + expect(encoded.status).toBe(404); + }); + + test("records access so eviction can order by recency", async () => { + const path = await seed(ACCOUNT_A, PAGE, "1"); + const stale = new Date(Date.now() - 60_000); + await utimes(path, stale, stale); + const before = (await stat(path)).mtimeMs; + + const response = await serve( + `${THUMBNAIL_SCHEME}://${ACCOUNT_A}/${PAGE}/1`, + ); + expect(response.status).toBe(200); + + expect((await stat(path)).mtimeMs).toBeGreaterThan(before); + }); +}); + +describe("ensureThumbnail", () => { + test("returns the cached url without attempting a capture", async () => { + await seed(ACCOUNT_A, PAGE, "7"); + + await expect( + ensureThumbnail({ + accountId: ACCOUNT_A, + pageId: PAGE, + version: "7", + html: "

never rendered

", + }), + ).resolves.toBe( + thumbnailUrl({ accountId: ACCOUNT_A, pageId: PAGE, version: "7" }), + ); + }); + + test("refuses a key that would read outside the account directory", async () => { + await seed(ACCOUNT_B, PAGE, "1"); + await expect( + ensureThumbnail({ + accountId: ACCOUNT_A, + pageId: `../${ACCOUNT_B}/${PAGE}`, + version: "1", + html: "

hi

", + }), + ).rejects.toThrow("Invalid thumbnail key"); + }); +}); diff --git a/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts b/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts new file mode 100644 index 00000000000..03fe9488b62 --- /dev/null +++ b/apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts @@ -0,0 +1,362 @@ +import { + mkdir, + readdir, + readFile, + stat, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import * as electron from "electron"; +import { + PAGE_SCHEME, + pageProtocolHandler, + registerPageContent, + releasePageContent, +} from "../pageContent"; + +export const THUMBNAIL_SCHEME = "superset-thumb"; + +const THUMBNAIL_PARTITION = "page-thumbnails"; + +const FRAME_WIDTH = 1280; +const FRAME_HEIGHT = 880; +const THUMBNAIL_WIDTH = 640; +const THUMBNAIL_HEIGHT = 440; +const JPEG_QUALITY = 80; + +const MAX_CACHED_THUMBNAILS = 512; +const MAX_CONCURRENT_CAPTURES = 2; + +const CAPTURE_DEADLINE_MS = 15_000; +const CAPTURE_ATTEMPT_TIMEOUT_MS = 1_500; +const CAPTURE_RETRY_INTERVAL_MS = 100; +const LOAD_TIMEOUT_MS = 10_000; +const SETTLE_MS = 400; + +const ID_PATTERN = /^[a-zA-Z0-9-]+$/; +const VERSION_PATTERN = /^\d+$/; + +export interface ThumbnailKey { + accountId: string; + pageId: string; + version: string; +} + +function cacheDir(): string { + return join(electron.app.getPath("userData"), "page-thumbnails"); +} + +function accountDir(accountId: string): string { + return join(cacheDir(), accountId); +} + +function thumbnailPath({ accountId, pageId, version }: ThumbnailKey): string { + return join(accountDir(accountId), `${pageId}-${version}.jpg`); +} + +export function thumbnailUrl({ + accountId, + pageId, + version, +}: ThumbnailKey): string { + return `${THUMBNAIL_SCHEME}://${accountId}/${pageId}/${version}`; +} + +function isValidKey({ accountId, pageId, version }: ThumbnailKey): boolean { + return ( + ID_PATTERN.test(accountId) && + ID_PATTERN.test(pageId) && + VERSION_PATTERN.test(version) + ); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function withTimeout( + promise: Promise, + ms: number, + message: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +let activeCaptures = 0; +const captureQueue: Array<() => void> = []; + +async function acquireCaptureSlot(): Promise { + if (activeCaptures < MAX_CONCURRENT_CAPTURES) { + activeCaptures += 1; + return; + } + await new Promise((resolve) => captureQueue.push(resolve)); + activeCaptures += 1; +} + +function releaseCaptureSlot(): void { + activeCaptures -= 1; + const next = captureQueue.shift(); + if (next) next(); +} + +async function captureWithRetry( + window: Electron.BrowserWindow, +): Promise { + const deadline = Date.now() + CAPTURE_DEADLINE_MS; + let lastError: unknown = null; + do { + if (window.isDestroyed()) break; + try { + const image = await withTimeout( + window.webContents.capturePage(), + CAPTURE_ATTEMPT_TIMEOUT_MS, + "Thumbnail capture attempt timed out", + ); + if (!image.isEmpty()) return image; + lastError = new Error("Thumbnail capture produced an empty image"); + } catch (error) { + lastError = error; + } + await delay(CAPTURE_RETRY_INTERVAL_MS); + } while (Date.now() < deadline); + + throw lastError instanceof Error + ? lastError + : new Error("Thumbnail capture failed"); +} + +const TIMED_OUT = Symbol("timed-out"); + +const BLANK_PROBE_SIZE = 8; + +function isBlank(image: Electron.NativeImage): boolean { + const bitmap = image + .resize({ + width: BLANK_PROBE_SIZE, + height: BLANK_PROBE_SIZE, + quality: "good", + }) + .toBitmap(); + if (bitmap.length < 4) return true; + for (let offset = 4; offset < bitmap.length; offset += 4) { + if ( + bitmap[offset] !== bitmap[0] || + bitmap[offset + 1] !== bitmap[1] || + bitmap[offset + 2] !== bitmap[2] + ) { + return false; + } + } + return true; +} + +let partitionReady = false; + +function ensurePartitionProtocol(): void { + if (partitionReady) return; + partitionReady = true; + const partition = electron.session.fromPartition(THUMBNAIL_PARTITION); + if (!partition.protocol.isProtocolHandled(PAGE_SCHEME)) { + partition.protocol.handle(PAGE_SCHEME, pageProtocolHandler); + } +} + +async function captureHtml(html: string): Promise { + ensurePartitionProtocol(); + const { token, url } = registerPageContent(html); + const window = new electron.BrowserWindow({ + show: false, + paintWhenInitiallyHidden: true, + width: FRAME_WIDTH, + height: FRAME_HEIGHT, + webPreferences: { + partition: THUMBNAIL_PARTITION, + backgroundThrottling: false, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + spellcheck: false, + }, + }); + + try { + window.webContents.setAudioMuted(true); + + const navigation = window + .loadURL(url) + .then(() => null) + .catch((error: unknown) => error); + const outcome = await Promise.race([ + navigation, + delay(LOAD_TIMEOUT_MS).then(() => TIMED_OUT), + ]); + if (outcome && outcome !== TIMED_OUT) { + throw outcome instanceof Error + ? outcome + : new Error("Thumbnail page failed to load"); + } + + if (window.isDestroyed()) { + throw new Error("Thumbnail window closed before capture"); + } + await delay(SETTLE_MS); + const image = await captureWithRetry(window); + if (outcome === TIMED_OUT && isBlank(image)) { + throw new Error( + "Thumbnail page had not rendered when the load timed out", + ); + } + return image + .resize({ + width: THUMBNAIL_WIDTH, + height: THUMBNAIL_HEIGHT, + quality: "good", + }) + .toJPEG(JPEG_QUALITY); + } finally { + releasePageContent(token); + if (!window.isDestroyed()) window.destroy(); + } +} + +async function listCachedFiles(): Promise< + Array<{ path: string; mtimeMs: number }> +> { + const root = cacheDir(); + const accounts = await readdir(root, { withFileTypes: true }); + const perAccount = await Promise.all( + accounts.map(async (account) => { + if (!account.isDirectory()) return []; + const dir = join(root, account.name); + const names = await readdir(dir).catch(() => [] as string[]); + const entries = await Promise.all( + names.map(async (name) => { + const path = join(dir, name); + try { + const info = await stat(path); + return { path, mtimeMs: info.mtimeMs }; + } catch { + return null; + } + }), + ); + return entries.filter( + (entry): entry is { path: string; mtimeMs: number } => Boolean(entry), + ); + }), + ); + return perAccount.flat(); +} + +let pruning = false; + +async function pruneCache(): Promise { + if (pruning) return; + pruning = true; + try { + const files = await listCachedFiles(); + if (files.length <= MAX_CACHED_THUMBNAILS) return; + const sorted = files.sort((a, b) => a.mtimeMs - b.mtimeMs); + const excess = sorted.length - MAX_CACHED_THUMBNAILS; + await Promise.all( + sorted + .slice(0, excess) + .map((entry) => unlink(entry.path).catch(() => undefined)), + ); + } catch { + return; + } finally { + pruning = false; + } +} + +async function hasThumbnail(key: ThumbnailKey): Promise { + try { + await stat(thumbnailPath(key)); + return true; + } catch { + return false; + } +} + +export async function peekThumbnail(key: ThumbnailKey): Promise { + if (!isValidKey(key)) return null; + return (await hasThumbnail(key)) ? thumbnailUrl(key) : null; +} + +const inflight = new Map>(); + +export function ensureThumbnail( + key: ThumbnailKey & { html: string }, +): Promise { + if (!isValidKey(key)) { + return Promise.reject(new Error("Invalid thumbnail key")); + } + + const cacheKey = `${key.accountId}:${key.pageId}:${key.version}`; + const existing = inflight.get(cacheKey); + if (existing) return existing; + + const pending: Promise = (async () => { + if (await hasThumbnail(key)) return thumbnailUrl(key); + + await acquireCaptureSlot(); + try { + const jpeg = await captureHtml(key.html); + await mkdir(accountDir(key.accountId), { recursive: true }); + await writeFile(thumbnailPath(key), jpeg); + void pruneCache(); + return thumbnailUrl(key); + } finally { + releaseCaptureSlot(); + } + })().finally(() => { + if (inflight.get(cacheKey) === pending) inflight.delete(cacheKey); + }); + + inflight.set(cacheKey, pending); + return pending; +} + +export async function thumbnailProtocolHandler( + request: Request, +): Promise { + const url = new URL(request.url); + const [pageId = "", version = ""] = url.pathname + .replace(/^\//, "") + .split("/"); + const key = { accountId: url.hostname, pageId, version }; + + if (!isValidKey(key)) { + return new Response("Not found", { status: 404 }); + } + + const path = thumbnailPath(key); + try { + const bytes = await readFile(path); + const now = new Date(); + await utimes(path, now, now).catch(() => undefined); + return new Response(new Uint8Array(bytes), { + status: 200, + headers: { + "Content-Type": "image/jpeg", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); + } catch { + return new Response("Not found", { status: 404 }); + } +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx index 3883c993729..0a00d1ddd96 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/PageViewer/PageViewer.tsx @@ -1,15 +1,17 @@ import { useLingui } from "@lingui/react/macro"; -import { authClient } from "@superset/auth/client"; import { CommentProvider, PageCommentsView } from "@superset/ui/page-comments"; import { Spinner } from "@superset/ui/spinner"; import { useQuery } from "@tanstack/react-query"; import { TRPCClientError } from "@trpc/client"; import { useCallback, useEffect, useRef } from "react"; +import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { electronTrpcClient } from "renderer/lib/trpc-client"; import { PageViewerMessage } from "./components/PageViewerMessage"; import { usePageCommentStore } from "./hooks/usePageCommentStore"; +const scrollPositions = new Map(); + export interface ResolvedPage { id: string; slug: string; @@ -43,6 +45,7 @@ export function PageViewer({ pageId: resolvedPageId ?? "", version: pull.data?.version ?? 0, }); + const scrollKey = `${resolvedPageId ?? slug}:${pull.data?.version ?? 0}`; const onResolvedRef = useRef(onResolved); onResolvedRef.current = onResolved; @@ -73,13 +76,14 @@ export function PageViewer({ const servedToken = useRef(null); const serveHtml = useCallback(async (injectedHtml: string) => { if (servedToken.current) { - void electronTrpcClient.pageContent.release.mutate({ + void electronTrpcClient.page.content.release.mutate({ token: servedToken.current, }); } - const { token, url } = await electronTrpcClient.pageContent.register.mutate( - { html: injectedHtml }, - ); + const { token, url } = + await electronTrpcClient.page.content.register.mutate({ + html: injectedHtml, + }); servedToken.current = token; return url; }, []); @@ -87,7 +91,7 @@ export function PageViewer({ useEffect( () => () => { if (servedToken.current) { - void electronTrpcClient.pageContent.release.mutate({ + void electronTrpcClient.page.content.release.mutate({ token: servedToken.current, }); servedToken.current = null; @@ -153,6 +157,8 @@ export function PageViewer({ html={content.data} title={resolvedTitle} serveHtml={serveHtml} + initialScrollY={scrollPositions.get(scrollKey) ?? 0} + onScrollYChange={(y) => scrollPositions.set(scrollKey, y)} /> diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts index a46659e9a72..4a585348f27 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/hooks/usePageHeaderData/usePageHeaderData.ts @@ -1,10 +1,10 @@ -import { authClient } from "@superset/auth/client"; import type { CommentThread, PageHeaderPage, PageHeaderVersion, } from "@superset/ui/page-comments"; import { useCallback } from "react"; +import { authClient } from "renderer/lib/auth-client"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; import { toThreads } from "renderer/routes/_authenticated/_dashboard/utils/toThreads"; @@ -41,6 +41,7 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { { enabled: Boolean(pageId) && version > 0 }, ); + const utils = cloudTrpc.useUtils(); const setVisibility = cloudTrpc.page.setVisibility.useMutation(); const setSharedVersion = cloudTrpc.page.setSharedVersion.useMutation(); const deletePage = cloudTrpc.page.delete.useMutation(); @@ -75,8 +76,13 @@ export function usePageHeaderData(data: PageHeaderTarget): PageHeaderData { currentUserId: session?.user.id, onSetVisibility: async (visibility) => { if (!pageId) return; - await setVisibility.mutateAsync({ id: pageId, visibility }); - await refresh(); + const updated = await setVisibility.mutateAsync({ + id: pageId, + visibility, + }); + utils.page.pull.setData(ref, (prev) => + prev ? { ...prev, visibility: updated.visibility } : prev, + ); }, onSetSharedVersion: async (version) => { if (!pageId) return; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx index 895158d803f..575316a288f 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/PagesGrid.tsx @@ -23,21 +23,25 @@ const SKELETON_KEYS = [ interface PagesGridProps { pages: PageCardItem[]; pinnedPageIds: ReadonlySet; + currentUserId: string | undefined; isPending: boolean; error?: string; hasFilters: boolean; onOpen: (page: PageCardItem, event: React.MouseEvent) => void; onTogglePin: (pageId: string) => void; + onDelete: (pageId: string) => Promise; } export function PagesGrid({ pages, pinnedPageIds, + currentUserId, isPending, error, hasFilters, onOpen, onTogglePin, + onDelete, }: PagesGridProps) { if (isPending) { return ( @@ -109,8 +113,10 @@ export function PagesGrid({ key={page.id} page={page} isPinned={pinnedPageIds.has(page.id)} + currentUserId={currentUserId} onOpen={onOpen} onTogglePin={onTogglePin} + onDelete={onDelete} /> ))} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx index 6bea6706f9a..79774ddbf21 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/PageCard.tsx @@ -7,10 +7,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { DeletePageDialog } from "@superset/ui/page-comments"; import { toast } from "@superset/ui/sonner"; import { cn } from "@superset/ui/utils"; -import { Globe, Link2, Lock, MoreVertical, Pin, PinOff } from "lucide-react"; -import type { MouseEvent } from "react"; +import { + Globe, + Link2, + Lock, + MoreVertical, + Pin, + PinOff, + Trash2, +} from "lucide-react"; +import { type MouseEvent, useState } from "react"; import { PageThumbnail } from "./components/PageThumbnail"; export interface PageCardItem { @@ -21,23 +30,35 @@ export interface PageCardItem { visibility: string; createdAt: Date | string; updatedAt: Date | string; + latestVersion: number | null; + sharedVersion: number | null; + createdByUserId: string | null; + ownerName: string | null; } interface PageCardProps { page: PageCardItem; isPinned: boolean; + currentUserId: string | undefined; onOpen: (page: PageCardItem, event: MouseEvent) => void; onTogglePin: (pageId: string) => void; + onDelete: (pageId: string) => Promise; } export function PageCard({ page, isPinned, + currentUserId, onOpen, onTogglePin, + onDelete, }: PageCardProps) { const { t } = useLingui(); + const [deleteOpen, setDeleteOpen] = useState(false); const isShared = page.visibility === "org"; + const isOwner = + currentUserId !== undefined && currentUserId === page.createdByUserId; + const ownerName = isOwner ? null : page.ownerName; const VisibilityIcon = isShared ? Globe : Lock; const edited = new Date(page.updatedAt).getTime(); const created = new Date(page.createdAt).getTime(); @@ -70,7 +91,11 @@ export function PageCard({ onClick={(event) => onOpen(page, event)} className="flex flex-1 flex-col text-left" > - +
{page.title} @@ -84,6 +109,12 @@ export function PageCard({ )}{" "} {timestamp} + {ownerName ? ( + <> + + {ownerName} + + ) : null}
@@ -127,8 +158,25 @@ export function PageCard({ Copy link + {isOwner ? ( + setDeleteOpen(true)} + > + + Delete + + ) : null} + + onDelete(page.id)} + /> ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx index 895a79df3a8..ed60df56cdb 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/pages/components/PagesGrid/components/PageCard/components/PageThumbnail/PageThumbnail.tsx @@ -1,27 +1,26 @@ import { Spinner } from "@superset/ui/spinner"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { FileText } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { cloudTrpc } from "renderer/lib/cloud-trpc"; -import { - FRAME_HEIGHT, - FRAME_WIDTH, - THUMBNAIL_ASPECT_RATIO, -} from "../../../../constants"; -import { - loadThumbnailUrl, - thumbnailCacheKey, -} from "./utils/pageThumbnailCache"; +import { electronTrpcClient } from "renderer/lib/trpc-client"; +import { THUMBNAIL_ASPECT_RATIO } from "../../../../constants"; interface PageThumbnailProps { - slug: string; pageId: string; + version: number | null; + accountId: string | undefined; } -export function PageThumbnail({ slug, pageId }: PageThumbnailProps) { +export function PageThumbnail({ + pageId, + version, + accountId, +}: PageThumbnailProps) { const containerRef = useRef(null); + const recovered = useRef(false); + const queryClient = useQueryClient(); const [isVisible, setIsVisible] = useState(false); - const [scale, setScale] = useState(0); useEffect(() => { const element = containerRef.current; @@ -35,46 +34,68 @@ export function PageThumbnail({ slug, pageId }: PageThumbnailProps) { ); observer.observe(element); - const resize = new ResizeObserver(([entry]) => { - const width = entry?.contentRect.width ?? 0; - if (width > 0) setScale(width / FRAME_WIDTH); - }); - resize.observe(element); - - return () => { - observer.disconnect(); - resize.disconnect(); - }; + return () => observer.disconnect(); }, []); + const versionKey = version === null ? null : String(version); + const enabled = isVisible && versionKey !== null && accountId !== undefined; + const key = + enabled && versionKey + ? { accountId: accountId as string, pageId, version: versionKey } + : null; + + const cached = useQuery({ + queryKey: ["page-thumbnail", "cached", accountId, pageId, versionKey], + enabled, + staleTime: Number.POSITIVE_INFINITY, + queryFn: () => + electronTrpcClient.page.thumbnail.peek.query( + key as NonNullable, + ), + }); + + const needsCapture = enabled && cached.isSuccess && cached.data === null; + const pull = cloudTrpc.page.pull.useQuery( - { id: pageId }, - { enabled: isVisible, staleTime: 5 * 60 * 1000 }, + { id: pageId, version: version ?? undefined }, + { enabled: needsCapture, staleTime: 5 * 60 * 1000 }, ); const downloadUrl = pull.data?.downloadUrl; - const version = pull.data?.version ?? 0; - - const thumbnailEnabled = Boolean(downloadUrl) && version > 0; - const thumbnail = useQuery({ - queryKey: ["page-thumbnail", pageId, version], - enabled: thumbnailEnabled, + const captured = useQuery({ + queryKey: ["page-thumbnail", "captured", accountId, pageId, versionKey], + enabled: needsCapture && Boolean(downloadUrl), staleTime: Number.POSITIVE_INFINITY, - queryFn: () => - loadThumbnailUrl(thumbnailCacheKey(pageId, version), async () => { - const response = await fetch(downloadUrl as string, { - cache: "force-cache", - }); - if (!response.ok) { - throw new Error(`Preview failed to load (${response.status})`); - } - return response.text(); - }), + retry: false, + queryFn: async () => { + const response = await fetch(downloadUrl as string, { + cache: "force-cache", + }); + if (!response.ok) { + throw new Error(`Preview failed to load (${response.status})`); + } + return electronTrpcClient.page.thumbnail.ensure.mutate({ + ...(key as NonNullable), + html: await response.text(), + }); + }, }); + const recoverFromEviction = () => { + if (recovered.current) return; + recovered.current = true; + for (const stage of ["cached", "captured"]) { + queryClient.removeQueries({ + queryKey: ["page-thumbnail", stage, accountId, pageId, versionKey], + }); + } + }; + + const src = cached.data ?? captured.data ?? null; + const failed = cached.isError || pull.isError || captured.isError; const isLoading = - isVisible && (pull.isPending || (thumbnailEnabled && thumbnail.isPending)); + enabled && !src && !failed && (cached.isPending || needsCapture); return (
- {thumbnail.data && scale > 0 ? ( -