mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-09 06:45:50 +00:00
feat: let extensions customize recommended apps
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user