mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
fix(app): retain plugin state across host disconnects (#8105)
Keep client plugin installations and local UI state alive through transient host disconnects. Reconcile changed bundles and removals on reconnect and refresh settings changed while offline. Connection switching still replaces the client and reinstalls plugins. Preserves the implementation from #6731, adds a failing-before/passing-after real browser regression, and corrects the English and Chinese lifecycle documentation. Verified with focused tests, browser reconnect journeys, Android debug offline interaction, root checks and green current-head CI. Co-authored-by: luke <luopeihuan@gmail.com> Co-authored-by: RoundRedDot <roundreddot@gmail.com>
This commit is contained in:
co-authored by
luke
RoundRedDot
parent
36fc4f0b43
commit
8e7e8cce21
+8
-6
@@ -333,11 +333,10 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
The contribution function must return cleanup. Server cleanup may be async; Paseo waits for it when
|
||||
the plugin is reloaded, disabled, removed, disconnected, or shut down. Cleanup is for resources
|
||||
created by plugin code. Paseo removes registered contributions, unmounts surfaces, clears query
|
||||
state, rejects pending RPCs, closes the plugin's daemon session, and stops the subprocess. Cleanup
|
||||
errors are logged and do not interrupt host teardown.
|
||||
The contribution function must return cleanup for resources created by plugin code. Server cleanup
|
||||
may be async; Paseo waits for it when stopping the server plugin. Cleanup errors are logged and do
|
||||
not interrupt host teardown. Client installation retention and disposal follow the public
|
||||
[hosts and lifecycle contract](../public-docs/plugins/reference.md#hosts-and-lifecycle).
|
||||
|
||||
Paseo owns the route, screen header, icon validation, close action, theme DTO, layout facts,
|
||||
and render error boundary. The contributed component owns the complete body below the header.
|
||||
@@ -622,7 +621,10 @@ Request observation with `client.paseo.agents.list({ subscribe: {} })` and consu
|
||||
demand. Provider and project `subscribe()` calls establish their own demand. On capable daemons, each
|
||||
list-and-subscribe call has its own server ID, even for the same query. Older daemons retain
|
||||
[shared delivery behavior](protocol-compatibility.md#owned-observations). Handle snapshots
|
||||
also run after reconnect; replace your view before applying its subsequent updates. The installation
|
||||
also run after reconnect; replace your view before applying its subsequent updates. An installation
|
||||
stays loaded during a dropped connection while the app retains its `DaemonClient` object. An
|
||||
unchanged installation does not rerun setup on reconnect, so refresh state from those snapshots
|
||||
rather than from setup. Switching connections replaces the client object and reinstalls plugins. The installation
|
||||
owns every observation created through its API and releases them on unload, including setup failure.
|
||||
Mounted surfaces and command invocations have shorter API lifetimes.
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
withRetainedPlugin,
|
||||
editRetainedDraft,
|
||||
expectRetainedDraft,
|
||||
expectStoredPluginTitle,
|
||||
hideRetainedPreferences,
|
||||
showRetainedPreferences,
|
||||
expectChangedPlugin,
|
||||
expectRemovedPlugin,
|
||||
} from "../support/helpers/plugin-reconnect";
|
||||
import { verifyPluginTranslations } from "../support/helpers/plugin-translations";
|
||||
import { pluginRequirements } from "../support/helpers/plugin-fixture";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
@@ -188,3 +198,51 @@ test("plugin content and host labels follow app language without losing an open
|
||||
test.setTimeout(180_000);
|
||||
await verifyPluginTranslations(page, info);
|
||||
});
|
||||
|
||||
test("retains plugin state offline and reconciles settings, changed code and removal", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
await withRetainedPlugin(page, async (probe) => {
|
||||
await probe.open();
|
||||
await editRetainedDraft(page, "Connected draft", 0);
|
||||
|
||||
await test.step("local plugin UI remains mounted and usable while disconnected", async () => {
|
||||
await probe.disconnect();
|
||||
await expectRetainedDraft(page, "Connected draft", 1);
|
||||
await editRetainedDraft(page, "Offline draft", 1);
|
||||
await probe.changeStoredTitle("Saved by another client");
|
||||
await expectStoredPluginTitle(page, "Agent monitor");
|
||||
await probe.reconnect();
|
||||
await expectStoredPluginTitle(page, "Saved by another client");
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
});
|
||||
|
||||
await test.step("inactive settings refresh without replacing the installation", async () => {
|
||||
await hideRetainedPreferences(page);
|
||||
await probe.disconnect();
|
||||
await probe.changeStoredTitle("Saved while preferences hidden");
|
||||
await probe.reconnect();
|
||||
await showRetainedPreferences(page);
|
||||
await expectStoredPluginTitle(page, "Saved while preferences hidden");
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
});
|
||||
|
||||
await test.step("changed code replaces the retained installation after reconnect", async () => {
|
||||
await probe.disconnect();
|
||||
await probe.changeCode();
|
||||
await expectRetainedDraft(page, "Offline draft", 2);
|
||||
await probe.reconnect();
|
||||
await expectChangedPlugin(page);
|
||||
await editRetainedDraft(page, "New code draft", 0);
|
||||
});
|
||||
|
||||
await test.step("removal is reconciled after reconnect", async () => {
|
||||
await probe.disconnect();
|
||||
await probe.remove();
|
||||
await expectRetainedDraft(page, "New code draft", 1);
|
||||
await probe.reconnect();
|
||||
await expectRemovedPlugin(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { z } from "zod";
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { installDaemonWebSocketGate } from "./daemon-websocket-gate";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
|
||||
const PLUGIN_ID = "retained-plugin-e2e";
|
||||
|
||||
export function retainedPluginSource(version: string) {
|
||||
return `import { useState } from "react";
|
||||
import { Text, View, TextInput, Pressable } from "react-native";
|
||||
import { useSettings } from "@getpaseo/plugin/client";
|
||||
import { preferences } from "./shared/preferences";
|
||||
function Preferences({ color }) {
|
||||
const settings = useSettings(preferences);
|
||||
return <Text style={{ color }}>{settings.status === "ready" ? "Stored title: " + settings.values.title : "Preferences " + settings.status}</Text>;
|
||||
}
|
||||
function Surface({ theme }) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [clicks, setClicks] = useState(0);
|
||||
const [showPreferences, setShowPreferences] = useState(true);
|
||||
const color = theme.colors.foreground;
|
||||
return <View>
|
||||
<Text style={{ color }}>Plugin version ${version}</Text>
|
||||
<TextInput accessibilityLabel="Plugin draft" value={draft} onChangeText={setDraft} style={{ color }} />
|
||||
<Pressable accessibilityRole="button" onPress={() => setClicks(value => value + 1)}><Text style={{ color }}>{"Local clicks " + clicks}</Text></Pressable>
|
||||
<Pressable accessibilityRole="button" onPress={() => setShowPreferences(value => !value)}><Text style={{ color }}>{showPreferences ? "Hide preferences" : "Show preferences"}</Text></Pressable>
|
||||
{showPreferences ? <Preferences color={color} /> : null}
|
||||
</View>;
|
||||
}
|
||||
export default function contribute(plugin) {
|
||||
plugin.addSurface("main", Surface);
|
||||
plugin.addSidebarItem({ id: "main", title: "Reconnect probe", icon: "PanelsTopLeft", surface: "main" });
|
||||
return () => {};
|
||||
}`;
|
||||
}
|
||||
|
||||
export async function withRetainedPlugin(
|
||||
page: Page,
|
||||
run: (probe: Awaited<ReturnType<typeof createProbe>>) => Promise<void>,
|
||||
) {
|
||||
const probe = await createProbe(page);
|
||||
try {
|
||||
await run(probe);
|
||||
} finally {
|
||||
await probe.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function createProbe(page: Page) {
|
||||
const example = await copyPluginExample("settings");
|
||||
const peer = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "plugin-reconnect" });
|
||||
const previous = await peer.getDaemonConfig();
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const clientEntry = path.join(example.directory, "index.client.tsx");
|
||||
let catalogCount = 0;
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(example.directory, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
||||
);
|
||||
await writeFile(clientEntry, retainedPluginSource("one"));
|
||||
await peer.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await peer.installDirectoryPlugin(example.directory);
|
||||
} catch (error) {
|
||||
await peer.close();
|
||||
await example.cleanup();
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
async open() {
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Reconnect probe", exact: true }).click();
|
||||
await expect(page.getByText("Stored title: Agent monitor", { exact: true })).toBeVisible();
|
||||
},
|
||||
async disconnect() {
|
||||
catalogCount = gate.getClientRequestCount("plugin.catalog.get.request");
|
||||
await gate.drop();
|
||||
await gate.waitForBlockedConnection();
|
||||
},
|
||||
async reconnect() {
|
||||
gate.restore();
|
||||
await gate.waitForServerMessage("plugin.catalog.get.response", catalogCount + 1);
|
||||
},
|
||||
async changeStoredTitle(title: string) {
|
||||
const rpc = settingsRpc("display");
|
||||
const current = rpc.read.output.parse(
|
||||
await peer.invokePluginRpc(PLUGIN_ID, rpc.read.name, {}),
|
||||
);
|
||||
if (current.status !== "ready") throw new Error("Expected ready settings");
|
||||
const values = z
|
||||
.object({ groupBy: z.string(), showMetadata: z.boolean(), title: z.string() })
|
||||
.parse(current.values);
|
||||
const result = await peer.invokePluginRpc(PLUGIN_ID, rpc.write.name, {
|
||||
revision: current.revision,
|
||||
values: { ...values, title },
|
||||
});
|
||||
expect(result).toMatchObject({ status: "saved", values: { title } });
|
||||
},
|
||||
async changeCode() {
|
||||
await writeFile(clientEntry, retainedPluginSource("two"));
|
||||
await peer.reloadPlugin(PLUGIN_ID);
|
||||
},
|
||||
async remove() {
|
||||
await peer.removePlugin(PLUGIN_ID);
|
||||
},
|
||||
async close() {
|
||||
gate.restore();
|
||||
await peer.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await peer.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
|
||||
await peer.close();
|
||||
await example.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function editRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||
await page.getByRole("textbox", { name: "Plugin draft", exact: true }).fill(draft);
|
||||
await page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }).click();
|
||||
await expectRetainedDraft(page, draft, clicks + 1);
|
||||
}
|
||||
|
||||
export async function expectRetainedDraft(page: Page, draft: string, clicks: number) {
|
||||
await expect(page.getByRole("textbox", { name: "Plugin draft", exact: true })).toHaveValue(draft);
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Local clicks ${clicks}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectStoredPluginTitle(page: Page, title: string) {
|
||||
await expect(page.getByText(`Stored title: ${title}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function hideRetainedPreferences(page: Page) {
|
||||
await page.getByRole("button", { name: "Hide preferences", exact: true }).click();
|
||||
await expect(page.getByText(/^Stored title:/)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function showRetainedPreferences(page: Page) {
|
||||
await page.getByRole("button", { name: "Show preferences", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectChangedPlugin(page: Page) {
|
||||
await expect(page.getByText("Plugin version two", { exact: true })).toBeVisible();
|
||||
await expectRetainedDraft(page, "", 0);
|
||||
}
|
||||
|
||||
export async function expectRemovedPlugin(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin surface is unavailable.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Reconnect probe", exact: true })).toHaveCount(0);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import appPackage from "../../package.json";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||
import { createPluginHosts } from "./hosts";
|
||||
import { PluginRegistry } from "./registry";
|
||||
import { pluginSettingsKey } from "./settings/query";
|
||||
import type { InstalledPlugin } from "./types";
|
||||
|
||||
const audio = { play: async () => 0 };
|
||||
const rpc = settingsRpc("preferences");
|
||||
type Catalog = Awaited<ReturnType<DaemonClient["getPluginCatalog"]>>;
|
||||
type Observation = ReturnType<DaemonClient["observeEvents"]>;
|
||||
type Observer = Parameters<Observation["subscribe"]>[0];
|
||||
|
||||
class SettingsStore {
|
||||
private revision = 1;
|
||||
private values = { label: "original" };
|
||||
reads = 0;
|
||||
|
||||
read() {
|
||||
this.reads++;
|
||||
return { status: "ready", revision: String(this.revision), values: this.values } as const;
|
||||
}
|
||||
|
||||
write(input: unknown) {
|
||||
const write = rpc.write.input.parse(input);
|
||||
if (write.revision !== String(this.revision)) {
|
||||
return { status: "conflict", error: "Settings changed" } as const;
|
||||
}
|
||||
const values = write.values;
|
||||
if (typeof values !== "object" || values === null || !("label" in values)) {
|
||||
throw new Error("Expected settings values");
|
||||
}
|
||||
if (typeof values.label !== "string") throw new Error("Expected label");
|
||||
this.values = { label: values.label };
|
||||
this.revision++;
|
||||
return { status: "saved", revision: String(this.revision), values: this.values } as const;
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory daemon port; it delivers owned snapshots and stops updates on release. */
|
||||
class CatalogClient extends DaemonClient {
|
||||
catalog = catalog("one");
|
||||
fetches = 0;
|
||||
releases = 0;
|
||||
events: Parameters<DaemonClient["observeEvents"]>[0] = [];
|
||||
private readonly observers = new Set<Observer>();
|
||||
|
||||
constructor(readonly settings = new SettingsStore()) {
|
||||
super({ url: "ws://unused.test", clientId: "catalog-sync-test" });
|
||||
}
|
||||
|
||||
override async getPluginCatalog(): Promise<Catalog> {
|
||||
this.fetches++;
|
||||
return this.catalog;
|
||||
}
|
||||
|
||||
override observeEvents(events: Parameters<DaemonClient["observeEvents"]>[0]): Observation {
|
||||
this.events = events;
|
||||
const snapshot = { requestId: "events", subscriptionId: "catalog" };
|
||||
const subscribers = new Set<Observer>();
|
||||
return {
|
||||
subscriptionId: snapshot.subscriptionId,
|
||||
ready: Promise.resolve(snapshot),
|
||||
subscribe: (observer) => {
|
||||
subscribers.add(observer);
|
||||
this.observers.add(observer);
|
||||
observer.snapshot(snapshot);
|
||||
return () => {
|
||||
subscribers.delete(observer);
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
},
|
||||
release: async () => {
|
||||
this.releases++;
|
||||
for (const observer of subscribers) this.observers.delete(observer);
|
||||
subscribers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
override async invokePluginRpc(_pluginId: string, method: string, input: unknown) {
|
||||
if (method === rpc.read.name) return this.settings.read();
|
||||
if (method === rpc.write.name) return this.settings.write(input);
|
||||
throw new Error(`Unexpected RPC: ${method}`);
|
||||
}
|
||||
|
||||
update(message: SessionOutboundMessage) {
|
||||
for (const observer of this.observers) observer.update(message);
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
for (const observer of this.observers) {
|
||||
observer.snapshot({ requestId: "reconnect", subscriptionId: "catalog" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function catalog(marker: string): Catalog {
|
||||
return [
|
||||
{
|
||||
id: "reports",
|
||||
requirements: { paseo: `>=${appPackage.version}` },
|
||||
clientBundle: `(function() { return { default: function(plugin) {
|
||||
plugin.addTimelineTransformer({
|
||||
id: "report", query: { itemType: "tool_call" },
|
||||
transform() { return ${JSON.stringify(marker)} ? { items: [] } : undefined; }
|
||||
});
|
||||
return function() { plugin.openSettings("cleanup"); };
|
||||
} }; })`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
afterEach(() => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) cleanup();
|
||||
});
|
||||
|
||||
function harness() {
|
||||
let evaluations = 0;
|
||||
let disposals = 0;
|
||||
const registry = new PluginRegistry({
|
||||
localeSource: { getLocale: () => "en", subscribe: () => () => {} },
|
||||
version: appPackage.version,
|
||||
createRuntime(installation) {
|
||||
evaluations++;
|
||||
return {
|
||||
hosts: createPluginHosts(
|
||||
{
|
||||
getHosts: () => [],
|
||||
getSnapshot: () => null,
|
||||
subscribeAll: () => () => {},
|
||||
subscribeHostList: () => () => {},
|
||||
},
|
||||
installation.lifetime.signal,
|
||||
),
|
||||
paseo: installation.paseo,
|
||||
i18n: installation.i18n,
|
||||
rpc: async () => {
|
||||
throw new Error("Unexpected runtime RPC");
|
||||
},
|
||||
openSettings: () => {
|
||||
disposals++;
|
||||
},
|
||||
openScreen() {},
|
||||
openSurface() {},
|
||||
async playAudio() {},
|
||||
openPanel() {},
|
||||
addComposerPill: () => ({ update() {}, remove() {} }),
|
||||
addHeaderButton: () => ({ update() {}, remove() {} }),
|
||||
};
|
||||
},
|
||||
});
|
||||
const client = new CatalogClient();
|
||||
cleanups.push(() => {
|
||||
registry.removeHost("host");
|
||||
registry.removeHost("other");
|
||||
});
|
||||
function sync(options: Partial<Parameters<typeof syncPluginCatalog>[0]> = {}) {
|
||||
const stop = syncPluginCatalog({
|
||||
serverId: "host",
|
||||
client,
|
||||
audio,
|
||||
connected: true,
|
||||
supported: true,
|
||||
registry,
|
||||
...options,
|
||||
});
|
||||
cleanups.push(stop);
|
||||
return stop;
|
||||
}
|
||||
function installed(serverId = "host") {
|
||||
const plugin = registry.getSnapshot().find((entry) => entry.serverId === serverId);
|
||||
if (!plugin) throw new Error(`Missing installation: ${serverId}`);
|
||||
return plugin;
|
||||
}
|
||||
return {
|
||||
registry,
|
||||
client,
|
||||
sync,
|
||||
installed,
|
||||
evaluations: () => evaluations,
|
||||
disposals: () => disposals,
|
||||
};
|
||||
}
|
||||
|
||||
async function connected() {
|
||||
const h = harness();
|
||||
const stop = h.sync();
|
||||
await expect.poll(() => h.registry.getSnapshot().length).toBe(1);
|
||||
return { ...h, stop };
|
||||
}
|
||||
|
||||
function settingsQuery(plugin: InstalledPlugin) {
|
||||
return {
|
||||
queryKey: pluginSettingsKey("preferences"),
|
||||
queryFn: async () => rpc.read.output.parse(await plugin.invoke(rpc.read.name, {})),
|
||||
// The replica policy used by useSettings deliberately disables automatic refetches.
|
||||
gcTime: Infinity,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
} as const;
|
||||
}
|
||||
|
||||
it("retains installations and timeline transforms while disconnected", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
const transformers = plugin.timelineTransformers;
|
||||
h.stop();
|
||||
h.sync({ connected: false });
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||
});
|
||||
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||
expect(h.installed().timelineTransformers).toBe(transformers);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(false);
|
||||
expect(h.disposals()).toBe(0);
|
||||
expect(h.client.fetches).toBe(1);
|
||||
expect(h.client.events).toEqual([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["mounted", "unmounted"])(
|
||||
"refreshes %s settings and revisions after another client saves offline",
|
||||
async (surface) => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
const options = settingsQuery(plugin);
|
||||
const original = await plugin.queryClient.fetchQuery(options);
|
||||
const observer = new QueryObserver(plugin.queryClient, options);
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
cleanups.push(unsubscribe);
|
||||
if (surface === "unmounted") unsubscribe();
|
||||
plugin.queryClient.setQueryData(["attachment-search"], "retained");
|
||||
const otherClient = new CatalogClient();
|
||||
h.registry.installCatalog("other", otherClient.catalog, { client: otherClient, audio });
|
||||
const otherPlugin = h.installed("other");
|
||||
await otherPlugin.queryClient.fetchQuery(settingsQuery(otherPlugin));
|
||||
// Another host publishes new presentation snapshots without replacing the owning lifetime.
|
||||
const beforeReconnect = h.registry.getSnapshot();
|
||||
const retainedInstallation = h.installed();
|
||||
|
||||
h.stop();
|
||||
h.sync({ connected: false });
|
||||
const clientB = new CatalogClient(h.client.settings);
|
||||
expect(
|
||||
await clientB.invokePluginRpc("reports", rpc.write.name, {
|
||||
revision: original.revision,
|
||||
values: { label: "client B" },
|
||||
}),
|
||||
).toEqual({ status: "saved", revision: "2", values: { label: "client B" } });
|
||||
expect(plugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||
h.sync();
|
||||
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "client B" },
|
||||
});
|
||||
const refreshed = await plugin.queryClient.fetchQuery(options);
|
||||
expect(
|
||||
await plugin.invoke(rpc.write.name, {
|
||||
revision: refreshed.revision,
|
||||
values: { label: "client A" },
|
||||
}),
|
||||
).toEqual({ status: "saved", revision: "3", values: { label: "client A" } });
|
||||
expect(h.registry.getSnapshot()).toBe(beforeReconnect);
|
||||
expect(h.installed()).toBe(retainedInstallation);
|
||||
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||
expect(h.installed().timelineTransformers).toBe(snapshot[0].timelineTransformers);
|
||||
expect(plugin.queryClient.getQueryData(["attachment-search"])).toBe("retained");
|
||||
expect(otherClient.settings.reads).toBe(1);
|
||||
expect(otherPlugin.queryClient.getQueryState(options.queryKey)?.isInvalidated).toBe(false);
|
||||
expect(otherPlugin.queryClient.getQueryData(options.queryKey)).toEqual(original);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.disposals()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("replaces changed bundles on reconnect and disposes the old installation", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
plugin.queryClient.setQueryData(["attachment-search"], "old");
|
||||
h.stop();
|
||||
h.client.catalog = catalog("two");
|
||||
h.sync();
|
||||
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||
expect(plugin.queryClient.getQueryCache().getAll()).toEqual([]);
|
||||
expect(h.installed().timelineTransformers.map((entry) => entry.id)).toEqual(["report"]);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.disposals()).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces an explicitly reloaded bundle even when its source is unchanged", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: { status: "plugin_catalog_changed", pluginId: "reports" },
|
||||
});
|
||||
await expect.poll(() => h.installed() === plugin).toBe(false);
|
||||
expect(h.disposals()).toBe(1);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["unsupported", "no audio"])("disposes plugins when %s", async (reason) => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
h.stop();
|
||||
h.sync({ supported: reason !== "unsupported", audio: reason === "no audio" ? null : audio });
|
||||
expect(h.registry.getSnapshot()).toEqual([]);
|
||||
expect(plugin.lifetime.signal.aborted).toBe(true);
|
||||
expect(h.disposals()).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes settings on a replayed subscription snapshot without reinstalling", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const options = settingsQuery(plugin);
|
||||
await plugin.queryClient.fetchQuery(options);
|
||||
h.client.settings.write({ revision: "1", values: { label: "replayed" } });
|
||||
h.client.snapshot();
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "replayed" },
|
||||
});
|
||||
expect(h.installed()).toBe(plugin);
|
||||
expect(h.evaluations()).toBe(1);
|
||||
});
|
||||
|
||||
it("invalidates only the named settings on its host for a live notification", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
const options = settingsQuery(plugin);
|
||||
await plugin.queryClient.fetchQuery(options);
|
||||
const observer = new QueryObserver(plugin.queryClient, options);
|
||||
cleanups.push(observer.subscribe(() => {}));
|
||||
plugin.queryClient.setQueryData(pluginSettingsKey("other-settings"), "unchanged");
|
||||
const other = new CatalogClient();
|
||||
h.registry.installCatalog("other", other.catalog, { client: other, audio });
|
||||
await h.installed("other").queryClient.fetchQuery(settingsQuery(h.installed("other")));
|
||||
|
||||
const beforeNotification = h.registry.getSnapshot();
|
||||
const retainedInstallation = h.installed();
|
||||
h.client.settings.write({ revision: "1", values: { label: "live" } });
|
||||
h.client.update({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "plugin_settings_changed",
|
||||
pluginId: "reports",
|
||||
settingsId: "preferences",
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => plugin.queryClient.getQueryData(options.queryKey))
|
||||
.toEqual({
|
||||
status: "ready",
|
||||
revision: "2",
|
||||
values: { label: "live" },
|
||||
});
|
||||
expect(plugin.queryClient.getQueryState(pluginSettingsKey("other-settings"))?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(other.settings.reads).toBe(1);
|
||||
expect(h.registry.getSnapshot()).toBe(beforeNotification);
|
||||
expect(h.installed()).toBe(retainedInstallation);
|
||||
expect(h.installed().lifetime).toBe(plugin.lifetime);
|
||||
expect(h.installed().queryClient).toBe(plugin.queryClient);
|
||||
expect(h.evaluations()).toBe(2);
|
||||
expect(h.client.fetches).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores an in-flight catalog snapshot after disconnect", async () => {
|
||||
const h = await connected();
|
||||
const plugin = h.installed();
|
||||
await plugin.queryClient.fetchQuery(settingsQuery(plugin));
|
||||
const snapshot = h.registry.getSnapshot();
|
||||
h.stop();
|
||||
let resolveCatalog: (value: Catalog) => void = () => {};
|
||||
const pending = new Promise<Catalog>((resolve) => {
|
||||
resolveCatalog = resolve;
|
||||
});
|
||||
let fetching: () => void = () => {};
|
||||
const started = new Promise<void>((resolve) => {
|
||||
fetching = resolve;
|
||||
});
|
||||
class PendingClient extends CatalogClient {
|
||||
override getPluginCatalog() {
|
||||
fetching();
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
const stop = h.sync({ client: new PendingClient(h.client.settings) });
|
||||
await started;
|
||||
stop();
|
||||
resolveCatalog(catalog("late"));
|
||||
await pending;
|
||||
// Drain the catalog continuation through a subsequently queued task.
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.registry.getSnapshot()).toBe(snapshot);
|
||||
expect(h.client.settings.reads).toBe(1);
|
||||
expect(h.evaluations()).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { AudioEngine } from "@/audio";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { PluginRegistry } from "./registry";
|
||||
import { pluginSettingsKey, pluginSettingsPrefix } from "./settings/query";
|
||||
|
||||
interface CatalogSyncInput {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
audio: Pick<AudioEngine, "play"> | null;
|
||||
connected: boolean;
|
||||
supported: boolean;
|
||||
registry: Pick<PluginRegistry, "installCatalog" | "getSnapshot" | "removeHost">;
|
||||
}
|
||||
|
||||
export function syncPluginCatalog({
|
||||
serverId,
|
||||
client,
|
||||
audio,
|
||||
connected,
|
||||
supported,
|
||||
registry,
|
||||
}: CatalogSyncInput): () => void {
|
||||
if (!supported || !audio) {
|
||||
registry.removeHost(serverId);
|
||||
return () => {};
|
||||
}
|
||||
// A dropped socket reconnects on the same client, so installations keep rendering offline.
|
||||
if (!connected) return () => {};
|
||||
const installationOptions = { client, audio };
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
function refresh(replacePluginId?: string) {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
registry.installCatalog(serverId, catalog, { ...installationOptions, replacePluginId });
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
}
|
||||
const observation = client.observeEvents([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
void refresh().then(() => {
|
||||
if (cancelled) return undefined;
|
||||
// Retained replicas miss offline writes and do not refetch when a screen mounts.
|
||||
for (const plugin of registry.getSnapshot()) {
|
||||
if (plugin.serverId !== serverId) continue;
|
||||
void plugin.queryClient.invalidateQueries({
|
||||
queryKey: pluginSettingsPrefix,
|
||||
refetchType: "all",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "status") return;
|
||||
if (message.payload.status === "plugin_settings_changed") {
|
||||
const { pluginId, settingsId } = message.payload;
|
||||
if (typeof settingsId === "string") {
|
||||
const plugin = registry
|
||||
.getSnapshot()
|
||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||
}
|
||||
}
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useVoiceAudioEngineOptional } from "@/contexts/voice-context";
|
||||
import { pluginSettingsKey } from "./settings/use-settings";
|
||||
import { useEffect } from "react";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { pluginRegistry } from "./registry";
|
||||
import { syncPluginCatalog } from "./catalog-sync-model";
|
||||
|
||||
export function PluginCatalogSync({
|
||||
serverId,
|
||||
@@ -17,72 +17,18 @@ export function PluginCatalogSync({
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "plugins");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshQueue = Promise.resolve();
|
||||
if (!supported || !audio) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
if (!connected) {
|
||||
pluginRegistry.removeHost(serverId);
|
||||
return;
|
||||
}
|
||||
const refresh = (replacePluginId?: string) => {
|
||||
refreshQueue = refreshQueue.then(() =>
|
||||
client
|
||||
.getPluginCatalog()
|
||||
.then((catalog) => {
|
||||
if (!cancelled) {
|
||||
pluginRegistry.installCatalog(serverId, catalog, {
|
||||
replacePluginId,
|
||||
client,
|
||||
audio,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) {
|
||||
console.warn(`[Plugins] Failed to load catalog for ${serverId}`, error);
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
);
|
||||
return refreshQueue;
|
||||
};
|
||||
const observation = client.observeEvents([
|
||||
"status.plugin_catalog_changed",
|
||||
"status.plugin_settings_changed",
|
||||
]);
|
||||
observation.subscribe({
|
||||
snapshot: () => {
|
||||
void refresh();
|
||||
},
|
||||
update: (message) => {
|
||||
if (message.type !== "status") return;
|
||||
if (message.payload.status === "plugin_settings_changed") {
|
||||
const { pluginId, settingsId } = message.payload;
|
||||
if (typeof settingsId === "string") {
|
||||
const plugin = pluginRegistry
|
||||
.getSnapshot()
|
||||
.find((item) => item.serverId === serverId && item.id === pluginId);
|
||||
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
|
||||
}
|
||||
}
|
||||
if (message.payload.status === "plugin_catalog_changed") {
|
||||
const pluginId = message.payload.pluginId;
|
||||
if (typeof pluginId === "string") void refresh(pluginId);
|
||||
}
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void observation
|
||||
.release()
|
||||
.catch((error) => console.warn("[Plugins] Failed to release catalog", error));
|
||||
};
|
||||
}, [audio, client, connected, serverId, supported]);
|
||||
useEffect(
|
||||
() =>
|
||||
syncPluginCatalog({
|
||||
serverId,
|
||||
client,
|
||||
audio,
|
||||
connected,
|
||||
supported,
|
||||
registry: pluginRegistry,
|
||||
}),
|
||||
[audio, client, connected, serverId, supported],
|
||||
);
|
||||
|
||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||
return null;
|
||||
|
||||
@@ -219,7 +219,10 @@ test("locale snapshots preserve installation state and observations, then releas
|
||||
expect(after.paseo).toBe(before.paseo);
|
||||
expect(after.surfaces[0].Component).toBe(before.surfaces[0].Component);
|
||||
expect(h.open).toEqual(new Set(["notes"]));
|
||||
const translatedSnapshot = h.plugins.getSnapshot();
|
||||
h.plugins.installCatalog("host", [translated], { client: h.client, audio });
|
||||
expect(h.plugins.getSnapshot()).toBe(translatedSnapshot);
|
||||
expect(h.plugins.getSnapshot()[0].locale).toBe("zh-CN");
|
||||
expect(h.plugins.getSnapshot()[0].i18n).toBe(before.i18n);
|
||||
h.plugins.removeHost("host");
|
||||
expect(listeners.size).toBe(0);
|
||||
|
||||
@@ -27,12 +27,12 @@ const pluginRegistry = {
|
||||
installCatalog(
|
||||
serverId: string,
|
||||
catalog: Parameters<typeof registry.installCatalog>[1],
|
||||
options: { replacePluginId?: string } = {},
|
||||
options: { replacePluginId?: string; client?: DaemonClient } = {},
|
||||
) {
|
||||
return registry.installCatalog(
|
||||
serverId,
|
||||
catalog.map((entry) => ({ ...entry, requirements: { paseo: `>=${appPackage.version}` } })),
|
||||
{ ...options, client: daemonClient, audio },
|
||||
{ client: daemonClient, ...options, audio },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -118,6 +118,37 @@ describe("PluginRegistry", () => {
|
||||
expect(second?.queryClient.getQueryData(["counter"])).toBe(3);
|
||||
});
|
||||
|
||||
it("keeps the snapshot when the same catalog is installed again", () => {
|
||||
const clientBundle = timelineBundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||
const snapshot = pluginRegistry.getSnapshot();
|
||||
const transformers = snapshot[0]?.timelineTransformers;
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = pluginRegistry.subscribe(listener);
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle }]);
|
||||
unsubscribe();
|
||||
|
||||
expect(pluginRegistry.getSnapshot()).toBe(snapshot);
|
||||
expect(pluginRegistry.getSnapshot()[0]?.timelineTransformers).toBe(transformers);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls an unchanged bundle for a new host client", () => {
|
||||
const clientBundle = bundle("one");
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle }], {
|
||||
client: {} as DaemonClient,
|
||||
});
|
||||
|
||||
const second = pluginRegistry.getSnapshot()[0];
|
||||
expect(second).not.toBe(first);
|
||||
expect(first?.lifetime.signal.aborted).toBe(true);
|
||||
expect(Reflect.get(globalThis, "__pluginCleanups")).toBe(1);
|
||||
});
|
||||
|
||||
it("replaces the query cache when the plugin bundle changes", () => {
|
||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
||||
const first = pluginRegistry.getSnapshot()[0];
|
||||
|
||||
@@ -19,6 +19,8 @@ export class PluginRegistry {
|
||||
private snapshot: InstalledPlugin[] = [];
|
||||
private readonly disposed = new WeakSet<InstalledPlugin>();
|
||||
private readonly evaluationErrors = new Map<string, string>();
|
||||
/** The client each host's installations were bound to; their Paseo API and RPCs call it. */
|
||||
private readonly clientByHost = new Map<string, DaemonClient>();
|
||||
|
||||
constructor(
|
||||
private readonly dependencies: {
|
||||
@@ -49,10 +51,15 @@ export class PluginRegistry {
|
||||
},
|
||||
): boolean {
|
||||
const previous = this.byHost.get(serverId) ?? [];
|
||||
const previousErrors = this.hostEvaluationErrors(serverId);
|
||||
// Installations call the client they were created with, so a new client replaces them all.
|
||||
const sameClient = this.clientByHost.get(serverId) === options.client;
|
||||
this.clientByHost.set(serverId, options.client);
|
||||
const previousTimelineBundles = previous
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
const preserved = catalog.flatMap((entry) => {
|
||||
if (!sameClient) return [];
|
||||
const existing = previous.find(
|
||||
(plugin) =>
|
||||
plugin.id !== options.replacePluginId &&
|
||||
@@ -146,13 +153,13 @@ export class PluginRegistry {
|
||||
}
|
||||
}
|
||||
this.byHost.set(serverId, installed);
|
||||
if (
|
||||
installed.length !== catalog.length ||
|
||||
installed.length !== previous.length ||
|
||||
installed.some((plugin, index) => plugin !== previous[index])
|
||||
) {
|
||||
this.publish();
|
||||
}
|
||||
// An unchanged catalog keeps the snapshot, so timeline projections cached against the
|
||||
// installed transformers stay valid across a reconnect.
|
||||
const unchanged =
|
||||
installed.length === previous.length &&
|
||||
installed.every((plugin, index) => plugin === previous[index]) &&
|
||||
this.hostEvaluationErrors(serverId) === previousErrors;
|
||||
if (!unchanged) this.publish();
|
||||
const installedTimelineBundles = installed
|
||||
.filter((plugin) => plugin.timelineTransformers.length > 0)
|
||||
.map((plugin) => `${plugin.id}\0${plugin.clientBundle}`);
|
||||
@@ -170,9 +177,17 @@ export class PluginRegistry {
|
||||
if (key.startsWith(`${serverId}/`)) this.evaluationErrors.delete(key);
|
||||
}
|
||||
this.byHost.delete(serverId);
|
||||
this.clientByHost.delete(serverId);
|
||||
this.publish();
|
||||
}
|
||||
|
||||
private hostEvaluationErrors(serverId: string): string {
|
||||
const prefix = `${serverId}/`;
|
||||
return JSON.stringify(
|
||||
[...this.evaluationErrors].filter(([key]) => key.startsWith(prefix)).sort(),
|
||||
);
|
||||
}
|
||||
|
||||
private dispose(plugin: InstalledPlugin): void {
|
||||
if (this.disposed.has(plugin)) return;
|
||||
this.disposed.add(plugin);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const pluginSettingsPrefix = ["plugin-settings"] as const;
|
||||
export const pluginSettingsKey = (id: string) => [...pluginSettingsPrefix, id] as const;
|
||||
@@ -6,8 +6,8 @@ import { type SettingsDefinition } from "@getpaseo/plugin";
|
||||
import { settingsRpc } from "@getpaseo/plugin";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { z, type ZodType } from "zod";
|
||||
import { pluginSettingsKey } from "./query";
|
||||
|
||||
export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const;
|
||||
function message(error: unknown): string {
|
||||
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -628,7 +628,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Paseo also removes registrations, unmounts surfaces, rejects pending RPCs, closes the plugin's daemon session, and stops its subprocess on reload, disable, removal, disconnect, or daemon shutdown.
|
||||
Cleanup can be async. Release timers, watchers, sockets, and other resources created by the plugin. Reload, disable, and removal dispose the affected installation. Stopping a server plugin also closes its daemon session and stops its subprocess. Client installations follow [Hosts and lifecycle](#hosts-and-lifecycle), including retention during a temporary disconnect.
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
@@ -2498,7 +2498,7 @@ Hiding preserves the registration, so showing it again restores its position. It
|
||||
an action already in progress.
|
||||
|
||||
`remove()` is idempotent. Updates after removal do nothing. Paseo removes outstanding buttons when
|
||||
the plugin installation or host connection is torn down. Return cleanup from the client entry for
|
||||
the plugin installation is disposed (see [Hosts and lifecycle](#hosts-and-lifecycle)). Return cleanup from the client entry for
|
||||
your subscriptions, timers, and other resources.
|
||||
|
||||
## Use the Paseo SDK
|
||||
@@ -2826,9 +2826,11 @@ Plugins are installed per daemon. When the same contribution exists on several c
|
||||
|
||||
Attachment sources remain scoped to each composer's host.
|
||||
|
||||
Workspace panels and Command Center items stay scoped to the active host and exact cached context.
|
||||
Reload replaces their registrations. Disable, removal, host disconnect, and evaluation failure
|
||||
remove Command Center items and clear the installation's query state. An already-restored panel tab
|
||||
A dropped connection keeps client plugin registrations, mounted surfaces, local state, and query caches. Reconnecting through the existing `DaemonClient` object keeps unchanged installations and refreshes their settings. Consume SDK observation snapshots to refresh other daemon state; setup does not run again for an unchanged installation. Daemon calls still depend on connectivity.
|
||||
|
||||
Switching connections (for example, from direct to relay) currently replaces the `DaemonClient` object and reinstalls client plugins. Reload, changed bundles or requirements, disable, plugin removal, and host removal also dispose affected installations and clear their query state. A replacement that fails evaluation leaves its contributions unavailable.
|
||||
|
||||
Workspace panels and Command Center items stay scoped to the active host and exact cached context. An already-restored panel tab
|
||||
remains as unavailable until its matching contribution returns or the user closes it. Panel render
|
||||
failures stay inside the plugin error boundary.
|
||||
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function contribute(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。在重新加载、停用、移除、断开连接或 daemon 关闭时,Paseo 还会移除注册、卸载界面、reject 待处理的 RPC、关闭插件的 daemon 会话并停止它的子进程。
|
||||
清理函数可以是异步的。请释放插件创建的计时器、监听器、套接字和其他资源。重新加载、停用和移除会销毁受影响的插件安装。停止服务端插件还会关闭它的 daemon 会话并停止其子进程。客户端安装遵循[主机与生命周期](#hosts-and-lifecycle)中的规则,包括在暂时断开连接时保留安装。
|
||||
|
||||
## 生命周期钩子 {#lifecycle-hooks}
|
||||
|
||||
@@ -2177,7 +2177,7 @@ const behavior: PluginButtonBehavior = {
|
||||
隐藏或禁用按钮会关闭它的浮层。更新它的 behavior 也会关闭浮层。隐藏会保留注册,因此再次显示时会恢复它
|
||||
原来的位置。它不会取消已经在进行中的操作。
|
||||
|
||||
`remove()` 是幂等的。移除之后的更新不起作用。当插件安装或主机连接被拆除时,Paseo 会移除尚存的按钮。
|
||||
`remove()` 是幂等的。移除之后的更新不起作用。当插件安装被销毁时,Paseo 会移除尚存的按钮(参见[主机与生命周期](#hosts-and-lifecycle))。
|
||||
请从客户端入口返回清理函数,用来释放你的订阅、定时器和其他资源。
|
||||
|
||||
## 使用 Paseo SDK {#use-the-paseo-sdk}
|
||||
@@ -2496,8 +2496,11 @@ Composer 菜单、搜索选择器、选中后的胶囊、草稿状态和提交
|
||||
|
||||
附件来源仍然限定在各个 Composer 所在的主机上。
|
||||
|
||||
工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。重新加载会替换它们的
|
||||
注册。停用、移除、主机断开和求值失败会移除 Command Center 条目,并清除该安装的查询状态。已经恢复的面板
|
||||
连接中断时,客户端插件的注册、已挂载界面、本地状态和查询缓存会保留。通过原有的 `DaemonClient` 对象重新连接时,未变化的安装会保留,其设置会刷新。请使用 SDK 观察快照刷新其他 daemon 状态;未变化的安装不会再次运行初始化函数。调用 daemon 仍然依赖连接。
|
||||
|
||||
切换连接(例如从直连切换到中继)目前会替换 `DaemonClient` 对象并重新安装客户端插件。重新加载、插件包或版本要求变化、停用、移除插件和移除主机也会销毁受影响的安装,并清除其查询状态。如果替换安装求值失败,它的贡献将不可用。
|
||||
|
||||
工作区面板和 Command Center 条目仍然限定在当前活动的主机和确切的缓存上下文中。已经恢复的面板
|
||||
标签页会保持“不可用”状态,直到与之匹配的贡献重新出现或用户关闭它。面板的渲染失败会被限制在插件的错误
|
||||
边界内。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user