dsh-mobile 0.8.5 → 0.8.6

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
@@ -1210,6 +1210,39 @@ function parseGatewayConfig(raw) {
1210
1210
  });
1211
1211
  }
1212
1212
  // src/exec-file.ts
1213
+ /**
1214
+ * Longest stream text attached to a failed subprocess result.
1215
+ *
1216
+ * The text exists for diagnostics only: a caller decides which part is safe to
1217
+ * show. It is bounded here so a chatty child cannot turn one failure into an
1218
+ * unbounded buffer in every future consumer of that rejection.
1219
+ */
1220
+ const MAX_SUBPROCESS_ERROR_STREAM_CHARS = 8192;
1221
+ function boundedStream(value) {
1222
+ if (typeof value !== "string" || value === "") return void 0;
1223
+ return value.length <= 8192 ? value : value.slice(0, MAX_SUBPROCESS_ERROR_STREAM_CHARS);
1224
+ }
1225
+ /**
1226
+ * Attach the captured streams to a subprocess failure, best effort.
1227
+ *
1228
+ * `execFile` reports a non-zero exit as an Error whose only text is the command
1229
+ * line with stderr merged into `message`; the raw stream strings are handed to
1230
+ * the callback and otherwise discarded, so a caller cannot read them from the
1231
+ * rejection. They are copied onto it when it accepts properties. A frozen,
1232
+ * exotic or primitive rejection value must keep `message` as its diagnostic
1233
+ * rather than turn one failure into a different error, so every write is
1234
+ * guarded and an existing property is never overwritten.
1235
+ */
1236
+ function attachCapturedStreams(error, stdout, stderr) {
1237
+ if (typeof error !== "object" || error === null) return;
1238
+ const target = error;
1239
+ try {
1240
+ const capturedStderr = boundedStream(stderr);
1241
+ const capturedStdout = boundedStream(stdout);
1242
+ if (target.stderr === void 0 && capturedStderr !== void 0) target.stderr = capturedStderr;
1243
+ if (target.stdout === void 0 && capturedStdout !== void 0) target.stdout = capturedStdout;
1244
+ } catch {}
1245
+ }
1213
1246
  /** Capture both output streams even when a desktop host wraps execFile without Node's promisify metadata. */
1214
1247
  function execFileText(file, args, options = {}) {
1215
1248
  return new Promise((resolve, reject) => {
@@ -1219,6 +1252,7 @@ function execFileText(file, args, options = {}) {
1219
1252
  encoding: "utf8"
1220
1253
  }, (error, stdout, stderr) => {
1221
1254
  if (error !== null) {
1255
+ attachCapturedStreams(error, stdout, stderr);
1222
1256
  reject(error);
1223
1257
  return;
1224
1258
  }
@@ -5703,6 +5737,7 @@ const MOBILE_PUSH_PREFERENCES_PATH = `${AUTH_PREFIX}/push/preferences`;
5703
5737
  const MOBILE_PUSH_DISABLE_PATH = `${AUTH_PREFIX}/push/disable`;
5704
5738
  const MOBILE_PUSH_TEST_PATH = `${AUTH_PREFIX}/push/test`;
5705
5739
  const MOBILE_PUSH_RESOLVE_PATH = `${AUTH_PREFIX}/push/resolve`;
5740
+ const BACKGROUND_EVENT_PATH = `${AUTH_PREFIX}/background-events`;
5706
5741
  const MOBILE_PUSH_PREFERENCE_KEYS = [
5707
5742
  "question",
5708
5743
  "plan",
@@ -6448,6 +6483,12 @@ function sanitizeRequestHeaders(request, upstream) {
6448
6483
  const value = request.headers[name];
6449
6484
  if (value !== void 0) headers[name] = value;
6450
6485
  }
6486
+ for (const name of Object.keys(request.headers)) {
6487
+ const lower = name.toLowerCase();
6488
+ if (!lower.startsWith("x-dsh-") || lower.startsWith("x-dsh-mobile-") || lower === "x-dsh-auth-token") continue;
6489
+ const value = request.headers[name];
6490
+ if (value !== void 0) headers[name] = Array.isArray(value) ? value[0] : value;
6491
+ }
6451
6492
  return headers;
6452
6493
  }
6453
6494
  const BLOCKED_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -7601,6 +7642,7 @@ var MobileAccessGateway = class {
7601
7642
  const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
7602
7643
  const workspaceDirectories = (request.method === "GET" || request.method === "POST") && target.decodedPathname === `/mobile-access/workspace-directories`;
7603
7644
  const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
7645
+ const backgroundEvents = target.decodedPathname === BACKGROUND_EVENT_PATH;
7604
7646
  const mobilePushRoute = [
7605
7647
  MOBILE_PUSH_CONFIG_PATH,
7606
7648
  MOBILE_PUSH_SUBSCRIPTION_PATH,
@@ -7634,7 +7676,7 @@ var MobileAccessGateway = class {
7634
7676
  fallback: void 0,
7635
7677
  digest: requestedCompatRevision
7636
7678
  } : void 0;
7637
- if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !workspaceDirectories && !clientModules && !mobilePushRoute && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
7679
+ if (customAsset === void 0 && requestedMobileBootBatch === void 0 && !computerImages && !computerImage && !workspaceDirectories && !clientModules && !backgroundEvents && !mobilePushRoute && extensionTarget(target.decodedPathname) === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
7638
7680
  if (![
7639
7681
  "GET",
7640
7682
  "HEAD",
@@ -7675,6 +7717,11 @@ var MobileAccessGateway = class {
7675
7717
  await this.handleMobilePushRequest(target, request, response, authorization);
7676
7718
  return;
7677
7719
  }
7720
+ if (backgroundEvents) {
7721
+ if (request.method !== "GET" || target.search !== "") throw new HttpError(request.method === "GET" ? 400 : 405, request.method === "GET" ? "bad_request" : "method_not_allowed");
7722
+ this.openBackgroundEventStream(request, response, authorization);
7723
+ return;
7724
+ }
7678
7725
  if (clientModules) {
7679
7726
  if (target.search !== "") throw new HttpError(400, "bad_request");
7680
7727
  if (request.method !== "GET" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
@@ -9415,6 +9462,54 @@ var MobileAccessGateway = class {
9415
9462
  request.once("aborted", close);
9416
9463
  response.once("close", close);
9417
9464
  }
9465
+ /**
9466
+ * Native background notifications need only Host task and revocation events.
9467
+ * They use the paired session cookie but no mobile document view, because the
9468
+ * App has no live WebView while this stream is serving a suspended page.
9469
+ */
9470
+ openBackgroundEventStream(request, response, authorization) {
9471
+ const operation = this.allocateRequest(authorization, response, {});
9472
+ let closed = false;
9473
+ let heartbeat;
9474
+ const close = () => {
9475
+ if (closed) return;
9476
+ closed = true;
9477
+ if (heartbeat !== void 0) clearInterval(heartbeat);
9478
+ this.taskEventListeners.delete(sendTask);
9479
+ const deviceListeners = this.deviceEventListeners.get(authorization.deviceId);
9480
+ deviceListeners?.delete(sendDevice);
9481
+ if (deviceListeners?.size === 0) this.deviceEventListeners.delete(authorization.deviceId);
9482
+ request.removeListener("aborted", close);
9483
+ response.removeListener("close", close);
9484
+ operation.release();
9485
+ };
9486
+ const sendTask = (payload) => {
9487
+ if (closed || response.destroyed || response.writableEnded) return;
9488
+ response.write(`event: task-notify\ndata: ${payload}\n\n`);
9489
+ };
9490
+ const sendDevice = (payload) => {
9491
+ if (closed || response.destroyed || response.writableEnded) return;
9492
+ response.write(`event: device-revoked\ndata: ${payload}\n\n`);
9493
+ };
9494
+ setSecurityHeaders(response, this.tlsEnabled);
9495
+ response.writeHead(200, {
9496
+ "Content-Type": "text/event-stream; charset=utf-8",
9497
+ "Cache-Control": "no-store",
9498
+ Connection: "keep-alive",
9499
+ "X-Accel-Buffering": "no"
9500
+ });
9501
+ response.write("retry: 2000\n: ready\n\n");
9502
+ this.taskEventListeners.add(sendTask);
9503
+ const deviceListeners = this.deviceEventListeners.get(authorization.deviceId) ?? /* @__PURE__ */ new Set();
9504
+ deviceListeners.add(sendDevice);
9505
+ this.deviceEventListeners.set(authorization.deviceId, deviceListeners);
9506
+ heartbeat = setInterval(() => {
9507
+ if (!closed && !response.destroyed && !response.writableEnded) response.write(": heartbeat\n\n");
9508
+ }, EXTENSION_EVENT_HEARTBEAT_MS);
9509
+ heartbeat.unref();
9510
+ request.once("aborted", close);
9511
+ response.once("close", close);
9512
+ }
9418
9513
  async readUpgradeResponse(upstream, expectedAccept) {
9419
9514
  return new Promise((resolve, reject) => {
9420
9515
  let buffer = Buffer.alloc(0);
@@ -9903,6 +9998,26 @@ var MemoryDeviceStore = class {
9903
9998
  return structuredClone(this.snapshot);
9904
9999
  }
9905
10000
  };
10001
+ // src/mobile-push/service.ts
10002
+ /**
10003
+ * Small Cordis service exposed to trusted Host plugins. The provider owns the
10004
+ * runtime lifetime and all recipient, category, session, and expiry checks.
10005
+ */
10006
+ var MobilePushService = class extends Service {
10007
+ send;
10008
+ constructor(ctx, send) {
10009
+ super(ctx, "mobilePush");
10010
+ this.send = send;
10011
+ }
10012
+ /**
10013
+ * Queue a notification for enabled web-push recipients.
10014
+ * @param input - Session, text, optional category and optional expiry.
10015
+ * @returns The number of newly queued recipient deliveries and the applied expiry.
10016
+ */
10017
+ async notify(input) {
10018
+ return await this.send(input);
10019
+ }
10020
+ };
9906
10021
  // src/managed-files.ts
9907
10022
  /** Refuse link-shaped ancestors inside an owned state root before accessing a child. */
9908
10023
  async function assertManagedParents(root, target, prefix) {
@@ -15961,6 +16076,79 @@ var IrohIdentityProbeError = class extends Error {
15961
16076
  function publicStatus$2(status) {
15962
16077
  return Object.freeze({ ...status });
15963
16078
  }
16079
+ function diagnosticText(value, dataDir) {
16080
+ const text = value.toString("utf8").replaceAll(dataDir, "<data-dir>").trim().replace(/\s+/gu, " ");
16081
+ return text === "" ? void 0 : text.slice(0, IDENTITY_DIAGNOSTIC_MAX_BYTES);
16082
+ }
16083
+ /** The wrapper `tls-prepare` prints when it discards the underlying native cause. */
16084
+ const HOST_TLS_PREPARATION_FAILURE = "Error: host TLS preparation failed";
16085
+ /**
16086
+ * Whether the wrapper is the only explanation stderr carries.
16087
+ *
16088
+ * Compare lines, never the whole buffer: the probe collapses whitespace for
16089
+ * reporting, and a preceding warning or a CRLF terminator must not hide the
16090
+ * fact that no cause chain was printed. A `Caused by:` section means the
16091
+ * native explanation already reached us, so the extra host run cannot add
16092
+ * anything the caller does not already have.
16093
+ */
16094
+ function isBareHostTlsPreparationFailure(value, dataDir) {
16095
+ const lines = value.toString("utf8").replaceAll(dataDir, "<data-dir>").split(/\r?\n/gu).map((line) => line.trim()).filter((line) => line !== "");
16096
+ return lines.includes(HOST_TLS_PREPARATION_FAILURE) && !lines.some((line) => /^Caused by:/u.test(line));
16097
+ }
16098
+ /**
16099
+ * Ask the host entry point for the cause chain when `tls-prepare` reports only
16100
+ * its top-level wrapper. The command is local-only and bounded; it never
16101
+ * downloads a component or enables relay/discovery.
16102
+ */
16103
+ async function hostIdentityDiagnostic(executable, dataDir, launch, onSpawn) {
16104
+ const args = [
16105
+ "--lang",
16106
+ "en",
16107
+ "host",
16108
+ "--data-dir",
16109
+ dataDir,
16110
+ "--local-only"
16111
+ ];
16112
+ const child = launch === void 0 ? spawn(executable, args, {
16113
+ stdio: [
16114
+ "pipe",
16115
+ "pipe",
16116
+ "pipe"
16117
+ ],
16118
+ windowsHide: true
16119
+ }) : launch(executable, args);
16120
+ onSpawn?.(child);
16121
+ let stderrOutput = Buffer.alloc(0);
16122
+ let settled = false;
16123
+ let deadline;
16124
+ return await new Promise((resolve) => {
16125
+ const finish = (value) => {
16126
+ if (settled) return;
16127
+ settled = true;
16128
+ if (deadline !== void 0) clearTimeout(deadline);
16129
+ resolve(value);
16130
+ };
16131
+ deadline = setTimeout(() => {
16132
+ terminateRemoteProcess(child).then(() => {
16133
+ finish(diagnosticText(stderrOutput, dataDir));
16134
+ }, () => {
16135
+ finish(diagnosticText(stderrOutput, dataDir));
16136
+ });
16137
+ }, 5e3);
16138
+ deadline.unref?.();
16139
+ child.stderr?.on("data", (chunk) => {
16140
+ const remaining = IDENTITY_DIAGNOSTIC_MAX_BYTES - stderrOutput.length;
16141
+ if (remaining > 0) stderrOutput = Buffer.concat([stderrOutput, chunk.subarray(0, remaining)]);
16142
+ });
16143
+ child.once("error", () => {
16144
+ finish(diagnosticText(stderrOutput, dataDir));
16145
+ });
16146
+ child.once("close", () => {
16147
+ finish(diagnosticText(stderrOutput, dataDir));
16148
+ });
16149
+ child.stdin?.end();
16150
+ });
16151
+ }
15964
16152
  /**
15965
16153
  * Prepare the sidecar's private identity/TLS state and resolve its endpoint.
15966
16154
  *
@@ -16030,6 +16218,15 @@ async function defaultResolveIdentity(executable, dataDir, options = {}) {
16030
16218
  if (settled) return;
16031
16219
  settled = true;
16032
16220
  const probeDiagnostic = diagnostic();
16221
+ if (code !== 0 && probeDiagnostic !== void 0 && isBareHostTlsPreparationFailure(stderrOutput, dataDir)) {
16222
+ hostIdentityDiagnostic(executable, dataDir, options.launch, options.onSpawn).then((detail) => {
16223
+ const resolvedDiagnostic = detail ?? probeDiagnostic;
16224
+ reject(new IrohIdentityProbeError(/private (?:Windows )?data directory is not secure|private path ACL grants/iu.test(resolvedDiagnostic) ? "iroh_data_directory_insecure" : "iroh_identity_invalid", resolvedDiagnostic));
16225
+ }, () => {
16226
+ reject(new IrohIdentityProbeError("iroh_identity_invalid", probeDiagnostic));
16227
+ });
16228
+ return;
16229
+ }
16033
16230
  if (code !== 0 && probeDiagnostic !== void 0 && /private (?:Windows )?data directory is not secure|private path ACL grants/iu.test(probeDiagnostic)) {
16034
16231
  reject(new IrohIdentityProbeError("iroh_data_directory_insecure", probeDiagnostic));
16035
16232
  return;
@@ -16939,23 +17136,36 @@ function remoteSuffix(origin) {
16939
17136
  return "地址格式无效";
16940
17137
  }
16941
17138
  }
17139
+ /**
17140
+ * Read-only PowerShell that reports whether both managed LAN rules are usable.
17141
+ *
17142
+ * The verdict mirrors `windowsFirewallSetupScript`'s post-condition: enabled,
17143
+ * inbound, allow, and `Profile` exactly `Any`. A Domain/Private-scoped rule is
17144
+ * active only on those networks, so reporting it as ready would tell a user on
17145
+ * a Public network that nothing needs fixing. The script only reads: it never
17146
+ * enables, creates or deletes a rule, and the caller keeps its 3s budget.
17147
+ * @param port - managed LAN listener port the rules must cover.
17148
+ */
17149
+ function windowsFirewallProbeScript(port) {
17150
+ return [
17151
+ "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
17152
+ "$ready = $true",
17153
+ "$specs | ForEach-Object {",
17154
+ " $spec = $_",
17155
+ " $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' -and $_.Profile -eq 'Any' } | Select-Object -First 1",
17156
+ " if ($null -eq $rule) { $ready = $false; return }",
17157
+ " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
17158
+ ` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
17159
+ " if ($matching.Count -eq 0) { $ready = $false }",
17160
+ "}",
17161
+ "if ($ready) { 'ready' } else { 'missing' }"
17162
+ ].join("; ");
17163
+ }
16942
17164
  function defaultFirewallProbe(platform = process.platform) {
16943
17165
  return async (port) => {
16944
17166
  if (platform !== "win32") return { state: "not-applicable" };
16945
17167
  if (port === void 0) return { state: "unknown" };
16946
- const script = [
16947
- "$specs = @(@{ Name = 'DSH Mobile HTTPS'; Protocol = 'TCP' }, @{ Name = 'DSH Mobile Discovery'; Protocol = 'UDP' })",
16948
- "$ready = $true",
16949
- "$specs | ForEach-Object {",
16950
- " $spec = $_",
16951
- " $rule = Get-NetFirewallRule -DisplayName $spec.Name -ErrorAction SilentlyContinue | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' } | Select-Object -First 1",
16952
- " if ($null -eq $rule) { $ready = $false; return }",
16953
- " $filters = @($rule | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue)",
16954
- ` $matching = @($filters | Where-Object { $_.Protocol -eq $spec.Protocol -and ($_.LocalPort -eq 'Any' -or $_.LocalPort -eq '${String(port)}') })`,
16955
- " if ($matching.Count -eq 0) { $ready = $false }",
16956
- "}",
16957
- "if ($ready) { 'ready' } else { 'missing' }"
16958
- ].join("; ");
17168
+ const script = windowsFirewallProbeScript(port);
16959
17169
  try {
16960
17170
  return { state: (await execFileText("powershell.exe", [
16961
17171
  "-NoProfile",
@@ -22109,7 +22319,8 @@ function validSource(value) {
22109
22319
  "lifetime",
22110
22320
  "turn",
22111
22321
  "callId",
22112
- "goal"
22322
+ "goal",
22323
+ "custom"
22113
22324
  ])) return false;
22114
22325
  return [
22115
22326
  value.sourceId,
@@ -22126,7 +22337,7 @@ function validSource(value) {
22126
22337
  "live",
22127
22338
  "projected",
22128
22339
  "committed"
22129
- ].includes(String(value.lifetime)) && (value.turn === void 0 || Number.isSafeInteger(value.turn)) && (value.callId === void 0 || typeof value.callId === "string") && (value.goal === void 0 || object(value.goal) && only(value.goal, ["id", "revision"]) && typeof value.goal.id === "string" && Number.isSafeInteger(value.goal.revision));
22340
+ ].includes(String(value.lifetime)) && (value.turn === void 0 || Number.isSafeInteger(value.turn)) && (value.callId === void 0 || typeof value.callId === "string") && (value.goal === void 0 || object(value.goal) && only(value.goal, ["id", "revision"]) && typeof value.goal.id === "string" && Number.isSafeInteger(value.goal.revision)) && (value.custom === void 0 || object(value.custom) && only(value.custom, ["title", "body"]) && typeof value.custom.title === "string" && value.custom.title.length > 0 && value.custom.title.length <= 80 && !/[\u0000-\u001f\u007f]/u.test(value.custom.title) && typeof value.custom.body === "string" && value.custom.body.length > 0 && value.custom.body.length <= 200 && !/[\u0000-\u001f\u007f]/u.test(value.custom.body));
22130
22341
  }
22131
22342
  function scopedCounts(rows, scope, maximum = 1024) {
22132
22343
  const counts = /* @__PURE__ */ new Map();
@@ -22742,10 +22953,11 @@ function validateNavigate(navigate, category) {
22742
22953
  if (typeof navigate !== "string" || !/^\/\?mobile-push=[A-Za-z0-9_-]{1,256}$/u.test(navigate)) throw new TypeError("push_navigation_invalid");
22743
22954
  }
22744
22955
  /** Build Apple's declarative Web Push schema with generic, locale-owned text. */
22745
- function buildDeclarativePayload(category, locale, navigate, tag) {
22956
+ function buildDeclarativePayload(category, locale, navigate, tag, custom) {
22746
22957
  validateNavigate(navigate, category);
22747
22958
  if (!(locale in messages) || !(category in messages[locale]) || typeof tag !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/u.test(tag)) throw new TypeError("push_payload_invalid");
22748
- const [title, body] = messages[locale][category];
22959
+ if (custom !== void 0 && (typeof custom.title !== "string" || custom.title.length === 0 || custom.title.length > 80 || typeof custom.body !== "string" || custom.body.length === 0 || custom.body.length > 200)) throw new TypeError("push_payload_invalid");
22960
+ const [title, body] = custom === void 0 ? messages[locale][category] : [custom.title, custom.body];
22749
22961
  const payload = JSON.stringify({
22750
22962
  web_push: 8030,
22751
22963
  notification: {
@@ -23085,6 +23297,12 @@ function sameKey(a, b) {
23085
23297
  function isPreferenceEnabled(preferences, category) {
23086
23298
  return preferences[category] === true;
23087
23299
  }
23300
+ function notificationText(value, maximum, label) {
23301
+ if (typeof value !== "string") throw new TypeError(`push_${label}_invalid`);
23302
+ const text = value.trim().replace(/[\u0000-\u001f\u007f]+/gu, " ");
23303
+ if (text.length === 0 || text.length > maximum) throw new TypeError(`push_${label}_invalid`);
23304
+ return text;
23305
+ }
23088
23306
  function pendingHas(snapshot, intent) {
23089
23307
  return snapshot.pending.some((item) => item.id === intent.id && sameKey(item.key, intent.key));
23090
23308
  }
@@ -23198,6 +23416,51 @@ var PushDelivery = class {
23198
23416
  this.#kick();
23199
23417
  });
23200
23418
  }
23419
+ /**
23420
+ * Queue trusted Host-plugin text for every enabled web-push recipient that
23421
+ * opted into the selected notification category. The source keeps its own
23422
+ * short expiry; a restart may still retry it only inside that window.
23423
+ */
23424
+ async notifyCustom(input) {
23425
+ if (!this.#started || this.#closed || !this.#admitting) throw new Error("push_unavailable");
23426
+ const sessionId = typeof input.sessionId === "string" ? input.sessionId.trim() : "";
23427
+ if (sessionId === "" || sessionId.length > 4096) throw new TypeError("push_session_id_invalid");
23428
+ const title = notificationText(input.title, 80, "title");
23429
+ const body = notificationText(input.body, 200, "body");
23430
+ const category = input.category ?? "response";
23431
+ if (![
23432
+ "question",
23433
+ "plan",
23434
+ "approval",
23435
+ "response",
23436
+ "goal",
23437
+ "failure"
23438
+ ].includes(category)) throw new TypeError("push_category_invalid");
23439
+ const now = this.#now();
23440
+ const maximumExpiry = now + OUTCOME_TTL_MS;
23441
+ if (input.expiresAt !== void 0 && (!Number.isSafeInteger(input.expiresAt) || input.expiresAt <= now)) throw new TypeError("push_expiry_invalid");
23442
+ const expiresAt = Math.min(input.expiresAt ?? maximumExpiry, maximumExpiry);
23443
+ if (!await this.#host.sessionExists(sessionId)) throw new Error("push_session_unavailable");
23444
+ const source = {
23445
+ sourceId: `custom:${randomUUID()}`,
23446
+ sessionId,
23447
+ targetSessionId: sessionId,
23448
+ category,
23449
+ createdAt: now,
23450
+ expiresAt,
23451
+ lifetime: "committed",
23452
+ custom: {
23453
+ title,
23454
+ body
23455
+ }
23456
+ };
23457
+ const queued = await this.#admit(source);
23458
+ this.#kick();
23459
+ return {
23460
+ queued,
23461
+ expiresAt
23462
+ };
23463
+ }
23201
23464
  async resolve(owner, reference) {
23202
23465
  if (this.#closed || !reference) return void 0;
23203
23466
  let snapshot;
@@ -23237,14 +23500,15 @@ var PushDelivery = class {
23237
23500
  this.#origins.clear();
23238
23501
  }
23239
23502
  async #admit(source) {
23240
- if (!this.#admitting || source.expiresAt <= this.#now()) return;
23503
+ if (!this.#admitting || source.expiresAt <= this.#now()) return 0;
23241
23504
  let eligible = false;
23242
23505
  try {
23243
- eligible = this.#host.available && await this.#host.eligible(source);
23506
+ eligible = source.custom === void 0 ? this.#host.available && await this.#host.eligible(source) : this.#host.available && await this.#host.sessionExists(source.sessionId);
23244
23507
  } catch {
23245
23508
  eligible = false;
23246
23509
  }
23247
- if (!eligible || source.expiresAt <= this.#now()) return;
23510
+ if (!eligible || source.expiresAt <= this.#now()) return 0;
23511
+ let queued = 0;
23248
23512
  for (const origin of this.#origins.values()) {
23249
23513
  if (!origin.active || this.#closed) continue;
23250
23514
  const devices = await this.#readAuthorized(origin);
@@ -23253,7 +23517,7 @@ var PushDelivery = class {
23253
23517
  try {
23254
23518
  snapshot = this.#registry.snapshot();
23255
23519
  } catch {
23256
- return;
23520
+ return queued;
23257
23521
  }
23258
23522
  const registrations = snapshot.registrations.filter((reg) => reg.scopeId === origin.scopeId && reg.origin === origin.origin && devices.has(reg.deviceId) && reg.enabled && reg.health === "ready" && isPreferenceEnabled(reg.preferences, source.category) && snapshot.keys?.fingerprint === reg.keyFingerprint);
23259
23523
  if (registrations.length === 0) continue;
@@ -23282,6 +23546,7 @@ var PushDelivery = class {
23282
23546
  attempts: 0,
23283
23547
  nextAttemptAt: this.#now()
23284
23548
  });
23549
+ queued += 1;
23285
23550
  }
23286
23551
  return {
23287
23552
  ...clean,
@@ -23290,6 +23555,7 @@ var PushDelivery = class {
23290
23555
  });
23291
23556
  }
23292
23557
  this.#kick();
23558
+ return queued;
23293
23559
  }
23294
23560
  async #reconcileKnownScopes() {
23295
23561
  let snapshot;
@@ -23474,7 +23740,7 @@ var PushDelivery = class {
23474
23740
  await this.#removeIntent(intent);
23475
23741
  return;
23476
23742
  }
23477
- const payload = buildDeclarativePayload(intent.source.category, registration.locale, `/?mobile-push=${reference}`, intent.source.sourceId.replace(/[^A-Za-z0-9._:-]/gu, "_").slice(0, 128) || "push");
23743
+ const payload = buildDeclarativePayload(intent.source.category, registration.locale, `/?mobile-push=${reference}`, intent.source.sourceId.replace(/[^A-Za-z0-9._:-]/gu, "_").slice(0, 128) || "push", intent.source.custom);
23478
23744
  let result;
23479
23745
  try {
23480
23746
  result = await this.#sender.send({
@@ -23584,6 +23850,11 @@ var PushDelivery = class {
23584
23850
  /** Maps the Host tri-state for durable-work decisions; boolean eligible() would collapse transient uncertainty into denial. */
23585
23851
  async #sourceGuard(source) {
23586
23852
  if (!this.#host.available) return "unverifiable";
23853
+ if (source.custom !== void 0) try {
23854
+ return await this.#host.sessionExists(source.sessionId) ? "allowed" : "denied";
23855
+ } catch {
23856
+ return "unverifiable";
23857
+ }
23587
23858
  let result;
23588
23859
  try {
23589
23860
  result = await this.#host.reconcile(source);
@@ -23937,6 +24208,10 @@ var PushGatewayCoordinator = class {
23937
24208
  installationId
23938
24209
  });
23939
24210
  }
24211
+ async notifyCustom(input) {
24212
+ if (!this.host.available) throw new Error("push_unavailable");
24213
+ return await this.delivery.notifyCustom(input);
24214
+ }
23940
24215
  async resolve(owner, reference) {
23941
24216
  if (!this.host.available || !await this.authorize(owner, true)) return void 0;
23942
24217
  return this.delivery.resolve(owner, reference);
@@ -24059,16 +24334,71 @@ const MAX_MANAGED_PORT = 65535;
24059
24334
  function assertManagedPort(port, name) {
24060
24335
  if (!Number.isSafeInteger(port) || port < MIN_MANAGED_PORT || port > MAX_MANAGED_PORT) throw new Error(`${name} must be from ${String(MIN_MANAGED_PORT)} through ${String(MAX_MANAGED_PORT)}`);
24061
24336
  }
24062
- function powerShellFailureDetail(error) {
24337
+ /** Longest failure detail carried in the thrown message. */
24338
+ const MAX_FAILURE_DETAIL_CHARS = 512;
24339
+ /**
24340
+ * Strip the elevated invocation and filesystem locations from PowerShell text.
24341
+ *
24342
+ * The launcher's stderr quotes the command it ran, including the base64
24343
+ * `-EncodedCommand` payload, and the elevated script quotes its own scratch
24344
+ * path. Neither helps an operator, and both can push the one useful line past
24345
+ * the length bound, so they are replaced before the detail is bounded.
24346
+ */
24347
+ function redactPowerShellDetail(value) {
24348
+ return value.replace(/EncodedCommand\s+\S+/giu, "EncodedCommand <redacted>").replace(/\b[A-Za-z0-9+/]{64,}={0,2}\b/gu, "<redacted>").replace(/(?<![A-Za-z])[A-Za-z]:[\\/][^\s'"]*/gu, "<path>");
24349
+ }
24350
+ /** Bounded, redacted detail for a failed elevated Windows operation. */
24351
+ function powerShellFailureDetail(error, innerError = "") {
24063
24352
  if (!(error instanceof Error)) return "unknown failure";
24064
- const code = "code" in error && typeof error.code === "string" ? ` (${error.code})` : "";
24065
- const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim().replace(/\s+/gu, " ").slice(0, 512) : "";
24066
- return `${code}${stderr === "" ? "" : `: ${stderr}`}`;
24353
+ const code = "code" in error && (typeof error.code === "string" || typeof error.code === "number") ? ` (${String(error.code)})` : "";
24354
+ const detail = redactPowerShellDetail(innerError.trim() === "" && "stderr" in error && typeof error.stderr === "string" ? error.stderr : innerError).trim().replace(/\s+/gu, " ").slice(0, MAX_FAILURE_DETAIL_CHARS);
24355
+ return `${code}${detail === "" ? "" : `: ${detail}`}`;
24356
+ }
24357
+ /** Default sink for a scratch directory that could not be removed. */
24358
+ function warnFirewallCleanupFailure(detail) {
24359
+ process.emitWarning(`Windows firewall temporary cleanup failed (${detail})`, { code: "DSH_MOBILE_FIREWALL_CLEANUP_FAILED" });
24360
+ }
24361
+ /**
24362
+ * Remove the elevated helper's scratch directory without touching the run's outcome.
24363
+ *
24364
+ * The directory is created before elevation and removed in a `finally`, so a
24365
+ * cleanup failure must not replace the firewall error that explains the
24366
+ * failure, and must not fail a run that already succeeded. It stays observable
24367
+ * instead: the removal error's code is reported to the warning sink, whose own
24368
+ * failure is ignored for the same reason.
24369
+ * @param directory - scratch path created for this run.
24370
+ * @param options - injectable removal and warning sink for tests.
24371
+ */
24372
+ async function discardFirewallScratch(directory, options = {}) {
24373
+ const remove = options.remove ?? ((path) => rm(path, {
24374
+ recursive: true,
24375
+ force: true
24376
+ }));
24377
+ try {
24378
+ await remove(directory);
24379
+ } catch (error) {
24380
+ const code = error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : "unknown";
24381
+ const warn = options.warn ?? warnFirewallCleanupFailure;
24382
+ try {
24383
+ warn(code);
24384
+ } catch {}
24385
+ }
24067
24386
  }
24068
24387
  async function runElevatedPowerShell(script) {
24388
+ const temporary = await mkdtemp(join(tmpdir(), "dsh-mobile-firewall-"));
24389
+ const errorFile = join(temporary, "error.log");
24390
+ const wrapped = [
24391
+ `$__dshMobileErrorFile = '${errorFile.replaceAll("'", "''")}'`,
24392
+ "try {",
24393
+ script,
24394
+ "} catch {",
24395
+ " try { [IO.File]::WriteAllText($__dshMobileErrorFile, ($_ | Out-String)) } catch {}",
24396
+ " throw",
24397
+ "}"
24398
+ ].join("\n");
24069
24399
  const launch = [
24070
24400
  "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
24071
- ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
24401
+ ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(wrapped, "utf16le").toString("base64")}')`,
24072
24402
  "; exit $process.ExitCode"
24073
24403
  ].join(" ");
24074
24404
  try {
@@ -24079,7 +24409,13 @@ async function runElevatedPowerShell(script) {
24079
24409
  launch
24080
24410
  ], { windowsHide: true });
24081
24411
  } catch (error) {
24082
- throw new Error(`Windows firewall operation failed${powerShellFailureDetail(error)}`, { cause: error });
24412
+ let innerError = "";
24413
+ try {
24414
+ innerError = (await readFile(errorFile, "utf8")).replace(/^\uFEFF/u, "");
24415
+ } catch {}
24416
+ throw new Error(`Windows firewall operation failed${powerShellFailureDetail(error, innerError)}`, { cause: error });
24417
+ } finally {
24418
+ await discardFirewallScratch(temporary);
24083
24419
  }
24084
24420
  }
24085
24421
  function firewallRuleScript(name, protocol, port) {
@@ -24094,7 +24430,7 @@ function firewallRuleScript(name, protocol, port) {
24094
24430
  " $rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter -RemoteAddress LocalSubnet",
24095
24431
  " }",
24096
24432
  "}",
24097
- `$verified = @(Get-NetFirewallRule -DisplayName '${name}' -Enabled True -Direction Inbound -Action Allow -Profile Any -ErrorAction Stop)`,
24433
+ `$verified = @(Get-NetFirewallRule -DisplayName '${name}' -ErrorAction Stop | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' -and $_.Profile -eq 'Any' })`,
24098
24434
  `$matchingPort = @($verified | Get-NetFirewallPortFilter | Where-Object { $_.Protocol -eq '${protocol}' -and $_.LocalPort -contains '${String(port)}' })`,
24099
24435
  `$matchingAddress = @($verified | Get-NetFirewallAddressFilter | Where-Object { $_.RemoteAddress -contains 'LocalSubnet' })`,
24100
24436
  `if ($verified.Count -eq 0 -or $matchingPort.Count -eq 0 -or $matchingAddress.Count -eq 0) { throw "DSH Mobile firewall rule '${name}' is not active with the expected scope" }`
@@ -24590,6 +24926,7 @@ async function apply(ctx, config) {
24590
24926
  if (service === void 0) throw new Error("push_unavailable");
24591
24927
  return service;
24592
24928
  };
24929
+ new MobilePushService(ctx, async (input) => (await requiredPushGatewayService()).notifyCustom(input));
24593
24930
  const queueDeviceInvalidations = (scopeId, deviceIds) => {
24594
24931
  let queued = pendingDeviceInvalidations.get(scopeId);
24595
24932
  if (queued === void 0) pendingDeviceInvalidations.set(scopeId, queued = /* @__PURE__ */ new Set());
@@ -24664,6 +25001,11 @@ async function apply(ctx, config) {
24664
25001
  async test(owner, installationId) {
24665
25002
  await (await requiredPushGatewayService()).test(owner, installationId);
24666
25003
  },
25004
+ async notifyCustom(input) {
25005
+ const service = await requiredPushGatewayService();
25006
+ if (service.notifyCustom === void 0) throw new Error("push_unavailable");
25007
+ return await service.notifyCustom(input);
25008
+ },
24667
25009
  async resolve(owner, reference) {
24668
25010
  return (await requiredPushGatewayService()).resolve(owner, reference);
24669
25011
  }
@@ -25999,6 +26341,6 @@ async function apply(ctx, config) {
25999
26341
  };
26000
26342
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
26001
26343
  }
26002
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CPOLAR_COMPONENT_RELEASE, CPOLAR_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, CpolarComponentManager, DEFAULT_IROH_LOCAL_PORT, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, IROH_COMPONENT_RELEASES, IROH_LEAF_RENEW_BEFORE_DAYS, IROH_LEAF_VALIDITY_DAYS, IROH_VERSION, IrohComponentManager, IrohController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_PAIRING_DESCRIPTOR_BYTES, MAX_PAIRING_LIFETIME_MS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, PAIRING_DESCRIPTOR_LOCAL_PORT, PAIRING_DESCRIPTOR_TYPE, PAIRING_DESCRIPTOR_VERSION, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, decodePairingDescriptor, encodePairingDescriptor, ensureFrpIngressCertificate, ensureIrohTls, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, irohGatewayConfig, irohHostname, irohOrigin, irohTlsPaths, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateIrohOrigin, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validatePairingDescriptor, validateWebSocketPath, watchTaskCompletions };
26344
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CPOLAR_COMPONENT_RELEASE, CPOLAR_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, CpolarComponentManager, DEFAULT_IROH_LOCAL_PORT, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, IROH_COMPONENT_RELEASES, IROH_LEAF_RENEW_BEFORE_DAYS, IROH_LEAF_VALIDITY_DAYS, IROH_VERSION, IrohComponentManager, IrohController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_PAIRING_DESCRIPTOR_BYTES, MAX_PAIRING_LIFETIME_MS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, MobilePushService, OriginConfigStore, OriginController, PAIRING_DESCRIPTOR_LOCAL_PORT, PAIRING_DESCRIPTOR_TYPE, PAIRING_DESCRIPTOR_VERSION, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, decodePairingDescriptor, encodePairingDescriptor, ensureFrpIngressCertificate, ensureIrohTls, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, irohGatewayConfig, irohHostname, irohOrigin, irohTlsPaths, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateIrohOrigin, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validatePairingDescriptor, validateWebSocketPath, watchTaskCompletions };
26003
26345
 
26004
26346
  //# sourceMappingURL=index.mjs.map