产品化改造:完善本地集成与启动体验
This commit is contained in:
0
qimingclaw/crates/agent-electron-client/resources/lanproxy/binaries/qiming-lanproxy-aarch64-apple-darwin
Normal file → Executable file
0
qimingclaw/crates/agent-electron-client/resources/lanproxy/binaries/qiming-lanproxy-aarch64-apple-darwin
Normal file → Executable file
@@ -2,11 +2,12 @@
|
||||
/**
|
||||
* qimingcode 多平台集成:准备 resources/qimingcode/{platform}/bin/
|
||||
*
|
||||
* 两种模式:
|
||||
* 三种模式:
|
||||
* 1) 本地 dist 复制(设置 QIMINGCODE_DIST_DIR 环境变量,开发调试用)
|
||||
* QIMINGCODE_DIST_DIR=~/workspace/qimingcode/packages/opencode/dist npm run prepare:qimingcode
|
||||
* 2) GitHub Release 下载(默认,CI/正式构建用)
|
||||
* npm run prepare:qimingcode
|
||||
* 2) 本地源码构建(默认开发模式):自动查找同级 qimingcode 仓库,缺 dist 时用 Bun 构建当前平台
|
||||
* 3) GitHub Release 下载(CI/正式构建兜底)
|
||||
* QIMINGCODE_ALLOW_REMOTE=1 npm run prepare:qimingcode
|
||||
*
|
||||
* 打包时 electron-builder extraResources 将 resources/qimingcode 打包到应用内
|
||||
* 运行时 getQimingCodeBundledBinPath() 解析对应平台二进制
|
||||
@@ -17,6 +18,8 @@
|
||||
*
|
||||
* 环境变量:
|
||||
* QIMINGCODE_DIST_DIR — qimingcode 本地构建产物目录(设置后走本地复制模式)
|
||||
* QIMINGCODE_SOURCE_DIR — qimingcode 本地源码仓库目录(默认查找 ../../../qimingcode)
|
||||
* QIMINGCODE_ALLOW_REMOTE — 允许本地源码不可用时从 GitHub Release 下载
|
||||
* QIMINGCODE_REPO — GitHub 仓库(默认 qiming-ai/qimingcode)
|
||||
* GITHUB_TOKEN — GitHub token(私有仓库或提高速率限制用)
|
||||
*/
|
||||
@@ -228,14 +231,123 @@ function copyFromDist(key) {
|
||||
function getLocalDistDir() {
|
||||
const candidates = [
|
||||
process.env.QIMINGCODE_DIST_DIR,
|
||||
path.resolve(projectRoot, '..', '..', '..', 'qimingcode', 'packages', 'opencode', 'dist'),
|
||||
getLocalSourceDir() ? path.join(getLocalSourceDir(), 'packages', 'opencode', 'dist') : null,
|
||||
path.join(process.env.HOME || '/root', 'workspace/qimingcode/packages/opencode/dist'),
|
||||
].filter(Boolean);
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0];
|
||||
}
|
||||
|
||||
// ==================== 模式 2: GitHub Release 下载 ====================
|
||||
function getLocalSourceDir() {
|
||||
const candidates = [
|
||||
process.env.QIMINGCODE_SOURCE_DIR,
|
||||
path.resolve(projectRoot, '..', '..', '..', 'qimingcode'),
|
||||
path.join(process.env.HOME || '/root', 'workspace/qimingcode'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(path.join(candidate, 'packages', 'opencode', 'package.json'))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBunCommand() {
|
||||
if (process.env.BUN_BIN) return process.env.BUN_BIN;
|
||||
try {
|
||||
return execFileSync('which', ['bun'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalBuildEnv(sourceDir, opencodeDir, bun) {
|
||||
const bunDir = path.dirname(bun);
|
||||
const localModelsJson = path.join(opencodeDir, 'assets', 'models.json');
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_VERSION: QIMINGCODE_VERSION,
|
||||
PATH: `${bunDir}${path.delimiter}${process.env.PATH || ''}`,
|
||||
...getLocalRuntimeEnv(sourceDir),
|
||||
};
|
||||
|
||||
if (!process.env.MODELS_DEV_API_JSON && fs.existsSync(localModelsJson)) {
|
||||
env.MODELS_DEV_API_JSON = localModelsJson;
|
||||
console.log(`[prepare-qimingcode] 使用本地模型快照: ${localModelsJson}`);
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function getLocalRuntimeEnv(sourceDir = getLocalSourceDir() || projectRoot) {
|
||||
const localRuntimeDir = path.join(sourceDir, '.local-runtime');
|
||||
return {
|
||||
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME || path.join(localRuntimeDir, 'cache'),
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME || path.join(localRuntimeDir, 'config'),
|
||||
XDG_DATA_HOME: process.env.XDG_DATA_HOME || path.join(localRuntimeDir, 'data'),
|
||||
XDG_STATE_HOME: process.env.XDG_STATE_HOME || path.join(localRuntimeDir, 'state'),
|
||||
OPENCODE_DATA_DIR: process.env.OPENCODE_DATA_DIR || path.join(localRuntimeDir, 'data', 'opencode'),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureLocalDist(key) {
|
||||
const distDir = getLocalDistDir();
|
||||
const distName = PLATFORM_MAP[key];
|
||||
if (!distName) return false;
|
||||
|
||||
const binDir = path.join(distDir, distName, 'bin');
|
||||
const existing = getBinaryCandidates(key)
|
||||
.map((candidate) => path.join(binDir, candidate))
|
||||
.find((candidatePath) => fs.existsSync(candidatePath));
|
||||
if (existing) return true;
|
||||
|
||||
const sourceDir = getLocalSourceDir();
|
||||
if (!sourceDir) return false;
|
||||
|
||||
const bun = getBunCommand();
|
||||
if (!bun) {
|
||||
console.error('[prepare-qimingcode] 找到本地 qimingcode 源码,但未找到 Bun。');
|
||||
console.error('[prepare-qimingcode] 请先安装 Bun,或设置 BUN_BIN=/path/to/bun 后重试。');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[prepare-qimingcode] 本地 dist 缺失,使用源码构建当前平台: ${sourceDir}`);
|
||||
const opencodeDir = path.join(sourceDir, 'packages', 'opencode');
|
||||
const hasRootNodeModules = fs.existsSync(path.join(sourceDir, 'node_modules'));
|
||||
const hasOpencodeNodeModules = fs.existsSync(path.join(opencodeDir, 'node_modules'));
|
||||
if (!hasRootNodeModules || !hasOpencodeNodeModules) {
|
||||
console.log('[prepare-qimingcode] 安装 qimingcode 依赖 (bun install --ignore-scripts)...');
|
||||
execFileSync(bun, ['install', '--ignore-scripts'], { cwd: sourceDir, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
const buildEnv = getLocalBuildEnv(sourceDir, opencodeDir, bun);
|
||||
|
||||
try {
|
||||
console.log('[prepare-qimingcode] 修复 node-pty helper 权限...');
|
||||
execFileSync(bun, ['run', 'fix-node-pty'], { cwd: opencodeDir, stdio: 'inherit', env: buildEnv });
|
||||
} catch (err) {
|
||||
console.warn(`[prepare-qimingcode] node-pty 权限修复失败,将继续构建: ${err.message}`);
|
||||
}
|
||||
|
||||
console.log('[prepare-qimingcode] 构建 qimingcode 当前平台二进制...');
|
||||
execFileSync(
|
||||
bun,
|
||||
['run', 'script/build.ts', '--single', '--skip-embed-web-ui', '--skip-install'],
|
||||
{
|
||||
cwd: opencodeDir,
|
||||
stdio: 'inherit',
|
||||
env: buildEnv,
|
||||
},
|
||||
);
|
||||
|
||||
return getBinaryCandidates(key)
|
||||
.map((candidate) => path.join(binDir, candidate))
|
||||
.some((candidatePath) => fs.existsSync(candidatePath));
|
||||
}
|
||||
|
||||
// ==================== 模式 2: GitHub Release 下载(显式兜底) ====================
|
||||
|
||||
/**
|
||||
* 下载文件到缓存目录
|
||||
@@ -568,7 +680,11 @@ function verifyBinaryVersion(binaryPath, expectedVersion, key, hash) {
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execFileSync(binaryPath, ['-v'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
||||
const output = execFileSync(binaryPath, ['-v'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...getLocalRuntimeEnv() },
|
||||
}).trim();
|
||||
if (output !== expectedVersion) {
|
||||
console.warn(
|
||||
`[prepare-qimingcode] ${key}: ⚠️ 二进制内部版本 ${output} 与期望版本 ${expectedVersion} 不一致(release tag 版本与二进制版本不同步)`,
|
||||
@@ -594,8 +710,13 @@ function codesign(binaryPath, key) {
|
||||
|
||||
async function main() {
|
||||
const allPlatforms = process.argv.includes('--all') || process.argv.includes('--all-platforms');
|
||||
const useLocalDist = !!process.env.QIMINGCODE_DIST_DIR || fs.existsSync(getLocalDistDir());
|
||||
const mode = useLocalDist ? '本地 dist 复制' : 'GitHub Release 下载';
|
||||
const allowRemote = process.env.QIMINGCODE_ALLOW_REMOTE === '1';
|
||||
const localSourceDir = getLocalSourceDir();
|
||||
const mode = localSourceDir || process.env.QIMINGCODE_DIST_DIR
|
||||
? '本地优先(dist/源码构建)'
|
||||
: allowRemote
|
||||
? 'GitHub Release 下载'
|
||||
: '本地优先(未找到本地源码)';
|
||||
|
||||
fs.mkdirSync(resDir, { recursive: true });
|
||||
|
||||
@@ -615,7 +736,18 @@ async function main() {
|
||||
let fail = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
const success = useLocalDist ? copyFromDist(key) : await downloadFromRelease(key);
|
||||
let success = false;
|
||||
|
||||
if (ensureLocalDist(key)) {
|
||||
success = copyFromDist(key);
|
||||
} else if (allowRemote) {
|
||||
console.warn('[prepare-qimingcode] 本地 qimingcode 产物不可用,按 QIMINGCODE_ALLOW_REMOTE=1 使用远端兜底');
|
||||
success = await downloadFromRelease(key);
|
||||
} else {
|
||||
console.error('[prepare-qimingcode] 本地 qimingcode 产物不可用,且未允许远端下载。');
|
||||
console.error('[prepare-qimingcode] 请确保同级存在 qimingcode 仓库,或设置 QIMINGCODE_SOURCE_DIR / QIMINGCODE_DIST_DIR。');
|
||||
}
|
||||
|
||||
if (success) {
|
||||
ok++;
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { startComputerServer } from "../services/computerServer";
|
||||
import {
|
||||
mcpProxyManager,
|
||||
DEFAULT_MCP_PROXY_CONFIG,
|
||||
pruneDisabledDefaultMcpServers,
|
||||
} from "../services/packages/mcp";
|
||||
import { getConfiguredPorts } from "../services/startupPorts";
|
||||
import { DEPS_SYNC_TIMEOUT } from "@shared/constants";
|
||||
@@ -55,13 +56,14 @@ export async function runStartupTasks(): Promise<void> {
|
||||
if (savedConfig) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedConfig.value);
|
||||
// 合并默认服务器(如 chrome-devtools),确保内置 MCP 服务始终存在
|
||||
// 合并可用的默认服务器;旧版本持久化的 chrome-devtools npx 默认项
|
||||
// 在本地未安装且未显式启用时会被过滤,避免启动时阻塞 60s。
|
||||
const merged = {
|
||||
...parsed,
|
||||
mcpServers: {
|
||||
mcpServers: pruneDisabledDefaultMcpServers({
|
||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
||||
...(parsed.mcpServers || {}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
mcpProxyManager.setConfig(merged);
|
||||
} catch (e) {
|
||||
|
||||
@@ -368,15 +368,71 @@ export function resolveServersConfig(
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
const CHROME_DEVTOOLS_MCP_NAME = "chrome-devtools";
|
||||
const CHROME_DEVTOOLS_MCP_PACKAGE = "chrome-devtools-mcp";
|
||||
|
||||
function findLocalChromeDevtoolsMcpBin(): string | null {
|
||||
const binName = isWindows()
|
||||
? `${CHROME_DEVTOOLS_MCP_PACKAGE}.cmd`
|
||||
: CHROME_DEVTOOLS_MCP_PACKAGE;
|
||||
const candidates = [
|
||||
path.join(
|
||||
os.homedir(),
|
||||
APP_DATA_DIR_NAME,
|
||||
"node_modules",
|
||||
".bin",
|
||||
binName,
|
||||
),
|
||||
path.join(process.cwd(), "node_modules", ".bin", binName),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
|
||||
}
|
||||
|
||||
function shouldEnableDefaultChromeDevtoolsMcp(): boolean {
|
||||
return (
|
||||
process.env.QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP === "1" ||
|
||||
!!findLocalChromeDevtoolsMcpBin()
|
||||
);
|
||||
}
|
||||
|
||||
function getDefaultMcpServers(): Record<string, McpServerEntry> {
|
||||
if (!shouldEnableDefaultChromeDevtoolsMcp()) return {};
|
||||
|
||||
const localBin = findLocalChromeDevtoolsMcpBin();
|
||||
return {
|
||||
[CHROME_DEVTOOLS_MCP_NAME]: localBin
|
||||
? {
|
||||
command: localBin,
|
||||
args: [],
|
||||
persistent: true,
|
||||
}
|
||||
: {
|
||||
command: "npx",
|
||||
args: ["-y", `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`],
|
||||
persistent: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLegacyDefaultChromeDevtoolsEntry(
|
||||
name: string,
|
||||
entry: McpServerEntry,
|
||||
): boolean {
|
||||
return (
|
||||
name === CHROME_DEVTOOLS_MCP_NAME &&
|
||||
!isRemoteEntry(entry) &&
|
||||
entry.command === "npx" &&
|
||||
Array.isArray(entry.args) &&
|
||||
entry.args.length === 2 &&
|
||||
entry.args[0] === "-y" &&
|
||||
entry.args[1] === `${CHROME_DEVTOOLS_MCP_PACKAGE}@latest`
|
||||
);
|
||||
}
|
||||
|
||||
/** 默认 mcpServers 配置 */
|
||||
export const DEFAULT_MCP_PROXY_CONFIG: McpServersConfig = {
|
||||
mcpServers: {
|
||||
"chrome-devtools": {
|
||||
command: "npx",
|
||||
args: ["-y", "chrome-devtools-mcp@latest"],
|
||||
persistent: true,
|
||||
},
|
||||
},
|
||||
mcpServers: getDefaultMcpServers(),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -437,6 +493,24 @@ export function isRemoteEntry(
|
||||
return "url" in entry;
|
||||
}
|
||||
|
||||
export function pruneDisabledDefaultMcpServers(
|
||||
servers: Record<string, McpServerEntry> = {},
|
||||
): Record<string, McpServerEntry> {
|
||||
if (shouldEnableDefaultChromeDevtoolsMcp()) return servers;
|
||||
|
||||
const result: Record<string, McpServerEntry> = {};
|
||||
for (const [name, entry] of Object.entries(servers)) {
|
||||
if (isLegacyDefaultChromeDevtoolsEntry(name, entry)) {
|
||||
log.info(
|
||||
"[McpProxy] Default chrome-devtools MCP disabled (not installed locally; set QIMINGCLAW_ENABLE_CHROME_DEVTOOLS_MCP=1 to force npx)",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
result[name] = entry;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** mcpServers 配置(传给 qiming-mcp-stdio-proxy 的 JSON) */
|
||||
export interface McpServersConfig {
|
||||
mcpServers: Record<string, McpServerEntry>;
|
||||
@@ -1298,18 +1372,17 @@ export async function syncMcpConfigToProxyAndReload(
|
||||
// realOnly 为空时(用户删除了所有动态 MCP)不提前返回,
|
||||
// 继续执行以确保 bridge 仅运行默认服务(chrome-devtools)
|
||||
|
||||
// 始终以默认服务为基础,再叠加动态 MCP:
|
||||
// - 用户删除所有动态 MCP → merged 仅含 chrome-devtools
|
||||
// - 用户删除部分动态 MCP → merged 含 chrome-devtools + 剩余动态 MCP
|
||||
// - 用户新增动态 MCP → merged 含 chrome-devtools + 所有动态 MCP
|
||||
// 以可用的默认服务为基础,再叠加动态 MCP。默认 chrome-devtools
|
||||
// 只有本地已安装或显式启用时才注入,避免 npx @latest 在启动时阻塞 60s。
|
||||
const merged: Record<string, McpServerEntry> = {
|
||||
...DEFAULT_MCP_PROXY_CONFIG.mcpServers,
|
||||
...getDefaultMcpServers(),
|
||||
...realOnly,
|
||||
};
|
||||
const prunedMerged = pruneDisabledDefaultMcpServers(merged);
|
||||
|
||||
// 为所有 MCP 服务器注入基础环境变量(包括 PATH)
|
||||
const prepareStartedAt = Date.now();
|
||||
const mergedWithEnv = injectBaseEnvToMcpServers(merged);
|
||||
const mergedWithEnv = injectBaseEnvToMcpServers(prunedMerged);
|
||||
|
||||
// Bridge 只管理 persistent 服务(如 chrome-devtools),动态 MCP 不进 bridge。
|
||||
// 变更检测和重启均只针对 persistent servers,避免动态 MCP 变化时重启 chrome-devtools。
|
||||
|
||||
@@ -1448,10 +1448,26 @@ export async function checkQimingFileServerBundled(): Promise<{
|
||||
export async function checkClaudeCodeAcpBundled(): Promise<{
|
||||
available: boolean;
|
||||
version?: string;
|
||||
binPath?: string;
|
||||
}> {
|
||||
const bundledDir = getClaudeCodeAcpBundledDir();
|
||||
if (!bundledDir) {
|
||||
log.info("[checkClaudeCodeAcpBundled] Bundled not found");
|
||||
const local = await detectNpmPackage(
|
||||
"claude-code-acp-ts",
|
||||
"claude-code-acp-ts",
|
||||
);
|
||||
if (local.installed) {
|
||||
log.info(
|
||||
`[checkClaudeCodeAcpBundled] Bundled not found; using npm-local package: ${local.binPath ?? "unknown"}, version=${local.version ?? "unknown"}`,
|
||||
);
|
||||
return {
|
||||
available: true,
|
||||
version: local.version,
|
||||
binPath: local.binPath,
|
||||
};
|
||||
}
|
||||
|
||||
log.info("[checkClaudeCodeAcpBundled] Bundled and npm-local package not found");
|
||||
return { available: false };
|
||||
}
|
||||
const pkgPath = path.join(bundledDir, "package.json");
|
||||
@@ -1462,10 +1478,10 @@ export async function checkClaudeCodeAcpBundled(): Promise<{
|
||||
log.info(
|
||||
`[checkClaudeCodeAcpBundled] Bundled available: ${bundledDir}, version=${version ?? "unknown"}`,
|
||||
);
|
||||
return { available: true, version };
|
||||
return { available: true, version, binPath: bundledDir };
|
||||
} catch (e) {
|
||||
log.warn("[checkClaudeCodeAcpBundled] Failed to read package.json:", e);
|
||||
return { available: true };
|
||||
return { available: true, binPath: bundledDir };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1960,7 +1976,21 @@ export async function checkAllDependencies(options?: {
|
||||
item.status = "missing";
|
||||
}
|
||||
} else {
|
||||
item.status = "missing";
|
||||
const result = await detectNpmPackage(dep.name, dep.binName);
|
||||
item.version = result.version;
|
||||
item.binPath = result.binPath;
|
||||
if (!result.installed) {
|
||||
item.status = "missing";
|
||||
} else if (dep.installVersion) {
|
||||
const installed = (result.version ?? "0").replace(/^v/, "");
|
||||
const target = dep.installVersion.replace(/^v/, "");
|
||||
item.status =
|
||||
installed === "0" || compareVersions(installed, target) < 0
|
||||
? "outdated"
|
||||
: "installed";
|
||||
} else {
|
||||
item.status = "installed";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -486,13 +486,15 @@ function App() {
|
||||
|
||||
const deps = result?.results ?? [];
|
||||
const hasMissingOrError = deps.some(
|
||||
(d: { status: string }) =>
|
||||
d.status === "missing" || d.status === "error",
|
||||
(d: { status: string; required?: boolean }) =>
|
||||
d.required !== false &&
|
||||
(d.status === "missing" || d.status === "error"),
|
||||
);
|
||||
const missingDeps = deps
|
||||
.filter(
|
||||
(d: { status: string }) =>
|
||||
d.status === "missing" || d.status === "error",
|
||||
(d: { status: string; required?: boolean }) =>
|
||||
d.required !== false &&
|
||||
(d.status === "missing" || d.status === "error"),
|
||||
)
|
||||
.map((d: { name: string; status: string }) => d.name);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
syncCookieAndGetNewSessionUrl,
|
||||
syncCookieAndGetChatUrl,
|
||||
persistTicketCookie,
|
||||
normalizeLocalBackendRedirect,
|
||||
} from "../../services/utils/sessionUrl";
|
||||
import { logger } from "../../services/utils/logService";
|
||||
import { APP_DISPLAY_NAME } from "@shared/constants";
|
||||
@@ -89,19 +90,47 @@ function SessionsPage({
|
||||
}
|
||||
};
|
||||
const onWillRedirect = (e: any) => {
|
||||
const normalizedUrl = normalizeLocalBackendRedirect(
|
||||
e.newURL,
|
||||
webviewUrl,
|
||||
);
|
||||
if (normalizedUrl) {
|
||||
logger.warn(
|
||||
"[SessionsPage][WebviewNav] normalized local backend redirect",
|
||||
"SessionsPage",
|
||||
{
|
||||
from: e.oldURL,
|
||||
to: e.newURL,
|
||||
normalizedUrl,
|
||||
},
|
||||
);
|
||||
e.preventDefault?.();
|
||||
el.loadURL?.(normalizedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("[SessionsPage][WebviewNav] will-redirect", "SessionsPage", {
|
||||
from: e.oldURL,
|
||||
to: e.newURL,
|
||||
});
|
||||
};
|
||||
const onDidFailLoad = (e: any) => {
|
||||
logger.warn("[SessionsPage][WebviewNav] did-fail-load", "SessionsPage", {
|
||||
url: e.validatedURL || e.url,
|
||||
errorCode: e.errorCode,
|
||||
errorDescription: e.errorDescription,
|
||||
});
|
||||
};
|
||||
|
||||
el.addEventListener("did-navigate", onDidNavigate);
|
||||
el.addEventListener("did-navigate-in-page", onDidNavigate);
|
||||
el.addEventListener("will-redirect", onWillRedirect);
|
||||
el.addEventListener("did-fail-load", onDidFailLoad);
|
||||
return () => {
|
||||
el.removeEventListener("did-navigate", onDidNavigate);
|
||||
el.removeEventListener("did-navigate-in-page", onDidNavigate);
|
||||
el.removeEventListener("will-redirect", onWillRedirect);
|
||||
el.removeEventListener("did-fail-load", onDidFailLoad);
|
||||
};
|
||||
}, [view, webviewUrl]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildRedirectUrl,
|
||||
buildNewSessionUrl,
|
||||
buildChatSessionUrl,
|
||||
normalizeLocalBackendRedirect,
|
||||
syncSessionCookie,
|
||||
syncCookieAndGetRedirectUrl,
|
||||
syncCookieAndGetNewSessionUrl,
|
||||
@@ -87,6 +88,51 @@ describe("buildChatSessionUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLocalBackendRedirect", () => {
|
||||
it("normalizes local redirects that lost protocol and port", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"https://localhost/home/chat/5/26?hideMenu=true",
|
||||
"http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBe("http://localhost:18081/home/chat/5/26?hideMenu=true");
|
||||
});
|
||||
|
||||
it("normalizes localhost and 127.0.0.1 mismatches to the configured backend", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"https://localhost/home/chat/5/26?hideMenu=true",
|
||||
"http://127.0.0.1:18081/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBe("http://127.0.0.1:18081/home/chat/5/26?hideMenu=true");
|
||||
});
|
||||
|
||||
it("does not rewrite already-correct local redirects", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"http://localhost:18081/home/chat/5/26?hideMenu=true",
|
||||
"http://localhost:18081/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not rewrite remote domains", () => {
|
||||
expect(
|
||||
normalizeLocalBackendRedirect(
|
||||
"https://example.com/home/chat/5/26?hideMenu=true",
|
||||
"https://example.com/api/sandbox/config/redirect/6?hideMenu=true",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid URLs", () => {
|
||||
expect(normalizeLocalBackendRedirect("not-url", "http://localhost:18081"))
|
||||
.toBeNull();
|
||||
expect(normalizeLocalBackendRedirect("https://localhost", "not-url"))
|
||||
.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("syncSessionCookie", () => {
|
||||
it("不设 domain 和 secure,由主进程根据 URL scheme 判断", async () => {
|
||||
await syncSessionCookie("https://app.example.com:8080/path", "tok123");
|
||||
|
||||
@@ -66,6 +66,46 @@ export function buildChatSessionUrl(domain: string, sessionId: string): string {
|
||||
return `${normalizedDomain}/api/sandbox/config/redirect/chat/${sessionId}?hideMenu=true`;
|
||||
}
|
||||
|
||||
function isLocalBackendHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* Some local backend builds redirect `http://localhost:18081/...` to
|
||||
* `https://localhost/...`, losing both the protocol and port. Normalize only
|
||||
* that local-only case so embedded webviews keep using the configured backend.
|
||||
*/
|
||||
export function normalizeLocalBackendRedirect(
|
||||
redirectUrl: string,
|
||||
expectedBackendUrl: string,
|
||||
): string | null {
|
||||
try {
|
||||
const redirect = new URL(redirectUrl);
|
||||
const expected = new URL(expectedBackendUrl);
|
||||
|
||||
if (
|
||||
!isLocalBackendHost(expected.hostname) ||
|
||||
!isLocalBackendHost(redirect.hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
redirect.protocol === expected.protocol &&
|
||||
redirect.port === expected.port
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
redirect.protocol = expected.protocol;
|
||||
redirect.host = expected.host;
|
||||
const normalized = redirect.toString();
|
||||
return normalized === redirectUrl ? null : normalized;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSessionCookie(
|
||||
domain: string,
|
||||
token: string,
|
||||
|
||||
Reference in New Issue
Block a user