dsh-mobile 0.6.7 → 0.6.9

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/lib/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { connect, createServer } from "node:net";
7
- import { chmod, copyFile, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { chmod, copyFile, link, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { performance as performance$1 } from "node:perf_hooks";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import { createSocket } from "node:dgram";
@@ -144,10 +144,14 @@ var AccessController = class {
144
144
  }
145
145
  snapshot(devices) {
146
146
  return Object.freeze({
147
- version: 1,
147
+ version: 2,
148
148
  devices: Object.freeze([...devices])
149
149
  });
150
150
  }
151
+ nativeIdentity(device) {
152
+ const nativeInstanceId = device.nativeInstanceId ?? this.options.legacyInstanceId ?? this.options.pairingInstanceId;
153
+ return nativeInstanceId === void 0 ? {} : { nativeInstanceId };
154
+ }
151
155
  emitSessionEnded(session, reason) {
152
156
  const authorization = Object.freeze({
153
157
  sessionKey: session.key,
@@ -165,7 +169,7 @@ var AccessController = class {
165
169
  pruneSessions(now) {
166
170
  for (const [key, session] of this.sessions) if (session.expiresAt <= now) this.removeSession(key, "expired");
167
171
  }
168
- createSession(deviceId, now, deviceExpiresAt) {
172
+ createSession(device, now) {
169
173
  this.pruneSessions(now);
170
174
  if (this.sessions.size >= this.options.maxSessions) {
171
175
  const oldest = [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt)[0];
@@ -176,14 +180,15 @@ var AccessController = class {
176
180
  const key = digestHex(sessionToken);
177
181
  const record = Object.freeze({
178
182
  key,
179
- deviceId,
183
+ deviceId: device.id,
180
184
  csrfDigest: digest$1(csrfToken),
181
185
  createdAt: now,
182
- expiresAt: Math.min(now + this.options.sessionTtlMs, deviceExpiresAt)
186
+ expiresAt: Math.min(now + this.options.sessionTtlMs, device.expiresAt)
183
187
  });
184
188
  this.sessions.set(key, record);
185
189
  return Object.freeze({
186
- deviceId,
190
+ ...this.nativeIdentity(device),
191
+ deviceId: device.id,
187
192
  sessionToken,
188
193
  csrfToken,
189
194
  sessionExpiresAt: record.expiresAt
@@ -228,12 +233,13 @@ var AccessController = class {
228
233
  tokenDigest: digestHex(deviceToken),
229
234
  createdAt: now,
230
235
  expiresAt: now + this.options.deviceTtlMs,
231
- lastSeenAt: now
236
+ lastSeenAt: now,
237
+ ...this.options.pairingInstanceId === void 0 ? {} : { nativeInstanceId: this.options.pairingInstanceId }
232
238
  });
233
239
  const next = [...this.devices.filter((candidate) => candidate.revokedAt === void 0 && candidate.expiresAt > now), device];
234
240
  await this.store.save(this.snapshot(next));
235
241
  this.devices = next;
236
- const session = this.createSession(device.id, now, device.expiresAt);
242
+ const session = this.createSession(device, now);
237
243
  return Object.freeze({
238
244
  ...session,
239
245
  deviceToken,
@@ -256,7 +262,7 @@ var AccessController = class {
256
262
  next[index] = updated;
257
263
  await this.store.save(this.snapshot(next));
258
264
  this.devices = next;
259
- return this.createSession(device.id, now, device.expiresAt);
265
+ return this.createSession(device, now);
260
266
  });
261
267
  }
262
268
  /** Validate a persistent device credential without consuming a Session slot. */
@@ -275,6 +281,7 @@ var AccessController = class {
275
281
  await this.store.save(this.snapshot(next));
276
282
  this.devices = next;
277
283
  return Object.freeze({
284
+ ...this.nativeIdentity(device),
278
285
  deviceId: device.id,
279
286
  deviceExpiresAt: device.expiresAt
280
287
  });
@@ -285,6 +292,7 @@ var AccessController = class {
285
292
  this.requireInitialized();
286
293
  const device = this.requireDevice(deviceToken, this.now());
287
294
  return Object.freeze({
295
+ ...this.nativeIdentity(device),
288
296
  deviceId: device.id,
289
297
  deviceExpiresAt: device.expiresAt
290
298
  });
@@ -5289,6 +5297,8 @@ var MobileAccessGateway = class {
5289
5297
  this.listenerTlsEnabled = config.tls.mode === "provided";
5290
5298
  this.tlsEnabled = config.publicTls;
5291
5299
  this.access = new AccessController(store, {
5300
+ pairingInstanceId: config.instanceId,
5301
+ ...config.legacyInstanceId === void 0 ? {} : { legacyInstanceId: config.legacyInstanceId },
5292
5302
  pairingTtlMs: config.pairingTtlMs,
5293
5303
  deviceTtlMs: config.deviceTtlMs,
5294
5304
  sessionTtlMs: config.sessionTtlMs,
@@ -5665,12 +5675,16 @@ var MobileAccessGateway = class {
5665
5675
  sessionExpiresAt: result.sessionExpiresAt
5666
5676
  }, this.tlsEnabled);
5667
5677
  }
5678
+ /** CA-backed entries retain their pin; public entries project the authenticated credential's identity. */
5679
+ nativeResponseInstanceId(result) {
5680
+ return this.config.pairingCaFile === void 0 ? result.nativeInstanceId ?? this.config.instanceId : this.config.instanceId;
5681
+ }
5668
5682
  async handleNativePair(request, response) {
5669
5683
  const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
5670
5684
  if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
5671
5685
  const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
5672
5686
  sendJson(response, 201, {
5673
- instanceId: this.config.instanceId,
5687
+ instanceId: this.nativeResponseInstanceId(result),
5674
5688
  deviceId: result.deviceId,
5675
5689
  deviceToken: result.deviceToken,
5676
5690
  deviceExpiresAt: result.deviceExpiresAt,
@@ -5685,7 +5699,7 @@ var MobileAccessGateway = class {
5685
5699
  if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5686
5700
  const result = await this.access.renew(body.deviceToken);
5687
5701
  sendJson(response, 200, {
5688
- instanceId: this.config.instanceId,
5702
+ instanceId: this.nativeResponseInstanceId(result),
5689
5703
  deviceId: result.deviceId,
5690
5704
  sessionToken: result.sessionToken,
5691
5705
  csrfToken: result.csrfToken,
@@ -5698,7 +5712,7 @@ var MobileAccessGateway = class {
5698
5712
  if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
5699
5713
  const result = await this.access.probe(body.deviceToken);
5700
5714
  sendJson(response, 200, {
5701
- instanceId: this.config.instanceId,
5715
+ instanceId: this.nativeResponseInstanceId(result),
5702
5716
  deviceId: result.deviceId,
5703
5717
  deviceExpiresAt: result.deviceExpiresAt
5704
5718
  }, this.tlsEnabled);
@@ -7367,6 +7381,7 @@ function parseDevice(value) {
7367
7381
  if (typeof record.id !== "string" || !/^[a-f\d]{32}$/u.test(record.id)) throw new Error("device state contains an invalid id");
7368
7382
  if (typeof record.label !== "string" || record.label.length < 1 || record.label.length > 64 || /[\u0000-\u001f\u007f]/u.test(record.label)) throw new Error("device state contains an invalid label");
7369
7383
  if (typeof record.tokenDigest !== "string" || !/^[a-f\d]{64}$/u.test(record.tokenDigest)) throw new Error("device state contains an invalid credential digest");
7384
+ if (record.nativeInstanceId !== void 0 && (typeof record.nativeInstanceId !== "string" || !/^[a-f\d]{64}$/u.test(record.nativeInstanceId))) throw new Error("device state contains an invalid native instance id");
7370
7385
  assertInteger(record.createdAt, "createdAt");
7371
7386
  assertInteger(record.expiresAt, "expiresAt");
7372
7387
  assertInteger(record.lastSeenAt, "lastSeenAt");
@@ -7379,6 +7394,7 @@ function parseDevice(value) {
7379
7394
  createdAt: record.createdAt,
7380
7395
  expiresAt: record.expiresAt,
7381
7396
  lastSeenAt: record.lastSeenAt,
7397
+ ...record.nativeInstanceId === void 0 ? {} : { nativeInstanceId: record.nativeInstanceId },
7382
7398
  ...record.revokedAt === void 0 ? {} : { revokedAt: record.revokedAt }
7383
7399
  });
7384
7400
  }
@@ -7386,14 +7402,21 @@ function parseDevice(value) {
7386
7402
  function parseDeviceSnapshot(value, maximumDevices = 256) {
7387
7403
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("device state must be an object");
7388
7404
  const snapshot = value;
7389
- if (snapshot.version !== 1 || !Array.isArray(snapshot.devices) || snapshot.devices.length > maximumDevices) throw new Error("device state has an unsupported version or device count");
7405
+ if (snapshot.version !== 1 && snapshot.version !== 2 || !Array.isArray(snapshot.devices) || snapshot.devices.length > maximumDevices) throw new Error("device state has an unsupported version or device count");
7390
7406
  const devices = snapshot.devices.map(parseDevice);
7391
7407
  if (new Set(devices.map((device) => device.id)).size !== devices.length || new Set(devices.map((device) => device.tokenDigest)).size !== devices.length) throw new Error("device state contains duplicate device identities");
7392
7408
  return Object.freeze({
7393
- version: 1,
7409
+ version: snapshot.version,
7394
7410
  devices: Object.freeze(devices)
7395
7411
  });
7396
7412
  }
7413
+ function snapshotForSave(snapshot, maximumDevices) {
7414
+ const validated = parseDeviceSnapshot(snapshot, maximumDevices);
7415
+ return Object.freeze({
7416
+ version: 2,
7417
+ devices: validated.devices
7418
+ });
7419
+ }
7397
7420
  /** Atomic JSON implementation with symlink refusal and owner-only file creation. */
7398
7421
  var JsonDeviceStore = class {
7399
7422
  file;
@@ -7424,7 +7447,7 @@ var JsonDeviceStore = class {
7424
7447
  return parseDeviceSnapshot(parsed, this.maximumDevices);
7425
7448
  }
7426
7449
  async save(snapshot) {
7427
- const validated = parseDeviceSnapshot(snapshot, this.maximumDevices);
7450
+ const validated = snapshotForSave(snapshot, this.maximumDevices);
7428
7451
  const directory = dirname(this.file);
7429
7452
  await mkdir(directory, {
7430
7453
  recursive: true,
@@ -7468,7 +7491,7 @@ var MemoryDeviceStore = class {
7468
7491
  return structuredClone(this.snapshot);
7469
7492
  }
7470
7493
  async save(snapshot) {
7471
- this.snapshot = structuredClone(parseDeviceSnapshot(snapshot));
7494
+ this.snapshot = structuredClone(snapshotForSave(snapshot));
7472
7495
  }
7473
7496
  /** Return a defensive copy for assertions or administrative export. */
7474
7497
  inspect() {
@@ -10587,6 +10610,8 @@ var RemoteProviderCoordinator = class {
10587
10610
  store;
10588
10611
  selectedValue;
10589
10612
  queue = Promise.resolve();
10613
+ closing = false;
10614
+ closeTask;
10590
10615
  constructor(selected, controllers, store) {
10591
10616
  this.controllers = controllers;
10592
10617
  this.store = store;
@@ -10638,8 +10663,16 @@ var RemoteProviderCoordinator = class {
10638
10663
  }
10639
10664
  });
10640
10665
  }
10666
+ /** Stop admitting mutations and wait for accepted work before callers release provider resources. */
10667
+ close() {
10668
+ this.closing = true;
10669
+ this.closeTask ??= this.queue;
10670
+ return this.closeTask;
10671
+ }
10641
10672
  enqueue(operation) {
10642
- const task = this.queue.then(() => this.runAndEnforce(operation), () => this.runAndEnforce(operation));
10673
+ if (this.closing) return Promise.reject(/* @__PURE__ */ new Error("remote_provider_closed"));
10674
+ const run = () => this.closing ? Promise.reject(/* @__PURE__ */ new Error("remote_provider_closed")) : this.runAndEnforce(operation);
10675
+ const task = this.queue.then(run, run);
10643
10676
  this.queue = task.then(() => void 0, () => void 0);
10644
10677
  return task;
10645
10678
  }
@@ -13330,6 +13363,8 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
13330
13363
 
13331
13364
  动手前先读已有的定制文件。若要调整 DSH 自带的输入框、侧栏或设置等现有界面,先确认当前 DSH 版本与手机页面模式(专用移动页面或 ?frontend=stock),只读核对运行页面的 DOM、属性和样式;本机有同版本前端源码时可参考相应组件,没有源码时可查看已安装的客户端产物,不必下载源码。优先使用扩展 API、插槽和稳定的 data-* 标记,不凭猜测依赖构建类名。只读查看不改变前述写入范围。
13332
13365
 
13366
+ 旧 CSS 可能覆盖新版内置修复。先备份并在同一会话、方向和字号下对比有无该样式,不要无症状叠加旧补丁。CSS Modules 的后缀/子串匹配必须限定到已核对的区域祖先,例如 [data-dsh-mobile-header] [class*="_titleRow"];不要裸匹配整页。display: none 会让其中的操作不可用,不得把隐藏功能描述成折叠。
13367
+
13333
13368
  2. 电脑端能力 —— 手机需要读电脑文件、执行命令或访问硬件时,创建扩展:
13334
13369
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
13335
13370
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
@@ -15272,10 +15307,35 @@ async function removeCaddyTree(root, target) {
15272
15307
  }
15273
15308
  //#endregion
15274
15309
  //#region src/caddy-component.ts
15275
- const CADDY_VERSION = "2.11.6";
15310
+ const CADDY_VERSION = "2.11.7";
15276
15311
  const CADDY_DNS_PLUGIN_VERSION = "v0.4.3";
15277
- const CADDY_DOWNLOAD_PAGE = "https://caddyserver.com/download";
15278
- const CADDY_COMPONENT_RELEASES = Object.freeze({});
15312
+ const CADDY_DOWNLOAD_PAGE = "https://github.com/saya-ch/dsh-mobile/releases/tag/caddy-component-2.11.7-tencentcloud-0.4.3-r1";
15313
+ const CADDY_COMPONENT_RELEASES = Object.freeze({
15314
+ "linux-x64": Object.freeze({
15315
+ version: CADDY_VERSION,
15316
+ dnsPluginVersion: CADDY_DNS_PLUGIN_VERSION,
15317
+ platform: "linux",
15318
+ arch: "x64",
15319
+ executableName: "caddy",
15320
+ downloadUrl: "https://github.com/saya-ch/dsh-mobile/releases/download/caddy-component-2.11.7-tencentcloud-0.4.3-r1/caddy-2.11.7-tencentcloud-0.4.3-linux-x64",
15321
+ downloadBytes: 81995704,
15322
+ executableBytes: 81995704,
15323
+ downloadSha256: "13652ef9ea3792ebff772a5b873ade63ed6de1c0a794bb3403a523639b8f57af",
15324
+ executableSha256: "13652ef9ea3792ebff772a5b873ade63ed6de1c0a794bb3403a523639b8f57af"
15325
+ }),
15326
+ "win32-x64": Object.freeze({
15327
+ version: CADDY_VERSION,
15328
+ dnsPluginVersion: CADDY_DNS_PLUGIN_VERSION,
15329
+ platform: "win32",
15330
+ arch: "x64",
15331
+ executableName: "caddy.exe",
15332
+ downloadUrl: "https://github.com/saya-ch/dsh-mobile/releases/download/caddy-component-2.11.7-tencentcloud-0.4.3-r1/caddy-2.11.7-tencentcloud-0.4.3-win32-x64.exe",
15333
+ downloadBytes: 82816e3,
15334
+ executableBytes: 82816e3,
15335
+ downloadSha256: "962f7937a06e1e737513d2d1d4ad75df54ba503d36fa2a458912bd38fbd0094a",
15336
+ executableSha256: "962f7937a06e1e737513d2d1d4ad75df54ba503d36fa2a458912bd38fbd0094a"
15337
+ })
15338
+ });
15279
15339
  Object.freeze({
15280
15340
  version: CADDY_VERSION,
15281
15341
  dnsPluginVersion: CADDY_DNS_PLUGIN_VERSION
@@ -15299,14 +15359,16 @@ function caddyInspectionEnvironment(environment = process.env) {
15299
15359
  ]) if (environment[name] !== void 0) result[name] = environment[name];
15300
15360
  return result;
15301
15361
  }
15302
- async function inspect(executable) {
15362
+ async function inspect(executable, assertOpen) {
15303
15363
  const options = {
15304
15364
  env: caddyInspectionEnvironment(),
15305
15365
  timeout: 15e3,
15306
15366
  maxBuffer: 262144
15307
15367
  };
15368
+ const version = (await execFileText(executable, ["version"], options)).stdout.trim();
15369
+ assertOpen();
15308
15370
  return {
15309
- version: (await execFileText(executable, ["version"], options)).stdout.trim(),
15371
+ version,
15310
15372
  modules: (await execFileText(executable, ["list-modules", "--versions"], options)).stdout
15311
15373
  };
15312
15374
  }
@@ -15325,6 +15387,10 @@ var CaddyComponentManager = class {
15325
15387
  installed = false;
15326
15388
  errorCode;
15327
15389
  queue = Promise.resolve();
15390
+ closed = false;
15391
+ closing;
15392
+ download;
15393
+ cleanupPaths = /* @__PURE__ */ new Set();
15328
15394
  constructor(options) {
15329
15395
  if (!isAbsolute(options.stateDirectory)) throw new Error("caddy state directory must be absolute");
15330
15396
  this.stateDirectory = resolve(options.stateDirectory);
@@ -15334,7 +15400,7 @@ var CaddyComponentManager = class {
15334
15400
  if (artifact !== void 0 && (artifact.platform !== platform || artifact.arch !== arch || !/^https:\/\//u.test(artifact.downloadUrl) || !/^[a-f0-9]{64}$/u.test(artifact.downloadSha256) || !/^[a-f0-9]{64}$/u.test(artifact.executableSha256) || artifact.downloadBytes < 1 || artifact.executableBytes < 1 || !/^[a-z0-9.-]+$/u.test(artifact.executableName))) throw new Error("caddy_artifact_invalid");
15335
15401
  this.artifact = artifact;
15336
15402
  this.componentRoot = join(this.stateDirectory, "components", "caddy");
15337
- this.componentStorage = join(this.componentRoot, artifact?.version ?? "2.11.6");
15403
+ this.componentStorage = join(this.componentRoot, artifact?.version ?? "2.11.7");
15338
15404
  this.executable = join(this.componentStorage, artifact?.executableName ?? (platform === "win32" ? "caddy.exe" : "caddy"));
15339
15405
  this.logRoot = join(this.stateDirectory, "caddy", "logs");
15340
15406
  this.stagingRoot = join(this.stateDirectory, "staging", "caddy");
@@ -15344,41 +15410,48 @@ var CaddyComponentManager = class {
15344
15410
  expectedBytes: artifact?.downloadBytes ?? 0,
15345
15411
  errorPrefix: "caddy"
15346
15412
  }));
15347
- this.inspectExecutable = options.inspectExecutable ?? inspect;
15413
+ this.inspectExecutable = options.inspectExecutable ?? ((executable) => inspect(executable, () => this.assertOpen()));
15348
15414
  this.promote = options.promoteDirectory ?? ((source, destination) => renameCaddyPath(this.stateDirectory, source, destination));
15349
15415
  }
15350
15416
  /** Recheck a pinned executable before launching; no unverified local binary can run. */
15351
- async ensureExecutable() {
15417
+ ensureExecutable() {
15418
+ return this.enqueue(() => this.checkExecutable());
15419
+ }
15420
+ async checkExecutable() {
15352
15421
  const artifact = this.artifact;
15353
15422
  if (artifact === void 0) throw new Error("caddy_component_unavailable");
15354
15423
  await assertCaddyParents(this.stateDirectory, this.executable);
15424
+ this.assertOpen();
15355
15425
  const entry = await lstat(this.executable);
15356
15426
  if (!entry.isFile() || entry.isSymbolicLink() || entry.size !== artifact.executableBytes || await digest(this.executable) !== artifact.executableSha256) throw new Error("caddy_component_invalid");
15357
15427
  await this.verifyMetadata(this.executable, artifact);
15358
15428
  }
15359
15429
  async initialize() {
15360
- this.installed = false;
15361
- this.errorCode = void 0;
15362
- if (this.artifact === void 0) {
15363
- this.errorCode = "caddy_component_unavailable";
15364
- return;
15365
- }
15366
- try {
15367
- await this.ensureExecutable();
15368
- this.installed = true;
15369
- } catch (error) {
15370
- if (error.code !== "ENOENT") this.errorCode = "caddy_component_invalid";
15371
- }
15430
+ return this.enqueue(async () => {
15431
+ this.installed = false;
15432
+ this.errorCode = void 0;
15433
+ if (this.artifact === void 0) {
15434
+ this.errorCode = "caddy_component_unavailable";
15435
+ return;
15436
+ }
15437
+ try {
15438
+ await this.checkExecutable();
15439
+ this.installed = true;
15440
+ } catch (error) {
15441
+ this.assertOpen();
15442
+ if (error.code !== "ENOENT") this.errorCode = "caddy_component_invalid";
15443
+ }
15444
+ });
15372
15445
  }
15373
15446
  status() {
15374
15447
  const artifact = this.artifact;
15375
15448
  return Object.freeze({
15376
15449
  supported: artifact !== void 0,
15377
15450
  installed: this.installed,
15378
- version: artifact?.version ?? "2.11.6",
15451
+ version: artifact?.version ?? "2.11.7",
15379
15452
  downloadBytes: artifact?.downloadBytes ?? 0,
15380
15453
  installedBytes: this.installed ? artifact?.executableBytes ?? 0 : 0,
15381
- sourceUrl: artifact?.downloadUrl ?? "https://caddyserver.com/download",
15454
+ sourceUrl: artifact?.downloadUrl ?? "https://github.com/saya-ch/dsh-mobile/releases/tag/caddy-component-2.11.7-tencentcloud-0.4.3-r1",
15382
15455
  downloadPage: CADDY_DOWNLOAD_PAGE,
15383
15456
  storagePath: this.componentRoot,
15384
15457
  ...this.errorCode === void 0 ? {} : { errorCode: this.errorCode }
@@ -15390,28 +15463,40 @@ var CaddyComponentManager = class {
15390
15463
  const artifact = this.artifact;
15391
15464
  if (artifact === void 0) throw new Error("caddy_component_unavailable");
15392
15465
  await ensureCaddyDirectory(this.stateDirectory, this.stagingRoot);
15466
+ this.assertOpen();
15393
15467
  await ensureCaddyDirectory(this.stateDirectory, this.componentRoot);
15468
+ this.assertOpen();
15394
15469
  const staging = await mkdtemp(join(this.stagingRoot, "install-"));
15395
15470
  const candidate = join(this.componentRoot, ".install-" + randomBytes(12).toString("hex"));
15396
15471
  const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
15397
15472
  let previous = false;
15398
15473
  let promoted = false;
15399
15474
  let failed = false;
15475
+ let operationError;
15400
15476
  try {
15477
+ this.assertOpen();
15401
15478
  const controller = new AbortController();
15402
15479
  const timeout = setTimeout(() => {
15403
15480
  controller.abort();
15404
15481
  }, 6e5);
15405
15482
  timeout.unref();
15483
+ this.download = {
15484
+ controller,
15485
+ timeout
15486
+ };
15406
15487
  let bytes;
15407
15488
  try {
15408
15489
  bytes = await this.fetchArtifact(artifact.downloadUrl, controller.signal);
15409
15490
  } finally {
15410
15491
  clearTimeout(timeout);
15492
+ this.download = void 0;
15411
15493
  }
15494
+ this.assertOpen();
15495
+ controller.signal.throwIfAborted();
15412
15496
  if (bytes.byteLength !== artifact.downloadBytes) throw new Error("caddy_download_size_mismatch");
15413
15497
  if (createHash("sha256").update(bytes).digest("hex") !== artifact.downloadSha256) throw new Error("caddy_download_hash_mismatch");
15414
15498
  await ensureCaddyDirectory(this.stateDirectory, candidate);
15499
+ this.assertOpen();
15415
15500
  const executable = join(candidate, artifact.executableName);
15416
15501
  await writeFile(executable, bytes, {
15417
15502
  flag: "wx",
@@ -15420,8 +15505,10 @@ var CaddyComponentManager = class {
15420
15505
  await chmod(executable, 448);
15421
15506
  if (bytes.byteLength !== artifact.executableBytes || await digest(executable) !== artifact.executableSha256) throw new Error("caddy_executable_hash_mismatch");
15422
15507
  await this.verifyMetadata(executable, artifact);
15508
+ this.assertOpen();
15423
15509
  try {
15424
15510
  const entry = await lstat(this.componentStorage);
15511
+ this.assertOpen();
15425
15512
  if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
15426
15513
  await renameCaddyPath(this.stateDirectory, this.componentStorage, backup);
15427
15514
  previous = true;
@@ -15429,6 +15516,7 @@ var CaddyComponentManager = class {
15429
15516
  if (error.code !== "ENOENT") throw error;
15430
15517
  }
15431
15518
  try {
15519
+ this.assertOpen();
15432
15520
  await this.promote(candidate, this.componentStorage);
15433
15521
  promoted = true;
15434
15522
  } catch (error) {
@@ -15446,21 +15534,29 @@ var CaddyComponentManager = class {
15446
15534
  this.errorCode = void 0;
15447
15535
  } catch (error) {
15448
15536
  failed = true;
15537
+ operationError = error;
15449
15538
  throw error;
15450
15539
  } finally {
15451
- let cleanupError;
15540
+ const cleanupErrors = [];
15452
15541
  for (const path of [
15453
15542
  staging,
15454
15543
  candidate,
15455
15544
  ...previous && promoted ? [backup] : []
15456
- ]) try {
15457
- await removeCaddyTree(this.stateDirectory, path);
15458
- } catch (error) {
15459
- cleanupError ??= error;
15545
+ ]) {
15546
+ this.cleanupPaths.add(path);
15547
+ try {
15548
+ await removeCaddyTree(this.stateDirectory, path);
15549
+ this.cleanupPaths.delete(path);
15550
+ } catch (error) {
15551
+ cleanupErrors.push(error);
15552
+ }
15553
+ }
15554
+ if (cleanupErrors.length > 0) {
15555
+ if (!failed && cleanupErrors.length === 1) throw cleanupErrors[0];
15556
+ throw new AggregateError([...failed ? [operationError] : [], ...cleanupErrors], "caddy_component_cleanup_failed", failed ? { cause: operationError } : void 0);
15460
15557
  }
15461
- if (!failed && cleanupError !== void 0) throw cleanupError;
15462
15558
  }
15463
- });
15559
+ }).then(() => this.status());
15464
15560
  }
15465
15561
  /** Remove optional component files; callers stop the controller and purge runtime configuration separately. */
15466
15562
  purge() {
@@ -15469,18 +15565,57 @@ var CaddyComponentManager = class {
15469
15565
  await removeCaddyTree(this.stateDirectory, this.stagingRoot);
15470
15566
  this.installed = false;
15471
15567
  this.errorCode = this.artifact === void 0 ? "caddy_component_unavailable" : void 0;
15568
+ }).then(() => this.status());
15569
+ }
15570
+ /**
15571
+ * Permanently stop admission and abort downloads, then await admitted work and cleanup.
15572
+ * An already started promotion/purge settles normally; cancellation does not undo committed bytes.
15573
+ * A downloader that ignores abort remains owned until its promise settles. Failed temporary cleanup
15574
+ * is retried here; a failed close can be called again without reopening admission or deleting recovery backups.
15575
+ */
15576
+ close() {
15577
+ if (this.closing !== void 0) return this.closing;
15578
+ this.closed = true;
15579
+ if (this.download !== void 0) {
15580
+ clearTimeout(this.download.timeout);
15581
+ this.download.controller.abort(/* @__PURE__ */ new Error("caddy_component_closed"));
15582
+ }
15583
+ const task = this.queue.then(async () => {
15584
+ const errors = [];
15585
+ for (const path of this.cleanupPaths) try {
15586
+ await removeCaddyTree(this.stateDirectory, path);
15587
+ this.cleanupPaths.delete(path);
15588
+ } catch (error) {
15589
+ errors.push(error);
15590
+ }
15591
+ if (errors.length === 1) throw errors[0];
15592
+ if (errors.length > 1) throw new AggregateError(errors, "caddy_component_cleanup_failed");
15593
+ });
15594
+ this.closing = task;
15595
+ task.catch(() => {
15596
+ if (this.closing === task) this.closing = void 0;
15472
15597
  });
15598
+ return task;
15473
15599
  }
15474
15600
  async verifyMetadata(executable, artifact) {
15601
+ this.assertOpen();
15475
15602
  const info = await this.inspectExecutable(executable);
15603
+ this.assertOpen();
15476
15604
  if (!info.version.startsWith("v" + artifact.version + " ")) throw new Error("caddy_executable_version_mismatch");
15477
15605
  const nonstandard = info.modules.split(/\r?\n/u).filter((line) => line.startsWith("dns.providers."));
15478
15606
  if (nonstandard.length !== 1 || nonstandard[0] !== "dns.providers.tencentcloud " + artifact.dnsPluginVersion || !info.modules.includes("Non-standard modules: 1")) throw new Error("caddy_executable_modules_mismatch");
15479
15607
  }
15608
+ assertOpen() {
15609
+ if (this.closed) throw new Error("caddy_component_closed");
15610
+ }
15480
15611
  enqueue(operation) {
15481
- const task = this.queue.then(operation, operation);
15612
+ if (this.closed) return Promise.reject(/* @__PURE__ */ new Error("caddy_component_closed"));
15613
+ const task = this.queue.then(() => {
15614
+ this.assertOpen();
15615
+ return operation();
15616
+ });
15482
15617
  this.queue = task.then(() => void 0, () => void 0);
15483
- return task.then(() => this.status());
15618
+ return task;
15484
15619
  }
15485
15620
  };
15486
15621
  const MAX_CONFIG_BYTES = 16384;
@@ -15839,6 +15974,7 @@ var CaddyController = class {
15839
15974
  } catch {}
15840
15975
  }
15841
15976
  async start() {
15977
+ if (this.child !== void 0 || this.gatewayValue !== void 0) await this.stop();
15842
15978
  const generation = ++this.generation;
15843
15979
  const startup = new AbortController();
15844
15980
  this.startupAbort = startup;
@@ -15888,12 +16024,19 @@ var CaddyController = class {
15888
16024
  "--adapter",
15889
16025
  "caddyfile"
15890
16026
  ], environment);
15891
- this.child = child;
16027
+ const ownedChild = {
16028
+ process: child,
16029
+ closed: false
16030
+ };
16031
+ this.child = ownedChild;
16032
+ child.once("close", () => {
16033
+ ownedChild.closed = true;
16034
+ });
15892
16035
  child.stdout.resume();
15893
16036
  child.stderr.resume();
15894
16037
  child.stdin.end();
15895
16038
  const failed = (code) => {
15896
- if (generation !== this.generation || this.child !== child) return;
16039
+ if (generation !== this.generation || this.child !== ownedChild) return;
15897
16040
  childFailure = code;
15898
16041
  startup.abort();
15899
16042
  if (this.latest.state === "ready") this.enqueue(async () => {
@@ -15968,20 +16111,49 @@ var CaddyController = class {
15968
16111
  this.startupAbort?.abort();
15969
16112
  await this.stopResources();
15970
16113
  }
16114
+ waitForChildClose(child) {
16115
+ if (child.closed) return Promise.resolve();
16116
+ return new Promise((resolve, reject) => {
16117
+ const finish = (error) => {
16118
+ clearTimeout(timer);
16119
+ child.process.removeListener("close", closed);
16120
+ if (error === void 0) resolve();
16121
+ else reject(error);
16122
+ };
16123
+ const closed = () => {
16124
+ finish();
16125
+ };
16126
+ const timer = setTimeout(() => {
16127
+ finish(/* @__PURE__ */ new Error("caddy_process_close_timeout"));
16128
+ }, 1500);
16129
+ timer.unref();
16130
+ child.process.once("close", closed);
16131
+ });
16132
+ }
15971
16133
  async stopResources() {
15972
16134
  const child = this.child;
15973
16135
  const gateway = this.gatewayValue;
15974
- await settleRemoteResources([async () => {
15975
- if (child !== void 0) {
15976
- await (this.options.terminateProcess ?? terminateRemoteProcess)(child);
15977
- if (this.child === child) this.child = void 0;
15978
- }
15979
- }, async () => {
15980
- if (gateway !== void 0) {
15981
- await gateway.close();
15982
- if (this.gatewayValue === gateway) this.gatewayValue = void 0;
15983
- }
15984
- }], "caddy cleanup failed");
16136
+ try {
16137
+ await settleRemoteResources([async () => {
16138
+ if (child !== void 0) {
16139
+ await (this.options.terminateProcess ?? terminateRemoteProcess)(child.process);
16140
+ await this.waitForChildClose(child);
16141
+ if (this.child === child) this.child = void 0;
16142
+ }
16143
+ }, async () => {
16144
+ if (gateway !== void 0) {
16145
+ await gateway.close();
16146
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
16147
+ }
16148
+ }], "caddy cleanup failed");
16149
+ } catch (error) {
16150
+ this.publish({
16151
+ enabled: this.enabled,
16152
+ state: "error",
16153
+ errorCode: "caddy_cleanup_failed"
16154
+ });
16155
+ throw error;
16156
+ }
15985
16157
  }
15986
16158
  };
15987
16159
  //#endregion
@@ -16691,23 +16863,75 @@ async function readInstallationId(file) {
16691
16863
  await restrictPrivateFile(file);
16692
16864
  return contents.slice(0, -1);
16693
16865
  }
16694
- /** Keep one random plugin identity across setup changes, restarts, and FRP resets. */
16866
+ /** Keep one random plugin identity across setup changes, restarts, and FRP resets; creation requires hard-link support in the state directory. */
16695
16867
  async function loadOrCreateInstallationId(file) {
16696
16868
  if (!isAbsolute(file)) throw new Error("installation ID path must be absolute");
16697
16869
  const target = resolve(file);
16698
- await mkdir(dirname(target), {
16699
- recursive: true,
16700
- mode: 448
16701
- });
16870
+ const directory = dirname(target);
16871
+ await ensureManagedDirectory(directory, directory, "installation_id");
16702
16872
  try {
16703
- await writeFile(target, `${randomBytes(32).toString("hex")}\n`, {
16704
- flag: "wx",
16705
- mode: 384
16706
- });
16873
+ return await readInstallationId(target);
16707
16874
  } catch (error) {
16708
- if (error.code !== "EEXIST") throw error;
16875
+ if (error.code !== "ENOENT") throw error;
16709
16876
  }
16710
- return readInstallationId(target);
16877
+ const temporary = join(directory, `.${basename(target)}.${randomBytes(12).toString("hex")}.tmp`);
16878
+ let created = false;
16879
+ let failure;
16880
+ try {
16881
+ await assertManagedParents(directory, temporary, "installation_id");
16882
+ const handle = await open(temporary, "wx", 384);
16883
+ created = true;
16884
+ let writeFailure;
16885
+ try {
16886
+ await handle.writeFile(`${randomBytes(32).toString("hex")}\n`, "utf8");
16887
+ } catch (error) {
16888
+ writeFailure = error;
16889
+ throw error;
16890
+ } finally {
16891
+ try {
16892
+ await handle.close();
16893
+ } catch (closeError) {
16894
+ if (writeFailure !== void 0) throw new AggregateError([writeFailure, closeError], "installation ID candidate write and close failed");
16895
+ throw closeError;
16896
+ }
16897
+ }
16898
+ await restrictPrivateFile(temporary);
16899
+ await assertManagedParents(directory, target, "installation_id");
16900
+ try {
16901
+ await link(temporary, target);
16902
+ } catch (error) {
16903
+ if (error.code !== "EEXIST") throw new Error("installation ID publication failed; the state filesystem must support hard links", { cause: error });
16904
+ }
16905
+ return await readInstallationId(target);
16906
+ } catch (error) {
16907
+ failure = error;
16908
+ throw error;
16909
+ } finally {
16910
+ if (created) try {
16911
+ await assertManagedParents(directory, temporary, "installation_id");
16912
+ await unlink(temporary);
16913
+ } catch (cleanupError) {
16914
+ if (failure !== void 0) throw new AggregateError([failure, cleanupError], "installation ID publication and temporary cleanup failed");
16915
+ throw cleanupError;
16916
+ }
16917
+ }
16918
+ }
16919
+ //#endregion
16920
+ //#region src/gateway-identity.ts
16921
+ /**
16922
+ * Use the persisted installation identity for an unpinned, automatically identified entry.
16923
+ * @param config - Validated gateway configuration containing the legacy identity.
16924
+ * @param installationId - Confirmed random identity read from this installation's state.
16925
+ * @param explicitIdentity - Whether the operator supplied the gateway identity.
16926
+ * @returns Configuration retaining certificate/operator identities or the new and legacy public identities.
16927
+ */
16928
+ function withInstallationIdentity(config, installationId, explicitIdentity) {
16929
+ if (config.pairingCaFile !== void 0 || explicitIdentity) return config;
16930
+ return Object.freeze({
16931
+ ...config,
16932
+ instanceId: installationId,
16933
+ legacyInstanceId: config.instanceId
16934
+ });
16711
16935
  }
16712
16936
  //#endregion
16713
16937
  //#region src/lan-setup.ts
@@ -16955,7 +17179,7 @@ async function stableInstanceId(loaded, template) {
16955
17179
  if (loaded.kind !== "managed") return loaded.config.instanceId ?? template.instanceId;
16956
17180
  return new X509Certificate(await readFile(loaded.setup.tls.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
16957
17181
  }
16958
- function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0) {
17182
+ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, listenPort = 0, legacyInstanceId) {
16959
17183
  const origin = new URL(publicOrigin);
16960
17184
  if (origin.protocol !== "https:" || origin.username !== "" || origin.password !== "" || origin.pathname !== "/" || origin.search !== "" || origin.hash !== "") throw new Error("remote public origin must be an HTTPS origin");
16961
17185
  const publicAuthority = origin.port === "" ? `${origin.hostname}:443` : origin.host;
@@ -16968,6 +17192,7 @@ function remoteGatewayConfig(template, publicOrigin, stateFile, instanceId, list
16968
17192
  allowedCidrs: Object.freeze([parseCidr("127.0.0.0/8")]),
16969
17193
  stateFile,
16970
17194
  instanceId,
17195
+ ...legacyInstanceId === void 0 ? {} : { legacyInstanceId },
16971
17196
  tls: Object.freeze({ mode: "disabled" }),
16972
17197
  publicTls: true,
16973
17198
  discovery: false
@@ -17028,11 +17253,11 @@ async function readFrpIngressTrustAnchor(settings, stateFile, ownedRoot = dirnam
17028
17253
  }
17029
17254
  }
17030
17255
  /** Reuse remote HTTPS policy while binding a separately validated private HTTP origin. */
17031
- function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort) {
17256
+ function originGatewayConfig(template, settings, stateFile, instanceId, listenPort = settings.listenPort, legacyInstanceId) {
17032
17257
  const validated = parseOriginSettings(settings);
17033
17258
  if (listenPort !== 0) validateOriginListenPort(listenPort);
17034
17259
  return Object.freeze({
17035
- ...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort),
17260
+ ...remoteGatewayConfig(template, validated.publicOrigin, stateFile, instanceId, listenPort, legacyInstanceId),
17036
17261
  listenHost: validated.listenHost,
17037
17262
  allowedCidrs: Object.freeze(validated.allowedCidrs.map(parseCidr))
17038
17263
  });
@@ -17115,6 +17340,7 @@ async function apply(ctx, config) {
17115
17340
  const frpComponent = new FrpComponentManager({ stateDirectory });
17116
17341
  await frpComponent.initialize();
17117
17342
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
17343
+ const remoteInstanceId = loaded.kind === "managed" ? frpIdentity : loaded.config.instanceId ?? frpIdentity;
17118
17344
  const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
17119
17345
  await frpConfig.initialize();
17120
17346
  const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"), stateDirectory);
@@ -17166,10 +17392,10 @@ async function apply(ctx, config) {
17166
17392
  let trustedNetworksWrite = Promise.resolve();
17167
17393
  let adminClosing = false;
17168
17394
  const startGateway = async (candidateConfig) => {
17169
- const resolved = parseGatewayConfig({
17395
+ const resolved = withInstallationIdentity(parseGatewayConfig({
17170
17396
  ...candidateConfig,
17171
17397
  upstreamOrigin: template.upstreamOrigin.origin
17172
- });
17398
+ }), frpIdentity, candidateConfig.instanceId !== void 0);
17173
17399
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, (source, code) => {
17174
17400
  logger.warn("%s discovery degraded while DSH remains available: %s", source, code);
17175
17401
  }, clientModulePreferences);
@@ -17227,7 +17453,7 @@ async function apply(ctx, config) {
17227
17453
  }
17228
17454
  }
17229
17455
  const createRemoteGateway = async (publicOrigin, listenPort = 0) => {
17230
- const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, instanceId, listenPort);
17456
+ const resolved = remoteGatewayConfig(template, publicOrigin, remoteDeviceFile, remoteInstanceId, listenPort, instanceId);
17231
17457
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17232
17458
  await candidate.start();
17233
17459
  return candidate;
@@ -17254,7 +17480,7 @@ async function apply(ctx, config) {
17254
17480
  return candidate;
17255
17481
  };
17256
17482
  const createOriginGateway = async (settings) => {
17257
- const resolved = originGatewayConfig(template, settings, remoteDeviceFile, instanceId);
17483
+ const resolved = originGatewayConfig(template, settings, remoteDeviceFile, remoteInstanceId, settings.listenPort, instanceId);
17258
17484
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
17259
17485
  try {
17260
17486
  await candidate.start();
@@ -17273,7 +17499,7 @@ async function apply(ctx, config) {
17273
17499
  store: createVolatileRemoteControlStore(),
17274
17500
  component: caddyComponent,
17275
17501
  config: caddyConfig,
17276
- instanceId,
17502
+ instanceId: remoteInstanceId,
17277
17503
  createGateway: (settings) => createRemoteGateway(settings.publicOrigin)
17278
17504
  });
17279
17505
  const originController = new ManagedOriginController({
@@ -17313,7 +17539,7 @@ async function apply(ctx, config) {
17313
17539
  store: frpStore,
17314
17540
  executable: frpComponent.executable,
17315
17541
  config: frpConfig,
17316
- instanceId,
17542
+ instanceId: remoteInstanceId,
17317
17543
  createGateway: createFrpGateway,
17318
17544
  /**
17319
17545
  * Hand the start-up self-check the CA the app itself pins.
@@ -17943,6 +18169,11 @@ async function apply(ctx, config) {
17943
18169
  adminClosing = true;
17944
18170
  unregister();
17945
18171
  };
18172
+ const drainRemoteWork = async () => {
18173
+ const draining = remoteProviders.close();
18174
+ caddyController.cancelPendingStart();
18175
+ await settleCleanupSteps([() => caddyComponent.close(), () => draining]);
18176
+ };
17946
18177
  const disposeMobileCommand = ctx.commands.register({
17947
18178
  name: "mobile",
17948
18179
  description: "定制手机界面或添加电脑能力",
@@ -18009,6 +18240,7 @@ async function apply(ctx, config) {
18009
18240
  try {
18010
18241
  await settleCleanupSteps([
18011
18242
  stopAdmin,
18243
+ drainRemoteWork,
18012
18244
  () => trustedNetworksWrite,
18013
18245
  disposeMobileCommand,
18014
18246
  disposeTaskEvents,
@@ -18028,6 +18260,7 @@ async function apply(ctx, config) {
18028
18260
  return async () => {
18029
18261
  await settleCleanupSteps([
18030
18262
  stopAdmin,
18263
+ drainRemoteWork,
18031
18264
  () => trustedNetworksWrite,
18032
18265
  disposeMobileCommand,
18033
18266
  disposeTaskEvents,