From 8a6daee9abaee1e8709c73505c57fa791e35e8f6 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Mon, 7 Sep 2026 16:24:02 -0700 Subject: [PATCH] feat: let extensions customize recommended apps --- doc/architecture.md | 12 ++ src/backend/clients/event/types.ts | 2 + .../apps/RecommendedAppsService.test.ts | 116 ++++++++++++++++++ .../services/apps/RecommendedAppsService.ts | 5 +- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src/backend/services/apps/RecommendedAppsService.test.ts diff --git a/doc/architecture.md b/doc/architecture.md index 7e15b76d6..6a864f8cc 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -96,6 +96,18 @@ extension.on('user.signup', (_key, data) => { }); ``` +The `app.recommended` event lets extensions customize desktop recommendations. +Its `appNames` array starts as a fresh copy of the default ordered names on each +call. Listeners may mutate or replace it, including setting it to an empty array. +Async listeners are awaited before names are resolved to app summaries; names +that do not exist are skipped. + +```ts +extension.on('app.recommended', (_key, data) => { + data.appNames = ['editor', 'camera']; +}); +``` + ## Conventions - **TypeScript preferred** in new code where feasible. Existing JS is fine; convert opportunistically when you're already touching a file. diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 93300d655..d4540a5dc 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -119,6 +119,8 @@ export type EventMap = { }; // ---- Apps ---- + /** Awaited before lookup; listeners may mutate or replace appNames. */ + 'app.recommended': { appNames: string[] }; 'app.changed': { app_uid: string; action: string; diff --git a/src/backend/services/apps/RecommendedAppsService.test.ts b/src/backend/services/apps/RecommendedAppsService.test.ts new file mode 100644 index 000000000..2535bd88b --- /dev/null +++ b/src/backend/services/apps/RecommendedAppsService.test.ts @@ -0,0 +1,116 @@ +import { randomUUID } from 'node:crypto'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { extension, extensionStore } from '../../extensions.js'; +import type { PuterServer } from '../../server.js'; +import { setupTestServer } from '../../testUtil.js'; + +describe('RecommendedAppsService', () => { + let server: PuterServer; + let customApp: Record; + let defaults: Array>; + const previousListeners = extensionStore.events['app.recommended']; + + beforeAll(async () => { + server = await setupTestServer(); + const user = await server.stores.user.create({ + username: 'recommendations-test', + uuid: randomUUID(), + password: null, + email: 'recommendations@test.local', + free_storage: 1024, + requires_email_confirmation: false, + }); + for (const name of ['editor', 'camera', 'custom-recommendation']) { + if (!(await server.stores.app.getByName(name))) { + await server.stores.app.create( + { + name, + title: name, + index_url: `https://${name}.example.com/`, + }, + { ownerUserId: user.id }, + ); + } + } + customApp = await server.stores.app.getByName('custom-recommendation'); + defaults = await server.services.recommendedApps.getRecommendedApps(); + }); + + afterEach(() => { + if (previousListeners) { + extensionStore.events['app.recommended'] = previousListeners; + } else { + delete extensionStore.events['app.recommended']; + } + }); + + afterAll(async () => { + await server?.shutdown(); + }); + + it('preserves default ordering without listeners', async () => { + const apps = await server.services.recommendedApps.getRecommendedApps(); + expect(apps).toEqual(defaults); + const names = apps.map((app) => app.name); + expect(names).toContain('editor'); + expect(names.indexOf('editor')).toBeLessThan(names.indexOf('camera')); + expect(names).not.toContain(customApp.name); + }); + + it('awaits extensions that replace the list and skips missing apps', async () => { + extension.on('app.recommended', async (_key, data) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + data.appNames = [ + 'camera', + 'custom-recommendation', + 'missing-recommendation', + 'editor', + ]; + }); + + const apps = await server.services.recommendedApps.getRecommendedApps(); + expect(apps.map((app) => app.name)).toEqual([ + 'camera', + 'custom-recommendation', + 'editor', + ]); + expect(apps[1]).toMatchObject({ + uuid: customApp.uid, + title: customApp.title, + index_url: customApp.index_url, + godmode: false, + maximize_on_start: false, + feedback_enabled: false, + external: false, + }); + }); + + it('isolates in-place mutations from subsequent calls', async () => { + let calls = 0; + extension.on('app.recommended', (_key, data) => { + if (calls++ === 0) { + data.appNames.splice( + 0, + data.appNames.length, + 'custom-recommendation', + ); + } + }); + + const apps = await server.services.recommendedApps.getRecommendedApps(); + expect(apps.map((app) => app.name)).toEqual(['custom-recommendation']); + expect( + await server.services.recommendedApps.getRecommendedApps(), + ).toEqual(defaults); + }); + + it('allows extensions to clear the list', async () => { + extension.on('app.recommended', (_key, data) => { + data.appNames = []; + }); + + expect( + await server.services.recommendedApps.getRecommendedApps(), + ).toEqual([]); + }); +}); diff --git a/src/backend/services/apps/RecommendedAppsService.ts b/src/backend/services/apps/RecommendedAppsService.ts index a12dcff8e..603b6c681 100644 --- a/src/backend/services/apps/RecommendedAppsService.ts +++ b/src/backend/services/apps/RecommendedAppsService.ts @@ -59,9 +59,12 @@ const RECOMMENDED_APP_NAMES = [ export class RecommendedAppsService extends PuterService { async getRecommendedApps(): Promise>> { + const event = { appNames: [...RECOMMENDED_APP_NAMES] }; + await this.clients.event.emitAndWait('app.recommended', event, {}); + const apiBaseUrl = this.config.api_base_url as string | undefined; const results: Array> = []; - for (const name of RECOMMENDED_APP_NAMES) { + for (const name of event.appNames) { const app = await this.stores.app.getByName(name); if (app) results.push(toAppSummary(app, apiBaseUrl, this.config)); }