b16e9f0bd1
Plan 0 (workspaces): add workspaces + workspace_memberships schema, extend JWT/Actor/claims with primary_workspace_id, seed default workspace per tenant, thread workspace_id through tenant monitoring quota. Plan A (desktop skeleton): new Electron app (apps/desktop-client) with main/ preload/renderer, shared Vue component package (packages/ui-shared), and server surface — desktop client registration + token rotation + heartbeat, SSE task event stream, desktop accounts/tasks/content handlers, publish job endpoint, and supporting repositories, services, sqlc queries, and migrations. Hard cutover per plan: remove browser-extension monitoring callback endpoints, stub legacy media API in admin-web, and delete monitoring_callback_handler.go.
226 lines
6.3 KiB
TypeScript
226 lines
6.3 KiB
TypeScript
import type { BrowserWindow } from "electron/main";
|
|
|
|
import { STANDARD_USER_AGENT } from "./user-agent";
|
|
|
|
const ignoredNavigationFailurePattern = /ERR_ABORTED|ERR_BLOCKED_BY_CLIENT/i;
|
|
const errorPagePrefix = "data:text/html";
|
|
|
|
interface WindowNavigationState {
|
|
readyForDetection: boolean;
|
|
lastMainFrameError: string | null;
|
|
}
|
|
|
|
const navigationStateRegistry = new WeakMap<BrowserWindow, WindowNavigationState>();
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll("\"", """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function errorPageDataURL(context: string, targetURL: string, message: string): string {
|
|
const html = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>${escapeHtml(context)}</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light dark;
|
|
font-family: "IBM Plex Sans", "Segoe UI Variable", "Segoe UI", sans-serif;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
min-height: 100vh;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 24px;
|
|
background:
|
|
radial-gradient(circle at top left, rgba(15, 118, 110, 0.12), transparent 28%),
|
|
radial-gradient(circle at top right, rgba(37, 99, 235, 0.1), transparent 24%),
|
|
linear-gradient(180deg, #eef3f8, #e4ebf3);
|
|
color: #0f172a;
|
|
}
|
|
.card {
|
|
width: min(680px, 100%);
|
|
padding: 24px;
|
|
border-radius: 24px;
|
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
background: rgba(255, 255, 255, 0.88);
|
|
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.12);
|
|
}
|
|
h1, p, pre {
|
|
margin: 0;
|
|
}
|
|
h1 {
|
|
font-size: 28px;
|
|
letter-spacing: -0.04em;
|
|
}
|
|
p {
|
|
margin-top: 12px;
|
|
line-height: 1.7;
|
|
color: #475569;
|
|
}
|
|
pre {
|
|
margin-top: 16px;
|
|
padding: 14px 16px;
|
|
border-radius: 16px;
|
|
background: rgba(15, 23, 42, 0.05);
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
margin-top: 18px;
|
|
}
|
|
button,
|
|
a {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 44px;
|
|
padding: 0 16px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
|
background: white;
|
|
color: #0f172a;
|
|
text-decoration: none;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
.primary {
|
|
background: linear-gradient(180deg, #44a7ff, #1b6cff);
|
|
border-color: rgba(27, 108, 255, 0.18);
|
|
color: white;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="card">
|
|
<h1>授权页加载失败</h1>
|
|
<p>${escapeHtml(context)} 在打开远程页面时出现网络或 TLS 握手异常。窗口已保留,不再直接闪退。</p>
|
|
<p>如果这是偶发网络抖动,点“重新加载”即可;如果持续失败,再看控制台里打印的 URL 和错误码定位。</p>
|
|
<pre>${escapeHtml(message)}
|
|
|
|
${escapeHtml(targetURL)}</pre>
|
|
<div class="actions">
|
|
<button class="primary" onclick="location.href = ${JSON.stringify(targetURL)}">重新加载</button>
|
|
<a href=${JSON.stringify(targetURL)} target="_self">打开目标地址</a>
|
|
</div>
|
|
</main>
|
|
</body>
|
|
</html>`;
|
|
|
|
return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`;
|
|
}
|
|
|
|
export function attachWindowDiagnostics(window: BrowserWindow, context: string): void {
|
|
navigationStateRegistry.set(window, {
|
|
readyForDetection: false,
|
|
lastMainFrameError: null,
|
|
});
|
|
|
|
window.webContents.on("did-start-loading", () => {
|
|
const state = navigationStateRegistry.get(window);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
state.readyForDetection = false;
|
|
});
|
|
|
|
window.webContents.on(
|
|
"did-fail-load",
|
|
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
if (!isMainFrame || errorCode === -3) {
|
|
return;
|
|
}
|
|
|
|
const state = navigationStateRegistry.get(window);
|
|
if (state) {
|
|
state.readyForDetection = false;
|
|
state.lastMainFrameError = `${errorCode}:${errorDescription}`;
|
|
}
|
|
|
|
console.warn(`[desktop-window] ${context} main-frame load failed`, {
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
});
|
|
},
|
|
);
|
|
|
|
window.webContents.on("did-finish-load", () => {
|
|
const state = navigationStateRegistry.get(window);
|
|
if (!state) {
|
|
return;
|
|
}
|
|
|
|
const currentURL = window.webContents.getURL();
|
|
state.readyForDetection = !currentURL.startsWith(errorPagePrefix);
|
|
if (state.readyForDetection) {
|
|
state.lastMainFrameError = null;
|
|
}
|
|
});
|
|
|
|
window.webContents.on("render-process-gone", (_event, details) => {
|
|
const state = navigationStateRegistry.get(window);
|
|
if (state) {
|
|
state.readyForDetection = false;
|
|
}
|
|
|
|
console.error(`[desktop-window] ${context} renderer gone`, details);
|
|
});
|
|
}
|
|
|
|
export function isWindowReadyForDetection(window: BrowserWindow): boolean {
|
|
const state = navigationStateRegistry.get(window);
|
|
if (!state) {
|
|
return !window.webContents.getURL().startsWith(errorPagePrefix);
|
|
}
|
|
return state.readyForDetection;
|
|
}
|
|
|
|
export async function loadWindowURLSafely(
|
|
window: BrowserWindow,
|
|
targetURL: string,
|
|
context: string,
|
|
): Promise<void> {
|
|
try {
|
|
await window.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
|
|
if (ignoredNavigationFailurePattern.test(message) || window.isDestroyed()) {
|
|
return;
|
|
}
|
|
|
|
console.error(`[desktop-window] ${context} loadURL failed`, {
|
|
targetURL,
|
|
message,
|
|
});
|
|
|
|
if (window.webContents.getURL().startsWith(errorPagePrefix)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await window.loadURL(errorPageDataURL(context, targetURL, message));
|
|
} catch (fallbackError) {
|
|
const fallbackMessage =
|
|
fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
|
|
console.error(`[desktop-window] ${context} error-page loadURL failed`, {
|
|
targetURL,
|
|
message: fallbackMessage,
|
|
});
|
|
}
|
|
}
|
|
}
|