109 lines
4.2 KiB
TypeScript
109 lines
4.2 KiB
TypeScript
|
|
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<Response> {
|
|||
|
|
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);
|
|||
|
|
});
|
|||
|
|
}
|