1import type { ToolActivityNativeAppReference } from "@t3tools/contracts";
2import * as Cache from "effect/Cache";
3import * as Cause from "effect/Cause";
4import * as Crypto from "effect/Crypto";
5import * as Effect from "effect/Effect";
6import * as Clock from "effect/Clock";
7import * as Context from "effect/Context";
8import * as Duration from "effect/Duration";
9import * as Exit from "effect/Exit";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as PlatformError from "effect/PlatformError";
15import * as Semaphore from "effect/Semaphore";
16import * as Hex from "effect/encoding/Hex";
17import { ChildProcess, ChildProcessSpawner } from "effect/process";
18import * as HostProcess from "@t3tools/shared/HostProcess";
19
20import * as ServerConfig from "../config.ts";
21
22const ICON_SIZE = 64;
23const COMMAND_TIMEOUT = "5 seconds";
24const RESOLUTION_CACHE_TTL = Duration.hours(1);
25const RESOLUTION_CACHE_MAX_ENTRIES = 256;
26
27/** Resolves and caches macOS application icons without exposing host paths to clients. */
28export class NativeAppIconResolver extends Context.Service<
29 NativeAppIconResolver,
30 {
31 /** Returns a cached PNG path for the application, or `null` when no icon is available. */
32 readonly resolve: (app: ToolActivityNativeAppReference) => Effect.Effect<string | null>;
33 }
34>()("t3/assets/NativeAppIconResolver") {}
35
36function appCacheKey(app: ToolActivityNativeAppReference): string {
37 return JSON.stringify(app);
38}
39
40function appFromCacheKey(key: string): ToolActivityNativeAppReference {
41 return JSON.parse(key) as ToolActivityNativeAppReference;
42}
43
44const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) {
45 const fileSystem = yield* FileSystem.FileSystem;
46 const info = yield* fileSystem.stat(filePath).pipe(
47 Effect.asSome,
48 Effect.catchTags({
49 PlatformError: (error) =>
50 error.reason._tag === "NotFound" ? Effect.succeedNone : Effect.fail(error),
51 }),
52 );
53 return Option.isSome(info) && info.value.type === "File" ? filePath : null;
54});
55
56const commandOutput = Effect.fn("NativeAppIconResolver.commandOutput")(function* (
57 command: string,
58 args: ReadonlyArray<string>,
59) {
60 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
61 return yield* spawner
62 .string(ChildProcess.make(command, args, { stdin: "ignore", stderr: "ignore" }))
63 .pipe(Effect.timeout(COMMAND_TIMEOUT));
64});
65
66const plistValue = Effect.fn("NativeAppIconResolver.plistValue")(function* (
67 infoPlistPath: string,
68 key: string,
69) {
70 return yield* commandOutput("/usr/bin/plutil", [
71 "-extract",
72 key,
73 "raw",
74 "-o",
75 "-",
76 infoPlistPath,
77 ]).pipe(
78 Effect.map((value) => value.trim()),
79 Effect.orElseSucceed(() => ""),
80 );
81});
82
83function escapeSpotlightString(value: string): string {
84 return value.replace(/([\\'*?])/gu, "\\$1");
85}
86
87function containsControlCharacter(value: string): boolean {
88 return [...value].some((character) => {
89 const codePoint = character.codePointAt(0) ?? 0;
90 return codePoint <= 31 || codePoint === 127;
91 });
92}
93
94const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* (
95 app: ToolActivityNativeAppReference,
96) {
97 const path = yield* Path.Path;
98 const query =
99 app._tag === "app-id"
100 ? `kMDItemCFBundleIdentifier == '${app.appId}'`
101 : `kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '${escapeSpotlightString(app.displayName)}'`;
102 const spotlightOutput = yield* commandOutput("/usr/bin/mdfind", [query]);
103 const candidates = spotlightOutput
104 .split(/\r?\n/u)
105 .map((value) => value.trim())
106 .filter((value) => value.endsWith(".app"));
107 const matchingCandidates =
108 app._tag === "app-id"
109 ? candidates
110 : candidates.filter(
111 (value) =>
112 path.basename(value, ".app").toLocaleLowerCase() ===
113 app.displayName.toLocaleLowerCase(),
114 );
115 const rankedCandidates = matchingCandidates.length > 0 ? matchingCandidates : candidates;
116 let mostRecentlyUsed: { readonly path: string; readonly lastUsed: string } | null = null;
117 for (const candidate of rankedCandidates) {
118 const lastUsed = yield* commandOutput("/usr/bin/mdls", [
119 "-raw",
120 "-name",
121 "kMDItemLastUsedDate",
122 candidate,
123 ]).pipe(
124 Effect.map((value) => value.trim()),
125 Effect.orElseSucceed(() => ""),
126 );
127 if (!mostRecentlyUsed || lastUsed > mostRecentlyUsed.lastUsed) {
128 mostRecentlyUsed = { path: candidate, lastUsed };
129 }
130 }
131 return mostRecentlyUsed?.path ?? null;
132});
133
134const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* (
135 app: ToolActivityNativeAppReference,
136) {
137 const fileSystem = yield* FileSystem.FileSystem;
138 const path = yield* Path.Path;
139 const config = yield* ServerConfig.ServerConfig;
140 const appPath = yield* resolveApplicationPath(app);
141 if (!appPath) return null;
142
143 const canonicalAppPath = yield* fileSystem.realPath(appPath);
144 const infoPlistPath = path.join(canonicalAppPath, "Contents", "Info.plist");
145 const resourcesDirectory = path.join(canonicalAppPath, "Contents", "Resources");
146 const iconName =
147 (yield* plistValue(infoPlistPath, "CFBundleIconFile")) ||
148 (yield* plistValue(infoPlistPath, "CFBundleIconName"));
149 if (iconName && path.basename(iconName) !== iconName) return null;
150 const iconFileName = iconName ? (path.extname(iconName) ? iconName : `${iconName}.icns`) : null;
151 const resourceEntries = yield* fileSystem
152 .readDirectory(resourcesDirectory)
153 .pipe(Effect.orElseSucceed(() => []));
154 const sourceIconCandidate =
155 (iconFileName ? yield* existingFile(path.join(resourcesDirectory, iconFileName)) : null) ??
156 (yield* existingFile(path.join(resourcesDirectory, "AppIcon.icns"))) ??
157 (resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))
158 ? yield* existingFile(
159 path.join(
160 resourcesDirectory,
161 resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))!,
162 ),
163 )
164 : null);
165 if (!sourceIconCandidate) return null;
166 const sourceIconPath = yield* fileSystem.realPath(sourceIconCandidate);
167 const relativeSource = path.relative(resourcesDirectory, sourceIconPath);
168 if (
169 relativeSource === ".." ||
170 relativeSource.startsWith(`..${path.sep}`) ||
171 path.isAbsolute(relativeSource)
172 ) {
173 return null;
174 }
175
176 const appVersion =
177 (yield* plistValue(infoPlistPath, "CFBundleVersion")) ||
178 (yield* plistValue(infoPlistPath, "CFBundleShortVersionString"));
179 const crypto = yield* Crypto.Crypto;
180 const cacheKey = yield* crypto
181 .digest(
182 "SHA-256",
183 new TextEncoder().encode(`${canonicalAppPath}\0${appVersion}\0${sourceIconPath}`),
184 )
185 .pipe(Effect.map(Hex.encode), Effect.orDie);
186 const cacheDirectory = path.join(config.providerStatusCacheDir, "native-app-icons");
187 const cachePath = path.join(cacheDirectory, `${cacheKey}.png`);
188 if (yield* existingFile(cachePath)) return cachePath;
189
190 yield* fileSystem.makeDirectory(cacheDirectory, { recursive: true });
191 const temporaryPath = path.join(
192 cacheDirectory,
193 `.${cacheKey}-${process.pid}-${(yield* Clock.currentTimeMillis).toString(36)}-${yield* crypto.randomUUIDv4().pipe(Effect.orDie)}.png`,
194 );
195 yield* commandOutput("/usr/bin/sips", [
196 "-z",
197 String(ICON_SIZE),
198 String(ICON_SIZE),
199 "-s",
200 "format",
201 "png",
202 sourceIconPath,
203 "--out",
204 temporaryPath,
205 ]).pipe(
206 Effect.tap(() => fileSystem.rename(temporaryPath, cachePath)),
207 Effect.ensuring(
208 fileSystem.remove(temporaryPath).pipe(Effect.catchTags({ PlatformError: () => Effect.void })),
209 ),
210 );
211 return yield* existingFile(cachePath);
212});
213
214/** @public Service construction is part of the canonical Effect module API. */
215export const make = Effect.gen(function* () {
216 const fileSystem = yield* FileSystem.FileSystem;
217 const hostPlatform = yield* HostProcess.Platform;
218 const resolutionSemaphore = yield* Semaphore.make(2);
219 const resolutionCache: Cache.Cache<
220 string,
221 string | null,
222 PlatformError.PlatformError | Cause.TimeoutError
223 > = yield* Cache.makeWith(
224 (key: string) =>
225 resolutionSemaphore.withPermits(1)(resolveNativeAppIconUncached(appFromCacheKey(key))),
226 {
227 capacity: RESOLUTION_CACHE_MAX_ENTRIES,
228 timeToLive: Exit.match({
229 onSuccess: () => RESOLUTION_CACHE_TTL,
230 onFailure: () => Duration.zero,
231 }),
232 },
233 );
234
235 const cachedFileExists = (filePath: string) =>
236 fileSystem.stat(filePath).pipe(
237 Effect.map((info) => info.type === "File"),
238 Effect.catchTags({
239 PlatformError: (error) =>
240 error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error),
241 }),
242 );
243
244 const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* (
245 app: ToolActivityNativeAppReference,
246 ) {
247 if (
248 hostPlatform !== "darwin" ||
249 (app._tag === "display-name" && containsControlCharacter(app.displayName))
250 ) {
251 return null;
252 }
253
254 const key = appCacheKey(app);
255 const cached = yield* Cache.get(resolutionCache, key);
256 if (cached === null) return null;
257 if (yield* cachedFileExists(cached)) return cached;
258
259 yield* Cache.invalidate(resolutionCache, key);
260 return yield* Cache.get(resolutionCache, key);
261 });
262
263 const resolve: NativeAppIconResolver["Service"]["resolve"] = (app) =>
264 resolveAttempt(app).pipe(
265 Effect.tapError((cause) =>
266 Effect.logDebug("Failed to resolve native application icon.", { app, cause }),
267 ),
268 Effect.orElseSucceed(() => null),
269 );
270
271 return NativeAppIconResolver.of({ resolve });
272});
273
274export const layer = Layer.effect(NativeAppIconResolver, make);