@qpjoy/electron-tunnel 0.1.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,635 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MihomoManager = void 0;
4
+ const events_1 = require("events");
5
+ const child_process_1 = require("child_process");
6
+ const fs_1 = require("fs");
7
+ const path_1 = require("path");
8
+ const zlib_1 = require("zlib");
9
+ const yaml_1 = require("yaml");
10
+ const TunnelDatabase_1 = require("../db/TunnelDatabase");
11
+ const defaults_1 = require("../defaults");
12
+ const renderRuntimeConfig_1 = require("../config/renderRuntimeConfig");
13
+ const MihomoApi_1 = require("./MihomoApi");
14
+ function pathsFromOptions(options) {
15
+ const root = (0, path_1.join)(options.userDataPath, 'mihomo-tunnel');
16
+ return {
17
+ root,
18
+ db: (0, path_1.join)(root, 'tunnel.sqlite'),
19
+ profiles: (0, path_1.join)(root, 'profiles'),
20
+ runtime: (0, path_1.join)(root, 'runtime'),
21
+ config: (0, path_1.join)(root, 'runtime', 'config.yaml'),
22
+ core: (0, path_1.join)(root, 'bin', 'mihomo')
23
+ };
24
+ }
25
+ function platformArchKey() {
26
+ const arch = process.arch === 'x64' ? 'x64' : process.arch;
27
+ return `${process.platform}-${arch}`;
28
+ }
29
+ function isRootUser() {
30
+ return typeof process.getuid === 'function' && process.getuid() === 0;
31
+ }
32
+ function needsElevatedTun(settings) {
33
+ return settings.mode === 'system-tun'
34
+ && settings.tunInstalled
35
+ && !isRootUser()
36
+ && (process.platform === 'darwin' || process.platform === 'linux');
37
+ }
38
+ function isProcessAlive(pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (error) {
44
+ return isRecord(error) && error.code === 'EPERM';
45
+ }
46
+ }
47
+ function shellQuote(value) {
48
+ return `'${value.replace(/'/g, `'\\''`)}'`;
49
+ }
50
+ function delay(ms) {
51
+ return new Promise((resolve) => setTimeout(resolve, ms));
52
+ }
53
+ function basicAuthHeader(username, password) {
54
+ return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
55
+ }
56
+ function isRecord(value) {
57
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
58
+ }
59
+ function normalizeSubscriptionInput(input) {
60
+ const rawUrl = input.url?.trim();
61
+ if (!rawUrl) {
62
+ throw new Error('subscription url is required');
63
+ }
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(rawUrl);
67
+ }
68
+ catch {
69
+ throw new Error('subscription url is invalid');
70
+ }
71
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
72
+ throw new Error('subscription url must use http or https');
73
+ }
74
+ const username = input.username?.trim() || decodeURIComponent(parsed.username);
75
+ const password = input.password || decodeURIComponent(parsed.password);
76
+ parsed.username = '';
77
+ parsed.password = '';
78
+ const inferredName = input.name?.trim()
79
+ || parsed.pathname.split('/').filter(Boolean).at(-1)
80
+ || parsed.hostname
81
+ || 'remote file';
82
+ return {
83
+ name: inferredName,
84
+ url: parsed.toString(),
85
+ username,
86
+ password
87
+ };
88
+ }
89
+ function validateSubscriptionYaml(content) {
90
+ let parsed;
91
+ try {
92
+ parsed = (0, yaml_1.parse)(content);
93
+ }
94
+ catch {
95
+ throw new Error('subscription yaml is invalid');
96
+ }
97
+ if (!isRecord(parsed)) {
98
+ throw new Error('subscription yaml is invalid');
99
+ }
100
+ if (!Array.isArray(parsed.proxies)
101
+ && !Array.isArray(parsed['proxy-groups'])
102
+ && !isRecord(parsed['proxy-providers'])) {
103
+ throw new Error('subscription yaml has no proxy definitions');
104
+ }
105
+ }
106
+ function normalizePort(value, label) {
107
+ if (!Number.isInteger(value) || value < 1024 || value > 65535) {
108
+ throw new Error(`${label} must be an integer between 1024 and 65535`);
109
+ }
110
+ return value;
111
+ }
112
+ class MihomoManager extends events_1.EventEmitter {
113
+ options;
114
+ db;
115
+ api;
116
+ paths;
117
+ child = null;
118
+ elevatedPid = null;
119
+ operation = Promise.resolve();
120
+ constructor(options) {
121
+ super();
122
+ this.options = options;
123
+ this.paths = pathsFromOptions(options);
124
+ (0, fs_1.mkdirSync)(this.paths.profiles, { recursive: true });
125
+ (0, fs_1.mkdirSync)(this.paths.runtime, { recursive: true });
126
+ (0, fs_1.mkdirSync)((0, path_1.join)(this.paths.root, 'bin'), { recursive: true });
127
+ this.db = new TunnelDatabase_1.TunnelDatabase(this.paths.db, {
128
+ admin: options.adminPort,
129
+ controller: options.controllerPort,
130
+ mixed: options.mixedPort,
131
+ dns: options.dnsPort
132
+ });
133
+ this.api = new MihomoApi_1.MihomoApi(() => this.db.getSettings());
134
+ }
135
+ status() {
136
+ const settings = this.db.getSettings();
137
+ const corePath = settings.corePath ?? ((0, fs_1.existsSync)(this.paths.core) ? this.paths.core : null);
138
+ const running = this.isRunning();
139
+ return {
140
+ running,
141
+ pid: running ? this.child?.pid ?? this.elevatedPid : null,
142
+ mode: settings.mode,
143
+ tunInstalled: settings.tunInstalled,
144
+ ports: settings.ports,
145
+ activeSubscription: this.db.getActiveSubscription(),
146
+ corePath,
147
+ adminUrl: `http://127.0.0.1:${settings.ports.admin}`,
148
+ controllerUrl: `http://127.0.0.1:${settings.ports.controller}`
149
+ };
150
+ }
151
+ async snapshot() {
152
+ return {
153
+ status: this.status(),
154
+ subscriptions: this.listSubscriptions(),
155
+ rules: this.listRules(),
156
+ events: this.listEvents(),
157
+ traffic: await this.trafficSummary()
158
+ };
159
+ }
160
+ async trafficSummary() {
161
+ if (!this.isRunning()) {
162
+ return {
163
+ available: false,
164
+ connections: 0,
165
+ uploadTotal: 0,
166
+ downloadTotal: 0
167
+ };
168
+ }
169
+ try {
170
+ const response = await this.api.connections();
171
+ const data = response.data;
172
+ return {
173
+ available: response.ok,
174
+ connections: Array.isArray(data?.connections) ? data.connections.length : 0,
175
+ uploadTotal: Number(data?.uploadTotal ?? 0),
176
+ downloadTotal: Number(data?.downloadTotal ?? 0)
177
+ };
178
+ }
179
+ catch {
180
+ return {
181
+ available: false,
182
+ connections: 0,
183
+ uploadTotal: 0,
184
+ downloadTotal: 0
185
+ };
186
+ }
187
+ }
188
+ listSubscriptions() {
189
+ return this.db.listSubscriptions();
190
+ }
191
+ listRules() {
192
+ return this.db.listRules();
193
+ }
194
+ listEvents() {
195
+ return this.db.listEvents();
196
+ }
197
+ async createSubscription(input) {
198
+ const normalized = normalizeSubscriptionInput(input);
199
+ const content = await this.fetchSubscriptionContent(normalized);
200
+ const subscription = this.db.createSubscription(normalized);
201
+ try {
202
+ const localPath = this.localSubscriptionPath(subscription.id);
203
+ (0, fs_1.writeFileSync)(localPath, content, 'utf8');
204
+ const updated = this.db.updateSubscriptionContent(subscription.id, content, localPath);
205
+ this.log('info', `Subscription created: ${updated.name}`);
206
+ return updated;
207
+ }
208
+ catch (error) {
209
+ this.db.deleteSubscription(subscription.id);
210
+ throw error;
211
+ }
212
+ }
213
+ localSubscriptionPath(id) {
214
+ return (0, path_1.join)(this.paths.profiles, `subscription-${id}.yaml`);
215
+ }
216
+ async fetchSubscriptionContent(subscription) {
217
+ const headers = {};
218
+ if (subscription.username || subscription.password) {
219
+ headers.Authorization = basicAuthHeader(subscription.username ?? '', subscription.password ?? '');
220
+ }
221
+ this.log('info', `Fetching subscription: ${subscription.url}`);
222
+ const response = await fetch(subscription.url, { headers });
223
+ if (!response.ok) {
224
+ throw new Error(`subscription update failed: HTTP ${response.status}`);
225
+ }
226
+ const content = await response.text();
227
+ if (!content.trim()) {
228
+ throw new Error('subscription update failed: empty body');
229
+ }
230
+ validateSubscriptionYaml(content);
231
+ return content;
232
+ }
233
+ deleteSubscription(id) {
234
+ this.db.deleteSubscription(id);
235
+ this.log('info', `Subscription deleted: ${id}`);
236
+ }
237
+ setActiveSubscription(id) {
238
+ const subscription = this.db.setActiveSubscription(id);
239
+ this.log('info', `Active subscription switched: ${subscription.name}`);
240
+ return subscription;
241
+ }
242
+ setMode(mode) {
243
+ const current = this.db.getSettings().mode;
244
+ if (current === mode) {
245
+ return false;
246
+ }
247
+ this.db.updateSettings({ mode });
248
+ this.log('info', `Runtime mode switched: ${mode}`);
249
+ return true;
250
+ }
251
+ async setLocalPorts(ports) {
252
+ const mixed = ports.mixed === undefined ? undefined : normalizePort(ports.mixed, 'mixed port');
253
+ const dns = ports.dns === undefined ? undefined : normalizePort(ports.dns, 'dns port');
254
+ const before = this.db.getSettings().ports;
255
+ const after = this.db.updatePorts({ mixed, dns }).ports;
256
+ this.log('info', `Local ports updated: mixed ${before.mixed}->${after.mixed}, dns ${before.dns}->${after.dns}`);
257
+ await this.applyRuntimeConfigChange();
258
+ }
259
+ installTunFeature() {
260
+ this.db.updateSettings({ tunInstalled: true });
261
+ this.log('info', 'TUN feature enabled for generated runtime config');
262
+ }
263
+ uninstallTunFeature() {
264
+ const settings = this.db.getSettings();
265
+ this.db.updateSettings({
266
+ tunInstalled: false,
267
+ mode: settings.mode === 'system-tun' ? 'app-rule' : settings.mode
268
+ });
269
+ this.log('info', 'TUN feature disabled for generated runtime config');
270
+ }
271
+ installCoreFromPath(sourcePath) {
272
+ if (!(0, fs_1.existsSync)(sourcePath)) {
273
+ throw new Error(`Tunnel engine not found: ${sourcePath}`);
274
+ }
275
+ const target = (0, path_1.join)(this.paths.root, 'bin', (0, path_1.basename)(sourcePath));
276
+ (0, fs_1.copyFileSync)(sourcePath, target);
277
+ this.db.updateSettings({ corePath: target });
278
+ this.log('info', `Mihomo core installed: ${target}`);
279
+ return target;
280
+ }
281
+ setCorePath(corePath) {
282
+ const normalized = corePath.trim() || null;
283
+ this.db.updateSettings({ corePath: normalized });
284
+ this.log('info', `Mihomo core path set: ${normalized ?? 'bundled/default'}`);
285
+ }
286
+ async updateSubscription(id) {
287
+ const subscription = this.db.getSubscription(id);
288
+ if (!subscription) {
289
+ throw new Error(`subscription not found: ${id}`);
290
+ }
291
+ const content = await this.fetchSubscriptionContent(subscription);
292
+ const localPath = this.localSubscriptionPath(subscription.id);
293
+ (0, fs_1.writeFileSync)(localPath, content, 'utf8');
294
+ const updated = this.db.updateSubscriptionContent(subscription.id, content, localPath);
295
+ this.log('info', `Subscription updated: ${subscription.name}`);
296
+ return updated;
297
+ }
298
+ async updateActiveSubscription() {
299
+ const active = this.db.getActiveSubscription();
300
+ if (!active) {
301
+ throw new Error('no active subscription configured');
302
+ }
303
+ return this.updateSubscription(active.id);
304
+ }
305
+ addDomainRule(kind, domain, source = 'manual') {
306
+ const rule = this.db.upsertRule(kind, domain, source);
307
+ this.log('info', `Domain rule saved: ${kind} ${rule.domain}`);
308
+ return rule;
309
+ }
310
+ addPreset(preset) {
311
+ const domains = defaults_1.DOMAIN_PRESETS[preset];
312
+ const rules = domains.map((domain) => this.db.upsertRule('allow', domain, `preset:${preset}`));
313
+ this.log('info', `Preset allowlist added: ${preset}`);
314
+ return rules;
315
+ }
316
+ removeDomainRule(id) {
317
+ this.db.removeRule(id);
318
+ this.log('info', `Domain rule removed: ${id}`);
319
+ }
320
+ renderConfig() {
321
+ const settings = this.db.getSettings();
322
+ const active = this.db.getActiveSubscription();
323
+ if (!active?.content) {
324
+ throw new Error('active subscription has no downloaded content');
325
+ }
326
+ const rendered = (0, renderRuntimeConfig_1.renderRuntimeConfig)({
327
+ baseYaml: active.content,
328
+ settings,
329
+ rules: this.db.listRules()
330
+ });
331
+ (0, fs_1.writeFileSync)(this.paths.config, rendered.yaml, 'utf8');
332
+ this.log('info', `Runtime config rendered with policy ${rendered.proxyPolicyName}`);
333
+ return this.paths.config;
334
+ }
335
+ runExclusive(task) {
336
+ const next = this.operation.then(task, task);
337
+ this.operation = next.catch(() => undefined);
338
+ return next;
339
+ }
340
+ async start() {
341
+ await this.runExclusive(() => this.startUnlocked());
342
+ }
343
+ async startUnlocked() {
344
+ if (this.isRunning()) {
345
+ return;
346
+ }
347
+ const settings = this.db.getSettings();
348
+ const corePath = this.resolveCorePath();
349
+ if (!(0, fs_1.existsSync)(corePath)) {
350
+ throw new Error(`Tunnel engine is not installed: ${corePath}. Package the QPJoy tunnel engine resources with your Electron app.`);
351
+ }
352
+ const configPath = this.renderConfig();
353
+ if (needsElevatedTun(settings)) {
354
+ await this.startElevated(corePath, configPath);
355
+ return;
356
+ }
357
+ this.child = (0, child_process_1.spawn)(corePath, ['-d', this.paths.runtime, '-f', configPath], {
358
+ cwd: this.paths.runtime,
359
+ env: {
360
+ ...process.env,
361
+ NO_COLOR: '1'
362
+ }
363
+ });
364
+ this.child.stdout.on('data', (chunk) => this.log('info', chunk.toString().trim()));
365
+ this.child.stderr.on('data', (chunk) => this.log('warn', chunk.toString().trim()));
366
+ this.child.on('exit', (code, signal) => {
367
+ this.log(code === 0 ? 'info' : 'warn', `Mihomo exited code=${code ?? 'null'} signal=${signal ?? 'null'}`);
368
+ this.child = null;
369
+ });
370
+ this.log('info', `Mihomo started pid=${this.child.pid ?? 'unknown'}`);
371
+ }
372
+ resolveCorePath() {
373
+ const settings = this.db.getSettings();
374
+ if (settings.corePath && (0, fs_1.existsSync)(settings.corePath)) {
375
+ return settings.corePath;
376
+ }
377
+ if ((0, fs_1.existsSync)(this.paths.core)) {
378
+ return this.paths.core;
379
+ }
380
+ const bundled = this.findBundledCore();
381
+ if (bundled) {
382
+ this.installBundledCore(bundled);
383
+ return this.paths.core;
384
+ }
385
+ return settings.corePath || this.paths.core;
386
+ }
387
+ findBundledCore() {
388
+ const bundledEngineDir = this.options.bundledEngineDir ?? this.options.bundledCoreDir;
389
+ if (!bundledEngineDir) {
390
+ return null;
391
+ }
392
+ const key = platformArchKey();
393
+ const aliases = key.endsWith('-x64') ? [key, key.replace('-x64', '-amd64')] : [key];
394
+ const names = ['mihomo', 'mihomo.gz'];
395
+ const candidates = aliases.flatMap((alias) => names.map((name) => (0, path_1.join)(bundledEngineDir, alias, name)));
396
+ for (const candidate of candidates) {
397
+ if ((0, fs_1.existsSync)(candidate)) {
398
+ return candidate;
399
+ }
400
+ }
401
+ return null;
402
+ }
403
+ installBundledCore(sourcePath) {
404
+ (0, fs_1.mkdirSync)((0, path_1.join)(this.paths.root, 'bin'), { recursive: true });
405
+ if (sourcePath.endsWith('.gz')) {
406
+ (0, fs_1.writeFileSync)(this.paths.core, (0, zlib_1.gunzipSync)((0, fs_1.readFileSync)(sourcePath)));
407
+ }
408
+ else {
409
+ (0, fs_1.copyFileSync)(sourcePath, this.paths.core);
410
+ }
411
+ (0, fs_1.chmodSync)(this.paths.core, 0o755);
412
+ this.db.updateSettings({ corePath: this.paths.core });
413
+ this.log('info', `Bundled Mihomo core installed: ${this.paths.core}`);
414
+ }
415
+ runElevatedShell(command) {
416
+ const launcher = process.platform === 'darwin'
417
+ ? {
418
+ command: '/usr/bin/osascript',
419
+ args: ['-e', `do shell script ${JSON.stringify(command)} with administrator privileges`]
420
+ }
421
+ : {
422
+ command: (0, fs_1.existsSync)('/usr/bin/pkexec') ? '/usr/bin/pkexec' : '/bin/pkexec',
423
+ args: ['/bin/sh', '-lc', command]
424
+ };
425
+ if (!(0, fs_1.existsSync)(launcher.command)) {
426
+ throw new Error('TUN mode requires administrator privileges, but no supported privilege helper was found.');
427
+ }
428
+ return new Promise((resolve, reject) => {
429
+ const child = (0, child_process_1.spawn)(launcher.command, launcher.args);
430
+ let stdout = '';
431
+ let stderr = '';
432
+ child.stdout.on('data', (chunk) => {
433
+ stdout += chunk.toString();
434
+ });
435
+ child.stderr.on('data', (chunk) => {
436
+ stderr += chunk.toString();
437
+ });
438
+ child.on('error', reject);
439
+ child.on('exit', (code) => {
440
+ if (code === 0) {
441
+ resolve(stdout.trim());
442
+ return;
443
+ }
444
+ reject(new Error(stderr.trim() || stdout.trim() || 'TUN mode requires administrator approval.'));
445
+ });
446
+ });
447
+ }
448
+ async startElevated(corePath, configPath) {
449
+ const logPath = (0, path_1.join)(this.paths.runtime, 'mihomo-elevated.log');
450
+ const command = [
451
+ ':',
452
+ '>',
453
+ shellQuote(logPath),
454
+ ';',
455
+ shellQuote(corePath),
456
+ '-d',
457
+ shellQuote(this.paths.runtime),
458
+ '-f',
459
+ shellQuote(configPath),
460
+ '>>',
461
+ shellQuote(logPath),
462
+ '2>&1',
463
+ '&',
464
+ 'echo $!'
465
+ ].join(' ');
466
+ const output = await this.runElevatedShell(command);
467
+ const pid = Number(output.split(/\s+/).at(-1));
468
+ if (!Number.isInteger(pid) || pid <= 0) {
469
+ throw new Error(`Failed to start privileged tunnel engine: ${output || 'empty pid'}`);
470
+ }
471
+ this.child = null;
472
+ this.rememberElevatedPid(pid);
473
+ await delay(900);
474
+ if (!isProcessAlive(pid)) {
475
+ this.clearElevatedPid();
476
+ const details = this.readElevatedLogTail(logPath);
477
+ throw new Error(`Privileged mihomo exited immediately.${details ? ` ${details}` : ''}`);
478
+ }
479
+ this.log('info', `Mihomo started with administrator privileges pid=${pid}`);
480
+ this.log('info', `Privileged Mihomo log file: ${logPath}`);
481
+ }
482
+ readElevatedLogTail(logPath) {
483
+ try {
484
+ const lines = (0, fs_1.readFileSync)(logPath, 'utf8')
485
+ .trim()
486
+ .split('\n')
487
+ .slice(-12)
488
+ .join('\n');
489
+ return lines ? `Recent log:\n${lines}` : '';
490
+ }
491
+ catch {
492
+ return '';
493
+ }
494
+ }
495
+ elevatedPidPath() {
496
+ return (0, path_1.join)(this.paths.runtime, 'mihomo-elevated.pid');
497
+ }
498
+ readElevatedPid() {
499
+ if (this.elevatedPid && isProcessAlive(this.elevatedPid)) {
500
+ return this.elevatedPid;
501
+ }
502
+ try {
503
+ const pid = Number((0, fs_1.readFileSync)(this.elevatedPidPath(), 'utf8').trim());
504
+ if (Number.isInteger(pid) && pid > 0 && isProcessAlive(pid)) {
505
+ this.elevatedPid = pid;
506
+ return pid;
507
+ }
508
+ }
509
+ catch {
510
+ // Missing or stale pid files are cleaned up by clearElevatedPid().
511
+ }
512
+ this.clearElevatedPid();
513
+ return null;
514
+ }
515
+ rememberElevatedPid(pid) {
516
+ this.elevatedPid = pid;
517
+ (0, fs_1.writeFileSync)(this.elevatedPidPath(), `${pid}\n`, 'utf8');
518
+ }
519
+ clearElevatedPid() {
520
+ this.elevatedPid = null;
521
+ try {
522
+ (0, fs_1.unlinkSync)(this.elevatedPidPath());
523
+ }
524
+ catch {
525
+ // It is fine when the pid file does not exist.
526
+ }
527
+ }
528
+ isRunning() {
529
+ if (this.child && !this.child.killed) {
530
+ return true;
531
+ }
532
+ if (this.readElevatedPid()) {
533
+ return true;
534
+ }
535
+ return false;
536
+ }
537
+ isElevatedRunning() {
538
+ return Boolean(this.readElevatedPid());
539
+ }
540
+ async reloadRuntimeConfig() {
541
+ const configPath = this.renderConfig();
542
+ const response = await this.api.reloadConfig(configPath);
543
+ if (response.ok) {
544
+ this.log('info', 'Runtime config hot reloaded');
545
+ return true;
546
+ }
547
+ this.log('warn', `Runtime config hot reload failed: HTTP ${response.status}`);
548
+ return false;
549
+ }
550
+ async stop() {
551
+ await this.runExclusive(() => this.stopUnlocked({ allowElevatedPrompt: true }));
552
+ }
553
+ async stopUnlocked(options = {}) {
554
+ const elevatedPid = this.readElevatedPid();
555
+ if (elevatedPid) {
556
+ const pid = elevatedPid;
557
+ if (!isProcessAlive(pid)) {
558
+ this.clearElevatedPid();
559
+ this.log('info', 'Privileged Mihomo was already stopped');
560
+ return;
561
+ }
562
+ if (!options.allowElevatedPrompt) {
563
+ this.log('info', 'Privileged Mihomo is still running; skipped elevated stop to avoid another administrator prompt');
564
+ return;
565
+ }
566
+ await this.runElevatedShell(`kill ${pid} 2>/dev/null || true`);
567
+ this.clearElevatedPid();
568
+ this.log('info', 'Privileged Mihomo stop requested');
569
+ return;
570
+ }
571
+ if (!this.child || this.child.killed) {
572
+ return;
573
+ }
574
+ this.child.kill('SIGTERM');
575
+ this.log('info', 'Mihomo stop requested');
576
+ }
577
+ async restart() {
578
+ await this.runExclusive(async () => {
579
+ if (this.isElevatedRunning()) {
580
+ const reloaded = await this.reloadRuntimeConfig();
581
+ if (reloaded) {
582
+ return;
583
+ }
584
+ this.log('warn', 'Privileged Mihomo restart skipped because it would require another administrator prompt');
585
+ return;
586
+ }
587
+ await this.stopUnlocked();
588
+ await delay(400);
589
+ await this.startUnlocked();
590
+ });
591
+ }
592
+ async applyRuntimeConfigChange() {
593
+ await this.runExclusive(async () => {
594
+ if (!this.isRunning()) {
595
+ return;
596
+ }
597
+ const settings = this.db.getSettings();
598
+ if (needsElevatedTun(settings) && !this.isElevatedRunning()) {
599
+ await this.stopUnlocked();
600
+ await delay(400);
601
+ await this.startUnlocked();
602
+ return;
603
+ }
604
+ const reloaded = await this.reloadRuntimeConfig();
605
+ if (!reloaded && !this.isElevatedRunning()) {
606
+ await this.stopUnlocked();
607
+ await delay(400);
608
+ await this.startUnlocked();
609
+ }
610
+ });
611
+ }
612
+ async handleNetworkChanged(reason) {
613
+ this.log('info', `Network change detected: ${reason}`);
614
+ if (!this.isRunning()) {
615
+ return;
616
+ }
617
+ await this.applyRuntimeConfigChange();
618
+ }
619
+ close() {
620
+ if (this.child && !this.child.killed) {
621
+ this.child.kill('SIGTERM');
622
+ this.child = null;
623
+ }
624
+ this.db.close();
625
+ }
626
+ log(level, message) {
627
+ const clean = message.trim();
628
+ if (!clean) {
629
+ return;
630
+ }
631
+ this.db.addEvent(level, clean);
632
+ this.emit('event', { level, message: clean });
633
+ }
634
+ }
635
+ exports.MihomoManager = MihomoManager;
@@ -0,0 +1,3 @@
1
+ export declare function hashPassword(password: string): string;
2
+ export declare function verifyPassword(password: string, storedHash: string): boolean;
3
+ export declare function createSessionToken(): string;
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hashPassword = hashPassword;
4
+ exports.verifyPassword = verifyPassword;
5
+ exports.createSessionToken = createSessionToken;
6
+ const crypto_1 = require("crypto");
7
+ const KEY_LENGTH = 32;
8
+ function hashPassword(password) {
9
+ const salt = (0, crypto_1.randomBytes)(16).toString('hex');
10
+ const hash = (0, crypto_1.scryptSync)(password, salt, KEY_LENGTH).toString('hex');
11
+ return `scrypt:${salt}:${hash}`;
12
+ }
13
+ function verifyPassword(password, storedHash) {
14
+ const [method, salt, hash] = storedHash.split(':');
15
+ if (method !== 'scrypt' || !salt || !hash) {
16
+ return false;
17
+ }
18
+ const expected = Buffer.from(hash, 'hex');
19
+ const actual = (0, crypto_1.scryptSync)(password, salt, KEY_LENGTH);
20
+ return expected.length === actual.length && (0, crypto_1.timingSafeEqual)(expected, actual);
21
+ }
22
+ function createSessionToken() {
23
+ return (0, crypto_1.randomBytes)(32).toString('hex');
24
+ }
@@ -0,0 +1,4 @@
1
+ import type { Session } from 'electron';
2
+ import type { RuntimeMode, TunnelPorts } from '../types';
3
+ export declare function applyElectronProxy(session: Session, mode: RuntimeMode, ports: TunnelPorts): Promise<void>;
4
+ export declare function proxyEnv(ports: TunnelPorts): NodeJS.ProcessEnv;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyElectronProxy = applyElectronProxy;
4
+ exports.proxyEnv = proxyEnv;
5
+ async function applyElectronProxy(session, mode, ports) {
6
+ if (mode === 'system-tun') {
7
+ await session.setProxy({ mode: 'direct' });
8
+ return;
9
+ }
10
+ const proxyRules = [
11
+ `http=127.0.0.1:${ports.mixed}`,
12
+ `https=127.0.0.1:${ports.mixed}`,
13
+ `socks5=127.0.0.1:${ports.mixed}`
14
+ ].join(';');
15
+ await session.setProxy({
16
+ mode: 'fixed_servers',
17
+ proxyRules,
18
+ proxyBypassRules: '<local>;localhost;127.0.0.1;::1'
19
+ });
20
+ }
21
+ function proxyEnv(ports) {
22
+ return {
23
+ http_proxy: `http://127.0.0.1:${ports.mixed}`,
24
+ https_proxy: `http://127.0.0.1:${ports.mixed}`,
25
+ HTTP_PROXY: `http://127.0.0.1:${ports.mixed}`,
26
+ HTTPS_PROXY: `http://127.0.0.1:${ports.mixed}`,
27
+ all_proxy: `socks5h://127.0.0.1:${ports.mixed}`,
28
+ ALL_PROXY: `socks5h://127.0.0.1:${ports.mixed}`
29
+ };
30
+ }