@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.
package/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # MX App H2O
2
+
3
+ `@qpjoy/electron-launcher-app-h2o` is **H2O: Home To Oversea**, the first
4
+ MX-H2I AppCenter built-in embed network plugin. The product direction is a
5
+ Clash-like user tool for proxy mode, PAC, Split DNS, rule status, and Internal
6
+ access visibility, while the actual privileged network work remains owned by
7
+ the MX-H2I standalone broker.
8
+
9
+ It is intentionally a normal npm-style Electron package: AppCenter can record
10
+ the package name and version in Internal, cache the installed package locally,
11
+ then open its entrypoint through the MX-H2I broker-session.
12
+
13
+ The default screen is user-facing: connection health, proxy mode, DNS/PAC
14
+ status, and recent activity are visible without exposing launcher internals.
15
+ Developers and support users can click `Debug` to inspect inherited MX-H2I
16
+ context such as broker session, socket path, package metadata, network scope,
17
+ local IP, and capability bridge state.
18
+
19
+ ## Development Test
20
+
21
+ From `electron-dock/mx-launcher`:
22
+
23
+ ```sh
24
+ pnpm --filter @qpjoy/electron-launcher-app-h2o check
25
+ pnpm --filter @qpjoy/electron-launcher-app-h2o dev
26
+ ```
27
+
28
+ The demo starts with a development broker registry so UI and broker-session states can be tested without a production socket. To verify the blocked embed path:
29
+
30
+ ```sh
31
+ MX_H2O_BROKER_MODE=off pnpm --filter @qpjoy/electron-launcher-app-h2o dev
32
+ ```
33
+
34
+ ## AppCenter Link
35
+
36
+ MX-H2I ships H2O in its default AppCenter catalog:
37
+
38
+ - `appId`: `h2o`
39
+ - `packageName`: `@qpjoy/electron-launcher-app-h2o`
40
+ - `launcherMode`: `embed`
41
+ - `standaloneChannelProductId`: `mx-h2i`
42
+ - `networkScope`: `broker-session`
43
+ - `runtimeContractVersion`: `0.1`
44
+ - `requiredCapabilities`: `user.session`, `network.status`, `network.proxy`,
45
+ `network.dns.policy`, `network.pac.policy`, `app-center-runtime`
46
+ - `entrypoints.dev`: `workspace:demos/mx-app-h2o`
47
+
48
+ In development, open MX-H2I, connect as guest or employee, install AppCenter,
49
+ then install H2O from AppCenter. MX-H2I resolves `entrypoints.dev` to this
50
+ workspace package, records the installed version and path in its local cache,
51
+ and keeps operational details available from AppCenter's `Debug` button. Run
52
+ this package with `pnpm --filter @qpjoy/electron-launcher-app-h2o dev` to test
53
+ the embed UI directly.
54
+
55
+ In production, Internal admin should upsert the AppCenter record with the same
56
+ package name and the release version. The client-side AppCenter cache stores
57
+ `installedVersion`, `latestVersion`, `installSource`, `entrypoints`, install
58
+ path, last action, and recent logs; the remote DB keeps the authoritative latest
59
+ package/version and access policy.
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@qpjoy/electron-launcher-app-h2o",
3
+ "productName": "MX-H2I H2O",
4
+ "version": "2.0.0",
5
+ "private": false,
6
+ "author": "QPJoy",
7
+ "description": "Home To Oversea embed network plugin for MX-H2I AppCenter.",
8
+ "license": "UNLICENSED",
9
+ "main": "src/main.cjs",
10
+ "files": [
11
+ "README.md",
12
+ "package.json",
13
+ "src"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "dependencies": {
19
+ "@qpjoy/electron-launcher": "2.0.0",
20
+ "@qpjoy/electron-core-mihomo": "^0.1.1",
21
+ "@qpjoy/electron-plugin-tunnel": "^0.1.17"
22
+ },
23
+ "optionalDependencies": {
24
+ "@qpjoy/electron-plugin-tunnel-engine-darwin-arm64": "^0.1.5",
25
+ "@qpjoy/electron-plugin-tunnel-engine-linux-x64": "^0.1.5",
26
+ "@qpjoy/electron-plugin-tunnel-engine-darwin-x64": "^0.1.5",
27
+ "@qpjoy/electron-plugin-tunnel-engine-linux-arm64": "^0.1.5",
28
+ "@qpjoy/electron-plugin-tunnel-engine-win32-x64": "^0.1.5"
29
+ },
30
+ "devDependencies": {
31
+ "electron": "^34.0.0"
32
+ },
33
+ "scripts": {
34
+ "build": "pnpm run check",
35
+ "dev": "electron .",
36
+ "start": "pnpm dev",
37
+ "check": "node --check src/main.cjs && node --check src/preload.cjs && node --check src/renderer.js"
38
+ }
39
+ }
package/src/index.html ADDED
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>H2O</title>
7
+ <link rel="stylesheet" href="../../../ui-design/src/styles.css" />
8
+ <link rel="stylesheet" href="./styles.css" />
9
+ </head>
10
+ <body class="qp-app qp-theme-neon-void qp-density--medium">
11
+ <main id="app" class="h2o-root"></main>
12
+ <script src="./renderer.js"></script>
13
+ </body>
14
+ </html>
package/src/main.cjs ADDED
@@ -0,0 +1,521 @@
1
+ const { app, BrowserWindow, ipcMain } = require('electron');
2
+ const path = require('path');
3
+
4
+ const H2O_APP_ID = 'h2o';
5
+ const H2O_DISPLAY_NAME = 'H2O';
6
+ const H2O_FULL_NAME = 'Home To Oversea';
7
+ const H2O_PACKAGE_NAME = '@qpjoy/electron-launcher-app-h2o';
8
+ const H2O_DESCRIPTION = 'AppCenter 内置的 Home To Oversea 网络插件,提供类 Clash 的代理模式、PAC、Split DNS 和 Internal 出海状态面板。';
9
+ const H2O_REQUIRED_CAPABILITIES = [
10
+ 'user.session',
11
+ 'network.status',
12
+ 'network.proxy',
13
+ 'network.dns.policy',
14
+ 'network.pac.policy',
15
+ 'app-center-runtime'
16
+ ];
17
+ const H2O_MODES = ['app-rule', 'app-global', 'system-tun', 'direct'];
18
+
19
+ let mainWindow = null;
20
+ let launcher = null;
21
+
22
+ let runtime = {
23
+ app: {
24
+ appId: H2O_APP_ID,
25
+ displayName: H2O_DISPLAY_NAME,
26
+ fullName: H2O_FULL_NAME,
27
+ description: H2O_DESCRIPTION,
28
+ packageName: H2O_PACKAGE_NAME,
29
+ version: app.getVersion(),
30
+ launcherMode: 'embed',
31
+ standaloneChannelProductId: 'mx-h2i',
32
+ networkScope: 'broker-session',
33
+ requiredCapabilities: H2O_REQUIRED_CAPABILITIES,
34
+ manifest: null
35
+ },
36
+ broker: {
37
+ state: 'idle',
38
+ ok: false,
39
+ message: 'Not connected',
40
+ session: null,
41
+ channel: null,
42
+ missingCapabilities: []
43
+ },
44
+ policy: {
45
+ mode: 'app-rule',
46
+ pac: 'dynamic-split',
47
+ dns: 'internal-first',
48
+ proxyPort: 23458,
49
+ profile: 'home-to-oversea'
50
+ },
51
+ engine: {
52
+ running: false,
53
+ status: 'stopped',
54
+ mode: 'app-rule',
55
+ tunInstalled: false,
56
+ activeSubscriptionId: 'h2o-default',
57
+ startedAt: null,
58
+ adminUrl: 'http://127.0.0.1:23456',
59
+ core: 'h2o-shim',
60
+ coreVersion: '0.1.0'
61
+ },
62
+ ports: {
63
+ admin: 23456,
64
+ controller: 23457,
65
+ mixed: 23458,
66
+ dns: 1053
67
+ },
68
+ network: {
69
+ localIp: null,
70
+ routePolicy: 'guest limited',
71
+ internalApi: 'pending',
72
+ splitDns: 'pending',
73
+ pac: 'pending'
74
+ },
75
+ subscriptions: [
76
+ {
77
+ id: 'h2o-default',
78
+ name: 'Home To Oversea 默认策略',
79
+ url: 'mx-h2i://managed/home-to-oversea',
80
+ nodes: 6,
81
+ latencyMs: 42,
82
+ status: 'ready',
83
+ lastUpdatedAt: new Date().toISOString()
84
+ }
85
+ ],
86
+ rules: [
87
+ { id: 'internal-api', host: 'api.mxinfo-inc.cn', target: '10.88.88.88', policy: 'internal-direct', enabled: true, source: 'builtin' },
88
+ { id: 'appcenter', host: 'appcenter.mxinfo-inc.cn', target: 'mx-h2i broker', policy: 'broker-session', enabled: true, source: 'builtin' },
89
+ { id: 'oversea-default', host: '*.oversea', target: 'system proxy', policy: 'home-to-oversea', enabled: true, source: 'managed' }
90
+ ],
91
+ metrics: {
92
+ uploadBytes: 0,
93
+ downloadBytes: 0,
94
+ lastProxyAppliedAt: null
95
+ },
96
+ activity: [],
97
+ updatedAt: new Date().toISOString()
98
+ };
99
+
100
+ app.whenReady().then(() => {
101
+ registerIpc();
102
+ createWindow();
103
+ });
104
+
105
+ app.on('window-all-closed', () => {
106
+ if (process.platform !== 'darwin') app.quit();
107
+ });
108
+
109
+ app.on('activate', () => {
110
+ if (BrowserWindow.getAllWindows().length === 0) createWindow();
111
+ });
112
+
113
+ function createWindow() {
114
+ mainWindow = new BrowserWindow({
115
+ width: 1280,
116
+ height: 820,
117
+ minWidth: 980,
118
+ minHeight: 680,
119
+ title: 'H2O',
120
+ backgroundColor: '#141417',
121
+ webPreferences: {
122
+ preload: path.join(__dirname, 'preload.cjs'),
123
+ contextIsolation: true,
124
+ nodeIntegration: false
125
+ }
126
+ });
127
+ mainWindow.loadFile(path.join(__dirname, 'index.html'));
128
+ }
129
+
130
+ function registerIpc() {
131
+ ipcMain.handle('h2o:get-state', async () => visibleRuntime());
132
+ ipcMain.handle('h2o:window-control', (_event, action) => {
133
+ if (!mainWindow || mainWindow.isDestroyed()) return false;
134
+ if (action === 'close') {
135
+ mainWindow.close();
136
+ return true;
137
+ }
138
+ if (action === 'minimize') {
139
+ mainWindow.minimize();
140
+ return true;
141
+ }
142
+ if (action === 'zoom') {
143
+ if (mainWindow.isMaximized()) mainWindow.unmaximize();
144
+ else mainWindow.maximize();
145
+ return true;
146
+ }
147
+ return false;
148
+ });
149
+ ipcMain.handle('h2o:connect-broker', async () => {
150
+ await connectBroker();
151
+ return visibleRuntime();
152
+ });
153
+ ipcMain.handle('h2o:refresh', async () => {
154
+ await refreshBrokerNetwork();
155
+ return visibleRuntime();
156
+ });
157
+ ipcMain.handle('h2o:set-mode', async (_event, mode) => {
158
+ const nextMode = normalizeH2oMode(mode);
159
+ runtime.policy.mode = nextMode;
160
+ runtime.engine.mode = nextMode;
161
+ runtime.engine.status = nextMode === 'system-tun' && !runtime.engine.tunInstalled
162
+ ? 'tun-required'
163
+ : runtime.engine.running
164
+ ? 'running'
165
+ : 'ready';
166
+ pushActivity('policy.mode', `Mode switched to ${modeLabel(nextMode)}`);
167
+ if (runtime.engine.running) {
168
+ await applyProxyPolicy('mode-switch');
169
+ }
170
+ broadcast();
171
+ return visibleRuntime();
172
+ });
173
+ ipcMain.handle('h2o:start-runtime', async () => {
174
+ await startRuntime();
175
+ return visibleRuntime();
176
+ });
177
+ ipcMain.handle('h2o:stop-runtime', async () => {
178
+ runtime.engine.running = false;
179
+ runtime.engine.status = 'stopped';
180
+ runtime.engine.startedAt = null;
181
+ pushActivity('runtime.stop', 'H2O proxy runtime stopped.');
182
+ await requestBroker('network.proxy', { mode: 'direct', reason: 'h2o-stop' });
183
+ broadcast();
184
+ return visibleRuntime();
185
+ });
186
+ ipcMain.handle('h2o:install-tun', async () => {
187
+ runtime.engine.tunInstalled = true;
188
+ runtime.engine.status = runtime.engine.running ? 'running' : 'ready';
189
+ pushActivity('tun.install', 'System TUN helper marked as installed for this demo runtime.');
190
+ broadcast();
191
+ return visibleRuntime();
192
+ });
193
+ ipcMain.handle('h2o:uninstall-tun', async () => {
194
+ runtime.engine.tunInstalled = false;
195
+ if (runtime.policy.mode === 'system-tun') {
196
+ runtime.policy.mode = 'app-rule';
197
+ runtime.engine.mode = 'app-rule';
198
+ }
199
+ runtime.engine.status = runtime.engine.running ? 'running' : 'ready';
200
+ pushActivity('tun.uninstall', 'System TUN helper removed; mode fell back to App rule.');
201
+ broadcast();
202
+ return visibleRuntime();
203
+ });
204
+ ipcMain.handle('h2o:set-ports', async (_event, input) => {
205
+ const next = input && typeof input === 'object' ? input : {};
206
+ runtime.ports = {
207
+ admin: normalizePort(next.admin, runtime.ports.admin),
208
+ controller: normalizePort(next.controller, runtime.ports.controller),
209
+ mixed: normalizePort(next.mixed, runtime.ports.mixed),
210
+ dns: normalizePort(next.dns, runtime.ports.dns)
211
+ };
212
+ runtime.policy.proxyPort = runtime.ports.mixed;
213
+ runtime.engine.adminUrl = `http://127.0.0.1:${runtime.ports.admin}`;
214
+ pushActivity('ports.save', `Ports saved: mixed ${runtime.ports.mixed}, dns ${runtime.ports.dns}.`);
215
+ broadcast();
216
+ return visibleRuntime();
217
+ });
218
+ ipcMain.handle('h2o:add-subscription', async (_event, input) => {
219
+ const row = input && typeof input === 'object' ? input : {};
220
+ const name = String(row.name || 'Managed subscription').trim().slice(0, 80) || 'Managed subscription';
221
+ const url = String(row.url || 'mx-h2i://managed/custom').trim().slice(0, 240) || 'mx-h2i://managed/custom';
222
+ const id = `sub-${Date.now().toString(36)}`;
223
+ runtime.subscriptions = [
224
+ { id, name, url, nodes: 3, latencyMs: 58, status: 'ready', lastUpdatedAt: new Date().toISOString() },
225
+ ...runtime.subscriptions
226
+ ].slice(0, 12);
227
+ runtime.engine.activeSubscriptionId = id;
228
+ pushActivity('subscription.add', `Subscription added: ${name}.`);
229
+ broadcast();
230
+ return visibleRuntime();
231
+ });
232
+ ipcMain.handle('h2o:set-active-subscription', async (_event, subscriptionId) => {
233
+ const id = String(subscriptionId || '');
234
+ if (runtime.subscriptions.some((item) => item.id === id)) {
235
+ runtime.engine.activeSubscriptionId = id;
236
+ pushActivity('subscription.active', `Active subscription switched to ${activeSubscription()?.name || id}.`);
237
+ }
238
+ broadcast();
239
+ return visibleRuntime();
240
+ });
241
+ ipcMain.handle('h2o:refresh-subscription', async (_event, subscriptionId) => {
242
+ const id = String(subscriptionId || runtime.engine.activeSubscriptionId || '');
243
+ runtime.subscriptions = runtime.subscriptions.map((item) => item.id === id
244
+ ? {
245
+ ...item,
246
+ latencyMs: 30 + Math.floor(Math.random() * 60),
247
+ status: 'ready',
248
+ lastUpdatedAt: new Date().toISOString()
249
+ }
250
+ : item);
251
+ pushActivity('subscription.refresh', `Subscription refreshed: ${activeSubscription()?.name || id}.`);
252
+ broadcast();
253
+ return visibleRuntime();
254
+ });
255
+ ipcMain.handle('h2o:toggle-rule', async (_event, ruleId) => {
256
+ const id = String(ruleId || '');
257
+ runtime.rules = runtime.rules.map((rule) => rule.id === id ? { ...rule, enabled: rule.enabled === false } : rule);
258
+ const rule = runtime.rules.find((item) => item.id === id);
259
+ if (rule) pushActivity('rule.toggle', `${rule.host} ${rule.enabled ? 'enabled' : 'disabled'}.`);
260
+ broadcast();
261
+ return visibleRuntime();
262
+ });
263
+ ipcMain.handle('h2o:request-broker', async (_event, name, payload) => {
264
+ const result = await requestBroker(String(name || ''), payload);
265
+ return { state: visibleRuntime(), result };
266
+ });
267
+ }
268
+
269
+ async function connectBroker() {
270
+ runtime.broker = {
271
+ ...runtime.broker,
272
+ state: 'discovering-broker',
273
+ ok: false,
274
+ message: 'Discovering MX-H2I broker channel...'
275
+ };
276
+ broadcast();
277
+ try {
278
+ const mod = await import('@qpjoy/electron-launcher');
279
+ const manifest = typeof mod.createLauncherEmbedManifest === 'function'
280
+ ? mod.createLauncherEmbedManifest({
281
+ appId: H2O_APP_ID,
282
+ productId: H2O_APP_ID,
283
+ displayName: H2O_DISPLAY_NAME,
284
+ description: H2O_DESCRIPTION,
285
+ packageName: H2O_PACKAGE_NAME,
286
+ category: 'network',
287
+ appVersion: runtime.app.version,
288
+ standaloneChannelProductId: runtime.app.standaloneChannelProductId,
289
+ requiredCapabilities: H2O_REQUIRED_CAPABILITIES
290
+ })
291
+ : null;
292
+ runtime.app = {
293
+ ...runtime.app,
294
+ manifest,
295
+ requiredCapabilities: manifest?.requiredCapabilities || H2O_REQUIRED_CAPABILITIES
296
+ };
297
+ launcher = mod.createElectronLauncher({
298
+ mode: 'embed',
299
+ appId: runtime.app.appId,
300
+ productId: runtime.app.appId,
301
+ displayName: runtime.app.displayName,
302
+ description: runtime.app.description,
303
+ packageName: runtime.app.packageName,
304
+ category: 'network',
305
+ standaloneChannelProductId: runtime.app.standaloneChannelProductId,
306
+ appVersion: runtime.app.version,
307
+ requiredCapabilities: runtime.app.requiredCapabilities,
308
+ channelRegistry: () => devChannelRegistry(mod),
309
+ requestImpl: brokerRequest
310
+ });
311
+ if (launcher.manifest) {
312
+ runtime.app.manifest = launcher.manifest;
313
+ }
314
+ const result = await launcher.connect({
315
+ installId: 'h2o-dev-install',
316
+ deviceId: 'h2o-dev-device',
317
+ userId: 'developer'
318
+ });
319
+ runtime.broker = serializeBrokerResult(result);
320
+ if (result.ok) {
321
+ await refreshBrokerNetwork();
322
+ pushActivity('broker.connected', result.message);
323
+ } else {
324
+ pushActivity('broker.blocked', result.message);
325
+ }
326
+ } catch (error) {
327
+ runtime.broker = {
328
+ state: 'blocked',
329
+ ok: false,
330
+ message: error && error.message ? error.message : String(error),
331
+ session: null,
332
+ channel: null,
333
+ missingCapabilities: []
334
+ };
335
+ pushActivity('broker.error', runtime.broker.message);
336
+ }
337
+ broadcast();
338
+ }
339
+
340
+ async function refreshBrokerNetwork() {
341
+ const status = await requestBroker('network.status', { reason: 'manual-refresh' });
342
+ if (status && typeof status === 'object') {
343
+ runtime.network = {
344
+ ...runtime.network,
345
+ ...status
346
+ };
347
+ }
348
+ runtime.updatedAt = new Date().toISOString();
349
+ broadcast();
350
+ }
351
+
352
+ async function requestBroker(name, payload) {
353
+ if (!launcher || !runtime.broker.ok) {
354
+ return brokerRequest(runtime.broker.session, name, payload);
355
+ }
356
+ try {
357
+ return await launcher.request(name, payload);
358
+ } catch (error) {
359
+ const message = error && error.message ? error.message : String(error);
360
+ pushActivity('broker.request.failed', `${name}: ${message}`);
361
+ return { ok: false, message };
362
+ }
363
+ }
364
+
365
+ async function brokerRequest(_session, name, payload) {
366
+ if (name === 'network.status') {
367
+ return {
368
+ ok: true,
369
+ localIp: '10.89.100.12',
370
+ routePolicy: runtime.network.routePolicy,
371
+ internalApi: 'ready',
372
+ splitDns: runtime.policy.dns,
373
+ pac: runtime.policy.pac,
374
+ profile: runtime.policy.profile,
375
+ networkScope: runtime.app.networkScope,
376
+ standaloneChannelProductId: runtime.app.standaloneChannelProductId
377
+ };
378
+ }
379
+ if (name === 'network.proxy') {
380
+ return {
381
+ ok: true,
382
+ mode: payload && payload.mode ? payload.mode : runtime.policy.mode,
383
+ mixedPort: runtime.ports.mixed,
384
+ dnsPort: runtime.ports.dns,
385
+ profile: runtime.policy.profile,
386
+ appliedAt: new Date().toISOString()
387
+ };
388
+ }
389
+ if (name === 'network.dns.policy' || name === 'network.pac.policy') {
390
+ return { ok: true, policy: name, appliedAt: new Date().toISOString() };
391
+ }
392
+ if (name === 'user.session') {
393
+ return { ok: true, userId: 'developer', roles: ['mx-h2i-dev'] };
394
+ }
395
+ return { ok: true, name, echo: payload || null };
396
+ }
397
+
398
+ async function startRuntime() {
399
+ const nextMode = normalizeH2oMode(runtime.policy.mode);
400
+ runtime.policy.mode = nextMode;
401
+ runtime.engine.mode = nextMode;
402
+ if (nextMode === 'system-tun' && !runtime.engine.tunInstalled) {
403
+ runtime.engine.running = false;
404
+ runtime.engine.status = 'tun-required';
405
+ pushActivity('runtime.blocked', 'System TUN mode requires installing the TUN helper first.', 'warning');
406
+ broadcast();
407
+ return;
408
+ }
409
+ runtime.engine.running = true;
410
+ runtime.engine.status = 'running';
411
+ runtime.engine.startedAt = runtime.engine.startedAt || new Date().toISOString();
412
+ runtime.metrics.lastProxyAppliedAt = new Date().toISOString();
413
+ runtime.metrics.downloadBytes += 1024 * (4 + Math.floor(Math.random() * 24));
414
+ runtime.metrics.uploadBytes += 512 * (2 + Math.floor(Math.random() * 10));
415
+ pushActivity('runtime.start', `H2O started in ${modeLabel(nextMode)} with ${activeSubscription()?.name || 'no subscription'}.`);
416
+ await applyProxyPolicy('runtime-start');
417
+ broadcast();
418
+ }
419
+
420
+ async function applyProxyPolicy(reason) {
421
+ const result = await requestBroker('network.proxy', {
422
+ mode: runtime.policy.mode,
423
+ mixedPort: runtime.ports.mixed,
424
+ dnsPort: runtime.ports.dns,
425
+ profile: runtime.policy.profile,
426
+ subscriptionId: runtime.engine.activeSubscriptionId,
427
+ reason
428
+ });
429
+ runtime.metrics.lastProxyAppliedAt = new Date().toISOString();
430
+ if (result?.ok === false) {
431
+ runtime.engine.status = 'error';
432
+ pushActivity('broker.proxy.failed', result.message || 'Broker rejected proxy policy.', 'error');
433
+ } else {
434
+ pushActivity('broker.proxy.applied', `Proxy policy applied by MX-H2I broker (${reason}).`);
435
+ }
436
+ }
437
+
438
+ function activeSubscription() {
439
+ return runtime.subscriptions.find((item) => item.id === runtime.engine.activeSubscriptionId) || runtime.subscriptions[0] || null;
440
+ }
441
+
442
+ function normalizeH2oMode(mode) {
443
+ const value = String(mode || '').trim();
444
+ if (value === 'rule') return 'app-rule';
445
+ if (value === 'global') return 'app-global';
446
+ if (value === 'tun') return 'system-tun';
447
+ return H2O_MODES.includes(value) ? value : 'app-rule';
448
+ }
449
+
450
+ function normalizePort(value, fallback) {
451
+ const port = Number(value);
452
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : fallback;
453
+ }
454
+
455
+ function modeLabel(mode) {
456
+ if (mode === 'app-global') return '全局模式';
457
+ if (mode === 'system-tun') return '虚拟网卡';
458
+ if (mode === 'direct') return '直连';
459
+ return '规则模式';
460
+ }
461
+
462
+ function devChannelRegistry(mod) {
463
+ if (process.env.MX_H2O_BROKER_MODE === 'off') return [];
464
+ const now = new Date().toISOString();
465
+ return [{
466
+ productId: 'mx-h2i',
467
+ instanceId: 'mx-h2i-dev-broker',
468
+ pid: process.pid,
469
+ socketPath: `${app.getPath('userData')}/mx-h2i-dev.sock`,
470
+ brokerAbiVersion: mod.brokerAbiVersion || '1.0',
471
+ protocolVersion: mod.launcherProtocolVersion || '1.0',
472
+ capabilities: [
473
+ ...H2O_REQUIRED_CAPABILITIES,
474
+ 'app-center-runtime',
475
+ 'observability.write'
476
+ ].filter((capability, index, rows) => rows.indexOf(capability) === index),
477
+ heartbeatAt: now,
478
+ displayName: 'MX-H2I Dev Broker'
479
+ }];
480
+ }
481
+
482
+ function serializeBrokerResult(result) {
483
+ return {
484
+ state: result.state,
485
+ ok: result.ok,
486
+ message: result.message,
487
+ session: result.session ? {
488
+ sessionId: result.session.sessionId,
489
+ appId: result.session.appId,
490
+ networkScope: result.session.networkScope,
491
+ standaloneChannelProductId: result.session.standaloneChannelProductId,
492
+ grantedCapabilities: result.session.grantedCapabilities,
493
+ issuedAt: result.session.issuedAt
494
+ } : null,
495
+ channel: result.channel ? {
496
+ productId: result.channel.productId,
497
+ displayName: result.channel.displayName,
498
+ socketPath: result.channel.socketPath,
499
+ capabilities: result.channel.capabilities,
500
+ heartbeatAt: result.channel.heartbeatAt
501
+ } : null,
502
+ missingCapabilities: result.missingCapabilities || []
503
+ };
504
+ }
505
+
506
+ function pushActivity(type, message, level = 'info') {
507
+ runtime.activity = [
508
+ { type, message, level, at: new Date().toISOString() },
509
+ ...runtime.activity
510
+ ].slice(0, 12);
511
+ runtime.updatedAt = new Date().toISOString();
512
+ }
513
+
514
+ function visibleRuntime() {
515
+ return JSON.parse(JSON.stringify(runtime));
516
+ }
517
+
518
+ function broadcast() {
519
+ if (!mainWindow || mainWindow.isDestroyed()) return;
520
+ mainWindow.webContents.send('h2o:state', visibleRuntime());
521
+ }
@@ -0,0 +1,25 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ contextBridge.exposeInMainWorld('h2o', {
4
+ getState: () => ipcRenderer.invoke('h2o:get-state'),
5
+ connectBroker: () => ipcRenderer.invoke('h2o:connect-broker'),
6
+ refresh: () => ipcRenderer.invoke('h2o:refresh'),
7
+ setMode: (mode) => ipcRenderer.invoke('h2o:set-mode', mode),
8
+ startRuntime: () => ipcRenderer.invoke('h2o:start-runtime'),
9
+ stopRuntime: () => ipcRenderer.invoke('h2o:stop-runtime'),
10
+ installTun: () => ipcRenderer.invoke('h2o:install-tun'),
11
+ uninstallTun: () => ipcRenderer.invoke('h2o:uninstall-tun'),
12
+ setPorts: (input) => ipcRenderer.invoke('h2o:set-ports', input),
13
+ addSubscription: (input) => ipcRenderer.invoke('h2o:add-subscription', input),
14
+ setActiveSubscription: (subscriptionId) => ipcRenderer.invoke('h2o:set-active-subscription', subscriptionId),
15
+ refreshSubscription: (subscriptionId) => ipcRenderer.invoke('h2o:refresh-subscription', subscriptionId),
16
+ toggleRule: (ruleId) => ipcRenderer.invoke('h2o:toggle-rule', ruleId),
17
+ requestBroker: (name, payload) => ipcRenderer.invoke('h2o:request-broker', name, payload),
18
+ windowControl: (action) => ipcRenderer.invoke('h2o:window-control', action),
19
+ onState: (listener) => {
20
+ if (typeof listener !== 'function') return () => {};
21
+ const wrapped = (_event, payload) => listener(payload);
22
+ ipcRenderer.on('h2o:state', wrapped);
23
+ return () => ipcRenderer.removeListener('h2o:state', wrapped);
24
+ }
25
+ });