diff --git a/src/backend/services/apps/SuggestedAppsService.test.ts b/src/backend/services/apps/SuggestedAppsService.test.ts index 02d56d52d..82fd83216 100644 --- a/src/backend/services/apps/SuggestedAppsService.test.ts +++ b/src/backend/services/apps/SuggestedAppsService.test.ts @@ -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. diff --git a/src/backend/services/apps/SuggestedAppsService.ts b/src/backend/services/apps/SuggestedAppsService.ts index c52064501..67796abd6 100644 --- a/src/backend/services/apps/SuggestedAppsService.ts +++ b/src/backend/services/apps/SuggestedAppsService.ts @@ -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>> { - const builtinNames = suggestionsForExtension(ext); - - const seen = new Set(); - const candidates: Array> = []; + 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(); + const candidates: Array> = []; + 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