diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index 75db78844a50..0fddb10826fc 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -256,5 +256,159 @@ it.layer(TestLayer)("ProjectFaviconResolverLive", (it) => { expect(resolved).toContain("public/brand/logo.svg"); }), ); + + it.effect("resolves icon from PWA manifest.json", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "manifest.json", + JSON.stringify({ + icons: [{ src: "/icon-512.png", sizes: "512x512", type: "image/png" }], + }), + ); + yield* writeTextFile(cwd, "public/icon-512.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/icon-512.png"); + }), + ); + + it.effect("prefers larger manifest icon by declared size", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "manifest.json", + JSON.stringify({ + icons: [ + { src: "/icon-192.png", sizes: "192x192", type: "image/png" }, + { src: "/icon-512.png", sizes: "512x512", type: "image/png" }, + ], + }), + ); + yield* writeTextFile(cwd, "public/icon-192.png", "png"); + yield* writeTextFile(cwd, "public/icon-512.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("public/icon-512.png"); + }), + ); + + it.effect("falls back to directory scan for common asset folders", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "images/icon.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/icon.png"); + }), + ); + + it.effect("directory scan respects name and extension priority", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "images/icon.png", "png"); + yield* writeTextFile(cwd, "images/favicon.ico", "ico"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/favicon.ico"); + }), + ); + + it.effect("directory scan prefers earlier directory over later better name", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "brand.svg", "svg"); + yield* writeTextFile(cwd, "images/favicon.svg", "svg"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("brand.svg"); + expect(resolved).not.toContain("images/favicon.svg"); + }), + ); + + it.effect("directory scan continues after an unreadable directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "images/icon.png", "png"); + const publicDir = path.join(cwd, "public"); + const cause = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readDirectory", + pathOrDescriptor: publicDir, + }); + const resolver = yield* makeResolverWithFileSystem( + FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (dirPath, options) => + dirPath === publicDir + ? Effect.fail(cause) + : fileSystem.readDirectory(dirPath, options), + }), + ); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/icon.png"); + }), + ); + + it.effect("manifest with JSON null falls back to directory scan", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "manifest.json", "null"); + yield* writeTextFile(cwd, "images/icon.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("images/icon.png"); + }), + ); + + it.effect("manifest multi-size string picks largest dimension", () => + Effect.gen(function* () { + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const cwd = yield* makeTempDir; + yield* writeTextFile( + cwd, + "manifest.json", + JSON.stringify({ + icons: [ + { src: "/icon-192.png", sizes: "192x192 512x512", type: "image/png" }, + { src: "/icon-256.png", sizes: "256x256", type: "image/png" }, + ], + }), + ); + yield* writeTextFile(cwd, "public/icon-192.png", "png"); + yield* writeTextFile(cwd, "public/icon-256.png", "png"); + + const resolved = yield* resolver.resolvePath(cwd); + + expect(resolved).not.toBeNull(); + expect(resolved).toContain("icon-192.png"); + }), + ); }); }); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 2c7195de630b..867dac42133e 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -54,6 +54,47 @@ const ICON_SOURCE_FILES = [ "src/index.html", ] as const; +// Manifest files that may declare PWA icons. +const MANIFEST_SOURCE_FILES = [ + "manifest.json", + "public/manifest.json", + "site.webmanifest", + "public/site.webmanifest", +] as const; + +// Directories scanned as a fallback for common icon filenames. +const IMAGE_DIR_CANDIDATES = [ + "", + "public", + "app", + "src", + "src/app", + "assets", + "src/assets", + "assets/icons", + "assets/icon", + "static", + "resources", + "images", + "img", + "media", + "app-icon", + ".idea", +] as const; + +const IMAGE_NAME_CANDIDATES = [ + "favicon", + "icon", + "logo", + "apple-touch-icon", + "app-icon", + "icon-rounded", + "brand", + "app", +] as const; + +const IMAGE_EXTENSIONS = [".svg", ".png", ".jpg", ".jpeg", ".webp", ".ico"] as const; + // Matches tags or object-like icon metadata where rel/href can appear in any order. const LINK_ICON_HTML_RE = /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; @@ -68,6 +109,8 @@ export class ProjectFaviconResolutionError extends Schema.TaggedErrorClass( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => @@ -125,6 +180,8 @@ export const make = Effect.gen(function* () { return [path.join("public", clean), clean]; }; + const toPosixRelativePath = (input: string): string => input.replaceAll("\\", "/"); + const findExistingFile = Effect.fn("ProjectFaviconResolver.findExistingFile")(function* ( projectCwd: string, relativeCandidates: ReadonlyArray, @@ -166,6 +223,179 @@ export const make = Effect.gen(function* () { return null; }); + const findBestIconInDirectory = Effect.fn("ProjectFaviconResolver.findBestIconInDirectory")( + function* ( + projectCwd: string, + dir: string, + candidatePriority: ReadonlyMap< + string, + { readonly dirIdx: number; readonly nameIdx: number; readonly extIdx: number } + >, + ): Effect.fn.Return< + { + readonly absolutePath: string; + readonly relativePath: string; + readonly priority: { + readonly dirIdx: number; + readonly nameIdx: number; + readonly extIdx: number; + }; + } | null, + ProjectFaviconResolutionError + > { + const absoluteDir = path.resolve(projectCwd, dir); + const relativeToRoot = toPosixRelativePath(path.relative(projectCwd, absoluteDir)); + if ( + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + path.isAbsolute(relativeToRoot) + ) { + return null; + } + + const entries = yield* Effect.option(fileSystem.readDirectory(absoluteDir)); + if (Option.isNone(entries)) return null; + + let best: { + readonly absolutePath: string; + readonly relativePath: string; + readonly priority: { + readonly dirIdx: number; + readonly nameIdx: number; + readonly extIdx: number; + }; + } | null = null; + + for (const entry of entries.value) { + const key = dir ? `${dir}/${entry}` : entry; + const priority = candidatePriority.get(key); + if (!priority) continue; + + const absolutePath = path.join(absoluteDir, entry); + const relativePath = dir ? path.join(dir, entry) : entry; + const stats = yield* Effect.option(fileSystem.stat(absolutePath)); + if (Option.isNone(stats) || stats.value.type !== "File") continue; + + if ( + !best || + priority.dirIdx < best.priority.dirIdx || + (priority.dirIdx === best.priority.dirIdx && priority.nameIdx < best.priority.nameIdx) || + (priority.dirIdx === best.priority.dirIdx && + priority.nameIdx === best.priority.nameIdx && + priority.extIdx < best.priority.extIdx) + ) { + best = { absolutePath, relativePath, priority }; + } + } + + return best; + }, + ); + + const findIconByDirectoryScan = Effect.fn("ProjectFaviconResolver.findIconByDirectoryScan")( + function* (projectCwd: string): Effect.fn.Return { + const candidatePriority = new Map< + string, + { readonly dirIdx: number; readonly nameIdx: number; readonly extIdx: number } + >(); + IMAGE_DIR_CANDIDATES.forEach((candidateDir, dirIdx) => { + IMAGE_NAME_CANDIDATES.forEach((name, nameIdx) => { + IMAGE_EXTENSIONS.forEach((ext, extIdx) => { + const key = candidateDir ? `${candidateDir}/${name}${ext}` : `${name}${ext}`; + candidatePriority.set(key, { dirIdx, nameIdx, extIdx }); + }); + }); + }); + + let best: { + readonly absolutePath: string; + readonly relativePath: string; + readonly priority: { + readonly dirIdx: number; + readonly nameIdx: number; + readonly extIdx: number; + }; + } | null = null; + + for (const candidateDir of IMAGE_DIR_CANDIDATES) { + const match = yield* findBestIconInDirectory(projectCwd, candidateDir, candidatePriority); + if (!match) continue; + if ( + !best || + match.priority.dirIdx < best.priority.dirIdx || + (match.priority.dirIdx === best.priority.dirIdx && + match.priority.nameIdx < best.priority.nameIdx) || + (match.priority.dirIdx === best.priority.dirIdx && + match.priority.nameIdx === best.priority.nameIdx && + match.priority.extIdx < best.priority.extIdx) + ) { + best = match; + } + } + + return best ? best.absolutePath : null; + }, + ); + + const resolveManifestIcon = Effect.fn("ProjectFaviconResolver.resolveManifestIcon")(function* ( + projectCwd: string, + relativePath: string, + ): Effect.fn.Return { + const sourcePath = yield* workspacePaths + .resolveRelativePathWithinRoot({ + workspaceRoot: projectCwd, + relativePath, + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "resolve-path", + workspaceRoot: projectCwd, + relativePath, + cause, + }), + ), + ); + const source = yield* optionOnNotFound(fileSystem.readFileString(sourcePath.absolutePath)).pipe( + Effect.mapError( + (cause) => + new ProjectFaviconResolutionError({ + operation: "read-manifest", + workspaceRoot: projectCwd, + relativePath, + absolutePath: sourcePath.absolutePath, + cause, + }), + ), + ); + if (Option.isNone(source)) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(source.value); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const manifest = parsed as { icons?: ReadonlyArray<{ src?: string; sizes?: string }> }; + if (!Array.isArray(manifest.icons)) return null; + + let bestIcon: string | null = null; + let bestSize = -1; + for (const icon of manifest.icons) { + if (typeof icon.src !== "string") continue; + const existing = yield* findExistingFile(projectCwd, resolveIconHref(icon.src)); + if (!existing) continue; + const size = parseIconSize(icon.sizes); + if (size > bestSize) { + bestIcon = existing; + bestSize = size; + } + } + return bestIcon; + }); + const resolvePath: ProjectFaviconResolver["Service"]["resolvePath"] = Effect.fn( "ProjectFaviconResolver.resolvePath", )(function* (cwd) { @@ -239,6 +469,18 @@ export const make = Effect.gen(function* () { } } + for (const manifestFile of MANIFEST_SOURCE_FILES) { + const existing = yield* resolveManifestIcon(projectCwd, manifestFile); + if (existing) { + return existing; + } + } + + const scanned = yield* findIconByDirectoryScan(projectCwd); + if (scanned) { + return scanned; + } + return null; });