@morndream/mihomo-tui 0.5.0-rc.0

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.
Files changed (145) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +21 -0
  3. package/README.md +462 -0
  4. package/bin/mihomo-tui +34 -0
  5. package/dist/App.js +161 -0
  6. package/dist/App.js.map +1 -0
  7. package/dist/api/client.js +239 -0
  8. package/dist/api/client.js.map +1 -0
  9. package/dist/api/status.js +43 -0
  10. package/dist/api/status.js.map +1 -0
  11. package/dist/api/stream.js +139 -0
  12. package/dist/api/stream.js.map +1 -0
  13. package/dist/api/types.js +6 -0
  14. package/dist/api/types.js.map +1 -0
  15. package/dist/cli.js +221 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/commands/conn.js +122 -0
  18. package/dist/commands/conn.js.map +1 -0
  19. package/dist/commands/logs.js +99 -0
  20. package/dist/commands/logs.js.map +1 -0
  21. package/dist/commands/output.js +177 -0
  22. package/dist/commands/output.js.map +1 -0
  23. package/dist/commands/provider.js +140 -0
  24. package/dist/commands/provider.js.map +1 -0
  25. package/dist/commands/proxy.js +161 -0
  26. package/dist/commands/proxy.js.map +1 -0
  27. package/dist/commands/proxyEnv.js +193 -0
  28. package/dist/commands/proxyEnv.js.map +1 -0
  29. package/dist/commands/rules.js +87 -0
  30. package/dist/commands/rules.js.map +1 -0
  31. package/dist/commands/status.js +75 -0
  32. package/dist/commands/status.js.map +1 -0
  33. package/dist/components/ConfirmDialog.js +31 -0
  34. package/dist/components/ConfirmDialog.js.map +1 -0
  35. package/dist/components/DelayBadge.js +27 -0
  36. package/dist/components/DelayBadge.js.map +1 -0
  37. package/dist/components/InputDialog.js +325 -0
  38. package/dist/components/InputDialog.js.map +1 -0
  39. package/dist/components/ListDialog.js +86 -0
  40. package/dist/components/ListDialog.js.map +1 -0
  41. package/dist/components/ProgressDialog.js +34 -0
  42. package/dist/components/ProgressDialog.js.map +1 -0
  43. package/dist/components/ScrollList.js +26 -0
  44. package/dist/components/ScrollList.js.map +1 -0
  45. package/dist/components/StatusBar.js +58 -0
  46. package/dist/components/StatusBar.js.map +1 -0
  47. package/dist/components/TextEditor.js +112 -0
  48. package/dist/components/TextEditor.js.map +1 -0
  49. package/dist/components/textEditorModel.js +154 -0
  50. package/dist/components/textEditorModel.js.map +1 -0
  51. package/dist/config/manager.js +296 -0
  52. package/dist/config/manager.js.map +1 -0
  53. package/dist/config/preserveGroups.js +9 -0
  54. package/dist/config/preserveGroups.js.map +1 -0
  55. package/dist/config/ruleDocument.js +88 -0
  56. package/dist/config/ruleDocument.js.map +1 -0
  57. package/dist/config/ruleService.js +256 -0
  58. package/dist/config/ruleService.js.map +1 -0
  59. package/dist/config/ruleValidation.js +98 -0
  60. package/dist/config/ruleValidation.js.map +1 -0
  61. package/dist/config/service.js +63 -0
  62. package/dist/config/service.js.map +1 -0
  63. package/dist/config/settingsService.js +162 -0
  64. package/dist/config/settingsService.js.map +1 -0
  65. package/dist/config/skeleton.js +324 -0
  66. package/dist/config/skeleton.js.map +1 -0
  67. package/dist/config/subscriptionService.js +284 -0
  68. package/dist/config/subscriptionService.js.map +1 -0
  69. package/dist/config/subscriptions.js +200 -0
  70. package/dist/config/subscriptions.js.map +1 -0
  71. package/dist/config/types.js +5 -0
  72. package/dist/config/types.js.map +1 -0
  73. package/dist/config.js +101 -0
  74. package/dist/config.js.map +1 -0
  75. package/dist/hooks/useProviders.js +138 -0
  76. package/dist/hooks/useProviders.js.map +1 -0
  77. package/dist/hooks/useProxies.js +298 -0
  78. package/dist/hooks/useProxies.js.map +1 -0
  79. package/dist/hooks/useStream.js +127 -0
  80. package/dist/hooks/useStream.js.map +1 -0
  81. package/dist/kernel/installer.js +317 -0
  82. package/dist/kernel/installer.js.map +1 -0
  83. package/dist/kernel/releases.js +111 -0
  84. package/dist/kernel/releases.js.map +1 -0
  85. package/dist/kernel/sources.js +98 -0
  86. package/dist/kernel/sources.js.map +1 -0
  87. package/dist/rules/editor.js +181 -0
  88. package/dist/rules/editor.js.map +1 -0
  89. package/dist/rules/matcher.js +83 -0
  90. package/dist/rules/matcher.js.map +1 -0
  91. package/dist/rules/model.js +25 -0
  92. package/dist/rules/model.js.map +1 -0
  93. package/dist/rules/runtime.js +46 -0
  94. package/dist/rules/runtime.js.map +1 -0
  95. package/dist/rules/types.js +10 -0
  96. package/dist/rules/types.js.map +1 -0
  97. package/dist/ui/FooterLine.js +62 -0
  98. package/dist/ui/FooterLine.js.map +1 -0
  99. package/dist/ui/Panel.js +47 -0
  100. package/dist/ui/Panel.js.map +1 -0
  101. package/dist/ui/RowIndicator.js +25 -0
  102. package/dist/ui/RowIndicator.js.map +1 -0
  103. package/dist/ui/SectionHeader.js +14 -0
  104. package/dist/ui/SectionHeader.js.map +1 -0
  105. package/dist/ui/StatusDot.js +15 -0
  106. package/dist/ui/StatusDot.js.map +1 -0
  107. package/dist/ui/TopBar.js +81 -0
  108. package/dist/ui/TopBar.js.map +1 -0
  109. package/dist/ui/exitGuard.js +20 -0
  110. package/dist/ui/exitGuard.js.map +1 -0
  111. package/dist/ui/keyCapture.js +28 -0
  112. package/dist/ui/keyCapture.js.map +1 -0
  113. package/dist/ui/layout.js +32 -0
  114. package/dist/ui/layout.js.map +1 -0
  115. package/dist/ui/rowIndicator.js +42 -0
  116. package/dist/ui/rowIndicator.js.map +1 -0
  117. package/dist/ui/tableHeader.js +33 -0
  118. package/dist/ui/tableHeader.js.map +1 -0
  119. package/dist/ui/theme.js +47 -0
  120. package/dist/ui/theme.js.map +1 -0
  121. package/dist/ui/tone.js +131 -0
  122. package/dist/ui/tone.js.map +1 -0
  123. package/dist/version.js +27 -0
  124. package/dist/version.js.map +1 -0
  125. package/dist/views/Conns.js +107 -0
  126. package/dist/views/Conns.js.map +1 -0
  127. package/dist/views/Logs.js +89 -0
  128. package/dist/views/Logs.js.map +1 -0
  129. package/dist/views/Providers.js +392 -0
  130. package/dist/views/Providers.js.map +1 -0
  131. package/dist/views/Proxies.js +233 -0
  132. package/dist/views/Proxies.js.map +1 -0
  133. package/dist/views/RuleEditor.js +356 -0
  134. package/dist/views/RuleEditor.js.map +1 -0
  135. package/dist/views/Rules.js +286 -0
  136. package/dist/views/Rules.js.map +1 -0
  137. package/dist/views/Settings.js +734 -0
  138. package/dist/views/Settings.js.map +1 -0
  139. package/dist/views/groupDisplay.js +53 -0
  140. package/dist/views/groupDisplay.js.map +1 -0
  141. package/dist/views/subscriptionFields.js +59 -0
  142. package/dist/views/subscriptionFields.js.map +1 -0
  143. package/dist/views/yamlFile.js +32 -0
  144. package/dist/views/yamlFile.js.map +1 -0
  145. package/package.json +66 -0
@@ -0,0 +1,734 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * 标签页 6:设置(spec 2026-09-13 settings-kernel §3.1)。
4
+ *
5
+ * 基础设置:显示值一律读 config.yaml(「下次重启将生效」语义);每次修改独立
6
+ * 走「写配置 → 重启服务」事务(settingsService):编辑对话框 → ConfirmDialog
7
+ * 明示 旧值 → 新值 → 进度 → 结果。
8
+ *
9
+ * mihomo 内核区块:当前版本复用启动时 GET /version 的结果;进入本页自动检查
10
+ * 更新(镜像回退,结果缓存);切换版本走 installer 四段式(下载→校验→替换→
11
+ * 重启确认,失败自动回滚);下载源存 TUI 自身 config.json,与内核无关。
12
+ *
13
+ * 光标只在可操作行间移动,只读行(外部控制/当前版本/可用更新)dim 且跳过。
14
+ */
15
+ import { existsSync } from 'node:fs';
16
+ import { homedir } from 'node:os';
17
+ import { useEffect, useRef, useState } from 'react';
18
+ import { Box, Text, useInput } from 'ink';
19
+ import { ConfigManager } from '../config/manager.js';
20
+ import { LOG_LEVELS, SETTINGS_STEPS, SettingsError, applySettings, settingsDepsFromAppConfig, } from '../config/settingsService.js';
21
+ import { ServiceManager } from '../config/service.js';
22
+ import { loadConfig, saveConfig, CONFIG_PATH } from '../config.js';
23
+ import { KERNELS_DIR_DEFAULT, InstallError, archiveKeyFor, archivedBinPath, installKernel, listArchivedTags, } from '../kernel/installer.js';
24
+ import { fetchAlphaRelease, isNewerVersion, listStableReleases, selectAlphaAsset, selectAsset, } from '../kernel/releases.js';
25
+ import { MihomoClient } from '../api/client.js';
26
+ import { ConfirmDialog } from '../components/ConfirmDialog.js';
27
+ import { InputDialog } from '../components/InputDialog.js';
28
+ import { ListDialog } from '../components/ListDialog.js';
29
+ import { ProgressDialog } from '../components/ProgressDialog.js';
30
+ import { Panel } from '../ui/Panel.js';
31
+ import { FooterLine } from '../ui/FooterLine.js';
32
+ import { colors, styles } from '../ui/theme.js';
33
+ import { displayWidth, padDisplay, truncateDisplay } from '../commands/output.js';
34
+ const ROWS = [
35
+ {
36
+ key: 'port',
37
+ label: '混合端口',
38
+ editable: true,
39
+ section: '基础设置',
40
+ description: 'HTTP/SOCKS 混合代理监听端口',
41
+ constraint: '1024 – 65535',
42
+ },
43
+ {
44
+ key: 'allowLan',
45
+ label: '允许局域网',
46
+ editable: true,
47
+ section: '基础设置',
48
+ description: '允许局域网内其他设备经本机代理',
49
+ constraint: '开 / 关',
50
+ },
51
+ {
52
+ key: 'ipv6',
53
+ label: 'IPv6',
54
+ editable: true,
55
+ section: '基础设置',
56
+ description: '是否启用 IPv6 解析与出站',
57
+ constraint: '开 / 关',
58
+ },
59
+ {
60
+ key: 'unifiedDelay',
61
+ label: '统一延迟',
62
+ editable: true,
63
+ section: '基础设置',
64
+ description: '测速时去除节点固定延迟差,便于横向比较',
65
+ constraint: '开 / 关',
66
+ },
67
+ {
68
+ key: 'tcpConcurrent',
69
+ label: 'TCP 并发',
70
+ editable: true,
71
+ section: '基础设置',
72
+ description: '建立连接时并发探测多个地址,取最快者',
73
+ constraint: '开 / 关',
74
+ },
75
+ {
76
+ key: 'logLevel',
77
+ label: '日志级别',
78
+ editable: true,
79
+ section: '基础设置',
80
+ description: '内核日志输出级别,对应日志页可过滤',
81
+ constraint: LOG_LEVELS.join(' / '),
82
+ },
83
+ {
84
+ key: 'dns',
85
+ label: 'DNS 接管',
86
+ editable: true,
87
+ section: '基础设置',
88
+ description: '由内核接管系统 DNS 解析(fake-ip / 防污染)',
89
+ constraint: '开 / 关',
90
+ },
91
+ {
92
+ key: 'controller',
93
+ label: '外部控制',
94
+ editable: false,
95
+ section: '基础设置',
96
+ description: 'External Controller 地址,本工具经它读写内核',
97
+ },
98
+ {
99
+ key: 'version',
100
+ label: '当前版本',
101
+ editable: false,
102
+ section: 'mihomo 内核',
103
+ description: '正在运行的 mihomo 内核版本(只读)',
104
+ },
105
+ {
106
+ key: 'update',
107
+ label: '可用更新',
108
+ editable: true,
109
+ section: 'mihomo 内核',
110
+ description: '检查是否有更新的稳定版内核,Enter 重新检查',
111
+ impact: '仅联网查询,不修改系统',
112
+ },
113
+ {
114
+ key: 'switch',
115
+ label: '切换版本',
116
+ editable: true,
117
+ section: 'mihomo 内核',
118
+ description: '下载并切换到指定内核版本,失败自动回滚',
119
+ impact: '下载 → 校验 → 替换二进制 → 重启服务',
120
+ },
121
+ {
122
+ key: 'source',
123
+ label: '下载源',
124
+ editable: true,
125
+ section: 'mihomo 内核',
126
+ description: '内核与 release 信息的下载镜像源',
127
+ impact: '仅影响后续下载,不重启服务',
128
+ },
129
+ ];
130
+ const ACTIONABLE = ROWS.filter((row) => row.editable).map((row) => row.key);
131
+ /** 开关行:从显示值取当前态 + 构造对应变更 */
132
+ const TOGGLES = {
133
+ allowLan: { get: (values) => values.allowLan, build: (next) => ({ 'allow-lan': next }) },
134
+ ipv6: { get: (values) => values.ipv6, build: (next) => ({ ipv6: next }) },
135
+ unifiedDelay: { get: (values) => values.unifiedDelay, build: (next) => ({ 'unified-delay': next }) },
136
+ tcpConcurrent: { get: (values) => values.tcpConcurrent, build: (next) => ({ 'tcp-concurrent': next }) },
137
+ dns: { get: (values) => values.dnsEnable, build: (next) => ({ dnsEnable: next }) },
138
+ };
139
+ /** 确认文案的影响行(所有基础设置修改共用) */
140
+ const IMPACT = '将写入 config.yaml 并重启服务,活动连接会瞬断数秒';
141
+ /** 右栏配置行标签列宽(当前值/说明/取值/影响,CJK 各 6 显示列,+1 空格 = 7) */
142
+ const PANEL_LABEL_COL = 7;
143
+ /**
144
+ * 把一项设置收敛为右栏配置行列表(纯函数,可测)。
145
+ * 行序固定:当前值 → 说明 → 取值 → 影响;缺省字段省略。
146
+ * 可操作项(editable)所有行都可点改;只读项各行 editable=false。
147
+ * 影响段:行定制 impact 优先,基础设置可编辑项回退 IMPACT,只读项无影响行。
148
+ */
149
+ export function panelLines(row, value) {
150
+ const lines = [];
151
+ // 当前值行恒存在
152
+ lines.push({ label: '当前值', text: value.text, color: value.color, editable: row.editable });
153
+ if (row.description) {
154
+ lines.push({ label: '说明', text: row.description, color: colors.muted, italic: true, editable: row.editable });
155
+ }
156
+ if (row.constraint) {
157
+ lines.push({ label: '取值', text: row.constraint, color: colors.muted, editable: row.editable });
158
+ }
159
+ const impact = row.impact ?? (row.editable && row.section === '基础设置' ? IMPACT : undefined);
160
+ if (impact) {
161
+ lines.push({ label: '影响', text: impact, color: colors.muted, editable: row.editable });
162
+ }
163
+ return lines;
164
+ }
165
+ /** 标签列宽:取全部标签的最大显示宽 + 2(允许局域网 10 列 → 12) */
166
+ const LABEL_COL = Math.max(...ROWS.map((row) => displayWidth(row.label))) + 2;
167
+ /** 内核安装的进度步骤(与 InstallPhase 一一对应) */
168
+ const INSTALL_STEPS = ['下载内核', '校验', '替换二进制', '重启服务', '确认状态'];
169
+ const PHASE_STEP = {
170
+ download: '下载内核',
171
+ verify: '校验',
172
+ replace: '替换二进制',
173
+ restart: '重启服务',
174
+ confirm: '确认状态',
175
+ };
176
+ function isRecord(value) {
177
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
178
+ }
179
+ function errText(err) {
180
+ return err instanceof Error ? err.message : String(err);
181
+ }
182
+ const boolText = (value) => (value ? '开' : '关');
183
+ /** 绝对路径缩略显示($HOME → ~) */
184
+ function prettyPath(path) {
185
+ const home = homedir();
186
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
187
+ }
188
+ function mb(bytes) {
189
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
190
+ }
191
+ function downloadDetail(progress) {
192
+ if (progress.phase !== 'download' || progress.received === undefined)
193
+ return undefined;
194
+ return progress.total
195
+ ? `${mb(progress.received)} / ${mb(progress.total)}(${Math.min(100, Math.round((progress.received / progress.total) * 100))}%)`
196
+ : `已下载 ${mb(progress.received)}`;
197
+ }
198
+ /** 从已解析的 config.yaml 收敛设置页显示值(与骨架同款兜底语义) */
199
+ export function parseSettingsValues(parsed) {
200
+ const dns = isRecord(parsed['dns']) ? parsed['dns'] : undefined;
201
+ return {
202
+ port: typeof parsed['mixed-port'] === 'number' ? parsed['mixed-port'] : undefined,
203
+ allowLan: parsed['allow-lan'] === true,
204
+ ipv6: parsed['ipv6'] !== false,
205
+ unifiedDelay: parsed['unified-delay'] !== false,
206
+ tcpConcurrent: parsed['tcp-concurrent'] !== false,
207
+ logLevel: typeof parsed['log-level'] === 'string' ? parsed['log-level'] : 'info',
208
+ dnsEnable: dns ? dns['enable'] !== false : true,
209
+ controller: typeof parsed['external-controller'] === 'string' ? parsed['external-controller'] : '',
210
+ };
211
+ }
212
+ // 模块级缓存:SettingsView 随 tab 切换条件卸载,缓存放模块级才能做到
213
+ // 「结果缓存不随 tab 切换重查」(spec §3.1)
214
+ let moduleReleases;
215
+ let moduleUpdate;
216
+ /** 测试用:清空模块级更新缓存(测试间隔离) */
217
+ export function resetSettingsCacheForTests() {
218
+ moduleReleases = undefined;
219
+ moduleUpdate = undefined;
220
+ }
221
+ export function SettingsView({ config, version, height, width, active, onMessage, serviceDeps, configPath = CONFIG_PATH, kernelsDir = KERNELS_DIR_DEFAULT, }) {
222
+ const [values, setValues] = useState(undefined);
223
+ const [loadError, setLoadError] = useState(undefined);
224
+ const [dialog, setDialog] = useState(undefined);
225
+ const [cursor, setCursor] = useState(0);
226
+ // 左右栏焦点 + 右栏配置行光标(复用节点页双栏焦点模型)
227
+ const [focus, setFocus] = useState('nav');
228
+ const [panelCursor, setPanelCursor] = useState(0);
229
+ const [update, setUpdateRaw] = useState(moduleUpdate ?? { status: 'idle' });
230
+ // 归一化:config 可能来自未含 downloadSource 的旧配置(缺省 auto)
231
+ const [source, setSource] = useState(config.downloadSource ?? { mode: 'auto' });
232
+ // release 列表缓存:更新检查与版本对话框共用,避免重复请求
233
+ const [releases, setReleasesRaw] = useState(moduleReleases);
234
+ // 写 state 的同时落模块级缓存(组件随 tab 切换卸载,缓存存活于模块)
235
+ const setUpdate = (next) => {
236
+ moduleUpdate = next;
237
+ setUpdateRaw(next);
238
+ };
239
+ const setReleases = (list) => {
240
+ moduleReleases = list;
241
+ setReleasesRaw(list);
242
+ };
243
+ const reload = () => {
244
+ try {
245
+ const parsed = new ConfigManager(config.mihomoDir, config.mihomoBin).loadConfig();
246
+ setValues(parseSettingsValues(parsed));
247
+ setLoadError(undefined);
248
+ }
249
+ catch (err) {
250
+ setLoadError(errText(err));
251
+ }
252
+ };
253
+ // 每次进入本页重读 config.yaml(覆盖本页外部的修改,如订阅事务的重生成)
254
+ useEffect(() => {
255
+ if (active)
256
+ reload();
257
+ // eslint-disable-next-line react-hooks/exhaustive-deps
258
+ }, [active]);
259
+ const sourceRef = useRef(source);
260
+ sourceRef.current = source;
261
+ const releasesRef = useRef(releases);
262
+ releasesRef.current = releases;
263
+ /** 更新检查:稳定版最新一版与当前比较;结果缓存,不随 tab 切换重查 */
264
+ const checkUpdate = async (force = false) => {
265
+ if (releasesRef.current?.length && !force) {
266
+ applyUpdateResult(releasesRef.current);
267
+ return;
268
+ }
269
+ setUpdate({ status: 'checking' });
270
+ try {
271
+ const list = await listStableReleases({ sourceConfig: sourceRef.current });
272
+ setReleases(list);
273
+ applyUpdateResult(list);
274
+ }
275
+ catch (err) {
276
+ setUpdate({ status: 'error', error: errText(err) });
277
+ }
278
+ };
279
+ const applyUpdateResult = (list) => {
280
+ const latest = list[0]?.tag;
281
+ if (!latest) {
282
+ setUpdate({ status: 'error', error: 'release 列表为空' });
283
+ return;
284
+ }
285
+ setUpdate(version && !isNewerVersion(latest, version)
286
+ ? { status: 'latest' }
287
+ : { status: 'ok', latestTag: latest });
288
+ };
289
+ // 进入本页触发一次性更新检查(只读 GET,失败不打扰)
290
+ useEffect(() => {
291
+ if (active && update.status === 'idle')
292
+ void checkUpdate();
293
+ // eslint-disable-next-line react-hooks/exhaustive-deps
294
+ }, [active]);
295
+ const moveCursor = (step) => {
296
+ setCursor((current) => (current + step + ACTIONABLE.length) % ACTIONABLE.length);
297
+ // 左栏切项 → 右栏详情切换,面板光标归零到首行
298
+ setPanelCursor(0);
299
+ };
300
+ const applyNow = (changes) => {
301
+ setDialog({ type: 'progress', stepIndex: 0, current: SETTINGS_STEPS[0].label, completed: [] });
302
+ applySettings(changes, {
303
+ ...(serviceDeps ?? settingsDepsFromAppConfig(config)),
304
+ onProgress: (current, completed) => {
305
+ setDialog({
306
+ type: 'progress',
307
+ stepIndex: SETTINGS_STEPS.indexOf(current),
308
+ current: current.label,
309
+ completed: completed.map((step) => step.label),
310
+ });
311
+ },
312
+ })
313
+ .then(() => {
314
+ setDialog(undefined);
315
+ reload();
316
+ onMessage('设置已应用,服务已重启');
317
+ })
318
+ .catch((err) => {
319
+ showError(err);
320
+ });
321
+ };
322
+ const showError = (err) => {
323
+ const lines = [errText(err)];
324
+ const detail = err instanceof SettingsError || err instanceof InstallError ? err.detail : undefined;
325
+ if (detail) {
326
+ const inner = Math.max(20, width - 6);
327
+ lines.push(...detail
328
+ .split('\n')
329
+ .filter((line) => line.trim() !== '')
330
+ .map((line) => truncateDisplay(line, inner)));
331
+ }
332
+ setDialog({ type: 'error', lines });
333
+ };
334
+ const confirmFor = (key) => {
335
+ if (!values)
336
+ return;
337
+ const row = ROWS.find((candidate) => candidate.key === key);
338
+ if (!row)
339
+ return;
340
+ if (key === 'port') {
341
+ setDialog({ type: 'port' });
342
+ return;
343
+ }
344
+ if (key === 'logLevel') {
345
+ setDialog({ type: 'loglevel' });
346
+ return;
347
+ }
348
+ const toggle = TOGGLES[key];
349
+ if (!toggle)
350
+ return;
351
+ const current = toggle.get(values);
352
+ const next = !current;
353
+ setDialog({
354
+ type: 'confirm',
355
+ changes: toggle.build(next),
356
+ message: [`${row.label}:${boolText(current)} → ${boolText(next)}`, IMPACT],
357
+ });
358
+ };
359
+ /** 版本对话框:稳定版列表 + Alpha 最新 + 本机已下载(离线切换) */
360
+ const openVersionDialog = async () => {
361
+ setDialog({ type: 'versionLoading' });
362
+ try {
363
+ const list = releasesRef.current?.length
364
+ ? releasesRef.current
365
+ : await listStableReleases({ sourceConfig: sourceRef.current });
366
+ setReleases(list);
367
+ let alpha;
368
+ try {
369
+ alpha = await fetchAlphaRelease({ sourceConfig: sourceRef.current });
370
+ }
371
+ catch {
372
+ // alpha 为可选通道:拉取失败不影响稳定版列表
373
+ }
374
+ const archived = new Set(listArchivedTags(kernelsDir));
375
+ const candidates = new Map();
376
+ const items = [{ label: '稳定版(最近 10 个)', section: true }];
377
+ for (const release of list.slice(0, 10)) {
378
+ const asset = selectAsset(release, release.tag);
379
+ candidates.set(release.tag, { release, asset });
380
+ const hints = [
381
+ release.tag === version ? '当前' : undefined,
382
+ asset === undefined ? '无可用资产' : undefined,
383
+ archived.has(archiveKeyFor(release.tag, false)) ? '已下载' : undefined,
384
+ release.publishedAt?.slice(0, 10),
385
+ ].filter((hint) => hint !== undefined);
386
+ items.push({ value: release.tag, label: release.tag, ...(hints.length ? { hint: hints.join(' · ') } : {}) });
387
+ }
388
+ if (alpha) {
389
+ const asset = selectAlphaAsset(alpha);
390
+ if (asset) {
391
+ candidates.set(alpha.tag, { release: alpha, asset, alpha: true });
392
+ items.push({ label: '其他通道', section: true });
393
+ const alphaArchived = archived.has(archiveKeyFor(alpha.tag, true, asset));
394
+ items.push({
395
+ value: alpha.tag,
396
+ label: 'Alpha 最新',
397
+ ...(alphaArchived ? { hint: '已下载' } : {}),
398
+ });
399
+ }
400
+ }
401
+ // alpha 归档键(Prerelease-Alpha-<sha>)不进「本机已下载」列表:
402
+ // 离线切回旧 alpha 需要 sha 级验证信息,留作磁盘存档即可
403
+ const archivedOnly = [...archived]
404
+ .filter((key) => !candidates.has(key) && !key.startsWith('Prerelease-Alpha-'))
405
+ .sort()
406
+ .reverse();
407
+ if (archivedOnly.length > 0) {
408
+ items.push({ label: '本机已下载(离线切换)', section: true });
409
+ for (const tag of archivedOnly) {
410
+ items.push({ value: tag, label: tag, hint: '离线切换' });
411
+ candidates.set(tag, {});
412
+ }
413
+ }
414
+ if (candidates.size === 0) {
415
+ throw new Error('没有可选的内核版本');
416
+ }
417
+ setDialog({ type: 'version', items, candidates });
418
+ }
419
+ catch (err) {
420
+ setDialog({ type: 'error', lines: [errText(err)] });
421
+ }
422
+ };
423
+ const onVersionPicked = (candidates, tag) => {
424
+ const candidate = candidates.get(tag) ?? {};
425
+ const alpha = candidate.alpha ?? false;
426
+ // 同版本短路:目标就是当前稳定版时提示即可,不重复下载替换(spec §3.4)
427
+ if (!alpha && tag === version) {
428
+ setDialog(undefined);
429
+ onMessage('已是该版本,无需切换');
430
+ return;
431
+ }
432
+ const asset = candidate.asset;
433
+ const fromArchive = existsSync(archivedBinPath(kernelsDir, archiveKeyFor(tag, alpha, asset)));
434
+ const sizeLine = fromArchive
435
+ ? '本机已有该版本,跳过下载直接切换'
436
+ : asset?.size
437
+ ? `下载约 ${mb(asset.size)},替换 ${prettyPath(config.mihomoBin ?? '') || '~/bin/mihomo'} 并重启服务`
438
+ : `替换 ${prettyPath(config.mihomoBin ?? '') || '~/bin/mihomo'} 并重启服务`;
439
+ setDialog({
440
+ type: 'installConfirm',
441
+ tag,
442
+ alpha,
443
+ asset,
444
+ fromArchive,
445
+ message: [`切换内核:${version ?? '未知'} → ${tag}`, sizeLine, '失败自动回滚旧内核'],
446
+ });
447
+ };
448
+ const runInstall = (plan) => {
449
+ const first = plan.fromArchive ? '校验' : '下载内核';
450
+ setDialog({
451
+ type: 'installProgress',
452
+ stepIndex: INSTALL_STEPS.indexOf(first),
453
+ current: first,
454
+ completed: [],
455
+ });
456
+ installKernel({ tag: plan.tag, asset: plan.asset, alpha: plan.alpha }, {
457
+ mihomoBin: config.mihomoBin,
458
+ kernelsDir,
459
+ sourceConfig: source,
460
+ // 复用注入的 ServiceManager(测试 stub);缺省真实服务
461
+ restart: () => (serviceDeps?.service ?? new ServiceManager()).restart(),
462
+ isActive: () => (serviceDeps?.service ?? new ServiceManager()).isActive(),
463
+ probeVersion: async () => {
464
+ try {
465
+ return (await new MihomoClient(config).version()).version;
466
+ }
467
+ catch {
468
+ return undefined;
469
+ }
470
+ },
471
+ onProgress: (progress) => {
472
+ const stepName = PHASE_STEP[progress.phase];
473
+ const stepIndex = INSTALL_STEPS.indexOf(stepName);
474
+ setDialog({
475
+ type: 'installProgress',
476
+ stepIndex: Math.max(0, stepIndex),
477
+ current: progress.message,
478
+ completed: [...INSTALL_STEPS.slice(0, Math.max(0, stepIndex))],
479
+ detail: downloadDetail(progress),
480
+ });
481
+ },
482
+ })
483
+ .then(({ fromArchive }) => {
484
+ setDialog(undefined);
485
+ onMessage(fromArchive
486
+ ? `内核已切换到 ${plan.tag}(离线)`
487
+ : `内核已切换到 ${plan.tag}`);
488
+ })
489
+ .catch((err) => {
490
+ showError(err);
491
+ });
492
+ };
493
+ /** 下载源保存:写 TUI 自身 config.json(与内核无关,无需重启) */
494
+ const saveSource = (next) => {
495
+ try {
496
+ saveConfig({ ...loadConfig(configPath), downloadSource: next }, configPath);
497
+ setSource(next);
498
+ setDialog(undefined);
499
+ onMessage(next.mode === 'custom'
500
+ ? `下载源已保存:自定义 ${next.customPrefix ?? ''}`
501
+ : '下载源已保存');
502
+ }
503
+ catch (err) {
504
+ setDialog({ type: 'error', lines: [`保存下载源失败:${errText(err)}`] });
505
+ }
506
+ };
507
+ const onRowEnter = (key) => {
508
+ if (key === 'switch') {
509
+ void openVersionDialog();
510
+ return;
511
+ }
512
+ if (key === 'source') {
513
+ setDialog({ type: 'source' });
514
+ return;
515
+ }
516
+ if (key === 'update') {
517
+ // 可用更新行:Enter 强制重查(检查失败后的重试入口)
518
+ void checkUpdate(true);
519
+ return;
520
+ }
521
+ confirmFor(key);
522
+ };
523
+ // 当前项 key(左栏焦点所在的可操作项);面板行派生见 valueOf 之后
524
+ const currentKey = ACTIONABLE[cursor];
525
+ useInput((input, key) => {
526
+ // 按键期即时派生当前面板行(valueOf 声明在 useInput 之后,无法在模块顶层先算)
527
+ const row = ROWS.find((r) => r.key === currentKey);
528
+ const lines = row ? panelLines(row, valueOf(row)) : [];
529
+ const editableCount = lines.filter((line) => line.editable).length;
530
+ // ←→/hl 左右栏焦点互切;右栏仅当前项有可点改行时可入
531
+ if (key.leftArrow || input === 'h') {
532
+ setFocus('nav');
533
+ return;
534
+ }
535
+ if (key.rightArrow || input === 'l') {
536
+ if (editableCount > 0)
537
+ setFocus('panel');
538
+ return;
539
+ }
540
+ if (key.upArrow || input === 'k') {
541
+ if (focus === 'nav')
542
+ moveCursor(-1);
543
+ else
544
+ setPanelCursor((i) => Math.max(0, i - 1));
545
+ return;
546
+ }
547
+ if (key.downArrow || input === 'j') {
548
+ if (focus === 'nav')
549
+ moveCursor(1);
550
+ else
551
+ setPanelCursor((i) => Math.min(lines.length - 1, i + 1));
552
+ return;
553
+ }
554
+ if (key.return) {
555
+ // 左栏与右栏 Enter 同入口:都进入当前项对话框(交互逻辑不变)
556
+ if (currentKey)
557
+ onRowEnter(currentKey);
558
+ }
559
+ }, { isActive: active && !dialog });
560
+ // 双栏宽度(复用节点页机制):左栏导航容纳最长标签+值缩写,右栏=余量
561
+ // 确保双栏总宽与 TopBar 对齐
562
+ const narrow = width < 100;
563
+ const navWidth = narrow ? width : Math.min(34, Math.floor(width * 0.34));
564
+ const panelWidth = narrow ? width : width - navWidth;
565
+ // 矮终端(80×24 的 body 仅 18 行)去掉区块间空行,保证面板完整可见
566
+ const spacious = height >= 20;
567
+ const valueOf = (row) => {
568
+ if (!values)
569
+ return { text: '…', color: colors.muted };
570
+ switch (row.key) {
571
+ case 'port':
572
+ return { text: values.port !== undefined ? String(values.port) : '—' };
573
+ case 'allowLan':
574
+ return { text: boolText(values.allowLan), color: values.allowLan ? colors.success : colors.muted };
575
+ case 'ipv6':
576
+ return { text: boolText(values.ipv6), color: values.ipv6 ? colors.success : colors.muted };
577
+ case 'unifiedDelay':
578
+ return {
579
+ text: boolText(values.unifiedDelay),
580
+ color: values.unifiedDelay ? colors.success : colors.muted,
581
+ };
582
+ case 'tcpConcurrent':
583
+ return {
584
+ text: boolText(values.tcpConcurrent),
585
+ color: values.tcpConcurrent ? colors.success : colors.muted,
586
+ };
587
+ case 'logLevel':
588
+ return { text: values.logLevel };
589
+ case 'dns':
590
+ return { text: boolText(values.dnsEnable), color: values.dnsEnable ? colors.success : colors.muted };
591
+ case 'controller':
592
+ return { text: values.controller || '—', color: colors.muted };
593
+ case 'version':
594
+ return { text: version ?? '未知', color: version ? undefined : colors.muted };
595
+ case 'update':
596
+ switch (update.status) {
597
+ case 'checking':
598
+ return { text: '检查中…', color: colors.muted };
599
+ case 'ok':
600
+ return { text: `${update.latestTag} 可更新`, color: colors.accent };
601
+ case 'latest':
602
+ return { text: '已是最新', color: colors.success };
603
+ case 'error':
604
+ return { text: '检查失败', color: colors.danger };
605
+ default:
606
+ return { text: '未检查', color: colors.muted };
607
+ }
608
+ case 'source':
609
+ switch (source.mode) {
610
+ case 'auto':
611
+ return { text: '自动', color: colors.success };
612
+ case 'direct':
613
+ return { text: '直连' };
614
+ case 'custom':
615
+ return { text: truncateDisplay(source.customPrefix ?? '自定义', 24), color: colors.info };
616
+ default:
617
+ return { text: source.mode };
618
+ }
619
+ default:
620
+ return { text: '' };
621
+ }
622
+ };
623
+ /** 左栏值缩写:右栏已有完整值,左栏只显示短值(开关/版本/端口),超长截断 */
624
+ const navValueWidth = Math.max(6, navWidth - LABEL_COL - 4);
625
+ // 当前项与其右栏配置行(渲染期派生,供右栏面板与详情标题用)
626
+ const currentRow = ROWS.find((row) => row.key === currentKey);
627
+ const currentPanelLines = currentRow ? panelLines(currentRow, valueOf(currentRow)) : [];
628
+ const renderNavRows = () => {
629
+ const nodes = [];
630
+ let lastSection;
631
+ for (const row of ROWS) {
632
+ if (row.section !== lastSection) {
633
+ if (lastSection !== undefined && spacious) {
634
+ nodes.push(_jsx(Box, { height: 1 }, `gap-${row.section}`));
635
+ }
636
+ nodes.push(_jsx(Text, { ...styles.tableHeader, children: ` ${row.section}` }, `section-${row.section}`));
637
+ lastSection = row.section;
638
+ }
639
+ const isCursor = row.editable && currentKey === row.key;
640
+ const value = valueOf(row);
641
+ const bar = isCursor ? (focus === 'nav' ? '▌' : '❯') : ' ';
642
+ const barColor = isCursor ? (focus === 'nav' ? colors.accent : colors.muted) : undefined;
643
+ nodes.push(_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: barColor, bold: isCursor && focus === 'nav', children: `${bar} ` }), _jsx(Text, { ...(isCursor ? styles.rowSelected : {}), children: padDisplay(row.label, LABEL_COL) }), value.text !== '' ? (_jsx(Text, { color: value.color ?? (row.editable ? undefined : colors.muted), children: truncateDisplay(value.text, navValueWidth) })) : null] }, row.key));
644
+ }
645
+ return nodes;
646
+ };
647
+ /** 右栏配置面板:当前项展开为可点改配置行(每行可 Enter 修改,等价左栏 Enter) */
648
+ const renderPanel = () => {
649
+ if (!currentRow) {
650
+ return _jsx(Text, { dimColor: true, children: "\u65E0\u53EF\u914D\u7F6E\u9879" });
651
+ }
652
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { height: 1 }), currentPanelLines.map((line, index) => {
653
+ const isCursor = focus === 'panel' && line.editable && index === panelCursor;
654
+ const bar = isCursor ? '▌' : ' ';
655
+ const label = padDisplay(line.label, PANEL_LABEL_COL);
656
+ return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: isCursor ? colors.accent : undefined, bold: isCursor, children: `${bar} ` }), _jsx(Text, { ...(isCursor ? styles.rowSelected : {}), dimColor: !line.editable, children: label }), _jsx(Text, { color: line.color, italic: line.italic, dimColor: line.italic || !line.editable, children: line.text })] }, `${line.label}-${index}`));
657
+ }), currentRow.editable ? (_jsx(Box, { marginTop: 1, children: _jsx(FooterLine, { hints: [{ key: '↑↓', label: '选择' }, { key: 'Enter', label: '修改' }], width: panelWidth - 4 }) })) : (_jsx(Text, { dimColor: true, children: "\u53EA\u8BFB" }))] }));
658
+ };
659
+ if (dialog) {
660
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, justifyContent: "center", alignItems: "center", children: [dialog.type === 'port' ? (_jsx(InputDialog, { title: "\u6DF7\u5408\u7AEF\u53E3", width: width, fields: [
661
+ {
662
+ label: '混合端口',
663
+ key: 'port',
664
+ required: true,
665
+ value: values?.port !== undefined ? String(values.port) : '',
666
+ validate: (raw) => {
667
+ if (!/^\d+$/.test(raw))
668
+ return '端口必须是数字';
669
+ const port = Number(raw);
670
+ if (port < 1024 || port > 65535)
671
+ return '端口必须在 1024–65535 之间';
672
+ return undefined;
673
+ },
674
+ },
675
+ ], onSubmit: (result) => {
676
+ const port = Number(result.port);
677
+ setDialog({
678
+ type: 'confirm',
679
+ changes: { 'mixed-port': port },
680
+ message: [`混合端口:${values?.port ?? '—'} → ${port}`, IMPACT],
681
+ });
682
+ }, onCancel: () => setDialog(undefined) })) : null, dialog.type === 'loglevel' ? (_jsx(ListDialog, { borderTitle: "\u65E5\u5FD7\u7EA7\u522B", items: LOG_LEVELS.map((level) => ({
683
+ value: level,
684
+ label: level,
685
+ hint: values?.logLevel === level ? '当前' : undefined,
686
+ })), onSubmit: (level) => {
687
+ setDialog({
688
+ type: 'confirm',
689
+ changes: { 'log-level': level },
690
+ message: [`日志级别:${values?.logLevel ?? '—'} → ${level}`, IMPACT],
691
+ });
692
+ }, onCancel: () => setDialog(undefined) })) : null, dialog.type === 'confirm' ? (_jsx(ConfirmDialog, { message: dialog.message, enterConfirms: true, width: Math.min(56, Math.max(40, width - 12)), onConfirm: () => applyNow(dialog.changes), onCancel: () => setDialog(undefined) })) : null, dialog.type === 'progress' ? (_jsx(ProgressDialog, { title: "\u5E94\u7528\u8BBE\u7F6E", current: dialog.current, total: SETTINGS_STEPS.length, step: dialog.stepIndex, completed: dialog.completed, width: 48 })) : null, dialog.type === 'versionLoading' ? (_jsx(ProgressDialog, { title: "\u5207\u6362\u5185\u6838", current: "\u83B7\u53D6\u7248\u672C\u5217\u8868\u2026", total: 1, step: 0, width: 44 })) : null, dialog.type === 'version' ? (_jsx(ListDialog, { borderTitle: "\u5207\u6362\u5185\u6838\u7248\u672C", items: dialog.items, maxVisible: Math.max(6, height - 10), onSubmit: (value) => onVersionPicked(dialog.candidates, value), onCancel: () => setDialog(undefined) })) : null, dialog.type === 'installConfirm' ? (_jsx(ConfirmDialog, { message: dialog.message, enterConfirms: true, width: Math.min(60, Math.max(44, width - 10)), onConfirm: () => runInstall(dialog), onCancel: () => setDialog(undefined) })) : null, dialog.type === 'installProgress' ? (_jsx(ProgressDialog, { title: "\u5207\u6362\u5185\u6838", current: dialog.current, total: INSTALL_STEPS.length, step: dialog.stepIndex, completed: dialog.completed, detail: dialog.detail, width: 52 })) : null, dialog.type === 'source' ? (_jsx(ListDialog, { borderTitle: "\u4E0B\u8F7D\u6E90", items: [
693
+ { value: 'auto', label: '自动(推荐)', hint: source.mode === 'auto' ? '当前' : '直连失败时回退镜像' },
694
+ { value: 'direct', label: '直连 GitHub', hint: source.mode === 'direct' ? '当前' : undefined },
695
+ { value: 'gh-proxy.com', label: 'gh-proxy.com', hint: source.mode === 'gh-proxy.com' ? '当前' : undefined },
696
+ { value: 'ghfast.top', label: 'ghfast.top', hint: source.mode === 'ghfast.top' ? '当前' : '仅支持文件下载' },
697
+ {
698
+ value: 'custom',
699
+ label: '自定义前缀…',
700
+ hint: source.mode === 'custom' ? truncateDisplay(source.customPrefix ?? '', 20) : undefined,
701
+ },
702
+ ], onSubmit: (value) => {
703
+ if (value === 'custom') {
704
+ setDialog({ type: 'customSource' });
705
+ return;
706
+ }
707
+ saveSource({ mode: value });
708
+ }, onCancel: () => setDialog(undefined) })) : null, dialog.type === 'customSource' ? (_jsx(InputDialog, { title: "\u81EA\u5B9A\u4E49\u4E0B\u8F7D\u6E90", width: width, fields: [
709
+ {
710
+ label: '镜像前缀',
711
+ key: 'prefix',
712
+ required: true,
713
+ value: source.customPrefix ?? '',
714
+ placeholder: 'https://your-mirror.example/',
715
+ validate: (raw) => /^https?:\/\/\S+$/.test(raw) ? undefined : '前缀必须是 http(s):// 开头的 URL',
716
+ },
717
+ ], onSubmit: (result) => {
718
+ const raw = result.prefix ?? '';
719
+ const prefix = raw.endsWith('/') ? raw : `${raw}/`;
720
+ saveSource({ mode: 'custom', customPrefix: prefix });
721
+ }, onCancel: () => setDialog(undefined) })) : null, dialog.type === 'error' ? (_jsx(ConfirmDialog, { borderTitle: "\u64CD\u4F5C\u5931\u8D25", danger: true, message: dialog.lines, enterConfirms: true, width: Math.min(64, Math.max(44, width - 8)), onConfirm: () => setDialog(undefined), onCancel: () => setDialog(undefined) })) : null] }));
722
+ }
723
+ if (loadError) {
724
+ return (_jsx(Box, { flexDirection: "column", flexGrow: 1, children: _jsx(Panel, { danger: true, title: "\u914D\u7F6E\u8BFB\u53D6\u5931\u8D25", width: width, children: _jsx(Text, { color: colors.danger, wrap: "truncate-end", children: loadError }) }) }));
725
+ }
726
+ const navPanel = (_jsx(Panel, { title: "\u25C6 \u8BBE\u7F6E", width: navWidth, fillHeight: true, children: renderNavRows() }));
727
+ const detailPanel = (_jsx(Panel, { title: currentRow ? currentRow.label : '详情', width: panelWidth, fillHeight: true, children: renderPanel() }));
728
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsx(Box, { flexDirection: "row", flexGrow: 1, children: narrow ? (focus === 'nav' ? navPanel : detailPanel) : (_jsxs(_Fragment, { children: [navPanel, detailPanel] })) }), _jsx(Box, { paddingLeft: 1, children: _jsx(FooterLine, { hints: [
729
+ { key: '↑↓', label: '移动' },
730
+ { key: '←→', label: '切栏' },
731
+ { key: 'Enter', label: '修改' },
732
+ ], width: width - 2 }) })] }));
733
+ }
734
+ //# sourceMappingURL=Settings.js.map