Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/electron/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ let sessionLaunchRuntime: ReturnType<typeof createSessionLaunchRuntime> | null =
const pluginServices = createPluginServices({
dataDir,
logger,
pluginActiveInProject,
getMainWindow,
getHost,
sendToRenderer,
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/electron/main/ipc/plugin-ui-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PluginPanelHost } from "../plugin-panel-host";
import type { PluginPanelTheme } from "../../shared/plugin-panel-chrome";
import type { IpcRegistrar } from "./types";
import { pluginProviderCatalogEntries } from "../plugin-provider-catalog";
import { createSessionPushGate } from "../plugin-session-push-gate";

export type PluginUiIpcDependencies = {
registrar: IpcRegistrar;
Expand Down Expand Up @@ -48,7 +49,7 @@ export function registerPluginUiIpc({
* The newest selection the shell reported, so a slower invoke that lands
* after a newer one cannot restore a session the user already left.
*/
let activeSessionSeq = 0;
const sessionPushGate = createSessionPushGate();
/**
* The session on screen, as the shell sees it.
*
Expand All @@ -63,12 +64,14 @@ export function registerPluginUiIpc({
IPC.invoke.pluginSessionActive,
async (
payload:
| { sessionId?: string | null; projectPath?: string | null; seq?: number | null }
| { sessionId?: string | null; projectPath?: string | null; seq?: number | null; bootId?: string | null }
| null,
) => {
const seq = typeof payload?.seq === "number" ? payload.seq : 0;
if (seq > 0 && seq < activeSessionSeq) return { ok: true, ignored: true };
if (seq > 0) activeSessionSeq = seq;
const bootId = typeof payload?.bootId === "string" ? payload.bootId : null;
if (sessionPushGate({ seq, bootId }).ignored) {
return { ok: true, ignored: true };
}
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId.trim() : "";
if (!sessionId) {
setActivePluginSession(null);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/electron/main/plugin-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1994,8 +1994,9 @@ export class PluginRuntime {
* receive the same names through `pluginBridge.on`; this is the process
* half (spec 07 §5).
*/
broadcastEvent(event: string, args: unknown[] = []): void {
broadcastEvent(event: string, args: unknown[] = [], filter?: (pluginId: string) => boolean): void {
for (const loaded of this.loaded.values()) {
if (filter && !filter(loaded.manifest.id)) continue;
try {
loaded.child?.postMessage({ t: "event", event, args });
} catch (error) {
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/electron/main/plugin-session-push-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* The out-of-order gate for plugin session pushes (#1753): within one
* renderer load a slower push must never undo a newer one, and a renderer
* reload (the automatic recovery for render-process-gone) restarts the
* renderer's counter — so a changed boot id resets the high-water mark
* instead of silently dropping every push afterwards. The boot id is per
* renderer load, so with two windows open the ordering guarantee is per
* window; only one window's session is on screen at a time. Kept dependency
* free so `plugin-session-follow.test.mjs` can drive it directly.
*/
export function createSessionPushGate() {
let activeSessionSeq = 0;
let activeSessionBootId: string | null = null;
return (push: { seq: number; bootId?: string | null }): { ok: boolean; ignored?: boolean } => {
if (push.seq > 0) {
if (push.bootId && push.bootId !== activeSessionBootId) {
activeSessionBootId = push.bootId;
activeSessionSeq = push.seq;
} else if (push.seq >= activeSessionSeq) {
activeSessionSeq = push.seq;
} else {
return { ok: true, ignored: true };
}
}
return { ok: true };
};
}
28 changes: 28 additions & 0 deletions apps/desktop/electron/main/plugin-session-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Scope rules for plugin session events (#1754). Selection events carry
* another project's session identity, so their recipients are scope-checked
* against the announced session's project — the same line `session.getActive`
* enforces on the pull side. Kept dependency free so
* `plugin-services.test.mjs` can drive the filter directly.
*/

/** Session events whose payload describes the user's current selection. */
export const SESSION_SCOPED_EVENTS = new Set(["session:activated"]);

/**
* The recipient filter for a session event, or `undefined` when the event
* fans out unconditionally.
*/
export function sessionEventScopeFilter(
event: string,
sessionId: string | undefined,
sessionProjects: ReadonlyMap<string, string | null>,
pluginActiveInProject: (pluginId: string, projectPath: string | null | undefined) => boolean,
): ((pluginId: string) => boolean) | undefined {
if (!SESSION_SCOPED_EVENTS.has(event)) return undefined;
return (pluginId: string) =>
pluginActiveInProject(
pluginId,
(sessionId && sessionProjects.get(sessionId)) ?? null,
);
}
3 changes: 2 additions & 1 deletion apps/desktop/electron/main/plugin-view-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,10 @@ export class PluginViewHost {
* are broadcast separately by `PluginPanelHost`; both surfaces share the
* preload channel `pi-plugin-panel-event:<event>`.
*/
broadcast(event: string, payload: unknown): void {
broadcast(event: string, payload: unknown, filter?: (pluginId: string) => boolean): void {
const channel = `pi-plugin-panel-event:${event}`;
for (const entry of this.views.values()) {
if (filter && !filter(entry.pluginId)) continue;
const wc = entry.view.webContents;
if (wc.isDestroyed()) continue;
try {
Expand Down
25 changes: 21 additions & 4 deletions apps/desktop/electron/main/services/plugin-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { McpOAuthManager } from "../mcp-oauth";
import { PluginPanelHost } from "../plugin-panel-host";
import { PluginViewHost } from "../plugin-view-host";
import { SESSION_SCOPED_EVENTS, sessionEventScopeFilter } from "../plugin-session-scope";
import { BrowserPane } from "../browser-view";
import { BrowserHost, BROWSER_PLUGIN_ID } from "../browser-host";
import { OAUTH_AUTH_KIND, type VendorOAuth } from "../oauth";
Expand Down Expand Up @@ -85,15 +86,19 @@ export type PluginServicesDependencies = {
* Scope records live in Main, so this is the one lookup that decides.
*/
isPluginActiveInProject: (pluginId: string) => boolean;
/**
* Scope check against an explicit project path, so the session-event
* fan-out can hold the same line `session.getActive` does for sessions
* that are not the window's own selection (#1754).
*/
pluginActiveInProject: (pluginId: string, projectPath: string | null | undefined) => boolean;
};

/**
* Session events whose payload describes the user's current selection. A
* detached `ui.panel` window is application-scoped — it does not belong to a
* session — so it never receives them; plugin processes and docked views do.
*/
const SESSION_SCOPED_EVENTS = new Set(["session:activated"]);

export function createPluginServices({
dataDir,
logger,
Expand All @@ -114,6 +119,7 @@ export function createPluginServices({
vendorOAuth,
getActiveTurn,
isPluginActiveInProject,
pluginActiveInProject,
}: PluginServicesDependencies) {
// A plugin request can lose its race with host shutdown or restart.
const isHostUnavailable = (error: unknown): boolean =>
Expand Down Expand Up @@ -575,8 +581,19 @@ export function createPluginServices({
const broadcastSessionEvent = (event: string, payload: unknown): void => {
const sessionId =
(payload as { sessionId?: string } | null | undefined)?.sessionId ?? undefined;
// Selection events carry another project's session identity, so the
// fan-out holds the scope line the pull side (session.getActive,
// ui.revealView) already enforces: a project-scoped plugin does not
// receive a session its scope excludes (#1754). Sessions without a
// project fall back to the workspace-relative check.
const scopeFilter = sessionEventScopeFilter(
event,
sessionId,
sessionProjects,
pluginActiveInProject,
);
try {
plugins.broadcastEvent(event, [payload]);
plugins.broadcastEvent(event, [payload], scopeFilter);
} catch (error) {
logger.app("plugin", "warn", `${event} plugin broadcast failed`, {
sessionId,
Expand All @@ -594,7 +611,7 @@ export function createPluginServices({
}
}
try {
pluginViews.broadcast(event, payload);
pluginViews.broadcast(event, payload, scopeFilter);
} catch (error) {
logger.app("plugin", "warn", `${event} view broadcast failed`, {
sessionId,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/features/app/useAppShellRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ export function useAppShellRuntime() {
const projectPath = useAppStore((s) => s.workspace?.path ?? null);
/** Monotonic counter for the plugin active-session push. */
const activeSessionSeq = useRef(0);
/** Identifies this renderer load: a reload restarts the counter, and the
* main-side high-water mark must restart with it (#1753). */
const sessionPushBootId = useRef(crypto.randomUUID());
/** The project of the session on screen, for the plugin session snapshot. */
const activeSessionProjectPath = useAppStore((s) =>
s.activeSessionId
Expand Down Expand Up @@ -476,6 +479,7 @@ export function useAppShellRuntime() {
sessionId: activeSessionId ?? null,
projectPath: activeSessionProjectPath,
seq: activeSessionSeq.current,
bootId: sessionPushBootId.current,
})
.catch(() => undefined);
}, [activeSessionId, activeSessionProjectPath]);
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,9 @@ export const api = {
projectPath?: string | null;
/** Renderer-side counter: a slower push must never undo a newer one. */
seq?: number;
/** Identifies the renderer load: a reload restarts the counter, and the
* main-side high-water mark restarts with it (#1753). */
bootId?: string;
}) => invoke<{ ok: boolean }>(IPC.invoke.pluginSessionActive, input),
listSessions: () =>
invoke<{ sessions: SessionSummary[] }>(IPC.invoke.sessionList).then((result) => ({
Expand Down
27 changes: 23 additions & 4 deletions apps/desktop/test/main-wiring-contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,9 @@ test("a finished turn is announced to every plugin surface", () => {
services,
services.indexOf("{", services.indexOf("const broadcastSessionEvent")),
);
assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\]\)/);
assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/);
assert.match(fanOut, /pluginPanels\.broadcast\(event, payload\)/);
assert.match(fanOut, /pluginViews\.broadcast\(event, payload\)/);
assert.match(fanOut, /pluginViews\.broadcast\(event, payload, scopeFilter\)/);
// Each surface owns its failure: one unreachable plugin process, panel or
// view must not suppress the other two.
assert.equal(
Expand All @@ -228,9 +228,9 @@ test("the selected session is announced to every plugin surface", () => {
// The snapshot is announced from the one module that owns it, and reaches the
// same three surfaces as a finished turn.
assert.match(context, /announce\("session:activated", next\)/);
assert.match(services, /plugins\.broadcastEvent\(event, \[payload\]\)/);
assert.match(services, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/);
assert.match(services, /pluginPanels\.broadcast\(event, payload\)/);
assert.match(services, /pluginViews\.broadcast\(event, payload\)/);
assert.match(services, /pluginViews\.broadcast\(event, payload, scopeFilter\)/);
// Turn identity and start time come from the host, never from the renderer.
assert.match(
services,
Expand All @@ -242,6 +242,25 @@ test("the selected session is announced to every plugin surface", () => {
assert.match(root, /pluginServices\.pluginSession\.noteTurnStarted\(sessionId, turnId\)/);
});

test("session activation derives one project filter for process and docked-view delivery", () => {
const services = read("services/plugin-services.ts");
const fanOut = braceBody(
services,
services.indexOf("{", services.indexOf("const broadcastSessionEvent")),
);
assert.match(
fanOut,
/sessionEventScopeFilter\(\s*event,\s*sessionId,\s*sessionProjects,\s*pluginActiveInProject,\s*\)/,
);
assert.match(fanOut, /plugins\.broadcastEvent\(event, \[payload\], scopeFilter\)/);
assert.match(fanOut, /pluginViews\.broadcast\(event, payload, scopeFilter\)/);
assert.match(
fanOut,
/if \(!SESSION_SCOPED_EVENTS\.has\(event\)\) \{[\s\S]*?pluginPanels\.broadcast\(event, payload\)/,
"selection events stay out of detached app-scoped panels",
);
});

test("the shell publishes its selection and opens a revealed view", () => {
const shell = readFileSync(
join(here, "..", "src", "features", "app", "useAppShellRuntime.tsx"),
Expand Down
45 changes: 44 additions & 1 deletion apps/desktop/test/plugin-provider-catalog-ipc.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import test from "node:test";
import ts from "typescript";
import { IPC } from "../../../packages/shared/src/protocol.ts";
import { pluginProviderCatalogEntries } from "../electron/main/plugin-provider-catalog.ts";
import { createSessionPushGate } from "../electron/main/plugin-session-push-gate.ts";

/** Load the real main-process registration function with unrelated hosts stubbed. */
function loadPluginUiIpc() {
function loadPluginUiIpc({ gateFactory = () => () => ({ ignored: false }) } = {}) {
const file = new URL("../electron/main/ipc/plugin-ui-ipc.ts", import.meta.url);
const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
Expand All @@ -28,6 +29,7 @@ function loadPluginUiIpc() {
"../plugin-view-host": { pluginViewKey: (...parts) => parts.join("/") },
"../plugin-panel-host": {},
"../plugin-provider-catalog": { pluginProviderCatalogEntries },
"../plugin-session-push-gate": { createSessionPushGate: gateFactory },
};
new Function("require", "exports", "module", outputText)(
(id) => {
Expand Down Expand Up @@ -90,3 +92,44 @@ test("the plugin Add Service IPC returns declared categories through the Host ha
description: "一句话中文简介。",
}]);
});

test("the plugin session IPC resets ordering on renderer reload and rejects stale pushes", async () => {
const handlers = new Map();
const activeSessions = [];
loadPluginUiIpc({ gateFactory: createSessionPushGate })({
registrar: { handle: (channel, handler) => handlers.set(channel, handler) },
plugins: { listLoaded: () => [] },
browserHost: {},
pluginViews: {},
pluginPanels: {},
pluginActiveInProject: () => true,
currentWorkspacePath: () => null,
getUpdaterLocale: () => "en",
getPluginPanelTheme: () => ({}),
setActivePluginSession: (session) => activeSessions.push(session),
});

const handler = handlers.get(IPC.invoke.pluginSessionActive);
assert.equal(typeof handler, "function", "session-active IPC handler is registered");
assert.deepEqual(
await handler({ sessionId: "before-reload", projectPath: "/work/a", seq: 8, bootId: "boot-a" }),
{ ok: true },
);
assert.deepEqual(
await handler({ sessionId: "after-reload", projectPath: "/work/b", seq: 1, bootId: "boot-b" }),
{ ok: true },
);
assert.deepEqual(
await handler({ sessionId: "after-newer-push", projectPath: "/work/b", seq: 3, bootId: "boot-b" }),
{ ok: true },
);
assert.deepEqual(
await handler({ sessionId: "stale", projectPath: "/work/a", seq: 2, bootId: "boot-b" }),
{ ok: true, ignored: true },
);
assert.deepEqual(activeSessions, [
{ sessionId: "before-reload", projectPath: "/work/a" },
{ sessionId: "after-reload", projectPath: "/work/b" },
{ sessionId: "after-newer-push", projectPath: "/work/b" },
]);
});
38 changes: 38 additions & 0 deletions apps/desktop/test/plugin-services.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -522,3 +522,41 @@ test("capability badges include every declared plugin capability", () => {
"bus",
]);
});

test("the session:activated scope filter derives from the announced session's project", async (t) => {
const { sessionEventScopeFilter, SESSION_SCOPED_EVENTS } = await import(
"../electron/main/plugin-session-scope.ts"
);
const sessionProjects = new Map([["s-a", "/work/a"]]);
const activeInProject = (pluginId, projectPath) =>
projectPath === "/work/a" && pluginId === "demo.in-scope";

// A selection event produces a filter that keys on the announced session's
// project: the in-scope plugin passes, another project's plugin does not.
const filter = sessionEventScopeFilter(
"session:activated",
"s-a",
sessionProjects,
activeInProject,
);
assert.equal(typeof filter, "function");
assert.equal(filter("demo.in-scope"), true);
assert.equal(filter("demo.out-of-scope"), false);

// A session without a project checks against null, not against whatever
// the map happens to hold for another session.
const noProject = sessionEventScopeFilter(
"session:activated",
"s-global",
sessionProjects,
activeInProject,
);
assert.equal(noProject("demo.in-scope"), false, "null project is passed through, not the map's value");

// Other session events fan out unconditionally.
assert.equal(
sessionEventScopeFilter("session:turnEnded", "s-a", sessionProjects, activeInProject),
undefined,
);
assert.ok(SESSION_SCOPED_EVENTS.has("session:activated"));
});
Loading
Loading