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", () => {...@@ -287,7 +287,7 @@ describe("pickProjectFavicon", () => {
287 filters: [287 filters: [
288 {288 {
289 name: "Images",289 name: "Images",
290 extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"],290 extensions: ["avif", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp", "icns"],
291 },291 },
292 ],292 ],
293 },293 },
apps/desktop/src/ipc/methods/window.ts+2-2
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 type DesktopEnvironmentBootstrap,13 type DesktopEnvironmentBootstrap,
14 type PickedThemeFile,14 type PickedThemeFile,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";16import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon";
17import { resolveEditorCommand } from "@t3tools/shared/editor";17import { resolveEditorCommand } from "@t3tools/shared/editor";
18import * as HostProcess from "@t3tools/shared/HostProcess";18import * as HostProcess from "@t3tools/shared/HostProcess";
19import * as FileSystem from "effect/FileSystem";19import * as FileSystem from "effect/FileSystem";
...@@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({...@@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
270 filters: [270 filters: [
271 {271 {
272 name: "Images",272 name: "Images",
273 extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)),273 extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)),
274 },274 },
275 ],275 ],
276 });276 });
apps/server/src/assets/AssetAccess.test.ts+32
...@@ -1226,6 +1226,38 @@ describe("AssetAccess", () => {...@@ -1226,6 +1226,38 @@ describe("AssetAccess", () => {
1226 }).pipe(Effect.provide(layerTest)),1226 }).pipe(Effect.provide(layerTest)),
1227 );1227 );
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
1229 it.effect("rejects a resolved project favicon with a non-image extension", () =>1261 it.effect("rejects a resolved project favicon with a non-image extension", () =>
1230 Effect.gen(function* () {1262 Effect.gen(function* () {
1231 const fileSystem = yield* FileSystem.FileSystem;1263 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/assets/AssetAccess.ts+30-6
...@@ -31,7 +31,10 @@ import {...@@ -31,7 +31,10 @@ import {
31 type ImageDimensions,31 type ImageDimensions,
32} from "@t3tools/shared/imageDimensions";32} from "@t3tools/shared/imageDimensions";
33import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";33import { 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";
35import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";38import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
36import * as Clock from "effect/Clock";39import * as Clock from "effect/Clock";
37import * as Crypto from "effect/Crypto";40import * as Crypto from "effect/Crypto";
...@@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";...@@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
57import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";60import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
58import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";61import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
59import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";62import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
63import { extractIcnsPng } from "./icns.ts";
60import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";64import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
61import * as HostProcess from "@t3tools/shared/HostProcess";65import * as HostProcess from "@t3tools/shared/HostProcess";
6266
...@@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";...@@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
66const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;70const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
67const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;71const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
68const PROJECT_FAVICON_VERSION_PREFIX = "v";72const PROJECT_FAVICON_VERSION_PREFIX = "v";
73// Clients draw project icons from a 96px thumbnail at most.
74const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
69const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;75const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
70// Extensions a document viewer or audio player may request inline. The extension comes from76// Extensions a document viewer or audio player may request inline. The extension comes from
71// the attachment id the server assigned, never from the client's mime type.77// the attachment id the server assigned, never from the client's mime type.
...@@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio...@@ -234,6 +240,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
234 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };240 : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
235});241});
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
237const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (263const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
238 filePath: string,264 filePath: string,
239) {265) {
...@@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i...@@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
634 const relativePath =660 const relativePath =
635 faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;661 faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
636 const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;662 const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
637 if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) {663 if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
638 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });664 return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
639 }665 }
640 sourcePath = sourceFaviconPath ?? undefined;666 sourcePath = sourceFaviconPath ?? undefined;
...@@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
837 workspaceRoot: claims.workspaceRoot,863 workspaceRoot: claims.workspaceRoot,
838 relativePath: claims.relativePath,864 relativePath: claims.relativePath,
839 });865 });
840 return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;866 return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
841 }867 }
842868
843 if (claims.kind === "project-favicon-external") {869 if (claims.kind === "project-favicon-external") {
...@@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (...@@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
850 ),876 ),
851 Effect.orElseSucceed(() => null),877 Effect.orElseSucceed(() => null),
852 );878 );
853 return faviconPath === claims.filePath879 return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
854 ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset)
855 : null;
856 }880 }
857881
858 if (claims.kind === "github-media") {882 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";...@@ -22,7 +22,7 @@ import * as Schema from "effect/Schema";
22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";22import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
23import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";23import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts";
2424
25// Resolution probes 21 well-known paths plus 7 source files, so a miss25// Resolution probes 23 well-known paths plus 7 source files, so a miss
26// costs ~30 filesystem probes. AssetAccess resolves on every project-favicon26// costs ~30 filesystem probes. AssetAccess resolves on every project-favicon
27// asset URL, and a project's icon does not move, so the answer is cached.27// asset URL, and a project's icon does not move, so the answer is cached.
28const FAVICON_CACHE_CAPACITY = 512;28const FAVICON_CACHE_CAPACITY = 512;
...@@ -69,6 +69,8 @@ const FAVICON_CANDIDATES = [...@@ -69,6 +69,8 @@ const FAVICON_CANDIDATES = [
69 "assets/logo.svg",69 "assets/logo.svg",
70 "assets/logo.png",70 "assets/logo.png",
71 ".idea/icon.svg",71 ".idea/icon.svg",
72 "Resources/AppIcon.icns",
73 "AppIcon.icns",
72] as const;74] as const;
7375
74// Files that may contain a <link rel="icon"> or icon metadata declaration.76// 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 {...@@ -25,7 +25,7 @@ import type {
25 ProjectSearchContentsResult,25 ProjectSearchContentsResult,
26 ProjectSearchEntriesResult,26 ProjectSearchEntriesResult,
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";28import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon";
2929
30// fff-node stays external to the CLI bundle because it dlopens a native30// fff-node stays external to the CLI bundle because it dlopens a native
31// library. A static `import` of an external package is a hard error inside a31// library. A static `import` of an external package is a hard error inside a
...@@ -169,7 +169,7 @@ function mapFileSearchResult(...@@ -169,7 +169,7 @@ function mapFileSearchResult(
169): ProjectSearchEntriesResult {169): ProjectSearchEntriesResult {
170 const entries = result.items.flatMap((item) => {170 const entries = result.items.flatMap((item) => {
171 const entry = toFileEntry(item);171 const entry = toFileEntry(item);
172 return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : [];172 return entry && (!imageOnly || isProjectFaviconPath(entry.path)) ? [entry] : [];
173 });173 });
174 return {174 return {
175 entries: entries.slice(0, limit),175 entries: entries.slice(0, limit),
packages/contracts/src/project.test.ts+4
...@@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath);...@@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath);
163effectIt.effect("project favicon paths accept only supported image files", () =>163effectIt.effect("project favicon paths accept only supported image files", () =>
164 Effect.gen(function* () {164 Effect.gen(function* () {
165 assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg");165 assert.strictEqual(yield* decodeFaviconPath("brand/icon.svg"), "brand/icon.svg");
166 assert.strictEqual(
167 yield* decodeFaviconPath("Resources/AppIcon.icns"),
168 "Resources/AppIcon.icns",
169 );
166 assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure");170 assert.strictEqual((yield* Effect.exit(decodeFaviconPath(".env")))._tag, "Failure");
167 }),171 }),
168);172);
packages/contracts/src/project.ts+1-1
...@@ -97,7 +97,7 @@ const ProjectMonogramIcon = Schema.Struct({...@@ -97,7 +97,7 @@ const ProjectMonogramIcon = Schema.Struct({
97/** A workspace-relative image a project may use as its favicon. */97/** A workspace-relative image a project may use as its favicon. */
98export const ProjectFaviconPath = TrimmedNonEmptyString.check(98export const ProjectFaviconPath = TrimmedNonEmptyString.check(
99 Schema.isMaxLength(1024),99 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),
101);101);
102export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;102export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
103103
packages/shared/src/projectFavicon.ts+9
...@@ -1,5 +1,14 @@...@@ -1,5 +1,14 @@
1import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./filePreview.ts";
2
1export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing";3export 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
3export function getProjectFaviconResourceKey(12export function getProjectFaviconResourceKey(
4 environmentId: string,13 environmentId: string,
5 workspaceRoot: string,14 workspaceRoot: string,