Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
89 changes: 89 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
}),
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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];
Comment on lines +48 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

fast-glob pattern order results documentation

💡 Result:

In fast-glob (Node.js):

  • Result ordering is not guaranteed: matched paths are returned in arbitrary order, so you must sort the returned array yourself if you need deterministic ordering. [1][2]
  • Pattern list order is not respected: when you pass multiple patterns, all negative patterns (!…) are applied first, and only then the positive patterns—regardless of the order you wrote them in. If you need “pattern A results first, then pattern B”, you must split into multiple calls and combine/sort manually. [3]

Sources:
[1] fast-glob README (GitHub) — “results are returned in arbitrary order”
[2] fast-glob package page (npm) — same statement
[3] fast-glob README (GitHub) — “does not respect the order of patterns… negative… then positive… use sorting or split calls”


fast-glob does not preserve input pattern order in results.

The comment on line 57 states results are "ordered by FAVICON_PATTERNS priority," but fast-glob returns matches in arbitrary filesystem traversal order. This means icons may be selected in unintended priority (e.g., public/logo.svg before favicon.ico), contradicting the intention of the FAVICON_PATTERNS array.

Sort matches by pattern priority after globbing:

Proposed fix
 	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];
+	// Sort matches by FAVICON_PATTERNS priority (fast-glob doesn't preserve pattern order)
+	const relativize = (abs: string) => abs.replace(`${repoPath}/`, "").replace(`${repoPath}\\`, "");
+	matches.sort((a, b) => {
+		const aIdx = FAVICON_PATTERNS.indexOf(relativize(a));
+		const bIdx = FAVICON_PATTERNS.indexOf(relativize(b));
+		return (aIdx === -1 ? Infinity : aIdx) - (bIdx === -1 ? Infinity : bIdx);
+	});
+	const iconPath = matches[0];
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/projects/utils/favicon-discovery.ts` around
lines 48 - 58, The current selection of iconPath relies on fast-glob results but
fg does not guarantee pattern-priority ordering, so change the logic after the
fg(...) call to pick the highest-priority match based on FAVICON_PATTERNS: build
a lookup of matches, then iterate FAVICON_PATTERNS in order and for each pattern
check which matched paths correspond to it (e.g., using minimatch or by
re-testing the pattern against each match) and choose the first hit as iconPath;
update the code referencing matches[0] to use this prioritized selection while
preserving ignore/cwd/absolute settings from the original fg call.


// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export const createQueryProcedures = () => {
githubOwner: string | null;
mainRepoPath: string;
hideImage: boolean;
iconUrl: string | null;
};
workspaces: Array<{
id: string;
Expand Down Expand Up @@ -190,6 +191,7 @@ export const createQueryProcedures = () => {
githubOwner: project.githubOwner ?? null,
mainRepoPath: project.mainRepoPath,
hideImage: project.hideImage ?? false,
iconUrl: project.iconUrl ?? null,
},
workspaces: [],
});
Expand Down
36 changes: 35 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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();

Expand All @@ -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();
Expand Down
Loading