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.
This commit is contained in:
Daniel Salazar
2026-09-03 13:32:55 -07:00
committed by GitHub
parent d855315db8
commit 799fc4ac3c
21 changed files with 462 additions and 51 deletions
+40 -1
View File
@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
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);
+6 -1
View File
@@ -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,
};
}),
+5 -3
View File
@@ -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<string, unknown> } | undefined;
| { offering?: Record<string, unknown> }
| undefined;
if (subscription?.offering) {
delete subscription.offering.group;
delete subscription.offering.benefits;
@@ -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;
}
@@ -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) {
@@ -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<string, unknown>,
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<string, unknown>) => {
const { res, captured } = makeRes();
await routeHandler('get', '/get-launch-apps')(
makeReq({ actor, query }),
res,
(() => {}) as never,
);
const body = captured.body as {
recent: Array<Record<string, unknown>>;
};
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')(
@@ -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,
+3 -13
View File
@@ -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()
@@ -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<Record<string, unknown>> = [];
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<string, unknown>,
apiBaseUrl: string | undefined,
config: AppIconHostConfig,
): Record<string, unknown> {
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,
@@ -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<string, unknown>,
apiBaseUrl: string | undefined,
config: AppIconHostConfig,
): Record<string, unknown> {
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,
Binary file not shown.
+63 -10
View File
@@ -17,19 +17,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Always routes through the backend `/app-icon/<uid>/<size>` 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 `<uid>-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/<uid>/<size>` 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 `<uid>-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:<type>/<subtype>[;param[=value]]…,<payload>`. 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/<uid>/<size>`
* 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<string, unknown>,
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`;
}
+32
View File
@@ -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<typeof getTaskbarItems>[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, {
+7 -1
View File
@@ -17,7 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<unknown>;
@@ -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);
+11 -8
View File
@@ -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 = `<div class="myapps-tile" role="button" tabindex="-1" data-app-name="${html_encode(app.name)}" data-app-title="${html_encode(title)}" data-app-uid="${html_encode(app.uid || '')}" data-target-link="${html_encode(targetLink)}" title="${html_encode(title)}">`;
h += '<div class="myapps-tile-icon">';
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
h += `<img ${iconAttrs} alt="" draggable="false">`;
// 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 += '<div class="myapps-tile-icon myapps-group-icon">';
h += '<div class="myapps-group-icon-grid">';
for ( const app of shown ) {
const iconUrl = app.icon === null
? window.icons['app-default.svg']
: (app.iconUrl || window.icons['app.svg']);
h += `<img src="${html_encode(iconUrl)}" alt="" draggable="false">`;
const iconAttrs = app.icon === null
? `src="${html_encode(window.icons['app-default.svg'])}"`
: appIconAttrs(app, window.icons['app.svg']);
h += `<img ${iconAttrs} alt="" draggable="false">`;
}
h += '</div>';
h += '</div>';
@@ -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);
+2 -1
View File
@@ -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 += `<div class="bento-recent-app" data-app-name="${html_encode(app_info.name)}" data-target-link="${html_encode(app_info.target_link)}">`;
// Icon
h += `<img class="bento-recent-app-icon" src="${html_encode(app_info.icon || window.icons['app.svg'])}">`;
h += `<img class="bento-recent-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
// Title
h += `<span class="bento-recent-app-title">${html_encode(app_info.title)}</span>`;
h += '</div>';
+12 -5
View File
@@ -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 += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(app_info.icon)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" src="${html_encode(app_info.icon ? app_info.icon : window.icons['app.svg'])}">`;
const icon = appIconSrc(app_info);
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
apps_str += '</div>';
apps_str += '</div>';
@@ -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 += `<div title="${html_encode(app_info.title)}" data-name="${html_encode(app_info.name)}" class="start-app-card">`;
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(app_info.icon)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" src="${html_encode(app_info.icon ? app_info.icon : window.icons['app.svg'])}">`;
const icon = appIconSrc(app_info);
apps_str += `<div class="start-app" data-app-name="${html_encode(app_info.name)}" data-app-uuid="${html_encode(app_info.uuid)}" data-app-icon="${html_encode(icon.src)}" data-app-icon-fallback="${html_encode(icon.fallback)}" data-app-title="${html_encode(app_info.title)}">`;
apps_str += `<img class="start-app-icon" ${appIconAttrs(app_info, window.icons['app.svg'])}>`;
apps_str += `<span class="start-app-title">${html_encode(app_info.title)}</span>`;
apps_str += '</div>';
apps_str += '</div>';
@@ -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,
+6 -1
View File
@@ -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 += '<div class="taskbar-icon">';
// Don't add img tag for separator
if ( options.app !== 'separator' ) {
h += `<img src="${html_encode(icon)}" style="${options.group === 'apps' ? 'filter:none;' : ''}">`;
h += `<img src="${html_encode(icon)}"${appIconFallbackAttr(icon_fallback)} style="${options.group === 'apps' ? 'filter:none;' : ''}">`;
}
h += '</div>';
+106
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
// 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 `<img>` 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 `<img
* ${appIconAttrs(app, def)}>` 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 `<img>` 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,
);
}
+84
View File
@@ -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, '&amp;').replace(/"/g, '&quot;');
});
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&quot;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);
});
});
+5
View File
@@ -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