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;
});