authorgravatar for psv2522@gmail.comPrathmesh Vhatkar <psv2522@gmail.com> 2026-10-10 17:20:31+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 06:50:31-05:00
logde49d637d4b72b3e3306533e3777cb37e6a3f738
tree15e1b00e2329565f5e113429c22de4ff716665d8
parent03b210528160e68c8dbfc27f11f693085f92ab09
signature Signed by PGP key B5690EEEBB952194

feat(server): use macOS .icns app icons as project icons (#17149)


11 files changed, 176 insertions(+), 13 deletions(-)

apps/desktop/src/ipc/methods/window.test.ts+1-1
......@@ -287,7 +287,7 @@ describe("pickProjectFavicon", () => {
287287 filters: [
288288 {
289289 name: "Images",
290 extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"],
290 extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"],
291291 },
292292 ],
293293 },
apps/desktop/src/ipc/methods/window.ts+2-2
......@@ -13,7 +13,7 @@ import {
1313 type DesktopEnvironmentBootstrap,
1414 type PickedThemeFile,
1515} from "@t3tools/contracts";
16import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
16import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon";
1717import { resolveEditorCommand } from "@t3tools/shared/editor";
1818import * as HostProcess from "@t3tools/shared/HostProcess";
1919import * as FileSystem from "effect/FileSystem";
......@@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
270270 filters: [
271271 {
272272 name: "Images",
273 extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)),
273 extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)),
274274 },
275275 ],
276276 });
apps/server/src/assets/AssetAccess.test.ts+32
......@@ -1226,6 +1226,38 @@ describe("AssetAccess", () => {
12261226 }).pipe(Effect.provide(layerTest)),
12271227 );
12281228
1229 it.effect("serves a native macOS app icon as its embedded PNG", () =>
1230 Effect.gen(function* () {
1231 const fileSystem = yield* FileSystem.FileSystem;
1232 const path = yield* Path.Path;
1233 const root = yield* fileSystem.makeTempDirectoryScoped({
1234 prefix: "t3-asset-favicon-icns-",
1235 });
1236 const icns = new Uint8Array(8 + 8 + screenshotPng.length);
1237 icns.set(new TextEncoder().encode("icns"));
1238 new DataView(icns.buffer).setUint32(4, icns.length);
1239 icns.set(new TextEncoder().encode("ic07"), 8);
1240 new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
1241 icns.set(screenshotPng, 16);
1242 yield* fileSystem.makeDirectory(path.join(root, "Resources"));
1243 yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);
1244
1245 const result = yield* issueAssetUrl({
1246 resource: { _tag: "project-favicon", cwd: root },
1247 });
1248 const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1249 const separatorIndex = suffix.indexOf("/");
1250
1251 expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns"));
1252 const asset = yield* resolveAsset(
1253 suffix.slice(0, separatorIndex),
1254 suffix.slice(separatorIndex + 1),
1255 );
1256 expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
1257 expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng);
1258 }).pipe(Effect.provide(layerTest)),
1259 );
1260
12291261 it.effect("rejects a resolved project favicon with a non-image extension", () =>
12301262 Effect.gen(function* () {
12311263 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+30-6
......@@ -31,7 +31,10 @@ import {
3131 type ImageDimensions,
3232} from "@t3tools/shared/imageDimensions";
3333import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
34import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
34import {
35 isProjectFaviconPath,
36 PROJECT_FAVICON_FALLBACK_MARKER,
37} from "@t3tools/shared/projectFavicon";
3538import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
3639import * as Clock from "effect/Clock";
3740import * as Crypto from "effect/Crypto";
......@@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
5760import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
5861import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
5962import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
63import { extractIcnsPng } from "./icns.ts";
6064import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
6165import * as HostProcess from "@t3tools/shared/HostProcess";
6266
......@@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
6670const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
6771const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
6872const PROJECT_FAVICON_VERSION_PREFIX = "v";
73// Clients draw project icons from a 96px thumbnail at most.
74const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
6975const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
7076// Extensions a document viewer or audio player may request inline. The extension comes from
7177// the attachment id the server assigned, never from the client's mime type.
......@@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
234240 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
235241});
236242
243/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
244const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
245 filePath: string,
246) {
247 if (!filePath.toLowerCase().endsWith(".icns")) {
248 return { kind: "file", path: filePath } satisfies ResolvedAsset;
249 }
250 const fileSystem = yield* FileSystem.FileSystem;
251 const bytes = yield* fileSystem.readFile(filePath).pipe(
252 Effect.tapError((cause) =>
253 Effect.logError("Failed to read project icon image.", { filePath, cause }),
254 ),
255 Effect.orElseSucceed(() => null),
256 );
257 const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null;
258 return png
259 ? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset)
260 : null;
261});
262
237263const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
238264 filePath: string,
239265) {
......@@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
634660 const relativePath =
635661 faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
636662 const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
637 if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) {
663 if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
638664 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
639665 }
640666 sourcePath = sourceFaviconPath ?? undefined;
......@@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
837863 workspaceRoot: claims.workspaceRoot,
838864 relativePath: claims.relativePath,
839865 });
840 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
866 return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
841867 }
842868
843869 if (claims.kind === "project-favicon-external") {
......@@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
850876 ),
851877 Effect.orElseSucceed(() => null),
852878 );
853 return faviconPath === claims.filePath
854 ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset)
855 : null;
879 return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
856880 }
857881
858882 if (claims.kind === "github-media") {
apps/server/src/assets/icns.test.ts created+62
......@@ -0,0 +1,62 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { extractIcnsPng } from "./icns.ts";
4
5function pngHeader(size: number): Uint8Array {
6 const png = new Uint8Array(24);
7 png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
8 new DataView(png.buffer).setUint32(16, size);
9 new DataView(png.buffer).setUint32(20, size);
10 return png;
11}
12
13function icns(...entries: ReadonlyArray<readonly [type: string, data: Uint8Array]>): Uint8Array {
14 const length = 8 + entries.reduce((total, [, data]) => total + 8 + data.length, 0);
15 const bytes = new Uint8Array(length);
16 const view = new DataView(bytes.buffer);
17 bytes.set(new TextEncoder().encode("icns"));
18 view.setUint32(4, length);
19 let offset = 8;
20 for (const [type, data] of entries) {
21 bytes.set(new TextEncoder().encode(type), offset);
22 view.setUint32(offset + 4, 8 + data.length);
23 bytes.set(data, offset + 8);
24 offset += 8 + data.length;
25 }
26 return bytes;
27}
28
29const widthOf = (png: Uint8Array | null) =>
30 png ? new DataView(png.buffer, png.byteOffset).getUint32(16) : null;
31
32describe("extractIcnsPng", () => {
33 it("picks the smallest PNG that covers the requested size", () => {
34 const file = icns(
35 ["TOC ", new Uint8Array(16)],
36 ["ic09", pngHeader(512)],
37 ["icp5", pngHeader(32)],
38 ["ic07", pngHeader(128)],
39 ["ic08", pngHeader(256)],
40 );
41
42 expect(widthOf(extractIcnsPng(file, 96))).toBe(128);
43 });
44
45 it("falls back to the largest PNG when none covers the requested size", () => {
46 const file = icns(["icp4", pngHeader(16)], ["icp5", pngHeader(32)]);
47
48 expect(widthOf(extractIcnsPng(file, 96))).toBe(32);
49 });
50
51 it("returns null for files without a PNG entry or with a foreign header", () => {
52 expect(extractIcnsPng(icns(["it32", new Uint8Array(64)]), 96)).toBeNull();
53 expect(extractIcnsPng(pngHeader(128), 96)).toBeNull();
54 });
55
56 it("stops at an entry whose length runs past the file", () => {
57 const file = icns(["ic07", pngHeader(128)], ["ic08", pngHeader(256)]);
58 new DataView(file.buffer).setUint32(8 + 8 + 24 + 4, 4096);
59
60 expect(widthOf(extractIcnsPng(file, 200))).toBe(128);
61 });
62});
apps/server/src/assets/icns.ts created+30
......@@ -0,0 +1,30 @@
1import { readImageDimensions } from "@t3tools/shared/imageDimensions";
2
3const ICNS_HEADER_BYTES = 8;
4const ICNS_MAGIC = 0x69636e73; // "icns"
5const PNG_MAGIC = 0x89504e47; // "\x89PNG"
6
7/**
8 * Picks the smallest PNG at least `minSize` wide, else the largest; null when there is none.
9 * Returns a copy so the response does not pin the whole `.icns` buffer.
10 */
11export function extractIcnsPng(bytes: Uint8Array, minSize: number): Uint8Array | null {
12 const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
13 if (bytes.length < ICNS_HEADER_BYTES || data.getUint32(0) !== ICNS_MAGIC) return null;
14 const end = Math.min(data.getUint32(4), bytes.length);
15 let best: { readonly png: Uint8Array; readonly width: number } | null = null;
16 for (let offset = ICNS_HEADER_BYTES; offset + ICNS_HEADER_BYTES <= end;) {
17 const length = data.getUint32(offset + 4);
18 if (length < ICNS_HEADER_BYTES || offset + length > end) break;
19 const entry = bytes.subarray(offset + ICNS_HEADER_BYTES, offset + length);
20 const isPng = entry.length >= 4 && data.getUint32(offset + ICNS_HEADER_BYTES) === PNG_MAGIC;
21 offset += length;
22 const width = isPng ? readImageDimensions(entry)?.width : undefined;
23 if (width === undefined) continue;
24 const isBetter =
25 best === null ||
26 (best.width < minSize ? width > best.width : width >= minSize && width < best.width);
27 if (isBetter) best = { png: entry, width };
28 }
29 return best?.png.slice() ?? null;
30}
apps/server/src/project/ProjectFaviconResolver.ts+3-1
......@@ -22,7 +22,7 @@ import * as Schema from "effect/Schema";
2222import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2323import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
2424
25// Resolution probes 21 well-known paths plus 7 source files, so a miss
25// Resolution probes 23 well-known paths plus 7 source files, so a miss
2626// costs ~30 filesystem probes. AssetAccess resolves on every project-favicon
2727// asset URL, and a project's icon does not move, so the answer is cached.
2828const FAVICON_CACHE_CAPACITY = 512;
......@@ -69,6 +69,8 @@ const FAVICON_CANDIDATES = [
6969 "assets/logo.svg",
7070 "assets/logo.png",
7171 ".idea/icon.svg",
72 "Resources/AppIcon.icns",
73 "AppIcon.icns",
7274] as const;
7375
7476// Files that may contain a <link rel="icon"> or icon metadata declaration.
apps/server/src/workspace/WorkspaceSearchIndex.ts+2-2
......@@ -25,7 +25,7 @@ import type {
2525 ProjectSearchContentsResult,
2626 ProjectSearchEntriesResult,
2727} from "@t3tools/contracts";
28import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
28import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon";
2929
3030// fff-node stays external to the CLI bundle because it dlopens a native
3131// library. A static `import` of an external package is a hard error inside a
......@@ -169,7 +169,7 @@ function mapFileSearchResult(
169169): ProjectSearchEntriesResult {
170170 const entries = result.items.flatMap((item) => {
171171 const entry = toFileEntry(item);
172 return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : [];
172 return entry && (!imageOnly || isProjectFaviconPath(entry.path)) ? [entry] : [];
173173 });
174174 return {
175175 entries: entries.slice(0, limit),
packages/contracts/src/project.test.ts+4
......@@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath);
163163effectIt.effect("project favicon paths accept only supported image files", () =>
164164 Effect.gen(function* () {
165165 assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg");
166 assert.strictEqual(
167 yield* decodeFaviconPath("Resources/AppIcon.icns"),
168 "Resources/AppIcon.icns",
169 );
166170 assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure");
167171 }),
168172);
packages/contracts/src/project.ts+1-1
......@@ -97,7 +97,7 @@ const ProjectMonogramIcon = Schema.Struct({
9797/** A workspace-relative image a project may use as its favicon. */
9898export const ProjectFaviconPath = TrimmedNonEmptyString.check(
9999 Schema.isMaxLength(1024),
100 Schema.isPattern(/\.(?:avif|gif|ico|jpe?g|png|svg|webp)$/i),
100 Schema.isPattern(/\.(?:avif|gif|icns|ico|jpe?g|png|svg|webp)$/i),
101101);
102102export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
103103
packages/shared/src/projectFavicon.ts+9
......@@ -1,5 +1,14 @@
1import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./filePreview.ts";
2
13export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";
24
5export const PROJECT_FAVICON_EXTENSIONS = [...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".icns"] as const;
6
7export function isProjectFaviconPath(path: string): boolean {
8 const literalPath = path.toLowerCase();
9 return PROJECT_FAVICON_EXTENSIONS.some((extension) => literalPath.endsWith(extension));
10}
11
312export function getProjectFaviconResourceKey(
413 environmentId: string,
514 workspaceRoot: string,