diff --git a/docs/plugins.md b/docs/plugins.md index 9d798afdd..3e01c10e2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -29,6 +29,11 @@ Plugins are administration of the machine that hosts the core, so every `plugins.*` method is owner only and the phone settings leave the page to the desktop app ([phone](phone.md)). +Each core has its own plugins. The page opens on this PC when the window has +one, since a desktop app shows its window there, whichever machine the open +conversation runs on. With several machines connected, a menu beside the +heading names the machine shown and switches to another one this window owns. + ## The manifest Put `boite-plugin.json` at the root of the repository: diff --git a/packages/ui/src/components/PluginsMachine.svelte b/packages/ui/src/components/PluginsMachine.svelte new file mode 100644 index 000000000..22ec79ff5 --- /dev/null +++ b/packages/ui/src/components/PluginsMachine.svelte @@ -0,0 +1,646 @@ + + +{#snippet refusal(rejected: PluginRejected)} + +{/snippet} + +{#snippet autostartSwitch(plugin: PluginState, on: boolean | undefined)} + + {#if on !== undefined} + + {/if} +{/snippet} + +{#snippet row(plugin: PluginState)} + {@const working = busy[plugin.id] === true} + {@const state = stateLine(plugin)} +
+
+ +
+

+ {plugin.name} + {#if plugin.version ?? plugin.availableVersion}v{plugin.version ?? plugin.availableVersion}{/if} + {#if plugin.homepage && !plugin.source} + + {/if} +

+ {#if state} +

+ {state} +

+ {/if} + {#if plugin.description}

{plugin.description}

{/if} +
+
+ {#if plugin.status === 'installing'} + + {:else if plugin.status === 'not-installed'} + + {:else} + {#if plugin.status === 'installed' && plugin.availableVersion && plugin.availableVersion !== plugin.version} + + {/if} + {#if plugin.status === 'error' || (plugin.status === 'rejected' && plugin.origin === 'recommended')} + + {/if} + {#if plugin.updates && plugin.version !== null} + {@const looking = plugin.updates.checking || checking[plugin.id] === true} + + {/if} + + {/if} +
+
+ {#if plugin.status === 'installing'} +
+ +
+ {/if} + {#if plugin.source} +

+ {bare(plugin.source.url)} + {fill(t.atCommit, { ref: plugin.source.ref, commit: short(plugin.source.commit) })} +

+ {/if} + {#if plugin.updates && plugin.version !== null} + {@const line = updatesLine(plugin.updates)} + {#if line} +

{line}

+ {/if} + {/if} + {#if plugin.error}{/if} + {#if errors[plugin.id]}{/if} + {#if plugin.rejected}{@render refusal(plugin.rejected)}{/if} + {#if plugin.status === 'installed' && plugin.app} + {@const app = plugin.app} + {@const live = app.status === 'running' || app.status === 'starting'} +
+
+

+ {t.app}{appLine(app)} +

+
+ {#if live} + + + {:else} + + {/if} +
+
+ {@render autostartSwitch(plugin, app.autostart)} + {#if app.error}

{app.error}

{/if} +
+ {/if} + {#if plugin.status === 'installed' && plugin.gateway} + {@const gateway = plugin.gateway} + {@const live = gateway.status !== 'stopped' && gateway.status !== 'crashed'} + {@const dashboard = gatewayDashboard(gateway)} +
+
+

+ {t.gateway}{gatewayLine(gateway)} + +

+
+ {#if live} + + + {:else} + + {/if} +
+
+ {#if !plugin.app}{@render autostartSwitch(plugin, gateway.autostart)}{/if} + {#if gateway.error}

{gateway.error}

{/if} + {#if gateway.status === 'running' && (dashboard || plugin.id === 'douane')} +
+ {#if dashboard}{/if} + {#if plugin.id === 'douane' && store.core?.features?.gatewayMove === true} + + {/if} +
+ {/if} +
+ {/if} + {#if plugin.status === 'installed' && plugin.pools.length > 0} +
+
+ + +
+ {#each pools[plugin.id] ?? [] as pool (pool.provider)} +
+
+
{providerName(pool.provider)}
+ +
+ {#if pool.accounts.length === 0}

{t.empty}

{/if} + {#each pool.accounts as account (account.email)} + + {/each} +
+ {/each} +
+ {/if} +
+{/snippet} + +
+
+

{t.heading}

+ {@render machineMenu?.()} +
+ {#if error}{/if} + + {#if installed.length > 0} +
+ +
+ {#each installed as plugin (plugin.id)}{@render row(plugin)}{/each} +
+
+ {/if} + + {#if recommended.length > 0} +
+ +
+ {#each recommended as plugin (plugin.id)}{@render row(plugin)}{/each} +
+
+ {/if} + +
+ +
+
+ + + +
+ {#if inspectError}{/if} + {#if preview} +
+ {#if preview.manifest} +
+ +
+

{preview.manifest.name}v{preview.manifest.version}

+

{preview.manifest.description}

+
+
+ {/if} + {#if preview.rejected} + {@render refusal(preview.rejected)} + {/if} +
+
{t.previewSource}
+
{bare(preview.source.url)} {fill(t.atCommit, { ref: preview.source.ref, commit: short(preview.source.commit) })}
+ {#if preview.replaces}
{t.previewReplaces}
v{preview.replaces}
{/if} + {#if preview.rejected === null && preview.artifact} +
{t.previewDownloads}
{preview.artifact.url}
+
{t.previewDigest}
{preview.artifact.sha256}
+
{t.previewRuns}
+
    {#each preview.commands as command (command)}
  • {command}
  • {/each}
+ {#if preview.manifest?.provides.accountPools} +
{t.previewPools}
+
{preview.manifest.provides.accountPools.providers.map(providerName).join(', ')}
+ {/if} + {#if preview.manifest?.provides.desktopApp} +
{t.previewApp}
+
{t.previewAppBody}
+ {/if} + {#if preview.manifest?.provides.gateway} +
{t.previewGateway}
+
{t.previewGatewayBody}
+ {/if} + {/if} +
+ {#if preview.rejected === null} +

{t.trust}

+ {/if} +
+ + {#if preview.previewId && preview.manifest} + + {/if} +
+
+ {/if} +
+
+
+ + { moving = false; }} /> + + diff --git a/packages/ui/src/components/PluginsPage.svelte b/packages/ui/src/components/PluginsPage.svelte index d9aeb6f83..a721fd96f 100644 --- a/packages/ui/src/components/PluginsPage.svelte +++ b/packages/ui/src/components/PluginsPage.svelte @@ -1,639 +1,43 @@ -{#snippet refusal(rejected: PluginRejected)} - -{/snippet} - -{#snippet autostartSwitch(plugin: PluginState, on: boolean | undefined)} - - {#if on !== undefined} - +{#snippet machineMenu()} + {#if machines.length > 1} + { picked = id; }}> + {chosen.label} + {/if} {/snippet} -{#snippet row(plugin: PluginState)} - {@const working = busy[plugin.id] === true} - {@const state = stateLine(plugin)} -
-
- -
-

- {plugin.name} - {#if plugin.version ?? plugin.availableVersion}v{plugin.version ?? plugin.availableVersion}{/if} - {#if plugin.homepage && !plugin.source} - - {/if} -

- {#if state} -

- {state} -

- {/if} - {#if plugin.description}

{plugin.description}

{/if} -
-
- {#if plugin.status === 'installing'} - - {:else if plugin.status === 'not-installed'} - - {:else} - {#if plugin.status === 'installed' && plugin.availableVersion && plugin.availableVersion !== plugin.version} - - {/if} - {#if plugin.status === 'error' || (plugin.status === 'rejected' && plugin.origin === 'recommended')} - - {/if} - {#if plugin.updates && plugin.version !== null} - {@const looking = plugin.updates.checking || checking[plugin.id] === true} - - {/if} - - {/if} -
-
- {#if plugin.status === 'installing'} -
- -
- {/if} - {#if plugin.source} -

- {bare(plugin.source.url)} - {fill(t.atCommit, { ref: plugin.source.ref, commit: short(plugin.source.commit) })} -

- {/if} - {#if plugin.updates && plugin.version !== null} - {@const line = updatesLine(plugin.updates)} - {#if line} -

{line}

- {/if} - {/if} - {#if plugin.error}{/if} - {#if errors[plugin.id]}{/if} - {#if plugin.rejected}{@render refusal(plugin.rejected)}{/if} - {#if plugin.status === 'installed' && plugin.app} - {@const app = plugin.app} - {@const live = app.status === 'running' || app.status === 'starting'} -
-
-

- {t.app}{appLine(app)} -

-
- {#if live} - - - {:else} - - {/if} -
-
- {@render autostartSwitch(plugin, app.autostart)} - {#if app.error}

{app.error}

{/if} -
- {/if} - {#if plugin.status === 'installed' && plugin.gateway} - {@const gateway = plugin.gateway} - {@const live = gateway.status !== 'stopped' && gateway.status !== 'crashed'} - {@const dashboard = gatewayDashboard(gateway)} -
-
-

- {t.gateway}{gatewayLine(gateway)} - -

-
- {#if live} - - - {:else} - - {/if} -
-
- {#if !plugin.app}{@render autostartSwitch(plugin, gateway.autostart)}{/if} - {#if gateway.error}

{gateway.error}

{/if} - {#if gateway.status === 'running' && (dashboard || plugin.id === 'douane')} -
- {#if dashboard}{/if} - {#if plugin.id === 'douane' && store.core?.features?.gatewayMove === true} - - {/if} -
- {/if} -
- {/if} - {#if plugin.status === 'installed' && plugin.pools.length > 0} -
-
- - -
- {#each pools[plugin.id] ?? [] as pool (pool.provider)} -
-
-
{providerName(pool.provider)}
- -
- {#if pool.accounts.length === 0}

{t.empty}

{/if} - {#each pool.accounts as account (account.email)} - - {/each} -
- {/each} -
- {/if} -
-{/snippet} - -
-

{t.heading}

- {#if error}{/if} - - {#if installed.length > 0} -
- -
- {#each installed as plugin (plugin.id)}{@render row(plugin)}{/each} -
-
- {/if} - - {#if recommended.length > 0} -
- -
- {#each recommended as plugin (plugin.id)}{@render row(plugin)}{/each} -
-
- {/if} - -
- -
-
- - - -
- {#if inspectError}{/if} - {#if preview} -
- {#if preview.manifest} -
- -
-

{preview.manifest.name}v{preview.manifest.version}

-

{preview.manifest.description}

-
-
- {/if} - {#if preview.rejected} - {@render refusal(preview.rejected)} - {/if} -
-
{t.previewSource}
-
{bare(preview.source.url)} {fill(t.atCommit, { ref: preview.source.ref, commit: short(preview.source.commit) })}
- {#if preview.replaces}
{t.previewReplaces}
v{preview.replaces}
{/if} - {#if preview.rejected === null && preview.artifact} -
{t.previewDownloads}
{preview.artifact.url}
-
{t.previewDigest}
{preview.artifact.sha256}
-
{t.previewRuns}
-
    {#each preview.commands as command (command)}
  • {command}
  • {/each}
- {#if preview.manifest?.provides.accountPools} -
{t.previewPools}
-
{preview.manifest.provides.accountPools.providers.map(providerName).join(', ')}
- {/if} - {#if preview.manifest?.provides.desktopApp} -
{t.previewApp}
-
{t.previewAppBody}
- {/if} - {#if preview.manifest?.provides.gateway} -
{t.previewGateway}
-
{t.previewGatewayBody}
- {/if} - {/if} -
- {#if preview.rejected === null} -

{t.trust}

- {/if} -
- - {#if preview.previewId && preview.manifest} - - {/if} -
-
- {/if} -
-
-
- - { moving = false; }} /> +{#key chosen.store} + +{/key} diff --git a/packages/ui/src/components/PluginsPage.test.ts b/packages/ui/src/components/PluginsPage.test.ts index c5f711860..f50723ff5 100644 --- a/packages/ui/src/components/PluginsPage.test.ts +++ b/packages/ui/src/components/PluginsPage.test.ts @@ -4,6 +4,7 @@ import type { PluginState } from '@boite/contracts'; import PluginsPage from './PluginsPage.svelte'; import type { Store } from '../lib/store.svelte'; import { FakeClient } from '../lib/fake-client'; +import { workspace } from '../lib/workspace.svelte'; let mounted: ReturnType | undefined; let client: FakeClient | undefined; @@ -269,3 +270,54 @@ test('Douane\'s row shows its gateway on loopback, stops and starts it, and its expect(strip()).not.toBeNull(); ask.mockRestore(); }); + +test('opens on this PC rather than on the server the chat is on, and switches machine from its header', async () => { + const pc = new FakeClient({ delayMs: 0 }); + const server = new FakeClient({ delayMs: 0 }); + await Promise.all([pc.connect(), server.connect()]); + const pcCall = pc.call.bind(pc); + // The PC answers Stop only when the test lets it, after the page has moved to the server. + let answer: () => void = () => {}; + const held = new Promise((resolve) => { answer = resolve; }); + vi.spyOn(pc, 'call').mockImplementation((async (method: string, params: unknown) => { + if (method === 'plugins.app') await held; + return pcCall(method as never, params as never); + }) as never); + const serverList = server.call.bind(server); + // Nothing is installed on the server. + vi.spyOn(server, 'call').mockImplementation((async (method: string, params: unknown) => { + const result = await serverList(method as never, params as never); + return method === 'plugins.list' ? (result as PluginState[]).filter((plugin) => plugin.origin === 'recommended').map((plugin) => ({ ...plugin, version: null, status: 'not-installed', app: null, gateway: null, pools: [] })) : result; + }) as never); + // Class instances, as real stores are: the workspace's state does not proxy them. + class Fake {} + const pcStore = Object.assign(new Fake(), { client: pc, providers: [], owner: true, localCore: true, endpointUrl: null, core: { hostname: 'nuno-pc' } }) as unknown as Store; + const serverStore = Object.assign(new Fake(), { client: server, providers: [], owner: true, endpointUrl: 'https://server.test', core: { hostname: 'server' } }) as unknown as Store; + workspace.machines = [ + { id: 'local', label: 'nuno-pc', store: pcStore }, + { id: 'https://server.test', label: 'server', store: serverStore }, + ]; + try { + mounted = mount(PluginsPage, { target: document.body, props: { store: serverStore } }); + await vi.waitFor(() => expect(row('bots')?.closest('[data-testid="plugins-installed"]') ?? null).not.toBeNull()); + expect(q('[data-testid="plugins-machine"]')?.textContent).toContain('nuno-pc'); + await vi.waitFor(() => expect(appButton('bots', 'stop')).not.toBeNull()); + appButton('bots', 'stop')!.click(); + + q('[data-testid="plugins-machine"]')!.click(); + flushSync(); + q('[data-testid="plugins-machine-menu"] [data-value="https://server.test"]')!.click(); + await vi.waitFor(() => expect(row('bots')?.closest('[data-testid="plugins-recommended"]') ?? null).not.toBeNull()); + expect(q('[data-testid="plugins-machine"]')?.textContent).toContain('server'); + + // The PC's late answer stays out of the server's page. + answer(); + await new Promise((resolve) => setTimeout(resolve, 50)); + flushSync(); + expect(row('bots')?.closest('[data-testid="plugins-recommended"]') ?? null).not.toBeNull(); + expect(app('bots')).toBeNull(); + } finally { + workspace.machines = []; + pc.close(); server.close(); + } +}); diff --git a/packages/ui/src/components/Sidebar.svelte b/packages/ui/src/components/Sidebar.svelte index 9652fe07d..33ce5067d 100644 --- a/packages/ui/src/components/Sidebar.svelte +++ b/packages/ui/src/components/Sidebar.svelte @@ -235,13 +235,6 @@ >{#if rollup.kind === 'working'}