From c28d47bf135b1cce85d71b22e7f9f9214ee5ac16 Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Sat, 31 Oct 2026 03:33:12 +0800 Subject: [PATCH] fix(electron): retain wrapped protocol handler registrations --- .../electronStaticAnalysisProtocol.test.ts | 42 ++++++++- .../electronStaticAnalysisProtocol.ts | 9 +- .../electronProtocolHandleCli.test.ts | 87 +++++++++++++++++++ .../electronProtocolBoundaries.test.ts | 46 ++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 tests/acceptance/applications/electronProtocolHandleCli.test.ts diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts index 5df4809b64..63fecbc2b6 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.test.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.test.ts @@ -289,8 +289,18 @@ describe("Electron protocol registrations without a scheme", () => { describe("Electron protocol calls that register nothing", () => { it.each([ - ["a handler without a callback", 'protocol.handle("app");'], - ["a handler with a null callback", 'protocol.handle("app", null);'], + [ + "a legacy handler without a callback", + 'protocol.registerStringProtocol("app");', + ], + [ + "a legacy handler with a null callback", + 'protocol.registerStringProtocol("app", null);', + ], + [ + "a legacy handler with a void callback", + 'protocol.registerStringProtocol("app", void 0);', + ], [ "an interceptor without a callback", 'protocol.interceptHttpProtocol("https");', @@ -331,3 +341,31 @@ describe("Electron protocol calls that register nothing", () => { ]); }); }); + +describe("Electron wrapped protocol.handle registrations", () => { + it.each([ + ["protocol", "default-protocol"], + ["session.defaultSession.protocol", "default-session"], + ])( + "retains %s registrations before handler invocation", + (receiver, session) => { + const protocols = protocolsOf(` + const { protocol, session } = require("electron"); + ${receiver}.handle("omitted"); + ${receiver}.handle("null", null); + ${receiver}.handle("undefined", void 0); + `); + expect(protocols).toMatchObject([ + { api: "handle", role: "handler", session, scheme: literal("omitted") }, + { api: "handle", role: "handler", session, scheme: literal("null") }, + { + api: "handle", + role: "handler", + session, + scheme: literal("undefined"), + }, + ]); + expect(protocols).toHaveLength(3); + }, + ); +}); diff --git a/src/domain/javascript/electronStaticAnalysisProtocol.ts b/src/domain/javascript/electronStaticAnalysisProtocol.ts index 8667c9da2b..d6887e78ea 100644 --- a/src/domain/javascript/electronStaticAnalysisProtocol.ts +++ b/src/domain/javascript/electronStaticAnalysisProtocol.ts @@ -87,7 +87,12 @@ export const inspectElectronProtocolNode = ( : INTERCEPTORS.has(target.api) ? "interceptor" : undefined; - if (role !== undefined && !handlerKnownMissing(node)) + // handle installs its own native callback and invokes the supplied handler + // only when a request arrives; a missing handler does not prevent registration. + if ( + role !== undefined && + (target.api === "handle" || !handlerKnownMissing(node)) + ) addProtocol(context, { node, api: target.api, @@ -97,7 +102,7 @@ export const inspectElectronProtocolNode = ( }); }; -/** Electron requires the callback, so a call without one installs nothing. */ +/** Legacy native registration APIs require a callback before installing it. */ const handlerKnownMissing = ( node: t.CallExpression | t.OptionalCallExpression, ): boolean => { diff --git a/tests/acceptance/applications/electronProtocolHandleCli.test.ts b/tests/acceptance/applications/electronProtocolHandleCli.test.ts new file mode 100644 index 0000000000..37abfe92c3 --- /dev/null +++ b/tests/acceptance/applications/electronProtocolHandleCli.test.ts @@ -0,0 +1,87 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains wrapped protocol.handle source registrations through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-electron-handle-presence-"); + await writeFile( + join(root, "package.json"), + JSON.stringify({ main: "main.cjs" }), + ); + await writeFile( + join(root, "main.cjs"), + ` + const { protocol, session } = require("electron"); + protocol.handle("module-omitted"); + protocol.handle("module-null", null); + protocol.handle("module-undefined", void 0); + session.defaultSession.protocol.handle("session-omitted"); + session.defaultSession.protocol.handle("session-null", null); + session.defaultSession.protocol.handle("session-undefined", void 0); + protocol.registerStringProtocol("legacy-omitted"); + protocol.registerStringProtocol("legacy-null", null); + protocol.registerStringProtocol("legacy-undefined", void 0); + throw new Error("analysis fixture must remain inert"); + `, + ); + let document: unknown; + if (surface === "CLI") { + const response = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(response.exitCode, response.stderr).toBe(0); + document = response.json; + } else { + const { call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + document = response.structuredContent; + } + const result = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(document).normalized_result; + expect(result.summary.protocol_registrations).toBe(6); + const schemes = result.graph.nodes.filter( + ({ kind }) => kind === "url-scheme", + ); + expect( + schemes.map((node) => node.observations[0]?.label).toSorted(), + ).toEqual([ + "module-null", + "module-omitted", + "module-undefined", + "session-null", + "session-omitted", + "session-undefined", + ]); + for (const scheme of schemes) { + const observation = scheme.observations[0]; + expect(observation?.properties).toMatchObject({ + api: "handle", + role: "handler", + resolution: "literal", + }); + expect(observation?.evidence).toMatchObject({ + authority: "ast-static-analysis", + state: "observed", + limitations: expect.arrayContaining([ + expect.stringContaining("successful registration"), + ]), + }); + } + expect( + result.graph.edges.filter((edge) => edge.relation === "handles"), + ).toHaveLength(6); + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts b/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts index a112fd8ae3..285fae7529 100644 --- a/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts +++ b/tests/boundary/filesystem/javascript-applications/electronProtocolBoundaries.test.ts @@ -37,6 +37,52 @@ const protocolFixture = async (): Promise => { return root; }; +describe("Electron wrapped protocol.handle graph", () => { + it("retains wrapped handle registrations while rejecting missing legacy callbacks", async () => { + const root = await createTestTempDirectory( + "rea-electron-wrapped-protocol-", + ); + await writeFile( + join(root, "main.cjs"), + ` + const { protocol, session } = require("electron"); + protocol.handle("module-omitted"); + protocol.handle("module-null", null); + protocol.handle("module-undefined", void 0); + session.defaultSession.protocol.handle("session-omitted"); + session.defaultSession.protocol.handle("session-null", null); + session.defaultSession.protocol.handle("session-undefined", void 0); + protocol.registerStringProtocol("legacy-omitted"); + protocol.registerStringProtocol("legacy-null", null); + protocol.registerStringProtocol("legacy-undefined", void 0); + `, + ); + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const schemes = graph.nodes.filter(({ kind }) => kind === "url-scheme"); + expect( + schemes.map((node) => node.observations[0]?.label).toSorted(), + ).toEqual([ + "module-null", + "module-omitted", + "module-undefined", + "session-null", + "session-omitted", + "session-undefined", + ]); + expect( + graph.edges.filter((edge) => edge.relation === "handles"), + ).toHaveLength(6); + for (const scheme of schemes) { + expect(scheme.observations[0]?.properties).toMatchObject({ + api: "handle", + role: "handler", + resolution: "literal", + }); + } + }); +}); + describe("Electron URL scheme boundaries", () => { it("joins case-insensitive scheme identities while preserving declared spellings", async () => { const root = await createTestTempDirectory("rea-electron-protocol-case-");