client: Windows 客户端(WinUI3 壳 + agent-core;调试用 Electron 壳存档);localserver token 改为常数时间比较

This commit is contained in:
Qiufeng
2026-08-20 20:38:21 +08:00
parent 33b7d57498
commit 9abf7f8213
60 changed files with 10025 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
const fs = require('fs');
const p = require('path');
const png = fs.readFileSync(p.join(__dirname, 'icons', 'app.png'));
const hdr = Buffer.alloc(6);
hdr.writeUInt16LE(1, 2);
hdr.writeUInt16LE(1, 4);
const ent = Buffer.alloc(16);
ent.writeUInt16LE(1, 4);
ent.writeUInt16LE(32, 6);
ent.writeUInt32LE(png.length, 8);
ent.writeUInt32LE(22, 12);
fs.writeFileSync(p.join(__dirname, 'icons', 'app.ico'), Buffer.concat([hdr, ent, png]));
console.log('app.ico bytes:', 22 + png.length);
+50
View File
@@ -0,0 +1,50 @@
// 生成 icons/app.png(256x256 纯蓝方块图标)
const zlib = require("zlib");
const fs = require("fs");
const path = require("path");
const W = 256, H = 256;
const row = Buffer.alloc(1 + W * 4);
row[0] = 0;
for (let x = 0; x < W; x++) {
row[1 + x * 4] = 0;
row[2 + x * 4] = 110;
row[3 + x * 4] = 255;
row[4 + x * 4] = 255;
}
const raw = Buffer.concat(Array(H).fill(row));
const crcTable = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
crcTable[n] = c >>> 0;
}
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const t = Buffer.from(type, "ascii");
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([t, data])));
return Buffer.concat([len, t, data, crc]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(W, 0);
ihdr.writeUInt32BE(H, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", ihdr),
chunk("IDAT", zlib.deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
fs.mkdirSync(path.join(__dirname, "icons"), { recursive: true });
fs.writeFileSync(path.join(__dirname, "icons", "app.png"), png);
console.log("icon generated:", png.length, "bytes");
Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 B

+177
View File
@@ -0,0 +1,177 @@
const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require('electron');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const http = require('http');
const isWin = process.platform === 'win32';
const isSmoke = process.argv.includes('--smoke');
const minimized = process.argv.includes('--minimized');
let win = null;
let tray = null;
let coreProc = null;
let corePort = 0;
let coreToken = '';
let localJsonTimer = null;
let quitting = false;
const dataDir = path.join(app.getPath('userData'), 'data');
const coreBin = isWin ? 'agent-core.exe' : 'agent-core';
const coreExe = app.isPackaged
? path.join(process.resourcesPath, coreBin)
: path.join(__dirname, '..', 'dist', coreBin);
function startCore() {
if (!fs.existsSync(coreExe)) {
console.error('[core] binary not found: ' + coreExe);
win?.webContents.send('core-error', '核心程序缺失:' + coreExe);
return;
}
fs.mkdirSync(dataDir, { recursive: true });
try { coreProc?.kill(); } catch (e) { /* ignore */ }
coreProc = spawn(coreExe, ['-dir', dataDir], { windowsHide: true });
coreProc.on('exit', (code) => {
coreProc = null;
win?.webContents.send('core-exit', code);
});
pollLocalJson();
}
function pollLocalJson() {
if (localJsonTimer) clearInterval(localJsonTimer);
localJsonTimer = setInterval(() => {
try {
const info = JSON.parse(fs.readFileSync(path.join(dataDir, 'local.json'), 'utf8'));
if (info.port && info.token) {
corePort = info.port;
coreToken = info.token;
clearInterval(localJsonTimer);
win?.webContents.send('core-ready');
}
} catch (e) { /* 核心尚未就绪 */ }
}, 800);
}
function coreRequest(method, p, body) {
return new Promise((resolve, reject) => {
if (!corePort || !coreToken) { reject(new Error('核心未就绪')); return; }
const data = body ? JSON.stringify(body) : null;
const req = http.request({
host: '127.0.0.1',
port: corePort,
path: p,
method: method,
headers: {
'X-Agent-Token': coreToken,
'Content-Type': 'application/json',
'Content-Length': data ? Buffer.byteLength(data) : 0,
},
}, (res) => {
let raw = '';
res.on('data', (c) => { raw += c; });
res.on('end', () => {
try { resolve(JSON.parse(raw)); } catch (e) { resolve({ raw }); }
});
});
req.on('error', reject);
req.setTimeout(8000, () => { req.destroy(new Error('核心请求超时')); });
if (data) req.write(data);
req.end();
});
}
// ---- IPC(渲染进程 → 核心/系统) ----
ipcMain.handle('core:status', () => coreRequest('GET', '/status'));
ipcMain.handle('core:pair', (e, code, name) => coreRequest('POST', '/pair', { code, name }));
ipcMain.handle('core:challenges', () => coreRequest('GET', '/challenges'));
ipcMain.handle('core:solve', (e, id, value) => coreRequest('POST', '/challenges/' + encodeURIComponent(id), { value }));
ipcMain.handle('core:get-config', () => readConfig());
ipcMain.handle('core:save-config', (e, cfg) => { writeConfig(cfg); startCore(); return true; });
ipcMain.handle('core:restart', () => { startCore(); return true; });
ipcMain.handle('app:open-data-dir', () => shell.openPath(dataDir));
ipcMain.handle('app:open-web', (e, url) => shell.openExternal(url));
ipcMain.handle('app:set-autostart', (e, on) => {
app.setLoginItemSettings({ openAtLogin: !!on });
return app.getLoginItemSettings().openAtLogin;
});
ipcMain.handle('app:get-autostart', () => app.getLoginItemSettings().openAtLogin);
ipcMain.handle('app:quit', () => { quitting = true; app.quit(); });
function readConfig() {
try { return JSON.parse(fs.readFileSync(path.join(dataDir, 'config.json'), 'utf8')); }
catch (e) { return { server: '', fakeAll: false }; }
}
function writeConfig(cfg) {
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(path.join(dataDir, 'config.json'), JSON.stringify(cfg, null, 2));
}
function createTray() {
let img = nativeImage.createFromPath(path.join(__dirname, 'icons', 'app.png'));
if (img.isEmpty()) img = nativeImage.createEmpty();
tray = new Tray(img);
tray.setToolTip('EveryPublish 客户端');
tray.setContextMenu(Menu.buildFromTemplate([
{ label: '打开主窗口', click: showWin },
{ type: 'separator' },
{ label: '退出', click: () => { quitting = true; app.quit(); } },
]));
tray.on('click', showWin);
}
function showWin() {
if (win) { win.show(); win.focus(); }
}
function createWindow() {
win = new BrowserWindow({
width: 980, height: 660,
minWidth: 780, minHeight: 540,
title: 'EveryPublish 客户端',
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
win.on('close', (e) => {
if (!quitting) { e.preventDefault(); win.hide(); }
});
}
app.whenReady().then(() => {
app.setAppUserModelId('com.everypublish.client');
if (isSmoke) {
// 冒烟模式:启动 core → 等就绪 → 查状态 → 退出(无窗口)
runSmoke();
return;
}
createTray();
createWindow();
if (minimized) win?.hide();
startCore();
});
async function runSmoke() {
startCore();
const deadline = Date.now() + 30000;
while (!coreToken && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 500));
}
if (!coreToken) { console.log('SMOKE_RESULT ' + JSON.stringify({ error: 'core 未就绪' })); app.exit(1); return; }
try {
const status = await coreRequest('GET', '/status');
console.log('SMOKE_RESULT ' + JSON.stringify(status));
app.exit(0);
} catch (e) {
console.log('SMOKE_RESULT ' + JSON.stringify({ error: String(e) }));
app.exit(1);
}
}
app.on('before-quit', () => { quitting = true; try { coreProc?.kill(); } catch (e) { /* ignore */ } });
app.on('window-all-closed', () => { /* 驻留托盘不退出 */ });
app.on('activate', showWin);
+5285
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "everypublish-client",
"version": "0.2.0",
"description": "EveryPublish Windows 客户端(Electron 壳 + Go agent-core)",
"main": "main.js",
"scripts": {
"start": "electron .",
"gen-icon": "node gen-icon.js",
"dist:win": "electron-builder --win nsis --x64"
},
"devDependencies": {
"electron": "^31.7.7",
"electron-builder": "^25.1.8"
},
"build": {
"appId": "com.everypublish.client",
"productName": "EveryPublish",
"directories": { "output": "release" },
"files": ["main.js", "preload.js", "renderer/**/*", "icons/**/*"],
"extraResources": [
{ "from": "../dist/agent-core.exe", "to": "agent-core.exe" }
],
"win": {
"target": [{ "target": "nsis", "arch": ["x64"] }],
"icon": "icons/app.ico",
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
}
}
}
+19
View File
@@ -0,0 +1,19 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
status: () => ipcRenderer.invoke('core:status'),
pair: (code, name) => ipcRenderer.invoke('core:pair', code, name),
challenges: () => ipcRenderer.invoke('core:challenges'),
solve: (id, value) => ipcRenderer.invoke('core:solve', id, value),
getConfig: () => ipcRenderer.invoke('core:get-config'),
saveConfig: (cfg) => ipcRenderer.invoke('core:save-config', cfg),
restart: () => ipcRenderer.invoke('core:restart'),
openDataDir: () => ipcRenderer.invoke('app:open-data-dir'),
openWeb: (url) => ipcRenderer.invoke('app:open-web', url),
setAutoStart: (on) => ipcRenderer.invoke('app:set-autostart', on),
getAutoStart: () => ipcRenderer.invoke('app:get-autostart'),
quit: () => ipcRenderer.invoke('app:quit'),
onCoreReady: (cb) => ipcRenderer.on('core-ready', cb),
onCoreError: (cb) => ipcRenderer.on('core-error', cb),
onCoreExit: (cb) => ipcRenderer.on('core-exit', cb),
});
+179
View File
@@ -0,0 +1,179 @@
// EveryPublish 客户端渲染进程逻辑
const $ = (id) => document.getElementById(id);
// ---- 页签切换 ----
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
tab.classList.add('active');
$('page-' + tab.dataset.tab).classList.add('active');
});
});
// ---- 状态页 ----
let currentServer = '';
async function refreshStatus() {
try {
const s = await window.api.status();
if (s && s.server) currentServer = s.server;
$('st-server').textContent = (s && s.server) || '-';
$('st-device').textContent = s && s.deviceId > 0 ? '#' + s.deviceId + '(' + (s.name || '') + ')' : '未配对';
$('st-session').textContent = (s && s.session) || '-';
const dot = $('st-dot');
if (!s) { $('st-online').textContent = '核心未就绪'; dot.className = 'dot gray'; }
else if (s.online) { $('st-online').textContent = '在线'; dot.className = 'dot green'; }
else { $('st-online').textContent = s.deviceId > 0 ? '离线(自动重连中)' : '未配对'; dot.className = 'dot orange'; }
} catch (e) {
$('st-online').textContent = '核心未就绪';
$('st-dot').className = 'dot gray';
}
}
setInterval(refreshStatus, 3000);
refreshStatus();
$('btn-open-web').addEventListener('click', () => {
const url = currentServer || 'http://127.0.0.1:8090';
window.api.openWeb(url);
});
$('btn-open-data').addEventListener('click', () => window.api.openDataDir());
// ---- 配对页 ----
$('btn-pair').addEventListener('click', async () => {
const code = $('pair-code').value.trim();
const name = $('pair-name').value.trim();
const res = $('pair-result');
if (!code) { res.textContent = '请输入配对码'; res.className = 'msg err'; return; }
$('btn-pair').disabled = true;
res.textContent = '配对中...';
res.className = 'msg';
try {
const r = await window.api.pair(code, name);
if (r && r.code === 0) {
res.textContent = '配对成功,正在连接服务器(可到「状态」页查看)';
res.className = 'msg ok';
} else {
res.textContent = (r && r.message) || '配对失败';
res.className = 'msg err';
}
} catch (e) {
res.textContent = '配对失败:' + e;
res.className = 'msg err';
}
$('btn-pair').disabled = false;
});
// ---- 挑战页 ----
let selectedChallenge = null;
let pollTimer = null;
const KIND_LABEL = { qr: '扫码', sms: '短信验证码', confirm: 'APP 确认', captcha: '图形验证码', pending: '等待设备' };
async function refreshChallenges() {
try {
const r = await window.api.challenges();
const list = (r && r.list) || [];
const badge = $('ch-badge');
if (list.length > 0) { badge.textContent = list.length; badge.classList.remove('hidden'); }
else badge.classList.add('hidden');
const box = $('ch-list');
box.innerHTML = '';
list.forEach((ch) => {
const div = document.createElement('div');
div.className = 'item' + (selectedChallenge && selectedChallenge.challengeId === ch.challengeId ? ' selected' : '');
const inner = [
'<span><b>',
ch.platform || '?',
'</b> · ',
KIND_LABEL[ch.kind] || ch.kind,
'</span><span class="p">',
ch.prompt || '',
'</span>',
].join('');
div.innerHTML = inner;
div.addEventListener('click', () => selectChallenge(ch));
box.appendChild(div);
});
if (!list.length) {
box.innerHTML = '<p class="hint">暂无待处理挑战</p>';
}
} catch (e) { /* 忽略 */ }
}
function selectChallenge(ch) {
selectedChallenge = ch;
refreshChallenges();
const detail = $('ch-detail');
detail.classList.remove('hidden');
const qr = ch.qrUrl || ch.qrToken || '';
const img = $('ch-qr');
if (qr && (qr.startsWith('http') || qr.startsWith('file://'))) {
img.src = qr;
img.classList.remove('hidden');
} else {
img.classList.add('hidden');
}
$('ch-value').value = '';
$('ch-result').textContent = '';
}
$('btn-solve').addEventListener('click', async () => {
if (!selectedChallenge) return;
const value = $('ch-value').value.trim() || 'confirmed';
const res = $('ch-result');
try {
const r = await window.api.solve(selectedChallenge.challengeId, value);
if (r && r.code === 0) {
res.textContent = '已提交,等待服务器确认';
res.className = 'msg ok';
selectedChallenge = null;
$('ch-detail').classList.add('hidden');
refreshChallenges();
} else {
res.textContent = (r && r.message) || '提交失败';
res.className = 'msg err';
}
} catch (e) {
res.textContent = '提交失败:' + e;
res.className = 'msg err';
}
});
function startPolling() {
if (pollTimer) return;
pollTimer = setInterval(refreshChallenges, 2000);
refreshChallenges();
}
startPolling();
// ---- 设置页 ----
async function loadSettings() {
try {
const cfg = await window.api.getConfig();
$('set-server').value = (cfg && cfg.server) || '';
$('set-fake').checked = !!(cfg && cfg.fakeAll);
$('set-autostart').checked = await window.api.getAutoStart();
} catch (e) { /* 忽略 */ }
}
loadSettings();
$('btn-save').addEventListener('click', async () => {
const server = $('set-server').value.trim();
const res = $('set-result');
if (!server) { res.textContent = '请填写服务器地址(内网部署填 http://服务器IP:8090)'; res.className = 'msg err'; return; }
const cfg = { server: server, fakeAll: $('set-fake').checked };
await window.api.saveConfig(cfg);
await window.api.setAutoStart($('set-autostart').checked);
currentServer = server;
res.textContent = '已保存,核心进程已重启';
res.className = 'msg ok';
});
$('btn-quit').addEventListener('click', () => window.api.quit());
// 核心就绪提示
if (window.api.onCoreReady) {
window.api.onCoreReady(() => {
$('st-online').textContent = '核心已就绪';
refreshStatus();
});
}
+93
View File
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>EveryPublish 客户端</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">EveryPublish <span class="sub">发布客户端</span></div>
<nav class="tabs">
<button class="tab active" data-tab="status">状态</button>
<button class="tab" data-tab="pair">配对</button>
<button class="tab" data-tab="challenges">挑战<span id="ch-badge" class="badge hidden">0</span></button>
<button class="tab" data-tab="settings">设置</button>
</nav>
</header>
<main id="main">
<!-- 状态页 -->
<section id="page-status" class="page active">
<div class="card">
<h2>运行状态</h2>
<div class="kv"><span>连接状态</span><b id="st-online">检测中...</b><span id="st-dot" class="dot gray"></span></div>
<div class="kv"><span>服务器</span><b id="st-server">-</b></div>
<div class="kv"><span>设备</span><b id="st-device">-</b></div>
<div class="kv"><span>会话</span><b id="st-session">-</b></div>
</div>
<div class="card actions">
<button id="btn-open-web" class="btn primary">打开网页控制台</button>
<button id="btn-open-data" class="btn">打开数据目录</button>
</div>
</section>
<!-- 配对页 -->
<section id="page-pair" class="page">
<div class="card">
<h2>设备配对</h2>
<ol class="steps">
<li>打开网页控制台(需与服务器内网互通)</li>
<li>登录后进入「设置 → Agent 设备」生成配对码</li>
<li>在下方输入配对码并点击「配对」</li>
</ol>
<label>配对码(6 位,5 分钟有效)</label>
<input id="pair-code" placeholder="例如:AB12CD" maxlength="6" />
<label>设备名(选填)</label>
<input id="pair-name" placeholder="例如:办公室电脑-01" />
<button id="btn-pair" class="btn primary">配对</button>
<p id="pair-result" class="msg"></p>
</div>
</section>
<!-- 挑战页 -->
<section id="page-challenges" class="page">
<div class="card">
<h2>二次验证挑战</h2>
<p class="hint">平台要求扫码/验证码时在此处理;超时可在网页端一键重发。</p>
<div id="ch-list" class="list"></div>
<div id="ch-detail" class="detail hidden">
<img id="ch-qr" alt="二维码" />
<label>验证码 / 确认值</label>
<input id="ch-value" placeholder="扫码后输入验证码;扫码挑战留空直接提交" />
<button id="btn-solve" class="btn primary">提交</button>
<p id="ch-result" class="msg"></p>
</div>
</div>
</section>
<!-- 设置页 -->
<section id="page-settings" class="page">
<div class="card">
<h2>设置</h2>
<label>服务器地址(内网部署时填 http://服务器IP:8090)</label>
<input id="set-server" placeholder="http://192.168.x.x:8090" />
<label class="switch-row">
<input type="checkbox" id="set-fake" />
<span>模拟执行(无真实账号的安装联调)</span>
</label>
<label class="switch-row">
<input type="checkbox" id="set-autostart" />
<span>开机自启</span>
</label>
<div class="actions">
<button id="btn-save" class="btn primary">保存并重启核心</button>
<button id="btn-quit" class="btn danger">退出客户端</button>
</div>
<p id="set-result" class="msg"></p>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; background: #f5f6f8; color: #1f2329; height: 100vh; display: flex; flex-direction: column; }
.topbar { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 0 24px; display: flex; align-items: center; height: 56px; gap: 32px; }
.brand { font-size: 18px; font-weight: 700; color: #0a63f0; }
.brand .sub { font-size: 13px; font-weight: 400; color: #86909c; margin-left: 6px; }
.tabs { display: flex; gap: 8px; }
.tab { border: none; background: none; padding: 8px 16px; font-size: 14px; cursor: pointer; border-radius: 6px; color: #4e5969; }
.tab:hover { background: #f2f4f7; }
.tab.active { background: #e8f1ff; color: #0a63f0; font-weight: 600; }
.badge { display: inline-block; margin-left: 4px; background: #f53f3f; color: #fff; border-radius: 8px; font-size: 11px; padding: 0 6px; }
.hidden { display: none !important; }
#main { flex: 1; overflow-y: auto; padding: 24px; }
.page { display: none; max-width: 680px; margin: 0 auto; }
.page.active { display: block; }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 16px; margin-bottom: 14px; }
.kv { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px dashed #f0f1f3; font-size: 14px; }
.kv span { color: #86909c; width: 80px; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-left: 6px; }
.dot.gray { background: #c9cdd4; } .dot.green { background: #00b42a; } .dot.orange { background: #ff7d00; } .dot.red { background: #f53f3f; }
label { display: block; font-size: 13px; color: #4e5969; margin: 12px 0 6px; }
input[type="text"], input:not([type]) { width: 100%; padding: 9px 12px; border: 1px solid #d9dde3; border-radius: 6px; font-size: 14px; outline: none; }
input:focus { border-color: #0a63f0; }
.btn { padding: 9px 20px; border: 1px solid #d9dde3; border-radius: 6px; background: #fff; cursor: pointer; font-size: 14px; }
.btn:hover { background: #f7f8fa; }
.btn.primary { background: #0a63f0; border-color: #0a63f0; color: #fff; }
.btn.primary:hover { background: #0b57d0; }
.btn.danger { color: #f53f3f; border-color: #f53f3f; }
.actions { display: flex; gap: 10px; margin-top: 16px; }
.msg { margin-top: 10px; font-size: 13px; color: #4e5969; min-height: 18px; }
.msg.ok { color: #00b42a; } .msg.err { color: #f53f3f; }
.hint { font-size: 13px; color: #86909c; margin-bottom: 10px; }
.steps { margin: 0 0 14px 18px; font-size: 13px; color: #4e5969; line-height: 1.9; }
.list .item { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border: 1px solid #e5e7eb; border-radius: 6px; margin-bottom: 8px; cursor: pointer; font-size: 13px; }
.list .item:hover { border-color: #0a63f0; }
.list .item.selected { border-color: #0a63f0; background: #f0f7ff; }
.list .item .p { color: #86909c; }
.detail { margin-top: 14px; }
.detail img { max-width: 220px; max-height: 220px; border: 1px solid #e5e7eb; border-radius: 6px; display: block; margin-bottom: 10px; }
.switch-row { display: flex; align-items: center; gap: 8px; font-size: 14px; margin: 10px 0; cursor: pointer; }