Skip to content

Commit b013db1

Browse files
committed
feat(server): use macOS .icns app icons as project icons
Browsers cannot draw .icns, so the server serves the PNG already embedded in the file. The settings picker, desktop file dialog and saved favicon path accept .icns too.
1 parent 2ea7684 commit b013db1

11 files changed

Lines changed: 173 additions & 13 deletions

File tree

‎apps/desktop/src/ipc/methods/window.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
type DesktopEnvironmentBootstrap,
1414
type PickedThemeFile,
1515
} from "@t3tools/contracts";
16-
import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview";
16+
import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon";
1717
import { resolveEditorCommand } from "@t3tools/shared/editor";
1818
import * as HostProcess from "@t3tools/shared/hostProcess";
1919
import * as NodeOS from "node:os";
@@ -271,7 +271,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({
271271
filters: [
272272
{
273273
name: "Images",
274-
extensions: WORKSPACE_IMAGE_PREVIEW_EXTENSIONS.map((extension) => extension.slice(1)),
274+
extensions: PROJECT_FAVICON_EXTENSIONS.map((extension) => extension.slice(1)),
275275
},
276276
],
277277
});

‎apps/server/src/assets/AssetAccess.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,38 @@ describe("AssetAccess", () => {
12341234
}).pipe(Effect.provide(layerTest)),
12351235
);
12361236

1237+
it.effect("serves a native macOS app icon as its embedded PNG", () =>
1238+
Effect.gen(function* () {
1239+
const fileSystem = yield* FileSystem.FileSystem;
1240+
const path = yield* Path.Path;
1241+
const root = yield* fileSystem.makeTempDirectoryScoped({
1242+
prefix: "t3-asset-favicon-icns-",
1243+
});
1244+
const icns = new Uint8Array(8 + 8 + screenshotPng.length);
1245+
icns.set(new TextEncoder().encode("icns"));
1246+
new DataView(icns.buffer).setUint32(4, icns.length);
1247+
icns.set(new TextEncoder().encode("ic07"), 8);
1248+
new DataView(icns.buffer).setUint32(12, 8 + screenshotPng.length);
1249+
icns.set(screenshotPng, 16);
1250+
yield* fileSystem.makeDirectory(path.join(root, "Resources"));
1251+
yield* fileSystem.writeFile(path.join(root, "Resources", "AppIcon.icns"), icns);
1252+
1253+
const result = yield* issueAssetUrl({
1254+
resource: { _tag: "project-favicon", cwd: root },
1255+
});
1256+
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
1257+
const separatorIndex = suffix.indexOf("/");
1258+
1259+
expect(result.sourcePath).toBe(path.join("Resources", "AppIcon.icns"));
1260+
const asset = yield* resolveAsset(
1261+
suffix.slice(0, separatorIndex),
1262+
suffix.slice(separatorIndex + 1),
1263+
);
1264+
expect(asset?.kind === "bytes" ? asset.mimeType : null).toBe("image/png");
1265+
expect(asset?.kind === "bytes" ? Uint8Array.from(asset.bytes) : null).toEqual(screenshotPng);
1266+
}).pipe(Effect.provide(layerTest)),
1267+
);
1268+
12371269
it.effect("rejects a resolved project favicon with a non-image extension", () =>
12381270
Effect.gen(function* () {
12391271
const fileSystem = yield* FileSystem.FileSystem;

‎apps/server/src/assets/AssetAccess.ts‎

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@ import {
3131
type ImageDimensions,
3232
} from "@t3tools/shared/imageDimensions";
3333
import { githubMediaFetchUrl, githubMediaFileName } from "@t3tools/shared/githubMedia";
34-
import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon";
34+
import {
35+
isProjectFaviconPath,
36+
PROJECT_FAVICON_FALLBACK_MARKER,
37+
} from "@t3tools/shared/projectFavicon";
3538
import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput";
3639
import * as Clock from "effect/Clock";
3740
import * as Crypto from "effect/Crypto";
@@ -57,6 +60,7 @@ import { expandHomePathWith } from "../pathExpansion.ts";
5760
import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
5861
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
5962
import * as NativeAppIconResolver from "./NativeAppIconResolver.ts";
63+
import { extractIcnsPng } from "./icns.ts";
6064
import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts";
6165

6266
export const ASSET_ROUTE_PREFIX = "/api/assets";
@@ -65,6 +69,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
6569
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
6670
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
6771
const PROJECT_FAVICON_VERSION_PREFIX = "v";
72+
// Clients draw project icons from a 96px thumbnail at most.
73+
const PROJECT_FAVICON_ICNS_MIN_SIZE = 96;
6874
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
6975
// Extensions a document viewer or audio player may request inline. The extension comes from
7076
// the attachment id the server assigned, never from the client's mime type.
@@ -233,6 +239,26 @@ const readToolOutputImage = Effect.fn("AssetAccess.readToolOutputImage")(functio
233239
: { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") };
234240
});
235241

242+
/** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */
243+
const projectFaviconAsset = Effect.fn("AssetAccess.projectFaviconAsset")(function* (
244+
filePath: string,
245+
) {
246+
if (!filePath.toLowerCase().endsWith(".icns")) {
247+
return { kind: "file", path: filePath } satisfies ResolvedAsset;
248+
}
249+
const fileSystem = yield* FileSystem.FileSystem;
250+
const bytes = yield* fileSystem.readFile(filePath).pipe(
251+
Effect.tapError((cause) =>
252+
Effect.logError("Failed to read project icon image.", { filePath, cause }),
253+
),
254+
Effect.orElseSucceed(() => null),
255+
);
256+
const png = bytes ? extractIcnsPng(bytes, PROJECT_FAVICON_ICNS_MIN_SIZE) : null;
257+
return png
258+
? ({ kind: "bytes", bytes: png, mimeType: "image/png" } satisfies ResolvedAsset)
259+
: null;
260+
});
261+
236262
const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* (
237263
filePath: string,
238264
) {
@@ -633,7 +659,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
633659
const relativePath =
634660
faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null;
635661
const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath;
636-
if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) {
662+
if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) {
637663
return yield* new AssetPreviewTypeValidationError({ resource: input.resource });
638664
}
639665
sourcePath = sourceFaviconPath ?? undefined;
@@ -836,7 +862,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
836862
workspaceRoot: claims.workspaceRoot,
837863
relativePath: claims.relativePath,
838864
});
839-
return faviconPath ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) : null;
865+
return faviconPath ? yield* projectFaviconAsset(faviconPath) : null;
840866
}
841867

842868
if (claims.kind === "project-favicon-external") {
@@ -849,9 +875,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
849875
),
850876
Effect.orElseSucceed(() => null),
851877
);
852-
return faviconPath === claims.filePath
853-
? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset)
854-
: null;
878+
return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null;
855879
}
856880

857881
if (claims.kind === "github-media") {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { extractIcnsPng } from "./icns.ts";
4+
5+
function 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+
13+
function 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+
29+
const widthOf = (png: Uint8Array | null) =>
30+
png ? new DataView(png.buffer, png.byteOffset).getUint32(16) : null;
31+
32+
describe("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‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { readImageDimensions } from "@t3tools/shared/imageDimensions";
2+
3+
const ICNS_HEADER_BYTES = 8;
4+
const ICNS_MAGIC = 0x69636e73; // "icns"
5+
const PNG_MAGIC = 0x89504e47; // "\x89PNG"
6+
7+
/** Picks the smallest PNG at least `minSize` wide, else the largest; null when there is none. */
8+
export function extractIcnsPng(bytes: Uint8Array, minSize: number): Uint8Array | null {
9+
const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10+
if (bytes.length < ICNS_HEADER_BYTES || data.getUint32(0) !== ICNS_MAGIC) return null;
11+
const end = Math.min(data.getUint32(4), bytes.length);
12+
let best: { readonly png: Uint8Array; readonly width: number } | null = null;
13+
for (let offset = ICNS_HEADER_BYTES; offset + ICNS_HEADER_BYTES <= end;) {
14+
const length = data.getUint32(offset + 4);
15+
if (length < ICNS_HEADER_BYTES || offset + length > end) break;
16+
const entry = bytes.subarray(offset + ICNS_HEADER_BYTES, offset + length);
17+
const isPng = entry.length >= 4 && data.getUint32(offset + ICNS_HEADER_BYTES) === PNG_MAGIC;
18+
offset += length;
19+
const width = isPng ? readImageDimensions(entry)?.width : undefined;
20+
if (width === undefined) continue;
21+
const isBetter =
22+
best === null ||
23+
(best.width < minSize ? width > best.width : width >= minSize && width < best.width);
24+
if (isBetter) best = { png: entry, width };
25+
}
26+
return best?.png ?? null;
27+
}

‎apps/server/src/project/ProjectFaviconResolver.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import * as Schema from "effect/Schema";
2222
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2323
import * 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.
2828
const 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‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type {
2525
ProjectSearchContentsResult,
2626
ProjectSearchEntriesResult,
2727
} from "@t3tools/contracts";
28-
import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
28+
import { 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‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath);
163163
effectIt.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‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ const ProjectMonogramIcon = Schema.Struct({
9797
/** A workspace-relative image a project may use as its favicon. */
9898
export 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
);
102102
export type ProjectFaviconPath = typeof ProjectFaviconPath.Type;
103103

0 commit comments

Comments
 (0)