From de49d637d4b72b3e3306533e3777cb37e6a3f738 Mon Sep 17 00:00:00 2001 From: Prathmesh Vhatkar Date: Sat, 10 Oct 2026 17:20:31 +0530 Subject: [PATCH] feat(server): use macOS .icns app icons as project icons (#17149) --- apps/desktop/src/ipc/methods/window.test.ts | 2 +- apps/desktop/src/ipc/methods/window.ts | 4 +- apps/server/src/assets/AssetAccess.test.ts | 32 ++++++++++ apps/server/src/assets/AssetAccess.ts | 36 +++++++++-- apps/server/src/assets/icns.test.ts | 62 +++++++++++++++++++ apps/server/src/assets/icns.ts | 30 +++++++++ .../src/project/ProjectFaviconResolver.ts | 4 +- .../src/workspace/WorkspaceSearchIndex.ts | 4 +- packages/contracts/src/project.test.ts | 4 ++ packages/contracts/src/project.ts | 2 +- packages/shared/src/projectFavicon.ts | 9 +++ 11 files changed, 176 insertions(+), 13 deletions(-) create mode 100644 apps/server/src/assets/icns.test.ts create mode 100644 apps/server/src/assets/icns.ts diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index a6913528653cadbecab8bae2631740ab54bb4d1b..d21f3cd6050535a91a41a0abcdb5d15f5ead5500 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -287,7 +287,7 @@ describe("pickProjectFavicon", () => { filters: [ { name: "Images", - extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"], + extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"], }, ], }, diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 47e6c02ffe52eaf73439b86589d66ef47caf3577..33895ffffbd1a27ef0081cc7cc07a7888d803a50 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -13,7 +13,7 @@ import { type DesktopEnvironmentBootstrap, type PickedThemeFile, } from "@t3tools/contracts"; -import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; +import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; import { resolveEditorCommand } from "@t3tools/shared/editor"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as FileSystem from "effect/FileSystem"; @@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ filters: [ { name: "Images", - extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)), + extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)), }, ], }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8a92b40aaea272cee86a678afe04581575c7c292..4e71c68f875c8302aaa33943c058479f6e063117 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1226,6 +1226,38 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("serves a native macOS app icon as its embedded PNG", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-asset-favicon-icns-", + }); + const icns = new Uint8Array(8 + 8 + screenshotPng.length); + icns.set(new TextEncoder().encode("icns")); + new DataView(icns.buffer).setUint32(4, icns.length); + icns.set(new TextEncoder().encode("ic07"), 8); + new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length); + icns.set(screenshotPng, 16); + yield* fileSystem.makeDirectory(path.join(root, "Resources")); + yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns); + + const result = yield* issueAssetUrl({ + resource: { _tag: "project-favicon", cwd: root }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const separatorIndex = suffix.indexOf("/"); + + expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns")); + const asset = yield* resolveAsset( + suffix.slice(0, separatorIndex), + suffix.slice(separatorIndex + 1), + ); + expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png"); + expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng); + }).pipe(Effect.provide(layerTest)), + ); + it.effect("rejects a resolved project favicon with a non-image extension", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 43d7d75f78649d23eded1d9741c485d70f903fbc..303137c149cdf82ce51afccff3cb0de2c76583bd 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -31,7 +31,10 @@ import { type ImageDimensions, } from "@t3tools/shared/imageDimensions"; import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia"; -import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; +import { + isProjectFaviconPath, + PROJECT_FAVICON_FALLBACK_MARKER, +} from "@t3tools/shared/projectFavicon"; import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; +import { extractIcnsPng } from "./icns.ts"; import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; const PROJECT_FAVICON_VERSION_PREFIX = "v"; +// Clients draw project icons from a 96px thumbnail at most. +const PROJECT_FAVICON_ICNS_MIN_SIZE = 96; const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; // Extensions a document viewer or audio player may request inline. The extension comes from // the attachment id the server assigned, never from the client's mime type. @@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") }; }); +/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */ +const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* ( + filePath: string, +) { + if (!filePath.toLowerCase().endsWith(".icns")) { + return { kind: "file", path: filePath } satisfies ResolvedAsset; + } + const fileSystem = yield* FileSystem.FileSystem; + const bytes = yield* fileSystem.readFile(filePath).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to read project icon image.", { filePath, cause }), + ), + Effect.orElseSucceed(() => null), + ); + const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null; + return png + ? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset) + : null; +}); + const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* ( filePath: string, ) { @@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const relativePath = faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null; const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath; - if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) { + if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) { return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); } sourcePath = sourceFaviconPath ?? undefined; @@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( workspaceRoot: claims.workspaceRoot, relativePath: claims.relativePath, }); - return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null; + return faviconPath ? yield* projectFaviconAsset(faviconPath) : null; } if (claims.kind === "project-favicon-external") { @@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( ), Effect.orElseSucceed(() => null), ); - return faviconPath === claims.filePath - ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) - : null; + return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null; } if (claims.kind === "github-media") { diff --git a/apps/server/src/assets/icns.test.ts b/apps/server/src/assets/icns.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee129b30032eb4d648ca264aff565a747ed18826 --- /dev/null +++ b/apps/server/src/assets/icns.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { extractIcnsPng } from "./icns.ts"; + +function pngHeader(size: number): Uint8Array { + const png = new Uint8Array(24); + png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + new DataView(png.buffer).setUint32(16, size); + new DataView(png.buffer).setUint32(20, size); + return png; +} + +function icns(...entries: ReadonlyArray): Uint8Array { + const length = 8 + entries.reduce((total, [, data]) => total + 8 + data.length, 0); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + bytes.set(new TextEncoder().encode("icns")); + view.setUint32(4, length); + let offset = 8; + for (const [type, data] of entries) { + bytes.set(new TextEncoder().encode(type), offset); + view.setUint32(offset + 4, 8 + data.length); + bytes.set(data, offset + 8); + offset += 8 + data.length; + } + return bytes; +} + +const widthOf = (png: Uint8Array | null) => + png ? new DataView(png.buffer, png.byteOffset).getUint32(16) : null; + +describe("extractIcnsPng", () => { + it("picks the smallest PNG that covers the requested size", () => { + const file = icns( + ["TOC ", new Uint8Array(16)], + ["ic09", pngHeader(512)], + ["icp5", pngHeader(32)], + ["ic07", pngHeader(128)], + ["ic08", pngHeader(256)], + ); + + expect(widthOf(extractIcnsPng(file, 96))).toBe(128); + }); + + it("falls back to the largest PNG when none covers the requested size", () => { + const file = icns(["icp4", pngHeader(16)], ["icp5", pngHeader(32)]); + + expect(widthOf(extractIcnsPng(file, 96))).toBe(32); + }); + + it("returns null for files without a PNG entry or with a foreign header", () => { + expect(extractIcnsPng(icns(["it32", new Uint8Array(64)]), 96)).toBeNull(); + expect(extractIcnsPng(pngHeader(128), 96)).toBeNull(); + }); + + it("stops at an entry whose length runs past the file", () => { + const file = icns(["ic07", pngHeader(128)], ["ic08", pngHeader(256)]); + new DataView(file.buffer).setUint32(8 + 8 + 24 + 4, 4096); + + expect(widthOf(extractIcnsPng(file, 200))).toBe(128); + }); +}); diff --git a/apps/server/src/assets/icns.ts b/apps/server/src/assets/icns.ts new file mode 100644 index 0000000000000000000000000000000000000000..429f4f7d3193d9ee2782cf1023b2966544810992 --- /dev/null +++ b/apps/server/src/assets/icns.ts @@ -0,0 +1,30 @@ +import { readImageDimensions } from "@t3tools/shared/imageDimensions"; + +const ICNS_HEADER_BYTES = 8; +const ICNS_MAGIC = 0x69636e73; // "icns" +const PNG_MAGIC = 0x89504e47; // "\x89PNG" + +/** + * Picks the smallest PNG at least `minSize` wide, else the largest; null when there is none. + * Returns a copy so the response does not pin the whole `.icns` buffer. + */ +export function extractIcnsPng(bytes: Uint8Array, minSize: number): Uint8Array | null { + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < ICNS_HEADER_BYTES || data.getUint32(0) !== ICNS_MAGIC) return null; + const end = Math.min(data.getUint32(4), bytes.length); + let best: { readonly png: Uint8Array; readonly width: number } | null = null; + for (let offset = ICNS_HEADER_BYTES; offset + ICNS_HEADER_BYTES <= end;) { + const length = data.getUint32(offset + 4); + if (length < ICNS_HEADER_BYTES || offset + length > end) break; + const entry = bytes.subarray(offset + ICNS_HEADER_BYTES, offset + length); + const isPng = entry.length >= 4 && data.getUint32(offset + ICNS_HEADER_BYTES) === PNG_MAGIC; + offset += length; + const width = isPng ? readImageDimensions(entry)?.width : undefined; + if (width === undefined) continue; + const isBetter = + best === null || + (best.width < minSize ? width > best.width : width >= minSize && width < best.width); + if (isBetter) best = { png: entry, width }; + } + return best?.png.slice() ?? null; +} diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 58a2de4f7a1a991be6a37343d12333ab92e1c37f..8a0bb57969763c15f46f20a694804df77705d5ab 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -22,7 +22,7 @@ import * as Schema from "effect/Schema"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; -// Resolution probes 21 well-known paths plus 7 source files, so a miss +// Resolution probes 23 well-known paths plus 7 source files, so a miss // costs ~30 filesystem probes. AssetAccess resolves on every project-favicon // asset URL, and a project's icon does not move, so the answer is cached. const FAVICON_CACHE_CAPACITY = 512; @@ -69,6 +69,8 @@ const FAVICON_CANDIDATES = [ "assets/logo.svg", "assets/logo.png", ".idea/icon.svg", + "Resources/AppIcon.icns", + "AppIcon.icns", ] as const; // Files that may contain a or icon metadata declaration. diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 7cd96a65ebee4cad6235008f9377a5ade383694a..78a108cd18ac07ede3fb31e1013270cebeb0ff1e 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -25,7 +25,7 @@ import type { ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; -import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; // fff-node stays external to the CLI bundle because it dlopens a native // library. A static `import` of an external package is a hard error inside a @@ -169,7 +169,7 @@ function mapFileSearchResult( ): ProjectSearchEntriesResult { const entries = result.items.flatMap((item) => { const entry = toFileEntry(item); - return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; + return entry && (!imageOnly || isProjectFaviconPath(entry.path)) ? [entry] : []; }); return { entries: entries.slice(0, limit), diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 81c260aa2725ed3916ae698a7f0f606e42a8a979..0cf03ab1a65e4bce4f945c82117b95de43c2f45b 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath); effectIt.effect("project favicon paths accept only supported image files", () => Effect.gen(function* () { assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg"); + assert.strictEqual( + yield* decodeFaviconPath("Resources/AppIcon.icns"), + "Resources/AppIcon.icns", + ); assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure"); }), ); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d1d13e82ac37adbf814dc146ef6d3786c9c66e50..baff3dc827059dd0ebe8103f1087855cc5ebccb3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -97,7 +97,7 @@ const ProjectMonogramIcon = Schema.Struct({ /** A workspace-relative image a project may use as its favicon. */ export const ProjectFaviconPath = TrimmedNonEmptyString.check( Schema.isMaxLength(1024), - Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i), + Schema.isPattern(/\.(?:avif|gif|icns|ico|jpe?g|png|svg|webp)$/i), ); export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index b6fd9e56f3a63831889c52d05529937a8f653970..8dbceb5504bcaa0e516f5c645337d4ca14f8876f 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,14 @@ +import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./filePreview.ts"; + export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export const PROJECT_FAVICON_EXTENSIONS = [...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".icns"] as const; + +export function isProjectFaviconPath(path: string): boolean { + const literalPath = path.toLowerCase(); + return PROJECT_FAVICON_EXTENSIONS.some((extension) => literalPath.endsWith(extension)); +} + export function getProjectFaviconResourceKey( environmentId: string, workspaceRoot: string, -- 2.54.0