gui: 多主题外观(6色板/背景图缓存)/连接认证(总网关登录窗自动抓Cookie)/中英切换/插件禁用

This commit is contained in:
JianFeeeee
2026-08-14 16:14:08 +08:00
parent 76d86d4dba
commit 9e4afa3040
5 changed files with 848 additions and 47 deletions

View File

@ -3,16 +3,69 @@ const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
const http = require('http');
const crypto = require('crypto');
const { pathToFileURL } = require('url');
const CONNECTIONS_FILE = path.join(app.getPath('userData'), 'connections.json');
const LOG_FILE = path.join(app.getPath('userData'), 'gui.log');
function log(msg) {
try { fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + msg + '\n'); } catch (e) {}
}
const _consoleLog = console.log, _consoleErr = console.error;
console.log = function () { log(Array.prototype.slice.call(arguments).join(' ')); _consoleLog.apply(null, arguments); };
console.error = function () { log('ERR ' + Array.prototype.slice.call(arguments).join(' ')); _consoleErr.apply(null, arguments); };
let homedProcess = null;
let mainWindow;
let authRule = null;
function installAuthRule() {
const { session } = require('electron');
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const h = Object.assign({}, details.requestHeaders);
if (authRule && details.url.indexOf(authRule.url) === 0) {
if (authRule.headers) {
Object.keys(authRule.headers).forEach((k) => {
h[k] = authRule.headers[k];
});
}
if (authRule.cookie) {
h['Cookie'] = (h['Cookie'] ? h['Cookie'] + '; ' : '') + authRule.cookie;
}
}
callback({ requestHeaders: h });
});
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
const h = Object.assign({}, details.responseHeaders || {});
if (details.method === 'OPTIONS' || (authRule && details.url.indexOf(authRule.url) === 0)) {
h['Access-Control-Allow-Origin'] = ['*'];
h['Access-Control-Allow-Methods'] = ['GET,POST,PUT,DELETE,OPTIONS'];
h['Access-Control-Allow-Headers'] = ['Content-Type, X-API-Key, Authorization, Cookie'];
h['Access-Control-Max-Age'] = ['86400'];
}
if (details.method === 'OPTIONS') {
callback({ responseHeaders: h, statusLine: 'HTTP/1.1 200 OK' });
return;
}
callback({ responseHeaders: h });
});
session.defaultSession.webRequest.onCompleted((details) => {
if (authRule && details.url.indexOf(authRule.url) === 0 && details.url.indexOf('/api/') !== -1) {
log('[req] ' + details.method + ' ' + details.statusCode + ' ' + details.url.slice(0, 120));
}
});
session.defaultSession.webRequest.onErrorOccurred((details) => {
if (authRule && details.url.indexOf(authRule.url) === 0) {
log('[req-err] ' + details.method + ' ' + details.error + ' ' + details.url.slice(0, 120));
}
});
}
function loadConnections() {
try {
if (fs.existsSync(CONNECTIONS_FILE)) {
const data = JSON.parse(fs.readFileSync(CONNECTIONS_FILE, 'utf-8'));
const raw = fs.readFileSync(CONNECTIONS_FILE, 'utf-8').replace(/^\uFEFF/, '');
const data = JSON.parse(raw);
normalizeConnections(data);
return data;
}
@ -202,6 +255,61 @@ ipcMain.handle('connections:delete', (_, id) => {
return data;
});
function downloadTo(src, dest) {
return new Promise((resolve, reject) => {
const mod = src.startsWith('https:') ? require('https') : http;
const req = mod.get(src, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
rejectUnauthorized: false,
}, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
downloadTo(new URL(res.headers.location, src).toString(), dest).then(resolve, reject);
return;
}
if (res.statusCode !== 200) { res.resume(); reject(new Error('HTTP ' + res.statusCode)); return; }
const out = fs.createWriteStream(dest);
res.pipe(out);
out.on('finish', () => out.close(resolve));
out.on('error', reject);
res.on('error', reject);
});
req.on('error', reject);
req.setTimeout(60000, () => req.destroy(new Error('timeout')));
});
}
ipcMain.handle('bg:cache', async (_, { src }) => {
if (!src || typeof src !== 'string') return { ok: false, error: 'no src' };
const dir = path.join(app.getPath('userData'), 'bg-cache');
try {
fs.mkdirSync(dir, { recursive: true });
const hash = crypto.createHash('sha1').update(src).digest('hex').slice(0, 24);
let dest = null;
if (/^https?:\/\//i.test(src)) {
dest = path.join(dir, hash);
if (!fs.existsSync(dest)) {
try { await downloadTo(src, dest); }
catch (e) { return { ok: false, error: '下载失败: ' + e.message, useOriginal: true }; }
}
} else if (/^file:\/\//i.test(src)) {
dest = new URL(src).pathname.replace(/^\/([A-Za-z]:)/, '$1');
if (!fs.existsSync(dest)) return { ok: false, error: '文件不存在: ' + src };
} else if (/^data:image\//i.test(src)) {
dest = path.join(dir, hash + '.png');
if (!fs.existsSync(dest)) fs.writeFileSync(dest, Buffer.from(src.split(',')[1] || '', 'base64'));
} else {
const p = path.resolve(src);
if (!fs.existsSync(p)) return { ok: false, error: '路径不存在: ' + src };
dest = path.join(dir, hash + (path.extname(p) || ''));
if (!fs.existsSync(dest)) fs.copyFileSync(p, dest);
}
return { ok: true, file: pathToFileURL(dest).href };
} catch (e) {
return { ok: false, error: e.message };
}
});
ipcMain.handle('connections:setCurrent', (_, id) => {
const data = loadConnections();
if (data.connections.some(c => c.id === id)) {
@ -211,6 +319,160 @@ ipcMain.handle('connections:setCurrent', (_, id) => {
return data;
});
function doWebuiLogin(baseUrl, username, password, extraCookie) {
return new Promise((resolve, reject) => {
const u = new URL(baseUrl + '/api/v1/login');
const body = JSON.stringify({ username, password });
const mod = u.protocol === 'https:' ? require('https') : http;
const headers = {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
'Accept': 'application/json',
};
if (extraCookie) headers['Cookie'] = extraCookie;
const req = mod.request(u, {
method: 'POST',
headers,
}, (res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => {
const sc = res.headers['set-cookie'];
if (!sc) {
reject(new Error('login failed HTTP ' + res.statusCode + ' ' + data.slice(0, 150)));
return;
}
const cookies = Array.isArray(sc) ? sc : [sc];
let tok = null;
cookies.some((c) => {
const m = /homeagent_session=([^;]+)/.exec(c);
if (m) { tok = m[1]; return true; }
return false;
});
if (!tok) {
reject(new Error('login ok but no homeagent_session cookie'));
return;
}
resolve(tok);
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
ipcMain.on('log:r', (_e, m) => { log('[r] ' + m); });
ipcMain.handle('webui:setAuth', async (_, { url, cookie, headers, username, password }) => {
const resp = { ok: true, error: '' };
let extra = cookie || '';
try {
if (username && url) {
try {
const tok = await doWebuiLogin(url, username, password, extra);
extra = extra ? extra + '; homeagent_session=' + tok : 'homeagent_session=' + tok;
} catch (e) {
if (extra) {
log('[setAuth] auto-login skipped (gateway cookie mode): ' + e.message.slice(0, 120));
} else {
throw e;
}
}
} else if (!cookie) {
authRule = { url: '', cookie: '', headers: {} };
return resp;
}
} catch (e) {
authRule = { url: url || '', cookie: extra || '', headers: headers || {} };
resp.ok = false;
resp.error = e.message;
log('[setAuth] fail url=' + (url || '') + ' err=' + e.message.slice(0, 150));
return resp;
}
authRule = { url: url || '', cookie: extra, headers: headers || {} };
log('[setAuth] ok url=' + (url || '') + ' cookieLen=' + extra.length + ' hasTok=' + (extra.indexOf('homeagent_session') !== -1));
return resp;
});
ipcMain.handle('webui:openLogin', (_, { url, username, password }) => {
if (!url) return { ok: false, error: 'no url' };
const loginWin = new BrowserWindow({
width: 980,
height: 740,
parent: mainWindow,
modal: true,
autoHideMenuBar: true,
title: 'HomeAgent - 网关联机登录',
webPreferences: { contextIsolation: true, nodeIntegration: false },
});
const u = encodeURIComponent(username || '');
const p = encodeURIComponent(password || '');
let autoFills = 0;
const tryAutofill = () => {
if (autoFills > 8) return;
autoFills++;
loginWin.webContents
.executeJavaScript(
'(function(){var pw=document.querySelector("input[type=password]");' +
'if(!pw)return false;var f=pw.closest("form")||pw.form;if(!f)return false;' +
'var ins=f.querySelectorAll("input");var filled=false;' +
'for(var i=0;i<ins.length;i++){var el=ins[i];var t=(el.type||"").toLowerCase();' +
'if(t==="password"){if(!el.value)el.value=decodeURIComponent("' + p + '");}' +
'else if(t!=="hidden"&&t!=="checkbox"&&t!=="submit"&&t!=="button"){' +
'if(!el.value)el.value=decodeURIComponent("' + u + '");}}' +
'if(f.querySelector("button")){}' +
'var sb=f.querySelector("input[type=submit],button[type=submit]");' +
'if(sb&&sb.disabled)return false;' +
'if(f.requestSubmit)f.requestSubmit();else f.submit();' +
'return true;})()'
)
.then((r) => {
if (r) log('[openLogin] autofilled gateway login form');
})
.catch((e) => log('[openLogin] autofill error: ' + e.message));
};
loginWin.webContents.on('did-finish-load', () => {
if (autoFills < 3) tryAutofill();
});
loginWin.webContents.on('did-navigate', (_e, u) => {
log('[openLogin] nav: ' + u);
if (autoFills < 6) setTimeout(tryAutofill, 600);
});
loginWin.webContents.on('did-fail-load', (_e, code, desc, isMain, failedUrl) => {
if (isMain) log('[openLogin] fail-load ' + code + ' ' + desc + ' @ ' + failedUrl);
});
loginWin.loadURL(url).catch((e) => console.error('login window load error:', e.message));
let grabbed = null;
loginWin.on('close', async () => {
if (grabbed) return;
try {
const all = await require('electron').session.defaultSession.cookies.get({});
const host = new URL(url).hostname;
const keep = (all || []).filter((c) => {
const d = (c.domain || '').replace(/^\./, '');
const onHost = host === d || host.endsWith('.' + d);
const onSibling = /(^|\.)jianfgit\.xyz$/i.test(d);
return onHost || onSibling || /^sl-/.test(c.name || '');
});
const list = keep.map((c) => c.name + '=' + c.value);
log('[openLogin] close-grab: host=' + host + ' kept=' + keep.length +
' names=' + keep.map((c) => c.name + '@' + (c.domain || '')).join(','));
grabbed = { ok: true, cookie: list.join('; '), count: keep.length };
} catch (e) {
log('[openLogin] close-grab error: ' + e.message);
grabbed = { ok: false, error: e.message };
}
});
loginWin.on('closed', () => {
if (mainWindow && !mainWindow.isDestroyed() && grabbed) {
mainWindow.webContents.send('webui:login-result', { ...grabbed, url });
}
});
return { ok: true };
});
// CLI 传输:通过 homed 的 unix socket(逐行 JSON 协议)发起请求。
// 认证行:/auth <apiKey>(若配置了密钥)。返回 JSON 响应行。
ipcMain.handle('cli:request', (_, { socketPath, apiKey, line }) => {
@ -258,6 +520,7 @@ ipcMain.handle('cli:request', (_, { socketPath, apiKey, line }) => {
});
app.whenReady().then(async () => {
installAuthRule();
const running = await isServerRunning();
if (!running) {
startHomed();