feat: let extensions customize recommended apps
Maintain Release Merge PR / update-release-pr (push) Waiting to run
Notify HeyPuter / notify (push) Waiting to run
release-please / release-please (push) Waiting to run

This commit is contained in:
Nariman Jelveh
2026-09-07 16:44:41 -07:00
parent e980b131a0
commit 8a6daee9ab
4 changed files with 134 additions and 1 deletions
+12
View File
@@ -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.
+2
View File
@@ -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;
@@ -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<string, unknown>;
let defaults: Array<Record<string, unknown>>;
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([]);
});
});
@@ -59,9 +59,12 @@ const RECOMMENDED_APP_NAMES = [
export class RecommendedAppsService extends PuterService {
async getRecommendedApps(): Promise<Array<Record<string, unknown>>> {
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<Record<string, unknown>> = [];
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));
}