tt-help-cli-ycl 1.4.79 → 1.4.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/clash/api.js +17 -0
- package/src/clash/checker.js +218 -94
- package/src/clash/daemon.js +227 -68
- package/src/clash/ipc.js +23 -4
- package/src/cli/clash-daemon.js +2 -1
- package/src/cli/explore.js +15 -4
- package/src/cli/refresh.js +15 -6
- package/src/cli/search.js +16 -7
- package/src/cli/webserver.js +8 -2
- package/src/lib/args.js +8 -0
- package/src/lib/constants.js +23 -0
- package/src/lib/shop-scraper.mjs +34 -17
- package/src/watch/public/app.js +150 -0
- package/src/watch/public/index.html +1 -1
- package/src/watch/watchdog-server.js +32 -1
- package/src/watchdog/agent.js +20 -2
- package/src/watchdog/process-manager.js +57 -0
- package/src/webserver/server.mjs +37 -14
- package/src/clash/health-checker.js +0 -248
package/package.json
CHANGED
package/src/clash/api.js
CHANGED
|
@@ -101,6 +101,23 @@ export async function getNodes() {
|
|
|
101
101
|
export async function getConfigs() {
|
|
102
102
|
return request("/configs");
|
|
103
103
|
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 从 Clash API 自动发现代理端口(mixed-port)
|
|
107
|
+
* 无需依赖任何配置文件,直接问 Clash 自己
|
|
108
|
+
* @returns {Promise<string>} 代理 URL,如 "http://127.0.0.1:7897"
|
|
109
|
+
*/
|
|
110
|
+
export async function resolveProxyUrl() {
|
|
111
|
+
try {
|
|
112
|
+
const configs = await getConfigs();
|
|
113
|
+
const port = configs?.["mixed-port"] || 7897;
|
|
114
|
+
return `http://127.0.0.1:${port}`;
|
|
115
|
+
} catch {
|
|
116
|
+
// Clash API 不可达,回退到默认值
|
|
117
|
+
return "http://127.0.0.1:7897";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
104
121
|
export async function patchConfigs(patch) {
|
|
105
122
|
return request("/configs", { method: "PATCH", body: JSON.stringify(patch) });
|
|
106
123
|
}
|
package/src/clash/checker.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 节点健康检测 —
|
|
2
|
+
* Clash 节点健康检测 — 统一模块
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1. 检测 Clash Verge
|
|
6
|
-
* 2.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
4
|
+
* 职责:
|
|
5
|
+
* 1. 检测 Clash Verge 进程/API 存活(跨平台 mac/win)
|
|
6
|
+
* 2. 自动发现代理端口(GET /configs → mixed-port)
|
|
7
|
+
* 3. 节点健康检测(curl 穿透代理请求 TikTok)
|
|
8
|
+
* 4. 国家过滤(按节点名 emoji 匹配)
|
|
9
|
+
* 5. 健康节点列表持久化 + 故障切换
|
|
10
|
+
* 6. CLI 入口硬门控预检
|
|
9
11
|
*/
|
|
12
|
+
|
|
10
13
|
import { spawnSync } from "child_process";
|
|
11
14
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
12
15
|
import { join } from "path";
|
|
13
16
|
import { tmpdir } from "os";
|
|
17
|
+
import * as api from "./api.js";
|
|
18
|
+
|
|
19
|
+
// ══════════════════════════════════════════════
|
|
20
|
+
// 常量 & 配置
|
|
21
|
+
// ══════════════════════════════════════════════
|
|
14
22
|
|
|
15
23
|
const IS_WIN = process.platform === "win32";
|
|
16
24
|
const HEALTHY_FILE = join(tmpdir(), "clash-healthy-nodes.json");
|
|
@@ -22,145 +30,261 @@ export const CONFIG = {
|
|
|
22
30
|
groupName: "GLOBAL",
|
|
23
31
|
curlTimeout: 12,
|
|
24
32
|
minPageSize: 50 * 1024,
|
|
33
|
+
nodeCountryFilter: null,
|
|
25
34
|
};
|
|
26
35
|
|
|
27
|
-
|
|
36
|
+
const CURL_UA =
|
|
37
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
|
|
38
|
+
|
|
39
|
+
const COUNTRY_PATTERNS = {
|
|
40
|
+
US: /[🇺🇸]\s*(美国|US|USA|United\s*States)/i,
|
|
41
|
+
GB: /[🇬🇧]\s*(英国|UK|GB|United\s*Kingdom)/i,
|
|
42
|
+
DE: /[🇩🇪]\s*(德国|DE|Germany)/i,
|
|
43
|
+
FR: /[🇫🇷]\s*(法国|FR|France)/i,
|
|
44
|
+
JP: /[🇯🇵]\s*(日本|JP|Japan)/i,
|
|
45
|
+
KR: /[🇰🇷]\s*(韩国|KR|Korea)/i,
|
|
46
|
+
SG: /[🇸🇬]\s*(新加坡|SG|Singapore)/i,
|
|
47
|
+
HK: /[🇭🇰]\s*(香港|HK|Hong\s*Kong)/i,
|
|
48
|
+
TW: /[🇹🇼]\s*(台湾|TW|Taiwan)/i,
|
|
49
|
+
RU: /[🇷🇺]\s*(俄罗斯|RU|Russia)/i,
|
|
50
|
+
NL: /[🇳🇱]\s*(荷兰|NL|Netherlands)/i,
|
|
51
|
+
IN: /[🇮🇳]\s*(印度|IN|India)/i,
|
|
52
|
+
CA: /[🇨🇦]\s*(加拿大|CA|Canada)/i,
|
|
53
|
+
AU: /[🇦🇺]\s*(澳大利亚|AU|Australia)/i,
|
|
54
|
+
MY: /[🇲🇾]\s*(马来西亚|MY|Malaysia)/i,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let _proxyResolved = false;
|
|
58
|
+
|
|
59
|
+
// ══════════════════════════════════════════════
|
|
60
|
+
// 1. 代理端口自动发现
|
|
61
|
+
// ══════════════════════════════════════════════
|
|
62
|
+
|
|
63
|
+
export async function resolveAndSetProxyUrl() {
|
|
64
|
+
if (_proxyResolved) return CONFIG.proxyUrl;
|
|
65
|
+
try { CONFIG.proxyUrl = await api.resolveProxyUrl(); } catch {}
|
|
66
|
+
_proxyResolved = true;
|
|
67
|
+
return CONFIG.proxyUrl;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resetProxyUrlCache() { _proxyResolved = false; }
|
|
71
|
+
|
|
72
|
+
// ══════════════════════════════════════════════
|
|
73
|
+
// 2. 进程 / API 可达性检测
|
|
74
|
+
// ══════════════════════════════════════════════
|
|
28
75
|
|
|
29
76
|
export function checkClashAlive() {
|
|
77
|
+
return checkClashAliveWithReason().alive;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function checkClashAliveWithReason() {
|
|
81
|
+
if (_detectProcess()) return { alive: true, reason: "verge-mihomo 进程已运行" };
|
|
82
|
+
if (_detectApi()) return { alive: true, reason: `API 端口 ${CONFIG.apiPort} 可达` };
|
|
83
|
+
return {
|
|
84
|
+
alive: false,
|
|
85
|
+
reason: `Clash Verge 未运行,或 API 端口 ${CONFIG.apiPort} 未开启(请在 Clash Verge 设置中开启"允许局域网连接"或"RESTful API")`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _detectProcess() {
|
|
30
90
|
try {
|
|
31
91
|
if (IS_WIN) {
|
|
32
|
-
const r = spawnSync(
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
{
|
|
36
|
-
encoding: "utf-8",
|
|
37
|
-
timeout: 5000,
|
|
38
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
39
|
-
},
|
|
40
|
-
);
|
|
41
|
-
if (r.stdout?.includes("verge-mihomo.exe")) return true;
|
|
42
|
-
} else {
|
|
43
|
-
const r = spawnSync("pgrep", ["-x", "verge-mihomo"], {
|
|
44
|
-
encoding: "utf-8",
|
|
45
|
-
timeout: 5000,
|
|
46
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
47
|
-
});
|
|
48
|
-
if (r.status === 0 && r.stdout.trim()) return true;
|
|
92
|
+
const r = spawnSync("tasklist", ["/FI", "IMAGENAME eq verge-mihomo.exe", "/NH"],
|
|
93
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
|
|
94
|
+
return r.stdout?.includes("verge-mihomo.exe");
|
|
49
95
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
96
|
+
const r = spawnSync("pgrep", ["-x", "verge-mihomo"],
|
|
97
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
|
|
98
|
+
return r.status === 0 && !!r.stdout.trim();
|
|
99
|
+
} catch { return false; }
|
|
100
|
+
}
|
|
53
101
|
|
|
102
|
+
function _detectApi() {
|
|
54
103
|
try {
|
|
55
104
|
const exe = IS_WIN ? "curl.exe" : "curl";
|
|
56
|
-
const r = spawnSync(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
{
|
|
60
|
-
encoding: "utf-8",
|
|
61
|
-
timeout: 5000,
|
|
62
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
63
|
-
},
|
|
64
|
-
);
|
|
105
|
+
const r = spawnSync(exe, ["-s", "--max-time", "3",
|
|
106
|
+
`http://127.0.0.1:${CONFIG.apiPort}/version`],
|
|
107
|
+
{ encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
|
|
65
108
|
return r.stdout.includes('"version"');
|
|
66
|
-
} catch {
|
|
67
|
-
|
|
109
|
+
} catch { return false; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function requireClashOrExit(label = "") {
|
|
113
|
+
const { alive, reason } = checkClashAliveWithReason();
|
|
114
|
+
if (!alive) {
|
|
115
|
+
const pf = label ? `[${label}] ` : "";
|
|
116
|
+
console.error(`\n${pf}⚠ 代理不可用:${reason}`);
|
|
117
|
+
console.error(`${pf}请先启动 Clash Verge 并确保:`);
|
|
118
|
+
console.error(` 1. Clash Verge 正在运行(verge-mihomo 进程存活)`);
|
|
119
|
+
console.error(` 2. API 端口 ${CONFIG.apiPort} 已开启(设置 → 允许局域网连接 / RESTful API)`);
|
|
120
|
+
console.error(` 3. 代理端口 ${CONFIG.proxyUrl.replace("http://", "")} 正在监听\n`);
|
|
121
|
+
process.exit(1);
|
|
68
122
|
}
|
|
123
|
+
return resolveAndSetProxyUrl();
|
|
69
124
|
}
|
|
70
125
|
|
|
71
|
-
//
|
|
126
|
+
// ══════════════════════════════════════════════
|
|
127
|
+
// 3. 节点健康检测(同步 curl)
|
|
128
|
+
// ══════════════════════════════════════════════
|
|
72
129
|
|
|
73
130
|
export function checkNodeHealth(url = CONFIG.testUrl) {
|
|
131
|
+
const { body, metaLine, error } = _curlViaProxy(url);
|
|
132
|
+
const { httpCode, timeTotal, sizeDownload } = _parseCurlMeta(metaLine);
|
|
133
|
+
const title = _extractTitle(body);
|
|
134
|
+
return {
|
|
135
|
+
healthy: _isHealthyPage(error, httpCode, sizeDownload, title, body),
|
|
136
|
+
httpCode, timeTotal, sizeDownload, title, error,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _curlViaProxy(url) {
|
|
74
141
|
const args = [
|
|
75
|
-
"-s",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
CONFIG.proxyUrl,
|
|
80
|
-
"--max-time",
|
|
81
|
-
String(CONFIG.curlTimeout),
|
|
82
|
-
"-L",
|
|
83
|
-
"-A",
|
|
84
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
85
|
-
url,
|
|
142
|
+
"-s", "-w", "\n__CURL_META__%{http_code}|%{time_total}|%{size_download}",
|
|
143
|
+
"--proxy", CONFIG.proxyUrl,
|
|
144
|
+
"--max-time", String(CONFIG.curlTimeout),
|
|
145
|
+
"-L", "-A", CURL_UA, url,
|
|
86
146
|
];
|
|
87
|
-
const
|
|
147
|
+
const r = spawnSync("curl", args, {
|
|
88
148
|
encoding: "utf-8",
|
|
89
149
|
timeout: (CONFIG.curlTimeout + 5) * 1000,
|
|
90
150
|
stdio: ["pipe", "pipe", "pipe"],
|
|
91
151
|
maxBuffer: 10 * 1024 * 1024,
|
|
92
152
|
});
|
|
153
|
+
const out = r.stdout || "";
|
|
154
|
+
const idx = out.lastIndexOf("__CURL_META__");
|
|
155
|
+
return {
|
|
156
|
+
body: idx >= 0 ? out.substring(0, idx).trimEnd() : "",
|
|
157
|
+
metaLine: idx >= 0 ? out.substring(idx + 13).trim() : "",
|
|
158
|
+
error: r.error ? r.error.message : r.status !== 0 ? `curl exit ${r.status}` : null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
93
161
|
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (idx >= 0) {
|
|
99
|
-
body = stdout.substring(0, idx).trimEnd();
|
|
100
|
-
metaLine = stdout.substring(idx + 13).trim();
|
|
101
|
-
}
|
|
102
|
-
const parts = metaLine.split("|");
|
|
103
|
-
const httpCode = parseInt(parts[0]) || 0;
|
|
104
|
-
const timeTotal = parseFloat(parts[1]) || 0;
|
|
105
|
-
const sizeDownload = parseInt(parts[2]) || 0;
|
|
162
|
+
function _parseCurlMeta(line) {
|
|
163
|
+
const p = line.split("|");
|
|
164
|
+
return { httpCode: parseInt(p[0]) || 0, timeTotal: parseFloat(p[1]) || 0, sizeDownload: parseInt(p[2]) || 0 };
|
|
165
|
+
}
|
|
106
166
|
|
|
107
|
-
|
|
167
|
+
function _extractTitle(body) {
|
|
108
168
|
const m = body.match(/<title>([^<]*)<\/title>/i);
|
|
109
|
-
|
|
169
|
+
return m ? m[1].trim() : "";
|
|
170
|
+
}
|
|
110
171
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
172
|
+
function _isHealthyPage(error, httpCode, size, title, body) {
|
|
173
|
+
return !error && httpCode === 200 && size >= CONFIG.minPageSize &&
|
|
174
|
+
title.includes("TikTok") && !title.startsWith("About |") &&
|
|
175
|
+
!body.includes("SlardarWAF") && !body.includes("_wafchallengeid");
|
|
176
|
+
}
|
|
114
177
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
sizeDownload >= CONFIG.minPageSize &&
|
|
119
|
-
title.includes("TikTok") &&
|
|
120
|
-
!title.startsWith("About |") &&
|
|
121
|
-
!body.includes("SlardarWAF") &&
|
|
122
|
-
!body.includes("_wafchallengeid");
|
|
178
|
+
// ══════════════════════════════════════════════
|
|
179
|
+
// 4. 国家过滤
|
|
180
|
+
// ══════════════════════════════════════════════
|
|
123
181
|
|
|
124
|
-
|
|
182
|
+
export function filterNodesByCountry(nodes, country) {
|
|
183
|
+
if (!country || !COUNTRY_PATTERNS[country]) return { filtered: nodes, excluded: 0 };
|
|
184
|
+
const filtered = nodes.filter(n => COUNTRY_PATTERNS[country].test(n));
|
|
185
|
+
return { filtered, excluded: nodes.length - filtered.length };
|
|
125
186
|
}
|
|
126
187
|
|
|
127
|
-
|
|
188
|
+
export function getSupportedCountries() {
|
|
189
|
+
return Object.keys(COUNTRY_PATTERNS);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ══════════════════════════════════════════════
|
|
193
|
+
// 5. 健康节点列表持久化
|
|
194
|
+
// ══════════════════════════════════════════════
|
|
128
195
|
|
|
129
|
-
export function loadHealthyList() {
|
|
196
|
+
export function loadHealthyList(country = null) {
|
|
130
197
|
try {
|
|
131
|
-
if (existsSync(HEALTHY_FILE))
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
198
|
+
if (!existsSync(HEALTHY_FILE)) return _emptyList();
|
|
199
|
+
const d = JSON.parse(readFileSync(HEALTHY_FILE, "utf-8"));
|
|
200
|
+
const allNodes = Array.isArray(d.nodes) ? d.nodes : [];
|
|
201
|
+
const filter = country || CONFIG.nodeCountryFilter;
|
|
202
|
+
const { filtered, excluded } = filterNodesByCountry(allNodes, filter);
|
|
203
|
+
return { updated: d.updated || "", nodes: filtered, totalNodes: allNodes.length, excluded, countryFilter: filter || null };
|
|
138
204
|
} catch (e) {
|
|
139
205
|
console.error(`[checker] 读取健康列表失败: ${e.message}`);
|
|
140
206
|
}
|
|
141
|
-
return
|
|
207
|
+
return _emptyList();
|
|
142
208
|
}
|
|
143
209
|
|
|
144
210
|
export function saveHealthyList(nodes) {
|
|
145
211
|
try {
|
|
146
|
-
writeFileSync(
|
|
147
|
-
|
|
148
|
-
JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2),
|
|
149
|
-
"utf-8",
|
|
150
|
-
);
|
|
212
|
+
writeFileSync(HEALTHY_FILE,
|
|
213
|
+
JSON.stringify({ updated: new Date().toISOString(), nodes }, null, 2), "utf-8");
|
|
151
214
|
} catch (e) {
|
|
152
215
|
console.error(`[checker] 保存健康列表失败: ${e.message}`);
|
|
153
216
|
}
|
|
154
217
|
}
|
|
155
218
|
|
|
156
|
-
|
|
219
|
+
function _emptyList() {
|
|
220
|
+
return { updated: "", nodes: [], totalNodes: 0, excluded: 0, countryFilter: null };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ══════════════════════════════════════════════
|
|
224
|
+
// 6. 故障切换
|
|
225
|
+
// ══════════════════════════════════════════════
|
|
157
226
|
|
|
158
227
|
export function pickFailoverNode(currentNode, healthyNodes) {
|
|
159
228
|
if (!healthyNodes?.length) return null;
|
|
160
|
-
const candidates = healthyNodes.filter(
|
|
229
|
+
const candidates = healthyNodes.filter(n => n !== currentNode);
|
|
161
230
|
if (!candidates.length) {
|
|
162
231
|
console.error(`[checker] 无可用候选节点(共${healthyNodes.length}个)`);
|
|
163
232
|
return null;
|
|
164
233
|
}
|
|
165
234
|
return candidates[Math.floor(Math.random() * candidates.length)];
|
|
166
235
|
}
|
|
236
|
+
|
|
237
|
+
export async function switchToNode(nodeName) {
|
|
238
|
+
try {
|
|
239
|
+
await api.switchProxy(CONFIG.groupName, nodeName);
|
|
240
|
+
await new Promise(r => setTimeout(r, 600));
|
|
241
|
+
const current = await api.getCurrentNode(CONFIG.groupName);
|
|
242
|
+
if (current !== nodeName) {
|
|
243
|
+
console.error(`[checker] 切换验证失败: 期望 ${nodeName}, 实际 ${current}`);
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
} catch (e) {
|
|
248
|
+
console.error(`[checker] 切换节点失败: ${e.message}`);
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ══════════════════════════════════════════════
|
|
254
|
+
// 7. 一站式巡检
|
|
255
|
+
// ══════════════════════════════════════════════
|
|
256
|
+
|
|
257
|
+
export async function runHealthCheck() {
|
|
258
|
+
if (!checkClashAlive()) return { action: "skipped", detail: "Clash Verge 未运行" };
|
|
259
|
+
await resolveAndSetProxyUrl();
|
|
260
|
+
|
|
261
|
+
const health = checkNodeHealth();
|
|
262
|
+
if (health.healthy) {
|
|
263
|
+
return { action: "ok", detail: `${health.timeTotal.toFixed(1)}s ${(health.sizeDownload / 1024).toFixed(0)}KB` };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const reason = health.error
|
|
267
|
+
? `错误: ${health.error}`
|
|
268
|
+
: `HTTP ${health.httpCode} ${(health.sizeDownload / 1024).toFixed(0)}KB "${health.title}"`;
|
|
269
|
+
|
|
270
|
+
let currentNode;
|
|
271
|
+
try { currentNode = await api.getCurrentNode(CONFIG.groupName); }
|
|
272
|
+
catch (e) { return { action: "failed", detail: `无法获取当前节点: ${e.message}` }; }
|
|
273
|
+
|
|
274
|
+
const { nodes } = loadHealthyList();
|
|
275
|
+
if (!nodes.length) return { action: "failed", detail: `${reason} — 健康列表为空` };
|
|
276
|
+
|
|
277
|
+
const target = pickFailoverNode(currentNode, nodes);
|
|
278
|
+
if (!target) return { action: "failed", detail: `${reason} — 无可用候选节点` };
|
|
279
|
+
|
|
280
|
+
if (!(await switchToNode(target))) {
|
|
281
|
+
return { action: "failed", detail: `${reason} → 切换到 ${target} 失败` };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const newHealth = checkNodeHealth();
|
|
285
|
+
const detail = newHealth.healthy
|
|
286
|
+
? `${currentNode} → ${target} (${newHealth.timeTotal.toFixed(1)}s ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`
|
|
287
|
+
: `${currentNode} → ${target} (切换后仍异常: ${newHealth.httpCode} ${(newHealth.sizeDownload / 1024).toFixed(0)}KB)`;
|
|
288
|
+
|
|
289
|
+
return { action: "switched", detail };
|
|
290
|
+
}
|