Suggested apps: rank registered apps above the editor fallback

For extensions with no intentional built-in mapping (doc, docx, and
every other unmapped type), suggestionsForExtension fell back to
['editor'], and #resolveForExtension always placed built-ins ahead of
apps from app_filetype_association. Since suggested[0] drives the GUI's
double-click open path and /open_item, a .docx defaulted to opening as
plain text in editor even when a word processor explicitly registered
the extension.

Tag the unknown-extension result as a fallback and order third-party
filetype-association apps ahead of it. Intentional mappings (code, txt,
md, images, pdf, media) keep built-ins in the head slot as before, and
the editor guess still appears as a last-resort option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jelveh
2026-07-30 13:51:01 -07:00
co-authored by Claude Fable 5
parent b28b41c90b
commit 2c9ae3489e
2 changed files with 82 additions and 29 deletions
@@ -333,6 +333,42 @@ describe('SuggestedAppsService hosted-backing guard', () => {
expect(suggested.find((a) => a.name === name)).toBeDefined();
});
it('ranks a registered app ahead of the editor fallback for unknown extensions', async () => {
// For extensions with no intentional built-in mapping, `editor` is
// only a guess — and `suggested[0]` is what double-click and
// `/open_item` launch. An app that explicitly registered the
// extension must take the head slot or binary files open as
// plain text.
const { userId } = await makeUser();
const ext = uniqueName('ext10').replace(/-/g, '');
await pointBuiltinAt('editor', userId, 'https://editor.example.com/');
const name = await makeOpenerApp({
userId,
indexUrl: 'https://dev-owned-domain.example/',
ext,
});
const names = (await suggestFor(ext)).map((a) => a.name);
expect(names[0]).toBe(name);
expect(names).toContain('editor');
});
it('keeps built-ins first for intentionally mapped extensions', async () => {
// `.txt` → editor is a deliberate mapping, not the fallback guess;
// a third-party association must not displace it.
const { userId } = await makeUser();
await pointBuiltinAt('editor', userId, 'https://editor.example.com/');
const name = await makeOpenerApp({
userId,
indexUrl: 'https://dev-owned-domain.example/',
ext: 'txt',
});
const names = (await suggestFor('txt')).map((a) => a.name);
expect(names[0]).toBe('editor');
expect(names).toContain(name);
});
it('does not hit the subdomain store for non-hosted index_urls', async () => {
// Built-ins and apps on a developer's own domain aren't on a
// hosting domain, so the guard short-circuits on the URL alone.
@@ -149,16 +149,28 @@ const MEDIA_EXTS = new Set([
'aac',
]);
function suggestionsForExtension(ext: string): string[] {
function suggestionsForExtension(ext: string): {
names: string[];
isFallback: boolean;
} {
const lower = ext.toLowerCase();
if (CODE_EXTS.has(lower)) return ['code', 'editor'];
if (lower === 'txt' || lower === '') return ['editor', 'code'];
if (lower === 'md') return ['markus', 'editor', 'code'];
if (IMAGE_EXTS.has(lower)) return ['viewer', 'draw'];
if (lower === 'pdf') return ['pdf'];
if (MEDIA_EXTS.has(lower)) return ['player'];
// Unknown extension — fall back to editor
return ['editor'];
if (CODE_EXTS.has(lower)) {
return { names: ['code', 'editor'], isFallback: false };
}
if (lower === 'txt' || lower === '') {
return { names: ['editor', 'code'], isFallback: false };
}
if (lower === 'md') {
return { names: ['markus', '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
@@ -247,34 +259,39 @@ export class SuggestedAppsService extends PuterService {
async #resolveForExtension(
ext: string,
): Promise<Array<Record<string, unknown>>> {
const builtinNames = suggestionsForExtension(ext);
const seen = new Set<number>();
const candidates: 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 at the end via `builtinNames`.
// because order is imposed below via `builtinNames`.
const builtinApps = await Promise.all(
builtinNames.map((appName) => this.stores.app.getByName(appName)),
);
for (const app of builtinApps) {
if (app && !seen.has(app.id)) {
seen.add(app.id);
candidates.push(app);
}
}
if (ext) {
const thirdParty = await this.stores.app.getAppsByFiletype(ext);
for (const app of thirdParty) {
if (seen.has(app.id)) continue;
if (app.approved_for_opening_items) {
seen.add(app.id);
candidates.push(app);
}
}
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