Files
puter/src/backend/services/appIcon/AppIconService.ts
T
Daniel Salazar 799fc4ac3c feat: send app icons as a subdomain URL plus an API fallback (#3734)
App payloads carried only the /app-icon endpoint URL, which 302s to the
icons hosting subdomain. Networks that mangle that redirect render no icon
at all, and every icon load pays a round trip for the hop.

Ship the direct subdomain URL as `iconCdnUrl` alongside it (taskbar items,
installedApps, recent/recommended launch apps, suggested apps), and have the
GUI load that first with the endpoint URL as a one-shot retry - desktop
taskbar, start menu, dashboard app grid and recents. Only rows whose `icon`
column is already an http(s) URL get one: a data: column means the resize
pipeline has not written anything to the subdomain yet.

Also folds the four copies of the generated-size list into one exported
APP_ICON_SIZES.
2026-09-03 13:32:55 -07:00

302 lines
12 KiB
TypeScript

/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Readable } from 'node:stream';
import type { LayerInstances } from '../../types';
import { APP_ICON_SIZES, getAppIconsBaseUrl } from '../../util/appIcon.js';
import { isUniqueViolation } from '../../util/dbError.js';
import type { puterServices } from '../index';
import { PuterService } from '../types.js';
const APP_ICONS_SUBDOMAIN = 'puter-app-icons';
const APP_ICONS_PATH_PREFIX = '/system/app_icons';
const ORIGINAL_ICON_FILENAME = (uid: string) => `${uid}.png`;
const SIZED_ICON_FILENAME = (uid: string, size: number) => `${uid}-${size}.png`;
/**
* App icon generation service.
*
* 1. On boot: ensures `/system/app_icons/` exists (owned by admin/system user) and
* that the `puter-app-icons` subdomain points at it. Icons are then served
* through Puter's regular hosting path
* (`https://puter-app-icons.<hosting-domain>/<uid>-<size>.png`) — no custom
* route, no custom S3 plumbing.
* 2. On `app.new-icon` event: decodes the data URL, resizes via sharp to the 6
* standard sizes, and writes the PNGs into that directory via FSService. The
* write populates the CDN-backed subdomain automatically because
* `puter-app-icons` is a regular hosted site.
* 3. Once the original is persisted, the app's `icon` column is rewritten from the
* data URL to the canonical endpoint URL so later reads don't re-ship the
* base64 payload.
*/
export class AppIconService extends PuterService {
declare protected services: LayerInstances<typeof puterServices>;
#sharp: typeof import('sharp') | null = null;
#dirReady: Promise<void> | null = null;
#ownerUserId: number | null = null;
override async onServerStart(): Promise<void> {
try {
this.#sharp = (await import('sharp')).default;
} catch {
console.warn(
'[app-icon] sharp not available — icon resizing disabled',
);
}
this.#dirReady = this.ensureIconsDirectory();
this.clients.event.on(
'app.new-icon',
async (_key: string, data: unknown) => {
try {
await this.#processIcon(data as Record<string, unknown>);
} catch (err) {
console.warn('[app-icon] icon processing failed', err);
}
},
);
// Apps written with a data URL icon outside this pipeline get
// picked up lazily through `app.changed`. Guarded against the
// `icon-migrated` action we emit ourselves.
this.clients.event.on(
'app.changed',
async (_key: string, data: unknown) => {
const d = data as Record<string, unknown> | undefined;
if (!d?.app_uid) return;
if (d.action === 'icon-migrated') return;
const app = await this.stores.app.getByUid(String(d.app_uid));
const icon = (app as Record<string, unknown> | null)?.icon as
| string
| undefined;
if (icon?.startsWith('data:')) {
await this.#processIcon({
app_uid: d.app_uid,
data_url: icon,
});
}
},
);
}
/**
* Public: canonical URL for an app's icon at a given size
* (CDN/subdomain-backed).
*/
getIconUrl(appUid: string, size: number): string | null {
const base = this.#iconsBaseUrl();
if (!base) return null;
const normalized = appUid.startsWith('app-') ? appUid : `app-${appUid}`;
return `${base}/${SIZED_ICON_FILENAME(normalized, size)}`;
}
/**
* Public: URL of the un-resized original PNG (no size suffix) on the
* subdomain.
*/
getOriginalIconUrl(appUid: string): string | null {
const base = this.#iconsBaseUrl();
if (!base) return null;
const normalized = appUid.startsWith('app-') ? appUid : `app-${appUid}`;
return `${base}/${ORIGINAL_ICON_FILENAME(normalized)}`;
}
/**
* Pick the best subdomain URL to redirect an icon request at. Falls back to
* the un-resized original when the sized variant hasn't been generated
* (e.g. apps imported with an HTTP icon URL that predates the sharp
* pipeline), preventing 404s on `<uid>-<size>.png`.
*/
async resolveIconRedirectUrl(
appUid: string,
size: number,
): Promise<string | null> {
const base = this.#iconsBaseUrl();
if (!base) return null;
const normalized = appUid.startsWith('app-') ? appUid : `app-${appUid}`;
const sizedPath = `${APP_ICONS_PATH_PREFIX}/${SIZED_ICON_FILENAME(normalized, size)}`;
const sizedExists = await this.stores.fsEntry.getEntryByPath(sizedPath);
if (sizedExists)
return `${base}/${SIZED_ICON_FILENAME(normalized, size)}`;
const originalPath = `${APP_ICONS_PATH_PREFIX}/${ORIGINAL_ICON_FILENAME(normalized)}`;
const originalExists =
await this.stores.fsEntry.getEntryByPath(originalPath);
if (originalExists)
return `${base}/${ORIGINAL_ICON_FILENAME(normalized)}`;
return null;
}
#iconsBaseUrl(): string | null {
return getAppIconsBaseUrl(this.config);
}
// -- Bootstrap ---------------------------------------------------
/**
* Public so `DefaultUserService` can call it immediately after it creates
* the admin user on first boot — otherwise we'd lose the race
* (AppIconService is registered BEFORE DefaultUserService and its own
* `onServerStart` runs when no admin exists yet). Idempotent: safe to call
* repeatedly.
*/
async ensureIconsDirectory(): Promise<void> {
// The admin user owns the icons directory. DefaultUserService
// creates the admin on first boot; if it doesn't exist yet we
// bail and try again the next time an icon is processed.
const adminUser = await this.stores.user.getByUsername('admin');
if (!adminUser) {
console.warn(
'[app-icon] admin user not found; deferring icons directory setup',
);
return;
}
this.#ownerUserId = adminUser.id;
// Ensure /system/app_icons/ exists.
const existing = await this.stores.fsEntry.getEntryByPath(
APP_ICONS_PATH_PREFIX,
);
let dirEntry = existing;
if (!dirEntry) {
// Write an empty dir by writing a dummy file and removing it
// isn't great — instead rely on `createMissingParents` when we
// write the first icon. We still need a directory entry for
// the subdomain `root_dir_id` though, so create it explicitly
// via the store's directory helper.
dirEntry = await this.stores.fsEntry.resolveParentDirectory(
adminUser.id,
APP_ICONS_PATH_PREFIX,
true,
);
}
if (!dirEntry) {
console.warn('[app-icon] failed to ensure icons directory');
return;
}
// Register the `puter-app-icons` subdomain pointing at that dir.
// Idempotent, and must stay so under a concurrent boot: this method
// runs twice on first boot — once un-awaited from our own
// `onServerStart`, then again (awaited) from `DefaultUserService`
// right after it creates the admin — and `existsBySubdomain` reads a
// cache that can still hold a stale negative entry. So the existence
// check can pass in both calls; let the unique constraint be the real
// arbiter and swallow the loser's duplicate. The end state (subdomain
// exists) is identical either way.
const already =
await this.stores.subdomain.existsBySubdomain(APP_ICONS_SUBDOMAIN);
if (already) return;
try {
await this.stores.subdomain.create({
userId: adminUser.id,
subdomain: APP_ICONS_SUBDOMAIN,
rootDirId: dirEntry.id ?? null,
});
} catch (e) {
if (!isUniqueViolation(e)) throw e;
}
}
// -- Icon pipeline -----------------------------------------------
async #processIcon(data: Record<string, unknown>): Promise<void> {
if (this.#dirReady) await this.#dirReady;
if (!this.#ownerUserId) {
// Retry the bootstrap — admin may have been created in the
// meantime (e.g. first-boot race).
await this.ensureIconsDirectory();
if (!this.#ownerUserId) return;
}
if (!this.#sharp) return; // can't resize without sharp
const dataUrl = (data.dataUrl ?? data.data_url) as string | undefined;
let appUid = (data.appUid ?? data.app_uid) as string | undefined;
if (!dataUrl || !appUid) return;
if (!appUid.startsWith('app-')) appUid = `app-${appUid}`;
const commaIdx = dataUrl.indexOf(',');
if (commaIdx === -1) return;
const inputBuffer = Buffer.from(dataUrl.slice(commaIdx + 1), 'base64');
if (inputBuffer.length === 0) return;
// Write the original alongside the sized variants so the CDN-backed
// subdomain serves everything through the same path.
const writes: Array<Promise<unknown>> = [];
const originalPng = await this.#sharp(inputBuffer).png().toBuffer();
writes.push(
this.#writeIcon(ORIGINAL_ICON_FILENAME(appUid), originalPng),
);
for (const size of APP_ICON_SIZES) {
const sizedPng = await this.#sharp(inputBuffer)
.resize(size)
.png()
.toBuffer();
writes.push(
this.#writeIcon(SIZED_ICON_FILENAME(appUid, size), sizedPng),
);
}
await Promise.all(writes);
// Rewrite the DB icon column from data URL to canonical endpoint URL.
// The endpoint URL is `/app-icon/<uid>` — the AppController route
// that falls back to the data URL if S3/CDN lookups miss. Using it
// here keeps the icon column small and makes clients go through
// the cached path.
const apiBase = String(this.config.api_base_url ?? '').replace(
/\/+$/,
'',
);
if (apiBase) {
await this.clients.db.write(
"UPDATE `apps` SET `icon` = ? WHERE `uid` = ? AND `icon` LIKE 'data:%'",
[`${apiBase}/app-icon/${appUid}`, appUid],
);
await this.stores.app.invalidateByUid(appUid);
this.clients.event.emit(
'app.changed',
{
app_uid: appUid,
action: 'icon-migrated',
},
{},
);
}
}
async #writeIcon(filename: string, buffer: Buffer): Promise<void> {
if (!this.#ownerUserId) return;
await this.services.fs.write(this.#ownerUserId, {
fileMetadata: {
path: `${APP_ICONS_PATH_PREFIX}/${filename}`,
size: buffer.length,
contentType: 'image/png',
overwrite: true,
createMissingParents: true,
},
fileContent: Readable.from(buffer),
});
}
}