feat(monitoring): dispatch monitoring tasks to desktop via AMQP outbox
Replace SSE /desktop/events with priority AMQP dispatch for monitoring
runs, and add phase1/phase2 infrastructure so desktop workers can lease,
resume, report, skip, and cancel monitoring tasks over the existing
dispatch WebSocket.
- Add monitoring collect outbox worker and phase2 desktop task fields
- Add /desktop/monitoring/tasks/{lease,resume,result,skip,cancel} routes
- Introduce execution-devtools and network-observer on desktop runtime
- Refactor runtime-controller, LoginView, and doubao adapter for the new flow
- Add channelName column to tracking views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { ensureViewLoaded, normalizeText } from "./common";
|
||||
import type { JsonValue, MonitoringSourceItem } from "@geo/shared-types";
|
||||
import type { Session } from "electron/main";
|
||||
import type { Page as PlaywrightPage } from "playwright-core";
|
||||
|
||||
import { normalizeText } from "./common";
|
||||
import type { MonitorAdapter } from "./base";
|
||||
|
||||
const DOUBAO_APP_ID = "497858";
|
||||
const DOUBAO_BOT_ID = "7338286299411103781";
|
||||
const DOUBAO_HOME_URL = "https://www.doubao.com/";
|
||||
const DOUBAO_CHAT_URL = "https://www.doubao.com/chat/completion";
|
||||
const DOUBAO_REFERER = "https://www.doubao.com/chat/";
|
||||
|
||||
@@ -63,6 +69,7 @@ type DoubaoStreamEvent = {
|
||||
|
||||
type DoubaoStreamSummary = {
|
||||
answer: string | null;
|
||||
reasoning: string | null;
|
||||
provider_request_id: string | null;
|
||||
request_id: string | null;
|
||||
conversation_id: string | null;
|
||||
@@ -72,6 +79,12 @@ type DoubaoStreamSummary = {
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
const ANSWER_EVENT_WHITELIST = new Set([
|
||||
"STREAM_CHUNK",
|
||||
"STREAM_MSG_NOTIFY",
|
||||
"FULL_MSG_NOTIFY",
|
||||
]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -326,13 +339,30 @@ function collectSources(
|
||||
}
|
||||
}
|
||||
|
||||
function collectAnswerFragments(payload: unknown, result: string[] = []): string[] {
|
||||
type AnswerFragment = {
|
||||
text: string;
|
||||
kind: "answer" | "reasoning";
|
||||
};
|
||||
|
||||
type AnswerWalkContext = {
|
||||
inReasoning: boolean;
|
||||
};
|
||||
|
||||
function isReasoningIcon(value: unknown): boolean {
|
||||
return typeof value === "string" && value.includes("Deep_Think");
|
||||
}
|
||||
|
||||
function collectAnswerFragments(
|
||||
payload: unknown,
|
||||
result: AnswerFragment[] = [],
|
||||
context: AnswerWalkContext = { inReasoning: false },
|
||||
): AnswerFragment[] {
|
||||
if (payload == null) {
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(payload)) {
|
||||
for (const item of payload) {
|
||||
collectAnswerFragments(item, result);
|
||||
collectAnswerFragments(item, result, context);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -340,19 +370,32 @@ function collectAnswerFragments(payload: unknown, result: string[] = []): string
|
||||
return result;
|
||||
}
|
||||
|
||||
const textBlockText = readNestedString(payload, ["text_block", "text"]);
|
||||
if (textBlockText) {
|
||||
result.push(textBlockText);
|
||||
let nextContext = context;
|
||||
if (typeof payload.block_type === "number" && payload.block_type !== 10000) {
|
||||
// Non-answer blocks (search queries, status cards, etc.) — skip entirely.
|
||||
return result;
|
||||
}
|
||||
if (isReasoningIcon((payload as { icon_url?: unknown }).icon_url)) {
|
||||
nextContext = { inReasoning: true };
|
||||
}
|
||||
|
||||
const textBlock = payload.text_block;
|
||||
if (isRecord(textBlock)) {
|
||||
const text = normalizeOptionalString(textBlock.text);
|
||||
if (text) {
|
||||
const reasoning = context.inReasoning || isReasoningIcon(textBlock.icon_url);
|
||||
result.push({ text, kind: reasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
}
|
||||
|
||||
const deltaText = normalizeOptionalString(payload.delta);
|
||||
if (deltaText) {
|
||||
result.push(deltaText);
|
||||
result.push({ text: deltaText, kind: nextContext.inReasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
|
||||
const nestedDeltaText = readNestedString(payload, ["delta", "text"]);
|
||||
if (nestedDeltaText) {
|
||||
result.push(nestedDeltaText);
|
||||
result.push({ text: nestedDeltaText, kind: nextContext.inReasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
|
||||
const assistantText =
|
||||
@@ -360,12 +403,13 @@ function collectAnswerFragments(payload: unknown, result: string[] = []): string
|
||||
normalizeOptionalString(payload.message_type) === "assistant") &&
|
||||
normalizeOptionalString(payload.text);
|
||||
if (assistantText) {
|
||||
result.push(assistantText);
|
||||
result.push({ text: assistantText, kind: nextContext.inReasoning ? "reasoning" : "answer" });
|
||||
}
|
||||
|
||||
for (const value of Object.values(payload)) {
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (key === "text_block") continue;
|
||||
if (Array.isArray(value) || isRecord(value)) {
|
||||
collectAnswerFragments(value, result);
|
||||
collectAnswerFragments(value, result, nextContext);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +467,7 @@ function formatDoubaoStreamError(payload: unknown): string {
|
||||
async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummary> {
|
||||
const summary: DoubaoStreamSummary = {
|
||||
answer: null,
|
||||
reasoning: null,
|
||||
provider_request_id: null,
|
||||
request_id: null,
|
||||
conversation_id: null,
|
||||
@@ -466,9 +511,15 @@ async function parseDoubaoStream(response: Response): Promise<DoubaoStreamSummar
|
||||
summary.request_id = findFirstStringByKey(parsed.payload, ["message_id", "local_message_id", "reply_id"]);
|
||||
}
|
||||
|
||||
const fragments = collectAnswerFragments(parsed.payload);
|
||||
for (const fragment of fragments) {
|
||||
summary.answer = mergeAnswerText(summary.answer, fragment);
|
||||
if (ANSWER_EVENT_WHITELIST.has(parsed.event.toUpperCase())) {
|
||||
const fragments = collectAnswerFragments(parsed.payload);
|
||||
for (const fragment of fragments) {
|
||||
if (fragment.kind === "answer") {
|
||||
summary.answer = mergeAnswerText(summary.answer, fragment.text);
|
||||
} else {
|
||||
summary.reasoning = mergeAnswerText(summary.reasoning, fragment.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectSources(parsed.payload, summary.search_results, summary.citations);
|
||||
@@ -573,101 +624,496 @@ function buildDoubaoRequestBody(questionText: string, runtimeState: DoubaoRuntim
|
||||
};
|
||||
}
|
||||
|
||||
function parseRuntimeState(value: unknown): DoubaoRuntimeState | null {
|
||||
const DOUBAO_RUNTIME_STATE_FIELDS: Array<keyof DoubaoRuntimeState> = [
|
||||
"fp",
|
||||
"ms_token",
|
||||
"device_id",
|
||||
"web_id",
|
||||
"tea_uuid",
|
||||
];
|
||||
const DOUBAO_RUNTIME_STATE_TIMEOUT_MS = 15_000;
|
||||
const DOUBAO_RUNTIME_STATE_POLL_INTERVAL_MS = 500;
|
||||
const DOUBAO_RUNTIME_STATE_DISK_SCAN_FILE_LIMIT = 8;
|
||||
const DOUBAO_RUNTIME_STATE_DISK_SCAN_MAX_BYTES = 512_000;
|
||||
const DOUBAO_XMST_PATTERN = /xmst[\x00-\x20\x7f-\xff]{0,8}([A-Za-z0-9_-]{20,}={0,2})/g;
|
||||
const DOUBAO_TEA_TOKENS_PATTERN = /__tea_cache_tokens_497858(?:u)?[\x00-\x20\x7f-\xff]{0,8}(\{[^{}]{0,500}\})/g;
|
||||
const DOUBAO_SAMANTHA_WEB_ID_PATTERN = /samantha_web_web_id[\x00-\x20\x7f-\xff]{0,8}(\{[^{}]{0,500}\})/g;
|
||||
const DOUBAO_SLARDAR_FLOW_PATTERN = /SLARDARflow_web[\x00-\x20\x7f-\xff]{0,16}(JTdC[A-Za-z0-9+/=_-]{40,}JTdE)/g;
|
||||
|
||||
function parseRuntimeState(value: unknown): {
|
||||
state: DoubaoRuntimeState | null;
|
||||
missing: Array<keyof DoubaoRuntimeState>;
|
||||
readError: string | null;
|
||||
} {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
return { state: null, missing: [...DOUBAO_RUNTIME_STATE_FIELDS], readError: null };
|
||||
}
|
||||
|
||||
const readError = normalizeOptionalString(value.error);
|
||||
const fp = normalizeOptionalString(value.fp);
|
||||
const msToken = normalizeOptionalString(value.ms_token);
|
||||
const deviceID = normalizeOptionalString(value.device_id);
|
||||
const webID = normalizeOptionalString(value.web_id);
|
||||
const teaUUID = normalizeOptionalString(value.tea_uuid);
|
||||
if (!fp || !msToken || !deviceID || !webID || !teaUUID) {
|
||||
return null;
|
||||
|
||||
const missing: Array<keyof DoubaoRuntimeState> = [];
|
||||
if (!fp) missing.push("fp");
|
||||
if (!msToken) missing.push("ms_token");
|
||||
if (!deviceID) missing.push("device_id");
|
||||
if (!webID) missing.push("web_id");
|
||||
if (!teaUUID) missing.push("tea_uuid");
|
||||
|
||||
if (missing.length > 0 || !fp || !msToken || !deviceID || !webID || !teaUUID) {
|
||||
return { state: null, missing, readError };
|
||||
}
|
||||
|
||||
return {
|
||||
fp,
|
||||
ms_token: msToken,
|
||||
device_id: deviceID,
|
||||
web_id: webID,
|
||||
tea_uuid: teaUUID,
|
||||
state: {
|
||||
fp,
|
||||
ms_token: msToken,
|
||||
device_id: deviceID,
|
||||
web_id: webID,
|
||||
tea_uuid: teaUUID,
|
||||
},
|
||||
missing,
|
||||
readError,
|
||||
};
|
||||
}
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new Error("adapter_aborted"));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("adapter_aborted"));
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePageOnDoubaoChat(
|
||||
page: PlaywrightPage,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
const currentURL = safePlaywrightPageURL(page);
|
||||
if (!currentURL.startsWith("https://www.doubao.com/")) {
|
||||
await page.goto(DOUBAO_HOME_URL, { waitUntil: "domcontentloaded" });
|
||||
if (signal.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
}
|
||||
if (!safePlaywrightPageURL(page).startsWith("https://www.doubao.com/chat")) {
|
||||
await page.goto(DOUBAO_REFERER, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await page.waitForLoadState("networkidle", { timeout: 3_000 }).catch(() => undefined);
|
||||
if (signal.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
}
|
||||
|
||||
function safePlaywrightPageURL(page: PlaywrightPage): string {
|
||||
try {
|
||||
return page.url();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
const DOUBAO_READ_RUNTIME_STATE_SCRIPT = () => {
|
||||
try {
|
||||
const safeParse = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const readStorage = (name) => {
|
||||
try {
|
||||
return window.localStorage.getItem(name) || window.sessionStorage.getItem(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const decodeFlowState = (value) => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return null;
|
||||
}
|
||||
let current = value.trim();
|
||||
try {
|
||||
current = window.atob(current);
|
||||
} catch {
|
||||
// Keep original text when it is not base64-encoded.
|
||||
}
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(current);
|
||||
if (next === current) {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return safeParse(current);
|
||||
};
|
||||
const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id"));
|
||||
const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858"));
|
||||
const teaSDKState = safeParse(window.localStorage.getItem("__tea_sdk_ab_version_497858"));
|
||||
const flowState = decodeFlowState(readStorage("SLARDARflow_web"));
|
||||
const pick = (source, paths) => {
|
||||
for (const path of paths) {
|
||||
let current = source;
|
||||
let ok = true;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
if (ok && typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const segments = document.cookie.split(";");
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.trim().split("=");
|
||||
if (rawName === name) {
|
||||
const joined = rest.join("=").trim();
|
||||
return joined || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
fp: readCookie("s_v_web_id"),
|
||||
ms_token: readStorage("xmst") || readStorage("msToken"),
|
||||
device_id:
|
||||
pick(flowState, [["deviceId"], ["device_id"], ["id"]]) ||
|
||||
pick(samanthaState, [["device_id"], ["deviceId"], ["web_id"], ["webId"], ["id"]]),
|
||||
web_id:
|
||||
pick(teaState, [["web_id"], ["webId"], ["id"]]) ||
|
||||
pick(samanthaState, [["web_id"], ["webId"], ["device_id"], ["deviceId"], ["id"]]),
|
||||
tea_uuid:
|
||||
pick(teaState, [["user_unique_id"], ["userUniqueId"], ["tea_uuid"], ["teaUuid"], ["uuid"], ["web_id"]]) ||
|
||||
pick(teaSDKState, [["uuid"], ["user_unique_id"], ["userUniqueId"]]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "runtime_state_read_failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function lastPatternMatch(text: string, pattern: RegExp): string | null {
|
||||
pattern.lastIndex = 0;
|
||||
let last: string | null = null;
|
||||
let matched: RegExpExecArray | null = null;
|
||||
while ((matched = pattern.exec(text)) !== null) {
|
||||
const candidate = normalizeOptionalString(matched[1]);
|
||||
if (candidate) {
|
||||
last = candidate;
|
||||
}
|
||||
}
|
||||
pattern.lastIndex = 0;
|
||||
return last;
|
||||
}
|
||||
|
||||
function readFileTailAsLatin1(filePath: string, maxBytes = DOUBAO_RUNTIME_STATE_DISK_SCAN_MAX_BYTES): string {
|
||||
const buffer = readFileSync(filePath);
|
||||
if (buffer.length <= maxBytes) {
|
||||
return buffer.toString("latin1");
|
||||
}
|
||||
return buffer.subarray(buffer.length - maxBytes).toString("latin1");
|
||||
}
|
||||
|
||||
function parseDoubaoRecord(value: string | null): Record<string, unknown> | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = safeParseJSON(value);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function decodeDoubaoFlowState(value: string | null): Record<string, unknown> | null {
|
||||
const raw = normalizeOptionalString(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
let decoded = Buffer.from(raw, "base64").toString("utf8");
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = next;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const parsed = safeParseJSON(decoded);
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function flushDoubaoSessionPersistence(session: Session): Promise<void> {
|
||||
const maybeFlush = (session as Session & {
|
||||
flushStorageData?: () => Promise<void> | void;
|
||||
}).flushStorageData;
|
||||
if (typeof maybeFlush === "function") {
|
||||
try {
|
||||
await Promise.resolve(maybeFlush.call(session));
|
||||
} catch {
|
||||
// Ignore persistence flush errors and continue with the best available state.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await Promise.resolve(session.cookies.flushStore());
|
||||
} catch {
|
||||
// Ignore cookie flush errors and continue with the best available state.
|
||||
}
|
||||
}
|
||||
|
||||
async function readDoubaoCookieValue(
|
||||
session: Session,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
let cookies: Awaited<ReturnType<Session["cookies"]["get"]>> = [];
|
||||
try {
|
||||
cookies = await Promise.resolve(session.cookies.get({ url: DOUBAO_HOME_URL }));
|
||||
} catch {
|
||||
cookies = [];
|
||||
}
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.name !== name) {
|
||||
continue;
|
||||
}
|
||||
const value = normalizeOptionalString(cookie.value);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDoubaoPersistedStorageState(storagePath: string | null): Partial<DoubaoRuntimeState> {
|
||||
if (!storagePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const leveldbPath = join(storagePath, "Local Storage", "leveldb");
|
||||
let candidates: string[] = [];
|
||||
try {
|
||||
candidates = readdirSync(leveldbPath)
|
||||
.map((entry) => join(leveldbPath, entry))
|
||||
.filter((entry) => /\.(?:log|ldb)$/i.test(entry))
|
||||
.sort((left, right) => statSync(right).mtimeMs - statSync(left).mtimeMs)
|
||||
.slice(0, DOUBAO_RUNTIME_STATE_DISK_SCAN_FILE_LIMIT);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
let xmst: string | null = null;
|
||||
let teaState: Record<string, unknown> | null = null;
|
||||
let samanthaState: Record<string, unknown> | null = null;
|
||||
let flowState: Record<string, unknown> | null = null;
|
||||
|
||||
for (const filePath of candidates) {
|
||||
let text = "";
|
||||
try {
|
||||
text = readFileTailAsLatin1(filePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
xmst ||= lastPatternMatch(text, DOUBAO_XMST_PATTERN);
|
||||
teaState ||= parseDoubaoRecord(lastPatternMatch(text, DOUBAO_TEA_TOKENS_PATTERN));
|
||||
samanthaState ||= parseDoubaoRecord(lastPatternMatch(text, DOUBAO_SAMANTHA_WEB_ID_PATTERN));
|
||||
flowState ||= decodeDoubaoFlowState(lastPatternMatch(text, DOUBAO_SLARDAR_FLOW_PATTERN));
|
||||
if (xmst && teaState && samanthaState && flowState) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const deviceID =
|
||||
getDirectString(flowState ?? {}, ["deviceId", "device_id", "id"]) ??
|
||||
getDirectString(samanthaState ?? {}, ["device_id", "deviceId", "web_id", "webId", "id"]);
|
||||
const webID =
|
||||
getDirectString(teaState ?? {}, ["web_id", "webId", "id"]) ??
|
||||
getDirectString(samanthaState ?? {}, ["web_id", "webId", "device_id", "deviceId", "id"]);
|
||||
const teaUUID =
|
||||
getDirectString(teaState ?? {}, ["user_unique_id", "userUniqueId", "tea_uuid", "teaUuid", "uuid", "web_id", "webId"]) ??
|
||||
webID;
|
||||
|
||||
return {
|
||||
ms_token: xmst ?? undefined,
|
||||
device_id: deviceID ?? undefined,
|
||||
web_id: webID ?? undefined,
|
||||
tea_uuid: teaUUID ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDoubaoRuntimeStateSources(...sources: Array<Record<string, unknown> | null | undefined>): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const field of DOUBAO_RUNTIME_STATE_FIELDS) {
|
||||
for (const source of sources) {
|
||||
const value = normalizeOptionalString(source?.[field]);
|
||||
if (value) {
|
||||
merged[field] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const source of sources) {
|
||||
const error = normalizeOptionalString(source?.error);
|
||||
if (error) {
|
||||
merged.error = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function readDoubaoRuntimeStateFallback(session: Session): Promise<Record<string, unknown>> {
|
||||
const [fp, persisted] = await Promise.all([
|
||||
readDoubaoCookieValue(session, "s_v_web_id"),
|
||||
Promise.resolve(readDoubaoPersistedStorageState(session.storagePath)),
|
||||
]);
|
||||
return {
|
||||
...(fp ? { fp } : {}),
|
||||
...persisted,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDoubaoRuntimeState(context: Parameters<MonitorAdapter["query"]>[0]): Promise<DoubaoRuntimeState> {
|
||||
if (!context.view) {
|
||||
throw new Error("doubao_view_required");
|
||||
if (!context.playwright) {
|
||||
throw new Error("doubao_playwright_required");
|
||||
}
|
||||
const view = context.view;
|
||||
const page = context.playwright.page;
|
||||
|
||||
context.reportProgress("doubao.bootstrap_view");
|
||||
await ensureViewLoaded(view, DOUBAO_REFERER, context.signal);
|
||||
await ensurePageOnDoubaoChat(page, context.signal);
|
||||
await flushDoubaoSessionPersistence(context.session);
|
||||
|
||||
context.reportProgress("doubao.read_runtime_state");
|
||||
const state = await view.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
try {
|
||||
const safeParse = (value) => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const samanthaState = safeParse(window.localStorage.getItem("samantha_web_web_id"));
|
||||
const teaState = safeParse(window.localStorage.getItem("__tea_cache_tokens_497858"));
|
||||
const pick = (source, paths) => {
|
||||
for (const path of paths) {
|
||||
let current = source;
|
||||
let ok = true;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
if (ok && typeof current === "string" && current.trim()) {
|
||||
return current.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const readCookie = (name) => {
|
||||
const segments = document.cookie.split(";");
|
||||
for (const segment of segments) {
|
||||
const [rawName, ...rest] = segment.trim().split("=");
|
||||
if (rawName === name) {
|
||||
const joined = rest.join("=").trim();
|
||||
return joined || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return {
|
||||
fp: readCookie("s_v_web_id"),
|
||||
ms_token: window.localStorage.getItem("xmst"),
|
||||
device_id: pick(samanthaState, [["web_id"], ["device_id"], ["deviceId"], ["id"]]),
|
||||
web_id: pick(teaState, [["web_id"], ["webId"], ["id"]]),
|
||||
tea_uuid: pick(teaState, [["web_id"], ["tea_uuid"], ["teaUuid"], ["user_unique_id"], ["uuid"]]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "runtime_state_read_failed",
|
||||
};
|
||||
}
|
||||
})();`,
|
||||
true,
|
||||
);
|
||||
|
||||
const runtimeState = parseRuntimeState(state);
|
||||
if (!runtimeState) {
|
||||
throw new Error("doubao_runtime_state_missing");
|
||||
const deadline = Date.now() + DOUBAO_RUNTIME_STATE_TIMEOUT_MS;
|
||||
let lastMissing: Array<keyof DoubaoRuntimeState> = [...DOUBAO_RUNTIME_STATE_FIELDS];
|
||||
let lastReadError: string | null = null;
|
||||
const persistedFallback = await readDoubaoRuntimeStateFallback(context.session).catch(() => ({}));
|
||||
|
||||
while (true) {
|
||||
if (context.signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
|
||||
const raw = await page.evaluate(DOUBAO_READ_RUNTIME_STATE_SCRIPT);
|
||||
const merged = mergeDoubaoRuntimeStateSources(raw, persistedFallback);
|
||||
const { state, missing, readError } = parseRuntimeState(merged);
|
||||
if (state) {
|
||||
return state;
|
||||
}
|
||||
lastMissing = missing;
|
||||
lastReadError = readError;
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(DOUBAO_RUNTIME_STATE_POLL_INTERVAL_MS, context.signal);
|
||||
}
|
||||
return runtimeState;
|
||||
|
||||
const detail = lastReadError
|
||||
? `read_error=${lastReadError}`
|
||||
: `missing=${lastMissing.join(",") || "unknown"}`;
|
||||
throw new Error(`doubao_runtime_state_missing:${detail}`);
|
||||
}
|
||||
|
||||
const DOUBAO_COMPLETION_FETCH_SCRIPT = async (
|
||||
{ url, body, referer }: { url: string; body: Record<string, unknown>; referer: string },
|
||||
) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
referrer: referer,
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
return { ok: res.ok, status: res.status, body: text };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: "",
|
||||
error: error instanceof Error ? error.message : "doubao_completion_fetch_failed",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
type DoubaoCompletionResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
async function fetchDoubaoCompletion(
|
||||
page: PlaywrightPage,
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<DoubaoCompletionResult> {
|
||||
let result: unknown;
|
||||
try {
|
||||
result = await page.evaluate(DOUBAO_COMPLETION_FETCH_SCRIPT, {
|
||||
url,
|
||||
body,
|
||||
referer: DOUBAO_REFERER,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: "",
|
||||
error: error instanceof Error ? error.message : "doubao_completion_fetch_eval_failed",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isRecord(result)) {
|
||||
return { ok: false, status: 0, body: "", error: "doubao_completion_fetch_malformed" };
|
||||
}
|
||||
return {
|
||||
ok: Boolean(result.ok),
|
||||
status: typeof result.status === "number" ? result.status : 0,
|
||||
body: typeof result.body === "string" ? result.body : "",
|
||||
error: normalizeOptionalString(result.error) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function extractQuestionText(payload: Record<string, unknown>): string {
|
||||
@@ -716,50 +1162,70 @@ function buildAdapterError(
|
||||
|
||||
export const doubaoAdapter: MonitorAdapter = {
|
||||
provider: "doubao",
|
||||
executionMode: "view",
|
||||
executionMode: "playwright",
|
||||
async query(context, payload) {
|
||||
if (!context.view) {
|
||||
if (!context.playwright) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "豆包监测缺少页面执行上下文。",
|
||||
summary: "豆包监测缺少 Playwright 执行上下文。",
|
||||
error: buildAdapterError(
|
||||
"doubao_view_required",
|
||||
"doubao_view_required",
|
||||
"doubao_playwright_required",
|
||||
"doubao_playwright_required",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const questionText = extractQuestionText(payload);
|
||||
const runtimeState = await loadDoubaoRuntimeState(context);
|
||||
let runtimeState: DoubaoRuntimeState;
|
||||
try {
|
||||
runtimeState = await loadDoubaoRuntimeState(context);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.startsWith("doubao_runtime_state_missing")) {
|
||||
const detail = message.slice("doubao_runtime_state_missing:".length) || "unknown";
|
||||
return {
|
||||
status: "failed",
|
||||
summary: `豆包运行时上下文未就绪(${detail}),请稍后重试或在桌面端打开豆包页面完成初始化。`,
|
||||
error: buildAdapterError(
|
||||
"doubao_runtime_state_missing",
|
||||
`doubao_runtime_state_missing:${detail}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const requestBody = buildDoubaoRequestBody(questionText, runtimeState);
|
||||
|
||||
context.reportProgress("doubao.query");
|
||||
const response = await context.session.fetch(buildDoubaoRequestUrl(runtimeState), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
referer: DOUBAO_REFERER,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const fetchResult = await fetchDoubaoCompletion(
|
||||
context.playwright.page,
|
||||
buildDoubaoRequestUrl(runtimeState),
|
||||
requestBody,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
const detail = normalizeText(bodyText)?.slice(0, 240);
|
||||
if (fetchResult.error) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${response.status})。`,
|
||||
summary: `豆包请求发送失败:${fetchResult.error}`,
|
||||
error: buildAdapterError("doubao_request_failed", fetchResult.error),
|
||||
};
|
||||
}
|
||||
|
||||
if (!fetchResult.ok) {
|
||||
const detail = normalizeText(fetchResult.body)?.slice(0, 240);
|
||||
return {
|
||||
status: "failed",
|
||||
summary: detail ? `豆包请求失败:${detail}` : `豆包请求失败(${fetchResult.status})。`,
|
||||
error: buildAdapterError(
|
||||
"doubao_request_failed",
|
||||
detail || `doubao_request_failed_${response.status}`,
|
||||
{ http_status: response.status },
|
||||
detail || `doubao_request_failed_${fetchResult.status}`,
|
||||
{ http_status: fetchResult.status },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
context.reportProgress("doubao.parse_stream");
|
||||
const streamSummary = await parseDoubaoStream(response);
|
||||
const streamSummary = await parseDoubaoStream(new Response(fetchResult.body));
|
||||
if (streamSummary.error_message) {
|
||||
return {
|
||||
status: "failed",
|
||||
@@ -786,6 +1252,7 @@ export const doubaoAdapter: MonitorAdapter = {
|
||||
request_id: streamSummary.request_id ?? streamSummary.conversation_id,
|
||||
conversation_id: streamSummary.conversation_id,
|
||||
answer: streamSummary.answer,
|
||||
reasoning: streamSummary.reasoning,
|
||||
citation_count: streamSummary.citations.length,
|
||||
search_result_count: streamSummary.search_results.length,
|
||||
event_count: streamSummary.event_count,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { hostname } from "node:os";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { BrowserWindow, WebContentsView, app, ipcMain, nativeTheme } from "electron/main";
|
||||
@@ -11,6 +12,7 @@ import type { DesktopAccountInfo, DesktopRuntimeSessionSyncRequest } from "@geo/
|
||||
|
||||
import { initAccountHealth } from "./account-health";
|
||||
import { bindPublishAccount, openPublishAccountConsole } from "./account-binder";
|
||||
import { installObservedGlobalFetch, registerObservedRequestRendererTarget } from "./network-observer";
|
||||
import { initProcessMetricsSampler } from "./process-metrics";
|
||||
import { getPlaywrightCDPPort, startHiddenPlaywrightReaper } from "./playwright-cdp";
|
||||
import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy";
|
||||
@@ -38,6 +40,7 @@ app.commandLine.appendSwitch("disable-quic");
|
||||
app.commandLine.appendSwitch("disable-background-networking");
|
||||
app.commandLine.appendSwitch("remote-debugging-port", String(getPlaywrightCDPPort()));
|
||||
app.userAgentFallback = STANDARD_USER_AGENT;
|
||||
installObservedGlobalFetch();
|
||||
|
||||
console.info("[desktop-main] boot", {
|
||||
electron: process.versions.electron,
|
||||
@@ -58,6 +61,107 @@ let mainWindow: ElectronBrowserWindow | null = null;
|
||||
let mainRendererContents: ElectronWebContents | null = null;
|
||||
let quitReleaseInFlight = false;
|
||||
|
||||
type WindowMode = "login" | "main";
|
||||
|
||||
const WINDOW_SIZES: Record<WindowMode, { width: number; height: number; resizable: boolean }> = {
|
||||
login: { width: 340, height: 540, resizable: false },
|
||||
main: { width: 1320, height: 860, resizable: true },
|
||||
};
|
||||
|
||||
function windowModePath(): string {
|
||||
return join(app.getPath("userData"), "window-mode.json");
|
||||
}
|
||||
|
||||
function readPersistedWindowMode(): WindowMode {
|
||||
try {
|
||||
const raw = readFileSync(windowModePath(), "utf8");
|
||||
const data = JSON.parse(raw) as { mode?: unknown };
|
||||
if (data?.mode === "main" || data?.mode === "login") {
|
||||
return data.mode;
|
||||
}
|
||||
} catch {
|
||||
// First run, missing file, or corrupt — fall through to default.
|
||||
}
|
||||
return "login";
|
||||
}
|
||||
|
||||
function persistWindowMode(mode: WindowMode): void {
|
||||
try {
|
||||
writeFileSync(windowModePath(), JSON.stringify({ mode }), "utf8");
|
||||
} catch (err) {
|
||||
console.warn("[desktop-main] persist window mode failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
let currentWindowMode: WindowMode = "login";
|
||||
let windowModeApplied = false;
|
||||
|
||||
function applyWindowMode(window: ElectronBrowserWindow, mode: WindowMode): void {
|
||||
const isFirstApply = !windowModeApplied;
|
||||
const sameMode = windowModeApplied && currentWindowMode === mode;
|
||||
|
||||
if (!sameMode) {
|
||||
currentWindowMode = mode;
|
||||
persistWindowMode(mode);
|
||||
|
||||
const target = WINDOW_SIZES[mode];
|
||||
window.setResizable(true);
|
||||
window.setMinimumSize(1, 1);
|
||||
window.setMaximumSize(0, 0);
|
||||
window.setSize(target.width, target.height, false);
|
||||
window.center();
|
||||
|
||||
if (mode === "login") {
|
||||
window.setMinimumSize(target.width, target.height);
|
||||
window.setMaximumSize(target.width, target.height);
|
||||
window.setResizable(false);
|
||||
} else {
|
||||
window.setMinimumSize(800, 600);
|
||||
window.setResizable(true);
|
||||
}
|
||||
}
|
||||
|
||||
windowModeApplied = true;
|
||||
|
||||
if (isFirstApply || !window.isVisible()) {
|
||||
window.show();
|
||||
window.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function currentMainWindow(): ElectronBrowserWindow | null {
|
||||
if (!mainWindow) {
|
||||
return null;
|
||||
}
|
||||
if (mainWindow.isDestroyed()) {
|
||||
mainWindow = null;
|
||||
windowModeApplied = false;
|
||||
return null;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
|
||||
const existing = currentMainWindow();
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const window = await createMainWindow();
|
||||
mainWindow = window;
|
||||
return window;
|
||||
}
|
||||
|
||||
async function revealMainWindow(): Promise<void> {
|
||||
const window = await ensureMainWindow();
|
||||
applyWindowMode(window, currentWindowMode);
|
||||
}
|
||||
|
||||
function revealMainWindowSafely(source: string): void {
|
||||
void revealMainWindow().catch((error) => {
|
||||
console.error("[desktop-main] reveal main window failed", { source, error });
|
||||
});
|
||||
}
|
||||
|
||||
function rendererURL(): string | null {
|
||||
return process.env.ELECTRON_RENDERER_URL ?? null;
|
||||
}
|
||||
@@ -83,6 +187,7 @@ async function mountRendererView(window: ElectronBrowserWindow): Promise<void> {
|
||||
|
||||
view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
|
||||
registerRendererDevtoolsProxyTarget(view.webContents);
|
||||
registerObservedRequestRendererTarget(view.webContents);
|
||||
mainRendererContents = view.webContents;
|
||||
window.contentView.addChildView(view);
|
||||
syncViewBounds(window, view);
|
||||
@@ -104,12 +209,30 @@ async function mountRendererView(window: ElectronBrowserWindow): Promise<void> {
|
||||
}
|
||||
|
||||
async function createMainWindow(): Promise<ElectronBrowserWindow> {
|
||||
const initial = WINDOW_SIZES[currentWindowMode];
|
||||
const window = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
width: initial.width,
|
||||
height: initial.height,
|
||||
show: false,
|
||||
title: "GEO Rankly Desktop",
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f4f1ea",
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 14 },
|
||||
backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#eaf2ff",
|
||||
});
|
||||
|
||||
const fallbackReveal = setTimeout(() => {
|
||||
if (!windowModeApplied && !window.isDestroyed()) {
|
||||
applyWindowMode(window, currentWindowMode);
|
||||
}
|
||||
}, 1500);
|
||||
window.once("closed", () => {
|
||||
clearTimeout(fallbackReveal);
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
windowModeApplied = false;
|
||||
}
|
||||
});
|
||||
|
||||
await mountRendererView(window);
|
||||
return window;
|
||||
}
|
||||
@@ -187,18 +310,23 @@ function registerBridgeHandlers(): void {
|
||||
await releaseRuntimeSession({ revoke: Boolean(revoke) });
|
||||
return null;
|
||||
});
|
||||
ipcMain.handle("desktop:set-window-mode", (_event, mode: WindowMode) => {
|
||||
const window = currentMainWindow();
|
||||
if (window && (mode === "login" || mode === "main")) {
|
||||
applyWindowMode(window, mode);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
if (!initSingleInstance(() => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
revealMainWindowSafely("single-instance");
|
||||
})) {
|
||||
app.quit();
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
currentWindowMode = readPersistedWindowMode();
|
||||
registerBridgeHandlers();
|
||||
await initSessionRegistry();
|
||||
initAccountHealth();
|
||||
@@ -213,25 +341,16 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
mainRendererContents.send("desktop:runtime-invalidated", event);
|
||||
});
|
||||
mainWindow = await createMainWindow();
|
||||
await ensureMainWindow();
|
||||
initTray(() => {
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
revealMainWindowSafely("tray");
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error("[desktop-main] app.whenReady failed", error);
|
||||
});
|
||||
|
||||
app.on("activate", async () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
return;
|
||||
}
|
||||
mainWindow = await createMainWindow();
|
||||
app.on("activate", () => {
|
||||
revealMainWindowSafely("activate");
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { WebContents } from "electron/main";
|
||||
|
||||
function normalizeBooleanEnv(value: string | undefined): boolean | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["1", "true", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["0", "false", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldAutoOpenExecutionDevtools(): boolean {
|
||||
const explicit = normalizeBooleanEnv(process.env.GEO_DESKTOP_EXECUTION_DEVTOOLS);
|
||||
if (explicit !== null) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
return Boolean(process.env.ELECTRON_RENDERER_URL);
|
||||
}
|
||||
|
||||
export function shouldAutoOpenBackgroundExecutionDevtools(): boolean {
|
||||
const explicit = normalizeBooleanEnv(process.env.GEO_DESKTOP_EXECUTION_DEVTOOLS);
|
||||
if (explicit !== null) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function maybeOpenExecutionDevtools(
|
||||
webContents: WebContents,
|
||||
label: string,
|
||||
options: { background?: boolean } = {},
|
||||
): void {
|
||||
const allowOpen = options.background
|
||||
? shouldAutoOpenBackgroundExecutionDevtools()
|
||||
: shouldAutoOpenExecutionDevtools();
|
||||
if (!allowOpen) {
|
||||
return;
|
||||
}
|
||||
if (webContents.isDestroyed() || webContents.isDevToolsOpened()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
webContents.openDevTools({ mode: "detach" });
|
||||
console.info("[desktop-devtools] opened execution devtools", { label });
|
||||
} catch (error) {
|
||||
console.warn("[desktop-devtools] failed to open execution devtools", { label, error });
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { DesktopTaskEventMessage, DesktopTaskInfo, JsonValue } from "@geo/s
|
||||
|
||||
export type MonitorSchedulerRouting = "rabbitmq-primary" | "db-recovery";
|
||||
type MonitorTaskState = "queued" | "leasing" | "active";
|
||||
export type MonitorTaskSource = "desktop_task" | "monitoring_lease";
|
||||
|
||||
export interface MonitorSchedulerTaskRecord {
|
||||
taskId: string;
|
||||
@@ -15,7 +16,11 @@ export interface MonitorSchedulerTaskRecord {
|
||||
clientId: string;
|
||||
platform: string;
|
||||
routing: MonitorSchedulerRouting;
|
||||
source: MonitorTaskSource;
|
||||
state: MonitorTaskState;
|
||||
priority: number;
|
||||
lane: string | null;
|
||||
laneWeight: number;
|
||||
title: string | null;
|
||||
businessDate: string | null;
|
||||
questionKey: string | null;
|
||||
@@ -51,6 +56,7 @@ export interface MonitorSchedulerSnapshot {
|
||||
export interface MonitorSchedulerSelection {
|
||||
taskId: string;
|
||||
routing: MonitorSchedulerRouting;
|
||||
source: MonitorTaskSource;
|
||||
}
|
||||
|
||||
interface MonitorSchedulerSelectionOptions {
|
||||
@@ -68,7 +74,7 @@ interface MonitorTaskMetadata {
|
||||
questionText: string | null;
|
||||
}
|
||||
|
||||
const schedulerStateVersion = 1;
|
||||
const schedulerStateVersion = 5;
|
||||
const schedulerQuestionCooldownMs = 45_000;
|
||||
const schedulerPlatformCooldownMs = 5_000;
|
||||
const schedulerRestartRecoveryDelayMs = 90_000;
|
||||
@@ -141,7 +147,15 @@ function normalizePersistedState(input: PersistedMonitorSchedulerState | null |
|
||||
return next;
|
||||
}
|
||||
|
||||
next.version = typeof input.version === "number" ? input.version : schedulerStateVersion;
|
||||
// The monitor scheduler is only a local optimization layer. When its on-disk
|
||||
// shape changes, start from a clean slate and let the server-side queue/resume
|
||||
// APIs repopulate state. Keeping old records risks reviving already-finished
|
||||
// monitor desktop_tasks and blocking legacy fallback pulls.
|
||||
if (input.version !== schedulerStateVersion) {
|
||||
return next;
|
||||
}
|
||||
|
||||
next.version = schedulerStateVersion;
|
||||
next.lastHydratedAt = typeof input.lastHydratedAt === "number" ? input.lastHydratedAt : 0;
|
||||
|
||||
if (input.tasks && typeof input.tasks === "object") {
|
||||
@@ -162,7 +176,11 @@ function normalizePersistedState(input: PersistedMonitorSchedulerState | null |
|
||||
clientId: typeof task.clientId === "string" ? task.clientId : "",
|
||||
platform: task.platform,
|
||||
routing: task.routing === "db-recovery" ? "db-recovery" : "rabbitmq-primary",
|
||||
source: task.source === "monitoring_lease" ? "monitoring_lease" : "desktop_task",
|
||||
state: task.state === "active" || task.state === "leasing" ? task.state : "queued",
|
||||
priority: typeof task.priority === "number" ? task.priority : 100,
|
||||
lane: normalizeOptionalString(task.lane),
|
||||
laneWeight: typeof task.laneWeight === "number" ? task.laneWeight : laneWeightFromLane(task.lane),
|
||||
title: typeof task.title === "string" ? task.title : null,
|
||||
businessDate: normalizeBusinessDate(task.businessDate),
|
||||
questionKey: normalizeOptionalString(task.questionKey),
|
||||
@@ -234,6 +252,11 @@ export function initMonitorScheduler(): void {
|
||||
const now = Date.now();
|
||||
const next = clonePersistedState(readPersistedState());
|
||||
prunePersistedState(next, now);
|
||||
for (const [taskId, task] of Object.entries(next.tasks)) {
|
||||
if (task.source === "monitoring_lease") {
|
||||
delete next.tasks[taskId];
|
||||
}
|
||||
}
|
||||
next.lastHydratedAt = now;
|
||||
writePersistedState(next);
|
||||
}
|
||||
@@ -254,7 +277,11 @@ export function enqueueMonitorTaskFromEvent(
|
||||
clientId: event.target_client_id,
|
||||
platform: event.platform,
|
||||
routing,
|
||||
source: "desktop_task",
|
||||
state: existing?.state === "active" ? "active" : "queued",
|
||||
priority: normalizePriority(event.priority, existing?.priority),
|
||||
lane: normalizeLane(event.lane, existing?.lane),
|
||||
laneWeight: laneWeightFromLane(event.lane ?? existing?.lane ?? null),
|
||||
title: metadata.title ?? existing?.title ?? null,
|
||||
businessDate: metadata.businessDate ?? existing?.businessDate ?? null,
|
||||
questionKey: metadata.questionKey ?? existing?.questionKey ?? null,
|
||||
@@ -286,7 +313,11 @@ export function noteMonitorTaskLeased(
|
||||
clientId: task.target_client_id,
|
||||
platform: task.platform,
|
||||
routing,
|
||||
source: "desktop_task",
|
||||
state: "active",
|
||||
priority: normalizePriority(payloadNumber(task.payload ?? null, "priority"), existing?.priority),
|
||||
lane: normalizeLane(firstString(task.payload ?? null, ["lane"]), existing?.lane),
|
||||
laneWeight: laneWeightFromLane(firstString(task.payload ?? null, ["lane"]) ?? existing?.lane ?? null),
|
||||
title: metadata.title ?? existing?.title ?? null,
|
||||
businessDate: metadata.businessDate ?? existing?.businessDate ?? null,
|
||||
questionKey: metadata.questionKey ?? existing?.questionKey ?? null,
|
||||
@@ -302,6 +333,67 @@ export function noteMonitorTaskLeased(
|
||||
});
|
||||
}
|
||||
|
||||
export function enqueueMonitorLeaseTask(input: {
|
||||
taskId: string;
|
||||
jobId?: string;
|
||||
accountId?: string;
|
||||
clientId: string;
|
||||
platform: string;
|
||||
routing: MonitorSchedulerRouting;
|
||||
priority?: number;
|
||||
lane?: string | null;
|
||||
title?: string | null;
|
||||
businessDate?: string | null;
|
||||
questionKey?: string | null;
|
||||
questionText?: string | null;
|
||||
}): void {
|
||||
const now = Date.now();
|
||||
|
||||
mutatePersistedState((draft) => {
|
||||
const existing = draft.tasks[input.taskId];
|
||||
draft.tasks[input.taskId] = {
|
||||
taskId: input.taskId,
|
||||
jobId: input.jobId ?? input.taskId,
|
||||
accountId: input.accountId ?? existing?.accountId ?? "",
|
||||
clientId: input.clientId,
|
||||
platform: input.platform,
|
||||
routing: input.routing,
|
||||
source: "monitoring_lease",
|
||||
state: existing?.state === "active" ? "active" : "queued",
|
||||
priority: normalizePriority(input.priority, existing?.priority),
|
||||
lane: normalizeLane(input.lane, existing?.lane),
|
||||
laneWeight: laneWeightFromLane(input.lane ?? existing?.lane ?? null),
|
||||
title: normalizeOptionalString(input.title) ?? existing?.title ?? null,
|
||||
businessDate: normalizeBusinessDate(input.businessDate) ?? existing?.businessDate ?? null,
|
||||
questionKey: normalizeOptionalString(input.questionKey) ?? existing?.questionKey ?? null,
|
||||
questionText: normalizeOptionalString(input.questionText) ?? existing?.questionText ?? null,
|
||||
updatedAt: now,
|
||||
enqueuedAt: existing?.enqueuedAt ?? now,
|
||||
availableAt: existing?.availableAt ?? now,
|
||||
lastSeenAt: now,
|
||||
lastLeaseAttemptAt: existing?.lastLeaseAttemptAt ?? null,
|
||||
lastStartedAt: existing?.lastStartedAt ?? null,
|
||||
leaseMisses: existing?.leaseMisses ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function noteMonitorTaskActivated(taskId: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
mutatePersistedState((draft) => {
|
||||
const existing = draft.tasks[taskId];
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
existing.state = "active";
|
||||
existing.lastLeaseAttemptAt = now;
|
||||
existing.lastStartedAt = now;
|
||||
existing.lastSeenAt = now;
|
||||
existing.availableAt = now;
|
||||
});
|
||||
}
|
||||
|
||||
export function noteMonitorTaskLeaseMiss(taskId: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
@@ -363,6 +455,12 @@ export function selectNextMonitorTask(
|
||||
const candidates = Object.values(draft.tasks)
|
||||
.filter((task) => task.state === "queued" && task.availableAt <= now)
|
||||
.sort((left, right) => {
|
||||
if (left.laneWeight !== right.laneWeight) {
|
||||
return right.laneWeight - left.laneWeight;
|
||||
}
|
||||
if (left.priority !== right.priority) {
|
||||
return right.priority - left.priority;
|
||||
}
|
||||
if (left.availableAt !== right.availableAt) {
|
||||
return left.availableAt - right.availableAt;
|
||||
}
|
||||
@@ -377,8 +475,10 @@ export function selectNextMonitorTask(
|
||||
continue;
|
||||
}
|
||||
|
||||
const bypassCooldowns = candidate.lane === "high";
|
||||
|
||||
const platformCooldown = draft.platformCooldowns[candidate.platform] ?? 0;
|
||||
if (platformCooldown > now) {
|
||||
if (!bypassCooldowns && platformCooldown > now) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -391,7 +491,7 @@ export function selectNextMonitorTask(
|
||||
continue;
|
||||
}
|
||||
const questionCooldown = draft.questionCooldowns[candidate.questionKey] ?? 0;
|
||||
if (questionCooldown > now) {
|
||||
if (!bypassCooldowns && questionCooldown > now) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -403,6 +503,7 @@ export function selectNextMonitorTask(
|
||||
return {
|
||||
taskId: candidate.taskId,
|
||||
routing: candidate.routing,
|
||||
source: candidate.source,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -412,7 +513,15 @@ export function selectNextMonitorTask(
|
||||
|
||||
export function getMonitorSchedulerSnapshot(): MonitorSchedulerSnapshot {
|
||||
const state = readPersistedState();
|
||||
const tasks = Object.values(state.tasks).sort((left, right) => left.enqueuedAt - right.enqueuedAt);
|
||||
const tasks = Object.values(state.tasks).sort((left, right) => {
|
||||
if (left.laneWeight !== right.laneWeight) {
|
||||
return right.laneWeight - left.laneWeight;
|
||||
}
|
||||
if (left.priority !== right.priority) {
|
||||
return right.priority - left.priority;
|
||||
}
|
||||
return left.enqueuedAt - right.enqueuedAt;
|
||||
});
|
||||
return {
|
||||
hydratedAt: state.lastHydratedAt || null,
|
||||
queueDepth: tasks.length,
|
||||
@@ -473,9 +582,12 @@ function metadataFromPayload(payload: Record<string, JsonValue> | null): Monitor
|
||||
}
|
||||
|
||||
function firstString(
|
||||
payload: Record<string, JsonValue>,
|
||||
payload: Record<string, JsonValue> | null,
|
||||
keys: string[],
|
||||
): string | null {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
for (const key of keys) {
|
||||
const value = payload[key];
|
||||
if (typeof value === "string") {
|
||||
@@ -492,6 +604,26 @@ function firstString(
|
||||
return null;
|
||||
}
|
||||
|
||||
function payloadNumber(
|
||||
payload: Record<string, JsonValue> | null,
|
||||
key: string,
|
||||
): number | null {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const value = payload[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function currentMonitoringBusinessDate(now: number): string {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
@@ -522,6 +654,32 @@ function normalizeOptionalString(value: unknown): string | null {
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizePriority(value: unknown, fallback = 100): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeLane(value: unknown, fallback: string | null = null): string | null {
|
||||
return normalizeOptionalString(value) ?? fallback;
|
||||
}
|
||||
|
||||
function laneWeightFromLane(lane: unknown): number {
|
||||
switch (normalizeOptionalString(lane)) {
|
||||
case "high":
|
||||
return 70;
|
||||
case "normal_boosted":
|
||||
return 50;
|
||||
case "normal":
|
||||
return 40;
|
||||
case "retry":
|
||||
return 20;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function digestText(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
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;
|
||||
|
||||
let rendererTarget: WebContents | null = null;
|
||||
let originalFetch: typeof globalThis.fetch | null = null;
|
||||
|
||||
export function installObservedGlobalFetch(): void {
|
||||
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 {
|
||||
rendererTarget = webContents;
|
||||
webContents.once("destroyed", () => {
|
||||
if (rendererTarget === webContents) {
|
||||
rendererTarget = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function observeSessionRequests(
|
||||
target: Session,
|
||||
options: { label: string; partition?: string | null },
|
||||
): void {
|
||||
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 {
|
||||
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 {
|
||||
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.
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,14 @@ const defaultConnectTimeoutMs = 10_000;
|
||||
const defaultPageTimeoutMs = 45_000;
|
||||
const cdpPort = Number.parseInt(process.env.GEO_ELECTRON_CDP_PORT ?? "9339", 10);
|
||||
const cdpEndpointURL = `http://127.0.0.1:${cdpPort}`;
|
||||
const cdpTargetsListURL = `${cdpEndpointURL}/json/list`;
|
||||
|
||||
interface CDPTargetDescriptor {
|
||||
id?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface HiddenPlaywrightRecord {
|
||||
key: string;
|
||||
@@ -220,17 +228,22 @@ async function connectBrowserOverCDP(): Promise<PlaywrightBrowser> {
|
||||
connectPromise = (async () => {
|
||||
await waitForCDPEndpoint();
|
||||
const { chromium } = await import("playwright-core");
|
||||
const browser = await chromium.connectOverCDP(cdpEndpointURL, {
|
||||
timeout: defaultConnectTimeoutMs,
|
||||
});
|
||||
await pruneStaleCDPTargets();
|
||||
|
||||
browser.on("disconnected", () => {
|
||||
connectedBrowser = null;
|
||||
connectPromise = null;
|
||||
});
|
||||
try {
|
||||
return await openBrowserOverCDP(chromium);
|
||||
} catch (error) {
|
||||
if (!isConnectOverCDPTimeout(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
connectedBrowser = browser;
|
||||
return browser;
|
||||
const prunedTargetCount = await pruneStaleCDPTargets();
|
||||
if (prunedTargetCount <= 0) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return openBrowserOverCDP(chromium);
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
@@ -260,6 +273,76 @@ async function waitForCDPEndpoint(timeoutMs = defaultConnectTimeoutMs): Promise<
|
||||
throw new Error("playwright_cdp_endpoint_unavailable");
|
||||
}
|
||||
|
||||
async function openBrowserOverCDP(
|
||||
chromium: typeof import("playwright-core").chromium,
|
||||
): Promise<PlaywrightBrowser> {
|
||||
const browser = await chromium.connectOverCDP(cdpEndpointURL, {
|
||||
timeout: defaultConnectTimeoutMs,
|
||||
});
|
||||
|
||||
browser.on("disconnected", () => {
|
||||
connectedBrowser = null;
|
||||
connectPromise = null;
|
||||
});
|
||||
|
||||
connectedBrowser = browser;
|
||||
return browser;
|
||||
}
|
||||
|
||||
async function pruneStaleCDPTargets(): Promise<number> {
|
||||
let targets: CDPTargetDescriptor[] = [];
|
||||
try {
|
||||
const response = await fetch(cdpTargetsListURL);
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
}
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
targets = payload as CDPTargetDescriptor[];
|
||||
}
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let closedCount = 0;
|
||||
for (const target of targets) {
|
||||
if (!isStaleCDPTarget(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const closeURL = `${cdpEndpointURL}/json/close/${encodeURIComponent(target.id ?? "")}`;
|
||||
const response = await fetch(closeURL);
|
||||
if (response.ok) {
|
||||
closedCount += 1;
|
||||
}
|
||||
} catch {
|
||||
// Ignore best-effort stale target cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
if (closedCount > 0) {
|
||||
await wait(150);
|
||||
}
|
||||
return closedCount;
|
||||
}
|
||||
|
||||
function isStaleCDPTarget(target: CDPTargetDescriptor): boolean {
|
||||
if ((target.type ?? "").trim() !== "page") {
|
||||
return false;
|
||||
}
|
||||
if (!(target.id ?? "").trim()) {
|
||||
return false;
|
||||
}
|
||||
return !(target.title ?? "").trim() && !(target.url ?? "").trim();
|
||||
}
|
||||
|
||||
function isConnectOverCDPTimeout(error: unknown): boolean {
|
||||
return error instanceof Error
|
||||
&& error.name === "TimeoutError"
|
||||
&& error.message.includes("connectOverCDP");
|
||||
}
|
||||
|
||||
async function waitForNewPage(
|
||||
browser: PlaywrightBrowser,
|
||||
knownPages: Set<PlaywrightPage>,
|
||||
@@ -270,6 +353,10 @@ async function waitForNewPage(
|
||||
while (Date.now() - startedAt < defaultPageTimeoutMs) {
|
||||
const nextPage = context.pages().find((page) => !knownPages.has(page));
|
||||
if (nextPage) {
|
||||
if (isDevtoolsPage(nextPage)) {
|
||||
knownPages.add(nextPage);
|
||||
continue;
|
||||
}
|
||||
return nextPage;
|
||||
}
|
||||
await wait(100);
|
||||
@@ -298,13 +385,12 @@ function safePageURL(page: PlaywrightPage): string {
|
||||
}
|
||||
}
|
||||
|
||||
function closeRecord(record: HiddenPlaywrightRecord): void {
|
||||
try {
|
||||
record.page.close().catch(() => undefined);
|
||||
} catch {
|
||||
// Ignore and fall through to window close.
|
||||
}
|
||||
function isDevtoolsPage(page: PlaywrightPage): boolean {
|
||||
const url = safePageURL(page);
|
||||
return url.startsWith("devtools://");
|
||||
}
|
||||
|
||||
function closeRecord(record: HiddenPlaywrightRecord): void {
|
||||
if (!record.window.isDestroyed()) {
|
||||
record.window.close();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WebContents } from "electron/main";
|
||||
import { ipcMain } from "electron/main";
|
||||
import type { IpcMainEvent, WebContents } from "electron/main";
|
||||
|
||||
interface RendererProxyRequest {
|
||||
url: string;
|
||||
@@ -17,6 +18,16 @@ interface RendererProxyResponse {
|
||||
}
|
||||
|
||||
let rendererWebContents: WebContents | null = null;
|
||||
let requestSequence = 0;
|
||||
const pendingRequests = new Map<string, {
|
||||
resolve: (value: RendererProxyResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
}>();
|
||||
const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request";
|
||||
const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response";
|
||||
|
||||
ipcMain.on(rendererProxyResponseChannel, handleRendererProxyResponse);
|
||||
|
||||
export function registerRendererDevtoolsProxyTarget(webContents: WebContents): void {
|
||||
rendererWebContents = webContents;
|
||||
@@ -28,7 +39,7 @@ export function registerRendererDevtoolsProxyTarget(webContents: WebContents): v
|
||||
}
|
||||
|
||||
export function canUseRendererDevtoolsProxy(): boolean {
|
||||
return Boolean(process.env.ELECTRON_RENDERER_URL && rendererWebContents && !rendererWebContents.isDestroyed());
|
||||
return Boolean(rendererWebContents && !rendererWebContents.isDestroyed());
|
||||
}
|
||||
|
||||
export async function rendererDevtoolsFetch(
|
||||
@@ -38,37 +49,75 @@ export async function rendererDevtoolsFetch(
|
||||
throw new Error("renderer_devtools_proxy_unavailable");
|
||||
}
|
||||
|
||||
const script = `(async () => {
|
||||
const requestId = `renderer-proxy-${Date.now()}-${requestSequence += 1}`;
|
||||
|
||||
return await new Promise<RendererProxyResponse>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(requestId);
|
||||
reject(new Error("renderer_devtools_proxy_timeout"));
|
||||
}, 30_000);
|
||||
|
||||
pendingRequests.set(requestId, {
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(${JSON.stringify(request.url)}, {
|
||||
method: ${JSON.stringify(request.method)},
|
||||
headers: ${JSON.stringify(request.headers ?? {})},
|
||||
body: ${JSON.stringify(request.body ?? null)},
|
||||
});
|
||||
const bodyText = await response.text();
|
||||
return JSON.stringify({
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
bodyText,
|
||||
rendererWebContents?.send(rendererProxyRequestChannel, {
|
||||
requestId,
|
||||
request,
|
||||
});
|
||||
} catch (error) {
|
||||
return JSON.stringify({
|
||||
ok: false,
|
||||
status: 0,
|
||||
statusText: "",
|
||||
headers: {},
|
||||
bodyText: "",
|
||||
error: String(error && (error.message || error)),
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
pendingRequests.delete(requestId);
|
||||
reject(error instanceof Error ? error : new Error("renderer_devtools_proxy_send_failed"));
|
||||
}
|
||||
})()`;
|
||||
});
|
||||
}
|
||||
|
||||
const serialized = await rendererWebContents.executeJavaScript(script, true);
|
||||
if (typeof serialized !== "string" || !serialized) {
|
||||
throw new Error("renderer_devtools_proxy_empty_response");
|
||||
function handleRendererProxyResponse(
|
||||
event: IpcMainEvent,
|
||||
payload: {
|
||||
requestId?: unknown;
|
||||
response?: unknown;
|
||||
},
|
||||
): void {
|
||||
if (rendererWebContents && event.sender !== rendererWebContents) {
|
||||
return;
|
||||
}
|
||||
|
||||
return JSON.parse(serialized) as RendererProxyResponse;
|
||||
const requestId = typeof payload?.requestId === "string" ? payload.requestId : "";
|
||||
if (!requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingRequests.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(pending.timeout);
|
||||
pendingRequests.delete(requestId);
|
||||
|
||||
const response = payload.response;
|
||||
if (!isRendererProxyResponse(response)) {
|
||||
pending.reject(new Error("renderer_devtools_proxy_invalid_response"));
|
||||
return;
|
||||
}
|
||||
|
||||
pending.resolve(response);
|
||||
}
|
||||
|
||||
function isRendererProxyResponse(value: unknown): value is RendererProxyResponse {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<RendererProxyResponse>;
|
||||
return typeof candidate.ok === "boolean"
|
||||
&& typeof candidate.status === "number"
|
||||
&& typeof candidate.statusText === "string"
|
||||
&& typeof candidate.bodyText === "string"
|
||||
&& Boolean(candidate.headers && typeof candidate.headers === "object");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { collectRecoverySnapshot } from "./crash-recovery";
|
||||
import { getKeepAlivePlan } from "./keep-alive";
|
||||
import { getLeaseManagerSnapshot } from "./lease-manager";
|
||||
import { getMonitorSchedulerSnapshot } from "./monitor-scheduler";
|
||||
import { getObservedRequestSnapshot } from "./network-observer";
|
||||
import { getHiddenPlaywrightSnapshot } from "./playwright-cdp";
|
||||
import { getRateLimiterSnapshot } from "./rate-limiter";
|
||||
import { getRuntimeControllerSnapshot } from "./runtime-controller";
|
||||
@@ -131,7 +132,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
||||
workspace: {
|
||||
id: `ws-${controller.client?.workspace_id ?? controller.session?.desktop_client?.workspace_id ?? "current"}`,
|
||||
name: `Workspace #${controller.client?.workspace_id ?? controller.session?.desktop_client?.workspace_id ?? "current"}`,
|
||||
strategy: controller.sseConnected ? "rabbitmq-first / db-fallback" : "db-fallback / heartbeat recovery",
|
||||
strategy: controller.dispatchConnected ? "websocket-first / db-fallback" : "db-fallback / heartbeat recovery",
|
||||
nextSweepAt: scheduler.nextPullAt ?? minutesAhead(now, 1),
|
||||
lastFullSyncAt: controller.lastAccountsSyncAt || now,
|
||||
},
|
||||
@@ -157,7 +158,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
||||
recovery: collectRecoverySnapshot(),
|
||||
runtimeController: {
|
||||
running: controller.running,
|
||||
sseConnected: controller.sseConnected,
|
||||
dispatchConnected: controller.dispatchConnected,
|
||||
queueDepth: controller.queueDepth,
|
||||
lastHeartbeatAt: controller.lastHeartbeatAt,
|
||||
lastHeartbeatStatus: controller.lastHeartbeatStatus,
|
||||
@@ -172,6 +173,7 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
||||
sessions,
|
||||
hotViews,
|
||||
hotViewPool: getHotViewPoolPolicy(),
|
||||
networkDebug: getObservedRequestSnapshot(),
|
||||
playwright: getHiddenPlaywrightSnapshot(),
|
||||
processMetrics: getProcessMetricsSnapshot(),
|
||||
vault: describeVaultBackend(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
|
||||
import { app, session } from "electron/main";
|
||||
import type { Session } from "electron/main";
|
||||
|
||||
import { observeSessionRequests } from "./network-observer";
|
||||
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from "./user-agent";
|
||||
|
||||
export interface SessionHandle {
|
||||
@@ -98,8 +99,15 @@ function applyStandardUserAgent(target: Session): Session {
|
||||
return target;
|
||||
}
|
||||
|
||||
function prepareSession(target: Session): Session {
|
||||
function prepareSession(
|
||||
target: Session,
|
||||
options: { label: string; partition: string },
|
||||
): Session {
|
||||
applyStandardUserAgent(target);
|
||||
observeSessionRequests(target, {
|
||||
label: options.label,
|
||||
partition: options.partition,
|
||||
});
|
||||
target.clearHostResolverCache().catch((error) => {
|
||||
console.warn("[desktop-session] clearHostResolverCache failed", error);
|
||||
});
|
||||
@@ -121,7 +129,10 @@ export function createSessionHandle(accountId?: string): SessionHandle {
|
||||
const handle: SessionHandle = {
|
||||
accountId: resolvedAccountID,
|
||||
partition,
|
||||
session: prepareSession(session.fromPartition(partition)),
|
||||
session: prepareSession(session.fromPartition(partition), {
|
||||
label: resolvedAccountID,
|
||||
partition,
|
||||
}),
|
||||
};
|
||||
registry.set(resolvedAccountID, handle);
|
||||
return handle;
|
||||
@@ -136,7 +147,10 @@ export function createSessionHandleForPartition(accountId: string, partition: st
|
||||
const handle: SessionHandle = {
|
||||
accountId,
|
||||
partition,
|
||||
session: prepareSession(session.fromPartition(partition)),
|
||||
session: prepareSession(session.fromPartition(partition), {
|
||||
label: accountId,
|
||||
partition,
|
||||
}),
|
||||
};
|
||||
registry.set(accountId, handle);
|
||||
rememberPersistedPartition(accountId, partition);
|
||||
@@ -149,7 +163,10 @@ export function createPendingSessionHandle(seed = "bind"): SessionHandle {
|
||||
const handle: SessionHandle = {
|
||||
accountId,
|
||||
partition,
|
||||
session: prepareSession(session.fromPartition(partition)),
|
||||
session: prepareSession(session.fromPartition(partition), {
|
||||
label: accountId,
|
||||
partition,
|
||||
}),
|
||||
};
|
||||
registry.set(accountId, handle);
|
||||
return handle;
|
||||
@@ -189,7 +206,10 @@ export async function clearSessionHandle(accountId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = prepareSession(session.fromPartition(partition));
|
||||
const target = prepareSession(session.fromPartition(partition), {
|
||||
label: accountId,
|
||||
partition,
|
||||
});
|
||||
|
||||
try {
|
||||
await target.clearStorageData();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiClientError, createApiClient, type ApiClient } from "@geo/http-clien
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
ApiErrorEnvelope,
|
||||
CancelDesktopTaskRequest,
|
||||
CompleteDesktopTaskRequest,
|
||||
CreatePublishJobResponse,
|
||||
DesktopArticleContent,
|
||||
@@ -15,19 +16,50 @@ import type {
|
||||
LeaseDesktopTaskRequest,
|
||||
LeaseDesktopTaskResponse,
|
||||
ListDesktopPublishTasksParams,
|
||||
MonitoringLeaseTasksPayload,
|
||||
MonitoringLeaseTasksResponse,
|
||||
MonitoringResumeTasksPayload,
|
||||
MonitoringResumeTasksResponse,
|
||||
MonitoringSkipTaskPayload,
|
||||
MonitoringSkipTaskResponse,
|
||||
MonitoringTaskResultPayload,
|
||||
MonitoringTaskResultResponse,
|
||||
UpsertDesktopAccountRequest,
|
||||
} from "@geo/shared-types";
|
||||
|
||||
import { canUseRendererDevtoolsProxy, rendererDevtoolsFetch } from "../renderer-devtools-proxy";
|
||||
import {
|
||||
failObservedRequest,
|
||||
resolveObservedRequest,
|
||||
startObservedRequest,
|
||||
} from "../network-observer";
|
||||
|
||||
type TransportAuthState = "pending" | "registered" | "expired";
|
||||
type TransportSseState = "idle" | "connecting" | "streaming";
|
||||
type TransportDispatchState = "idle" | "connecting" | "streaming";
|
||||
|
||||
type ObservedAxiosConfig = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
baseURL?: string;
|
||||
headers?: Record<string, unknown>;
|
||||
__observedRequestId?: string;
|
||||
};
|
||||
|
||||
interface DesktopTransportSession {
|
||||
baseURL: string;
|
||||
clientToken: string | null;
|
||||
}
|
||||
|
||||
interface MonitoringCancelTaskPayload {
|
||||
lease_token: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface MonitoringCancelTaskResponse {
|
||||
task_id: number;
|
||||
task_status: string;
|
||||
}
|
||||
|
||||
let desktopApiClient: ApiClient | null = null;
|
||||
let transportSession: DesktopTransportSession = {
|
||||
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
|
||||
@@ -40,7 +72,7 @@ const transportState = {
|
||||
mode: "rabbitmq-first" as const,
|
||||
requestDebugMode: "main-process" as "main-process" | "renderer-devtools",
|
||||
auth: "pending" as TransportAuthState,
|
||||
sseState: "idle" as TransportSseState,
|
||||
dispatchState: "idle" as TransportDispatchState,
|
||||
lastHeartbeatAt: 0,
|
||||
lastHeartbeatStatus: "idle" as "idle" | "success" | "failed",
|
||||
lastPullAt: 0,
|
||||
@@ -58,9 +90,48 @@ function createDesktopClient(baseURL: string): ApiClient {
|
||||
config.headers = config.headers ?? {};
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${transportSession.clientToken}`;
|
||||
}
|
||||
|
||||
const observedConfig = config as ObservedAxiosConfig;
|
||||
observedConfig.__observedRequestId = startObservedRequest({
|
||||
source: "transport",
|
||||
label: "desktop.api",
|
||||
method: config.method,
|
||||
url: resolveObservedRequestURL(config.url, config.baseURL ?? baseURL),
|
||||
});
|
||||
return config;
|
||||
});
|
||||
|
||||
client.raw.interceptors.response.use(
|
||||
(response) => {
|
||||
const observedConfig = response.config as ObservedAxiosConfig;
|
||||
if (observedConfig.__observedRequestId) {
|
||||
resolveObservedRequest(observedConfig.__observedRequestId, {
|
||||
phase: "response",
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: unknown) => {
|
||||
const responseError = error as {
|
||||
config?: ObservedAxiosConfig;
|
||||
response?: { status?: number };
|
||||
};
|
||||
const requestId = responseError.config?.__observedRequestId;
|
||||
if (requestId) {
|
||||
if (typeof responseError.response?.status === "number") {
|
||||
resolveObservedRequest(requestId, {
|
||||
phase: "response",
|
||||
status: responseError.response.status,
|
||||
});
|
||||
} else {
|
||||
failObservedRequest(requestId, error);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -72,6 +143,33 @@ function resolveDesktopURL(path: string): string {
|
||||
return new URL(path, transportSession.baseURL).toString();
|
||||
}
|
||||
|
||||
function resolveObservedRequestURL(path: string | undefined, baseURL: string): string {
|
||||
if (!path) {
|
||||
return baseURL;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(path, baseURL).toString();
|
||||
} catch {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
function shouldFallbackToMainProcess(error: unknown): boolean {
|
||||
if (error instanceof ApiClientError) {
|
||||
if (typeof error.status === "number" && error.status > 0) {
|
||||
return false;
|
||||
}
|
||||
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return /Failed to fetch|renderer_devtools_proxy_/i.test(error.message);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function proxyDesktopRequest<T, B = unknown>(
|
||||
method: "GET" | "POST" | "DELETE",
|
||||
path: string,
|
||||
@@ -134,7 +232,14 @@ async function proxyDesktopRequest<T, B = unknown>(
|
||||
async function desktopGet<T>(path: string): Promise<T> {
|
||||
transportState.requestDebugMode = currentRequestDebugMode();
|
||||
if (transportState.requestDebugMode === "renderer-devtools") {
|
||||
return proxyDesktopRequest<T>("GET", path);
|
||||
try {
|
||||
return await proxyDesktopRequest<T>("GET", path);
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToMainProcess(error)) {
|
||||
throw error;
|
||||
}
|
||||
transportState.requestDebugMode = "main-process";
|
||||
}
|
||||
}
|
||||
return getDesktopApiClient().get<T>(path);
|
||||
}
|
||||
@@ -142,7 +247,14 @@ async function desktopGet<T>(path: string): Promise<T> {
|
||||
async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
|
||||
transportState.requestDebugMode = currentRequestDebugMode();
|
||||
if (transportState.requestDebugMode === "renderer-devtools") {
|
||||
return proxyDesktopRequest<T, B>("POST", path, body);
|
||||
try {
|
||||
return await proxyDesktopRequest<T, B>("POST", path, body);
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToMainProcess(error)) {
|
||||
throw error;
|
||||
}
|
||||
transportState.requestDebugMode = "main-process";
|
||||
}
|
||||
}
|
||||
return getDesktopApiClient().post<T, B>(path, body);
|
||||
}
|
||||
@@ -150,7 +262,14 @@ async function desktopPost<T, B = unknown>(path: string, body?: B): Promise<T> {
|
||||
async function desktopDelete<T>(path: string): Promise<T> {
|
||||
transportState.requestDebugMode = currentRequestDebugMode();
|
||||
if (transportState.requestDebugMode === "renderer-devtools") {
|
||||
return proxyDesktopRequest<T>("DELETE", path);
|
||||
try {
|
||||
return await proxyDesktopRequest<T>("DELETE", path);
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToMainProcess(error)) {
|
||||
throw error;
|
||||
}
|
||||
transportState.requestDebugMode = "main-process";
|
||||
}
|
||||
}
|
||||
return getDesktopApiClient().remove<T>(path);
|
||||
}
|
||||
@@ -176,6 +295,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
|
||||
transportState.baseURL = transportSession.baseURL;
|
||||
transportState.requestDebugMode = currentRequestDebugMode();
|
||||
transportState.auth = "pending";
|
||||
transportState.dispatchState = "idle";
|
||||
desktopApiClient = null;
|
||||
return null;
|
||||
}
|
||||
@@ -187,6 +307,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
|
||||
transportState.baseURL = transportSession.baseURL;
|
||||
transportState.requestDebugMode = currentRequestDebugMode();
|
||||
transportState.auth = session.client_token ? "registered" : "pending";
|
||||
transportState.dispatchState = session.client_token ? "connecting" : "idle";
|
||||
transportState.initializedAt = Date.now();
|
||||
desktopApiClient = createDesktopClient(transportSession.baseURL);
|
||||
return desktopApiClient;
|
||||
@@ -196,8 +317,8 @@ export function setTransportAuthState(next: TransportAuthState): void {
|
||||
transportState.auth = next;
|
||||
}
|
||||
|
||||
export function setTransportSseState(next: TransportSseState): void {
|
||||
transportState.sseState = next;
|
||||
export function setTransportDispatchState(next: TransportDispatchState): void {
|
||||
transportState.dispatchState = next;
|
||||
}
|
||||
|
||||
export function noteTransportHeartbeat(success: boolean): void {
|
||||
@@ -328,3 +449,61 @@ export async function completeDesktopTask(
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelDesktopTask(
|
||||
taskId: string,
|
||||
payload: CancelDesktopTaskRequest,
|
||||
): Promise<DesktopTaskInfo> {
|
||||
return desktopPost<DesktopTaskInfo, CancelDesktopTaskRequest>(
|
||||
`/api/desktop/tasks/${taskId}/cancel`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function leaseMonitoringTasks(
|
||||
payload: MonitoringLeaseTasksPayload = {},
|
||||
): Promise<MonitoringLeaseTasksResponse> {
|
||||
return desktopPost<MonitoringLeaseTasksResponse, MonitoringLeaseTasksPayload>(
|
||||
"/api/desktop/monitoring/tasks/lease",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resumeMonitoringTasks(
|
||||
payload: MonitoringResumeTasksPayload = {},
|
||||
): Promise<MonitoringResumeTasksResponse> {
|
||||
return desktopPost<MonitoringResumeTasksResponse, MonitoringResumeTasksPayload>(
|
||||
"/api/desktop/monitoring/tasks/resume",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function submitMonitoringTaskResult(
|
||||
taskId: number,
|
||||
payload: MonitoringTaskResultPayload,
|
||||
): Promise<MonitoringTaskResultResponse> {
|
||||
return desktopPost<MonitoringTaskResultResponse, MonitoringTaskResultPayload>(
|
||||
`/api/desktop/monitoring/tasks/${taskId}/result`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function skipMonitoringTask(
|
||||
taskId: number,
|
||||
payload: MonitoringSkipTaskPayload,
|
||||
): Promise<MonitoringSkipTaskResponse> {
|
||||
return desktopPost<MonitoringSkipTaskResponse, MonitoringSkipTaskPayload>(
|
||||
`/api/desktop/monitoring/tasks/${taskId}/skip`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
export async function cancelMonitoringTask(
|
||||
taskId: number,
|
||||
payload: MonitoringCancelTaskPayload,
|
||||
): Promise<MonitoringCancelTaskResponse> {
|
||||
return desktopPost<MonitoringCancelTaskResponse, MonitoringCancelTaskPayload>(
|
||||
`/api/desktop/monitoring/tasks/${taskId}/cancel`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import WebSocket, { type RawData } from "ws";
|
||||
|
||||
import {
|
||||
failObservedRequest,
|
||||
resolveObservedRequest,
|
||||
startObservedRequest,
|
||||
} from "../network-observer";
|
||||
|
||||
export type DispatchEventHandler = (payload: unknown) => void;
|
||||
|
||||
export interface DispatchWsClientOptions {
|
||||
@@ -34,6 +40,7 @@ export class DispatchWsClient {
|
||||
#socket: WebSocket | null = null;
|
||||
#retryDelayMs: number;
|
||||
#reconnectTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
||||
#observedRequestId: string | null = null;
|
||||
|
||||
constructor(options: DispatchWsClientOptions) {
|
||||
this.#options = options;
|
||||
@@ -65,6 +72,15 @@ export class DispatchWsClient {
|
||||
this.#clearReconnect();
|
||||
this.#setState("idle");
|
||||
|
||||
if (this.#observedRequestId) {
|
||||
resolveObservedRequest(this.#observedRequestId, {
|
||||
phase: "close",
|
||||
status: 1000,
|
||||
error: "client_stop",
|
||||
});
|
||||
this.#observedRequestId = null;
|
||||
}
|
||||
|
||||
const socket = this.#socket;
|
||||
this.#socket = null;
|
||||
if (!socket) {
|
||||
@@ -112,6 +128,13 @@ export class DispatchWsClient {
|
||||
return;
|
||||
}
|
||||
this.#setState("connecting");
|
||||
this.#observedRequestId = startObservedRequest({
|
||||
kind: "ws",
|
||||
source: "transport",
|
||||
label: "desktop.dispatch-ws",
|
||||
method: "GET",
|
||||
url: this.#options.url,
|
||||
});
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
@@ -120,6 +143,10 @@ export class DispatchWsClient {
|
||||
handshakeTimeout: 15_000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.#observedRequestId) {
|
||||
failObservedRequest(this.#observedRequestId, error);
|
||||
this.#observedRequestId = null;
|
||||
}
|
||||
this.emit("error", error);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
@@ -130,6 +157,13 @@ export class DispatchWsClient {
|
||||
socket.on("open", () => {
|
||||
this.#retryDelayMs = this.#options.initialRetryDelayMs ?? 1_000;
|
||||
this.#setState("open");
|
||||
if (this.#observedRequestId) {
|
||||
resolveObservedRequest(this.#observedRequestId, {
|
||||
phase: "response",
|
||||
status: 101,
|
||||
keepActive: true,
|
||||
});
|
||||
}
|
||||
this.emit("open", null);
|
||||
});
|
||||
|
||||
@@ -154,11 +188,23 @@ export class DispatchWsClient {
|
||||
});
|
||||
|
||||
socket.on("error", (error: Error) => {
|
||||
if (this.#observedRequestId) {
|
||||
failObservedRequest(this.#observedRequestId, error);
|
||||
this.#observedRequestId = null;
|
||||
}
|
||||
this.emit("error", error);
|
||||
});
|
||||
|
||||
socket.on("close", (code: number, reason: Buffer) => {
|
||||
this.#socket = null;
|
||||
if (this.#observedRequestId) {
|
||||
resolveObservedRequest(this.#observedRequestId, {
|
||||
phase: "close",
|
||||
status: code,
|
||||
error: reason.toString() || null,
|
||||
});
|
||||
this.#observedRequestId = null;
|
||||
}
|
||||
this.emit("close", { code, reason: reason.toString() });
|
||||
if (!this.#running) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { WebContentsView } from "electron/main";
|
||||
import type { WebContentsView as ElectronWebContentsView } from "electron/main";
|
||||
|
||||
import { maybeOpenExecutionDevtools } from "./execution-devtools";
|
||||
|
||||
export interface HotViewHandle {
|
||||
accountId: string;
|
||||
view: ElectronWebContentsView;
|
||||
@@ -68,6 +70,7 @@ export function acquireHotView(accountId: string): HotViewHandle {
|
||||
lastReleasedAt: Date.now(),
|
||||
retainCount: 0,
|
||||
};
|
||||
maybeOpenExecutionDevtools(handle.view.webContents, `hot-view:${accountId}`, { background: true });
|
||||
hotViews.set(accountId, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ListDesktopPublishTasksParams,
|
||||
} from "@geo/shared-types";
|
||||
import type { DesktopRuntimeSnapshot } from "../renderer/types";
|
||||
import type { DesktopObservedRequest } from "../shared/network-debug";
|
||||
|
||||
interface DesktopDeviceInfo {
|
||||
device_name: string;
|
||||
@@ -15,6 +16,68 @@ interface DesktopDeviceInfo {
|
||||
cpu_arch: string;
|
||||
}
|
||||
|
||||
const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request";
|
||||
const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response";
|
||||
|
||||
ipcRenderer.on(
|
||||
rendererProxyRequestChannel,
|
||||
async (
|
||||
_event,
|
||||
payload: {
|
||||
requestId?: unknown;
|
||||
request?: {
|
||||
url?: unknown;
|
||||
method?: unknown;
|
||||
headers?: unknown;
|
||||
body?: unknown;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const requestId = typeof payload?.requestId === "string" ? payload.requestId : "";
|
||||
const request = payload?.request;
|
||||
if (!requestId || !request || typeof request !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = typeof request.url === "string" ? request.url : "";
|
||||
const method = typeof request.method === "string" ? request.method : "GET";
|
||||
const headers = isStringRecord(request.headers) ? request.headers : {};
|
||||
const body = typeof request.body === "string" ? request.body : undefined;
|
||||
|
||||
const response = await (async () => {
|
||||
try {
|
||||
const fetchResponse = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
const bodyText = await fetchResponse.text();
|
||||
return {
|
||||
ok: fetchResponse.ok,
|
||||
status: fetchResponse.status,
|
||||
statusText: fetchResponse.statusText,
|
||||
headers: Object.fromEntries(fetchResponse.headers.entries()),
|
||||
bodyText,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
statusText: "",
|
||||
headers: {},
|
||||
bodyText: "",
|
||||
error: String(error && ((error as Error).message || error)),
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
ipcRenderer.send(rendererProxyResponseChannel, {
|
||||
requestId,
|
||||
response,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const desktopBridge = {
|
||||
app: {
|
||||
ping: () => ipcRenderer.invoke("desktop:ping") as Promise<string>,
|
||||
@@ -42,6 +105,8 @@ const desktopBridge = {
|
||||
ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise<null>,
|
||||
releaseRuntimeSession: (revoke?: boolean) =>
|
||||
ipcRenderer.invoke("desktop:runtime-session-release", revoke) as Promise<null>,
|
||||
setWindowMode: (mode: "login" | "main") =>
|
||||
ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise<null>,
|
||||
onRuntimeInvalidated: (
|
||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
||||
) => {
|
||||
@@ -53,7 +118,24 @@ const desktopBridge = {
|
||||
ipcRenderer.off("desktop:runtime-invalidated", wrapped);
|
||||
};
|
||||
},
|
||||
onNetworkObserved: (listener: (event: DesktopObservedRequest) => void) => {
|
||||
const wrapped = (_event: IpcRendererEvent, payload: DesktopObservedRequest) => {
|
||||
listener(payload);
|
||||
};
|
||||
ipcRenderer.on("desktop:network-observed", wrapped);
|
||||
return () => {
|
||||
ipcRenderer.off("desktop:network-observed", wrapped);
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("desktopBridge", desktopBridge);
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value).every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent, onMounted } from "vue";
|
||||
import { defineAsyncComponent, onMounted, watch } from "vue";
|
||||
import { useDesktopSession } from "./composables/useDesktopSession";
|
||||
|
||||
const DesktopShell = defineAsyncComponent(() => import("./components/DesktopShell.vue"));
|
||||
@@ -7,8 +7,28 @@ const LoginView = defineAsyncComponent(() => import("./views/LoginView.vue"));
|
||||
|
||||
const { isAuthenticated, syncRuntimeSession } = useDesktopSession();
|
||||
|
||||
interface WindowModeBridge {
|
||||
setWindowMode?: (mode: "login" | "main") => Promise<unknown>;
|
||||
}
|
||||
|
||||
function getModeBridge(): WindowModeBridge | undefined {
|
||||
return (globalThis as unknown as {
|
||||
desktopBridge?: { app?: WindowModeBridge };
|
||||
}).desktopBridge?.app;
|
||||
}
|
||||
|
||||
function syncWindowMode(authenticated: boolean): void {
|
||||
void getModeBridge()?.setWindowMode?.(authenticated ? "main" : "login");
|
||||
}
|
||||
|
||||
// Fire immediately during setup so the main process can size + reveal the
|
||||
// window before the user sees any "wrong-sized" frame.
|
||||
syncWindowMode(isAuthenticated.value);
|
||||
watch(isAuthenticated, (next) => syncWindowMode(next));
|
||||
|
||||
onMounted(() => {
|
||||
void syncRuntimeSession();
|
||||
syncWindowMode(isAuthenticated.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ const clientLabel = computed(
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<div class="window-drag-bar" aria-hidden="true"></div>
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">
|
||||
@@ -144,6 +145,7 @@ const clientLabel = computed(
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<div class="content-dragbar" aria-hidden="true"></div>
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
@@ -156,6 +158,23 @@ const clientLabel = computed(
|
||||
overflow: hidden;
|
||||
background: #f0f2f5;
|
||||
color: #1a1a1a;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Real drag handle for the macOS hidden title bar — must be above content
|
||||
but only spans the empty zone behind the traffic lights. */
|
||||
.window-drag-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 80px;
|
||||
right: 0;
|
||||
height: 28px;
|
||||
z-index: 1000;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.app-shell :where(button, a, input, select, textarea, [role="button"], [role="link"]) {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
@@ -165,6 +184,7 @@ const clientLabel = computed(
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
padding-top: 44px;
|
||||
border-right: 1px solid #e6edf5;
|
||||
background: #ffffff;
|
||||
flex-shrink: 0;
|
||||
@@ -174,6 +194,7 @@ const clientLabel = computed(
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@@ -459,6 +480,18 @@ h1 {
|
||||
height: 100vh;
|
||||
padding: 24px 32px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-dragbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
margin: -24px -32px 0;
|
||||
-webkit-app-region: drag;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ import type {
|
||||
DesktopRuntimeSessionSyncRequest,
|
||||
ListDesktopPublishTasksParams,
|
||||
} from "@geo/shared-types";
|
||||
import type { DesktopObservedRequest } from "../shared/network-debug";
|
||||
import type { DesktopRuntimeSnapshot } from "./types";
|
||||
|
||||
export {};
|
||||
@@ -29,9 +30,11 @@ declare global {
|
||||
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>;
|
||||
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>;
|
||||
releaseRuntimeSession(revoke?: boolean): Promise<null>;
|
||||
setWindowMode(mode: "login" | "main"): Promise<null>;
|
||||
onRuntimeInvalidated(
|
||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
||||
): () => void;
|
||||
onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import { router } from "./routes";
|
||||
import type { DesktopObservedRequest } from "../shared/network-debug";
|
||||
|
||||
import "ant-design-vue/dist/reset.css";
|
||||
import "../../../../packages/ui-shared/src/tokens/index.css";
|
||||
@@ -14,4 +15,42 @@ const themeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
applyTheme(themeQuery.matches);
|
||||
themeQuery.addEventListener("change", (event) => applyTheme(event.matches));
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
window.desktopBridge.app.onNetworkObserved((event) => {
|
||||
logObservedRequest(event);
|
||||
});
|
||||
}
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
||||
function logObservedRequest(event: DesktopObservedRequest): void {
|
||||
const summary = `${event.method} ${event.url}`;
|
||||
const detail = {
|
||||
kind: event.kind,
|
||||
source: event.source,
|
||||
label: event.label,
|
||||
partition: event.partition,
|
||||
resourceType: event.resourceType,
|
||||
status: event.status,
|
||||
durationMs: event.durationMs,
|
||||
error: event.error,
|
||||
at: event.at,
|
||||
};
|
||||
|
||||
if (event.phase === "error") {
|
||||
console.warn("[electron-request:error]", summary, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.phase === "close") {
|
||||
console.info("[electron-request:close]", summary, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.phase === "response") {
|
||||
console.info("[electron-request:response]", summary, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[electron-request:start]", summary, detail);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { useDesktopSession } from "../composables/useDesktopSession";
|
||||
|
||||
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession();
|
||||
|
||||
const showServerSettings = ref(false);
|
||||
const menuOpen = ref(false);
|
||||
const showServerDialog = ref(false);
|
||||
const rememberPassword = ref(!!localStorage.getItem("geo_rankly_saved_password"));
|
||||
|
||||
const form = reactive({
|
||||
@@ -13,6 +14,35 @@ const form = reactive({
|
||||
password: localStorage.getItem("geo_rankly_saved_password") || "",
|
||||
});
|
||||
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value;
|
||||
}
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (!menuOpen.value) return;
|
||||
const root = menuRef.value;
|
||||
if (root && !root.contains(event.target as Node)) {
|
||||
menuOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openServerSettings() {
|
||||
menuOpen.value = false;
|
||||
form.apiBaseURL = apiBaseURL.value;
|
||||
showServerDialog.value = true;
|
||||
}
|
||||
|
||||
function closeServerSettings() {
|
||||
showServerDialog.value = false;
|
||||
}
|
||||
|
||||
function saveServerSettings() {
|
||||
setApiBaseURL(form.apiBaseURL);
|
||||
showServerDialog.value = false;
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
if (rememberPassword.value) {
|
||||
localStorage.setItem("geo_rankly_saved_email", form.email);
|
||||
@@ -22,12 +52,19 @@ async function submitLogin() {
|
||||
localStorage.removeItem("geo_rankly_saved_password");
|
||||
}
|
||||
|
||||
setApiBaseURL(form.apiBaseURL);
|
||||
await login({
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("mousedown", handleOutsideClick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,64 +73,91 @@ async function submitLogin() {
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
<div class="grid-overlay"></div>
|
||||
</div>
|
||||
|
||||
<div class="top-bar" ref="menuRef">
|
||||
<button
|
||||
type="button"
|
||||
class="menu-trigger"
|
||||
:class="{ active: menuOpen }"
|
||||
aria-label="菜单"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<div v-if="menuOpen" class="menu-dropdown">
|
||||
<button type="button" class="menu-item" @click="openServerSettings">
|
||||
服务器设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-wrapper">
|
||||
<div class="auth-card">
|
||||
<header class="brand-header">
|
||||
<div class="brand-logo">
|
||||
<span class="brand-core"></span>
|
||||
</div>
|
||||
<h2>登录 GEO Rankly</h2>
|
||||
<p>登录租户账号,绑定专属客户端环境</p>
|
||||
</header>
|
||||
<header class="brand-header">
|
||||
<div class="brand-logo">
|
||||
<span class="brand-core"></span>
|
||||
</div>
|
||||
<h1 class="brand-name">GEO Rankly</h1>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="submitLogin">
|
||||
<label class="field">
|
||||
<span>邮箱 / 账号</span>
|
||||
<input
|
||||
v-model.trim="form.email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
placeholder="you@company.com"
|
||||
required
|
||||
/>
|
||||
<form class="login-form" @submit.prevent="submitLogin">
|
||||
<div class="field">
|
||||
<input
|
||||
v-model.trim="form.email"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
placeholder="邮箱 / 账号"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="密码"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="options-row">
|
||||
<label class="check-label">
|
||||
<input type="checkbox" v-model="rememberPassword" />
|
||||
<span>记住密码</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" class="submit-button" :disabled="pending">
|
||||
{{ pending ? "登录中..." : "登 录" }}
|
||||
</button>
|
||||
|
||||
<div class="remember-row">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="rememberPassword" />
|
||||
<span>记住账号和密码</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-button" :disabled="pending">
|
||||
{{ pending ? "验证中..." : "登 录" }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="advanced-section">
|
||||
<a
|
||||
v-if="!showServerSettings"
|
||||
class="toggle-link"
|
||||
@click="showServerSettings = true"
|
||||
>
|
||||
服务器选项
|
||||
</a>
|
||||
<div v-else class="advanced-fields">
|
||||
<label class="field subtle-field">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="showServerDialog"
|
||||
class="dialog-mask"
|
||||
@click.self="closeServerSettings"
|
||||
>
|
||||
<div class="dialog" role="dialog" aria-modal="true">
|
||||
<header class="dialog-header">
|
||||
<h3>服务器设置</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="dialog-close"
|
||||
aria-label="关闭"
|
||||
@click="closeServerSettings"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="dialog-body">
|
||||
<label class="dialog-field">
|
||||
<span>网关 API 地址</span>
|
||||
<input
|
||||
v-model.trim="form.apiBaseURL"
|
||||
@@ -102,29 +166,40 @@ async function submitLogin() {
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
<p class="dialog-hint">修改后对本机下次登录生效。</p>
|
||||
</div>
|
||||
<footer class="dialog-footer">
|
||||
<button type="button" class="btn-ghost" @click="closeServerSettings">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" class="btn-primary" @click="saveServerSettings">
|
||||
保存
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error-text">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<footer class="login-footer">
|
||||
<p>GEO Rankly Desktop Environment</p>
|
||||
</footer>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #f7f9fc;
|
||||
padding: 24px;
|
||||
background: linear-gradient(180deg, #eaf2ff 0%, #e4ecff 40%, #e8efff 100%);
|
||||
padding: 0 24px 28px;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.login-shell input,
|
||||
.login-shell button,
|
||||
.login-shell a,
|
||||
.login-shell label {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.bg-layer {
|
||||
@@ -139,269 +214,81 @@ async function submitLogin() {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(60px);
|
||||
opacity: 0.6;
|
||||
animation: float 20s infinite ease-in-out alternate;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(22, 119, 255, 0.4), rgba(22, 119, 255, 0));
|
||||
top: -200px;
|
||||
left: -200px;
|
||||
animation-delay: 0s;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
background: radial-gradient(circle, rgba(120, 170, 255, 0.45), rgba(120, 170, 255, 0));
|
||||
top: -180px;
|
||||
left: -160px;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(20, 184, 166, 0.3), rgba(20, 184, 166, 0));
|
||||
bottom: -150px;
|
||||
right: -100px;
|
||||
animation-duration: 25s;
|
||||
animation-delay: -5s;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
background: radial-gradient(circle, rgba(180, 210, 255, 0.5), rgba(180, 210, 255, 0));
|
||||
bottom: -140px;
|
||||
right: -120px;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0));
|
||||
top: 50%;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: radial-gradient(circle, rgba(200, 220, 255, 0.45), rgba(200, 220, 255, 0));
|
||||
top: 55%;
|
||||
left: 60%;
|
||||
animation-duration: 22s;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.grid-overlay {
|
||||
.top-bar {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
opacity: 0.6;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: translate(5%, 10%) scale(1.05);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-10%, -5%) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
animation: slideUpFade 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes slideUpFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
padding: 40px 32px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin: 0 auto 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: #f0f7ff;
|
||||
border: 1px solid #bae0ff;
|
||||
}
|
||||
|
||||
.brand-core {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #1677ff;
|
||||
box-shadow: 0 4px 12px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.brand-header h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.field input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(217, 217, 217, 0.8);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1a1a1a;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.remember-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
.menu-trigger {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #1677ff;
|
||||
}
|
||||
|
||||
.checkbox-label span {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
margin-top: 8px;
|
||||
height: 40px;
|
||||
background: #1677ff;
|
||||
color: #ffffff;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: #4096ff;
|
||||
.menu-trigger span {
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: #3a4a66;
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
.menu-trigger:hover,
|
||||
.menu-trigger.active {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.advanced-section {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
.menu-dropdown {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
right: 0;
|
||||
min-width: 140px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 32px rgba(31, 58, 112, 0.18);
|
||||
padding: 8px;
|
||||
animation: dropdown-in 0.15s ease;
|
||||
}
|
||||
|
||||
.toggle-link {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.toggle-link:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.advanced-fields {
|
||||
text-align: left;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #e6edf5;
|
||||
animation: fade-in 0.3s ease;
|
||||
}
|
||||
|
||||
.subtle-field span {
|
||||
color: #8c8c8c;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subtle-field input {
|
||||
background: #fafafb;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin: 16px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 6px;
|
||||
color: #ff4d4f;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
@keyframes dropdown-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
@@ -411,4 +298,316 @@ async function submitLogin() {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.auth-wrapper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
animation: slideUpFade 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes slideUpFade {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.brand-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
|
||||
box-shadow: 0 10px 22px rgba(22, 119, 255, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.brand-core {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(22, 119, 255, 0.25);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand-core::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 7px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(135deg, #1677ff, #69b1ff);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
margin: 10px 0 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 4px 12px rgba(31, 58, 112, 0.08);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: #9aa4b8;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 4px 14px rgba(31, 58, 112, 0.08), 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.options-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.check-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.check-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #1677ff;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
margin-top: 6px;
|
||||
height: 40px;
|
||||
background: #1677ff;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.4em;
|
||||
padding-left: 0.4em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 16px rgba(22, 119, 255, 0.25);
|
||||
transition: background 0.2s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background: #4096ff;
|
||||
}
|
||||
|
||||
.submit-button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin: 4px 0 0;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 77, 79, 0.08);
|
||||
border: 1px solid rgba(255, 77, 79, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #d9363e;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(17, 24, 39, 0.35);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #eef1f6;
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
color: #9aa4b8;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background: #f2f6ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
padding: 18px 20px 8px;
|
||||
}
|
||||
|
||||
.dialog-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-field span {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.dialog-field input {
|
||||
height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d6dde8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-field input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.dialog-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
color: #9aa4b8;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 14px 20px 18px;
|
||||
}
|
||||
|
||||
.btn-ghost,
|
||||
.btn-primary {
|
||||
height: 34px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: #d6dde8;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: #f6f8fc;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1677ff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4096ff;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export type DesktopObservedRequestPhase = "request" | "response" | "close" | "error";
|
||||
|
||||
export type DesktopObservedRequestKind = "http" | "sse" | "ws";
|
||||
|
||||
export type DesktopObservedRequestSource = "session" | "main" | "transport";
|
||||
|
||||
export interface DesktopObservedRequest {
|
||||
id: string;
|
||||
at: number;
|
||||
phase: DesktopObservedRequestPhase;
|
||||
kind: DesktopObservedRequestKind;
|
||||
source: DesktopObservedRequestSource;
|
||||
label: string;
|
||||
method: string;
|
||||
url: string;
|
||||
partition: string | null;
|
||||
resourceType: string | null;
|
||||
status: number | null;
|
||||
durationMs: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopObservedRequestSnapshot {
|
||||
rendererAttached: boolean;
|
||||
activeCount: number;
|
||||
recent: DesktopObservedRequest[];
|
||||
}
|
||||
Reference in New Issue
Block a user