2026-04-22 00:24:21 +08:00
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
|
2026-05-01 17:20:00 +08:00
|
|
|
import { app } from "electron/main";
|
2026-04-22 00:24:21 +08:00
|
|
|
import type { Session, WebContents } from "electron/main";
|
|
|
|
|
|
|
|
|
|
import type {
|
|
|
|
|
DesktopObservedRequest,
|
|
|
|
|
DesktopObservedRequestKind,
|
|
|
|
|
DesktopObservedRequestSnapshot,
|
|
|
|
|
DesktopObservedRequestSource,
|
|
|
|
|
} from "../shared/network-debug";
|
|
|
|
|
|
|
|
|
|
interface ActiveObservedRequest {
|
|
|
|
|
id: string;
|
|
|
|
|
startedAt: number;
|
|
|
|
|
kind: DesktopObservedRequestKind;
|
|
|
|
|
source: DesktopObservedRequestSource;
|
|
|
|
|
label: string;
|
|
|
|
|
method: string;
|
|
|
|
|
url: string;
|
|
|
|
|
partition: string | null;
|
|
|
|
|
resourceType: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface StartObservedRequestOptions {
|
|
|
|
|
kind?: DesktopObservedRequestKind;
|
|
|
|
|
source?: DesktopObservedRequestSource;
|
|
|
|
|
label: string;
|
|
|
|
|
method?: string;
|
|
|
|
|
url: string;
|
|
|
|
|
partition?: string | null;
|
|
|
|
|
resourceType?: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const observedSessions = new WeakSet<Session>();
|
|
|
|
|
const activeRequests = new Map<string, ActiveObservedRequest>();
|
|
|
|
|
const observedRecords: DesktopObservedRequest[] = [];
|
|
|
|
|
const maxObservedRecords = 240;
|
|
|
|
|
const requestFilter = {
|
|
|
|
|
urls: ["http://*/*", "https://*/*", "ws://*/*", "wss://*/*"],
|
|
|
|
|
};
|
|
|
|
|
const redactedQueryKeys = /token|signature|sig|auth|key|password|session/i;
|
2026-05-01 17:20:00 +08:00
|
|
|
const networkObserverEnabled = !app.isPackaged;
|
2026-04-22 00:24:21 +08:00
|
|
|
|
|
|
|
|
let rendererTarget: WebContents | null = null;
|
|
|
|
|
let originalFetch: typeof globalThis.fetch | null = null;
|
|
|
|
|
|
|
|
|
|
export function installObservedGlobalFetch(): void {
|
2026-05-01 17:20:00 +08:00
|
|
|
if (!networkObserverEnabled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:24:21 +08:00
|
|
|
if (originalFetch || typeof globalThis.fetch !== "function") {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
originalFetch = globalThis.fetch.bind(globalThis);
|
|
|
|
|
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
|
|
|
const url = requestURL(input);
|
|
|
|
|
if (!url || !isInspectableURL(url) || !originalFetch) {
|
|
|
|
|
return originalFetch!(input, init);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const method = requestMethod(input, init);
|
|
|
|
|
const requestId = startObservedRequest({
|
|
|
|
|
kind: inferKindFromHeaders(input, init),
|
|
|
|
|
source: "main",
|
|
|
|
|
label: "main.fetch",
|
|
|
|
|
method,
|
|
|
|
|
url,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await originalFetch(input, init);
|
|
|
|
|
resolveObservedRequest(requestId, {
|
|
|
|
|
phase: "response",
|
|
|
|
|
status: response.status,
|
|
|
|
|
});
|
|
|
|
|
return response;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
failObservedRequest(requestId, error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}) as typeof globalThis.fetch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function registerObservedRequestRendererTarget(webContents: WebContents): void {
|
2026-05-01 17:20:00 +08:00
|
|
|
if (!networkObserverEnabled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:24:21 +08:00
|
|
|
rendererTarget = webContents;
|
|
|
|
|
webContents.once("destroyed", () => {
|
|
|
|
|
if (rendererTarget === webContents) {
|
|
|
|
|
rendererTarget = null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function observeSessionRequests(
|
|
|
|
|
target: Session,
|
|
|
|
|
options: { label: string; partition?: string | null },
|
|
|
|
|
): void {
|
2026-05-01 17:20:00 +08:00
|
|
|
if (!networkObserverEnabled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:24:21 +08:00
|
|
|
if (observedSessions.has(target)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
observedSessions.add(target);
|
|
|
|
|
|
|
|
|
|
target.webRequest.onBeforeRequest(requestFilter, (details, callback) => {
|
|
|
|
|
if (!isInspectableURL(details.url)) {
|
|
|
|
|
callback({});
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const requestId = sessionRequestId(details.id, options.partition ?? null);
|
|
|
|
|
activeRequests.set(requestId, {
|
|
|
|
|
id: requestId,
|
|
|
|
|
startedAt: Date.now(),
|
|
|
|
|
kind: details.resourceType === "webSocket" ? "ws" : "http",
|
|
|
|
|
source: "session",
|
|
|
|
|
label: options.label,
|
|
|
|
|
method: normalizeMethod(details.method),
|
|
|
|
|
url: sanitizeURL(details.url),
|
|
|
|
|
partition: options.partition ?? null,
|
|
|
|
|
resourceType: normalizeResourceType(details.resourceType),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pushRecord(buildRecord(activeRequests.get(requestId)!, {
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
phase: "request",
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
callback({});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
target.webRequest.onCompleted(requestFilter, (details) => {
|
|
|
|
|
if (!isInspectableURL(details.url)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const requestId = sessionRequestId(details.id, options.partition ?? null);
|
|
|
|
|
const active = activeRequests.get(requestId)
|
|
|
|
|
?? createFallbackActiveRequest(requestId, {
|
|
|
|
|
source: "session",
|
|
|
|
|
label: options.label,
|
|
|
|
|
method: normalizeMethod(details.method),
|
|
|
|
|
url: details.url,
|
|
|
|
|
partition: options.partition ?? null,
|
|
|
|
|
resourceType: normalizeResourceType(details.resourceType),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pushRecord(buildRecord(active, {
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
phase: "response",
|
|
|
|
|
status: details.statusCode,
|
|
|
|
|
durationMs: Date.now() - active.startedAt,
|
|
|
|
|
}));
|
|
|
|
|
activeRequests.delete(requestId);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
target.webRequest.onErrorOccurred(requestFilter, (details) => {
|
|
|
|
|
if (!isInspectableURL(details.url)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const requestId = sessionRequestId(details.id, options.partition ?? null);
|
|
|
|
|
const active = activeRequests.get(requestId)
|
|
|
|
|
?? createFallbackActiveRequest(requestId, {
|
|
|
|
|
source: "session",
|
|
|
|
|
label: options.label,
|
|
|
|
|
method: normalizeMethod(details.method),
|
|
|
|
|
url: details.url,
|
|
|
|
|
partition: options.partition ?? null,
|
|
|
|
|
resourceType: normalizeResourceType(details.resourceType),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
pushRecord(buildRecord(active, {
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
phase: "error",
|
|
|
|
|
durationMs: Date.now() - active.startedAt,
|
|
|
|
|
error: normalizeErrorMessage(details.error),
|
|
|
|
|
}));
|
|
|
|
|
activeRequests.delete(requestId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function startObservedRequest(options: StartObservedRequestOptions): string {
|
2026-05-01 17:20:00 +08:00
|
|
|
if (!networkObserverEnabled) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:24:21 +08:00
|
|
|
const id = `main:${randomUUID()}`;
|
|
|
|
|
const active: ActiveObservedRequest = {
|
|
|
|
|
id,
|
|
|
|
|
startedAt: Date.now(),
|
|
|
|
|
kind: options.kind ?? "http",
|
|
|
|
|
source: options.source ?? "main",
|
|
|
|
|
label: options.label,
|
|
|
|
|
method: normalizeMethod(options.method),
|
|
|
|
|
url: sanitizeURL(options.url),
|
|
|
|
|
partition: options.partition ?? null,
|
|
|
|
|
resourceType: options.resourceType ?? null,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
activeRequests.set(id, active);
|
|
|
|
|
pushRecord(buildRecord(active, {
|
|
|
|
|
at: active.startedAt,
|
|
|
|
|
phase: "request",
|
|
|
|
|
}));
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveObservedRequest(
|
|
|
|
|
id: string,
|
|
|
|
|
options: {
|
|
|
|
|
phase?: "response" | "close";
|
|
|
|
|
status?: number | null;
|
|
|
|
|
error?: string | null;
|
|
|
|
|
keepActive?: boolean;
|
|
|
|
|
} = {},
|
|
|
|
|
): void {
|
|
|
|
|
const active = activeRequests.get(id);
|
|
|
|
|
if (!active) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pushRecord(buildRecord(active, {
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
phase: options.phase ?? "response",
|
|
|
|
|
status: options.status ?? null,
|
|
|
|
|
durationMs: Date.now() - active.startedAt,
|
|
|
|
|
error: options.error ?? null,
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (!options.keepActive) {
|
|
|
|
|
activeRequests.delete(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function failObservedRequest(id: string, error: unknown): void {
|
|
|
|
|
const active = activeRequests.get(id);
|
|
|
|
|
if (!active) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pushRecord(buildRecord(active, {
|
|
|
|
|
at: Date.now(),
|
|
|
|
|
phase: "error",
|
|
|
|
|
durationMs: Date.now() - active.startedAt,
|
|
|
|
|
error: normalizeErrorMessage(error),
|
|
|
|
|
}));
|
|
|
|
|
activeRequests.delete(id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getObservedRequestSnapshot(): DesktopObservedRequestSnapshot {
|
2026-05-01 17:20:00 +08:00
|
|
|
if (!networkObserverEnabled) {
|
|
|
|
|
return {
|
|
|
|
|
rendererAttached: false,
|
|
|
|
|
activeCount: 0,
|
|
|
|
|
recent: [],
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 00:24:21 +08:00
|
|
|
return {
|
|
|
|
|
rendererAttached: Boolean(rendererTarget && !rendererTarget.isDestroyed()),
|
|
|
|
|
activeCount: activeRequests.size,
|
|
|
|
|
recent: observedRecords.slice(-80),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requestURL(input: RequestInfo | URL): string | null {
|
|
|
|
|
if (typeof input === "string") {
|
|
|
|
|
return input;
|
|
|
|
|
}
|
|
|
|
|
if (input instanceof URL) {
|
|
|
|
|
return input.toString();
|
|
|
|
|
}
|
|
|
|
|
return typeof input.url === "string" ? input.url : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requestMethod(input: RequestInfo | URL, init?: RequestInit): string {
|
|
|
|
|
if (init?.method) {
|
|
|
|
|
return normalizeMethod(init.method);
|
|
|
|
|
}
|
|
|
|
|
if (typeof Request !== "undefined" && input instanceof Request) {
|
|
|
|
|
return normalizeMethod(input.method);
|
|
|
|
|
}
|
|
|
|
|
return "GET";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function inferKindFromHeaders(input: RequestInfo | URL, init?: RequestInit): DesktopObservedRequestKind {
|
|
|
|
|
const headers = new Headers(init?.headers);
|
|
|
|
|
if (!headers.has("accept") && typeof Request !== "undefined" && input instanceof Request) {
|
|
|
|
|
const requestAccept = input.headers.get("accept");
|
|
|
|
|
if (requestAccept) {
|
|
|
|
|
headers.set("accept", requestAccept);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const accept = headers.get("accept")?.toLowerCase() ?? "";
|
|
|
|
|
if (accept.includes("text/event-stream")) {
|
|
|
|
|
return "sse";
|
|
|
|
|
}
|
|
|
|
|
return "http";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isInspectableURL(url: string): boolean {
|
|
|
|
|
try {
|
|
|
|
|
const protocol = new URL(url).protocol;
|
|
|
|
|
return protocol === "http:" || protocol === "https:" || protocol === "ws:" || protocol === "wss:";
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sanitizeURL(url: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(url);
|
|
|
|
|
for (const key of parsed.searchParams.keys()) {
|
|
|
|
|
if (redactedQueryKeys.test(key)) {
|
|
|
|
|
parsed.searchParams.set(key, "[REDACTED]");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
} catch {
|
|
|
|
|
return url;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeMethod(value: string | undefined | null): string {
|
|
|
|
|
return typeof value === "string" && value.trim() ? value.trim().toUpperCase() : "GET";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeResourceType(value: unknown): string | null {
|
|
|
|
|
return typeof value === "string" && value.trim() ? value : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeErrorMessage(error: unknown): string {
|
|
|
|
|
if (typeof error === "string" && error.trim()) {
|
|
|
|
|
return error;
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof Error) {
|
|
|
|
|
return error.message || error.name || "request_failed";
|
|
|
|
|
}
|
|
|
|
|
if (error && typeof error === "object" && "message" in error) {
|
|
|
|
|
const message = (error as { message?: unknown }).message;
|
|
|
|
|
if (typeof message === "string" && message.trim()) {
|
|
|
|
|
return message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "request_failed";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sessionRequestId(id: number, partition: string | null): string {
|
|
|
|
|
return `session:${partition ?? "default"}:${id}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFallbackActiveRequest(
|
|
|
|
|
id: string,
|
|
|
|
|
options: {
|
|
|
|
|
source: DesktopObservedRequestSource;
|
|
|
|
|
label: string;
|
|
|
|
|
method?: string;
|
|
|
|
|
url: string;
|
|
|
|
|
partition?: string | null;
|
|
|
|
|
resourceType?: string | null;
|
|
|
|
|
},
|
|
|
|
|
): ActiveObservedRequest {
|
|
|
|
|
return {
|
|
|
|
|
id,
|
|
|
|
|
startedAt: Date.now(),
|
|
|
|
|
kind: options.resourceType === "webSocket" ? "ws" : "http",
|
|
|
|
|
source: options.source,
|
|
|
|
|
label: options.label,
|
|
|
|
|
method: normalizeMethod(options.method),
|
|
|
|
|
url: sanitizeURL(options.url),
|
|
|
|
|
partition: options.partition ?? null,
|
|
|
|
|
resourceType: options.resourceType ?? null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildRecord(
|
|
|
|
|
active: ActiveObservedRequest,
|
|
|
|
|
input: {
|
|
|
|
|
at: number;
|
|
|
|
|
phase: DesktopObservedRequest["phase"];
|
|
|
|
|
status?: number | null;
|
|
|
|
|
durationMs?: number | null;
|
|
|
|
|
error?: string | null;
|
|
|
|
|
},
|
|
|
|
|
): DesktopObservedRequest {
|
|
|
|
|
return {
|
|
|
|
|
id: active.id,
|
|
|
|
|
at: input.at,
|
|
|
|
|
phase: input.phase,
|
|
|
|
|
kind: active.kind,
|
|
|
|
|
source: active.source,
|
|
|
|
|
label: active.label,
|
|
|
|
|
method: active.method,
|
|
|
|
|
url: active.url,
|
|
|
|
|
partition: active.partition,
|
|
|
|
|
resourceType: active.resourceType,
|
|
|
|
|
status: input.status ?? null,
|
|
|
|
|
durationMs: input.durationMs ?? null,
|
|
|
|
|
error: input.error ?? null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushRecord(record: DesktopObservedRequest): void {
|
|
|
|
|
observedRecords.push(record);
|
|
|
|
|
if (observedRecords.length > maxObservedRecords) {
|
|
|
|
|
observedRecords.splice(0, observedRecords.length - maxObservedRecords);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!rendererTarget || rendererTarget.isDestroyed()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
rendererTarget.send("desktop:network-observed", record);
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort diagnostics only.
|
|
|
|
|
}
|
|
|
|
|
}
|