feat: let extensions customize recommended apps
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

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
+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));
}