@qpjoy/electron-tunnel 0.1.2 → 0.1.4

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.
@@ -209,6 +209,23 @@ class AdminServer {
209
209
  sendJson(res, 200, subscription);
210
210
  };
211
211
  }
212
+ const editSubscriptionMatch = pathname.match(/^\/api\/subscriptions\/(\d+)$/);
213
+ if ((method === 'PATCH' || method === 'PUT') && editSubscriptionMatch) {
214
+ return async (_req, res, body) => {
215
+ const input = body;
216
+ const subscription = await this.manager.editSubscription({
217
+ ...input,
218
+ id: Number(editSubscriptionMatch[1])
219
+ });
220
+ if (subscription.active) {
221
+ await this.applyRuntimeConfigChange();
222
+ }
223
+ else {
224
+ await this.notifySettingsChange();
225
+ }
226
+ sendJson(res, 200, subscription);
227
+ };
228
+ }
212
229
  const deleteSubscriptionMatch = pathname.match(/^\/api\/subscriptions\/(\d+)$/);
213
230
  if (method === 'DELETE' && deleteSubscriptionMatch) {
214
231
  return async (_req, res) => {
@@ -241,6 +258,13 @@ class AdminServer {
241
258
  sendJson(res, 200, rules);
242
259
  };
243
260
  }
261
+ if (method === 'DELETE' && presetMatch) {
262
+ return async (_req, res) => {
263
+ const count = this.manager.removePreset(presetMatch[1]);
264
+ await this.applyRuntimeConfigChange();
265
+ sendJson(res, 200, { ok: true, count });
266
+ };
267
+ }
244
268
  return null;
245
269
  }
246
270
  }
@@ -57,7 +57,7 @@ function navMarkup() {
57
57
  function presetMarkup() {
58
58
  return Object.keys(defaults_1.DOMAIN_PRESETS).map((id) => {
59
59
  const preset = presetLabels[id] ?? { label: id, icon: 'globe' };
60
- return '<button class="btn outline" data-preset="' + id + '">' + icon(preset.icon) + '<span>' + preset.label + '</span></button>';
60
+ return '<button class="btn outline" data-preset="' + id + '" data-preset-button="' + id + '">' + icon(preset.icon) + '<span>' + preset.label + '</span></button>';
61
61
  }).join('');
62
62
  }
63
63
  function adminHtml() {
@@ -105,6 +105,7 @@ function adminHtml() {
105
105
  .field-short{width:150px}
106
106
  .btn{height:42px;border:1px solid #1578ff;border-radius:6px;background:#1976d2;color:#fff;padding:0 16px;display:inline-flex;align-items:center;justify-content:center;gap:9px;font-weight:800;white-space:nowrap}
107
107
  .btn.outline{background:#fff;color:#1976d2}
108
+ .btn.active{background:#dce9ff;color:#0f62d0}
108
109
  .btn.negative{background:#fff;color:#c10015;border-color:#c10015}
109
110
  .icon-button{width:46px;height:46px;border:0;border-radius:50%;background:transparent;color:#101418;display:grid;place-items:center}
110
111
  .icon-button.outline{border:1px solid #1976d2;color:#1976d2;background:#fff}
@@ -124,6 +125,12 @@ function adminHtml() {
124
125
  .mono-log{height:calc(100vh - 150px);min-height:280px;overflow:auto;border-radius:8px;background:#101418;color:#dbeafe;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;padding:12px;white-space:pre-wrap}
125
126
  .toast{position:fixed;right:18px;top:18px;z-index:20;max-width:min(520px,calc(100vw - 36px));border-radius:6px;padding:12px 16px;background:#21ba45;color:#fff;box-shadow:0 8px 24px rgba(16,24,40,.18);font-weight:700}
126
127
  .toast.negative{background:#c10015}
128
+ .modal-backdrop{position:fixed;inset:0;z-index:15;display:grid;place-items:center;background:rgba(15,23,42,.28);padding:24px}
129
+ .modal-card{width:min(720px,100%);border:1px solid #dfe4ea;border-radius:8px;background:#fff;padding:18px;box-shadow:0 18px 48px rgba(16,24,40,.22)}
130
+ .modal-title{margin:0 0 14px;font-size:22px;font-weight:800}
131
+ .modal-form{display:grid;grid-template-columns:1fr 1fr;gap:12px}
132
+ .modal-form .wide{grid-column:1 / -1}
133
+ .modal-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:16px}
127
134
  .empty{color:#697386;padding:10px 0}
128
135
  [hidden]{display:none!important}
129
136
  @media (max-width:860px){
@@ -166,6 +173,7 @@ function adminHtml() {
166
173
  </div>
167
174
 
168
175
  <div id="toast" class="toast" hidden></div>
176
+ <div id="modal" class="modal-backdrop" hidden></div>
169
177
 
170
178
  <script>
171
179
  var token = window.localStorage.getItem('qpjoyTunnelAdminToken') || '';
@@ -218,14 +226,16 @@ function adminHtml() {
218
226
  toast.timer = window.setTimeout(function () { el.hidden = true; }, 1800);
219
227
  }
220
228
  async function run(action, message) {
221
- if (busy) return;
229
+ if (busy) return false;
222
230
  setBusy(true);
223
231
  try {
224
232
  await action();
225
233
  await refresh();
226
234
  if (message) toast(message, false);
235
+ return true;
227
236
  } catch (error) {
228
237
  toast(error instanceof Error ? error.message : String(error), true);
238
+ return false;
229
239
  } finally {
230
240
  setBusy(false);
231
241
  }
@@ -267,6 +277,12 @@ function adminHtml() {
267
277
  function status() {
268
278
  return snapshot ? snapshot.status : null;
269
279
  }
280
+ function presetActive(preset) {
281
+ return !!((snapshot && snapshot.rules) || []).some(function (rule) { return rule.source === 'preset:' + preset; });
282
+ }
283
+ function subscriptionById(id) {
284
+ return ((snapshot && snapshot.subscriptions) || []).find(function (sub) { return sub.id === Number(id); });
285
+ }
270
286
  function renderFrame() {
271
287
  var s = status();
272
288
  byId('pageTitle').textContent = pageTitles[currentPage] || '首页';
@@ -332,6 +348,7 @@ function adminHtml() {
332
348
  var cards = items.length ? items.map(function (sub) {
333
349
  return '<article class="subscription-card ' + (sub.active ? 'active' : '') + '">' +
334
350
  '<div class="card-head"><span>${icon('subscriptions')}</span><div class="card-title">' + escapeHtml(sub.name) + '</div><div class="spacer"></div>' +
351
+ '<button class="icon-button" data-edit-sub="' + sub.id + '" title="编辑">${icon('save')}</button>' +
335
352
  '<button class="icon-button" data-refresh-sub="' + sub.id + '" title="刷新">${icon('refresh')}</button>' +
336
353
  '<button class="icon-button" data-delete-sub="' + sub.id + '" title="删除">${icon('delete')}</button></div>' +
337
354
  '<div class="muted ellipsis">' + escapeHtml(redactedUrl(sub.url)) + '</div>' +
@@ -402,6 +419,51 @@ function adminHtml() {
402
419
  else if (currentPage === 'logs') body.innerHTML = renderLogs();
403
420
  else body.innerHTML = renderHome();
404
421
  bindPageEvents();
422
+ document.querySelectorAll('[data-preset-button]').forEach(function (button) {
423
+ var preset = button.dataset.presetButton;
424
+ var active = presetActive(preset);
425
+ button.classList.toggle('active', active);
426
+ button.title = active ? '再次点击移除这一组白名单' : '点击加入这一组白名单';
427
+ });
428
+ }
429
+ function closeModal() {
430
+ var modal = byId('modal');
431
+ modal.hidden = true;
432
+ modal.innerHTML = '';
433
+ }
434
+ function openEditSubscriptionModal(id) {
435
+ var sub = subscriptionById(id);
436
+ if (!sub) return;
437
+ var modal = byId('modal');
438
+ modal.innerHTML = '<div class="modal-card">' +
439
+ '<h2 class="modal-title">编辑订阅</h2>' +
440
+ '<div class="modal-form">' +
441
+ '<input id="editSubUrl" class="field wide" placeholder="订阅文件链接" value="' + escapeHtml(sub.url) + '">' +
442
+ '<input id="editSubName" class="field" placeholder="名称" value="' + escapeHtml(sub.name) + '">' +
443
+ '<input id="editSubUser" class="field" placeholder="用户" value="' + escapeHtml(sub.username || '') + '">' +
444
+ '<input id="editSubPass" class="field wide" type="password" placeholder="密码" value="' + escapeHtml(sub.password || '') + '">' +
445
+ '</div>' +
446
+ '<div class="modal-actions">' +
447
+ '<button id="cancelEditSub" class="btn outline">取消</button>' +
448
+ '<button id="saveEditSub" class="btn">${icon('save')}<span>保存</span></button>' +
449
+ '</div>' +
450
+ '</div>';
451
+ modal.hidden = false;
452
+ byId('cancelEditSub').onclick = closeModal;
453
+ byId('saveEditSub').onclick = async function () {
454
+ var saved = await run(function () {
455
+ return api('/api/subscriptions/' + sub.id, {
456
+ method: 'PATCH',
457
+ body: JSON.stringify({
458
+ name: byId('editSubName').value,
459
+ url: byId('editSubUrl').value,
460
+ username: byId('editSubUser').value,
461
+ password: byId('editSubPass').value
462
+ })
463
+ });
464
+ }, '订阅已保存');
465
+ if (saved) closeModal();
466
+ };
405
467
  }
406
468
  function bindPageEvents() {
407
469
  var modeSelect = byId('modeSelect');
@@ -453,12 +515,16 @@ function adminHtml() {
453
515
  document.body.onclick = function (event) {
454
516
  var target = event.target;
455
517
  if (!(target instanceof Element)) return;
456
- var element = target.closest('[data-preset],[data-active-sub],[data-refresh-sub],[data-delete-sub],[data-rule-remove],[data-test-url]');
518
+ var element = target.closest('[data-preset],[data-active-sub],[data-refresh-sub],[data-delete-sub],[data-edit-sub],[data-rule-remove],[data-test-url]');
457
519
  if (!element) return;
458
- if (element.dataset.preset) run(function () { return api('/api/presets/' + element.dataset.preset, { method: 'POST' }); }, '白名单集合已加入');
520
+ if (element.dataset.preset) {
521
+ var active = presetActive(element.dataset.preset);
522
+ run(function () { return api('/api/presets/' + element.dataset.preset, { method: active ? 'DELETE' : 'POST' }); }, active ? '白名单集合已移除' : '白名单集合已加入');
523
+ }
459
524
  if (element.dataset.activeSub) run(function () { return api('/api/subscriptions/' + element.dataset.activeSub + '/active', { method: 'POST' }); }, '订阅已启用');
460
525
  if (element.dataset.refreshSub) run(function () { return api('/api/subscriptions/' + element.dataset.refreshSub + '/update', { method: 'POST' }); }, '订阅已更新');
461
526
  if (element.dataset.deleteSub) run(function () { return api('/api/subscriptions/' + element.dataset.deleteSub, { method: 'DELETE' }); }, '订阅已删除');
527
+ if (element.dataset.editSub) openEditSubscriptionModal(Number(element.dataset.editSub));
462
528
  if (element.dataset.ruleRemove) run(function () { return api('/api/rules/' + element.dataset.ruleRemove, { method: 'DELETE' }); }, '规则已删除');
463
529
  if (element.dataset.testUrl) {
464
530
  var testInput = byId('testUrl');
@@ -7,6 +7,7 @@ const AdminServer_1 = require("./admin/AdminServer");
7
7
  const registerTunnelIpc_1 = require("./ipc/registerTunnelIpc");
8
8
  const MihomoManager_1 = require("./mihomo/MihomoManager");
9
9
  const electronProxy_1 = require("./system/electronProxy");
10
+ const TUNNEL_PLUGIN_ID = 'qpjoy.electron-tunnel';
10
11
  function defaultBundledEngineDir() {
11
12
  const resourcesPath = process.resourcesPath ?? process.cwd();
12
13
  const packageDir = typeof __dirname === 'undefined' ? process.cwd() : __dirname;
@@ -38,6 +39,13 @@ function createElectronTunnel(host, options = {}) {
38
39
  (0, registerTunnelIpc_1.registerTunnelIpc)(host.ipcMain, manager, {
39
40
  afterSettingsChange: applyProxy
40
41
  });
42
+ // Self-register in the shared marketplace.db so the panel (when it shows
43
+ // up later) knows the tunnel is here. Best-effort; failure is silent —
44
+ // tunnel must keep working even if marketplace-db is missing.
45
+ registerSelfInMarketplaceDb(host.app, options).catch((err) => {
46
+ // eslint-disable-next-line no-console
47
+ console.warn('[electron-tunnel] marketplace-db self-register failed:', err);
48
+ });
41
49
  return {
42
50
  manager,
43
51
  admin,
@@ -49,3 +57,71 @@ function createElectronTunnel(host, options = {}) {
49
57
  }
50
58
  };
51
59
  }
60
+ async function registerSelfInMarketplaceDb(app, options) {
61
+ // Late require so a missing peer dep doesn't break standalone use. Use a
62
+ // computed specifier + cast so tsc never tries to resolve types for it.
63
+ const specifier = '@qpjoy/marketplace-db';
64
+ let mod;
65
+ try {
66
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval, @typescript-eslint/no-var-requires
67
+ mod = require(specifier);
68
+ }
69
+ catch {
70
+ return; // package not installed alongside tunnel — fine.
71
+ }
72
+ const userDataPath = options.userDataPath ?? app.getPath('userData');
73
+ const dbPath = mod.resolveMarketplaceDbPath(userDataPath);
74
+ const packageJson = readNearbyJson('package.json');
75
+ const manifest = readNearbyJson('plugin.manifest.json', 'dist');
76
+ const db = mod.MarketplaceDB.open(dbPath);
77
+ try {
78
+ if (db.getInstalled(TUNNEL_PLUGIN_ID))
79
+ return; // host already registered us
80
+ const version = packageJson?.version ?? '0.0.0';
81
+ db.upsertInstalled({
82
+ id: TUNNEL_PLUGIN_ID,
83
+ npm: '@qpjoy/electron-tunnel',
84
+ version,
85
+ installPath: resolveTunnelPackageRoot(),
86
+ installSource: 'standalone',
87
+ manifest: {
88
+ id: TUNNEL_PLUGIN_ID,
89
+ name: 'QPJoy Tunnel',
90
+ version,
91
+ engines: { electronPlugin: '>=0.1.0', electron: '>=28' },
92
+ permissions: manifest?.permissions ?? [],
93
+ activationEvents: ['onStartup'],
94
+ contributes: { adminPanel: { url: 'http://127.0.0.1:23456', label: 'Tunnel' } }
95
+ },
96
+ // Standalone tunnel granted itself everything; the user implicitly
97
+ // accepted by choosing to install it directly (not via marketplace).
98
+ grantedPermissions: manifest?.permissions ?? [],
99
+ state: 'active',
100
+ errorMessage: null,
101
+ marketplaceEntryId: TUNNEL_PLUGIN_ID
102
+ });
103
+ }
104
+ finally {
105
+ db.close();
106
+ }
107
+ }
108
+ function readNearbyJson(name, sub) {
109
+ const packageDir = typeof __dirname === 'undefined' ? process.cwd() : __dirname;
110
+ const candidates = sub
111
+ ? [(0, path_1.resolve)(packageDir, sub, name), (0, path_1.resolve)(packageDir, '..', sub, name)]
112
+ : [(0, path_1.resolve)(packageDir, name), (0, path_1.resolve)(packageDir, '..', name)];
113
+ for (const p of candidates) {
114
+ try {
115
+ if ((0, fs_1.existsSync)(p))
116
+ return JSON.parse((0, fs_1.readFileSync)(p, 'utf8'));
117
+ }
118
+ catch {
119
+ // try next
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+ function resolveTunnelPackageRoot() {
125
+ const packageDir = typeof __dirname === 'undefined' ? process.cwd() : __dirname;
126
+ return (0, path_1.resolve)(packageDir, '..');
127
+ }
@@ -11,12 +11,14 @@ export declare class TunnelDatabase {
11
11
  getSubscription(id: number): SubscriptionRecord | null;
12
12
  getActiveSubscription(): SubscriptionRecord | null;
13
13
  createSubscription(input: SubscriptionInput): SubscriptionRecord;
14
+ updateSubscription(id: number, input: SubscriptionInput): SubscriptionRecord;
14
15
  deleteSubscription(id: number): void;
15
16
  setActiveSubscription(id: number): SubscriptionRecord;
16
17
  updateSubscriptionContent(id: number, content: string, localPath: string): SubscriptionRecord;
17
18
  listRules(): DomainRule[];
18
19
  upsertRule(kind: DomainRuleKind, domain: string, source?: string): DomainRule;
19
20
  removeRule(id: number): void;
21
+ removeRulesBySource(source: string): number;
20
22
  addEvent(level: EventRecord['level'], message: string): void;
21
23
  listEvents(limit?: number): EventRecord[];
22
24
  private ensureDefaultSettings;
@@ -174,6 +174,33 @@ class TunnelDatabase {
174
174
  }
175
175
  return this.getSubscription(created.id) ?? created;
176
176
  }
177
+ updateSubscription(id, input) {
178
+ const current = this.getSubscription(id);
179
+ if (!current) {
180
+ throw new Error(`subscription not found: ${id}`);
181
+ }
182
+ this.db.prepare(`
183
+ UPDATE subscriptions
184
+ SET name = @name,
185
+ url = @url,
186
+ username = @username,
187
+ password = @password,
188
+ updated_at = @updatedAt
189
+ WHERE id = @id
190
+ `).run({
191
+ id,
192
+ name: input.name,
193
+ url: input.url,
194
+ username: input.username ?? '',
195
+ password: input.password ?? '',
196
+ updatedAt: nowIso()
197
+ });
198
+ const updated = this.getSubscription(id);
199
+ if (!updated) {
200
+ throw new Error(`subscription not found after update: ${id}`);
201
+ }
202
+ return updated;
203
+ }
177
204
  deleteSubscription(id) {
178
205
  this.db.prepare('DELETE FROM subscriptions WHERE id = ?').run(id);
179
206
  const active = this.getActiveSubscription();
@@ -245,6 +272,10 @@ class TunnelDatabase {
245
272
  removeRule(id) {
246
273
  this.db.prepare('DELETE FROM domain_rules WHERE id = ?').run(id);
247
274
  }
275
+ removeRulesBySource(source) {
276
+ const result = this.db.prepare('DELETE FROM domain_rules WHERE source = ?').run(source);
277
+ return Number(result.changes);
278
+ }
248
279
  addEvent(level, message) {
249
280
  this.db.prepare(`
250
281
  INSERT INTO events (level, message, created_at)
@@ -20,6 +20,16 @@ function registerTunnelIpc(ipcMain, manager, options) {
20
20
  }
21
21
  return subscription;
22
22
  });
23
+ ipcMain.handle('tunnel:edit-subscription', async (_event, input) => {
24
+ const subscription = await manager.editSubscription(input);
25
+ if (subscription.active) {
26
+ await runtimeChanged(manager, options);
27
+ }
28
+ else {
29
+ await changed(options);
30
+ }
31
+ return subscription;
32
+ });
23
33
  ipcMain.handle('tunnel:delete-subscription', async (_event, id) => {
24
34
  manager.deleteSubscription(id);
25
35
  await runtimeChanged(manager, options);
@@ -95,4 +105,9 @@ function registerTunnelIpc(ipcMain, manager, options) {
95
105
  await runtimeChanged(manager, options);
96
106
  return rules;
97
107
  });
108
+ ipcMain.handle('tunnel:remove-preset', async (_event, preset) => {
109
+ const count = manager.removePreset(preset);
110
+ await runtimeChanged(manager, options);
111
+ return count;
112
+ });
98
113
  }
@@ -2,7 +2,7 @@ import { EventEmitter } from 'events';
2
2
  import { TunnelDatabase } from '../db/TunnelDatabase';
3
3
  import { type DomainPresetId } from '../defaults';
4
4
  import { MihomoApi } from './MihomoApi';
5
- import type { DomainRule, RuntimeMode, SubscriptionInput, SubscriptionRecord, TunnelManagerOptions, TunnelSnapshot, TunnelPorts, TunnelStatus } from '../types';
5
+ import type { DomainRule, RuntimeMode, SubscriptionInput, SubscriptionUpdateInput, SubscriptionRecord, TunnelManagerOptions, TunnelSnapshot, TunnelPorts, TunnelStatus } from '../types';
6
6
  interface ManagerPaths {
7
7
  root: string;
8
8
  db: string;
@@ -32,6 +32,7 @@ export declare class MihomoManager extends EventEmitter {
32
32
  listRules(): DomainRule[];
33
33
  listEvents(): import("../types").EventRecord[];
34
34
  createSubscription(input: SubscriptionInput): Promise<SubscriptionRecord>;
35
+ editSubscription(input: SubscriptionUpdateInput): Promise<SubscriptionRecord>;
35
36
  private localSubscriptionPath;
36
37
  private fetchSubscriptionContent;
37
38
  deleteSubscription(id: number): void;
@@ -46,6 +47,7 @@ export declare class MihomoManager extends EventEmitter {
46
47
  updateActiveSubscription(): Promise<SubscriptionRecord>;
47
48
  addDomainRule(kind: 'allow' | 'block', domain: string, source?: string): DomainRule;
48
49
  addPreset(preset: DomainPresetId): DomainRule[];
50
+ removePreset(preset: DomainPresetId): number;
49
51
  removeDomainRule(id: number): void;
50
52
  renderConfig(): string;
51
53
  private runExclusive;
@@ -210,6 +210,20 @@ class MihomoManager extends events_1.EventEmitter {
210
210
  throw error;
211
211
  }
212
212
  }
213
+ async editSubscription(input) {
214
+ const current = this.db.getSubscription(input.id);
215
+ if (!current) {
216
+ throw new Error(`subscription not found: ${input.id}`);
217
+ }
218
+ const normalized = normalizeSubscriptionInput(input);
219
+ const content = await this.fetchSubscriptionContent(normalized);
220
+ const subscription = this.db.updateSubscription(input.id, normalized);
221
+ const localPath = this.localSubscriptionPath(subscription.id);
222
+ (0, fs_1.writeFileSync)(localPath, content, 'utf8');
223
+ const updated = this.db.updateSubscriptionContent(subscription.id, content, localPath);
224
+ this.log('info', `Subscription edited: ${updated.name}`);
225
+ return updated;
226
+ }
213
227
  localSubscriptionPath(id) {
214
228
  return (0, path_1.join)(this.paths.profiles, `subscription-${id}.yaml`);
215
229
  }
@@ -313,6 +327,11 @@ class MihomoManager extends events_1.EventEmitter {
313
327
  this.log('info', `Preset allowlist added: ${preset}`);
314
328
  return rules;
315
329
  }
330
+ removePreset(preset) {
331
+ const changes = this.db.removeRulesBySource(`preset:${preset}`);
332
+ this.log('info', `Preset allowlist removed: ${preset} (${changes} rules)`);
333
+ return changes;
334
+ }
316
335
  removeDomainRule(id) {
317
336
  this.db.removeRule(id);
318
337
  this.log('info', `Domain rule removed: ${id}`);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Plugin adapter for @qpjoy/electron-plugin.
3
+ *
4
+ * The same package keeps working as a standalone npm dependency (use
5
+ * `createElectronTunnel(...)` directly). When loaded through the plugin
6
+ * host, this default export is what gets invoked instead — wrapping the
7
+ * exact same runtime behind the standard plugin contract.
8
+ *
9
+ * No new state is introduced here: the underlying MihomoManager owns its
10
+ * own SQLite + admin server, so this adapter is a ~30-line shim.
11
+ */
12
+ import type { App, IpcMain, Session } from 'electron';
13
+ /**
14
+ * Subset of the SDK types — re-declared structurally so this file does not
15
+ * take a build-time dependency on `@qpjoy/plugin-sdk`. (Tunnel still ships
16
+ * fine when installed without the plugin host present.)
17
+ */
18
+ interface PluginHostBridge {
19
+ app: App;
20
+ ipcMain: IpcMain;
21
+ session: Session;
22
+ }
23
+ type ExposedApi = Record<string, (...args: any[]) => any>;
24
+ interface PluginContextLike<S> {
25
+ host: PluginHostBridge;
26
+ settings: {
27
+ get(): S;
28
+ };
29
+ log: {
30
+ info(m: string, meta?: Record<string, unknown>): void;
31
+ };
32
+ expose(api: ExposedApi): void;
33
+ }
34
+ export interface TunnelPluginSettings {
35
+ adminPort?: number;
36
+ controllerPort?: number;
37
+ mixedPort?: number;
38
+ dnsPort?: number;
39
+ bundledEngineDir?: string;
40
+ }
41
+ declare const tunnelPlugin: {
42
+ activate(ctx: PluginContextLike<TunnelPluginSettings>): Promise<() => void>;
43
+ };
44
+ export default tunnelPlugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const createElectronTunnel_1 = require("./createElectronTunnel");
4
+ const tunnelPlugin = {
5
+ async activate(ctx) {
6
+ const settings = ctx.settings.get() ?? {};
7
+ const handle = (0, createElectronTunnel_1.createElectronTunnel)({ app: ctx.host.app, ipcMain: ctx.host.ipcMain, session: ctx.host.session }, {
8
+ adminPort: settings.adminPort ?? 23456,
9
+ controllerPort: settings.controllerPort ?? 23457,
10
+ mixedPort: settings.mixedPort ?? 23458,
11
+ dnsPort: settings.dnsPort ?? 23459,
12
+ bundledEngineDir: settings.bundledEngineDir
13
+ });
14
+ // Expose an RPC surface so the host (and other plugins) can drive the
15
+ // tunnel without poking at internals. Anything not on this list is
16
+ // intentionally private — bump the surface explicitly when needed.
17
+ ctx.expose({
18
+ status: () => handle.status(),
19
+ snapshot: () => handle.manager.snapshot(),
20
+ applyProxy: () => handle.applyProxy(),
21
+ addDomainRule: async (kind, domain) => {
22
+ const rule = handle.manager.addDomainRule(kind, domain);
23
+ await handle.manager.applyRuntimeConfigChange();
24
+ return rule;
25
+ },
26
+ removeDomainRule: async (id) => {
27
+ handle.manager.removeDomainRule(id);
28
+ await handle.manager.applyRuntimeConfigChange();
29
+ },
30
+ addPreset: async (preset) => {
31
+ const rules = handle.manager.addPreset(preset);
32
+ await handle.manager.applyRuntimeConfigChange();
33
+ return rules;
34
+ },
35
+ removePreset: async (preset) => {
36
+ const count = handle.manager.removePreset(preset);
37
+ await handle.manager.applyRuntimeConfigChange();
38
+ return count;
39
+ },
40
+ onEvent: (listener) => {
41
+ handle.manager.on('event', listener);
42
+ return () => handle.manager.off('event', listener);
43
+ }
44
+ });
45
+ ctx.log.info('tunnel activated', {
46
+ ports: handle.status().ports,
47
+ mode: handle.status().mode
48
+ });
49
+ return () => handle.close();
50
+ }
51
+ };
52
+ exports.default = tunnelPlugin;
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "qpjoy.electron-tunnel",
3
+ "name": "QPJoy Tunnel",
4
+ "version": "0.1.3",
5
+ "author": "qpjoy",
6
+ "description": "Mihomo-based tunnel runtime with TUN / system-proxy / rule modes. Provides outbound networking for other plugins.",
7
+ "engines": { "electronPlugin": ">=0.1.0", "electron": ">=28" },
8
+ "permissions": [
9
+ "fs:userData",
10
+ "net:listen:23456",
11
+ "net:listen:23457",
12
+ "net:listen:23458",
13
+ "net:listen:23459",
14
+ "system:proxy",
15
+ "system:exec:mihomo",
16
+ "ui:adminPanel"
17
+ ],
18
+ "activationEvents": ["onStartup"],
19
+ "contributes": {
20
+ "adminPanel": { "url": "http://127.0.0.1:23456", "label": "Tunnel" }
21
+ }
22
+ }
package/dist/types.d.ts CHANGED
@@ -24,6 +24,9 @@ export interface SubscriptionInput {
24
24
  username?: string;
25
25
  password?: string;
26
26
  }
27
+ export interface SubscriptionUpdateInput extends SubscriptionInput {
28
+ id: number;
29
+ }
27
30
  export interface SubscriptionRecord extends Required<SubscriptionInput> {
28
31
  id: number;
29
32
  localPath: string | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qpjoy/electron-tunnel",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "private": false,
5
5
  "description": "Reusable QPJoy tunnel runtime and CLI for Electron apps on macOS and Linux.",
6
6
  "license": "UNLICENSED",
@@ -12,6 +12,10 @@
12
12
  "types": "./dist/index.d.ts",
13
13
  "default": "./dist/index.js"
14
14
  },
15
+ "./plugin": {
16
+ "types": "./dist/plugin.d.ts",
17
+ "default": "./dist/plugin.js"
18
+ },
15
19
  "./package.json": "./package.json"
16
20
  },
17
21
  "bin": {
@@ -23,11 +27,16 @@
23
27
  "dist",
24
28
  "resources/engine"
25
29
  ],
30
+ "qpjoyPlugin": {
31
+ "specVersion": 1,
32
+ "entry": "dist/plugin.js",
33
+ "manifest": "dist/plugin.manifest.json"
34
+ },
26
35
  "publishConfig": {
27
36
  "access": "public"
28
37
  },
29
38
  "scripts": {
30
- "build": "tsc -p tsconfig.json",
39
+ "build": "tsc -p tsconfig.json && node -e \"require('fs').copyFileSync('src/plugin.manifest.json','dist/plugin.manifest.json')\"",
31
40
  "prepack": "pnpm build",
32
41
  "typecheck": "tsc -p tsconfig.json --noEmit",
33
42
  "lint": "tsc -p tsconfig.json --noEmit"
@@ -43,6 +52,9 @@
43
52
  "better-sqlite3": "^11.8.1",
44
53
  "yaml": "^2.7.0"
45
54
  },
55
+ "optionalDependencies": {
56
+ "@qpjoy/marketplace-db": ">=0.1.0"
57
+ },
46
58
  "peerDependencies": {
47
59
  "electron": ">=28"
48
60
  },