Skip to content
Source
Sign inSign up
Open History to see the last commit

NativeAppIconResolver.ts

279 lines9.7 KB

1import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
2import type { NativeAppIconReference } from "@t3tools/contracts";
3import * as Cache from "effect/Cache";
4import * as Cause from "effect/Cause";
5import * as Digest from "effect/crypto/Digest";
6import * as SecureRandom from "effect/crypto/SecureRandom";
7import * as Effect from "effect/Effect";
8import * as Clock from "effect/Clock";
9import * as Context from "effect/Context";
10import * as Duration from "effect/Duration";
11import * as Exit from "effect/Exit";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Path from "effect/Path";
16import * as PlatformError from "effect/PlatformError";
17import * as Semaphore from "effect/Semaphore";
18import * as Hex from "effect/encoding/Hex";
19import { ChildProcess, ChildProcessSpawner } from "effect/process";
20import * as HostProcess from "@t3tools/shared/HostProcess";
21​
22import * as ServerConfig from "../config.ts";
23​
24const ICON_SIZE = 64;
25const COMMAND_TIMEOUT = "5 seconds";
26const RESOLUTION_CACHE_TTL = Duration.hours(1);
27const RESOLUTION_CACHE_MAX_ENTRIES = 256;
28​
29export class NativeAppIconResolver extends Context.Service<
30 NativeAppIconResolver,
31 {
32 readonly resolve: (app: NativeAppIconReference) => Effect.Effect<string | null>;
33 }
34>()("t3/assets/NativeAppIconResolver") {}
35​
36function appCacheKey(app: NativeAppIconReference): string {
37 return JSON.stringify(app);
39​
40function appFromCacheKey(key: string): NativeAppIconReference {
41 return JSON.parse(key) as NativeAppIconReference;
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");
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 });
93​
94const resolveApplicationPath = Effect.fn("NativeAppIconResolver.resolveApplicationPath")(function* (
95 app: NativeAppIconReference,
96) {
97 const path = yield* Path.Path;
98 if (app._tag === "app-path") {
99 const appPath = expandHomePath(app.path, yield* HostProcess.HomeDirectory);
100 return path.isAbsolute(appPath) && appPath.endsWith(".app") ? appPath : null;
101 }
102 const query =
103 app._tag === "app-id"
104 ? `kMDItemCFBundleIdentifier == '${app.appId}'`
105 : `kMDItemContentType == 'com.apple.application-bundle' && kMDItemDisplayName == '${escapeSpotlightString(app.displayName)}'`;
106 const spotlightOutput = yield* commandOutput("/usr/bin/mdfind", [query]);
107 const candidates = spotlightOutput
108 .split(/\r?\n/u)
109 .map((value) => value.trim())
110 .filter((value) => value.endsWith(".app"));
111 const matchingCandidates =
112 app._tag === "app-id"
113 ? candidates
114 : candidates.filter(
115 (value) =>
116 path.basename(value, ".app").toLocaleLowerCase() ===
117 app.displayName.toLocaleLowerCase(),
118 );
119 const rankedCandidates = matchingCandidates.length > 0 ? matchingCandidates : candidates;
120 let mostRecentlyUsed: { readonly path: string; readonly lastUsed: string } | null = null;
121 for (const candidate of rankedCandidates) {
122 const lastUsed = yield* commandOutput("/usr/bin/mdls", [
123 "-raw",
124 "-name",
125 "kMDItemLastUsedDate",
126 candidate,
127 ]).pipe(
128 Effect.map((value) => value.trim()),
129 Effect.orElseSucceed(() => ""),
130 );
131 if (!mostRecentlyUsed || lastUsed > mostRecentlyUsed.lastUsed) {
132 mostRecentlyUsed = { path: candidate, lastUsed };
133 }
134 }
135 return mostRecentlyUsed?.path ?? null;
136});
137​
138const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUncached")(function* (
139 app: NativeAppIconReference,
140) {
141 const fileSystem = yield* FileSystem.FileSystem;
142 const path = yield* Path.Path;
143 const config = yield* ServerConfig.ServerConfig;
144 const appPath = yield* resolveApplicationPath(app);
145 if (!appPath) return null;
146​
147 const canonicalAppPath = yield* fileSystem.realPath(appPath);
148 const infoPlistPath = path.join(canonicalAppPath, "Contents", "Info.plist");
149 const resourcesDirectory = path.join(canonicalAppPath, "Contents", "Resources");
150 const iconName =
151 (yield* plistValue(infoPlistPath, "CFBundleIconFile")) ||
152 (yield* plistValue(infoPlistPath, "CFBundleIconName"));
153 if (iconName && path.basename(iconName) !== iconName) return null;
154 const iconFileName = iconName ? (path.extname(iconName) ? iconName : `${iconName}.icns`) : null;
155 const resourceEntries = yield* fileSystem
156 .readDirectory(resourcesDirectory)
157 .pipe(Effect.orElseSucceed(() => []));
158 const sourceIconCandidate =
159 (iconFileName ? yield* existingFile(path.join(resourcesDirectory, iconFileName)) : null) ??
160 (yield* existingFile(path.join(resourcesDirectory, "AppIcon.icns"))) ??
161 (resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))
162 ? yield* existingFile(
163 path.join(
164 resourcesDirectory,
165 resourceEntries.find((entry) => entry.toLowerCase().endsWith(".icns"))!,
166 ),
167 )
168 : null);
169 if (!sourceIconCandidate) return null;
170 const sourceIconPath = yield* fileSystem.realPath(sourceIconCandidate);
171 const relativeSource = path.relative(resourcesDirectory, sourceIconPath);
172 if (
173 relativeSource === ".." ||
174 relativeSource.startsWith(`..${path.sep}`) ||
175 path.isAbsolute(relativeSource)
176 ) {
177 return null;
178 }
179​
180 const appVersion =
181 (yield* plistValue(infoPlistPath, "CFBundleVersion")) ||
182 (yield* plistValue(infoPlistPath, "CFBundleShortVersionString"));
183 const random = yield* SecureRandom.SecureRandom;
184 const digest = yield* Digest.Digest;
185 const cacheKey = yield* digest
186 .digest(
187 Digest.sha256,
188 new TextEncoder().encode(`${canonicalAppPath}\0${appVersion}\0${sourceIconPath}`),
189 )
190 .pipe(Effect.map(Hex.encode), Effect.orDie);
191 const cacheDirectory = path.join(config.providerStatusCacheDir, "native-app-icons");
192 const cachePath = path.join(cacheDirectory, `${cacheKey}.png`);
193 if (yield* existingFile(cachePath)) return cachePath;
194​
195 yield* fileSystem.makeDirectory(cacheDirectory, { recursive: true });
196 const temporaryPath = path.join(
197 cacheDirectory,
198 `.${cacheKey}-${process.pid}-${(yield* Clock.currentTimeMillis).toString(36)}-${yield* random.uuidV4()}.png`,
199 );
200 yield* commandOutput("/usr/bin/sips", [
201 "-z",
202 String(ICON_SIZE),
203 String(ICON_SIZE),
204 "-s",
205 "format",
206 "png",
207 sourceIconPath,
208 "--out",
209 temporaryPath,
210 ]).pipe(
211 Effect.tap(() => fileSystem.rename(temporaryPath, cachePath)),
212 Effect.ensuring(
213 fileSystem.remove(temporaryPath).pipe(Effect.catchTags({ PlatformError: () => Effect.void })),
214 ),
215 );
216 return yield* existingFile(cachePath);
217});
218​
219/** @public */
220export const make = Effect.gen(function* () {
221 const fileSystem = yield* FileSystem.FileSystem;
222 const hostPlatform = yield* HostProcess.Platform;
223 const resolutionSemaphore = yield* Semaphore.make(2);
224 const resolutionCache: Cache.Cache<
225 string,
226 string | null,
227 PlatformError.PlatformError | Cause.TimeoutError
228 > = yield* Cache.makeWith(
229 (key: string) =>
230 resolutionSemaphore.withPermits(1)(resolveNativeAppIconUncached(appFromCacheKey(key))),
231 {
232 capacity: RESOLUTION_CACHE_MAX_ENTRIES,
233 timeToLive: Exit.match({
234 onSuccess: () => RESOLUTION_CACHE_TTL,
235 onFailure: () => Duration.zero,
236 }),
237 },
238 );
239​
240 const cachedFileExists = (filePath: string) =>
241 fileSystem.stat(filePath).pipe(
242 Effect.map((info) => info.type === "File"),
243 Effect.catchTags({
244 PlatformError: (error) =>
245 error.reason._tag === "NotFound" ? Effect.succeed(false) : Effect.fail(error),
246 }),
247 );
248​
249 const resolveAttempt = Effect.fn("NativeAppIconResolver.resolve")(function* (
250 app: NativeAppIconReference,
251 ) {
252 if (
253 hostPlatform !== "darwin" ||
254 (app._tag === "display-name" && containsControlCharacter(app.displayName))
255 ) {
256 return null;
257 }
258​
259 const key = appCacheKey(app);
260 const cached = yield* Cache.get(resolutionCache, key);
261 if (cached === null) return null;
262 if (yield* cachedFileExists(cached)) return cached;
263​
264 yield* Cache.invalidate(resolutionCache, key);
265 return yield* Cache.get(resolutionCache, key);
266 });
267​
268 const resolve: NativeAppIconResolver["Service"]["resolve"] = (app) =>
269 resolveAttempt(app).pipe(
270 Effect.tapError((cause) =>
271 Effect.logDebug("Failed to resolve native application icon.", { app, cause }),
272 ),
273 Effect.orElseSucceed(() => null),
274 );
275​
276 return NativeAppIconResolver.of({ resolve });
277});
278​
279export const layer = Layer.effect(NativeAppIconResolver, make);