@qpjoy/electron-launcher-app-h2o 2.0.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.
@@ -0,0 +1,684 @@
1
+ const electronApi = window.h2o || null;
2
+ const api = electronApi || createMockApi();
3
+ const root = document.getElementById('app');
4
+
5
+ let state = null;
6
+ let busy = '';
7
+ let view = 'runtime';
8
+ let debugOpen = false;
9
+ let shellMenuOpen = false;
10
+ let shellNotice = '';
11
+
12
+ void boot();
13
+
14
+ async function boot() {
15
+ state = await api.getState();
16
+ render();
17
+ api.onState?.((next) => {
18
+ state = next;
19
+ render();
20
+ });
21
+ }
22
+
23
+ root.addEventListener('click', (event) => {
24
+ const control = event.target.closest('[data-window-control]');
25
+ if (control) {
26
+ shellMenuOpen = false;
27
+ void api.windowControl?.(control.dataset.windowControl);
28
+ return;
29
+ }
30
+ const button = event.target.closest('[data-action]');
31
+ if (!button) return;
32
+ const action = button.dataset.action;
33
+ if (action === 'toggle-shell-menu') {
34
+ shellMenuOpen = !shellMenuOpen;
35
+ render();
36
+ return;
37
+ }
38
+ if (action === 'switch-shell-target') {
39
+ shellMenuOpen = false;
40
+ const target = button.dataset.target || 'h2o';
41
+ if (target === 'close') {
42
+ void api.windowControl?.('close');
43
+ return;
44
+ }
45
+ shellNotice = target === 'h2i'
46
+ ? 'H2I(VPN)入口由 MX-H2I/AppCenter 容器承载;开发态请回到 MX-H2I 窗口。'
47
+ : 'AppCenter 入口由 MX-H2I 容器承载;开发态请回到 MX-H2I 窗口。';
48
+ render();
49
+ return;
50
+ }
51
+ if (action === 'set-view') {
52
+ shellMenuOpen = false;
53
+ view = button.dataset.view || 'runtime';
54
+ render();
55
+ return;
56
+ }
57
+ if (action === 'set-mode') {
58
+ shellMenuOpen = false;
59
+ void run('setMode', button.dataset.mode || 'app-rule');
60
+ return;
61
+ }
62
+ if (action === 'start-runtime') {
63
+ shellMenuOpen = false;
64
+ void run('startRuntime');
65
+ return;
66
+ }
67
+ if (action === 'stop-runtime') {
68
+ shellMenuOpen = false;
69
+ void run('stopRuntime');
70
+ return;
71
+ }
72
+ if (action === 'install-tun') {
73
+ shellMenuOpen = false;
74
+ void run('installTun');
75
+ return;
76
+ }
77
+ if (action === 'uninstall-tun') {
78
+ shellMenuOpen = false;
79
+ void run('uninstallTun');
80
+ return;
81
+ }
82
+ if (action === 'save-ports') {
83
+ shellMenuOpen = false;
84
+ void run('setPorts', readPortFields());
85
+ return;
86
+ }
87
+ if (action === 'add-demo-subscription') {
88
+ shellMenuOpen = false;
89
+ void run('addSubscription', {
90
+ name: 'Oversea backup policy',
91
+ url: 'mx-h2i://managed/home-to-oversea-backup'
92
+ });
93
+ return;
94
+ }
95
+ if (action === 'set-active-subscription') {
96
+ shellMenuOpen = false;
97
+ void run('setActiveSubscription', button.dataset.subscriptionId || '');
98
+ return;
99
+ }
100
+ if (action === 'refresh-subscription') {
101
+ shellMenuOpen = false;
102
+ void run('refreshSubscription', button.dataset.subscriptionId || '');
103
+ return;
104
+ }
105
+ if (action === 'toggle-rule') {
106
+ shellMenuOpen = false;
107
+ void run('toggleRule', button.dataset.ruleId || '');
108
+ return;
109
+ }
110
+ if (action === 'toggle-debug') {
111
+ shellMenuOpen = false;
112
+ debugOpen = !debugOpen;
113
+ render();
114
+ return;
115
+ }
116
+ if (action === 'request-proxy') {
117
+ shellMenuOpen = false;
118
+ void run('requestBroker', 'network.proxy', { mode: state.policy.mode });
119
+ return;
120
+ }
121
+ shellMenuOpen = false;
122
+ void run(action);
123
+ });
124
+
125
+ async function run(action, ...args) {
126
+ if (busy) return;
127
+ busy = action;
128
+ render();
129
+ try {
130
+ const handlers = {
131
+ connectBroker: () => api.connectBroker(),
132
+ refresh: () => api.refresh(),
133
+ setMode: (mode) => api.setMode(mode),
134
+ startRuntime: () => api.startRuntime(),
135
+ stopRuntime: () => api.stopRuntime(),
136
+ installTun: () => api.installTun(),
137
+ uninstallTun: () => api.uninstallTun(),
138
+ setPorts: (input) => api.setPorts(input),
139
+ addSubscription: (input) => api.addSubscription(input),
140
+ setActiveSubscription: (subscriptionId) => api.setActiveSubscription(subscriptionId),
141
+ refreshSubscription: (subscriptionId) => api.refreshSubscription(subscriptionId),
142
+ toggleRule: (ruleId) => api.toggleRule(ruleId),
143
+ requestBroker: (name, payload) => api.requestBroker(name, payload)
144
+ };
145
+ if (handlers[action]) {
146
+ const result = await handlers[action](...args);
147
+ state = result?.state || result;
148
+ }
149
+ } finally {
150
+ busy = '';
151
+ render();
152
+ }
153
+ }
154
+
155
+ function render() {
156
+ if (!state) return;
157
+ const connected = state.broker?.ok === true;
158
+ root.innerHTML = `
159
+ <section class="h2o-shell ${debugOpen ? 'is-debug-open' : ''}">
160
+ ${renderAppShellBar(connected)}
161
+ <aside class="h2o-sidebar">
162
+ <div class="h2o-brand">
163
+ <div class="h2o-mark">H2O</div>
164
+ <div>
165
+ <h1>H2O</h1>
166
+ <p>Home To Oversea</p>
167
+ </div>
168
+ </div>
169
+ <nav class="h2o-nav">
170
+ ${navItem('runtime', '概览')}
171
+ ${navItem('proxy', '代理')}
172
+ ${navItem('subscriptions', '订阅')}
173
+ ${navItem('rules', '规则')}
174
+ ${navItem('logs', '记录')}
175
+ </nav>
176
+ <div class="h2o-version">
177
+ <button class="text-button" type="button" data-action="toggle-debug">${debugOpen ? '隐藏 Debug' : 'Debug'}</button>
178
+ <span>App ${escapeHtml(state.app.version)}</span>
179
+ </div>
180
+ </aside>
181
+
182
+ <section class="h2o-main">
183
+ <header class="h2o-toolbar">
184
+ <div>
185
+ <p class="kicker">H2O</p>
186
+ <h2>Home To Oversea</h2>
187
+ </div>
188
+ <div class="toolbar-actions">
189
+ <button class="secondary-button" type="button" data-action="toggle-debug">${debugOpen ? '关闭 Debug' : 'Debug'}</button>
190
+ <button class="primary-button" type="button" data-action="refresh" ${!connected || busy === 'refresh' ? 'disabled' : ''}>刷新</button>
191
+ </div>
192
+ </header>
193
+
194
+ <section class="h2o-status-strip" data-state="${escapeAttr(state.broker.state)}">
195
+ <strong>${escapeHtml(connected ? '运行正常' : '需要连接')}</strong>
196
+ <span>${escapeHtml(shellNotice || (connected ? 'Home To Oversea 规则、PAC 和 Split DNS 已就绪。' : state.broker.message))}</span>
197
+ </section>
198
+
199
+ ${renderView()}
200
+ </section>
201
+
202
+ ${debugOpen ? renderDebugPanel() : renderUserPanel()}
203
+ </section>
204
+ `;
205
+ }
206
+
207
+ function renderAppShellBar(connected) {
208
+ return `
209
+ <header class="app-shell-bar">
210
+ <div class="app-shell-identity">
211
+ <div class="app-shell-mark">H2O</div>
212
+ <div>
213
+ <strong>H2O</strong>
214
+ <span>${escapeHtml(connected ? 'MX-H2I broker-session 已连接' : '等待 MX-H2I broker-session')}</span>
215
+ </div>
216
+ </div>
217
+ <div class="app-shell-actions">
218
+ <button class="app-window-button" type="button" data-window-control="minimize" aria-label="Minimize">-</button>
219
+ <button class="app-window-button" type="button" data-window-control="zoom" aria-label="Zoom">□</button>
220
+ <div class="app-shell-menu">
221
+ <button class="app-window-button" type="button" data-action="toggle-shell-menu" aria-label="App menu">...</button>
222
+ ${shellMenuOpen ? `
223
+ <div class="app-shell-popover" role="menu">
224
+ <button type="button" data-action="switch-shell-target" data-target="h2i">
225
+ <span>H2I(VPN)</span>
226
+ <small>返回连接面板</small>
227
+ </button>
228
+ <button type="button" data-action="switch-shell-target" data-target="appcenter">
229
+ <span>AppCenter</span>
230
+ <small>应用中心</small>
231
+ </button>
232
+ <button type="button" data-action="switch-shell-target" data-target="close">
233
+ <span>关闭</span>
234
+ <small>关闭当前应用</small>
235
+ </button>
236
+ </div>
237
+ ` : ''}
238
+ </div>
239
+ </div>
240
+ </header>
241
+ `;
242
+ }
243
+
244
+ function renderView() {
245
+ if (view === 'proxy') return renderProxy();
246
+ if (view === 'subscriptions') return renderSubscriptions();
247
+ if (view === 'rules') return renderRules();
248
+ if (view === 'logs') return renderLogs();
249
+ return renderRuntime();
250
+ }
251
+
252
+ function renderUserPanel() {
253
+ const connected = state.broker?.ok === true;
254
+ return `
255
+ <aside class="h2o-inspector h2o-user-panel">
256
+ <h3>当前状态</h3>
257
+ <div class="h2o-health-card" data-state="${escapeAttr(connected ? 'ok' : 'warning')}">
258
+ <strong>${escapeHtml(connected ? '已就绪' : '未连接')}</strong>
259
+ <span>${escapeHtml(connected ? '当前出海网络策略由 MX-H2I broker-session 托管。' : '请先通过 MX-H2I 打开 H2O。')}</span>
260
+ </div>
261
+ <div class="h2o-tip-list">
262
+ <div><strong>代理模式</strong><span>${escapeHtml(modeLabel(state.policy.mode))}</span></div>
263
+ <div><strong>引擎</strong><span>${escapeHtml(state.engine?.running ? '运行中' : runtimeStatusLabel(state.engine?.status))}</span></div>
264
+ <div><strong>订阅</strong><span>${escapeHtml(activeSubscription()?.name || '未选择')}</span></div>
265
+ <div><strong>规则数量</strong><span>${escapeHtml(String((state.rules || []).length))}</span></div>
266
+ <div><strong>最近刷新</strong><span>${escapeHtml(formatTime(state.updatedAt))}</span></div>
267
+ </div>
268
+ <button class="secondary-button" type="button" data-action="toggle-debug">Debug</button>
269
+ </aside>
270
+ `;
271
+ }
272
+
273
+ function renderDebugPanel() {
274
+ return `
275
+ <aside class="h2o-inspector h2o-debug-panel">
276
+ <h3>Debug</h3>
277
+ <div class="detail-list">
278
+ ${detail('State', state.broker.state)}
279
+ ${detail('Scope', state.app.networkScope)}
280
+ ${detail('Channel', state.app.standaloneChannelProductId)}
281
+ ${detail('Package', state.app.packageName)}
282
+ ${detail('Contract', state.app.manifest?.runtimeContractVersion || '-')}
283
+ ${detail('Session', state.broker.session?.sessionId || '-')}
284
+ ${detail('Socket', state.broker.channel?.socketPath || '-')}
285
+ ${detail('Local IP', state.network.localIp || '-')}
286
+ ${detail('Internal', state.network.internalApi)}
287
+ ${detail('Engine', state.engine?.status || '-')}
288
+ ${detail('TUN', state.engine?.tunInstalled ? 'installed' : 'missing')}
289
+ ${detail('Mixed', `:${state.ports?.mixed || '-'}`)}
290
+ ${detail('DNS', `:${state.ports?.dns || '-'}`)}
291
+ </div>
292
+ <div class="permission-list">
293
+ ${(state.broker.session?.grantedCapabilities || state.broker.channel?.capabilities || []).map((item) => `<span>${escapeHtml(item)}</span>`).join('') || '<span>pending</span>'}
294
+ </div>
295
+ <button class="secondary-button" type="button" data-action="toggle-debug">关闭 Debug</button>
296
+ </aside>
297
+ `;
298
+ }
299
+
300
+ function renderRuntime() {
301
+ const running = state.engine?.running === true;
302
+ const active = activeSubscription();
303
+ return `
304
+ <section class="h2o-grid">
305
+ ${metricCard('引擎', running ? '运行中' : runtimeStatusLabel(state.engine?.status))}
306
+ ${metricCard('代理模式', modeLabel(state.policy.mode))}
307
+ ${metricCard('订阅', active?.name || '未选择')}
308
+ ${metricCard('Internal', state.network.internalApi === 'ready' ? '可访问' : '待检查')}
309
+ </section>
310
+ <section class="h2o-panel">
311
+ <div class="panel-head">
312
+ <div>
313
+ <h3>Home To Oversea</h3>
314
+ <p>${escapeHtml(running ? '当前策略已由 MX-H2I broker 托管' : '启动后接管当前应用的出海策略')}</p>
315
+ </div>
316
+ <div class="toolbar-actions">
317
+ <button class="secondary-button" type="button" data-action="request-proxy" ${!running ? 'disabled' : ''}>应用策略</button>
318
+ <button class="${running ? 'secondary-button' : 'primary-button'}" type="button" data-action="${running ? 'stop-runtime' : 'start-runtime'}" ${busy === 'startRuntime' || busy === 'stopRuntime' ? 'disabled' : ''}>
319
+ ${running ? '停止' : '启动'}
320
+ </button>
321
+ </div>
322
+ </div>
323
+ <div class="mode-segments">
324
+ ${modeButton('app-rule', '规则')}
325
+ ${modeButton('app-global', '全局')}
326
+ ${modeButton('system-tun', 'TUN')}
327
+ ${modeButton('direct', '直连')}
328
+ </div>
329
+ ${state.engine?.status === 'tun-required' ? '<p class="h2o-warning">TUN 模式需要先安装虚拟网卡助手。可以在“代理”页安装,或切回规则模式。</p>' : ''}
330
+ </section>
331
+ <section class="h2o-panel h2o-managed-card">
332
+ <div class="panel-head">
333
+ <div>
334
+ <h3>当前订阅</h3>
335
+ <p>${escapeHtml(active?.url || '由 MX-H2I managed profile 提供')}</p>
336
+ </div>
337
+ <button class="secondary-button" type="button" data-action="refresh-subscription" data-subscription-id="${escapeAttr(active?.id || '')}">刷新</button>
338
+ </div>
339
+ <div class="h2o-grid is-compact">
340
+ ${metricCard('节点', String(active?.nodes || 0))}
341
+ ${metricCard('延迟', active ? `${active.latencyMs} ms` : '-')}
342
+ ${metricCard('规则', String((state.rules || []).filter((rule) => rule.enabled !== false).length))}
343
+ ${metricCard('端口', `:${state.ports?.mixed || state.policy.proxyPort}`)}
344
+ </div>
345
+ </section>
346
+ ${renderRules({ compact: true })}
347
+ `;
348
+ }
349
+
350
+ function renderProxy() {
351
+ const running = state.engine?.running === true;
352
+ return `
353
+ <section class="h2o-panel">
354
+ <div class="panel-head">
355
+ <div>
356
+ <h3>代理</h3>
357
+ <p>${escapeHtml(modeLabel(state.policy.mode))} / ${escapeHtml(runtimeStatusLabel(state.engine?.status))}</p>
358
+ </div>
359
+ <div class="toolbar-actions">
360
+ <button class="${running ? 'secondary-button' : 'primary-button'}" type="button" data-action="${running ? 'stop-runtime' : 'start-runtime'}">
361
+ ${running ? '停止' : '启动'}
362
+ </button>
363
+ <button class="primary-button" type="button" data-action="request-proxy" ${!running ? 'disabled' : ''}>切换</button>
364
+ </div>
365
+ </div>
366
+ <div class="mode-segments">
367
+ ${modeButton('app-rule', 'App 规则')}
368
+ ${modeButton('app-global', 'App 全局')}
369
+ ${modeButton('system-tun', '系统 TUN')}
370
+ ${modeButton('direct', '直连')}
371
+ </div>
372
+ </section>
373
+ <section class="h2o-panel">
374
+ <div class="panel-head">
375
+ <div>
376
+ <h3>TUN 助手</h3>
377
+ <p>${escapeHtml(state.engine?.tunInstalled ? '已安装,可以启用系统 TUN 模式' : '仅系统 TUN 模式需要安装')}</p>
378
+ </div>
379
+ <button class="secondary-button" type="button" data-action="${state.engine?.tunInstalled ? 'uninstall-tun' : 'install-tun'}">
380
+ ${state.engine?.tunInstalled ? '卸载 TUN' : '安装 TUN'}
381
+ </button>
382
+ </div>
383
+ <div class="h2o-grid">
384
+ ${metricCard('Mixed', `:${state.ports?.mixed || '-'}`)}
385
+ ${metricCard('DNS', `:${state.ports?.dns || '-'}`)}
386
+ ${metricCard('Controller', `:${state.ports?.controller || '-'}`)}
387
+ ${metricCard('Admin', `:${state.ports?.admin || '-'}`)}
388
+ </div>
389
+ <div class="port-editor">
390
+ ${portField('mixed', state.ports?.mixed)}
391
+ ${portField('dns', state.ports?.dns)}
392
+ ${portField('controller', state.ports?.controller)}
393
+ ${portField('admin', state.ports?.admin)}
394
+ <button class="secondary-button" type="button" data-action="save-ports">保存端口</button>
395
+ </div>
396
+ </section>
397
+ `;
398
+ }
399
+
400
+ function renderSubscriptions() {
401
+ const subscriptions = state.subscriptions || [];
402
+ return `
403
+ <section class="h2o-panel">
404
+ <div class="panel-head">
405
+ <div>
406
+ <h3>订阅</h3>
407
+ <p>Managed profile / local fallback</p>
408
+ </div>
409
+ <button class="secondary-button" type="button" data-action="add-demo-subscription">添加示例</button>
410
+ </div>
411
+ <div class="subscription-list">
412
+ ${subscriptions.map((item) => `
413
+ <article class="${item.id === state.engine?.activeSubscriptionId ? 'is-active' : ''}">
414
+ <div>
415
+ <strong>${escapeHtml(item.name)}</strong>
416
+ <span>${escapeHtml(item.url)}</span>
417
+ </div>
418
+ <div class="subscription-meta">
419
+ <span>${escapeHtml(String(item.nodes || 0))} nodes</span>
420
+ <span>${escapeHtml(String(item.latencyMs || '-'))} ms</span>
421
+ <span>${escapeHtml(formatTime(item.lastUpdatedAt))}</span>
422
+ </div>
423
+ <div class="toolbar-actions">
424
+ <button class="secondary-button" type="button" data-action="refresh-subscription" data-subscription-id="${escapeAttr(item.id)}">刷新</button>
425
+ <button class="primary-button" type="button" data-action="set-active-subscription" data-subscription-id="${escapeAttr(item.id)}" ${item.id === state.engine?.activeSubscriptionId ? 'disabled' : ''}>使用</button>
426
+ </div>
427
+ </article>
428
+ `).join('') || '<p class="empty">暂无订阅</p>'}
429
+ </div>
430
+ </section>
431
+ `;
432
+ }
433
+
434
+ function renderPermissions() {
435
+ return `
436
+ <section class="h2o-panel">
437
+ <div class="panel-head">
438
+ <div>
439
+ <h3>Permissions</h3>
440
+ <p>granted by MX-H2I broker</p>
441
+ </div>
442
+ </div>
443
+ <div class="permission-list is-large">
444
+ ${(state.broker.session?.grantedCapabilities || []).map((item) => `<span>${escapeHtml(item)}</span>`).join('') || '<span>Connect broker first</span>'}
445
+ </div>
446
+ </section>
447
+ `;
448
+ }
449
+
450
+ function renderLogs() {
451
+ return `
452
+ <section class="h2o-panel">
453
+ <div class="panel-head">
454
+ <div>
455
+ <h3>Activity</h3>
456
+ <p>${escapeHtml(state.updatedAt)}</p>
457
+ </div>
458
+ </div>
459
+ <div class="activity-list">
460
+ ${(state.activity || []).map((item) => `<div><strong>${escapeHtml(item.type)}</strong><span>${escapeHtml(item.message)}</span><small>${escapeHtml(formatTime(item.at))}</small></div>`).join('') || '<p class="empty">No activity</p>'}
461
+ </div>
462
+ </section>
463
+ `;
464
+ }
465
+
466
+ function renderRules(options = {}) {
467
+ return `
468
+ <section class="h2o-panel">
469
+ <div class="panel-head">
470
+ <div>
471
+ <h3>规则</h3>
472
+ <p>${options.compact ? '常用 Internal 服务' : 'Internal direct / broker session / oversea policy'}</p>
473
+ </div>
474
+ </div>
475
+ <div class="rule-table">
476
+ ${(state.rules || []).map((rule) => `
477
+ <div class="${rule.enabled === false ? 'is-disabled' : ''}">
478
+ <strong>${escapeHtml(rule.host)}</strong>
479
+ <span>${escapeHtml(rule.target)}</span>
480
+ <em>${escapeHtml(rule.policy)}</em>
481
+ ${options.compact ? '' : `<button class="secondary-button" type="button" data-action="toggle-rule" data-rule-id="${escapeAttr(rule.id)}">${rule.enabled === false ? '启用' : '停用'}</button>`}
482
+ </div>
483
+ `).join('')}
484
+ </div>
485
+ </section>
486
+ `;
487
+ }
488
+
489
+ function navItem(id, label) {
490
+ return `<button class="${view === id ? 'is-active' : ''}" type="button" data-action="set-view" data-view="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
491
+ }
492
+
493
+ function modeButton(mode, label) {
494
+ return `<button class="${state.policy.mode === mode ? 'is-active' : ''}" type="button" data-action="set-mode" data-mode="${escapeAttr(mode)}">${escapeHtml(label)}</button>`;
495
+ }
496
+
497
+ function modeLabel(mode) {
498
+ if (mode === 'global' || mode === 'app-global') return '全局';
499
+ if (mode === 'system-tun') return '系统 TUN';
500
+ if (mode === 'direct') return '直连';
501
+ return '规则';
502
+ }
503
+
504
+ function runtimeStatusLabel(status) {
505
+ if (state.engine?.running) return '运行中';
506
+ if (status === 'tun-required') return '等待 TUN';
507
+ if (status === 'ready') return '就绪';
508
+ if (status === 'error') return '异常';
509
+ return '未启动';
510
+ }
511
+
512
+ function activeSubscription() {
513
+ const rows = state.subscriptions || [];
514
+ return rows.find((item) => item.id === state.engine?.activeSubscriptionId) || rows[0] || null;
515
+ }
516
+
517
+ function metricCard(label, value) {
518
+ return `<article class="metric-card"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value || '-')}</strong></article>`;
519
+ }
520
+
521
+ function detail(label, value) {
522
+ return `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(value || '-')}</strong></div>`;
523
+ }
524
+
525
+ function portField(name, value) {
526
+ const label = name === 'mixed' ? 'Mixed' : name === 'dns' ? 'DNS' : name === 'controller' ? 'Controller' : 'Admin';
527
+ return `
528
+ <label>
529
+ <span>${escapeHtml(label)}</span>
530
+ <input data-port-field="${escapeAttr(name)}" inputmode="numeric" value="${escapeAttr(String(value || ''))}" />
531
+ </label>
532
+ `;
533
+ }
534
+
535
+ function readPortFields() {
536
+ const result = {};
537
+ for (const input of root.querySelectorAll('[data-port-field]')) {
538
+ result[input.dataset.portField] = Number(input.value || 0);
539
+ }
540
+ return result;
541
+ }
542
+
543
+ function formatTime(value) {
544
+ const date = new Date(value);
545
+ return Number.isNaN(date.getTime()) ? '-' : date.toLocaleTimeString('zh-CN', { hour12: false });
546
+ }
547
+
548
+ function escapeHtml(value) {
549
+ return String(value ?? '')
550
+ .replaceAll('&', '&amp;')
551
+ .replaceAll('<', '&lt;')
552
+ .replaceAll('>', '&gt;')
553
+ .replaceAll('"', '&quot;')
554
+ .replaceAll("'", '&#39;');
555
+ }
556
+
557
+ function escapeAttr(value) {
558
+ return escapeHtml(value);
559
+ }
560
+
561
+ function createMockApi() {
562
+ let mock = {
563
+ app: {
564
+ appId: 'h2o',
565
+ displayName: 'H2O',
566
+ fullName: 'Home To Oversea',
567
+ description: 'AppCenter 内置的 Home To Oversea 网络插件,提供类 Clash 的代理模式、PAC、Split DNS 和 Internal 出海状态面板。',
568
+ packageName: '@qpjoy/electron-launcher-app-h2o',
569
+ version: '0.1.0',
570
+ launcherMode: 'embed',
571
+ standaloneChannelProductId: 'mx-h2i',
572
+ networkScope: 'broker-session',
573
+ manifest: {
574
+ appId: 'h2o',
575
+ productId: 'h2o',
576
+ displayName: 'H2O',
577
+ description: 'AppCenter 内置的 Home To Oversea 网络插件,提供类 Clash 的代理模式、PAC、Split DNS 和 Internal 出海状态面板。',
578
+ packageName: '@qpjoy/electron-launcher-app-h2o',
579
+ launcherMode: 'embed',
580
+ runtimeContractVersion: '0.1',
581
+ requiredCapabilities: ['user.session', 'network.status', 'network.proxy', 'network.dns.policy', 'network.pac.policy', 'app-center-runtime'],
582
+ network: { scope: 'broker-session' },
583
+ embed: { standaloneChannelProductId: 'mx-h2i', launchWithoutBroker: 'blocked' }
584
+ }
585
+ },
586
+ broker: {
587
+ state: 'network-ready',
588
+ ok: true,
589
+ message: 'Connected to MX-H2I mock broker.',
590
+ session: {
591
+ sessionId: 'embed_h2o_mock',
592
+ grantedCapabilities: ['user.session', 'network.status', 'network.proxy', 'network.dns.policy', 'network.pac.policy', 'app-center-runtime']
593
+ },
594
+ channel: { socketPath: '~/.qpjoy/mx-launcher/sockets/mx-h2i.sock' },
595
+ missingCapabilities: []
596
+ },
597
+ policy: { mode: 'app-rule', pac: 'dynamic-split', dns: 'internal-first', proxyPort: 23458, profile: 'home-to-oversea' },
598
+ engine: {
599
+ running: false,
600
+ status: 'ready',
601
+ mode: 'app-rule',
602
+ tunInstalled: false,
603
+ activeSubscriptionId: 'h2o-default',
604
+ startedAt: null,
605
+ adminUrl: 'http://127.0.0.1:23456',
606
+ core: 'h2o-shim',
607
+ coreVersion: '0.1.0'
608
+ },
609
+ ports: { admin: 23456, controller: 23457, mixed: 23458, dns: 1053 },
610
+ network: { localIp: '10.89.100.12', routePolicy: 'guest limited', internalApi: 'ready', splitDns: 'internal-first', pac: 'dynamic-split', profile: 'home-to-oversea' },
611
+ subscriptions: [
612
+ { id: 'h2o-default', name: 'Home To Oversea 默认策略', url: 'mx-h2i://managed/home-to-oversea', nodes: 6, latencyMs: 42, status: 'ready', lastUpdatedAt: new Date().toISOString() }
613
+ ],
614
+ rules: [
615
+ { id: 'internal-api', host: 'api.mxinfo-inc.cn', target: '10.88.88.88', policy: 'internal-direct', enabled: true, source: 'builtin' },
616
+ { id: 'appcenter', host: 'appcenter.mxinfo-inc.cn', target: 'mx-h2i broker', policy: 'broker-session', enabled: true, source: 'builtin' },
617
+ { id: 'oversea-default', host: '*.oversea', target: 'system proxy', policy: 'home-to-oversea', enabled: true, source: 'managed' }
618
+ ],
619
+ metrics: { uploadBytes: 0, downloadBytes: 0, lastProxyAppliedAt: null },
620
+ activity: [],
621
+ updatedAt: new Date().toISOString()
622
+ };
623
+ const commit = (patch) => {
624
+ mock = { ...mock, ...patch, updatedAt: new Date().toISOString() };
625
+ return JSON.parse(JSON.stringify(mock));
626
+ };
627
+ return {
628
+ getState: async () => JSON.parse(JSON.stringify(mock)),
629
+ connectBroker: async () => commit({ broker: { ...mock.broker, state: 'network-ready', ok: true, message: 'Connected to MX-H2I mock broker.' } }),
630
+ refresh: async () => commit({ network: { ...mock.network, internalApi: 'ready', localIp: '10.89.100.12' } }),
631
+ setMode: async (mode) => commit({
632
+ policy: { ...mock.policy, mode },
633
+ engine: { ...mock.engine, mode, status: mode === 'system-tun' && !mock.engine.tunInstalled ? 'tun-required' : mock.engine.running ? 'running' : 'ready' },
634
+ activity: [{ type: 'policy.mode', level: 'info', message: `Mode switched to ${mode}`, at: new Date().toISOString() }, ...mock.activity]
635
+ }),
636
+ startRuntime: async () => commit({
637
+ engine: mock.policy.mode === 'system-tun' && !mock.engine.tunInstalled
638
+ ? { ...mock.engine, running: false, status: 'tun-required' }
639
+ : { ...mock.engine, running: true, status: 'running', startedAt: mock.engine.startedAt || new Date().toISOString() },
640
+ activity: [{ type: 'runtime.start', level: 'info', message: 'H2O proxy runtime started.', at: new Date().toISOString() }, ...mock.activity]
641
+ }),
642
+ stopRuntime: async () => commit({
643
+ engine: { ...mock.engine, running: false, status: 'stopped', startedAt: null },
644
+ activity: [{ type: 'runtime.stop', level: 'info', message: 'H2O proxy runtime stopped.', at: new Date().toISOString() }, ...mock.activity]
645
+ }),
646
+ installTun: async () => commit({
647
+ engine: { ...mock.engine, tunInstalled: true, status: mock.engine.running ? 'running' : 'ready' },
648
+ activity: [{ type: 'tun.install', level: 'info', message: 'TUN helper installed.', at: new Date().toISOString() }, ...mock.activity]
649
+ }),
650
+ uninstallTun: async () => commit({
651
+ policy: { ...mock.policy, mode: mock.policy.mode === 'system-tun' ? 'app-rule' : mock.policy.mode },
652
+ engine: { ...mock.engine, tunInstalled: false, mode: mock.engine.mode === 'system-tun' ? 'app-rule' : mock.engine.mode, status: mock.engine.running ? 'running' : 'ready' },
653
+ activity: [{ type: 'tun.uninstall', level: 'info', message: 'TUN helper removed.', at: new Date().toISOString() }, ...mock.activity]
654
+ }),
655
+ setPorts: async (input) => commit({
656
+ ports: { ...mock.ports, ...input },
657
+ policy: { ...mock.policy, proxyPort: Number(input?.mixed || mock.ports.mixed) },
658
+ activity: [{ type: 'ports.save', level: 'info', message: 'Ports saved.', at: new Date().toISOString() }, ...mock.activity]
659
+ }),
660
+ addSubscription: async (input) => {
661
+ const id = `sub-${Date.now().toString(36)}`;
662
+ const row = { id, name: input?.name || 'Managed subscription', url: input?.url || 'mx-h2i://managed/custom', nodes: 3, latencyMs: 58, status: 'ready', lastUpdatedAt: new Date().toISOString() };
663
+ return commit({
664
+ subscriptions: [row, ...mock.subscriptions],
665
+ engine: { ...mock.engine, activeSubscriptionId: id },
666
+ activity: [{ type: 'subscription.add', level: 'info', message: `Subscription added: ${row.name}`, at: new Date().toISOString() }, ...mock.activity]
667
+ });
668
+ },
669
+ setActiveSubscription: async (subscriptionId) => commit({
670
+ engine: { ...mock.engine, activeSubscriptionId: subscriptionId },
671
+ activity: [{ type: 'subscription.active', level: 'info', message: `Active subscription switched to ${subscriptionId}`, at: new Date().toISOString() }, ...mock.activity]
672
+ }),
673
+ refreshSubscription: async (subscriptionId) => commit({
674
+ subscriptions: mock.subscriptions.map((item) => item.id === subscriptionId ? { ...item, latencyMs: 36, lastUpdatedAt: new Date().toISOString() } : item),
675
+ activity: [{ type: 'subscription.refresh', level: 'info', message: `Subscription refreshed: ${subscriptionId || mock.engine.activeSubscriptionId}`, at: new Date().toISOString() }, ...mock.activity]
676
+ }),
677
+ toggleRule: async (ruleId) => commit({
678
+ rules: mock.rules.map((rule) => rule.id === ruleId ? { ...rule, enabled: rule.enabled === false } : rule),
679
+ activity: [{ type: 'rule.toggle', level: 'info', message: `Rule toggled: ${ruleId}`, at: new Date().toISOString() }, ...mock.activity]
680
+ }),
681
+ requestBroker: async (name, payload) => ({ state: commit({ activity: [{ type: name, message: JSON.stringify(payload || {}), at: new Date().toISOString() }, ...mock.activity] }), result: { ok: true } }),
682
+ onState: () => () => {}
683
+ };
684
+ }