【核心重构】
- getLatestVersion() 不再 throw Error,无更新时返回 { version: APP_VERSION }
- checkForUpdates() 先自行预检 → 无更新直发 IPC,有更新才交 electron-updater
- 移除 NO_UPDATE_MESSAGE 常量及所有字符串匹配拦截代码
- HeiXiuProvider 增加 500ms TTL 缓存,避免预检+electron-updater 重复请求 API
【用户体验】
- 检查到新版本后不再自动下载,展示更新内容供用户决定
- 新增"下载更新"按钮(与"安装更新"分离为两步操作)
- 设置页新增"查看详情"弹窗,Markdown 格式化渲染更新日志
- available / downloading / downloaded 三个状态 UI 独立展示
【Bug 修复】
- 测试服务器版本排序:字符串序 → 语义版本序(_version_key),0.0.10 正确 > 0.0.9
- 版本比较:!= → <(_version_key),防止高版本误判为有更新
- get_best_release fallback 同样改为语义版本排序
- Windows cmd set 命令尾部空格 → Invalid URL(.trim() 修复)
- electron-updater 'error' 事件中拦截 NO_UPDATE_MESSAGE → UPDATE_NOT_AVAILABLE
【文档更新】
- CHANGELOG.md、PROJECT.md、UpdateA.md 同步更新架构图和流程说明
232 lines
7.3 KiB
TypeScript
232 lines
7.3 KiB
TypeScript
// ============================================================
|
||
// NetRequest — Electron net.request 的 axios 风格封装
|
||
//
|
||
// 用法:
|
||
// import { request, get, post } from './net-request';
|
||
// const { data } = await get<UpdateCheckResult>('/api/v1/update/check', {
|
||
// baseURL: 'https://www.heixiu.com',
|
||
// params: { platform: 'win32', version: '1.0.0' },
|
||
// });
|
||
//
|
||
// 设计:
|
||
// - 底层使用 Electron net.request(不引入额外依赖)
|
||
// - Promise 封装,支持 async/await
|
||
// - JSON 自动解析、查询参数自动拼接
|
||
// - 流式下载场景仍直接用 net.request(本模块不封装 stream)
|
||
// ============================================================
|
||
|
||
import { net } from 'electron';
|
||
|
||
// ---------- 类型 ----------
|
||
|
||
export interface RequestConfig {
|
||
/** 请求方法(默认 GET) */
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||
/** 基础 URL(可选,方便切换环境) */
|
||
baseURL?: string;
|
||
/** 查询参数(自动拼接 ?key=value&...) */
|
||
params?: Record<string, string | number | undefined>;
|
||
/** 请求头 */
|
||
headers?: Record<string, string>;
|
||
/** 请求体(对象自动 JSON.stringify,字符串原样发送) */
|
||
data?: unknown;
|
||
/** 响应类型(默认 json,设置 text 跳过解析) */
|
||
responseType?: 'json' | 'text';
|
||
/** 超时时间(毫秒,默认 30s) */
|
||
timeout?: number;
|
||
}
|
||
|
||
export interface RequestResponse<T = unknown> {
|
||
/** HTTP 状态码 */
|
||
status: number;
|
||
/** 状态文本(如 "OK") */
|
||
statusText: string;
|
||
/** 响应头 */
|
||
headers: Record<string, string | string[]>;
|
||
/** 响应体 */
|
||
data: T;
|
||
}
|
||
|
||
/** 网络请求错误 */
|
||
export class RequestError extends Error {
|
||
status?: number;
|
||
constructor(message: string, status?: number) {
|
||
super(message);
|
||
this.name = 'NetRequestError';
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
// ---------- 工具 ----------
|
||
|
||
const DEFAULT_TIMEOUT = 30_000;
|
||
|
||
/** 拼接 URL(baseURL + path + query) */
|
||
function buildUrl(url: string, config: RequestConfig): string {
|
||
// 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题)
|
||
if (!config.baseURL && !/^https?:\/\//i.test(url)) {
|
||
throw new RequestError(
|
||
`URL 缺少 baseURL,无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`,
|
||
);
|
||
}
|
||
|
||
const base = (config.baseURL || '').trim().replace(/\/+$/, '');
|
||
const path = url.startsWith('/') ? url : `/${url}`;
|
||
let fullUrl = base ? `${base}${path}` : url;
|
||
|
||
if (config.params) {
|
||
const search = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(config.params)) {
|
||
if (value !== undefined && value !== null) {
|
||
search.append(key, String(value));
|
||
}
|
||
}
|
||
const qs = search.toString();
|
||
if (qs) {
|
||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
|
||
}
|
||
}
|
||
|
||
// 调试:在 net.request 调用之前验证 URL 合法性
|
||
try {
|
||
void new URL(fullUrl);
|
||
} catch {
|
||
throw new RequestError(
|
||
`构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`,
|
||
);
|
||
}
|
||
|
||
console.log('[net-request] buildUrl:', fullUrl);
|
||
return fullUrl;
|
||
}
|
||
|
||
/** 序列化请求体 */
|
||
function serializeBody(data: unknown): { body: string; contentType: string } {
|
||
if (typeof data === 'string') {
|
||
return { body: data, contentType: 'text/plain' };
|
||
}
|
||
return { body: JSON.stringify(data), contentType: 'application/json' };
|
||
}
|
||
|
||
// ---------- 核心 ----------
|
||
|
||
/**
|
||
* 发起请求(axios 风格 API)
|
||
*
|
||
* 流式下载场景请直接使用 Electron net.request(本模块不封装)
|
||
*/
|
||
export async function request<T = unknown>(
|
||
url: string,
|
||
config: RequestConfig = {},
|
||
): Promise<RequestResponse<T>> {
|
||
const fullUrl = buildUrl(url, config);
|
||
const method = (config.method || 'GET').toUpperCase();
|
||
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// 调试:验证 URL 是否合法
|
||
try {
|
||
void new URL(fullUrl);
|
||
} catch (urlErr) {
|
||
console.error('[net-request] 非法 URL:', JSON.stringify(fullUrl));
|
||
console.error('[net-request] URL 字符码:', [...fullUrl].map(c => c.charCodeAt(0)).join(','));
|
||
console.error('[net-request] config:', JSON.stringify(config));
|
||
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
|
||
return;
|
||
}
|
||
|
||
const req = net.request({
|
||
method,
|
||
url: fullUrl,
|
||
});
|
||
|
||
// 超时处理
|
||
let timedOut = false;
|
||
const timer = setTimeout(() => {
|
||
timedOut = true;
|
||
req.abort();
|
||
reject(new RequestError(`请求超时 (${timeout}ms)`));
|
||
}, timeout);
|
||
|
||
// 请求头
|
||
if (config.headers) {
|
||
for (const [key, value] of Object.entries(config.headers)) {
|
||
req.setHeader(key, value);
|
||
}
|
||
}
|
||
|
||
// 请求体
|
||
if (config.data !== undefined) {
|
||
const { body, contentType } = serializeBody(config.data);
|
||
req.setHeader('Content-Type', contentType);
|
||
req.write(body);
|
||
}
|
||
|
||
req.on('response', (response) => {
|
||
clearTimeout(timer);
|
||
|
||
const status = response.statusCode;
|
||
const statusText = response.statusMessage ?? '';
|
||
|
||
// 读取响应头
|
||
const headers: Record<string, string | string[]> = {};
|
||
// Electron IncomingMessage.headers 是 Record<string, string[]>
|
||
const rawHeaders = response.headers;
|
||
if (rawHeaders) {
|
||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||
headers[key] = value.length === 1 ? value[0] : value;
|
||
}
|
||
}
|
||
|
||
// 读取响应体
|
||
let body = '';
|
||
response.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
||
response.on('end', () => {
|
||
try {
|
||
const asJson = config.responseType !== 'text';
|
||
const data = asJson ? JSON.parse(body) : body;
|
||
resolve({ status, statusText, headers, data: data as T });
|
||
} catch {
|
||
// JSON 解析失败 → 返回原始文本
|
||
resolve({ status, statusText, headers, data: body as T });
|
||
}
|
||
});
|
||
|
||
response.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
reject(new RequestError(`响应读取失败: ${err.message}`));
|
||
});
|
||
});
|
||
|
||
req.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
if (timedOut) return;
|
||
reject(new RequestError(`网络请求失败: ${err.message}`));
|
||
});
|
||
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ---------- 默认导出:类 axios 命名空间 ----------
|
||
|
||
export const netRequest = {
|
||
request,
|
||
|
||
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'GET' });
|
||
},
|
||
|
||
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'POST', data });
|
||
},
|
||
|
||
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'PUT', data });
|
||
},
|
||
|
||
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'DELETE' });
|
||
},
|
||
};
|