| author | |
| committer | |
| log | de49d637d4b72b3e3306533e3777cb37e6a3f738 |
| tree | 15e1b00e2329565f5e113429c22de4ff716665d8 |
| parent | 03b210528160e68c8dbfc27f11f693085f92ab09 |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 176 insertions(+), 13 deletions(-)
apps/desktop/src/ipc/methods/window.test.ts+1-1| ... | ... | @@ -287,7 +287,7 @@ describe("pickProjectFavicon", () => { |
| 287 | 287 | filters: [ |
| 288 | 288 | { |
| 289 | 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 | 13 | type DesktopEnvironmentBootstrap, |
| 14 | 14 | type PickedThemeFile, |
| 15 | 15 | } from "@t3tools/contracts"; |
| 16 | import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; | |
| 16 | import { PROJECT_FAVICON_EXTENSIONS } from "@t3tools/shared/projectFavicon"; | |
| 17 | 17 | import { resolveEditorCommand } from "@t3tools/shared/editor"; |
| 18 | 18 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 19 | 19 | import * as FileSystem from "effect/FileSystem"; |
| ... | ... | @@ -270,7 +270,7 @@ export const pickProjectFavicon = DesktopIpc.makeIpcMethod({ |
| 270 | 270 | filters: [ |
| 271 | 271 | { |
| 272 | 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 | 1226 | }).pipe(Effect.provide(layerTest)), |
| 1227 | 1227 | ); |
| 1228 | 1228 | |
| 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 | 1261 | it.effect("rejects a resolved project favicon with a non-image extension", () => |
| 1230 | 1262 | Effect.gen(function* () { |
| 1231 | 1263 | const fileSystem = yield* FileSystem.FileSystem; |
apps/server/src/assets/AssetAccess.ts+30-6| ... | ... | @@ -31,7 +31,10 @@ import { |
| 31 | 31 | type ImageDimensions, |
| 32 | 32 | } from "@t3tools/shared/imageDimensions"; |
| 33 | 33 | 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"; | |
| 35 | 38 | import { MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, toolOutputImages } from "@t3tools/shared/toolOutput"; |
| 36 | 39 | import * as Clock from "effect/Clock"; |
| 37 | 40 | import * as Crypto from "effect/Crypto"; |
| ... | ... | @@ -57,6 +60,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 57 | 60 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 58 | 61 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 59 | 62 | import * as NativeAppIconResolver from "./NativeAppIconResolver.ts"; |
| 63 | import { extractIcnsPng } from "./icns.ts"; | |
| 60 | 64 | import { openMediaFile, readMediaFileHeader, type OpenMediaFile } from "./MediaFile.ts"; |
| 61 | 65 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 62 | 66 | |
| ... | ... | @@ -66,6 +70,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key"; |
| 66 | 70 | const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; |
| 67 | 71 | const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; |
| 68 | 72 | const PROJECT_FAVICON_VERSION_PREFIX = "v"; |
| 73 | // Clients draw project icons from a 96px thumbnail at most. | |
| 74 | const PROJECT_FAVICON_ICNS_MIN_SIZE = 96; | |
| 69 | 75 | const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i; |
| 70 | 76 | // Extensions a document viewer or audio player may request inline. The extension comes from |
| 71 | 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 | 240 | : { mimeType: image.mimeType, bytes: Buffer.from(image.data, "base64") }; |
| 235 | 241 | }); |
| 236 | 242 | |
| 243 | /** Serves an `.icns` favicon as its embedded PNG, since browsers cannot draw it. */ | |
| 244 | const 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 | ||
| 237 | 263 | const resolveCanonicalFile = Effect.fn("AssetAccess.resolveCanonicalFile")(function* ( |
| 238 | 264 | filePath: string, |
| 239 | 265 | ) { |
| ... | ... | @@ -634,7 +660,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 634 | 660 | const relativePath = |
| 635 | 661 | faviconPath && !isExternalOverride ? path.relative(workspaceRoot, faviconPath) : null; |
| 636 | 662 | const sourceFaviconPath = isExternalOverride ? faviconPath : relativePath; |
| 637 | if (sourceFaviconPath && !isWorkspaceImagePreviewPath(sourceFaviconPath)) { | |
| 663 | if (sourceFaviconPath && !isProjectFaviconPath(sourceFaviconPath)) { | |
| 638 | 664 | return yield* new AssetPreviewTypeValidationError({ resource: input.resource }); |
| 639 | 665 | } |
| 640 | 666 | sourcePath = sourceFaviconPath ?? undefined; |
| ... | ... | @@ -837,7 +863,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 837 | 863 | workspaceRoot: claims.workspaceRoot, |
| 838 | 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 | } |
| 842 | 868 | |
| 843 | 869 | if (claims.kind === "project-favicon-external") { |
| ... | ... | @@ -850,9 +876,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 850 | 876 | ), |
| 851 | 877 | Effect.orElseSucceed(() => null), |
| 852 | 878 | ); |
| 853 | return faviconPath === claims.filePath | |
| 854 | ? ({ kind: "file", path: faviconPath } satisfies ResolvedAsset) | |
| 855 | : null; | |
| 879 | return faviconPath === claims.filePath ? yield* projectFaviconAsset(faviconPath) : null; | |
| 856 | 880 | } |
| 857 | 881 | |
| 858 | 882 | if (claims.kind === "github-media") { |
apps/server/src/assets/icns.test.ts created+62| ... | ... | @@ -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 created+30| ... | ... | @@ -0,0 +1,30 @@ |
| 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 | /** | |
| 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 | */ | |
| 11 | export 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 | 22 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 23 | 23 | import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; |
| 24 | 24 | |
| 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 | |
| 26 | 26 | // costs ~30 filesystem probes. AssetAccess resolves on every project-favicon |
| 27 | 27 | // asset URL, and a project's icon does not move, so the answer is cached. |
| 28 | 28 | const FAVICON_CACHE_CAPACITY = 512; |
| ... | ... | @@ -69,6 +69,8 @@ const FAVICON_CANDIDATES = [ |
| 69 | 69 | "assets/logo.svg", |
| 70 | 70 | "assets/logo.png", |
| 71 | 71 | ".idea/icon.svg", |
| 72 | "Resources/AppIcon.icns", | |
| 73 | "AppIcon.icns", | |
| 72 | 74 | ] as const; |
| 73 | 75 | |
| 74 | 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 | 25 | ProjectSearchContentsResult, |
| 26 | 26 | ProjectSearchEntriesResult, |
| 27 | 27 | } from "@t3tools/contracts"; |
| 28 | import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; | |
| 28 | import { isProjectFaviconPath } from "@t3tools/shared/projectFavicon"; | |
| 29 | 29 | |
| 30 | 30 | // fff-node stays external to the CLI bundle because it dlopens a native |
| 31 | 31 | // library. A static `import` of an external package is a hard error inside a |
| ... | ... | @@ -169,7 +169,7 @@ function mapFileSearchResult( |
| 169 | 169 | ): ProjectSearchEntriesResult { |
| 170 | 170 | const entries = result.items.flatMap((item) => { |
| 171 | 171 | const entry = toFileEntry(item); |
| 172 | return entry && (!imageOnly || isWorkspaceImagePreviewPath(entry.path)) ? [entry] : []; | |
| 172 | return entry && (!imageOnly || isProjectFaviconPath(entry.path)) ? [entry] : []; | |
| 173 | 173 | }); |
| 174 | 174 | return { |
| 175 | 175 | entries: entries.slice(0, limit), |
packages/contracts/src/project.test.ts+4| ... | ... | @@ -163,6 +163,10 @@ const decodeFaviconPath = Schema.decodeUnknownEffect(ProjectFaviconPath); |
| 163 | 163 | effectIt.effect("project favicon paths accept only supported image files", () => |
| 164 | 164 | Effect.gen(function* () { |
| 165 | 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 | 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 | 97 | /** A workspace-relative image a project may use as its favicon. */ |
| 98 | 98 | export const ProjectFaviconPath = TrimmedNonEmptyString.check( |
| 99 | 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 | ); |
| 102 | 102 | export type ProjectFaviconPath = typeof ProjectFaviconPath.Type; |
| 103 | 103 |
packages/shared/src/projectFavicon.ts+9| ... | ... | @@ -1,5 +1,14 @@ |
| 1 | import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "./filePreview.ts"; | |
| 2 | ||
| 1 | 3 | export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; |
| 2 | 4 | |
| 5 | export const PROJECT_FAVICON_EXTENSIONS = [...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, ".icns"] as const; | |
| 6 | ||
| 7 | export function isProjectFaviconPath(path: string): boolean { | |
| 8 | const literalPath = path.toLowerCase(); | |
| 9 | return PROJECT_FAVICON_EXTENSIONS.some((extension) => literalPath.endsWith(extension)); | |
| 10 | } | |
| 11 | ||
| 3 | 12 | export function getProjectFaviconResourceKey( |
| 4 | 13 | environmentId: string, |
| 5 | 14 | workspaceRoot: string, |