dsh-deeppilot 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -203,7 +203,7 @@ function validateRequest(type, value) {
203
203
  "c2s.workspaces.list",
204
204
  "c2s.directory.pick"
205
205
  ]);
206
- if (value === void 0 && (type === "c2s.session.create" || type === "c2s.directory.list")) value = {};
206
+ if (value === void 0 && (type === "c2s.session.create" || type === "c2s.directory.list" || type === "c2s.device.revoke")) value = {};
207
207
  if (value == null && empty.has(type)) return;
208
208
  if (value === null || typeof value !== "object" || Array.isArray(value)) return "payload must be an object";
209
209
  const p = value;
@@ -213,6 +213,7 @@ function validateRequest(type, value) {
213
213
  if (type.startsWith("c2s.session.") && type !== "c2s.session.create" && !text(p.sessionId)) return "invalid sessionId";
214
214
  if (type === "c2s.session.create" && (!optional("workspaceId", (v) => text(v)) || !optional("cwd", (v) => text(v, 32768)) || p.workspaceId !== void 0 && p.cwd !== void 0)) return "invalid workspace selection";
215
215
  if (type === "c2s.directory.list" && !optional("path", (v) => text(v, 32768, false))) return "invalid path";
216
+ if (type === "c2s.device.revoke" && !optional("deviceId", (v) => text(v))) return "invalid deviceId";
216
217
  if (type === "c2s.workspace.create" && !text(p.path, 32768)) return "invalid path";
217
218
  if (type === "c2s.session.open" && !optional("tailCount", (v) => integer(v, 1, 1e4))) return "invalid tailCount";
218
219
  if (type === "c2s.session.history" && (!integer(p.beforeSeq, 0, Number.MAX_SAFE_INTEGER) || !optional("limit", (v) => integer(v, 1, 500)))) return "invalid history range";
@@ -530,6 +531,7 @@ var DeviceStore = class DeviceStore {
530
531
  }
531
532
  const current = record.apns;
532
533
  if (current && current.token === next.token && current.environment === next.environment && JSON.stringify(current.categories ?? {}) === JSON.stringify(next.categories ?? {})) return;
534
+ this.detachDuplicateToken("apns", normalized, deviceId);
533
535
  record.apns = next;
534
536
  this.flush();
535
537
  }
@@ -566,10 +568,12 @@ var DeviceStore = class DeviceStore {
566
568
  setWidgetPushToken(deviceId, token, environment, now) {
567
569
  const record = this.authorized(deviceId);
568
570
  if (!record || !isValidApnsToken(token)) return;
571
+ const normalized = token.toLowerCase();
569
572
  const previous = record.widgetApns;
570
- if (previous?.token === token && previous.environment === environment && now - previous.updatedAt < 36e5) return;
573
+ if (previous?.token === normalized && previous.environment === environment && now - previous.updatedAt < 36e5) return;
574
+ this.detachDuplicateToken("widgetApns", normalized, deviceId);
571
575
  record.widgetApns = {
572
- token,
576
+ token: normalized,
573
577
  environment,
574
578
  updatedAt: now
575
579
  };
@@ -582,6 +586,22 @@ var DeviceStore = class DeviceStore {
582
586
  delete record.widgetApns;
583
587
  this.flush();
584
588
  }
589
+ /**
590
+ * One physical device owns one APNs token, but a re-pair or a botched unbind
591
+ * can leave the same token attached to a second deviceId. Registering it
592
+ * under `keepDeviceId` detaches it from every other record — only that one
593
+ * token field, never the other record's pairing key or scopes. No flush of
594
+ * its own: the caller batches it with the write that follows, so a single
595
+ * registry write covers both changes.
596
+ */
597
+ detachDuplicateToken(field, token, keepDeviceId) {
598
+ for (const record of this.devices.values()) {
599
+ if (record.deviceId === keepDeviceId) continue;
600
+ if (field === "apns") {
601
+ if (record.apns?.token === token) delete record.apns;
602
+ } else if (record.widgetApns?.token === token) delete record.widgetApns;
603
+ }
604
+ }
585
605
  /** Serialized so concurrent touches can never interleave half-written JSON. */
586
606
  flush() {
587
607
  const next = this.flushTail.then(() => this.writeFile());
@@ -640,6 +660,7 @@ function isEnvelope(value) {
640
660
  function requiredScope(type) {
641
661
  if (typeof type !== "string") return void 0;
642
662
  if (type === "c2s.ping" || type === "c2s.resume") return void 0;
663
+ if (type === "c2s.device.revoke") return void 0;
643
664
  if (type === "c2s.session.sendPrompt" || type === "c2s.session.delivery") return "prompt.send";
644
665
  if (type === "c2s.pending.list") return "interactions.respond";
645
666
  if (type === "c2s.approval.respond" || type === "c2s.question.respond") return "interactions.respond";
@@ -712,6 +733,12 @@ var BridgeConnection = class {
712
733
  authenticated = false;
713
734
  authenticationSettled = false;
714
735
  closed = false;
736
+ /**
737
+ * Set the moment c2s.device.revoke tears this device down. `ws.close()` is
738
+ * asynchronous, so frames that arrive while the 4401 close is in flight must
739
+ * already be refused — a revoked device may not keep driving the bridge.
740
+ */
741
+ revoked = false;
715
742
  helloTimer;
716
743
  openSessions = /* @__PURE__ */ new Set();
717
744
  /**
@@ -868,6 +895,7 @@ var BridgeConnection = class {
868
895
  }
869
896
  async onMessage(raw) {
870
897
  this.lastActivity = Date.now();
898
+ if (this.revoked) return;
871
899
  if (!this.authenticated && raw.length > 65536) {
872
900
  this.close(1009, "pre-auth frame too large");
873
901
  return;
@@ -906,7 +934,9 @@ var BridgeConnection = class {
906
934
  "c2s.sessions.list",
907
935
  "c2s.pending.list",
908
936
  "c2s.widget.push.register"
909
- ].includes(env.type)) return this.fail(env.id, "E_FORBIDDEN", "widget connection is read-only");
937
+ ].includes(env.type)) {
938
+ if (env.type !== "c2s.device.revoke") return this.fail(env.id, "E_FORBIDDEN", "widget connection is read-only");
939
+ }
910
940
  const required = requiredScope(env.type);
911
941
  if (required !== void 0 && !this.scopes.has(required)) {
912
942
  this.fail(env.id, "E_FORBIDDEN", `scope ${required} required`);
@@ -1164,6 +1194,23 @@ var BridgeConnection = class {
1164
1194
  this.send("s2c.ack", {}, env.id);
1165
1195
  return;
1166
1196
  }
1197
+ case "c2s.device.revoke": {
1198
+ const p = env.payload;
1199
+ if (p?.deviceId !== void 0 && typeof p.deviceId !== "string") return this.fail(env.id, "E_PROTOCOL", "deviceId must be a string");
1200
+ if (typeof p?.deviceId === "string" && p.deviceId !== this.deviceId) return this.fail(env.id, "E_FORBIDDEN", "deviceId does not match the authenticated device");
1201
+ if (this.widgetClient) return this.fail(env.id, "E_FORBIDDEN", "widget connection cannot revoke its device");
1202
+ const deviceId = this.deviceId;
1203
+ this.deps.devices.revoke(deviceId, Date.now());
1204
+ this.revoked = true;
1205
+ try {
1206
+ await this.deps.onDeviceRevoke?.(deviceId, this);
1207
+ } catch (error) {
1208
+ if (this.deps.debug === true) this.deps.log("device revoke hook failed: " + String(error));
1209
+ }
1210
+ this.send("s2c.ack", { revoked: true }, env.id);
1211
+ this.close(4401, "device revoked");
1212
+ return;
1213
+ }
1167
1214
  case "c2s.liveActivity.unregister": {
1168
1215
  const p = env.payload;
1169
1216
  if (this.pendingLiveActivityId === p.activityId) {
@@ -1238,6 +1285,7 @@ var BridgeConnection = class {
1238
1285
  }
1239
1286
  }
1240
1287
  async prove(env) {
1288
+ if (this.revoked) return;
1241
1289
  const p = env.payload ?? {};
1242
1290
  if (!p.deviceId) {
1243
1291
  this.fail(env.id, "E_PROTOCOL", "deviceId required");
@@ -1891,7 +1939,8 @@ var HostBridge = class {
1891
1939
  projectSelection: typeof this.apiProxy.workspace?.list === "function" && typeof this.apiProxy.workspace?.create === "function",
1892
1940
  push: this.pushOutlet?.isAvailable() === true,
1893
1941
  widgetPush: true,
1894
- liveActivityPush: true
1942
+ liveActivityPush: true,
1943
+ deviceRevoke: true
1895
1944
  };
1896
1945
  }
1897
1946
  diagnostic(message) {
@@ -2430,11 +2479,38 @@ var HostBridge = class {
2430
2479
  hasMore: Boolean(result.hasMore) || page.dropped > 0
2431
2480
  });
2432
2481
  this.deriveTitleFallback(sessionId, messages);
2482
+ if (this.apiProxy.supportsProjections === true) this.refreshProjections(sessionId);
2433
2483
  return true;
2434
2484
  } catch {
2435
2485
  return false;
2436
2486
  }
2437
2487
  }
2488
+ /**
2489
+ * Pull one session's projection baseline (DSH 0.1.7+) and fold every key
2490
+ * through applyProjection — unknown keys are ignored there, `null` means the
2491
+ * session no longer exists, and any failure degrades to a diagnostic: opening
2492
+ * a session must never fail because a baseline could not be read.
2493
+ */
2494
+ async refreshProjections(sessionId) {
2495
+ try {
2496
+ const response = await this.apiProxy.sessions.projections?.({
2497
+ rpcId: randomUUID(),
2498
+ payload: { sessionId }
2499
+ });
2500
+ if (this.disposed) return;
2501
+ const result = response?.result;
2502
+ if (!result) return;
2503
+ if (!result.ok) {
2504
+ this.diagnostic("sessions.projections failed: " + result.error.code);
2505
+ return;
2506
+ }
2507
+ const baseline = result.value;
2508
+ if (baseline === null || baseline === void 0) return;
2509
+ for (const [key, value] of Object.entries(baseline.values ?? {})) this.applyProjection(sessionId, key, value);
2510
+ } catch (error) {
2511
+ this.diagnostic("sessions.projections threw: " + String(error?.message ?? error));
2512
+ }
2513
+ }
2438
2514
  async historyPage(sessionId, beforeSeq, limit) {
2439
2515
  try {
2440
2516
  const response = await this.apiProxy.sessions.history({
@@ -3391,6 +3467,14 @@ var Dsh012ApiProxy = class {
3391
3467
  this.shouldSurfaceInteraction = options.shouldSurfaceInteraction ?? (() => true);
3392
3468
  }
3393
3469
  /**
3470
+ * Whether the host controller implements `session.projections` (added in
3471
+ * DSH 0.1.7). HostBridge gates its open-time baseline refresh on this so
3472
+ * older hosts keep their current list-row-hint behaviour with no RPC noise.
3473
+ */
3474
+ get supportsProjections() {
3475
+ return typeof this.session.projections === "function";
3476
+ }
3477
+ /**
3394
3478
  * Decide whether this Gateway Client can surface a phone card. The decision
3395
3479
  * fails open to false so a broken device registry delegates this delivery
3396
3480
  * while the official Web Client remains able to answer.
@@ -3444,7 +3528,12 @@ var Dsh012ApiProxy = class {
3444
3528
  selectModel: async (request) => this.call(() => this.session.selectModel(request.payload ?? {})),
3445
3529
  rename: async (request) => this.call(() => this.session.rename(request.payload)),
3446
3530
  cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
3447
- attachment: async (request) => this.call(() => this.session.attachment(request.payload))
3531
+ attachment: async (request) => this.call(() => this.session.attachment(request.payload)),
3532
+ projections: async (request) => this.call(() => {
3533
+ const read = this.session.projections;
3534
+ if (typeof read !== "function") throw unavailable("session projections unavailable");
3535
+ return read.call(this.session, request.payload);
3536
+ })
3448
3537
  };
3449
3538
  workspace = {
3450
3539
  list: async () => this.call(async () => {
@@ -4018,6 +4107,35 @@ const scopesSchema = { parse(value) {
4018
4107
  if (!Array.isArray(value) || value.some((scope) => typeof scope !== "string" || !DEVICE_SCOPES.includes(scope))) reject("scopes");
4019
4108
  return normalizeDeviceScopes(value);
4020
4109
  } };
4110
+ const booleanSchema = { parse(value) {
4111
+ if (typeof value !== "boolean") reject("boolean");
4112
+ return value;
4113
+ } };
4114
+ /**
4115
+ * One strict boundary codec carrying both generation shapes of the supported
4116
+ * `0.1.x` host family.
4117
+ *
4118
+ * Through `0.1.6-alpha.1` a descriptor published the schema value itself and
4119
+ * every consumer parsed through `codec.schema.parse(value)`. `0.1.6-alpha.2`
4120
+ * switched to a lazily materialized process-realm factory: `TypertRegistry`
4121
+ * now rejects a strict codec without `create()`, and the Gateway parses
4122
+ * through `codec.create().parse(value)`.
4123
+ *
4124
+ * Publishing both keys — `create()` returning the same hand-written,
4125
+ * dependency-free codec — keeps one package valid across the entire declared
4126
+ * peer range instead of forking the wire contract per host generation. The
4127
+ * cast is required because each generation's `TypertCodec` declares only its
4128
+ * own key, so a fresh object literal carrying both fails excess-property
4129
+ * checking against either one.
4130
+ */
4131
+ function strictCodec(typeSymbol, schema) {
4132
+ return {
4133
+ mode: "strict",
4134
+ typeSymbol,
4135
+ schema,
4136
+ create: () => schema
4137
+ };
4138
+ }
4021
4139
  const REPORT_HOST_CONTRIBUTION = {
4022
4140
  package: REPORT_REMOTE_PACKAGE,
4023
4141
  face: "host",
@@ -4035,11 +4153,7 @@ const REPORT_HOST_CONTRIBUTION = {
4035
4153
  method: "report",
4036
4154
  invocation: { kind: "direct" },
4037
4155
  parameters: [],
4038
- result: {
4039
- mode: "strict",
4040
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeepPilotReport`,
4041
- schema: reportSchema
4042
- }
4156
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeepPilotReport`, reportSchema)
4043
4157
  },
4044
4158
  {
4045
4159
  id: `${REPORT_REMOTE_PACKAGE}#${BEGIN_PAIRING_ENDPOINT}`,
@@ -4048,11 +4162,7 @@ const REPORT_HOST_CONTRIBUTION = {
4048
4162
  method: "beginPairing",
4049
4163
  invocation: { kind: "direct" },
4050
4164
  parameters: [],
4051
- result: {
4052
- mode: "strict",
4053
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`,
4054
- schema: pairingGrantSchema
4055
- }
4165
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#PairingGrantSnapshot`, pairingGrantSchema)
4056
4166
  },
4057
4167
  {
4058
4168
  id: `${REPORT_REMOTE_PACKAGE}#${REVOKE_DEVICE_ENDPOINT}`,
@@ -4064,20 +4174,9 @@ const REPORT_HOST_CONTRIBUTION = {
4064
4174
  name: "deviceId",
4065
4175
  wire: "deviceId",
4066
4176
  source: "json",
4067
- codec: {
4068
- mode: "strict",
4069
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
4070
- schema: deviceIdSchema
4071
- }
4177
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceId`, deviceIdSchema)
4072
4178
  }],
4073
- result: {
4074
- mode: "strict",
4075
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#Boolean`,
4076
- schema: { parse(value) {
4077
- if (typeof value !== "boolean") reject("boolean");
4078
- return value;
4079
- } }
4080
- }
4179
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#Boolean`, booleanSchema)
4081
4180
  },
4082
4181
  {
4083
4182
  id: `${REPORT_REMOTE_PACKAGE}#${SET_DEVICE_SCOPES_ENDPOINT}`,
@@ -4089,26 +4188,14 @@ const REPORT_HOST_CONTRIBUTION = {
4089
4188
  name: "deviceId",
4090
4189
  wire: "deviceId",
4091
4190
  source: "json",
4092
- codec: {
4093
- mode: "strict",
4094
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceId`,
4095
- schema: deviceIdSchema
4096
- }
4191
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceId`, deviceIdSchema)
4097
4192
  }, {
4098
4193
  name: "scopes",
4099
4194
  wire: "scopes",
4100
4195
  source: "json",
4101
- codec: {
4102
- mode: "strict",
4103
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
4104
- schema: scopesSchema
4105
- }
4196
+ codec: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceScopes`, scopesSchema)
4106
4197
  }],
4107
- result: {
4108
- mode: "strict",
4109
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#DeviceScopes`,
4110
- schema: scopesSchema
4111
- }
4198
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#DeviceScopes`, scopesSchema)
4112
4199
  },
4113
4200
  {
4114
4201
  id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testRelay`,
@@ -4117,11 +4204,7 @@ const REPORT_HOST_CONTRIBUTION = {
4117
4204
  method: "testRelay",
4118
4205
  invocation: { kind: "direct" },
4119
4206
  parameters: [],
4120
- result: {
4121
- mode: "strict",
4122
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#RelayTestResult`,
4123
- schema: relayTestSchema
4124
- }
4207
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#RelayTestResult`, relayTestSchema)
4125
4208
  },
4126
4209
  {
4127
4210
  id: `${REPORT_REMOTE_PACKAGE}#deeppilot/testPush`,
@@ -4130,11 +4213,7 @@ const REPORT_HOST_CONTRIBUTION = {
4130
4213
  method: "testPush",
4131
4214
  invocation: { kind: "direct" },
4132
4215
  parameters: [],
4133
- result: {
4134
- mode: "strict",
4135
- typeSymbol: `${REPORT_REMOTE_PACKAGE}#PushTestResult`,
4136
- schema: pushTestSchema
4137
- }
4216
+ result: strictCodec(`${REPORT_REMOTE_PACKAGE}#PushTestResult`, pushTestSchema)
4138
4217
  }
4139
4218
  ]
4140
4219
  };
@@ -5289,18 +5368,18 @@ function localListenError(error, port) {
5289
5368
  /** Operator-run relay used by distributed builds; overridable via config. */
5290
5369
  const DEFAULT_RELAY_URL = "https://pilot.hailab.dev";
5291
5370
  const Config = z.object({
5292
- enabled: z.boolean().default(true),
5371
+ enabled: live(z.boolean().default(true)),
5293
5372
  devicesPath: z.string().default(join(bridgeDataDir(), "devices-v2.json")),
5294
5373
  historyBufferMax: z.natural().min(100).default(2e3),
5295
- debug: z.boolean().default(false),
5296
- local: z.object({
5374
+ debug: live(z.boolean().default(false)),
5375
+ local: live(z.object({
5297
5376
  enabled: z.boolean().default(true),
5298
5377
  port: z.natural().min(MIN_LOCAL_PORT).max(MAX_LOCAL_PORT).default(DEFAULT_LOCAL_PORT).description("DeepPilot 局域网独立端口(仅 TLS;默认 3098,修改后本地连接会短暂重连)")
5299
5378
  }).default({
5300
5379
  enabled: true,
5301
5380
  port: DEFAULT_LOCAL_PORT
5302
- }),
5303
- remote: z.object({
5381
+ })),
5382
+ remote: live(z.object({
5304
5383
  enabled: z.boolean().default(false),
5305
5384
  provider: z.union(["tailscale-funnel"]).default("tailscale-funnel"),
5306
5385
  hostname: z.string().default(DEFAULT_REMOTE_HOSTNAME),
@@ -5320,7 +5399,7 @@ const Config = z.object({
5320
5399
  helperPath: "",
5321
5400
  funnelPort: 443,
5322
5401
  maxConnectionsPerSource: 8
5323
- }),
5402
+ })),
5324
5403
  push: z.object({
5325
5404
  provider: z.union([
5326
5405
  "none",
@@ -5346,14 +5425,50 @@ const Config = z.object({
5346
5425
  })
5347
5426
  });
5348
5427
  /**
5428
+ * Declare one live-update (volatile) config node when the host supports it.
5429
+ *
5430
+ * DSH 0.1.7's schemastery adds `Schema#volatile()`; the schemas this package
5431
+ * typechecks against (3.18.x line) do not have the method, so the call goes
5432
+ * through a structural check instead of a direct chain — a direct
5433
+ * `.volatile()` would both fail typecheck today and throw at import on an
5434
+ * older host, killing plugin activation.
5435
+ */
5436
+ function live(node) {
5437
+ const schema = node;
5438
+ if (typeof schema.volatile === "function") return schema.volatile();
5439
+ if (typeof schema.extra === "function") return schema.extra("volatile", true);
5440
+ return node;
5441
+ }
5442
+ /**
5443
+ * Deep-unwrap live-update (volatile) references to plain values.
5444
+ *
5445
+ * On a 0.1.7 host, `apply()`'s options — and even a direct `Config(raw)` call
5446
+ * — carry `{ get() }` reference objects for volatile fields. Every consumer of
5447
+ * `currentConfig()` compares plain values (`=== true`, numeric bounds, object
5448
+ * fields), and schemastery refuses to re-parse a reference, so normalization
5449
+ * unwraps on the way in and on the way out. A JSON-shaped config value cannot
5450
+ * carry a function, so the `get` check cannot false-positive; plain input
5451
+ * passes through unchanged, keeping older-host behavior identical.
5452
+ */
5453
+ function plainConfig(value) {
5454
+ if (value === null || typeof value !== "object") return value;
5455
+ if (typeof value.get === "function") return plainConfig(value.get());
5456
+ if (Array.isArray(value)) return value.map(plainConfig);
5457
+ const plain = {};
5458
+ for (const [key, item] of Object.entries(value)) plain[key] = plainConfig(item);
5459
+ return plain;
5460
+ }
5461
+ /**
5349
5462
  * Cordis hands the second argument in different shapes depending on host
5350
5463
  * composition: a reactive options getter, the resolved config value, or
5351
- * nothing when the patch row omits `config`. Normalize all of them.
5464
+ * nothing when the patch row omits `config`. Normalize all of them — and
5465
+ * unwrap volatile references so the returned Config is plain data on every
5466
+ * host generation.
5352
5467
  */
5353
5468
  function normalizeOptions(options) {
5354
- if (typeof options === "function") return options();
5355
- if (options && typeof options === "object") return options;
5356
- return Config(void 0) ?? {};
5469
+ if (typeof options === "function") return plainConfig(options());
5470
+ if (options && typeof options === "object") return plainConfig(options);
5471
+ return plainConfig(Config(void 0) ?? {});
5357
5472
  }
5358
5473
  //#endregion
5359
5474
  //#region src/phone-http.ts
@@ -5955,8 +6070,13 @@ function apply(ctx, options) {
5955
6070
  })
5956
6071
  };
5957
6072
  ctx.inject(["settings"], (settingsCtx) => {
6073
+ if (typeof settingsCtx.settings?.installSection !== "function") return;
5958
6074
  settingsCtx.settings.installSection(ctx, "deeppilot", Config, normalizeOptions(void 0), settingsHooks);
5959
6075
  });
6076
+ ctx.on("loader/volatile-update", () => queueMicrotask(() => {
6077
+ scheduleLocalReconcile?.();
6078
+ scheduleRemoteReconcile?.();
6079
+ }));
5960
6080
  if (currentConfig().enabled !== true) log("disabled via settings; bridge stays inactive (rumors of /phone below are skipped)");
5961
6081
  const dataDir = bridgeDataDir();
5962
6082
  const pushRelayPath = join(dataDir, "push-relay.json");
@@ -6110,6 +6230,28 @@ function apply(ctx, options) {
6110
6230
  for (const connection of connections) connection.closeForServerStop();
6111
6231
  connections.clear();
6112
6232
  };
6233
+ /**
6234
+ * Unbind one device. Single path shared by the settings page (report-service
6235
+ * revokeDevice) and the wire-level c2s.device.revoke frame: mark the registry
6236
+ * tombstone (which also drops every APNs/WidgetKit/LiveActivity token, so the
6237
+ * push fan-out can no longer select it) and hard-drop its live sockets.
6238
+ * `except` is the connection that sent the revoke frame — it closes itself
6239
+ * with 4401 after acking, so it is not terminated here.
6240
+ */
6241
+ const revokeDevice = async (deviceId, except) => {
6242
+ const { devices } = await ready;
6243
+ if (!devices) throw new Error("device registry unavailable");
6244
+ const revoked = devices.revoke(deviceId, Date.now());
6245
+ if (revoked) {
6246
+ for (const connection of [...connections]) {
6247
+ if (connection === except || connection.connectedDeviceId !== deviceId) continue;
6248
+ connection.terminate();
6249
+ connections.delete(connection);
6250
+ }
6251
+ log(`device revoked id=${auditLabel(deviceId)}`);
6252
+ }
6253
+ return revoked;
6254
+ };
6113
6255
  const resolvePushConfig = (config) => {
6114
6256
  const push = config.push ?? {};
6115
6257
  const configured = push.provider ?? "none";
@@ -6500,18 +6642,7 @@ function apply(ctx, options) {
6500
6642
  devices
6501
6643
  };
6502
6644
  }, beginPairing, async (deviceId) => {
6503
- const { devices } = await ready;
6504
- if (!devices) throw new Error("device registry unavailable");
6505
- const revoked = devices.revoke(deviceId, Date.now());
6506
- if (revoked) {
6507
- for (const connection of [...connections]) {
6508
- if (connection.connectedDeviceId !== deviceId) continue;
6509
- connection.terminate();
6510
- connections.delete(connection);
6511
- }
6512
- log(`device revoked id=${auditLabel(deviceId)}`);
6513
- }
6514
- return revoked;
6645
+ return await revokeDevice(deviceId);
6515
6646
  }, async (deviceId, scopes) => {
6516
6647
  const { devices } = await ready;
6517
6648
  if (!devices) throw new Error("device registry unavailable");
@@ -6720,7 +6851,8 @@ function apply(ctx, options) {
6720
6851
  onDeviceAuthenticated: (deviceId) => {
6721
6852
  log(`device authenticated id=${auditLabel(deviceId)} source=${auditLabel(source)}`);
6722
6853
  },
6723
- onPushEnrollKey: handlePushEnrollKey
6854
+ onPushEnrollKey: handlePushEnrollKey,
6855
+ onDeviceRevoke: (deviceId, except) => revokeDevice(deviceId, except)
6724
6856
  });
6725
6857
  connections.add(connection);
6726
6858
  } catch (error) {