From 799fc4ac3c26443f32ae16e85a513dbd10415d75 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 3 Sep 2026 13:32:55 -0700 Subject: [PATCH] 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. --- extensions/installedApps.test.ts | 41 ++++++- extensions/installedApps.ts | 7 +- extensions/whoami.ts | 8 +- src/backend/controllers/apps/AppController.js | 5 +- .../controllers/auth/AuthController.ts | 1 + .../fs/LegacyFSController.routes.test.ts | 49 ++++++++ .../controllers/fs/LegacyFSController.ts | 16 +++ .../services/appIcon/AppIconService.ts | 16 +-- .../services/apps/RecommendedAppsService.ts | 8 +- .../services/apps/SuggestedAppsService.ts | 8 +- src/backend/util/appIcon.test.ts | Bin 15680 -> 18166 bytes src/backend/util/appIcon.ts | 73 ++++++++++-- src/backend/util/taskbarItems.test.ts | 32 ++++++ src/backend/util/taskbarItems.ts | 8 +- src/gui/src/UI/Dashboard/TabApps.js | 19 ++-- src/gui/src/UI/Dashboard/TabHome.js | 3 +- src/gui/src/UI/UITaskbar.js | 17 ++- src/gui/src/UI/UITaskbarItem.js | 7 +- src/gui/src/helpers/appIcon.js | 106 ++++++++++++++++++ src/gui/src/helpers/appIcon.test.js | 84 ++++++++++++++ src/gui/src/initgui.js | 5 + 21 files changed, 462 insertions(+), 51 deletions(-) create mode 100644 src/gui/src/helpers/appIcon.js create mode 100644 src/gui/src/helpers/appIcon.test.js diff --git a/extensions/installedApps.test.ts b/extensions/installedApps.test.ts index 423c6b3cb..f42c59523 100644 --- a/extensions/installedApps.test.ts +++ b/extensions/installedApps.test.ts @@ -9,6 +9,7 @@ import { vi, } from 'vitest'; import { runWithContext } from '../src/backend/core/context.ts'; +import { extension } from '../src/backend/extensions.ts'; import { PuterServer } from '../src/backend/server.ts'; import { setupTestServer } from '../src/backend/testUtil.ts'; import { handleInstalledApps } from './installedApps.ts'; @@ -51,13 +52,14 @@ const seedUser = async () => { }); }; -const seedApp = async (ownerUserId: number) => { +const seedApp = async (ownerUserId: number, overrides = {}) => { const slug = Math.random().toString(36).slice(2, 8); return server.stores.app.create( { name: `iaapp_${slug}`, title: `Installed App ${slug}`, index_url: `https://example.com/${slug}`, + ...overrides, }, { ownerUserId }, ); @@ -145,6 +147,43 @@ describe('installedApps extension — handleInstalledApps', () => { ).toBe(false); }); + it('pairs iconUrl with the direct subdomain URL for a generated icon', async () => { + const user = await seedUser(); + // The resize pipeline leaves an http(s) icon column behind, which is + // what says the sized PNG is on the icons subdomain. + const app = await seedApp(user.id as number, { + icon: 'https://api.puter.com/app-icon/x', + }); + await grantInstalled(app!.id as number, user.id as number); + + const { res, captured } = makeRes(); + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleInstalledApps(makeReq({}), res), + ); + + const list = captured.body as Array>; + const { protocol, static_hosting_domain } = extension.config; + expect(list[0].iconCdnUrl).toBe( + `${protocol}://puter-app-icons.${static_hosting_domain}/${app!.uid}-256.png`, + ); + }); + + it('reports no subdomain URL for an app with no generated icon', async () => { + const user = await seedUser(); + const app = await seedApp(user.id as number); + await grantInstalled(app!.id as number, user.id as number); + + const { res, captured } = makeRes(); + await runWithContext( + { actor: { user: { uuid: user.uuid, id: user.id as number } } }, + () => handleInstalledApps(makeReq({}), res), + ); + + const list = captured.body as Array>; + expect(list[0].iconCdnUrl).toBeNull(); + }); + it('flags apps with no owner_user_id as external', async () => { const user = await seedUser(); const slug = Math.random().toString(36).slice(2, 8); diff --git a/extensions/installedApps.ts b/extensions/installedApps.ts index 8ef7625c8..7c460eb42 100644 --- a/extensions/installedApps.ts +++ b/extensions/installedApps.ts @@ -2,7 +2,10 @@ import type { Request, Response } from 'express'; import { Context } from '@heyputer/backend/src/core'; import { HttpError } from '@heyputer/backend/src/core/http'; import { extension } from '@heyputer/backend/src/extensions'; -import { getAppIconUrl } from '@heyputer/backend/src/util/appIcon.js'; +import { + getAppIconCdnUrl, + getAppIconUrl, +} from '@heyputer/backend/src/util/appIcon.js'; const clients = extension.import('client'); @@ -73,6 +76,8 @@ export const handleInstalledApps = async ( return { ...rest, iconUrl: getAppIconUrl(app, { apiBaseUrl }), + // Direct subdomain URL for the client to try before iconUrl. + iconCdnUrl: getAppIconCdnUrl(app, extension.config), external, }; }), diff --git a/extensions/whoami.ts b/extensions/whoami.ts index b59a28268..a2e10dcc4 100644 --- a/extensions/whoami.ts +++ b/extensions/whoami.ts @@ -1,6 +1,7 @@ import { Context } from '@heyputer/backend/src/core'; import { extension } from '@heyputer/backend/src/extensions'; import { isCardFallbackEligible } from '@heyputer/backend/src/util/cardFallback.js'; +import { APP_ICON_SIZES } from '@heyputer/backend/src/util/appIcon.js'; import { getTaskbarItems } from '@heyputer/backend/src/util/taskbarItems.js'; import type { Request, Response } from 'express'; import TimeAgo from 'javascript-time-ago'; @@ -114,13 +115,12 @@ export const handleWhoami = async ( } const oidcOnly = user.password === null; - const ALLOWED_ICON_SIZES = new Set([16, 32, 64, 128, 256, 512]); const rawIconSize = typeof req.query?.icon_size === 'string' ? Number(req.query.icon_size) : undefined; const iconSize = - rawIconSize !== undefined && ALLOWED_ICON_SIZES.has(rawIconSize) + rawIconSize !== undefined && APP_ICON_SIZES.includes(rawIconSize) ? rawIconSize : undefined; const noIcons = !iconSize; @@ -201,6 +201,7 @@ export const handleWhoami = async ( stores, services, apiBaseUrl: String(extension.config.api_base_url ?? ''), + config: extension.config, }, { iconSize, noIcons }, ) @@ -291,7 +292,8 @@ export const handleWhoami = async ( } const subscription = details.subscription as - { offering?: Record } | undefined; + | { offering?: Record } + | undefined; if (subscription?.offering) { delete subscription.offering.group; delete subscription.offering.benefits; diff --git a/src/backend/controllers/apps/AppController.js b/src/backend/controllers/apps/AppController.js index 002f5912e..6d0c20df6 100644 --- a/src/backend/controllers/apps/AppController.js +++ b/src/backend/controllers/apps/AppController.js @@ -21,6 +21,7 @@ import { isAccessTokenActor, isAppActor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; import { driversContainers } from '../../exports.js'; import { + APP_ICON_SIZES, ICON_DATA_URL_MIME_ALLOWLIST, isTrustedIconHost, } from '../../util/appIcon.js'; @@ -414,8 +415,6 @@ export class AppController extends PuterController { // // ⚠ FLAG: Missing sharp-based resize pipeline; serves the original. - const ICON_SIZES = [16, 32, 64, 128, 256, 512]; - // Neutering headers for any response that echoes an icon byte // stream on the main origin. `image/svg+xml` is in our MIME // allow-list — it's a legitimate image format, and our own @@ -457,7 +456,7 @@ export class AppController extends PuterController { res.status(400).send('Missing app_uid'); return; } - if (!ICON_SIZES.includes(size)) { + if (!APP_ICON_SIZES.includes(size)) { res.status(400).send('Invalid size'); return; } diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index d6edb9047..b13d1ec70 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -4654,6 +4654,7 @@ export class AuthController extends PuterController { services: this.services, apiBaseUrl: (this.config as { api_base_url?: string }) .api_base_url, + config: this.config, } as never, ); } catch (e) { diff --git a/src/backend/controllers/fs/LegacyFSController.routes.test.ts b/src/backend/controllers/fs/LegacyFSController.routes.test.ts index e72054312..7b989ee13 100644 --- a/src/backend/controllers/fs/LegacyFSController.routes.test.ts +++ b/src/backend/controllers/fs/LegacyFSController.routes.test.ts @@ -331,6 +331,55 @@ describe('LegacyFSController inline routes', () => { expect(entry).not.toHaveProperty('owner_user_id'); }); + it('pairs a recent app icon with a direct subdomain URL at the asked size', async () => { + const { actor, userId } = await makeUser(); + const app = (await ( + server.stores.app.create as unknown as ( + fields: Record, + opts: { ownerUserId: number }, + ) => Promise<{ uid: string; name: string }> + )( + { + name: `recent-${uuidv4()}`, + title: 'Recent App', + index_url: 'https://recent.example.test/', + // An http(s) icon column is what the resize pipeline leaves + // behind, so the sized PNG is on the icons subdomain. + icon: 'https://api.puter.com/app-icon/x', + }, + { ownerUserId: userId }, + ))!; + await server.clients.db.write( + 'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)', + [app.uid, userId, Math.floor(Date.now() / 1000)], + ); + + const runWithIconSize = async (query: Record) => { + const { res, captured } = makeRes(); + await routeHandler('get', '/get-launch-apps')( + makeReq({ actor, query }), + res, + (() => {}) as never, + ); + const body = captured.body as { + recent: Array>; + }; + return body.recent.find((item) => item.uuid === app.uid)!; + }; + + const { protocol, static_hosting_domain } = controller.config; + const base = `${protocol}://puter-app-icons.${static_hosting_domain}`; + + expect((await runWithIconSize({ icon_size: '64' })).iconCdnUrl).toBe( + `${base}/${app.uid}-64.png`, + ); + // An unusable size falls back to the default rather than pointing at a + // file the pipeline never wrote. + expect((await runWithIconSize({ icon_size: '65' })).iconCdnUrl).toBe( + `${base}/${app.uid}-256.png`, + ); + }); + it('reports a zero cache timestamp when unauthenticated', async () => { const { res, captured } = makeRes(); await routeHandler('get', '/cache/last-change-timestamp')( diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 64bdcc6a3..9050c8920 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -41,6 +41,7 @@ import { NON_OWNER_SIGNATURE_TTL_SECONDS, verifySignature, } from '../../util/fileSigning.js'; +import { APP_ICON_SIZES, getAppIconCdnUrl } from '../../util/appIcon.js'; import { expandTildePath } from '../../services/fs/resolveNode.js'; import { maskEntryPath } from '../../services/fs/sharePathMask.js'; import { @@ -293,6 +294,14 @@ export class LegacyFSController extends PuterController { ? await recommendedSvc.getRecommendedApps() : []; + // The direct icon URL names a size; honour the one the + // caller asked for so the client isn't handed a 256px PNG for + // a 64px slot. `icon` keeps the raw column either way. + const requestedIconSize = Number(req.query.icon_size); + const iconSize = APP_ICON_SIZES.includes(requestedIconSize) + ? requestedIconSize + : undefined; + let recent: unknown[] = []; const userId = req.actor?.user?.id; if (userId) { @@ -346,6 +355,13 @@ export class LegacyFSController extends PuterController { name: app.name, title: app.title, icon: app.icon ?? null, + // Direct subdomain URL for the client to try + // before `icon`. + iconCdnUrl: getAppIconCdnUrl( + app, + this.config, + iconSize, + ), godmode: Boolean(app.godmode), maximize_on_start: Boolean(app.maximize_on_start), index_url: backingGone ? null : app.index_url, diff --git a/src/backend/services/appIcon/AppIconService.ts b/src/backend/services/appIcon/AppIconService.ts index 979eba2c0..aea29ff2d 100644 --- a/src/backend/services/appIcon/AppIconService.ts +++ b/src/backend/services/appIcon/AppIconService.ts @@ -19,11 +19,11 @@ 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 ICON_SIZES = [16, 32, 64, 128, 256, 512] as const; const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; const APP_ICONS_PATH_PREFIX = '/system/app_icons'; @@ -146,17 +146,7 @@ export class AppIconService extends PuterService { } #iconsBaseUrl(): string | null { - const cfg = this.config; - const host = cfg.static_hosting_domain ?? cfg.static_hosting_domain_alt; - if (!host) return null; - const protocol = cfg.protocol ?? 'https'; - // Externally-visible port. Mirrors what PuterHomepageService et al. - // do — non-80/443 deployments (local dev, reverse-proxied setups on - // non-standard ports) would otherwise get a hostname with no port. - const pubPort = cfg.pub_port; - const portSuffix = - pubPort && pubPort !== 80 && pubPort !== 443 ? `:${pubPort}` : ''; - return `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}${portSuffix}`; + return getAppIconsBaseUrl(this.config); } // -- Bootstrap --------------------------------------------------- @@ -258,7 +248,7 @@ export class AppIconService extends PuterService { this.#writeIcon(ORIGINAL_ICON_FILENAME(appUid), originalPng), ); - for (const size of ICON_SIZES) { + for (const size of APP_ICON_SIZES) { const sizedPng = await this.#sharp(inputBuffer) .resize(size) .png() diff --git a/src/backend/services/apps/RecommendedAppsService.ts b/src/backend/services/apps/RecommendedAppsService.ts index aa5364bba..a12dcff8e 100644 --- a/src/backend/services/apps/RecommendedAppsService.ts +++ b/src/backend/services/apps/RecommendedAppsService.ts @@ -18,7 +18,8 @@ * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ -import { getAppIconUrl } from '../../util/appIcon.js'; +import type { AppIconHostConfig } from '../../util/appIcon.js'; +import { getAppIconCdnUrl, getAppIconUrl } from '../../util/appIcon.js'; import { PuterService } from '../types.js'; /** @@ -62,7 +63,7 @@ export class RecommendedAppsService extends PuterService { const results: Array> = []; for (const name of RECOMMENDED_APP_NAMES) { const app = await this.stores.app.getByName(name); - if (app) results.push(toAppSummary(app, apiBaseUrl)); + if (app) results.push(toAppSummary(app, apiBaseUrl, this.config)); } return results; } @@ -71,12 +72,15 @@ export class RecommendedAppsService extends PuterService { function toAppSummary( app: Record, apiBaseUrl: string | undefined, + config: AppIconHostConfig, ): Record { return { uuid: app.uid, name: app.name, title: app.title, icon: getAppIconUrl(app, { apiBaseUrl }) ?? app.icon ?? null, + // Direct subdomain URL for the client to try before `icon`. + iconCdnUrl: getAppIconCdnUrl(app, config), godmode: Boolean(app.godmode), maximize_on_start: Boolean(app.maximize_on_start), index_url: app.index_url, diff --git a/src/backend/services/apps/SuggestedAppsService.ts b/src/backend/services/apps/SuggestedAppsService.ts index 09e42204b..544b1be07 100644 --- a/src/backend/services/apps/SuggestedAppsService.ts +++ b/src/backend/services/apps/SuggestedAppsService.ts @@ -18,7 +18,8 @@ */ import { posix as pathPosix } from 'node:path'; -import { getAppIconUrl } from '../../util/appIcon.js'; +import type { AppIconHostConfig } from '../../util/appIcon.js'; +import { getAppIconCdnUrl, getAppIconUrl } from '../../util/appIcon.js'; import { hostedIndexUrlBackingIsUnavailable } from '../../util/hostedAppBacking.js'; import { PuterService } from '../types.js'; @@ -319,19 +320,22 @@ export class SuggestedAppsService extends PuterService { return candidates .filter((_app, index) => !availability[index]) - .map((app) => toAppSummary(app, apiBaseUrl)); + .map((app) => toAppSummary(app, apiBaseUrl, this.config)); } } function toAppSummary( app: Record, apiBaseUrl: string | undefined, + config: AppIconHostConfig, ): Record { return { uuid: app.uid, name: app.name, title: app.title, icon: getAppIconUrl(app, { apiBaseUrl }) ?? app.icon ?? null, + // Direct subdomain URL for the client to try before `icon`. + iconCdnUrl: getAppIconCdnUrl(app, config), godmode: Boolean(app.godmode), maximize_on_start: Boolean(app.maximize_on_start), index_url: app.index_url, diff --git a/src/backend/util/appIcon.test.ts b/src/backend/util/appIcon.test.ts index 77dfd883e7f34d432e16bf5f7d04d0ddfaa397d9..86715c5c402733d7a775e05e18f19de5cc5bd342 100644 GIT binary patch delta 1271 zcmZ`(-D(p-6eh+JvZ!tCZzAfclx!mTY1&XVNky$7O7WN8c%j{7C(XcSX5F1dVyXH9 zDtdf@UWzw@FW?J!;k_XE5MFy`Hb07WF9LJs%y+)?o$ut!vtO?gpB^U9Y`S7Q@QN1D zx69O3-@ndGnWeqFk_waaX`3UTz*EoeAa@mB9PwKgn<0OAj33f-eJw2dlP5T^OSpQo1?);aV!U<;k|=X-GTnI=+g3hlq8s zyGr?oWkjvVx>Ox%)s+=|TUwehsraLsnH)ja^(8fso?UeV>Bm~Qtm8z~j(#2*&H+CQt^WZ60T^T8GhT8nM3oBL1{Kb0AskWvm;qyjXYnZeP4NNq%>5(2-ZO-Ax$@KhT zBi(fI`Fh5pk#+bkY=9ZbMc8i`5%naAo$u?Lk*pDSNi2$%s(+}Z$Buq#5no=IYz*QA zGjfP8xzHLYW0H_RV=mmieFLQU+ckJM2Y9D$os4aor#eN5`Qg0_=kRMGi&v@>>1t`c zR`$iNjo&I~iUXlz)it;gTgag2cyyw=&~gF>5s@d$%wT0QwbI|FlWq;l<+7<`T8jjs zalzc8N!Pu$+BRvTb)6c}YMIBx3}4r>i5)!1*+(g@^zPE)gr>Daw6ZTM6}Sqnqa4`2 zzXf?20PFdxU!aD(s3)Mu9P)zRnt&PBKKGeNwV~^T!dCD!;7T!JR3tD*^i@~yg_5z1 zqb=(*1wUWD*HLPdlfk6wW(KK7dy=5-aVBV>s*H*H{7NJ|7LJ#8NQxdiUcel;PxU}A zMLsO(28O$qBn|M*+8pjTuP53#eSR4$C#KM9P9GsZOGuXmG_kUF-5Adlw8qB4rH!+{ E0i}VFzW@LL delta 13 Ucmey?%Xpw_LlpDo4(4Z;04}ixDF6Tf diff --git a/src/backend/util/appIcon.ts b/src/backend/util/appIcon.ts index b6e5690c6..80359891d 100644 --- a/src/backend/util/appIcon.ts +++ b/src/backend/util/appIcon.ts @@ -17,19 +17,23 @@ * along with this program. If not, see . */ -// Always routes through the backend `/app-icon//` endpoint rather -// than the `puter-app-icons` subdomain directly. Some apps (especially those -// imported with a URL icon column that predates the sharp pipeline) only have -// the original PNG on the subdomain and no sized variants — a direct subdomain -// URL like `-256.png` 404s in that case. The backend endpoint self-heals: -// it can fall back to the original, decode data URLs inline, or serve the -// default placeholder. Mirrors v1's `getAppIconPath`. +// Icon URLs come in a pair. `getAppIconUrl` builds the backend +// `/app-icon//` endpoint URL, which self-heals — it falls back to +// the un-resized original, decodes data URLs inline, or serves the default +// placeholder (mirroring v1's `getAppIconPath`). `getAppIconCdnUrl` builds the +// direct `puter-app-icons` subdomain URL the endpoint would redirect to. +// Clients load the direct one first and keep the endpoint as a fallback: the +// direct URL saves a redirect and survives networks that break on one, while +// the endpoint covers apps that only have the original PNG on the subdomain +// (a direct `-256.png` 404s for those). export const DEFAULT_APP_ICON_SIZE = 256; -// Subdomain where AppIconService publishes generated icons. Mirrors the -// constant in AppIconService; duplicated here to avoid a dependency cycle -// between the util layer and the service layer. +// The sizes AppIconService generates, and so the only ones the endpoint and +// the direct subdomain URLs can serve. +export const APP_ICON_SIZES: readonly number[] = [16, 32, 64, 128, 256, 512]; + +// Subdomain where AppIconService publishes generated icons. const APP_ICONS_SUBDOMAIN = 'puter-app-icons'; // MIME types accepted on the write path for `data:` icon URLs. Anything @@ -55,6 +59,11 @@ interface TrustedIconHostConfig { api_base_url?: string; } +export interface AppIconHostConfig extends TrustedIconHostConfig { + protocol?: string; + pub_port?: number; +} + const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; // `data:/[;param[=value]]…,`. The parameter list is @@ -403,3 +412,47 @@ export function getAppIconUrl( } return `${normalizedApiBaseUrl}/app-icon/${normalizedUid}/${iconSize}`; } + +/** + * Base URL of the subdomain AppIconService publishes generated icons to. Keeps + * the externally-visible port on deployments not served from 80/443. + */ +export function getAppIconsBaseUrl(config: AppIconHostConfig): string | null { + const host = + config.static_hosting_domain ?? config.static_hosting_domain_alt; + if (!host) return null; + const protocol = config.protocol ?? 'https'; + const pubPort = config.pub_port; + const portSuffix = + pubPort && pubPort !== 80 && pubPort !== 443 ? `:${pubPort}` : ''; + return `${protocol}://${APP_ICONS_SUBDOMAIN}.${host}${portSuffix}`; +} + +/** + * Direct subdomain URL for an app's icon — the target `/app-icon//` + * redirects to — or null when the app has nothing published there. + * + * Only rows whose `icon` is already an http(s) URL get one: a `data:` column + * means the resize pipeline hasn't run, so no file exists yet, and the endpoint + * serves those inline anyway. Callers ship this alongside the endpoint URL so + * clients can try it first. + */ +export function getAppIconCdnUrl( + app: Record, + config: AppIconHostConfig, + size?: number, +): string | null { + const appUid = (app.uid ?? app.uuid) as string | undefined; + if (!appUid) return null; + const icon = app.icon; + if (typeof icon !== 'string' || !/^https?:\/\//i.test(icon)) return null; + + const base = getAppIconsBaseUrl(config); + if (!base) return null; + + const normalizedUid = appUid.startsWith('app-') ? appUid : `app-${appUid}`; + const iconSize = Number.isFinite(Number(size)) + ? Number(size) + : DEFAULT_APP_ICON_SIZE; + return `${base}/${normalizedUid}-${iconSize}.png`; +} diff --git a/src/backend/util/taskbarItems.test.ts b/src/backend/util/taskbarItems.test.ts index fdf5b631c..3a3a45cdd 100644 --- a/src/backend/util/taskbarItems.test.ts +++ b/src/backend/util/taskbarItems.test.ts @@ -33,6 +33,7 @@ describe('getTaskbarItems', () => { clients: server.clients, stores: server.stores, apiBaseUrl: 'https://api.puter.com', + config: { static_hosting_domain: 'puter.site' }, } as unknown as Parameters[1]; }); @@ -172,6 +173,37 @@ describe('getTaskbarItems', () => { expect('icon' in withoutIcon[0]).toBe(false); }); + it('pairs the icon URL with the direct subdomain URL when one exists', async () => { + const owner = await makeUser([]); + // An http(s) icon column is what the resize pipeline leaves behind, so + // the sized PNG is on the subdomain. + const app = await makeApp(owner.id, { + icon: 'https://api.puter.com/app-icon/x', + }); + const user = await makeUser([{ name: app.name, type: 'app' }]); + + const items = await getTaskbarItems(user as never, deps, { + iconSize: 64, + }); + expect(items[0].icon).toBe( + `https://api.puter.com/app-icon/${app.uid}/64`, + ); + expect(items[0].iconCdnUrl).toBe( + `https://puter-app-icons.puter.site/${app.uid}-64.png`, + ); + }); + + it('reports no subdomain URL for an app whose icon was never generated', async () => { + const owner = await makeUser([]); + const app = await makeApp(owner.id); + const user = await makeUser([{ name: app.name, type: 'app' }]); + + const items = await getTaskbarItems(user as never, deps, { + iconSize: 64, + }); + expect(items[0].iconCdnUrl).toBeNull(); + }); + it('falls back to the raw icon column when no API base URL is configured', async () => { const owner = await makeUser([]); const app = await makeApp(owner.id, { diff --git a/src/backend/util/taskbarItems.ts b/src/backend/util/taskbarItems.ts index a7bf4b1ec..cc4cd4958 100644 --- a/src/backend/util/taskbarItems.ts +++ b/src/backend/util/taskbarItems.ts @@ -17,7 +17,8 @@ * along with this program. If not, see . */ -import { getAppIconUrl } from './appIcon.js'; +import type { AppIconHostConfig } from './appIcon.js'; +import { getAppIconCdnUrl, getAppIconUrl } from './appIcon.js'; interface TaskbarEntry { name?: string; @@ -33,6 +34,7 @@ interface TaskbarOptions { interface TaskbarDeps { apiBaseUrl?: string; + config?: AppIconHostConfig; clients: { db: { write: (query: string, params?: unknown[]) => Promise; @@ -117,6 +119,10 @@ export async function getTaskbarItems( } else { item.icon = getAppIconUrl(app, deps, options.iconSize) ?? app.icon ?? null; + // Direct subdomain URL for the client to try before `icon`. + item.iconCdnUrl = deps.config + ? getAppIconCdnUrl(app, deps.config, options.iconSize) + : null; } items.push(item); diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index ce71729a2..037bc4167 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -7,6 +7,7 @@ import { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js'; import { reconcileAppOrder, serializeAppOrder, mergeSavedOrder, APPS_ORDER_KV_KEY } from './appOrder.js'; import { parseRemovedApps, serializeRemovedApps, REMOVED_APPS_KV_KEY } from './removedApps.js'; import { appTileLink } from './appLink.js'; +import { appIconAttrs } from '../../helpers/appIcon.js'; import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js'; import { APP_GROUPS_KV_KEY, @@ -209,13 +210,13 @@ function buildTileHtml (app) { // installedApps reports icon: null when an app has no icon at all; its // iconUrl would be a wasted fetch, so use the bundled default instead. // Strictly null — launch-list entries carry no icon key (undefined). - const iconUrl = app.icon === null - ? window.icons['app-default.svg'] - : (app.iconUrl || window.icons['app.svg']); + const iconAttrs = app.icon === null + ? `src="${html_encode(window.icons['app-default.svg'])}"` + : appIconAttrs(app, window.icons['app.svg']); let h = `
`; h += '
'; - h += ``; + h += ``; // iOS-style uninstall badge; only shown while reorder mode is on (CSS). // tabindex=-1 keeps it out of the grid's roving-tabindex tab order. if ( ! APP_NAMES_NO_UNINSTALL.has((app.name || '').toLowerCase()) ) { @@ -292,10 +293,10 @@ function buildGroupTileHtml (group, apps) { h += '
'; h += '
'; for ( const app of shown ) { - const iconUrl = app.icon === null - ? window.icons['app-default.svg'] - : (app.iconUrl || window.icons['app.svg']); - h += ``; + const iconAttrs = app.icon === null + ? `src="${html_encode(window.icons['app-default.svg'])}"` + : appIconAttrs(app, window.icons['app.svg']); + h += ``; } h += '
'; h += '
'; @@ -3158,6 +3159,7 @@ const TabApps = { index_url: app.index_url || null, external: app.external ?? false, iconUrl: app.iconUrl || app.icon || null, + iconCdnUrl: app.iconCdnUrl || null, })); // Build seen set from launch apps @@ -3319,6 +3321,7 @@ const TabApps = { index_url: info.index_url || null, external: false, iconUrl: info.icon || null, + iconCdnUrl: info.iconCdnUrl || null, }; if ( ! this._pendingInstalls ) this._pendingInstalls = new Map(); this._pendingInstalls.set(appName, app); diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index 55ae12371..1cb2d1777 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -20,6 +20,7 @@ import UIWindowSaveAccount from '../UIWindowSaveAccount.js'; import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js'; import { usageBudget } from './usageBudget.js'; +import { appIconAttrs } from '../../helpers/appIcon.js'; // How long a completed usage load stays fresh enough to skip a repeat. Long // enough to absorb the init/onActivate/routing burst on a single dashboard @@ -50,7 +51,7 @@ function buildRecentAppsHTML() { h += `
`; // Icon - h += ``; + h += ``; // Title h += `${html_encode(app_info.title)}`; h += '
'; diff --git a/src/gui/src/UI/UITaskbar.js b/src/gui/src/UI/UITaskbar.js index 0ba839003..9d5b69f27 100644 --- a/src/gui/src/UI/UITaskbar.js +++ b/src/gui/src/UI/UITaskbar.js @@ -21,6 +21,7 @@ import UITaskbarItem from './UITaskbarItem.js'; import UIPopover from './UIPopover.js'; import launch_app from '../helpers/launchApp.js'; import UIContextMenu from './UIContextMenu.js'; +import { appIconAttrs, appIconSrc } from '../helpers/appIcon.js'; async function UITaskbar (options) { window.global_element_id++; @@ -139,8 +140,9 @@ async function UITaskbar (options) { for ( let index = 0; index < window.launch_recent_apps_count && index < window.launch_apps.recent.length; index++ ) { const app_info = window.launch_apps.recent[index]; apps_str += `
`; - apps_str += `
`; - apps_str += ``; + const icon = appIconSrc(app_info); + apps_str += `
`; + apps_str += ``; apps_str += `${html_encode(app_info.title)}`; apps_str += '
'; apps_str += '
'; @@ -158,8 +160,9 @@ async function UITaskbar (options) { for ( let index = 0; index < window.launch_apps.recommended.length; index++ ) { const app_info = window.launch_apps.recommended[index]; apps_str += `
`; - apps_str += `
`; - apps_str += ``; + const icon = appIconSrc(app_info); + apps_str += `
`; + apps_str += ``; apps_str += `${html_encode(app_info.title)}`; apps_str += '
'; apps_str += '
'; @@ -266,6 +269,7 @@ async function UITaskbar (options) { // No taskbar item yet: create a new pinned one UITaskbarItem({ icon: e.currentTarget.dataset.appIcon, + iconFallback: e.currentTarget.dataset.appIconFallback, app: e.currentTarget.dataset.appName, name: e.currentTarget.dataset.appTitle, keep_in_taskbar: true, @@ -351,9 +355,11 @@ async function UITaskbar (options) { if ( window.user.taskbar_items && window.user.taskbar_items.length > 0 ) { for ( let index = 0; index < window.user.taskbar_items.length; index++ ) { const app_info = window.user.taskbar_items[index]; + const icon = appIconSrc(app_info); // add taskbar item for each app UITaskbarItem({ - icon: app_info.icon, + icon: icon.src, + iconFallback: icon.fallback, app: app_info.name, name: app_info.title, keep_in_taskbar: true, @@ -456,6 +462,7 @@ window.make_taskbar_sortable = function () { let item = UITaskbarItem({ icon: $(ui.item).attr('data-app-icon'), + iconFallback: $(ui.item).attr('data-app-icon-fallback'), app: $(ui.item).attr('data-app-name'), name: $(ui.item).attr('data-app-title'), append_to_taskbar: false, diff --git a/src/gui/src/UI/UITaskbarItem.js b/src/gui/src/UI/UITaskbarItem.js index 2b1c8a9d1..14c6aa913 100644 --- a/src/gui/src/UI/UITaskbarItem.js +++ b/src/gui/src/UI/UITaskbarItem.js @@ -21,6 +21,7 @@ import UIContextMenu from './UIContextMenu.js'; import path from '../lib/path.js'; import launch_app from '../helpers/launchApp.js'; import { user_facing_windows } from '../helpers/windowVisibility.js'; +import { appIconFallbackAttr } from '../helpers/appIcon.js'; let tray_item_id = 1; @@ -53,16 +54,20 @@ function UITaskbarItem (options) { style= "${options.style ? html_encode(options.style) : ''}" >`; let icon = options.icon ? options.icon : window.icons['app.svg']; + // App icons carry a retry URL (see helpers/appIcon.js); the bundled icons + // below never need one. + let icon_fallback = options.iconFallback ?? ''; if ( options.app === 'explorer' ) { icon = window.icons['folders.svg']; + icon_fallback = ''; } // taskbar icon h += '
'; // Don't add img tag for separator if ( options.app !== 'separator' ) { - h += ``; + h += ``; } h += '
'; diff --git a/src/gui/src/helpers/appIcon.js b/src/gui/src/helpers/appIcon.js new file mode 100644 index 000000000..738c7fdb5 --- /dev/null +++ b/src/gui/src/helpers/appIcon.js @@ -0,0 +1,106 @@ +/* + * 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 . + */ + +// App icons come from the backend as two URLs: `iconCdnUrl` points straight at +// the icons hosting subdomain, and `icon`/`iconUrl` at the API's /app-icon +// endpoint, which redirects there. We load the direct URL and keep the +// endpoint as a retry — the direct one skips a redirect some networks mangle, +// the endpoint covers apps whose sized variant was never generated (it falls +// back to the original, or serves the icon inline). + +const FALLBACK_ATTR = 'data-icon-fallback'; + +/** + * Pick the URL to load an app's icon from, plus the one URL to retry with. + * + * @param {Object} app - App record from the backend (taskbar item, launch app, + * installedApps row). Reads `iconCdnUrl` and `iconUrl`/`icon`. + * @param {string} [defaultIcon] - Used when the app carries no icon at all. + * @returns {{ src: string; fallback: string }} `fallback` is empty when there + * is nothing left to try. + */ +export function appIconSrc(app, defaultIcon = '') { + const cdn = typeof app?.iconCdnUrl === 'string' ? app.iconCdnUrl : ''; + const endpoint = + (typeof app?.iconUrl === 'string' ? app.iconUrl : '') || + (typeof app?.icon === 'string' ? app.icon : ''); + + if (cdn && endpoint && cdn !== endpoint) { + return { src: cdn, fallback: endpoint }; + } + return { src: cdn || endpoint || defaultIcon, fallback: '' }; +} + +/** + * The attribute that marks an `` as retryable, ready to interpolate into + * an HTML string (empty when there is no retry URL). + * + * @param {string} fallback + * @returns {string} + */ +export function appIconFallbackAttr(fallback) { + return fallback ? ` ${FALLBACK_ATTR}="${html_encode(fallback)}"` : ''; +} + +/** + * `src` and the retry attribute in one go, for the common `` case. + * + * @param {Object} app + * @param {string} [defaultIcon] + * @returns {string} + */ +export function appIconAttrs(app, defaultIcon = '') { + const { src, fallback } = appIconSrc(app, defaultIcon); + return `src="${html_encode(src)}"${appIconFallbackAttr(fallback)}`; +} + +/** + * Retry a failed app-icon `` against its fallback URL, once. Returns + * whether a retry was made. + * + * @param {HTMLImageElement} img + * @returns {boolean} + */ +export function applyAppIconFallback(img) { + const next = img?.dataset?.iconFallback; + if (!next) return false; + // One shot: a fallback that fails too must not loop. + delete img.dataset.iconFallback; + img.src = next; + return true; +} + +/** + * Wire the retry for every app icon on the page. Called once at GUI init; + * `error` doesn't bubble, hence the capture phase. + * + * @param {Document | Element} [root] + */ +export function installAppIconFallback(root = document) { + root.addEventListener( + 'error', + (event) => { + const el = event.target; + if (!el || el.tagName !== 'IMG') return; + applyAppIconFallback(el); + }, + true, + ); +} diff --git a/src/gui/src/helpers/appIcon.test.js b/src/gui/src/helpers/appIcon.test.js new file mode 100644 index 000000000..66319355e --- /dev/null +++ b/src/gui/src/helpers/appIcon.test.js @@ -0,0 +1,84 @@ +import { describe, expect, it, beforeAll } from 'vitest'; +import { + appIconAttrs, + appIconFallbackAttr, + appIconSrc, + applyAppIconFallback, +} from './appIcon.js'; + +beforeAll(() => { + globalThis.html_encode = (str) => + String(str ?? '').replace(/&/g, '&').replace(/"/g, '"'); +}); + +const CDN = 'https://puter-app-icons.puter.site/app-1-256.png'; +const ENDPOINT = 'https://api.puter.com/app-icon/app-1'; + +describe('appIconSrc', () => { + it('loads the subdomain URL first and keeps the endpoint as a retry', () => { + expect(appIconSrc({ iconCdnUrl: CDN, icon: ENDPOINT })).toEqual({ + src: CDN, + fallback: ENDPOINT, + }); + expect(appIconSrc({ iconCdnUrl: CDN, iconUrl: ENDPOINT })).toEqual({ + src: CDN, + fallback: ENDPOINT, + }); + }); + + it('has nothing to retry when the backend sent one URL', () => { + expect(appIconSrc({ icon: ENDPOINT })).toEqual({ + src: ENDPOINT, + fallback: '', + }); + expect(appIconSrc({ iconCdnUrl: CDN })).toEqual({ + src: CDN, + fallback: '', + }); + expect(appIconSrc({ iconCdnUrl: CDN, icon: CDN })).toEqual({ + src: CDN, + fallback: '', + }); + }); + + it('uses the bundled default for an app with no icon', () => { + expect(appIconSrc({}, 'default.svg').src).toBe('default.svg'); + expect(appIconSrc(null, 'default.svg').src).toBe('default.svg'); + expect(appIconSrc({ icon: null, iconCdnUrl: null }).src).toBe(''); + }); +}); + +describe('appIconFallbackAttr / appIconAttrs', () => { + it('emits the retry attribute only when there is a retry URL', () => { + expect(appIconFallbackAttr(ENDPOINT)).toBe( + ` data-icon-fallback="${ENDPOINT}"`, + ); + expect(appIconFallbackAttr('')).toBe(''); + }); + + it('encodes both URLs into img attributes', () => { + expect(appIconAttrs({ iconCdnUrl: CDN, icon: ENDPOINT })).toBe( + `src="${CDN}" data-icon-fallback="${ENDPOINT}"`, + ); + expect(appIconAttrs({ icon: 'a"b' })).toBe('src="a"b"'); + }); +}); + +describe('applyAppIconFallback', () => { + it('swaps in the retry URL once', () => { + const img = { dataset: { iconFallback: ENDPOINT }, src: CDN }; + expect(applyAppIconFallback(img)).toBe(true); + expect(img.src).toBe(ENDPOINT); + + // A failing retry must not loop back onto itself. + expect(applyAppIconFallback(img)).toBe(false); + expect(img.src).toBe(ENDPOINT); + }); + + it('leaves images without a retry URL alone', () => { + const img = { dataset: {}, src: CDN }; + expect(applyAppIconFallback(img)).toBe(false); + expect(img.src).toBe(CDN); + expect(applyAppIconFallback(undefined)).toBe(false); + }); +}); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index d9745161b..236eeb6f0 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -54,6 +54,7 @@ import { import init_device_signals from './helpers/deviceSignals.js'; import { holdsPermissions } from './helpers/holdsPermissions.js'; import item_icon from './helpers/itemIcon.js'; +import { installAppIconFallback } from './helpers/appIcon.js'; import launch_app from './helpers/launchApp.js'; import { parse_url_paths } from './helpers/urlPaths.js'; import update_last_touch_coordinates from './helpers/updateLastTouchCoordinates.js'; @@ -1189,6 +1190,10 @@ window.initgui = async function (options) { // dispatch id needs gui_params.preludeSdkKey. init_device_signals(); + // Retry app icons that fail on the icons subdomain against the API + // endpoint. One listener for the whole GUI, desktop and dashboard alike. + installAppIconFallback(); + let picked_a_user_for_sdk_login = false; // update SDK if auth_token is different from the one in the SDK