Files
puter/src/backend/services/apps/SuggestedAppsService.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

349 lines
11 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 { posix as pathPosix } from 'node:path';
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';
// -- Extension → suggested app names mapping -------------------------
//
// Each extension maps to an ordered list of built-in app names that
// can open files of that type.
const CODE_EXTS = new Set([
'js',
'jsx',
'ts',
'tsx',
'json',
'json5',
'jsonl',
'css',
'scss',
'sass',
'less',
'html',
'htm',
'xhtml',
'xml',
'svg',
'yaml',
'yml',
'toml',
'ini',
'conf',
'cfg',
'env',
'sh',
'bash',
'zsh',
'fish',
'bat',
'cmd',
'ps1',
'py',
'pyw',
'rb',
'php',
'pl',
'pm',
'lua',
'java',
'kt',
'kts',
'scala',
'groovy',
'go',
'rs',
'c',
'h',
'cpp',
'hpp',
'cc',
'cxx',
'cs',
'swift',
'r',
'jl',
'ex',
'exs',
'erl',
'hrl',
'clj',
'cljs',
'hs',
'ml',
'mli',
'fs',
'fsi',
'fsx',
'dart',
'sql',
'graphql',
'gql',
'proto',
'makefile',
'cmake',
'dockerfile',
'tf',
'hcl',
'nix',
'vim',
'el',
'lisp',
'rkt',
'scm',
'asm',
's',
'wasm',
'wat',
'v',
'vhd',
'vhdl',
'tcl',
]);
const IMAGE_EXTS = new Set([
'jpg',
'jpeg',
'png',
'gif',
'webp',
'svg',
'bmp',
'ico',
'tiff',
'tif',
]);
const MEDIA_EXTS = new Set([
'mp4',
'webm',
'mpg',
'mpeg',
'avi',
'mov',
'mkv',
'mp3',
'm4a',
'ogg',
'wav',
'flac',
'aac',
]);
function suggestionsForExtension(ext: string): {
names: string[];
isFallback: boolean;
} {
const lower = ext.toLowerCase();
if (CODE_EXTS.has(lower)) {
return { names: ['code', 'editor'], isFallback: false };
}
if (lower === 'txt' || lower === 'md' || lower === '') {
return { names: ['editor', 'code'], isFallback: false };
}
if (IMAGE_EXTS.has(lower)) {
return { names: ['viewer', 'draw'], isFallback: false };
}
if (lower === 'pdf') return { names: ['pdf'], isFallback: false };
if (MEDIA_EXTS.has(lower)) return { names: ['player'], isFallback: false };
// Unknown extension — editor is a last-resort guess, not a mapping.
// Callers rank it below apps that explicitly registered the extension.
return { names: ['editor'], isFallback: true };
}
// In-memory cache TTL. Apps rarely change, and the worst-case on staleness
// is a few minutes before a new filetype association surfaces — not worth a
// Redis round-trip per lookup on a hot path (readdir fans out per-child).
const SUGGESTION_CACHE_TTL_MS = 5 * 60 * 1000;
type SuggestionsEntry = {
promise: Promise<Array<Record<string, unknown>>>;
expiresAt: number;
};
function extractExtension(entry: { name?: string; path?: string }): string {
const name =
entry.name ?? (entry.path ? pathPosix.basename(entry.path) : '');
return pathPosix.extname(name).replace(/^\./, '').toLowerCase();
}
/**
* Given a file entry (path, name, or extension), returns an ordered list of
* apps that can open it. Built-in apps come from the hardcoded map above;
* third-party apps come from the `app_filetype_association` table.
*
* Lookups cache per-extension (plus a separate per-app-name cache for the small
* set of built-in opener apps), so a `readdir` with N children of the same type
* pays the DB cost once.
*/
export class SuggestedAppsService extends PuterService {
// Keyed by the normalized extension (lowercase, no leading dot). The
// cached value is the promise — in-flight lookups coalesce, and the
// same promise is reused for every entry that shares an extension.
#extensionCache = new Map<string, SuggestionsEntry>();
async getSuggestedApps(entry: {
name?: string;
path?: string;
}): Promise<Array<Record<string, unknown>>> {
return this.#getByExtension(extractExtension(entry));
}
/**
* Resolve suggestions for many entries in one pass. Entries that share an
* extension are deduped to a single underlying lookup; results are returned
* positionally so callers can `entries[i].suggestedApps = out[i]`.
*/
async getSuggestedAppsForEntries(
entries: Array<{ name?: string; path?: string }>,
): Promise<Array<Array<Record<string, unknown>>>> {
if (entries.length === 0) return [];
const extensions = entries.map(extractExtension);
const uniqueExtensions = Array.from(new Set(extensions));
const resultByExt = new Map<string, Array<Record<string, unknown>>>();
await Promise.all(
uniqueExtensions.map(async (ext) => {
resultByExt.set(ext, await this.#getByExtension(ext));
}),
);
return extensions.map((ext) => resultByExt.get(ext) ?? []);
}
#getByExtension(ext: string): Promise<Array<Record<string, unknown>>> {
const now = Date.now();
const cached = this.#extensionCache.get(ext);
if (cached && cached.expiresAt > now) {
return cached.promise;
}
const promise = this.#resolveForExtension(ext).catch((error) => {
// Failure must not poison the cache — drop the entry so the
// next caller retries.
if (this.#extensionCache.get(ext)?.promise === promise) {
this.#extensionCache.delete(ext);
}
throw error;
});
this.#extensionCache.set(ext, {
promise,
expiresAt: now + SUGGESTION_CACHE_TTL_MS,
});
return promise;
}
async #resolveForExtension(
ext: string,
): Promise<Array<Record<string, unknown>>> {
const { names: builtinNames, isFallback } =
suggestionsForExtension(ext);
const apiBaseUrl = this.config.api_base_url as string | undefined;
// Built-in apps, looked up by their stable app name. Parallel-safe
// because order is imposed below via `builtinNames`.
const builtinApps = await Promise.all(
builtinNames.map((appName) => this.stores.app.getByName(appName)),
);
const thirdPartyApps = ext
? (await this.stores.app.getAppsByFiletype(ext)).filter(
(app) => app.approved_for_opening_items,
)
: [];
// Order decides the default opener: `suggested[0]` feeds the GUI's
// double-click path and `/open_item`. Intentionally mapped built-ins
// keep the head slot, but the unknown-extension `editor` fallback is
// only a guess — an app that explicitly registered the extension
// outranks it (a .docx should open in a word processor that claimed
// it, not in the plain-text editor).
const ordered = isFallback
? [...thirdPartyApps, ...builtinApps]
: [...builtinApps, ...thirdPartyApps];
const seen = new Set<number>();
const candidates: Array<Record<string, unknown>> = [];
for (const app of ordered) {
if (!app || seen.has(app.id)) continue;
seen.add(app.id);
candidates.push(app);
}
// Drop apps whose puter-hosted backing is gone or has been reclaimed
// by another user. These summaries feed the GUI's default-open path,
// which launches straight from `app_obj` without re-reading the app
// through AppDriver — so its hosted-backing guard never runs and the
// launcher would append `puter.auth.token` to a subdomain the app
// owner no longer controls. `/open_item` also mints a user-app token
// (and grants `fs:<uuid>:write`) for `suggested[0]`, so an
// unlaunchable app must not reach the head of this list either.
//
// Built-ins never hit the DB here: their index_urls aren't on a
// hosting domain, so the check short-circuits on the URL alone.
const availability = await Promise.all(
candidates.map((app) =>
hostedIndexUrlBackingIsUnavailable({
app,
subdomainStore: this.stores.subdomain,
config: this.config,
}).catch(() => {
// A subdomain lookup failure must not silently widen the
// guard into "suggest nothing" — but it must not open it
// either. Treat the backing as unavailable: the app is
// unlaunchable for this window, not deleted.
return true;
}),
),
);
return candidates
.filter((_app, index) => !availability[index])
.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,
// The GUI launches straight from this summary as `app_obj` (no
// re-read through AppDriver), so any launch-relevant flag omitted
// here silently disappears on those paths — e.g. the dashboard
// drawer's feedback control renders off this.
feedback_enabled: Boolean(app.feedback_enabled),
};
}