diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 36c22bc7fbd..856ffefbe77 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -14,6 +14,10 @@ import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { track } from "main/lib/analytics"; import { localDb } from "main/lib/local-db"; +import { + deleteProjectIcon, + saveProjectIconFromDataUrl, +} from "main/lib/project-icons"; import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; @@ -34,6 +38,7 @@ import { sanitizeAuthorPrefix, } from "../workspaces/utils/git"; import { getDefaultProjectColor } from "./utils/colors"; +import { discoverAndSaveProjectIcon } from "./utils/favicon-discovery"; import { fetchGitHubOwner, getGitHubAvatarUrl } from "./utils/github"; type Project = SelectProject; @@ -1054,6 +1059,90 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { prefix: sanitizeAuthorPrefix(authorName), }; }), + + triggerFaviconDiscovery: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project ${input.id} not found`, + }); + } + + // Skip if the project already has an icon + if (project.iconUrl) { + return { iconUrl: project.iconUrl }; + } + + const iconUrl = await discoverAndSaveProjectIcon({ + projectId: project.id, + repoPath: project.mainRepoPath, + }); + + if (iconUrl) { + localDb + .update(projects) + .set({ iconUrl }) + .where(eq(projects.id, input.id)) + .run(); + } + + return { iconUrl }; + }), + + setProjectIcon: publicProcedure + .input( + z.object({ + id: z.string(), + icon: z.string().nullable(), + }), + ) + .mutation(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + + if (!project) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Project ${input.id} not found`, + }); + } + + if (input.icon === null) { + // Remove icon + deleteProjectIcon(input.id); + localDb + .update(projects) + .set({ iconUrl: null }) + .where(eq(projects.id, input.id)) + .run(); + return { iconUrl: null }; + } + + // Save icon from data URL + const iconUrl = await saveProjectIconFromDataUrl({ + projectId: input.id, + dataUrl: input.icon, + }); + + localDb + .update(projects) + .set({ iconUrl }) + .where(eq(projects.id, input.id)) + .run(); + + return { iconUrl }; + }), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts new file mode 100644 index 00000000000..f985b7c2eb1 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts @@ -0,0 +1,86 @@ +import { readFile, stat } from "node:fs/promises"; +import { extname } from "node:path"; +import fg from "fast-glob"; +import { + saveProjectIconFromBuffer, + saveProjectIconFromFile, +} from "main/lib/project-icons"; + +/** Common favicon file names to search for in project roots */ +const FAVICON_PATTERNS = [ + "favicon.ico", + "favicon.png", + "favicon.svg", + "logo.png", + "logo.svg", + "icon.png", + "icon.svg", + ".github/logo.png", + ".github/logo.svg", + "public/favicon.ico", + "public/favicon.png", + "public/favicon.svg", + "public/logo.png", + "public/logo.svg", + "static/favicon.ico", + "static/favicon.png", + "static/favicon.svg", + "assets/favicon.ico", + "assets/favicon.png", + "assets/icon.png", +]; + +/** Max file size for discovered favicons: 256KB */ +const MAX_FAVICON_SIZE = 256 * 1024; + +/** + * Discovers a favicon/icon in the project directory and saves it to disk. + * Returns the protocol URL if found, or null if no icon was discovered. + */ +export async function discoverAndSaveProjectIcon({ + projectId, + repoPath, +}: { + projectId: string; + repoPath: string; +}): Promise { + try { + const matches = await fg(FAVICON_PATTERNS, { + cwd: repoPath, + absolute: true, + ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"], + onlyFiles: true, + }); + + if (matches.length === 0) return null; + + // Use the first match (ordered by FAVICON_PATTERNS priority) + const iconPath = matches[0]; + + // Check file size + const fileStat = await stat(iconPath); + if (fileStat.size > MAX_FAVICON_SIZE) { + console.log( + `[favicon-discovery] Icon too large (${Math.round(fileStat.size / 1024)}KB): ${iconPath}`, + ); + return null; + } + + const ext = extname(iconPath).replace(".", "") || "png"; + + // For .ico files, read as buffer since they may need special handling + if (ext === "ico") { + const buffer = await readFile(iconPath); + return await saveProjectIconFromBuffer({ + projectId, + buffer: Buffer.from(buffer), + ext: "ico", + }); + } + + return await saveProjectIconFromFile({ projectId, sourcePath: iconPath }); + } catch (error) { + console.error("[favicon-discovery] Error discovering icon:", error); + return null; + } +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts index 70c7307750a..9ec29e5a8d8 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/query.ts @@ -160,6 +160,7 @@ export const createQueryProcedures = () => { githubOwner: string | null; mainRepoPath: string; hideImage: boolean; + iconUrl: string | null; }; workspaces: Array<{ id: string; @@ -190,6 +191,7 @@ export const createQueryProcedures = () => { githubOwner: project.githubOwner ?? null, mainRepoPath: project.mainRepoPath, hideImage: project.hideImage ?? false, + iconUrl: project.iconUrl ?? null, }, workspaces: [], }); diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 09c0d42fc90..93a167ec012 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,6 +1,7 @@ import path from "node:path"; +import { pathToFileURL } from "node:url"; import { settings } from "@superset/local-db"; -import { app, BrowserWindow, dialog } from "electron"; +import { app, BrowserWindow, dialog, net, protocol, session } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { handleAuthCallback, @@ -11,6 +12,7 @@ import { setupAgentHooks } from "./lib/agent-setup"; import { initAppState } from "./lib/app-state"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; +import { ensureProjectIconsDir, getProjectIconPath } from "./lib/project-icons"; import { initSentry } from "./lib/sentry"; import { reconcileDaemonSessions } from "./lib/terminal"; import { disposeTray, initTray } from "./lib/tray"; @@ -210,6 +212,19 @@ if (process.env.NODE_ENV === "development") { parentCheckInterval.unref(); } +// Register superset-icon:// protocol for serving project icons from disk +protocol.registerSchemesAsPrivileged([ + { + scheme: "superset-icon", + privileges: { + standard: true, + secure: true, + bypassCSP: true, + supportFetchAPI: true, + }, + }, +]); + // Single instance lock - required for second-instance event on Windows/Linux const gotTheLock = app.requestSingleInstanceLock(); @@ -229,6 +244,25 @@ if (!gotTheLock) { (async () => { await app.whenReady(); + // Register protocol handler for superset-icon:// URLs + // Must register on BOTH default session and the app's custom partition + const iconProtocolHandler = (request: Request) => { + const url = new URL(request.url); + // superset-icon://projects/{projectId} → file on disk + const projectId = url.pathname.replace(/^\//, ""); + const iconPath = getProjectIconPath(projectId); + if (!iconPath) { + return new Response("Not found", { status: 404 }); + } + return net.fetch(pathToFileURL(iconPath).toString()); + }; + protocol.handle("superset-icon", iconProtocolHandler); + session + .fromPartition("persist:superset") + .protocol.handle("superset-icon", iconProtocolHandler); + + ensureProjectIconsDir(); + initSentry(); await initAppState(); diff --git a/apps/desktop/src/main/lib/project-icons.ts b/apps/desktop/src/main/lib/project-icons.ts new file mode 100644 index 00000000000..4b056fae060 --- /dev/null +++ b/apps/desktop/src/main/lib/project-icons.ts @@ -0,0 +1,145 @@ +import { existsSync, mkdirSync, readdirSync, unlinkSync } from "node:fs"; +import { copyFile, writeFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { SUPERSET_HOME_DIR } from "./app-environment"; + +export const PROJECT_ICONS_DIR = join(SUPERSET_HOME_DIR, "project-icons"); + +/** Max icon file size: 512KB */ +const MAX_ICON_SIZE = 512 * 1024; + +/** + * Ensures the project icons directory exists. + * Call at startup. + */ +export function ensureProjectIconsDir(): void { + if (!existsSync(PROJECT_ICONS_DIR)) { + mkdirSync(PROJECT_ICONS_DIR, { recursive: true }); + } +} + +/** + * Finds the icon file for a project by globbing for any extension. + * Returns the full path or null if no icon exists. + */ +export function getProjectIconPath(projectId: string): string | null { + if (!existsSync(PROJECT_ICONS_DIR)) return null; + + const files = readdirSync(PROJECT_ICONS_DIR); + const match = files.find((f) => { + const name = f.substring(0, f.lastIndexOf(".")); + return name === projectId; + }); + + return match ? join(PROJECT_ICONS_DIR, match) : null; +} + +/** + * Removes any existing icon file for a project (any extension). + */ +function removeExistingIcon(projectId: string): void { + const existing = getProjectIconPath(projectId); + if (existing) { + unlinkSync(existing); + } +} + +/** + * Returns the protocol URL for a project icon. + */ +export function getProjectIconProtocolUrl(projectId: string): string { + return `superset-icon://projects/${projectId}`; +} + +/** + * Saves an icon file for a project from a local file path. + * Copies the file to PROJECT_ICONS_DIR/{projectId}.{ext}. + * Returns the protocol URL. + */ +export async function saveProjectIconFromFile({ + projectId, + sourcePath, +}: { + projectId: string; + sourcePath: string; +}): Promise { + ensureProjectIconsDir(); + removeExistingIcon(projectId); + + const ext = extname(sourcePath) || ".png"; + const destPath = join(PROJECT_ICONS_DIR, `${projectId}${ext}`); + await copyFile(sourcePath, destPath); + + return getProjectIconProtocolUrl(projectId); +} + +/** + * Saves an icon file for a project from a base64 data URL. + * Decodes and writes the file to PROJECT_ICONS_DIR/{projectId}.{ext}. + * Returns the protocol URL. + */ +export async function saveProjectIconFromDataUrl({ + projectId, + dataUrl, +}: { + projectId: string; + dataUrl: string; +}): Promise { + ensureProjectIconsDir(); + removeExistingIcon(projectId); + + // Parse data URL: data:image/png;base64, + const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/); + if (!match) { + throw new Error("Invalid data URL format"); + } + + const ext = match[1] === "jpeg" ? "jpg" : match[1]; + const buffer = Buffer.from(match[2], "base64"); + + if (buffer.length > MAX_ICON_SIZE) { + throw new Error( + `Icon file too large (${Math.round(buffer.length / 1024)}KB). Maximum is ${MAX_ICON_SIZE / 1024}KB.`, + ); + } + + const destPath = join(PROJECT_ICONS_DIR, `${projectId}.${ext}`); + await writeFile(destPath, buffer); + + return getProjectIconProtocolUrl(projectId); +} + +/** + * Saves an icon from a Buffer with explicit extension. + * Returns the protocol URL. + */ +export async function saveProjectIconFromBuffer({ + projectId, + buffer, + ext, +}: { + projectId: string; + buffer: Buffer; + ext: string; +}): Promise { + ensureProjectIconsDir(); + removeExistingIcon(projectId); + + if (buffer.length > MAX_ICON_SIZE) { + throw new Error( + `Icon file too large (${Math.round(buffer.length / 1024)}KB). Maximum is ${MAX_ICON_SIZE / 1024}KB.`, + ); + } + + const destPath = join(PROJECT_ICONS_DIR, `${projectId}.${ext}`); + await writeFile(destPath, buffer); + + return getProjectIconProtocolUrl(projectId); +} + +/** + * Removes the icon file for a project from disk. + */ +export function deleteProjectIcon(projectId: string): void { + removeExistingIcon(projectId); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx index e0e9cb3a27e..7b38abc7bc2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/project/$projectId/components/ProjectSettings/ProjectSettings.tsx @@ -11,8 +11,9 @@ import { import { Switch } from "@superset/ui/switch"; import { cn } from "@superset/ui/utils"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { HiOutlineCog6Tooth, HiOutlinePaintBrush } from "react-icons/hi2"; +import { LuImagePlus, LuTrash2 } from "react-icons/lu"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { PROJECT_COLOR_DEFAULT, @@ -84,6 +85,44 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) { }, }); + const setProjectIcon = electronTrpc.projects.setProjectIcon.useMutation({ + onError: (err) => { + console.error("[project-settings/setProjectIcon] Failed:", err); + }, + onSettled: () => { + utils.projects.get.invalidate({ id: projectId }); + utils.workspaces.getAllGrouped.invalidate(); + }, + }); + + const fileInputRef = useRef(null); + + const handleIconUpload = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + setProjectIcon.mutate({ id: projectId, icon: dataUrl }); + }; + reader.readAsDataURL(file); + + // Reset input so the same file can be re-selected + e.target.value = ""; + }, + [projectId, setProjectIcon], + ); + + const handleRemoveIcon = useCallback(() => { + setProjectIcon.mutate({ id: projectId, icon: null }); + }, [projectId, setProjectIcon]); + const handleBranchPrefixModeChange = (value: string) => { if (value === "default") { updateProject.mutate({ @@ -259,6 +298,58 @@ export function ProjectSettings({ projectId }: ProjectSettingsProps) { /> + + {/* Project Icon */} +
+
+ +

+ Upload a custom icon for the sidebar. +

+
+
+ {project.iconUrl && ( + Project icon + )} + + + {project.iconUrl && ( + + )} +
+
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx index 4d9c2a5cf00..b877b5646af 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectHeader.tsx @@ -43,6 +43,7 @@ interface ProjectHeaderProps { githubOwner: string | null; mainRepoPath: string; hideImage: boolean; + iconUrl: string | null; /** Whether the project section is collapsed (workspaces hidden) */ isCollapsed: boolean; /** Whether the sidebar is in collapsed mode (icon-only view) */ @@ -59,6 +60,7 @@ export function ProjectHeader({ githubOwner, mainRepoPath, hideImage, + iconUrl, isCollapsed, isSidebarCollapsed = false, onToggleCollapse, @@ -205,6 +207,7 @@ export function ProjectHeader({ projectName={projectName} projectColor={projectColor} githubOwner={githubOwner} + iconUrl={iconUrl} /> @@ -279,6 +282,7 @@ export function ProjectHeader({ projectColor={projectColor} githubOwner={githubOwner} hideImage={hideImage} + iconUrl={iconUrl} /> {projectName} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx index a791df1da9d..6e754563a36 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectSection.tsx @@ -29,6 +29,7 @@ interface ProjectSectionProps { githubOwner: string | null; mainRepoPath: string; hideImage: boolean; + iconUrl: string | null; workspaces: Workspace[]; /** Base index for keyboard shortcuts (0-based) */ shortcutBaseIndex: number; @@ -45,6 +46,7 @@ export function ProjectSection({ githubOwner, mainRepoPath, hideImage, + iconUrl, workspaces, shortcutBaseIndex, index, @@ -143,6 +145,7 @@ export function ProjectSection({ githubOwner={githubOwner} mainRepoPath={mainRepoPath} hideImage={hideImage} + iconUrl={iconUrl} isCollapsed={isCollapsed} isSidebarCollapsed={isSidebarCollapsed} onToggleCollapse={() => toggleProjectCollapsed(projectId)} @@ -200,6 +203,7 @@ export function ProjectSection({ githubOwner={githubOwner} mainRepoPath={mainRepoPath} hideImage={hideImage} + iconUrl={iconUrl} isCollapsed={isCollapsed} isSidebarCollapsed={isSidebarCollapsed} onToggleCollapse={() => toggleProjectCollapsed(projectId)} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx index 5fe236905d6..a49b8a10186 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/ProjectSection/ProjectThumbnail/ProjectThumbnail.tsx @@ -9,6 +9,7 @@ interface ProjectThumbnailProps { projectColor: string; githubOwner: string | null; hideImage?: boolean; + iconUrl?: string | null; className?: string; } @@ -39,9 +40,11 @@ export function ProjectThumbnail({ projectColor, githubOwner, hideImage, + iconUrl, className, }: ProjectThumbnailProps) { const [imageError, setImageError] = useState(false); + const [iconError, setIconError] = useState(false); const { data: avatarData } = electronTrpc.projects.getGitHubAvatar.useQuery( { id: projectId }, @@ -64,7 +67,28 @@ export function ProjectThumbnail({ ? { borderColor: hexToRgba(projectColor, 0.6) } : undefined; - // Show GitHub avatar if available and not hidden + // Priority 1: Show project icon if available (works for both superset-icon:// and https://) + if (iconUrl && !iconError) { + return ( +
+ {`${projectName} setIconError(true)} + /> +
+ ); + } + + // Priority 2: Show GitHub avatar if available and not hidden if (owner && !imageError && !hideImage) { return (
(), branchPrefixCustom: text("branch_prefix_custom"), hideImage: integer("hide_image", { mode: "boolean" }), + iconUrl: text("icon_url"), }, (table) => [ index("projects_main_repo_path_idx").on(table.mainRepoPath),