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:
2026-04-22 00:24:21 +08:00
parent 749b6b99cd
commit 4142c53fa6
57 changed files with 7897 additions and 985 deletions
+577 -110
View File
@@ -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,
+139 -20
View File
@@ -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.
}
}
+101 -15
View File
@@ -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;
}
+82
View File
@@ -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");
}
+21 -1
View File
@@ -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
View File
@@ -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;
};
};
}
+39
View File
@@ -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[];
}