import * as NodeCrypto from "node:crypto"; import type { ToolActivityNativeAppReference } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Semaphore from "effect/Semaphore"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; const ICON_SIZE = 64; const COMMAND_TIMEOUT = "5 seconds"; const RESOLUTION_CACHE_TTL = Duration.hours(1); const RESOLUTION_CACHE_MAX_ENTRIES = 256; /** Resolves and caches macOS application icons without exposing host paths to clients. */ export class NativeAppIconResolver extends Context.Service< NativeAppIconResolver, { /** Returns a cached PNG path for the application, or `null` when no icon is available. */ readonly resolve: (app: ToolActivityNativeAppReference) => Effect.Effect; } >()("t3/assets/NativeAppIconResolver") {} function appCacheKey(app: ToolActivityNativeAppReference): string { return JSON.stringify(app); } function appFromCacheKey(key: string): ToolActivityNativeAppReference { return JSON.parse(key) as ToolActivityNativeAppReference; } const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { const fileSystem = yield* FileSystem.FileSystem; const info = yield* fileSystem.stat(filePath).pipe( Effect.map(Option.some), Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), }), ); return Option.isSome(info) && info.value.type === "File" ? filePath : null; }); const commandOutput = Effect.fn("NativeAppIconResolver.commandOutput")(function* ( command: string, args: ReadonlyArray, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; return yield* spawner .string(ChildProcess.make(command, args, { stdin: "ignore", stderr: "ignore" })) .pipe(Effect.timeout(COMMAND_TIMEOUT)); }); const plistValue = Effect.fn("NativeAppIconResolver.plistValue")(function* ( infoPlistPath: string, key: string, ) { return yield* commandOutput("/usr/bin/plutil", [ "-extract", key, "raw", "-o", "-", infoPlistPath, ]).pipe( Effect.map((value) => value.trim()), Effect.orElseSucceed(() => ""), ); }); function escapeSpotlightString(value: string): string { return value.replace(/([\\'*?])/gu, "\\$1"); } function containsControlCharacter(value: string): boolean { return [...value].some((character) => { const codePoint = character.codePointAt(0) ?? 0; return codePoint <= 31 || codePoint === 127; }); } const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* ( app: ToolActivityNativeAppReference, ) { const path = yield* Path.Path; const query = app._tag === "app-id" ? `kMDItemCFBundleIdentifier == '${app.appId}'` : `kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '${escapeSpotlightString(app.displayName)}'`; const spotlightOutput = yield* commandOutput("/usr/bin/mdfind", [query]); const candidates = spotlightOutput .split(/\r?\n/u) .map((value) => value.trim()) .filter((value) => value.endsWith(".app")); const matchingCandidates = app._tag === "app-id" ? candidates : candidates.filter( (value) => path.basename(value, ".app").toLocaleLowerCase() === app.displayName.toLocaleLowerCase(), ); const rankedCandidates = matchingCandidates.length > 0 ? matchingCandidates : candidates; let mostRecentlyUsed: { readonly path: string; readonly lastUsed: string } | null = null; for (const candidate of rankedCandidates) { const lastUsed = yield* commandOutput("/usr/bin/mdls", [ "-raw", "-name", "kMDItemLastUsedDate", candidate, ]).pipe( Effect.map((value) => value.trim()), Effect.orElseSucceed(() => ""), ); if (!mostRecentlyUsed || lastUsed > mostRecentlyUsed.lastUsed) { mostRecentlyUsed = { path: candidate, lastUsed }; } } return mostRecentlyUsed?.path ?? null; }); const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* ( app: ToolActivityNativeAppReference, ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; const appPath = yield* resolveApplicationPath(app); if (!appPath) return null; const canonicalAppPath = yield* fileSystem.realPath(appPath); const infoPlistPath = path.join(canonicalAppPath, "Contents", "Info.plist"); const resourcesDirectory = path.join(canonicalAppPath, "Contents", "Resources"); const iconName = (yield* plistValue(infoPlistPath, "CFBundleIconFile")) || (yield* plistValue(infoPlistPath, "CFBundleIconName")); if (iconName && path.basename(iconName) !== iconName) return null; const iconFileName = iconName ? (path.extname(iconName) ? iconName : `${iconName}.icns`) : null; const resourceEntries = yield* fileSystem .readDirectory(resourcesDirectory) .pipe(Effect.orElseSucceed(() => [])); const sourceIconCandidate = (iconFileName ? yield* existingFile(path.join(resourcesDirectory, iconFileName)) : null) ?? (yield* existingFile(path.join(resourcesDirectory, "AppIcon.icns"))) ?? (resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns")) ? yield* existingFile( path.join( resourcesDirectory, resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))!, ), ) : null); if (!sourceIconCandidate) return null; const sourceIconPath = yield* fileSystem.realPath(sourceIconCandidate); const relativeSource = path.relative(resourcesDirectory, sourceIconPath); if ( relativeSource === ".." || relativeSource.startsWith(`..${path.sep}`) || path.isAbsolute(relativeSource) ) { return null; } const appVersion = (yield* plistValue(infoPlistPath, "CFBundleVersion")) || (yield* plistValue(infoPlistPath, "CFBundleShortVersionString")); const cacheKey = NodeCrypto.createHash("sha256") .update(`${canonicalAppPath}\0${appVersion}\0${sourceIconPath}`) .digest("hex"); const cacheDirectory = path.join(config.providerStatusCacheDir, "native-app-icons"); const cachePath = path.join(cacheDirectory, `${cacheKey}.png`); if (yield* existingFile(cachePath)) return cachePath; yield* fileSystem.makeDirectory(cacheDirectory, { recursive: true }); const temporaryPath = path.join( cacheDirectory, `.${cacheKey}-${process.pid}-${(yield* Clock.currentTimeMillis).toString(36)}-${NodeCrypto.randomUUID()}.png`, ); yield* commandOutput("/usr/bin/sips", [ "-z", String(ICON_SIZE), String(ICON_SIZE), "-s", "format", "png", sourceIconPath, "--out", temporaryPath, ]).pipe( Effect.tap(() => fileSystem.rename(temporaryPath, cachePath)), Effect.ensuring( fileSystem.remove(temporaryPath).pipe(Effect.catchTags({ PlatformError: () => Effect.void })), ), ); return yield* existingFile(cachePath); }); /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; const resolutionSemaphore = yield* Semaphore.make(2); const resolutionCache: Cache.Cache< string, string | null, PlatformError.PlatformError | Cause.TimeoutError > = yield* Cache.makeWith( (key: string) => resolutionSemaphore.withPermits(1)(resolveNativeAppIconUncached(appFromCacheKey(key))), { capacity: RESOLUTION_CACHE_MAX_ENTRIES, timeToLive: Exit.match({ onSuccess: () => RESOLUTION_CACHE_TTL, onFailure: () => Duration.zero, }), }, ); const cachedFileExists = (filePath: string) => fileSystem.stat(filePath).pipe( Effect.map((info) => info.type === "File"), Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error), }), ); const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* ( app: ToolActivityNativeAppReference, ) { if ( hostPlatform !== "darwin" || (app._tag === "display-name" && containsControlCharacter(app.displayName)) ) { return null; } const key = appCacheKey(app); const cached = yield* Cache.get(resolutionCache, key); if (cached === null) return null; if (yield* cachedFileExists(cached)) return cached; yield* Cache.invalidate(resolutionCache, key); return yield* Cache.get(resolutionCache, key); }); const resolve: NativeAppIconResolver["Service"]["resolve"] = (app) => resolveAttempt(app).pipe( Effect.tapError((cause) => Effect.logDebug("Failed to resolve native application icon.", { app, cause }), ), Effect.orElseSucceed(() => null), ); return NativeAppIconResolver.of({ resolve }); }); export const layer = Layer.effect(NativeAppIconResolver, make);