稳定测试版
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/dist_electron
|
||||||
|
iOSAI
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
1
.npmrc
Normal file
1
.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
|
||||||
24
js/burst-broadcast.js
Normal file
24
js/burst-broadcast.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// burst-broadcast.js (CommonJS)
|
||||||
|
function createBurstBroadcaster(broadcast, {
|
||||||
|
event = 'message', // 要广播的事件名
|
||||||
|
idleMs = 10_000, // 静默阈值
|
||||||
|
startPayload = 'start', // 静默后先发的内容
|
||||||
|
startOnFirst = true, // 首条是否也先发 start
|
||||||
|
} = {}) {
|
||||||
|
let lastTs = 0;
|
||||||
|
|
||||||
|
return function burstSend(data) {
|
||||||
|
const now = Date.now();
|
||||||
|
const idleTooLong = (now - lastTs) >= idleMs;
|
||||||
|
const isFirst = lastTs === 0;
|
||||||
|
|
||||||
|
if ((startOnFirst && isFirst) || idleTooLong) {
|
||||||
|
broadcast(event, startPayload); // 先发“start”
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(event, data); // 再发真实数据
|
||||||
|
lastTs = now;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createBurstBroadcaster };
|
||||||
30
js/preload.js
Normal file
30
js/preload.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// preload.js
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
selectApkFile: () => ipcRenderer.invoke('select-apk-file'),
|
||||||
|
selectFile: () => ipcRenderer.invoke('select-file'),
|
||||||
|
manualGc: () => ipcRenderer.invoke('manual-gc'),
|
||||||
|
mqSend: (arg) => ipcRenderer.invoke('mq-send', arg),
|
||||||
|
startMq: (tendid, id) => ipcRenderer.invoke('start-mq', tendid, id),
|
||||||
|
fileExists: (url) => ipcRenderer.invoke('file-exists', url),
|
||||||
|
isiproxy: (url) => ipcRenderer.invoke('isiproxy', url),
|
||||||
|
})
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('appUpdater', {
|
||||||
|
onAvailable: (cb) => ipcRenderer.on('update:available', (_e, info) => cb(info)),
|
||||||
|
onProgress: (cb) => ipcRenderer.on('update:progress', (_e, p) => cb(p)),
|
||||||
|
onDownloaded: (cb) => ipcRenderer.on('update:downloaded', (_e, info) => cb(info)),
|
||||||
|
onError: (cb) => ipcRenderer.on('update:error', (_e, err) => cb(err)),
|
||||||
|
// 主动触发
|
||||||
|
checkNow: () => ipcRenderer.invoke('update:checkNow'),
|
||||||
|
quitAndInstallNow: () => ipcRenderer.invoke('update:quitAndInstallNow'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 页面卸载时清理监听(可选)
|
||||||
|
window.addEventListener('unload', () => {
|
||||||
|
ipcRenderer.removeAllListeners('update:available');
|
||||||
|
ipcRenderer.removeAllListeners('update:progress');
|
||||||
|
ipcRenderer.removeAllListeners('update:downloaded');
|
||||||
|
ipcRenderer.removeAllListeners('update:error');
|
||||||
|
});
|
||||||
310
js/rabbitmq-service.js
Normal file
310
js/rabbitmq-service.js
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
// rabbitmq-live-client.js (CommonJS)
|
||||||
|
const amqp = require('amqplib');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
const CFG = {
|
||||||
|
protocol: process.env.RABBIT_PROTOCOL || 'amqp', // 'amqp' | 'amqps'
|
||||||
|
// host: process.env.RABBIT_HOST || '192.168.1.144',
|
||||||
|
host: process.env.RABBIT_HOST || 'crawlclient.api.yolozs.com',
|
||||||
|
port: Number(process.env.RABBIT_PORT || 5672),
|
||||||
|
user: process.env.RABBIT_USER || 'tkdata',
|
||||||
|
pass: process.env.RABBIT_PASS || '6rARaRj8Z7UG3ahLzh',
|
||||||
|
vhost: process.env.RABBIT_VHOST || '/',
|
||||||
|
heartbeat: Number(process.env.RABBIT_HEARTBEAT || 60), // <-- 关键:心跳
|
||||||
|
frameMax: Number(process.env.RABBIT_FRAME_MAX || 0), // 0=默认;可调大以减少分片
|
||||||
|
};
|
||||||
|
|
||||||
|
let conn = null;
|
||||||
|
let pubCh = null; // 发布 Confirm Channel
|
||||||
|
let conCh = null; // 消费 Channel
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
|
||||||
|
const consumers = new Map(); // queueName -> { onMessage, options, consumerTag }
|
||||||
|
|
||||||
|
let reconnecting = false;
|
||||||
|
let closing = false;
|
||||||
|
let backoff = 1000; // ms
|
||||||
|
const MAX_BACKOFF = 15000;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
|
// —— 工具:序列化消息
|
||||||
|
function toBuffer(payload) {
|
||||||
|
if (Buffer.isBuffer(payload)) return payload;
|
||||||
|
if (typeof payload === 'string') return Buffer.from(payload);
|
||||||
|
return Buffer.from(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 内部:建立连接(含心跳、keepalive、事件)
|
||||||
|
async function createConnection() {
|
||||||
|
const connection = await amqp.connect({
|
||||||
|
protocol: CFG.protocol,
|
||||||
|
hostname: CFG.host,
|
||||||
|
port: CFG.port,
|
||||||
|
username: CFG.user,
|
||||||
|
password: CFG.pass,
|
||||||
|
vhost: CFG.vhost,
|
||||||
|
heartbeat: CFG.heartbeat,
|
||||||
|
frameMax: CFG.frameMax > 0 ? CFG.frameMax : undefined,
|
||||||
|
// 也可用 URL 形式:`amqp://u:p@host:5672/vhost?heartbeat=60`
|
||||||
|
});
|
||||||
|
|
||||||
|
// 打开 TCP keepalive,降低 NAT/空闲超时断开的概率
|
||||||
|
try {
|
||||||
|
const stream = connection.stream || connection.socket;
|
||||||
|
if (stream?.setKeepAlive) stream.setKeepAlive(true, 15_000); // 15s
|
||||||
|
} catch (_) { }
|
||||||
|
|
||||||
|
connection.on('error', (e) => {
|
||||||
|
// 心跳超时常见,避免重复噪音
|
||||||
|
const msg = e?.message || String(e);
|
||||||
|
if (msg && /heartbeat/i.test(msg)) {
|
||||||
|
console.error('[AMQP] connection error (heartbeat):', msg);
|
||||||
|
} else {
|
||||||
|
console.error('[AMQP] connection error:', msg);
|
||||||
|
}
|
||||||
|
emitter.emit('error', e);
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('close', () => {
|
||||||
|
if (closing) return; // 正在关闭时不重连
|
||||||
|
console.error('[AMQP] connection closed');
|
||||||
|
conn = null; pubCh = null; conCh = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broker 侧内存/磁盘压力会 block 连接
|
||||||
|
connection.on('blocked', (reason) => {
|
||||||
|
console.warn('[AMQP] connection blocked by broker:', reason);
|
||||||
|
emitter.emit('blocked', reason);
|
||||||
|
});
|
||||||
|
connection.on('unblocked', () => {
|
||||||
|
console.log('[AMQP] connection unblocked');
|
||||||
|
emitter.emit('unblocked');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[AMQP] connected to ${CFG.host} (hb=${CFG.heartbeat}s)`);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 内部:确保连接和通道存在
|
||||||
|
async function ensureChannels() {
|
||||||
|
if (!conn) conn = await createConnection();
|
||||||
|
|
||||||
|
if (!pubCh) {
|
||||||
|
pubCh = await conn.createConfirmChannel();
|
||||||
|
pubCh.on('error', e => console.error('[AMQP] pub channel error:', e?.message || e));
|
||||||
|
pubCh.on('close', () => { pubCh = null; if (!closing) scheduleReconnect(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conCh) {
|
||||||
|
conCh = await conn.createChannel();
|
||||||
|
conCh.on('error', e => console.error('[AMQP] con channel error:', e?.message || e));
|
||||||
|
conCh.on('close', () => { conCh = null; if (!closing) scheduleReconnect(); });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 内部:安排重连(指数退避 + 抖动,且只触发一个循环)
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (reconnecting || closing) return;
|
||||||
|
reconnecting = true;
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
|
||||||
|
const attempt = async () => {
|
||||||
|
if (closing) return;
|
||||||
|
try {
|
||||||
|
await ensureChannels();
|
||||||
|
await resumeConsumers(); // 恢复所有消费
|
||||||
|
reconnecting = false;
|
||||||
|
backoff = 1000;
|
||||||
|
emitter.emit('reconnected');
|
||||||
|
console.log('[AMQP] reconnected and consumers resumed');
|
||||||
|
} catch (e) {
|
||||||
|
const base = Math.min(backoff, MAX_BACKOFF);
|
||||||
|
// 加抖动,避免雪崩:在 75%~125% 之间浮动
|
||||||
|
const jitter = base * (0.75 + Math.random() * 0.5);
|
||||||
|
console.warn(`[AMQP] reconnect failed: ${e?.message || e}; retry in ${Math.round(jitter)}ms`);
|
||||||
|
backoff = Math.min(backoff * 1.6, MAX_BACKOFF);
|
||||||
|
reconnectTimer = setTimeout(attempt, jitter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(attempt, backoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 内部:恢复所有消费者
|
||||||
|
async function resumeConsumers() {
|
||||||
|
if (!conCh) return;
|
||||||
|
for (const [queue, c] of consumers.entries()) {
|
||||||
|
try {
|
||||||
|
if (c.consumerTag) await conCh.cancel(c.consumerTag);
|
||||||
|
} catch (_) { }
|
||||||
|
const tag = await startOneConsumer(queue, c.onMessage, c.options, true);
|
||||||
|
c.consumerTag = tag.consumerTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 内部:启动一个消费者
|
||||||
|
async function startOneConsumer(queueName, onMessage, options = {}, isResume = false) {
|
||||||
|
await ensureChannels();
|
||||||
|
|
||||||
|
const {
|
||||||
|
prefetch = 1,
|
||||||
|
durable = true,
|
||||||
|
assertQueue = true,
|
||||||
|
requeueOnError = false,
|
||||||
|
// 可选:exclusive, arguments, dead-letter 等
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (assertQueue) {
|
||||||
|
await conCh.assertQueue(queueName, { durable });
|
||||||
|
}
|
||||||
|
await conCh.prefetch(prefetch);
|
||||||
|
|
||||||
|
const consumeResult = await conCh.consume(
|
||||||
|
queueName,
|
||||||
|
async (msg) => {
|
||||||
|
if (!msg) return;
|
||||||
|
const raw = msg.content;
|
||||||
|
const text = raw?.toString?.() ?? '';
|
||||||
|
let json;
|
||||||
|
try { json = JSON.parse(text); } catch (_) { /* 忽略解析失败 */ }
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
raw, text, json,
|
||||||
|
fields: msg.fields,
|
||||||
|
properties: msg.properties,
|
||||||
|
ack: () => { try { conCh.ack(msg); } catch (_) { } },
|
||||||
|
nack: (requeue = requeueOnError) => { try { conCh.nack(msg, false, requeue); } catch (_) { } },
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
emitter.emit('message', queueName, payload);
|
||||||
|
await onMessage(payload); // 业务回调
|
||||||
|
payload.ack();
|
||||||
|
} catch (err) {
|
||||||
|
emitter.emit('handlerError', queueName, err, payload);
|
||||||
|
payload.nack(requeueOnError);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ noAck: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isResume) {
|
||||||
|
consumers.set(queueName, { onMessage, options, consumerTag: consumeResult.consumerTag });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[*] consuming "${queueName}" (prefetch=${prefetch})`);
|
||||||
|
return consumeResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 对外 API:启动消费
|
||||||
|
async function startConsumer(queueName, onMessage, options = {}) {
|
||||||
|
if (!queueName) throw new Error('queueName 必填');
|
||||||
|
if (typeof onMessage !== 'function') throw new Error('onMessage 回调必填');
|
||||||
|
const res = await startOneConsumer(queueName, onMessage, options, false);
|
||||||
|
return {
|
||||||
|
emitter,
|
||||||
|
async stop() {
|
||||||
|
const c = consumers.get(queueName);
|
||||||
|
if (c?.consumerTag && conCh) {
|
||||||
|
await conCh.cancel(c.consumerTag).catch(() => { });
|
||||||
|
}
|
||||||
|
consumers.delete(queueName);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 对外 API:发送到队列(支持 confirm)
|
||||||
|
async function publishToQueue(queueName, payload, options = {}) {
|
||||||
|
if (!queueName) throw new Error('queueName 必填');
|
||||||
|
await ensureChannels();
|
||||||
|
|
||||||
|
const {
|
||||||
|
durable = true,
|
||||||
|
persistent = true,
|
||||||
|
assertQueue = true,
|
||||||
|
confirm = true,
|
||||||
|
headers = {},
|
||||||
|
mandatory = false, // 可选:不可路由返回
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (assertQueue) {
|
||||||
|
await pubCh.assertQueue(queueName, { durable });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = pubCh.sendToQueue(queueName, toBuffer(payload), { persistent, headers, mandatory });
|
||||||
|
if (!ok) await new Promise(r => pubCh.once('drain', r));
|
||||||
|
if (confirm) await pubCh.waitForConfirms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 对外 API:发送到交换机(支持 confirm)
|
||||||
|
async function publishToExchange(exchange, routingKey, payload, options = {}) {
|
||||||
|
if (!exchange) throw new Error('exchange 必填');
|
||||||
|
await ensureChannels();
|
||||||
|
|
||||||
|
const {
|
||||||
|
type = 'direct',
|
||||||
|
durable = true,
|
||||||
|
assertExchange = true,
|
||||||
|
confirm = true,
|
||||||
|
persistent = true,
|
||||||
|
headers = {},
|
||||||
|
mandatory = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (assertExchange) {
|
||||||
|
await pubCh.assertExchange(exchange, type, { durable });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = pubCh.publish(exchange, routingKey || '', toBuffer(payload), { persistent, headers, mandatory });
|
||||||
|
if (!ok) await new Promise(r => pubCh.once('drain', r));
|
||||||
|
if (confirm) await pubCh.waitForConfirms();
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 对外 API:主动重连(给 Electron 恢复/网络变化时调用)
|
||||||
|
async function reconnectNow() {
|
||||||
|
if (closing) return;
|
||||||
|
if (reconnecting) return;
|
||||||
|
try {
|
||||||
|
if (pubCh) await pubCh.close().catch(() => { });
|
||||||
|
if (conCh) await conCh.close().catch(() => { });
|
||||||
|
if (conn) await conn.close().catch(() => { });
|
||||||
|
} catch (_) { }
|
||||||
|
pubCh = null; conCh = null; conn = null;
|
||||||
|
reconnecting = false;
|
||||||
|
scheduleReconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 关闭
|
||||||
|
async function close() {
|
||||||
|
closing = true;
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
try {
|
||||||
|
// 取消所有消费者
|
||||||
|
for (const [q, c] of consumers.entries()) {
|
||||||
|
if (c?.consumerTag && conCh) {
|
||||||
|
await conCh.cancel(c.consumerTag).catch(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) { }
|
||||||
|
consumers.clear();
|
||||||
|
|
||||||
|
try { if (pubCh) await pubCh.close(); } catch (_) { }
|
||||||
|
try { if (conCh) await conCh.close(); } catch (_) { }
|
||||||
|
try { if (conn) await conn.close(); } catch (_) { }
|
||||||
|
pubCh = null; conCh = null; conn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— 进程信号(可选)
|
||||||
|
process.once('SIGINT', async () => { try { await close(); } finally { process.exit(0); } });
|
||||||
|
process.once('SIGTERM', async () => { try { await close(); } finally { process.exit(0); } });
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
startConsumer,
|
||||||
|
publishToQueue,
|
||||||
|
publishToExchange,
|
||||||
|
reconnectNow,
|
||||||
|
close,
|
||||||
|
emitter, // 可订阅 'message' / 'handlerError' / 'reconnected' / 'error' / 'blocked' / 'unblocked'
|
||||||
|
};
|
||||||
88
js/sse-server.js
Normal file
88
js/sse-server.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// sse-server.js (CommonJS)
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
|
||||||
|
function startSSE({
|
||||||
|
port = 3312,
|
||||||
|
path = '/events',
|
||||||
|
corsOrigin = '*', // 也可填 'http://localhost:8080' 或你的前端地址
|
||||||
|
heartbeatMs = 15000,
|
||||||
|
} = {}) {
|
||||||
|
const app = express();
|
||||||
|
if (corsOrigin) app.use(cors({ origin: corsOrigin, credentials: true }));
|
||||||
|
|
||||||
|
const clients = new Map(); // id -> res
|
||||||
|
|
||||||
|
app.get(path, (req, res) => {
|
||||||
|
res.set({
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
|
});
|
||||||
|
if (corsOrigin) res.set('Access-Control-Allow-Origin', corsOrigin);
|
||||||
|
res.flushHeaders?.();
|
||||||
|
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
clients.set(id, res);
|
||||||
|
|
||||||
|
// // 初次握手
|
||||||
|
// res.write(`event: hello\n`);
|
||||||
|
// res.write(`data: ${JSON.stringify({ connected: true, t: Date.now() })}\n\n`);
|
||||||
|
console.log("sse连接成功")
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!res.writableEnded) res.write(`: keep-alive ${Date.now()}\n\n`);
|
||||||
|
}, heartbeatMs);
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
clients.delete(id);
|
||||||
|
try { res.end(); } catch { }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 简单健康检查
|
||||||
|
app.get('/health', (_req, res) => res.json({ ok: true, clients: clients.size }));
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
server.listen(port, () => {
|
||||||
|
console.log(`[SSE] listening at http://127.0.0.1:${port}${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 广播:event 自定义;data 可传对象/字符串/Buffer
|
||||||
|
function broadcast(eventOrData, maybeData) {
|
||||||
|
const hasEventName = typeof maybeData !== 'undefined'; // 两种调用方式
|
||||||
|
const eventName = hasEventName ? eventOrData : null;
|
||||||
|
const data = hasEventName ? maybeData : eventOrData;
|
||||||
|
|
||||||
|
const payload = Buffer.isBuffer(data)
|
||||||
|
? data.toString()
|
||||||
|
: (typeof data === 'string' ? data : JSON.stringify(data));
|
||||||
|
|
||||||
|
for (const [id, res] of clients.entries()) {
|
||||||
|
try {
|
||||||
|
if (eventName && eventName !== 'message') {
|
||||||
|
res.write(`event: ${eventName}\n`);
|
||||||
|
} // 否则省略 event 行 => 触发前端 onmessage
|
||||||
|
res.write(`data: ${payload}\n\n`);
|
||||||
|
} catch {
|
||||||
|
clients.delete(id);
|
||||||
|
try { res.end(); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close() {
|
||||||
|
for (const [, res] of clients.entries()) {
|
||||||
|
try { res.end(); } catch { }
|
||||||
|
}
|
||||||
|
clients.clear();
|
||||||
|
await new Promise(r => server.close(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { app, server, broadcast, close, clientsCount: () => clients.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startSSE };
|
||||||
778
main.js
Normal file
778
main.js
Normal file
@@ -0,0 +1,778 @@
|
|||||||
|
const { app, globalShortcut, BrowserWindow, net, dialog, ipcMain } = require('electron');
|
||||||
|
const { startSSE } = require('./js/sse-server');
|
||||||
|
const { createBurstBroadcaster } = require('./js/burst-broadcast');
|
||||||
|
const mq = require('./js/rabbitmq-service');
|
||||||
|
const axios = require('axios');
|
||||||
|
const os = require('os');
|
||||||
|
const fsp = require('node:fs/promises')
|
||||||
|
const { fileURLToPath } = require('node:url')
|
||||||
|
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const USER_ROOT = path.resolve(os.homedir());
|
||||||
|
const { autoUpdater } = require('electron-updater')
|
||||||
|
const fs = require('fs')
|
||||||
|
const log = require('electron-log')
|
||||||
|
Object.assign(console, log.functions)
|
||||||
|
// 存储 SSE 服务器的引用
|
||||||
|
let sseServer = null;
|
||||||
|
|
||||||
|
let userData = { tenantId: null, userId: null }
|
||||||
|
const { exec, spawn, execFile } = require('child_process'); // 如果你用 exec 启动 scrcpy,保留此行
|
||||||
|
app.commandLine.appendSwitch('enable-precise-memory-info')
|
||||||
|
app.commandLine.appendSwitch('js-flags', '--expose-gc --max-old-space-size=4096') // 以 MB 为单位,建议设置 2048-4096
|
||||||
|
|
||||||
|
app.commandLine.appendSwitch('enable-experimental-web-platform-features');
|
||||||
|
app.commandLine.appendSwitch('enable-features', 'WebCodecs,MediaStreamInsertableStreams');
|
||||||
|
// === 更新门闩:更新进行时,禁止跳转业务页面 ===
|
||||||
|
let updateInProgress = false; // 有更新或正在下载 -> true
|
||||||
|
let updateDownloaded = false; // 已下载完成(通常会随即重启安装)
|
||||||
|
let pendingNavs = []; // 被延迟的跳转请求(函数队列)
|
||||||
|
let installingNow = false; // 正在静默安装(用户点击“立即重启安装”之后)
|
||||||
|
function flushPendingNavs() {
|
||||||
|
const fns = pendingNavs.slice();
|
||||||
|
pendingNavs = [];
|
||||||
|
for (const fn of fns) { try { fn(); } catch (e) { console.warn('[Nav] deferred run err:', e); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeLoadURL(win, url, onFail) {
|
||||||
|
if (!win || win.isDestroyed()) return;
|
||||||
|
win.loadURL(url).catch(err => {
|
||||||
|
console.warn('[loadURL 失败]', err);
|
||||||
|
onFail?.(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 进程异常处理
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
console.error('uncaughtException未捕获异常:', error);
|
||||||
|
});
|
||||||
|
process.on('unhandledRejection', (reason, p) => {
|
||||||
|
console.error('[unhandledRejection未捕获异常]', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('web-contents-created', (_, contents) => {
|
||||||
|
contents.on('render-process-gone', (event, details) => {
|
||||||
|
console.error('⚠️ 渲染崩溃:', details);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('ready', () => {
|
||||||
|
console.log('[App Ready] 当前版本:', app.getVersion());
|
||||||
|
|
||||||
|
// 配置日志记录(可选)
|
||||||
|
const log = require('electron-log');
|
||||||
|
autoUpdater.logger = log;
|
||||||
|
autoUpdater.logger.transports.file.level = 'info';
|
||||||
|
|
||||||
|
// 启用静默更新(关键设置)
|
||||||
|
autoUpdater.autoDownload = true; // 自动下载更新
|
||||||
|
autoUpdater.autoInstallOnAppQuit = false; // 我们手动调用安装(静默)
|
||||||
|
// 监听事件(用于控制流程或调试,可保留日志)
|
||||||
|
autoUpdater.on('checking-for-update', () => {
|
||||||
|
console.log('[autoUpdater] 正在检查更新...');
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-available', async (info) => {
|
||||||
|
updateInProgress = true; // ⛔ 开始阻塞导航
|
||||||
|
console.log(`[autoUpdater] 检测到新版本: ${info.version},开始自动下载...`);
|
||||||
|
await killIOSAI()
|
||||||
|
broadcast('update:available', info);
|
||||||
|
});
|
||||||
|
let lastSend = 0;
|
||||||
|
autoUpdater.on('update-not-available', () => {
|
||||||
|
console.log('[autoUpdater] 当前已是最新版本');
|
||||||
|
updateInProgress = false; // ✅ 解锁
|
||||||
|
flushPendingNavs(); // 立刻执行被延迟的导航
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('download-progress', (p) => {
|
||||||
|
updateInProgress = true; // ⛔ 持续阻塞
|
||||||
|
// 节流 150ms,避免刷屏
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastSend > 150) {
|
||||||
|
lastSend = now;
|
||||||
|
broadcast('update:progress', {
|
||||||
|
percent: p.percent, // 0~100
|
||||||
|
bytesPerSecond: p.bytesPerSecond, // B/s
|
||||||
|
transferred: p.transferred, // 已下载字节
|
||||||
|
total: p.total // 总字节
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 维持你原来的任务栏进度
|
||||||
|
const win = BrowserWindow.getAllWindows()[0];
|
||||||
|
if (win) win.setProgressBar(p.percent / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
updateInProgress = true; // 已下载,通常马上安装并退出
|
||||||
|
updateDownloaded = true;
|
||||||
|
console.log('[autoUpdater] 更新下载完成');
|
||||||
|
broadcast('update:downloaded', info);
|
||||||
|
|
||||||
|
// 如果想“用户点按钮再安装”,注释掉下一行,交给渲染进程触发
|
||||||
|
// autoUpdater.quitAndInstall(false, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
autoUpdater.on('error', (err) => {
|
||||||
|
console.error('[autoUpdater] 错误:', err);
|
||||||
|
broadcast('update:error', { message: String(err) });
|
||||||
|
updateInProgress = false; // ✅ 出错也解锁(按需可保持阻塞)
|
||||||
|
flushPendingNavs();
|
||||||
|
});
|
||||||
|
// 可选:提供渲染端调用的安装入口
|
||||||
|
|
||||||
|
|
||||||
|
// ✅ 渲染端主动触发静默安装(不显示 UI)
|
||||||
|
ipcMain.handle('update:quitAndInstallNow', async () => {
|
||||||
|
try {
|
||||||
|
installingNow = true; // 进入“静默安装中”阶段
|
||||||
|
// 安装前做一次业务清理:但避免误杀自身或阻塞
|
||||||
|
await killIOSAI();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[update] pre-install cleanup warning:', e);
|
||||||
|
}
|
||||||
|
// electron-updater 实际上传给 NSIS /S 静默参数,第二个 true 表示强制重启后运行新版本
|
||||||
|
autoUpdater.quitAndInstall(false, true);
|
||||||
|
});
|
||||||
|
ipcMain.handle('update:checkNow', () => {
|
||||||
|
return autoUpdater.checkForUpdates();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动检查更新(不弹窗)
|
||||||
|
autoUpdater.checkForUpdates();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 应用程序生命周期管理
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
setInterval(dumpAllMem, 5000); //循环查看内存情况
|
||||||
|
|
||||||
|
|
||||||
|
// 再创建窗口
|
||||||
|
createWindow();
|
||||||
|
|
||||||
|
// macOS 特有:激活时若无打开窗口再创建
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// globalShortcut.register('F12', () => toggleDevtools(BrowserWindow.getFocusedWindow()));
|
||||||
|
globalShortcut.register('Control+Shift+I', () => toggleDevtools(BrowserWindow.getFocusedWindow()));
|
||||||
|
});
|
||||||
|
app.on('will-quit', () => globalShortcut.unregisterAll());
|
||||||
|
|
||||||
|
|
||||||
|
// 仅在“不是更新安装中”时,做你的退出清理
|
||||||
|
app.on('before-quit', async () => {
|
||||||
|
if (!installingNow) {
|
||||||
|
await killIOSAI();
|
||||||
|
} else {
|
||||||
|
console.log('[before-quit] skip killIOSAI because installingNow = true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function toggleDevtools(win) {
|
||||||
|
if (!win) return;
|
||||||
|
const wc = win.webContents;
|
||||||
|
if (wc.isDevToolsOpened()) wc.closeDevTools();
|
||||||
|
else wc.openDevTools({ mode: 'detach' }); // 独立窗口
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口全部关闭时,退出或保留后台进程
|
||||||
|
app.on('window-all-closed', async () => {
|
||||||
|
await killIOSAI();
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 再加几道保险(进程异常/信号也关)
|
||||||
|
process.on('exit', killIOSAI);
|
||||||
|
process.on('SIGINT', killIOSAI);
|
||||||
|
process.on('SIGTERM', killIOSAI);
|
||||||
|
|
||||||
|
function toMB(v) {
|
||||||
|
if (!v || v <= 0) return 0;
|
||||||
|
// 某些平台字段是 bytes、也有历史 Electron 返回 KB 的情况,做兜底
|
||||||
|
// 这里按优先:bytes->MB,其次 KB->MB,最后假定已经是MB
|
||||||
|
// const bytesToMB = v / (1024 * 1024);
|
||||||
|
// if (bytesToMB > 1) return Number(bytesToMB.toFixed(1)); // 看起来像 bytes
|
||||||
|
const kbToMB = v / 1024;
|
||||||
|
if (kbToMB > 1) return Number(kbToMB.toFixed(1)); // 看起来像 KB
|
||||||
|
return Number(v.toFixed(1)); // 当作 MB
|
||||||
|
}
|
||||||
|
|
||||||
|
function dumpAllMem() {
|
||||||
|
try {
|
||||||
|
const metrics = app.getAppMetrics();
|
||||||
|
|
||||||
|
const report = metrics.map(m => {
|
||||||
|
const mem = m.memory || {};
|
||||||
|
const workingSetMB = toMB(mem.workingSetSize ?? mem.workingSet ?? 0);
|
||||||
|
const privateMB = toMB(mem.privateBytes ?? mem.private ?? 0);
|
||||||
|
const sharedMB = toMB(mem.shared ?? 0);
|
||||||
|
return { pid: m.pid, type: m.type, workingSetMB, privateMB, sharedMB };
|
||||||
|
});
|
||||||
|
|
||||||
|
// console.log('>Tab workingSetMB=', report[3].workingSetMB);
|
||||||
|
|
||||||
|
// // 渲染进程类型在不同版本可能是 "Renderer" 或 "Tab"
|
||||||
|
// const isRenderer = (t) => t === 'Renderer' || t === 'Tab';
|
||||||
|
// const renderers = report.filter(r => isRenderer(r.type));
|
||||||
|
// const gpu = report.filter(r => r.type.toLowerCase().includes('gpu'));
|
||||||
|
|
||||||
|
// const sum = (arr, k) => arr.reduce((a, b) => a + (b[k] || 0), 0).toFixed(1);
|
||||||
|
// console.log('> [MEM] SUM Renderer workingSetMB=', sum(renderers, 'workingSetMB'));
|
||||||
|
// if (gpu.length) console.log('> [MEM] GPU=', gpu);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('getAppMetrics error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在函数外定义计数器(或者放在函数内部,用闭包封装)
|
||||||
|
let consumeCount = 0;
|
||||||
|
async function setupMQConsumerAndPublisher(emitMessage, tenantId) {
|
||||||
|
// 启动持续消费:来一条 -> 立刻 SSE 广播
|
||||||
|
await mq.startConsumer(
|
||||||
|
`q.tenant.${tenantId}`,
|
||||||
|
async (msg) => {
|
||||||
|
const payload = msg.json ?? msg.text; // 数据
|
||||||
|
consumeCount++; // 每消费一条消息就加 1
|
||||||
|
|
||||||
|
console.log('消费到:', payload.hostsId, payload.country, '共' + consumeCount + '条数据');
|
||||||
|
|
||||||
|
// 广播到前端(事件名自定义为 'mq')
|
||||||
|
emitMessage(payload);
|
||||||
|
// 成功返回会在 mq 客户端内部自动 ack
|
||||||
|
},
|
||||||
|
{ prefetch: 1, requeueOnError: false, durable: true, assertQueue: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 供渲染进程发送消息到队列
|
||||||
|
ipcMain.removeHandler('mq-send');
|
||||||
|
ipcMain.handle('mq-send', async (_event, user) => {
|
||||||
|
console.log('消息已发送', user)
|
||||||
|
await mq.publishToQueue(`q.tenant.${tenantId}`, user);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 BrowserWindow,并在加载 http://localhost:8080/ 失败时启动定时检测,成功后自动 reload
|
||||||
|
*/
|
||||||
|
function createWindow() {
|
||||||
|
const appVersion = app.getVersion(); // 获取 package.json 中的 version
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
title: `YOLO(AI助手ios) v${appVersion}`, // 设置窗口标题
|
||||||
|
frame: true,
|
||||||
|
fullscreenable: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'js', 'preload.js'), // ✅ 这里必须配置
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
enableRemoteModule: false,
|
||||||
|
offscreen: false,
|
||||||
|
experimentalFeatures: true, // 保险
|
||||||
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
|
devTools: true, // 开发者工具
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
win.setMenu(null); // 禁用菜单栏F12
|
||||||
|
win.maximize();
|
||||||
|
|
||||||
|
// 自动判断环境使用不同的页面地址
|
||||||
|
const isProd = app.isPackaged;
|
||||||
|
const targetURL = isProd ? 'iosaitest.yolozs.com' : 'http://192.168.1.128:8080';
|
||||||
|
// const targetURL = isProd ? 'https://iosai.yolozs.com' : 'http://192.168.1.128:8080';
|
||||||
|
console.log('[页面加载] 使用地址:', targetURL);
|
||||||
|
|
||||||
|
let retryIntervalId = null;
|
||||||
|
|
||||||
|
// 启动定时重试逻辑
|
||||||
|
function scheduleRetry() {
|
||||||
|
const intervalMs = 5000; // 每 5 秒检测一次,可根据需求调整
|
||||||
|
console.log(`[Auto-Reconnect] 启动定时检测,每 ${intervalMs / 1000}s 重新检测服务`);
|
||||||
|
}
|
||||||
|
// 统一的“尝试跳转”函数:更新中 -> 延迟;否则直接跳
|
||||||
|
const tryNavigate = (reasonTag = '') => {
|
||||||
|
const ts = Date.now() // 或者用前端版本号
|
||||||
|
const go = () => safeLoadURL(win, `${targetURL}?t=${ts}`, () => {
|
||||||
|
if (!retryIntervalId) scheduleRetry();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updateInProgress) {
|
||||||
|
console.log(`[Nav] 阻塞跳转(${reasonTag}):正在更新中,等待解锁`);
|
||||||
|
pendingNavs.push(go); // 等待 autoUpdater 事件解锁后 flush 执行
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
go();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听前端请求文件选择
|
||||||
|
ipcMain.handle('select-apk-file', async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
title: '选择 APK 文件',
|
||||||
|
filters: [{ name: 'APK', extensions: ['apk'] }],
|
||||||
|
properties: ['openFile']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
// 监听前端请求文件选择
|
||||||
|
ipcMain.handle('select-file', async () => {
|
||||||
|
const result = await dialog.showOpenDialog({
|
||||||
|
title: '选择文件',
|
||||||
|
// filters: [{ name: 'APK', extensions: ['apk'] }],
|
||||||
|
properties: ['openFile']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.canceled || result.filePaths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 接收渲染进程请求执行 GC
|
||||||
|
ipcMain.handle('manual-gc', () => {
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc();
|
||||||
|
console.log('🧹 手动触发 GC 成功');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ global.gc 不存在,请确认是否添加了 --expose-gc');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 小工具:异步 sleep
|
||||||
|
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端调用:一直检测,直到检测到 iproxy.exe 才返回
|
||||||
|
* 用法:
|
||||||
|
* const res = await window.electron.ipcRenderer.invoke('iproxy:waitUntilRunning', { intervalMs: 2000, exeName: 'iproxy.exe' })
|
||||||
|
* if (res.running) { /* 决定是否跳转 *\/ }
|
||||||
|
*/
|
||||||
|
ipcMain.handle('isiproxy', async (_event, opts = {}) => {
|
||||||
|
startIOSAIExecutable(); //启动iosAi服务
|
||||||
|
const { intervalMs = 2000, exeName = 'iproxy.exe', maxWaitMs = 0 } = opts;
|
||||||
|
|
||||||
|
// 非 Windows 平台直接认为通过(按需改)
|
||||||
|
if (process.platform !== 'win32') return { running: true, platform: process.platform };
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
while (true) {
|
||||||
|
const ok = await isProcessRunningWin(exeName);
|
||||||
|
if (ok) return { running: true };
|
||||||
|
if (maxWaitMs > 0 && (Date.now() - start) >= maxWaitMs) {
|
||||||
|
return { running: false, timeout: true };
|
||||||
|
}
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 仅检查固定路径:C:\Users\Administrator\IOSAI\aiConfig.json
|
||||||
|
ipcMain.handle('file-exists', async () => {
|
||||||
|
try {
|
||||||
|
// ★ 固定路径(如需改用户,改这里即可)
|
||||||
|
const fixedPath = `${USER_ROOT}\\IOSAI\\aiConfig.json`;
|
||||||
|
|
||||||
|
// 用 stat 判断是否存在 & 获取信息
|
||||||
|
const stat = await fsp.stat(fixedPath).catch(err => {
|
||||||
|
if (err && err.code === 'ENOENT') return null; // 不存在
|
||||||
|
throw err; // 其他错误抛出
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stat) {
|
||||||
|
return { ok: true, exists: false, path: fixedPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
exists: true,
|
||||||
|
path: fixedPath,
|
||||||
|
isFile: stat.isFile(),
|
||||||
|
isDirectory: stat.isDirectory(),
|
||||||
|
size: stat.size,
|
||||||
|
mtimeMs: stat.mtimeMs
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message || String(e) };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// // 判断文件存不存在
|
||||||
|
// ipcMain.handle('file-exists', async (_evt, targetPath, baseDir /* 可选 */) => {
|
||||||
|
// try {
|
||||||
|
// const full = normalizePath(targetPath, baseDir)
|
||||||
|
// const stat = await fsp.stat(full).catch(err => {
|
||||||
|
// if (err && err.code === 'ENOENT') return null
|
||||||
|
// throw err
|
||||||
|
// })
|
||||||
|
// if (!stat) {
|
||||||
|
// return { ok: true, exists: false, path: full }
|
||||||
|
// }
|
||||||
|
// return {
|
||||||
|
// ok: true,
|
||||||
|
// exists: true,
|
||||||
|
// path: full,
|
||||||
|
// isFile: stat.isFile(),
|
||||||
|
// isDirectory: stat.isDirectory(),
|
||||||
|
// size: stat.size,
|
||||||
|
// mtimeMs: stat.mtimeMs
|
||||||
|
// }
|
||||||
|
// } catch (e) {
|
||||||
|
// return { ok: false, error: e.message || String(e) }
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
|
// 监听加载失败事件
|
||||||
|
win.webContents.on('did-fail-load', (event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||||
|
if (isMainFrame) {
|
||||||
|
console.warn(`[did-fail-load] 加载失败(${errorCode}): ${errorDescription},URL: ${validatedURL}`);
|
||||||
|
if (!retryIntervalId) {
|
||||||
|
scheduleRetry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 监听加载完成事件:一旦成功加载,清除重试定时器
|
||||||
|
win.webContents.on('did-finish-load', () => {
|
||||||
|
const currentURL = win.webContents.getURL();
|
||||||
|
console.log(`[did-finish-load] 加载完成,当前 URL: ${currentURL}`)
|
||||||
|
|
||||||
|
if (retryIntervalId) {
|
||||||
|
clearInterval(retryIntervalId);
|
||||||
|
retryIntervalId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 首次加载:先上“加载页” ===
|
||||||
|
const loadingFile = path.join(__dirname, 'waiting.html');
|
||||||
|
const hasLoading = fs.existsSync(loadingFile);
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (hasLoading) {
|
||||||
|
win.loadFile(loadingFile).catch(err => {
|
||||||
|
console.warn('[加载页 loadFile 失败,退回内联HTML]', err);
|
||||||
|
// 兜底:内联一个极简页面
|
||||||
|
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<h3>正在等待 iproxy.exe 启动…</h3>'));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果没有把加载页文件放到指定位置,给个兜底
|
||||||
|
win.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent('<h3>正在等待 iproxy.exe 启动…</h3>'));
|
||||||
|
}
|
||||||
|
// ✅ 不再自动检测 iproxy,3 秒后直接进入业务页面
|
||||||
|
setTimeout(() => {
|
||||||
|
tryNavigate('delay-3s-autogo');
|
||||||
|
}, 3000);
|
||||||
|
// // 开始检测 iproxy,检测到后再跳转 targetURL
|
||||||
|
// waitForIproxyAndNavigate(win, targetURL, {
|
||||||
|
// intervalMs: 2000,
|
||||||
|
// timeoutMs: 999999,
|
||||||
|
// exeName: 'iproxy.exe'
|
||||||
|
// }, tryNavigate);
|
||||||
|
} else {
|
||||||
|
// 非 Windows 平台直接进页面
|
||||||
|
tryNavigate('non-win-first');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//启动sse
|
||||||
|
sseServer = startSSE();
|
||||||
|
const emitMessage = createBurstBroadcaster(sseServer.broadcast, {
|
||||||
|
event: 'message', // 你前端监 n听的事件名
|
||||||
|
idleMs: 10_000,
|
||||||
|
startPayload: 'start',
|
||||||
|
startOnFirst: true // 如果不想程序启动后的第一条先发 start,就设为 false
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开启mq
|
||||||
|
ipcMain.handle('start-mq', async (event, tentId, id) => {
|
||||||
|
userData.tenantId = tentId;
|
||||||
|
userData.userId = id;
|
||||||
|
//启动mq
|
||||||
|
setupMQConsumerAndPublisher(emitMessage, tentId)
|
||||||
|
|
||||||
|
});
|
||||||
|
// 可选:开发阶段打开 DevTools F12
|
||||||
|
// win.webContents.openDevTools();
|
||||||
|
|
||||||
|
return win;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// —— 启动 iOSAI.exe ——
|
||||||
|
// 兼容开发/打包路径:优先同级目录,其次 resources,再到源码目录
|
||||||
|
function resolveIOSAIPath() {
|
||||||
|
const candidates = [
|
||||||
|
// 打包后:和应用同级目录(app.exe 同目录)
|
||||||
|
path.join(path.dirname(process.execPath), 'iOSAI', 'IOSAI.exe'),
|
||||||
|
// 打包后:在 resources 目录(需要用 electron-builder 的 extraResources 拷贝进去)
|
||||||
|
path.join(process.resourcesPath || '', 'iOSAI', 'IOSAI.exe'),
|
||||||
|
// 开发环境:以当前文件目录为基准
|
||||||
|
path.join(__dirname, 'iOSAI', 'IOSAI.exe'),
|
||||||
|
// 兜底:以当前工作目录为基准
|
||||||
|
path.join(process.cwd(), 'iOSAI', 'IOSAI.exe'),
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
} catch (_) { }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let iosAIProcess = null; // 如果你后面想控制/关闭它,用得到;“只要打开就行”也可以不管
|
||||||
|
|
||||||
|
// function startIOSAIExecutable() {
|
||||||
|
// if (process.platform !== 'win32') {
|
||||||
|
// console.warn('[IOSAI] 当前不是 Windows,跳过启动 IOSAI.exe');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
// const exePath = resolveIOSAIPath();
|
||||||
|
// if (!exePath) {
|
||||||
|
// console.error('[IOSAI] 未找到 IOSAI.exe,请确认路径:根目录/iOSAI/IOSAI.exe(或已打包到 resources)');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const exeDir = path.dirname(exePath);
|
||||||
|
// const exeFile = path.basename(exePath);
|
||||||
|
|
||||||
|
// // 打印确认:应为 ...\IOSAI.exe
|
||||||
|
// console.log('[IOSAI] 计划启动文件:', exePath);
|
||||||
|
// console.log('[IOSAI] 文件存在:', fs.existsSync(exePath));
|
||||||
|
|
||||||
|
// // 注意:start 的第一个带引号参数是窗口标题,这里用 "" 占位
|
||||||
|
// // /D 指定工作目录;最后只传文件名,避免整条路径再被拆分
|
||||||
|
// // const cmd = `start "" /D "${exeDir}" "${exeFile}"`;
|
||||||
|
// const params = 'iosai';
|
||||||
|
|
||||||
|
// const cmd = `start "" /D "${exeDir}" "${exeFile}" ${params}`;
|
||||||
|
// try {
|
||||||
|
// exec(cmd, { cwd: exeDir, windowsHide: false }, (err) => {
|
||||||
|
// if (err) {
|
||||||
|
// console.error('[IOSAI] 启动失败:', err);
|
||||||
|
// } else {
|
||||||
|
// console.log('[IOSAI] 启动命令已执行(独立控制台窗口中运行)');
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// } catch (e) {
|
||||||
|
// console.error('[IOSAI] 启动异常:', e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 启动 IOSAI.exe(管理员权限)
|
||||||
|
// 检查当前 Electron 是否已是管理员(高完整性)
|
||||||
|
function isElevated(cb) {
|
||||||
|
// 查询当前进程完整性级别:High 表示管理员
|
||||||
|
exec('whoami /groups', { windowsHide: true }, (err, stdout) => {
|
||||||
|
if (err) return cb(false);
|
||||||
|
cb(/High Mandatory Level/i.test(stdout));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function psEscapeSingleQuotes(s) {
|
||||||
|
return s.replace(/'/g, "''");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startIOSAIExecutable() {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.warn('[IOSAI] 当前不是 Windows,跳过启动 IOSAI.exe');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exePath = resolveIOSAIPath();
|
||||||
|
if (!exePath || !fs.existsSync(exePath)) {
|
||||||
|
console.error('[IOSAI] 未找到 IOSAI.exe:', exePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exeDir = path.dirname(exePath);
|
||||||
|
const args = ['iosai'];
|
||||||
|
|
||||||
|
isElevated((elev) => {
|
||||||
|
console.log('[IOSAI] 当前 Electron 是否管理员:', elev);
|
||||||
|
|
||||||
|
if (elev) {
|
||||||
|
const exeFile = path.basename(exePath);
|
||||||
|
|
||||||
|
// 已经是管理员:直接普通方式启动即可
|
||||||
|
const cmd = `start "" /D "${exeDir}" "${exeFile}" ${args[0]}`;
|
||||||
|
exec(cmd, { cwd: exeDir, windowsHide: false }, (err) => {
|
||||||
|
console.log(err)
|
||||||
|
if (err) console.error('[IOSAI] 启动失败:', err);
|
||||||
|
else console.log('[IOSAI] 已启动(父进程已是管理员,无需 UAC 弹窗)');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未提权 → 用 PowerShell 提权
|
||||||
|
const exePathPS = psEscapeSingleQuotes(exePath);
|
||||||
|
const exeDirPS = psEscapeSingleQuotes(exeDir);
|
||||||
|
const argListPS = `@(${args.map(a => `'${psEscapeSingleQuotes(String(a))}'`).join(',')})`;
|
||||||
|
|
||||||
|
const psCommand = `Start-Process -FilePath '${exePathPS}' -ArgumentList ${argListPS} -WorkingDirectory '${exeDirPS}' -Verb RunAs -WindowStyle Normal -PassThru`;
|
||||||
|
console.log('[IOSAI] 提权启动命令(PS):', psCommand);
|
||||||
|
|
||||||
|
execFile(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', psCommand],
|
||||||
|
{ windowsHide: true },
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[IOSAI] 提权启动失败:', err);
|
||||||
|
} else {
|
||||||
|
console.log('[IOSAI] 已请求管理员启动(按系统策略可能不弹 UAC)');
|
||||||
|
// 可选:用 tasklist 简单确认是否起来了
|
||||||
|
setTimeout(() => {
|
||||||
|
exec('tasklist /FI "IMAGENAME eq IOSAI.exe"', { windowsHide: true }, (e2, out) => {
|
||||||
|
console.log('[IOSAI] 进程检查:\n', out);
|
||||||
|
});
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function killIOSAI() {
|
||||||
|
console.log('尝试关闭IOSAI', userData);
|
||||||
|
// console.log('axios', axios);
|
||||||
|
|
||||||
|
await axios.post('https://crawlclient.api.yolozs.com/api/user/aiChat-logout', { userId: userData.userId, tenantId: userData.tenantId }, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json', // 设置请求头
|
||||||
|
'vvtoken': userData.tokenValue
|
||||||
|
}
|
||||||
|
}).then(response => {
|
||||||
|
console.log("发送登出请求成功")
|
||||||
|
}).catch(error => {
|
||||||
|
console.log("发送登出请求错误", error.message)
|
||||||
|
}).finally(error => {
|
||||||
|
// console.log("发送")
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
|
||||||
|
if (iosAIProcess && iosAIProcess.pid) {
|
||||||
|
// 按 PID 杀,并带上 /T 杀掉它可能拉起的子进程
|
||||||
|
exec(`taskkill /PID ${iosAIProcess.pid} /T /F`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.warn('[IOSAI] 按 PID 关闭失败,改用按进程名:', err.message);
|
||||||
|
// 兜底:按进程名杀(会杀掉系统里所有 IOSAI.exe,若允许同名多实例请注意)
|
||||||
|
exec('taskkill /IM IOSAI.exe /F');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 没有 PID(比如程序二次拉起后原 PID 退出),直接按进程名兜底
|
||||||
|
exec('taskkill /IM IOSAI.exe /F');
|
||||||
|
exec('taskkill /IM iproxy.exe /F');
|
||||||
|
exec('taskkill /IM pythonw.exe /F');
|
||||||
|
}
|
||||||
|
} catch (_) { }
|
||||||
|
iosAIProcess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const broadcast = (channel, payload) => {
|
||||||
|
BrowserWindow.getAllWindows().forEach(w => {
|
||||||
|
if (!w.isDestroyed()) w.webContents.send(channel, payload);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// —— 进程检测:Windows 使用 tasklist 精确匹配 ——
|
||||||
|
// 返回 Promise<boolean>
|
||||||
|
function isProcessRunningWin(exeName) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (process.platform !== 'win32') return resolve(true); // 非 Win 平台直接放行
|
||||||
|
// /FI 过滤进程名;/FO CSV 便于稳定解析;/NH 去表头
|
||||||
|
const cmd = `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`;
|
||||||
|
exec(cmd, { windowsHide: true }, (err, stdout) => {
|
||||||
|
if (err || !stdout) return resolve(false);
|
||||||
|
// 匹配形如:"iproxy.exe","xxxx","Console",...
|
||||||
|
const found = stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`);
|
||||||
|
resolve(found);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 等待 iproxy.exe 启动后跳转到目标 URL
|
||||||
|
* @param {BrowserWindow} win
|
||||||
|
* @param {string} targetURL
|
||||||
|
* @param {object} options
|
||||||
|
*/
|
||||||
|
function waitForIproxyAndNavigate(win, targetURL, options = {}, tryNavigate) {
|
||||||
|
const {
|
||||||
|
intervalMs = 2000, // 轮询间隔
|
||||||
|
timeoutMs = 999999, // 超时提示(不强制跳转)
|
||||||
|
exeName = 'iproxy.exe'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let elapsed = 0;
|
||||||
|
// 加个不确定进度(任务栏)提示
|
||||||
|
win.setProgressBar(2, { mode: 'indeterminate' });
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
const ok = await isProcessRunningWin(exeName);
|
||||||
|
if (ok) {
|
||||||
|
clearInterval(timer);
|
||||||
|
win.setProgressBar(-1);
|
||||||
|
console.log(`[iproxy] 检测到 ${exeName} 已运行,进入页面:${targetURL}`);
|
||||||
|
// 检测到后再真正加载业务页面
|
||||||
|
tryNavigate('no-waiting-file')
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
elapsed += intervalMs;
|
||||||
|
if (elapsed >= timeoutMs) {
|
||||||
|
// 仅提示,不强制退出。你也可以在此弹窗或修改窗口标题
|
||||||
|
console.warn(`[iproxy] 等待 ${exeName} 超时 ${timeoutMs / 1000}s,仍未检测到进程。`);
|
||||||
|
win.setTitle('YOLO(AI助手ios) - 正在等待 iproxy.exe 启动…(可检查连接/杀软/权限)');
|
||||||
|
// 到时间后依旧保持加载页 + 继续轮询(如果想停止轮询,把 clearInterval 放这里)
|
||||||
|
// clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将传入路径标准化:
|
||||||
|
* - 支持 file:// URL
|
||||||
|
* - 相对路径会按 baseDir 解析(不传 baseDir 则按当前进程工作目录)
|
||||||
|
*/
|
||||||
|
function normalizePath(targetPath, baseDir) {
|
||||||
|
if (typeof targetPath !== 'string' || !targetPath.trim()) {
|
||||||
|
throw new Error('无效的路径参数')
|
||||||
|
}
|
||||||
|
// 支持 file://
|
||||||
|
if (targetPath.startsWith('file://')) {
|
||||||
|
return fileURLToPath(new URL(targetPath))
|
||||||
|
}
|
||||||
|
// 相对路径 → 解析到 baseDir
|
||||||
|
if (!path.isAbsolute(targetPath)) {
|
||||||
|
const base = baseDir && typeof baseDir === 'string' ? baseDir : process.cwd()
|
||||||
|
return path.resolve(base, targetPath)
|
||||||
|
}
|
||||||
|
return path.resolve(targetPath)
|
||||||
|
}
|
||||||
61
main/index.js
Normal file
61
main/index.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// main/index.js
|
||||||
|
const { app, globalShortcut, BrowserWindow } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const { registerUpdater, updaterState } = require('./updater')
|
||||||
|
const { createMainWindow } = require('./window')
|
||||||
|
const { registerSystemIpc } = require('./ipc/system')
|
||||||
|
const { registerFileIpc } = require('./ipc/files')
|
||||||
|
const { registerMqIpc } = require('./ipc/mq')
|
||||||
|
const { startIOSAIExecutable, killIOSAI } = require('./services/iosai')
|
||||||
|
const { startSSE } = require('./services/sse')
|
||||||
|
const { setupGuards } = require('./utils/guard')
|
||||||
|
const { dumpAllMem } = require('./utils/mem')
|
||||||
|
|
||||||
|
setupGuards()
|
||||||
|
app.commandLine.appendSwitch('enable-precise-memory-info')
|
||||||
|
app.commandLine.appendSwitch('js-flags', '--expose-gc --max-old-space-size=4096')
|
||||||
|
app.commandLine.appendSwitch('enable-experimental-web-platform-features')
|
||||||
|
app.commandLine.appendSwitch('enable-features', 'WebCodecs,MediaStreamInsertableStreams')
|
||||||
|
|
||||||
|
let sse = null
|
||||||
|
|
||||||
|
app.on('ready', async () => {
|
||||||
|
// 更新
|
||||||
|
registerUpdater()
|
||||||
|
|
||||||
|
// 启动外部服务
|
||||||
|
startIOSAIExecutable()
|
||||||
|
|
||||||
|
// IPC
|
||||||
|
registerSystemIpc()
|
||||||
|
registerFileIpc()
|
||||||
|
registerMqIpc((payload) => sse?.broadcast('message', payload)) // 渲染用
|
||||||
|
|
||||||
|
// SSE
|
||||||
|
sse = startSSE()
|
||||||
|
|
||||||
|
// 窗口
|
||||||
|
const win = createMainWindow({ updaterState })
|
||||||
|
|
||||||
|
// 快捷键
|
||||||
|
globalShortcut.register('Control+Shift+I', () => {
|
||||||
|
const w = BrowserWindow.getFocusedWindow()
|
||||||
|
if (!w) return
|
||||||
|
const wc = w.webContents
|
||||||
|
wc.isDevToolsOpened() ? wc.closeDevTools() : wc.openDevTools({ mode: 'detach' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 内存Dump(可选)
|
||||||
|
setInterval(dumpAllMem, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('will-quit', () => globalShortcut.unregisterAll())
|
||||||
|
app.on('before-quit', async () => { await killIOSAI() })
|
||||||
|
app.on('window-all-closed', async () => {
|
||||||
|
await killIOSAI()
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createMainWindow({ updaterState })
|
||||||
|
})
|
||||||
24
main/ipc/files.js
Normal file
24
main/ipc/files.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// main/ipc/files.js
|
||||||
|
const { ipcMain } = require('electron')
|
||||||
|
const fsp = require('node:fs/promises')
|
||||||
|
const { normalizePath } = require('../utils/paths')
|
||||||
|
|
||||||
|
function registerFileIpc() {
|
||||||
|
ipcMain.removeHandler('file-exists')
|
||||||
|
ipcMain.handle('file-exists', async (_evt, targetPath, baseDir) => {
|
||||||
|
try {
|
||||||
|
const full = normalizePath(targetPath, baseDir)
|
||||||
|
const stat = await fsp.stat(full).catch(err => (err?.code === 'ENOENT' ? null : Promise.reject(err)))
|
||||||
|
if (!stat) return { ok: true, exists: false, path: full }
|
||||||
|
return {
|
||||||
|
ok: true, exists: true, path: full,
|
||||||
|
isFile: stat.isFile(), isDirectory: stat.isDirectory(),
|
||||||
|
size: stat.size, mtimeMs: stat.mtimeMs
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: e.message || String(e) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerFileIpc }
|
||||||
30
main/ipc/mq.js
Normal file
30
main/ipc/mq.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// main/ipc/mq.js
|
||||||
|
const { ipcMain } = require('electron')
|
||||||
|
const mq = require('../../js/rabbitmq-service') // 复用你的文件
|
||||||
|
let currentTenantId = null
|
||||||
|
|
||||||
|
async function startConsumer(emitMessage, tenantId) {
|
||||||
|
await mq.startConsumer(
|
||||||
|
`q.tenant.${tenantId}`,
|
||||||
|
(msg) => emitMessage(msg.json ?? msg.text),
|
||||||
|
{ prefetch: 1, requeueOnError: false, durable: true, assertQueue: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerMqIpc(emitMessage) {
|
||||||
|
ipcMain.removeHandler('start-mq')
|
||||||
|
ipcMain.handle('start-mq', async (_event, tenantId, userId) => {
|
||||||
|
currentTenantId = tenantId
|
||||||
|
await startConsumer(emitMessage, tenantId)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.removeHandler('mq-send')
|
||||||
|
ipcMain.handle('mq-send', async (_event, payload) => {
|
||||||
|
if (!currentTenantId) return { ok: false, error: 'tenant not set' }
|
||||||
|
await mq.publishToQueue(`q.tenant.${currentTenantId}`, payload)
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerMqIpc }
|
||||||
17
main/ipc/system.js
Normal file
17
main/ipc/system.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// main/ipc/system.js
|
||||||
|
const { ipcMain, dialog } = require('electron')
|
||||||
|
|
||||||
|
function registerSystemIpc() {
|
||||||
|
ipcMain.removeHandler('manual-gc')
|
||||||
|
ipcMain.handle('manual-gc', () => {
|
||||||
|
if (global.gc) {
|
||||||
|
global.gc()
|
||||||
|
console.log('🧹 手动触发 GC 成功')
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ global.gc 不存在,请确认 --expose-gc')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 你也可以把 select-file 放这里(如果不想放 window.js)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerSystemIpc }
|
||||||
30
main/preload.js
Normal file
30
main/preload.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// preload.js
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
selectApkFile: () => ipcRenderer.invoke('select-apk-file'),
|
||||||
|
selectFile: () => ipcRenderer.invoke('select-file'),
|
||||||
|
manualGc: () => ipcRenderer.invoke('manual-gc'),
|
||||||
|
mqSend: (arg) => ipcRenderer.invoke('mq-send', arg),
|
||||||
|
startMq: (tendid, id) => ipcRenderer.invoke('start-mq', tendid, id),
|
||||||
|
fileExists: (url) => ipcRenderer.invoke('file-exists', url),
|
||||||
|
isiproxy: (url) => ipcRenderer.invoke('isiproxy', url),
|
||||||
|
})
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('appUpdater', {
|
||||||
|
onAvailable: (cb) => ipcRenderer.on('update:available', (_e, info) => cb(info)),
|
||||||
|
onProgress: (cb) => ipcRenderer.on('update:progress', (_e, p) => cb(p)),
|
||||||
|
onDownloaded: (cb) => ipcRenderer.on('update:downloaded', (_e, info) => cb(info)),
|
||||||
|
onError: (cb) => ipcRenderer.on('update:error', (_e, err) => cb(err)),
|
||||||
|
// 主动触发
|
||||||
|
checkNow: () => ipcRenderer.invoke('update:checkNow'),
|
||||||
|
quitAndInstallNow: () => ipcRenderer.invoke('update:quitAndInstallNow'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 页面卸载时清理监听(可选)
|
||||||
|
window.addEventListener('unload', () => {
|
||||||
|
ipcRenderer.removeAllListeners('update:available');
|
||||||
|
ipcRenderer.removeAllListeners('update:progress');
|
||||||
|
ipcRenderer.removeAllListeners('update:downloaded');
|
||||||
|
ipcRenderer.removeAllListeners('update:error');
|
||||||
|
});
|
||||||
78
main/services/iosai.js
Normal file
78
main/services/iosai.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// main/services/iosai.js
|
||||||
|
const { app } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const { exec } = require('child_process')
|
||||||
|
const axios = require('axios')
|
||||||
|
|
||||||
|
let iosAIProcess = null
|
||||||
|
let userData = { tenantId: null, userId: null, tokenValue: null } // 如果需要共享,请提供 setter
|
||||||
|
|
||||||
|
function resolveIOSAIPath() {
|
||||||
|
const candidates = [
|
||||||
|
path.join(path.dirname(process.execPath), 'iOSAI', 'IOSAI.exe'),
|
||||||
|
path.join(process.resourcesPath || '', 'iOSAI', 'IOSAI.exe'),
|
||||||
|
path.join(__dirname, '..', 'iOSAI', 'IOSAI.exe'),
|
||||||
|
path.join(process.cwd(), 'iOSAI', 'IOSAI.exe'),
|
||||||
|
]
|
||||||
|
for (const p of candidates) {
|
||||||
|
try { if (fs.existsSync(p)) return p } catch { }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startIOSAIExecutable() {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
console.warn('[IOSAI] 非 Windows,跳过')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const exePath = resolveIOSAIPath()
|
||||||
|
if (!exePath) return console.error('[IOSAI] 未找到 IOSAI.exe')
|
||||||
|
|
||||||
|
const exeDir = path.dirname(exePath)
|
||||||
|
const exeFile = path.basename(exePath)
|
||||||
|
const cmd = `start "" /D "${exeDir}" "${exeFile}"`
|
||||||
|
|
||||||
|
try {
|
||||||
|
exec(cmd, { cwd: exeDir, windowsHide: false }, (err) => {
|
||||||
|
if (err) console.error('[IOSAI] 启动失败:', err)
|
||||||
|
else console.log('[IOSAI] 启动命令已执行')
|
||||||
|
})
|
||||||
|
} catch (e) { console.error('[IOSAI] 启动异常:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function killIOSAI() {
|
||||||
|
try {
|
||||||
|
if (userData.userId && userData.tenantId) {
|
||||||
|
await axios.post('https://crawlclient.api.yolozs.com/api/user/aiChat-logout',
|
||||||
|
{ userId: userData.userId, tenantId: userData.tenantId },
|
||||||
|
{ headers: { 'Content-Type': 'application/json', 'vvtoken': userData.tokenValue } }
|
||||||
|
).catch(e => console.log('[IOSAI] 登出失败', e?.message))
|
||||||
|
}
|
||||||
|
if (iosAIProcess?.pid) {
|
||||||
|
exec(`taskkill /PID ${iosAIProcess.pid} /T /F`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
exec('taskkill /IM IOSAI.exe /F')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
exec('taskkill /IM IOSAI.exe /F')
|
||||||
|
exec('taskkill /IM iproxy.exe /F')
|
||||||
|
exec('taskkill /IM tidevice.exe /F')
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
iosAIProcess = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessRunningWin(exeName) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (process.platform !== 'win32') return resolve(true)
|
||||||
|
const cmd = `tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`
|
||||||
|
exec(cmd, { windowsHide: true }, (err, stdout) => {
|
||||||
|
if (err || !stdout) return resolve(false)
|
||||||
|
resolve(stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startIOSAIExecutable, killIOSAI, isProcessRunningWin }
|
||||||
20
main/services/sse.js
Normal file
20
main/services/sse.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// main/services/sse.js
|
||||||
|
const { startSSE } = require('../../js/sse-server')
|
||||||
|
const { createBurstBroadcaster } = require('../../js/burst-broadcast')
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const sseServer = startSSE()
|
||||||
|
const broadcast = createBurstBroadcaster(sseServer.broadcast, {
|
||||||
|
event: 'message',
|
||||||
|
idleMs: 10_000,
|
||||||
|
startPayload: 'start',
|
||||||
|
startOnFirst: true
|
||||||
|
})
|
||||||
|
// 返回一个统一接口
|
||||||
|
return {
|
||||||
|
broadcast: (event, payload) => sseServer.broadcast(event, payload),
|
||||||
|
burst: broadcast,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startSSE: start }
|
||||||
69
main/updater.js
Normal file
69
main/updater.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// main/updater.js
|
||||||
|
const { app, BrowserWindow, ipcMain } = require('electron')
|
||||||
|
const { autoUpdater } = require('electron-updater')
|
||||||
|
const log = require('electron-log')
|
||||||
|
Object.assign(console, log.functions)
|
||||||
|
|
||||||
|
const updaterState = {
|
||||||
|
updateInProgress: false,
|
||||||
|
updateDownloaded: false,
|
||||||
|
pendingNavs: [],
|
||||||
|
}
|
||||||
|
function flushPendingNavs() {
|
||||||
|
const fns = updaterState.pendingNavs.slice()
|
||||||
|
updaterState.pendingNavs = []
|
||||||
|
for (const fn of fns) try { fn() } catch (e) { console.warn('[Nav defer err]', e) }
|
||||||
|
}
|
||||||
|
function broadcast(channel, payload) {
|
||||||
|
BrowserWindow.getAllWindows().forEach(w => !w.isDestroyed() && w.webContents.send(channel, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerUpdater() {
|
||||||
|
autoUpdater.logger = log
|
||||||
|
autoUpdater.logger.transports.file.level = 'info'
|
||||||
|
autoUpdater.autoDownload = true
|
||||||
|
autoUpdater.autoInstallOnAppQuit = false
|
||||||
|
|
||||||
|
autoUpdater.on('checking-for-update', () => console.log('[updater] checking...'))
|
||||||
|
autoUpdater.on('update-available', (info) => {
|
||||||
|
updaterState.updateInProgress = true
|
||||||
|
console.log('[updater] available', info.version)
|
||||||
|
broadcast('update:available', info)
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-not-available', () => {
|
||||||
|
console.log('[updater] not-available')
|
||||||
|
updaterState.updateInProgress = false
|
||||||
|
flushPendingNavs()
|
||||||
|
})
|
||||||
|
let lastSend = 0
|
||||||
|
autoUpdater.on('download-progress', (p) => {
|
||||||
|
updaterState.updateInProgress = true
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastSend > 150) {
|
||||||
|
lastSend = now
|
||||||
|
broadcast('update:progress', p)
|
||||||
|
}
|
||||||
|
const win = BrowserWindow.getAllWindows()[0]
|
||||||
|
if (win) win.setProgressBar(p.percent / 100)
|
||||||
|
})
|
||||||
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
console.log('[updater] downloaded')
|
||||||
|
updaterState.updateInProgress = true
|
||||||
|
updaterState.updateDownloaded = true
|
||||||
|
broadcast('update:downloaded', info)
|
||||||
|
// 想要自动安装:autoUpdater.quitAndInstall(false, true)
|
||||||
|
})
|
||||||
|
autoUpdater.on('error', (err) => {
|
||||||
|
console.error('[updater] error', err)
|
||||||
|
broadcast('update:error', { message: String(err) })
|
||||||
|
updaterState.updateInProgress = false
|
||||||
|
flushPendingNavs()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('update:quitAndInstall', () => autoUpdater.quitAndInstall(false, true))
|
||||||
|
ipcMain.handle('update:checkNow', () => autoUpdater.checkForUpdates())
|
||||||
|
|
||||||
|
autoUpdater.checkForUpdates()
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerUpdater, updaterState }
|
||||||
11
main/utils/guard.js
Normal file
11
main/utils/guard.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// main/utils/guard.js
|
||||||
|
const { app } = require('electron')
|
||||||
|
|
||||||
|
function setupGuards() {
|
||||||
|
process.on('uncaughtException', (error) => console.error('uncaughtException:', error))
|
||||||
|
process.on('unhandledRejection', (reason) => console.error('unhandledRejection:', reason))
|
||||||
|
app.on('web-contents-created', (_, contents) => {
|
||||||
|
contents.on('render-process-gone', (_e, details) => console.error('渲染崩溃:', details))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
module.exports = { setupGuards }
|
||||||
25
main/utils/mem.js
Normal file
25
main/utils/mem.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// main/utils/mem.js
|
||||||
|
const { app } = require('electron')
|
||||||
|
|
||||||
|
function toMB(v) {
|
||||||
|
if (!v || v <= 0) return 0
|
||||||
|
const kbToMB = v / 1024
|
||||||
|
if (kbToMB > 1) return Number(kbToMB.toFixed(1))
|
||||||
|
return Number(v.toFixed(1))
|
||||||
|
}
|
||||||
|
function dumpAllMem() {
|
||||||
|
try {
|
||||||
|
const metrics = app.getAppMetrics()
|
||||||
|
const report = metrics.map(m => {
|
||||||
|
const mem = m.memory || {}
|
||||||
|
const workingSetMB = toMB(mem.workingSetSize ?? mem.workingSet ?? 0)
|
||||||
|
const privateMB = toMB(mem.privateBytes ?? mem.private ?? 0)
|
||||||
|
const sharedMB = toMB(mem.shared ?? 0)
|
||||||
|
return { pid: m.pid, type: m.type, workingSetMB, privateMB, sharedMB }
|
||||||
|
})
|
||||||
|
// console.log(report)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('getAppMetrics error:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = { toMB, dumpAllMem }
|
||||||
21
main/utils/paths.js
Normal file
21
main/utils/paths.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// main/utils/paths.js
|
||||||
|
const path = require('path')
|
||||||
|
const { fileURLToPath } = require('node:url')
|
||||||
|
const { app } = require('electron')
|
||||||
|
|
||||||
|
function normalizePath(targetPath, baseDir) {
|
||||||
|
if (typeof targetPath !== 'string' || !targetPath.trim()) throw new Error('无效的路径参数')
|
||||||
|
if (targetPath.startsWith('file://')) return fileURLToPath(new URL(targetPath))
|
||||||
|
if (!path.isAbsolute(targetPath)) {
|
||||||
|
const base = baseDir && typeof baseDir === 'string' ? baseDir : process.cwd()
|
||||||
|
return path.resolve(base, targetPath)
|
||||||
|
}
|
||||||
|
return path.resolve(targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResource(relPath) {
|
||||||
|
const base = app.isPackaged ? process.resourcesPath : path.resolve(__dirname, '..')
|
||||||
|
return path.join(base, relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { normalizePath, resolveResource }
|
||||||
96
main/window.js
Normal file
96
main/window.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// main/window.js
|
||||||
|
const { app, BrowserWindow, dialog, ipcMain } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs')
|
||||||
|
const { isProcessRunningWin } = require('./services/iosai')
|
||||||
|
|
||||||
|
function safeLoadURL(win, url, onFail) {
|
||||||
|
if (!win || win.isDestroyed()) return
|
||||||
|
win.loadURL(url).catch(err => { console.warn('[loadURL fail]', err); onFail?.(err) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMainWindow({ updaterState }) {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
title: `YOLO(AI助手ios) v${app.getVersion()}`,
|
||||||
|
frame: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
enableRemoteModule: false,
|
||||||
|
offscreen: false,
|
||||||
|
experimentalFeatures: true,
|
||||||
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
|
devTools: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
win.setMenu(null)
|
||||||
|
win.maximize()
|
||||||
|
|
||||||
|
const isProd = app.isPackaged
|
||||||
|
const targetURL = isProd ? 'https://iosai.yolozs.com' : 'http://192.168.1.128:8080'
|
||||||
|
console.log('[page] target:', targetURL)
|
||||||
|
|
||||||
|
const tryNavigate = (reason = '') => {
|
||||||
|
const ts = Date.now()
|
||||||
|
const go = () => safeLoadURL(win, `${targetURL}?t=${ts}`)
|
||||||
|
if (updaterState.updateInProgress) {
|
||||||
|
console.log(`[Nav] blocked (${reason}): updating...`)
|
||||||
|
updaterState.pendingNavs.push(go)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待页
|
||||||
|
const loadingFile = path.join(__dirname, '..', 'waiting.html')
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (fs.existsSync(loadingFile)) {
|
||||||
|
win.loadFile(loadingFile).catch(() => win.loadURL('data:text/html,<h3>正在等待 iproxy.exe 启动…</h3>'))
|
||||||
|
} else {
|
||||||
|
win.loadURL('data:text/html,<h3>正在等待 iproxy.exe 启动…</h3>')
|
||||||
|
}
|
||||||
|
waitForProcessAndNavigate(win, targetURL, { exeName: 'iproxy.exe' }, tryNavigate)
|
||||||
|
} else {
|
||||||
|
tryNavigate('non-win-first')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择文件对话框(放这也行,但我在 system.js 里也提供了封装)
|
||||||
|
ipcMain.handle('select-apk-file', async () => {
|
||||||
|
const r = await dialog.showOpenDialog({ title: '选择 APK 文件', filters: [{ name: 'APK', extensions: ['apk'] }], properties: ['openFile'] })
|
||||||
|
return r.canceled ? null : r.filePaths[0]
|
||||||
|
})
|
||||||
|
ipcMain.handle('select-file', async () => {
|
||||||
|
const r = await dialog.showOpenDialog({ title: '选择文件', properties: ['openFile'] })
|
||||||
|
return r.canceled ? null : r.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
return win
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForProcessAndNavigate(win, targetURL, { intervalMs = 2000, timeoutMs = 999999, exeName = 'iproxy.exe' } = {}, tryNavigate) {
|
||||||
|
let elapsed = 0
|
||||||
|
win.setProgressBar(2, { mode: 'indeterminate' })
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
const ok = await isProcessRunningWin(exeName)
|
||||||
|
if (ok) {
|
||||||
|
clearInterval(timer)
|
||||||
|
win.setProgressBar(-1)
|
||||||
|
console.log(`[iproxy] running, go -> ${targetURL}`)
|
||||||
|
tryNavigate('iproxy-ok')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elapsed += intervalMs
|
||||||
|
if (elapsed >= timeoutMs) {
|
||||||
|
console.warn(`[iproxy] wait timeout ${timeoutMs / 1000}s`)
|
||||||
|
win.setTitle('YOLO(AI助手ios) - 正在等待 iproxy.exe 启动…(可检查连接/杀软/权限)')
|
||||||
|
}
|
||||||
|
}, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createMainWindow, safeLoadURL }
|
||||||
9496
package-lock.json
generated
Normal file
9496
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
97
package.json
Normal file
97
package.json
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{
|
||||||
|
"name": "YOLO-ios-ai",
|
||||||
|
"productName": "YOLO(AI助手ios)",
|
||||||
|
"version": "2.5.3",
|
||||||
|
"description": "Vue3 + WS 控制台",
|
||||||
|
"author": "yourname",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "chcp 65001 && npx electron . --js-flags=--expose-gc",
|
||||||
|
"pack": "electron-builder",
|
||||||
|
"build:vue": "cd vue-app && npm install && npm run build",
|
||||||
|
"start": "npm run build:vue && npx electron . --js-flags=--expose-gc",
|
||||||
|
"build:ci": "electron-builder --publish always"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "27.1.3",
|
||||||
|
"electron-builder": "^26.0.12"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.yolozs.iosai",
|
||||||
|
"productName": "YOLO(AI助手ios)",
|
||||||
|
"icon": "icon.ico",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist_electron"
|
||||||
|
},
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "iOSAI",
|
||||||
|
"to": "iOSAI"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources",
|
||||||
|
"to": "updater",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extraFiles": [
|
||||||
|
{
|
||||||
|
"from": "./ADB",
|
||||||
|
"to": ".",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "waiting.html",
|
||||||
|
"to": "resources/app",
|
||||||
|
"filter": [
|
||||||
|
"waiting.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"asar": false,
|
||||||
|
"files": [
|
||||||
|
"main.js",
|
||||||
|
"js/*",
|
||||||
|
"vue-app/dist/**/*",
|
||||||
|
"ws-scrcpy/**/*"
|
||||||
|
],
|
||||||
|
"publish": [
|
||||||
|
{
|
||||||
|
"provider": "generic",
|
||||||
|
"url": "https://s3.tknb.net/iosAiUpdate/",
|
||||||
|
"useMultipleRangeRequest": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"provider": "s3",
|
||||||
|
"bucket": "ai-update",
|
||||||
|
"endpoint": "https://b632a61caa85401f63c9b32eef3a74c8.r2.cloudflarestorage.com",
|
||||||
|
"region": "auto",
|
||||||
|
"acl": "public-read",
|
||||||
|
"path": "iosAiUpdate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis",
|
||||||
|
"requestedExecutionLevel": "requireAdministrator"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"perMachine": true,
|
||||||
|
"differentialPackage": true,
|
||||||
|
"allowElevation": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"amqplib": "^0.10.9",
|
||||||
|
"axios": "^1.11.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"electron-log": "^5.4.1",
|
||||||
|
"electron-updater": "^6.6.2",
|
||||||
|
"express": "^5.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
resources/waiting.ps1
Normal file
28
resources/waiting.ps1
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
param([string]$LogPath = "$env:TEMP\waiting-log.txt")
|
||||||
|
function Write-Log($m){ try{"[$(Get-Date -Format o)] $m" | Out-File $LogPath -Append -Encoding UTF8}catch{} }
|
||||||
|
|
||||||
|
try{
|
||||||
|
[System.Threading.Thread]::CurrentThread.TrySetApartmentState('STA') | Out-Null
|
||||||
|
Add-Type -AssemblyName PresentationFramework | Out-Null
|
||||||
|
|
||||||
|
$xaml = @'
|
||||||
|
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
Title="Installing..." Width="420" Height="160"
|
||||||
|
WindowStartupLocation="CenterScreen" ResizeMode="NoResize" Topmost="True">
|
||||||
|
<Grid Margin="16">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="Installing the new version, please wait..." Margin="0,0,0,12" FontSize="16" FontWeight="Bold"/>
|
||||||
|
<ProgressBar IsIndeterminate="True" Height="18"/>
|
||||||
|
<TextBlock Name="Status" Margin="0,10,0,0" Opacity="0.8" Text="Do not close your computer."/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
'@
|
||||||
|
|
||||||
|
$win = [Windows.Markup.XamlReader]::Parse($xaml)
|
||||||
|
Write-Log "ShowDialog begin"
|
||||||
|
$null = $win.ShowDialog()
|
||||||
|
Write-Log "ShowDialog end"
|
||||||
|
}catch{
|
||||||
|
Write-Log "FATAL: $($_.Exception.Message)`n$($_.Exception.StackTrace)"
|
||||||
|
}
|
||||||
274
waiting.html
Normal file
274
waiting.html
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data:;">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark light;
|
||||||
|
--bg: #0b1220;
|
||||||
|
--card: #111827;
|
||||||
|
--fg: #e6eaf2;
|
||||||
|
--muted: #9ca3af;
|
||||||
|
--accent1: #60a5fa;
|
||||||
|
--accent2: #34d399;
|
||||||
|
--error: #fca5a5;
|
||||||
|
--ok: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||||
|
background: radial-gradient(1200px 600px at 10% 0%, rgba(96, 165, 250, .2), transparent 70%),
|
||||||
|
radial-gradient(1200px 600px at 100% 120%, rgba(52, 211, 153, .15), transparent 70%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(540px, 92vw);
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 32px 26px;
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, .35);
|
||||||
|
text-align: center;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
border: 4px solid rgba(255, 255, 255, .15);
|
||||||
|
border-top-color: var(--accent1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
opacity: .9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
opacity: .75;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
opacity: .9;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #0b1220;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 18px;
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更新视图样式 */
|
||||||
|
.progress {
|
||||||
|
margin: 18px auto 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
height: 12px;
|
||||||
|
background: #0b1220;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress__bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, var(--accent1), var(--accent2));
|
||||||
|
transition: width .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
opacity: .9;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent1);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn[disabled] {
|
||||||
|
opacity: .6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warn {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
color: var(--ok);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<!-- 等待视图 -->
|
||||||
|
<div id="wait-view">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h2>正在检查更新…</h2>
|
||||||
|
<p class="muted">系统会自动检测可用的新版本</p>
|
||||||
|
<div class="tips">
|
||||||
|
<strong>如果长时间停留在此页面,可尝试:</strong>
|
||||||
|
<ul>
|
||||||
|
<li>检查网络是否可用</li>
|
||||||
|
<li>确认杀软/防火墙没有拦截本应用</li>
|
||||||
|
<li>重启应用后再试</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更新视图 -->
|
||||||
|
<div id="update-view" hidden>
|
||||||
|
<h2 id="upd-title">检测到新版本,正在下载…</h2>
|
||||||
|
<div class="progress" aria-label="下载进度">
|
||||||
|
<div id="upd-bar" class="progress__bar"></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div id="upd-percent">0.00%</div>
|
||||||
|
<div id="upd-meta"><small>—</small></div>
|
||||||
|
</div>
|
||||||
|
<div id="upd-msg" class="ok" hidden>下载完成,准备安装</div>
|
||||||
|
<div id="upd-err" class="warn" hidden></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="btn-install" class="btn" hidden>立即重启安装</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function bindUpdaterUI() {
|
||||||
|
const bridge = window.appUpdater;
|
||||||
|
if (!bridge) return;
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const waitView = $('wait-view');
|
||||||
|
const updView = $('update-view');
|
||||||
|
const updBar = $('upd-bar');
|
||||||
|
const updPercent = $('upd-percent');
|
||||||
|
const updMeta = $('upd-meta');
|
||||||
|
const updTitle = $('upd-title');
|
||||||
|
const updMsg = $('upd-msg');
|
||||||
|
const updErr = $('upd-err');
|
||||||
|
const btnInstall = $('btn-install');
|
||||||
|
|
||||||
|
const fmtMB = b => (b / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
const fmtSpeed = bps => (bps / 1024).toFixed(0) + ' KB/s';
|
||||||
|
|
||||||
|
const showUpdateView = (titleText) => {
|
||||||
|
if (titleText) updTitle.textContent = titleText;
|
||||||
|
waitView.hidden = true;
|
||||||
|
updView.hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
bridge.onAvailable((info) => {
|
||||||
|
const ver = info?.version || '';
|
||||||
|
showUpdateView(ver ? `检测到新版本 v${ver},正在下载…` : '检测到新版本,正在下载…');
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.onProgress((p) => {
|
||||||
|
if (updView.hidden) showUpdateView('正在下载更新…');
|
||||||
|
const percent = Math.max(0, Math.min(100, p?.percent || 0));
|
||||||
|
updBar.style.width = percent.toFixed(2) + '%';
|
||||||
|
updPercent.textContent = percent.toFixed(2) + '%';
|
||||||
|
const transferred = typeof p?.transferred === 'number' ? fmtMB(p.transferred) : '-';
|
||||||
|
const total = typeof p?.total === 'number' ? fmtMB(p.total) : '-';
|
||||||
|
const speed = typeof p?.bytesPerSecond === 'number' ? fmtSpeed(p.bytesPerSecond) : '-';
|
||||||
|
updMeta.innerHTML = `<small>${transferred} / ${total} · ${speed}</small>`;
|
||||||
|
updMsg.hidden = true;
|
||||||
|
updErr.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.onDownloaded(() => {
|
||||||
|
updTitle.textContent = '更新下载完成';
|
||||||
|
updBar.style.width = '100%';
|
||||||
|
updPercent.textContent = '100%';
|
||||||
|
updMsg.hidden = false;
|
||||||
|
btnInstall.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.onError((err) => {
|
||||||
|
updTitle.textContent = '更新出错';
|
||||||
|
updErr.hidden = false;
|
||||||
|
updErr.textContent = (err && (err.message || err)) || '发生未知错误';
|
||||||
|
btnInstall.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
btnInstall.addEventListener('click', () => {
|
||||||
|
btnInstall.disabled = true;
|
||||||
|
try { bridge.quitAndInstallNow?.(); } catch (e) { }
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user