@@ -31,7 +31,10 @@ import {
3131 type ImageDimensions ,
3232} from "@t3tools/shared/imageDimensions" ;
3333import { 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" ;
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 { expandHomePathWith } from "../pathExpansion.ts";
5760import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts" ;
5861import * as WorkspacePaths from "../workspace/WorkspacePaths.ts" ;
5962import * as NativeAppIconResolver from "./NativeAppIconResolver.ts" ;
63+ import { extractIcnsPng } from "./icns.ts" ;
6064import { openMediaFile , readMediaFileHeader , type OpenMediaFile } from "./MediaFile.ts" ;
6165
6266export const ASSET_ROUTE_PREFIX = "/api/assets" ;
@@ -65,6 +69,8 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
6569const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000 ;
6670const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000 ;
6771const PROJECT_FAVICON_VERSION_PREFIX = "v" ;
72+ // Clients draw project icons from a 96px thumbnail at most.
73+ const PROJECT_FAVICON_ICNS_MIN_SIZE = 96 ;
6874const INLINE_VIDEO_MIME_TYPE_PATTERN = / ^ v i d e o \/ [ \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+
236262const 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" ) {
0 commit comments