mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
||||
Text,
|
||||
Pressable,
|
||||
Modal,
|
||||
Image,
|
||||
} from "react-native";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
useSectionOrderStore,
|
||||
sortProjectsByStoredOrder,
|
||||
} from "@/stores/section-order-store";
|
||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||
|
||||
interface SectionData {
|
||||
key: string;
|
||||
@@ -96,6 +98,13 @@ function SectionHeader({
|
||||
});
|
||||
const checkout = checkoutQuery.data ?? null;
|
||||
|
||||
// Get project icon
|
||||
const iconQuery = useProjectIconQuery({
|
||||
serverId: section.firstAgentServerId ?? "",
|
||||
cwd: section.workingDir ?? "",
|
||||
});
|
||||
const icon = iconQuery.icon;
|
||||
|
||||
// Derive display title: prefer repo name from remote URL, fallback to path-based name
|
||||
let displayTitle = section.title;
|
||||
if (checkout?.isGit && checkout.remoteUrl) {
|
||||
@@ -138,6 +147,12 @@ function SectionHeader({
|
||||
onHoverOut={() => setIsHovered(false)}
|
||||
>
|
||||
<View style={styles.sectionHeaderLeft}>
|
||||
{icon && (
|
||||
<Image
|
||||
source={{ uri: `data:${icon.mimeType};base64,${icon.data}` }}
|
||||
style={styles.projectIcon}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.sectionTitle} numberOfLines={1}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
@@ -586,6 +601,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: "flex-start",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
projectIcon: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
sectionHeaderRight: {
|
||||
flexDirection: "row",
|
||||
|
||||
44
packages/app/src/hooks/use-project-icon-query.ts
Normal file
44
packages/app/src/hooks/use-project-icon-query.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { ProjectIcon } from "@server/shared/messages";
|
||||
|
||||
export function projectIconQueryKey(serverId: string, cwd: string) {
|
||||
return ["projectIcon", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
interface UseProjectIconQueryOptions {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export function useProjectIconQuery({ serverId, cwd }: UseProjectIconQueryOptions) {
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const isConnected = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.connection.isConnected ?? false
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: projectIconQueryKey(serverId, cwd),
|
||||
queryFn: async (): Promise<ProjectIcon | null> => {
|
||||
if (!client) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
const result = await client.requestProjectIcon(cwd);
|
||||
return result.icon;
|
||||
},
|
||||
enabled: !!client && isConnected && !!cwd,
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
return {
|
||||
icon: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
CheckoutPrStatusResponse,
|
||||
PaseoWorktreeListResponse,
|
||||
PaseoWorktreeArchiveResponse,
|
||||
ProjectIconResponse,
|
||||
ListCommandsResponse,
|
||||
ExecuteCommandResponse,
|
||||
ListVoiceConversationsResponseMessage,
|
||||
@@ -1599,6 +1600,33 @@ export class DaemonClientV2 {
|
||||
return response;
|
||||
}
|
||||
|
||||
async requestProjectIcon(
|
||||
cwd: string,
|
||||
requestId?: string
|
||||
): Promise<ProjectIconResponse["payload"]> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "project_icon_request",
|
||||
cwd,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
const response = this.waitFor(
|
||||
(msg) => {
|
||||
if (msg.type !== "project_icon_response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== resolvedRequestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
10000,
|
||||
{ skipQueue: true }
|
||||
);
|
||||
await this.sendSessionMessageOrThrow(message);
|
||||
return response;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Provider Models / Commands
|
||||
// ============================================================================
|
||||
|
||||
@@ -326,7 +326,8 @@ export class AgentManager {
|
||||
async resumeAgent(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
agentId?: string
|
||||
agentId?: string,
|
||||
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
|
||||
): Promise<ManagedAgent> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const mergedConfig = {
|
||||
@@ -344,7 +345,8 @@ export class AgentManager {
|
||||
return this.registerSession(
|
||||
session,
|
||||
normalizedConfig,
|
||||
agentId ?? this.idFactory()
|
||||
agentId ?? this.idFactory(),
|
||||
timestamps
|
||||
);
|
||||
}
|
||||
|
||||
@@ -792,7 +794,8 @@ export class AgentManager {
|
||||
private async registerSession(
|
||||
session: AgentSession,
|
||||
config: AgentSessionConfig,
|
||||
agentId: string
|
||||
agentId: string,
|
||||
timestamps?: { createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null }
|
||||
): Promise<ManagedAgent> {
|
||||
if (this.agents.has(agentId)) {
|
||||
throw new Error(`Agent with id ${agentId} already exists`);
|
||||
@@ -801,6 +804,7 @@ export class AgentManager {
|
||||
// Inform the session of its managed agent ID for MCP parent-child relationships
|
||||
session.setManagedAgentId?.(agentId);
|
||||
|
||||
const now = new Date();
|
||||
const managed = {
|
||||
id: agentId,
|
||||
provider: config.provider,
|
||||
@@ -810,8 +814,8 @@ export class AgentManager {
|
||||
config,
|
||||
runtimeInfo: undefined,
|
||||
lifecycle: "initializing",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: timestamps?.createdAt ?? now,
|
||||
updatedAt: timestamps?.updatedAt ?? now,
|
||||
availableModes: [],
|
||||
currentModeId: null,
|
||||
pendingPermissions: new Map(),
|
||||
@@ -819,7 +823,7 @@ export class AgentManager {
|
||||
timeline: [],
|
||||
persistence: session.describePersistence(),
|
||||
historyPrimed: false,
|
||||
lastUserMessageAt: null,
|
||||
lastUserMessageAt: timestamps?.lastUserMessageAt ?? null,
|
||||
attention: { requiresAttention: false },
|
||||
parentAgentId: config.parentAgentId,
|
||||
internal: config.internal ?? false,
|
||||
|
||||
@@ -74,3 +74,13 @@ export function buildSessionConfig(
|
||||
extra: overrides.extra,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractTimestamps(
|
||||
record: StoredAgentRecord
|
||||
): { createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null } {
|
||||
return {
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt: new Date(record.lastActivityAt ?? record.updatedAt),
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import type { VoiceConversationStore } from "./voice-conversation-store.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
buildSessionConfig,
|
||||
extractTimestamps,
|
||||
} from "./persistence-hooks.js";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
createPullRequest,
|
||||
getPullRequestStatus,
|
||||
} from "../utils/checkout-git.js";
|
||||
import { getProjectIcon } from "../utils/project-icon.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import type pino from "pino";
|
||||
|
||||
@@ -721,7 +723,8 @@ export class Session {
|
||||
snapshot = await this.agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
agentId,
|
||||
extractTimestamps(record)
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
@@ -935,6 +938,10 @@ export class Session {
|
||||
await this.handleFileExplorerRequest(msg);
|
||||
break;
|
||||
|
||||
case "project_icon_request":
|
||||
await this.handleProjectIconRequest(msg);
|
||||
break;
|
||||
|
||||
case "file_download_token_request":
|
||||
await this.handleFileDownloadTokenRequest(msg);
|
||||
break;
|
||||
@@ -1952,7 +1959,8 @@ export class Session {
|
||||
snapshot = await this.agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
agentId,
|
||||
extractTimestamps(record)
|
||||
);
|
||||
}
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
@@ -3836,6 +3844,38 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle project icon request for a given cwd
|
||||
*/
|
||||
private async handleProjectIconRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "project_icon_request" }>
|
||||
): Promise<void> {
|
||||
const { cwd, requestId } = request;
|
||||
|
||||
try {
|
||||
const icon = await getProjectIcon(cwd);
|
||||
this.emit({
|
||||
type: "project_icon_response",
|
||||
payload: {
|
||||
cwd,
|
||||
icon,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
this.emit({
|
||||
type: "project_icon_response",
|
||||
payload: {
|
||||
cwd,
|
||||
icon: null,
|
||||
error: error.message,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle file download token request scoped to an agent's cwd
|
||||
*/
|
||||
|
||||
@@ -616,6 +616,12 @@ export const FileExplorerRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectIconRequestSchema = z.object({
|
||||
type: z.literal("project_icon_request"),
|
||||
cwd: z.string(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const FileDownloadTokenRequestSchema = z.object({
|
||||
type: z.literal("file_download_token_request"),
|
||||
agentId: z.string(),
|
||||
@@ -745,6 +751,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
HighlightedDiffRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
ProjectIconRequestSchema,
|
||||
FileDownloadTokenRequestSchema,
|
||||
GitRepoInfoRequestMessageSchema,
|
||||
ClearAgentAttentionMessageSchema,
|
||||
@@ -1221,6 +1228,21 @@ export const FileExplorerResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const ProjectIconSchema = z.object({
|
||||
data: z.string(),
|
||||
mimeType: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectIconResponseSchema = z.object({
|
||||
type: z.literal("project_icon_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
icon: ProjectIconSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const FileDownloadTokenResponseSchema = z.object({
|
||||
type: z.literal("file_download_token_response"),
|
||||
payload: z.object({
|
||||
@@ -1406,6 +1428,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
HighlightedDiffResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
GitRepoInfoResponseSchema,
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
@@ -1499,6 +1522,9 @@ export type HighlightedDiffRequest = z.infer<typeof HighlightedDiffRequestSchema
|
||||
export type HighlightedDiffResponse = z.infer<typeof HighlightedDiffResponseSchema>;
|
||||
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
|
||||
export type FileExplorerResponse = z.infer<typeof FileExplorerResponseSchema>;
|
||||
export type ProjectIconRequest = z.infer<typeof ProjectIconRequestSchema>;
|
||||
export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
|
||||
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
|
||||
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
|
||||
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
|
||||
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;
|
||||
|
||||
266
packages/server/src/utils/project-icon.test.ts
Normal file
266
packages/server/src/utils/project-icon.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, realpathSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { findProjectIcon, getProjectIcon, ICON_PATTERNS, PRIORITY_DIRS, IGNORED_DIRS } from "./project-icon.js";
|
||||
|
||||
function createTempDir(): string {
|
||||
return realpathSync(mkdtempSync(join(tmpdir(), "project-icon-test-")));
|
||||
}
|
||||
|
||||
describe("findProjectIcon", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("ICON_PATTERNS", () => {
|
||||
it("includes common favicon patterns", () => {
|
||||
expect(ICON_PATTERNS).toContain("favicon.ico");
|
||||
expect(ICON_PATTERNS).toContain("favicon.png");
|
||||
expect(ICON_PATTERNS).toContain("favicon.svg");
|
||||
});
|
||||
|
||||
it("includes app icon patterns", () => {
|
||||
expect(ICON_PATTERNS).toContain("icon.png");
|
||||
expect(ICON_PATTERNS).toContain("icon.svg");
|
||||
expect(ICON_PATTERNS).toContain("app-icon.png");
|
||||
});
|
||||
|
||||
it("includes logo patterns", () => {
|
||||
expect(ICON_PATTERNS).toContain("logo.png");
|
||||
expect(ICON_PATTERNS).toContain("logo.svg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PRIORITY_DIRS", () => {
|
||||
it("includes common asset directories", () => {
|
||||
expect(PRIORITY_DIRS).toContain("public");
|
||||
expect(PRIORITY_DIRS).toContain("static");
|
||||
expect(PRIORITY_DIRS).toContain("assets");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IGNORED_DIRS", () => {
|
||||
it("includes common ignored directories", () => {
|
||||
expect(IGNORED_DIRS).toContain(".git");
|
||||
expect(IGNORED_DIRS).toContain("node_modules");
|
||||
expect(IGNORED_DIRS).toContain("dist");
|
||||
expect(IGNORED_DIRS).toContain("build");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no icon is found", async () => {
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("finds favicon.ico in root directory", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.ico"), "icon content");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "favicon.ico"));
|
||||
});
|
||||
|
||||
it("finds favicon.png in root directory", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.png"), "icon content");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "favicon.png"));
|
||||
});
|
||||
|
||||
it("finds icon in public directory (priority dir)", async () => {
|
||||
mkdirSync(join(tempDir, "public"));
|
||||
writeFileSync(join(tempDir, "public", "favicon.ico"), "icon content");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "public", "favicon.ico"));
|
||||
});
|
||||
|
||||
it("finds icon in static directory (priority dir)", async () => {
|
||||
mkdirSync(join(tempDir, "static"));
|
||||
writeFileSync(join(tempDir, "static", "favicon.svg"), "icon content");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "static", "favicon.svg"));
|
||||
});
|
||||
|
||||
it("finds icon in assets directory (priority dir)", async () => {
|
||||
mkdirSync(join(tempDir, "assets"));
|
||||
writeFileSync(join(tempDir, "assets", "logo.png"), "icon content");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "assets", "logo.png"));
|
||||
});
|
||||
|
||||
it("prioritizes favicon over logo", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.ico"), "favicon");
|
||||
writeFileSync(join(tempDir, "logo.png"), "logo");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "favicon.ico"));
|
||||
});
|
||||
|
||||
it("prioritizes priority dirs over root", async () => {
|
||||
writeFileSync(join(tempDir, "logo.png"), "root logo");
|
||||
mkdirSync(join(tempDir, "public"));
|
||||
writeFileSync(join(tempDir, "public", "favicon.ico"), "public favicon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "public", "favicon.ico"));
|
||||
});
|
||||
|
||||
it("ignores files in .git directory", async () => {
|
||||
mkdirSync(join(tempDir, ".git"));
|
||||
writeFileSync(join(tempDir, ".git", "favicon.ico"), "git icon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores files in node_modules directory", async () => {
|
||||
mkdirSync(join(tempDir, "node_modules"));
|
||||
writeFileSync(join(tempDir, "node_modules", "favicon.ico"), "node icon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores files in dist directory", async () => {
|
||||
mkdirSync(join(tempDir, "dist"));
|
||||
writeFileSync(join(tempDir, "dist", "favicon.ico"), "dist icon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("finds icon in nested priority directory", async () => {
|
||||
mkdirSync(join(tempDir, "public", "images"), { recursive: true });
|
||||
writeFileSync(join(tempDir, "public", "images", "favicon.png"), "nested icon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "public", "images", "favicon.png"));
|
||||
});
|
||||
|
||||
it("finds apple-touch-icon.png", async () => {
|
||||
writeFileSync(join(tempDir, "apple-touch-icon.png"), "apple icon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "apple-touch-icon.png"));
|
||||
});
|
||||
|
||||
it("finds icon-*.png patterns", async () => {
|
||||
writeFileSync(join(tempDir, "icon-192.png"), "192 icon");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
expect(result).toBe(join(tempDir, "icon-192.png"));
|
||||
});
|
||||
|
||||
it("handles non-existent directory gracefully", async () => {
|
||||
const result = await findProjectIcon(join(tempDir, "nonexistent"));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the first match when multiple icons exist in same location", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.ico"), "ico");
|
||||
writeFileSync(join(tempDir, "favicon.png"), "png");
|
||||
writeFileSync(join(tempDir, "favicon.svg"), "svg");
|
||||
|
||||
const result = await findProjectIcon(tempDir);
|
||||
// Should return the first one based on pattern order (favicon.ico comes first)
|
||||
expect(result).toBe(join(tempDir, "favicon.ico"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectIcon", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = createTempDir();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Valid 1x1 PNG (square)
|
||||
const squarePng = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, // IHDR chunk length
|
||||
0x49, 0x48, 0x44, 0x52, // IHDR
|
||||
0x00, 0x00, 0x00, 0x01, // width: 1
|
||||
0x00, 0x00, 0x00, 0x01, // height: 1
|
||||
0x08, 0x02, // bit depth, color type
|
||||
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||
0x90, 0x77, 0x53, 0xde, // CRC
|
||||
0x00, 0x00, 0x00, 0x0c, // IDAT chunk length
|
||||
0x49, 0x44, 0x41, 0x54, // IDAT
|
||||
0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0xff, 0x00, 0x05, 0xfe, 0x02, 0xfe, // data
|
||||
0xa3, 0x6c, 0x47, 0x9f, // CRC
|
||||
0x00, 0x00, 0x00, 0x00, // IEND chunk length
|
||||
0x49, 0x45, 0x4e, 0x44, // IEND
|
||||
0xae, 0x42, 0x60, 0x82, // CRC
|
||||
]);
|
||||
|
||||
// Valid 2x1 PNG (non-square)
|
||||
const nonSquarePng = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, // IHDR chunk length
|
||||
0x49, 0x48, 0x44, 0x52, // IHDR
|
||||
0x00, 0x00, 0x00, 0x02, // width: 2
|
||||
0x00, 0x00, 0x00, 0x01, // height: 1
|
||||
0x08, 0x02, // bit depth, color type
|
||||
0x00, 0x00, 0x00, // compression, filter, interlace
|
||||
0x00, 0x00, 0x00, 0x00, // CRC (not validated)
|
||||
]);
|
||||
|
||||
it("returns icon data for square PNG", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.png"), squarePng);
|
||||
|
||||
const result = await getProjectIcon(tempDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/png");
|
||||
expect(result?.data).toBe(squarePng.toString("base64"));
|
||||
});
|
||||
|
||||
it("returns null for non-square PNG", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.png"), nonSquarePng);
|
||||
|
||||
const result = await getProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns icon data for ICO files (assumed square)", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.ico"), "ico content");
|
||||
|
||||
const result = await getProjectIcon(tempDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/x-icon");
|
||||
});
|
||||
|
||||
it("returns icon data for SVG files (assumed square)", async () => {
|
||||
writeFileSync(join(tempDir, "favicon.svg"), "<svg></svg>");
|
||||
|
||||
const result = await getProjectIcon(tempDir);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mimeType).toBe("image/svg+xml");
|
||||
});
|
||||
|
||||
it("returns null for files over 32KB", async () => {
|
||||
const largeContent = Buffer.alloc(33 * 1024, 0);
|
||||
writeFileSync(join(tempDir, "favicon.ico"), largeContent);
|
||||
|
||||
const result = await getProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no icon is found", async () => {
|
||||
const result = await getProjectIcon(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
406
packages/server/src/utils/project-icon.ts
Normal file
406
packages/server/src/utils/project-icon.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import { readdir, readFile, stat } from "fs/promises";
|
||||
import { extname, join } from "path";
|
||||
|
||||
/**
|
||||
* Icon file patterns to search for, in priority order.
|
||||
* Patterns starting with '*' are glob patterns (e.g., icon-*.png).
|
||||
*/
|
||||
export const ICON_PATTERNS = [
|
||||
"favicon.ico",
|
||||
"favicon.png",
|
||||
"favicon.svg",
|
||||
"icon.png",
|
||||
"icon.svg",
|
||||
"app-icon.png",
|
||||
"app-icon.svg",
|
||||
"apple-touch-icon.png",
|
||||
"icon-*.png",
|
||||
"logo.png",
|
||||
"logo.svg",
|
||||
];
|
||||
|
||||
/**
|
||||
* Directories to search first (in priority order).
|
||||
*/
|
||||
export const PRIORITY_DIRS = ["public", "static", "assets", "images", "img"];
|
||||
|
||||
/**
|
||||
* Directories to ignore during search.
|
||||
*/
|
||||
export const IGNORED_DIRS = [
|
||||
".git",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".nuxt",
|
||||
".output",
|
||||
"coverage",
|
||||
".cache",
|
||||
"vendor",
|
||||
"src",
|
||||
"lib",
|
||||
"test",
|
||||
"tests",
|
||||
"__tests__",
|
||||
];
|
||||
|
||||
export interface ProjectIcon {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
const MAX_ICON_SIZE = 32 * 1024; // 32KB max
|
||||
|
||||
interface ImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function getPngDimensions(buffer: Buffer): ImageDimensions | null {
|
||||
// PNG header: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (buffer.length < 24) return null;
|
||||
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
|
||||
return null;
|
||||
}
|
||||
// Width and height are at bytes 16-19 and 20-23 (big endian)
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function getJpegDimensions(buffer: Buffer): ImageDimensions | null {
|
||||
// JPEG starts with FF D8 FF
|
||||
if (buffer.length < 4) return null;
|
||||
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) return null;
|
||||
|
||||
let offset = 2;
|
||||
while (offset < buffer.length - 8) {
|
||||
if (buffer[offset] !== 0xff) {
|
||||
offset++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = buffer[offset + 1];
|
||||
// SOF0-SOF2 markers contain dimensions
|
||||
if (marker >= 0xc0 && marker <= 0xc2) {
|
||||
const height = buffer.readUInt16BE(offset + 5);
|
||||
const width = buffer.readUInt16BE(offset + 7);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// Skip to next marker
|
||||
const length = buffer.readUInt16BE(offset + 2);
|
||||
offset += 2 + length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getGifDimensions(buffer: Buffer): ImageDimensions | null {
|
||||
// GIF header: GIF87a or GIF89a
|
||||
if (buffer.length < 10) return null;
|
||||
if (buffer[0] !== 0x47 || buffer[1] !== 0x49 || buffer[2] !== 0x46) return null;
|
||||
// Width and height at bytes 6-7 and 8-9 (little endian)
|
||||
const width = buffer.readUInt16LE(6);
|
||||
const height = buffer.readUInt16LE(8);
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function getWebpDimensions(buffer: Buffer): ImageDimensions | null {
|
||||
// WEBP: RIFF....WEBP
|
||||
if (buffer.length < 30) return null;
|
||||
if (buffer.toString("ascii", 0, 4) !== "RIFF") return null;
|
||||
if (buffer.toString("ascii", 8, 12) !== "WEBP") return null;
|
||||
|
||||
const chunkType = buffer.toString("ascii", 12, 16);
|
||||
if (chunkType === "VP8 ") {
|
||||
// Lossy format - dimensions at offset 26-27 and 28-29
|
||||
const width = buffer.readUInt16LE(26) & 0x3fff;
|
||||
const height = buffer.readUInt16LE(28) & 0x3fff;
|
||||
return { width, height };
|
||||
} else if (chunkType === "VP8L") {
|
||||
// Lossless format
|
||||
const bits = buffer.readUInt32LE(21);
|
||||
const width = (bits & 0x3fff) + 1;
|
||||
const height = ((bits >> 14) & 0x3fff) + 1;
|
||||
return { width, height };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getImageDimensions(buffer: Buffer, mimeType: string): ImageDimensions | null {
|
||||
switch (mimeType) {
|
||||
case "image/png":
|
||||
return getPngDimensions(buffer);
|
||||
case "image/jpeg":
|
||||
return getJpegDimensions(buffer);
|
||||
case "image/gif":
|
||||
return getGifDimensions(buffer);
|
||||
case "image/webp":
|
||||
return getWebpDimensions(buffer);
|
||||
case "image/x-icon":
|
||||
// ICO files are typically square, trust them
|
||||
return { width: 1, height: 1 };
|
||||
case "image/svg+xml":
|
||||
// SVG can be any aspect ratio but icons are typically square, trust them
|
||||
return { width: 1, height: 1 };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isSquareImage(buffer: Buffer, mimeType: string): boolean {
|
||||
const dimensions = getImageDimensions(buffer, mimeType);
|
||||
if (!dimensions) return false;
|
||||
return dimensions.width === dimensions.height;
|
||||
}
|
||||
|
||||
function getMimeType(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".ico":
|
||||
return "image/x-icon";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
function matchesPattern(filename: string, pattern: string): boolean {
|
||||
if (pattern.includes("*")) {
|
||||
// Convert glob pattern to regex
|
||||
const regexPattern = pattern
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*/g, ".*");
|
||||
return new RegExp(`^${regexPattern}$`).test(filename);
|
||||
}
|
||||
return filename === pattern;
|
||||
}
|
||||
|
||||
async function findIconInDir(
|
||||
dir: string,
|
||||
patterns: string[]
|
||||
): Promise<string | null> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check each pattern in order of priority
|
||||
for (const pattern of patterns) {
|
||||
for (const entry of entries) {
|
||||
if (matchesPattern(entry, pattern)) {
|
||||
const fullPath = join(dir, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
if (stats.isFile()) {
|
||||
return fullPath;
|
||||
}
|
||||
} catch {
|
||||
// File may have been deleted, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function searchDirRecursively(
|
||||
dir: string,
|
||||
patterns: string[],
|
||||
ignoredDirs: Set<string>,
|
||||
maxDepth: number,
|
||||
currentDepth: number = 0
|
||||
): Promise<string | null> {
|
||||
if (currentDepth > maxDepth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// First check this directory for icons
|
||||
const found = await findIconInDir(dir, patterns);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// Then recurse into subdirectories
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ignoredDirs.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = join(dir, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
const result = await searchDirRecursively(
|
||||
fullPath,
|
||||
patterns,
|
||||
ignoredDirs,
|
||||
maxDepth,
|
||||
currentDepth + 1
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory may be inaccessible, continue
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a project icon/favicon in the given directory.
|
||||
* Searches priority directories first, then falls back to scanning the root.
|
||||
*
|
||||
* @param projectDir - The root directory of the project to search
|
||||
* @param maxDepth - Maximum depth to search (default: 3)
|
||||
* @returns The absolute path to the found icon, or null if not found
|
||||
*/
|
||||
export async function findProjectIcon(
|
||||
projectDir: string,
|
||||
maxDepth: number = 3
|
||||
): Promise<string | null> {
|
||||
const ignoredDirsSet = new Set(IGNORED_DIRS);
|
||||
|
||||
// First search priority directories
|
||||
for (const priorityDir of PRIORITY_DIRS) {
|
||||
const priorityPath = join(projectDir, priorityDir);
|
||||
try {
|
||||
const stats = await stat(priorityPath);
|
||||
if (stats.isDirectory()) {
|
||||
const result = await searchDirRecursively(
|
||||
priorityPath,
|
||||
ICON_PATTERNS,
|
||||
ignoredDirsSet,
|
||||
maxDepth - 1
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory doesn't exist, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Then search root and any other non-priority directories
|
||||
const found = await findDirRecursively(projectDir);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findDirRecursively(
|
||||
dir: string,
|
||||
maxDepth: number = 2,
|
||||
currentDepth: number = 0
|
||||
): Promise<string | null> {
|
||||
const ignoredDirsSet = new Set(IGNORED_DIRS);
|
||||
const priorityDirsSet = new Set(PRIORITY_DIRS);
|
||||
|
||||
if (currentDepth > maxDepth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check root for icons
|
||||
const found = await findIconInDir(dir, ICON_PATTERNS);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
|
||||
// Don't recurse further from root - we already searched priority dirs
|
||||
if (currentDepth > 0) {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (ignoredDirsSet.has(entry) || priorityDirsSet.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullPath = join(dir, entry);
|
||||
try {
|
||||
const stats = await stat(fullPath);
|
||||
if (stats.isDirectory()) {
|
||||
const result = await findDirRecursively(
|
||||
fullPath,
|
||||
maxDepth,
|
||||
currentDepth + 1
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and read a project icon/favicon, returning it as base64.
|
||||
* Only returns square icons smaller than MAX_ICON_SIZE (32KB).
|
||||
*
|
||||
* @param projectDir - The root directory of the project to search
|
||||
* @returns The icon data with mime type, or null if not found
|
||||
*/
|
||||
export async function getProjectIcon(
|
||||
projectDir: string
|
||||
): Promise<ProjectIcon | null> {
|
||||
const iconPath = await findProjectIcon(projectDir);
|
||||
if (!iconPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await stat(iconPath);
|
||||
if (stats.size > MAX_ICON_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const buffer = await readFile(iconPath);
|
||||
const mimeType = getMimeType(iconPath);
|
||||
|
||||
// Only return square images
|
||||
if (!isSquareImage(buffer, mimeType)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = buffer.toString("base64");
|
||||
return { data, mimeType };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user