import { existsSync, statSync } from 'node:fs'; import { join, normalize } from 'node:path'; import { pathToFileURL } from 'node:url'; import { app, net, protocol } from 'electron'; import { API_UPSTREAM, APP_SCHEME, IM_UPSTREAM } from './config'; /** * app:// 自定义协议:桌面端的「本地 nginx」 * 1. 静态文件服务:把 web-antd 生产 dist 按 URL 路径映射到本地文件,带 SPA fallback * (等价 nginx 的 try_files $uri /index.html),因此无需把前端路由改成 hash 模式 * 2. 反向代理:/api/admin/* 与 /im-api/* 用 net.fetch 转发到线上上游 —— * 渲染层看到的是同源请求,天然规避 CORS,后端与 nginx 都不需要任何改动 */ /** Electron net.fetch 支持但标准 RequestInit 类型缺失的扩展字段 */ type ElectronFetchInit = RequestInit & { /** 转发出去的请求不再进入自定义协议处理器,防止死循环 */ bypassCustomProtocolHandlers?: boolean; /** 携带流式 body 时 fetch 规范要求显式声明半双工 */ duplex?: 'half'; }; /** * 注册特权协议,必须在 app ready 之前调用 * standard/secure 让 app:// 成为「安全上下文」,navigator.clipboard、getUserMedia、 * IndexedDB、crypto.subtle 等浏览器能力才可用;stream 支持流式响应(音频等大文件) */ export function registerSchemePrivileges(): void { protocol.registerSchemesAsPrivileged([ { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true, corsEnabled: true, }, }, ]); } /** * 解析 web dist 根目录 * 打包后位于 resources/web(electron-builder extraResources 拷入,不进 asar); * 未打包(本地验证生产包行为)时兜底读仓库内 web-antd/dist */ export function getWebRoot(): string { return app.isPackaged ? join(process.resourcesPath, 'web') : join(__dirname, '../../../web-antd/dist'); } /** * 把渲染层的同源请求转发到远端上游(等价 nginx proxy_pass) * 保留原请求的方法 / 头 / 流式请求体,Bearer token 请求头原样透传 */ function proxyRequest(target: string, request: Request): Promise { const init: ElectronFetchInit = { method: request.method, headers: request.headers, body: request.body, bypassCustomProtocolHandlers: true, }; if (request.body) init.duplex = 'half'; return net.fetch(target, init); } /** * 静态文件解析:带路径穿越防护 + SPA fallback * 命中不了真实文件的路径(前端路由地址、不存在的资源)统一回 index.html 交给前端路由 */ function resolveStaticFile(webRoot: string, pathname: string): string { const indexHtml = join(webRoot, 'index.html'); try { const filePath = normalize(join(webRoot, decodeURIComponent(pathname))); // 防路径穿越:解析结果必须仍在 webRoot 目录内 if (!filePath.startsWith(normalize(webRoot))) return indexHtml; if (existsSync(filePath) && statSync(filePath).isFile()) return filePath; } catch { // decode 失败等异常场景直接回 index.html,不让协议处理器抛错 } return indexHtml; } /** * 注册 app:// 协议处理器,在 app ready 之后调用 */ export function registerAppProtocol(): void { const webRoot = getWebRoot(); protocol.handle(APP_SCHEME, (request) => { const url = new URL(request.url); // 反代 /api/admin/*:生产构建的 VITE_GLOB_API_URL=/api/admin,路径直接透传 if (url.pathname.startsWith('/api/')) { return proxyRequest(`${API_UPSTREAM}${url.pathname}${url.search}`, request); } // 反代 /im-api/*:对齐开发代理规则,剥掉 /im-api 前缀后拼到上游 /api/* if (url.pathname.startsWith('/im-api/')) { const stripped = url.pathname.slice('/im-api'.length); return proxyRequest(`${IM_UPSTREAM}/api${stripped}${url.search}`, request); } // 其余全部按静态资源处理(net.fetch file:// 会按扩展名自动带上正确的 Content-Type) const fileUrl = pathToFileURL(resolveStaticFile(webRoot, url.pathname)).toString(); return net.fetch(fileUrl, { bypassCustomProtocolHandlers: true } as ElectronFetchInit); }); }