Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { createMenuRouter } from "./menu";
import { createMigrationRouter } from "./migration";
import { createNotificationsRouter } from "./notifications";
import { createPageContentRouter } from "./page-content";
import { createPageThumbnailRouter } from "./page-thumbnail";
import { createPermissionsRouter } from "./permissions";
import { createPluginsRouter } from "./plugins";
import { createPortsRouter } from "./ports";
Expand Down Expand Up @@ -51,6 +52,7 @@ export const createAppRouter = (getWindow: () => BrowserWindow | null) => {
filesystem: createFilesystemRouter(),
notifications: createNotificationsRouter(getWindow),
pageContent: createPageContentRouter(),
pageThumbnail: createPageThumbnailRouter(),
permissions: createPermissionsRouter(),
plugins: createPluginsRouter(),
ports: createPortsRouter(),
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/lib/trpc/routers/page-thumbnail/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { createPageThumbnailRouter } from "./page-thumbnail";
21 changes: 21 additions & 0 deletions apps/desktop/src/lib/trpc/routers/page-thumbnail/page-thumbnail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ensureThumbnail, peekThumbnail } from "main/lib/pageThumbnails";
import { z } from "zod";
import { publicProcedure, router } from "../..";

export const createPageThumbnailRouter = () => {
return router({
peek: publicProcedure
.input(z.object({ pageId: z.string(), version: z.string() }))
.query(({ input }) => peekThumbnail(input.pageId, input.version)),

ensure: publicProcedure
.input(
z.object({
pageId: z.string(),
version: z.string(),
html: z.string(),
}),
)
.mutation(({ input }) => ensureThumbnail(input)),
});
};
18 changes: 18 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,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,
Expand Down Expand Up @@ -350,6 +354,15 @@ protocol.registerSchemesAsPrivileged([
secure: true,
},
},
{
scheme: THUMBNAIL_SCHEME,
privileges: {
standard: true,
secure: true,
bypassCSP: true,
supportFetchAPI: true,
},
},
]);

const gotTheLock = app.requestSingleInstanceLock();
Expand Down Expand Up @@ -413,6 +426,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") {
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/main/lib/pageThumbnails/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
ensureThumbnail,
peekThumbnail,
THUMBNAIL_SCHEME,
thumbnailProtocolHandler,
thumbnailUrl,
} from "./pageThumbnails";
299 changes: 299 additions & 0 deletions apps/desktop/src/main/lib/pageThumbnails/pageThumbnails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
import {
mkdir,
readdir,
readFile,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { app, BrowserWindow, session } 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 PAGE_ID_PATTERN = /^[a-zA-Z0-9-]+$/;
const VERSION_PATTERN = /^\d+$/;

function cacheDir(): string {
return join(app.getPath("userData"), "page-thumbnails");
}

function thumbnailPath(pageId: string, version: string): string {
return join(cacheDir(), `${pageId}-${version}.jpg`);
}

export function thumbnailUrl(pageId: string, version: string): string {
return `${THUMBNAIL_SCHEME}://${pageId}/${version}`;
}

function isValidKey(pageId: string, version: string): boolean {
return PAGE_ID_PATTERN.test(pageId) && VERSION_PATTERN.test(version);
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

async function withTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_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<void> {
if (activeCaptures < MAX_CONCURRENT_CAPTURES) {
activeCaptures += 1;
return;
}
await new Promise<void>((resolve) => captureQueue.push(resolve));
activeCaptures += 1;
}

function releaseCaptureSlot(): void {
activeCaptures -= 1;
const next = captureQueue.shift();
if (next) next();
}

async function captureWithRetry(
window: BrowserWindow,
): Promise<Electron.NativeImage> {
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");
}

let partitionReady = false;

function ensurePartitionProtocol(): void {
if (partitionReady) return;
partitionReady = true;
const partition = session.fromPartition(THUMBNAIL_PARTITION);
if (!partition.protocol.isProtocolHandled(PAGE_SCHEME)) {
partition.protocol.handle(PAGE_SCHEME, pageProtocolHandler);
}
}

async function captureHtml(html: string): Promise<Buffer> {
ensurePartitionProtocol();
const { token, url } = registerPageContent(html);
const window = new 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);
await Promise.race([
window.loadURL(url).catch(() => undefined),
delay(LOAD_TIMEOUT_MS),
]);
if (window.isDestroyed()) {
throw new Error("Thumbnail window closed before capture");
}
await delay(SETTLE_MS);
const image = await captureWithRetry(window);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return image
.resize({
width: THUMBNAIL_WIDTH,
height: THUMBNAIL_HEIGHT,
quality: "good",
})
.toJPEG(JPEG_QUALITY);
} finally {
releasePageContent(token);
if (!window.isDestroyed()) window.destroy();
}
}

let pruning = false;

async function pruneCache(): Promise<void> {
if (pruning) return;
pruning = true;
try {
const dir = cacheDir();
const names = await readdir(dir);
if (names.length <= MAX_CACHED_THUMBNAILS) return;

const entries = await Promise.all(
names.map(async (name) => {
try {
const info = await stat(join(dir, name));
return { name, mtimeMs: info.mtimeMs };
} catch {
return null;
}
}),
);

const sorted = entries
.filter((entry): entry is { name: string; mtimeMs: number } =>
Boolean(entry),
)
.sort((a, b) => a.mtimeMs - b.mtimeMs);

const excess = sorted.length - MAX_CACHED_THUMBNAILS;
await Promise.all(
sorted
.slice(0, excess)
.map((entry) => unlink(join(dir, entry.name)).catch(() => undefined)),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
);
} catch {
return;
} finally {
pruning = false;
}
}

async function hasThumbnail(pageId: string, version: string): Promise<boolean> {
try {
await stat(thumbnailPath(pageId, version));
return true;
} catch {
return false;
}
}

export async function peekThumbnail(
pageId: string,
version: string,
): Promise<string | null> {
if (!isValidKey(pageId, version)) return null;
return (await hasThumbnail(pageId, version))
? thumbnailUrl(pageId, version)
: null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

const inflight = new Map<string, Promise<string>>();

export function ensureThumbnail({
pageId,
version,
html,
}: {
pageId: string;
version: string;
html: string;
}): Promise<string> {
if (!isValidKey(pageId, version)) {
return Promise.reject(new Error("Invalid thumbnail key"));
}

const key = `${pageId}:${version}`;
const existing = inflight.get(key);
if (existing) return existing;

const pending: Promise<string> = (async () => {
if (await hasThumbnail(pageId, version)) {
return thumbnailUrl(pageId, version);
}

await acquireCaptureSlot();
try {
const jpeg = await captureHtml(html);
await mkdir(cacheDir(), { recursive: true });
await writeFile(thumbnailPath(pageId, version), jpeg);
void pruneCache();
return thumbnailUrl(pageId, version);
} finally {
releaseCaptureSlot();
}
})().finally(() => {
if (inflight.get(key) === pending) inflight.delete(key);
});

inflight.set(key, pending);
return pending;
}

export async function thumbnailProtocolHandler(
request: Request,
): Promise<Response> {
const url = new URL(request.url);
const pageId = url.hostname;
const version = url.pathname.replace(/^\//, "");

if (!isValidKey(pageId, version)) {
return new Response("Not found", { status: 404 });
}

try {
const bytes = await readFile(thumbnailPath(pageId, version));
return new Response(new Uint8Array(bytes), {
status: 200,
headers: {
"Content-Type": "image/jpeg",
"Cache-Control": "public, max-age=31536000, immutable",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
} catch {
return new Response("Not found", { status: 404 });
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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";
Expand Down
Loading