opentray 0.17.0 → 0.19.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/dist/index.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { PROTOCOL_VERSION, PROTOCOL_VERSION as PROTOCOL_VERSION$1, brokerArtifactIdentityEquals, createBrokerEndpointIdentity, createBrokerEndpointIdentity as createBrokerEndpointIdentity$1, formatBrokerEndpointName, formatBrokerStateRoot, formatUnixSocketPath, formatUnixSocketPath as formatUnixSocketPath$1, formatWindowsPipeName, formatWindowsPipeName as formatWindowsPipeName$1, isBrokerArtifactIdentity, isSupportedProtocolVersion, parseServerFrame, sanitizeCallerLabel } from "@opentray/spec";
3
- import { access, mkdir, open, readFile, realpath, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { access, appendFile, link, mkdir, open, readFile, realpath, rm, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import { createConnection } from "node:net";
6
6
  import { homedir } from "node:os";
7
7
  import { fileURLToPath } from "node:url";
8
- import { clearDarwinAppBundleOwner, ensureDarwinAppBundle, resolveDefaultDarwinAppBundlePath, resolveOpenTrayPackageIdentity, writeDarwinAppBundleOwner } from "@opentray/packaging";
8
+ import { clearDarwinAppBundleOwner, convergeDarwinAppBundleIdentity, ensureDarwinAppBundle, resolveDefaultDarwinAppBundlePath, resolveOpenTrayPackageIdentity, updateDarwinAppLaunchDescriptor, writeDarwinAppBundleOwner } from "@opentray/packaging";
9
+ import { createHash, randomUUID } from "node:crypto";
9
10
  import { constants } from "node:fs";
10
11
  import { spawn } from "node:child_process";
11
- import { createHash } from "node:crypto";
12
12
  //#region src/app-icon.ts
13
13
  const DEFAULT_APP_ICON_VARIANT = "default";
14
14
  const APP_ICON_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
@@ -221,7 +221,7 @@ const matchesFormat = (bytes, format) => {
221
221
  const hasPrefix = (bytes, prefix) => bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte);
222
222
  const assertExactKeys = (record, expected, platform, optional = []) => {
223
223
  const actual = Object.keys(record);
224
- const allowed = new Set([...expected, ...optional]);
224
+ const allowed = /* @__PURE__ */ new Set([...expected, ...optional]);
225
225
  const unexpected = actual.find((key) => !allowed.has(key));
226
226
  if (unexpected !== void 0) throw new InvalidAppIconError(`unexpected asset field ${unexpected}`, platform);
227
227
  const missing = expected.find((key) => !(key in record));
@@ -272,8 +272,9 @@ const resolveNativeExtensionArtifact = async (artifact, platform = process.platf
272
272
  });
273
273
  }
274
274
  const manifest = await readPlatformPackageManifest(platformPackageJsonPath, target, packageTarget.packageName);
275
+ const libraryPath = join(dirname(platformPackageJsonPath), packageTarget.libraryPath);
275
276
  return {
276
- path: await resolveAccessibleLibrary(join(dirname(platformPackageJsonPath), packageTarget.libraryPath), target, packageTarget.packageName),
277
+ path: await resolveAccessibleLibrary(libraryPath, target, packageTarget.packageName),
277
278
  packageName: packageTarget.packageName,
278
279
  packageVersion: manifest.version,
279
280
  expectedIdentity,
@@ -377,12 +378,13 @@ function createClient(transport, options = {}) {
377
378
  return { async createTray(options) {
378
379
  const { app } = await ensureApp(options);
379
380
  const requestId = nextRequestId();
380
- const frame = expectResponse(await transport.request({
381
+ const response = await transport.request({
381
382
  type: "create-tray",
382
383
  requestId,
383
384
  app,
384
385
  tray: options
385
- }), requestId, "tray-created");
386
+ });
387
+ const frame = expectResponse(response, requestId, "tray-created");
386
388
  return createTrayHandle(transport, app.appId, frame.trayId ?? options.id, nextRequestId);
387
389
  } };
388
390
  }
@@ -398,47 +400,51 @@ function createTrayHandle(transport, appId, trayId, nextRequestId = createReques
398
400
  app: createAppHandle(transport, appId, nextRequestId),
399
401
  async getBounds() {
400
402
  const requestId = nextRequestId();
401
- return expectResponse(await transport.request({
403
+ const response = await transport.request({
402
404
  type: "get-tray-bounds",
403
405
  requestId,
404
406
  appId,
405
407
  trayId
406
- }), requestId, "tray-bounds").bounds;
408
+ });
409
+ return expectResponse(response, requestId, "tray-bounds").bounds;
407
410
  },
408
411
  async setMenu(menu) {
409
412
  const requestId = nextRequestId();
410
- expectResponse(await transport.request({
413
+ const response = await transport.request({
411
414
  type: "set-tray-menu",
412
415
  requestId,
413
416
  appId,
414
417
  trayId,
415
418
  menu
416
- }), requestId, "ack");
419
+ });
420
+ expectResponse(response, requestId, "ack");
417
421
  },
418
422
  async setTooltip(tooltip) {
419
423
  const requestId = nextRequestId();
420
- expectResponse(await transport.request({
424
+ const response = await transport.request({
421
425
  type: "set-tray-tooltip",
422
426
  requestId,
423
427
  appId,
424
428
  trayId,
425
429
  tooltip
426
- }), requestId, "ack");
430
+ });
431
+ expectResponse(response, requestId, "ack");
427
432
  },
428
433
  async setIcon(icon) {
429
434
  const requestId = nextRequestId();
430
- expectResponse(await transport.request({
435
+ const response = await transport.request({
431
436
  type: "set-tray-icon",
432
437
  requestId,
433
438
  appId,
434
439
  trayId,
435
440
  icon
436
- }), requestId, "ack");
441
+ });
442
+ expectResponse(response, requestId, "ack");
437
443
  },
438
444
  async loadExtension(options) {
439
445
  const requestId = nextRequestId();
440
446
  const resolved = await resolveNativeExtensionArtifact(options.artifact);
441
- expectResponse(await transport.request({
447
+ const response = await transport.request({
442
448
  type: "load-ext",
443
449
  requestId,
444
450
  appId,
@@ -446,7 +452,8 @@ function createTrayHandle(transport, appId, trayId, nextRequestId = createReques
446
452
  path: resolved.path,
447
453
  expectedIdentity: resolved.expectedIdentity,
448
454
  ...options.mountId === void 0 ? {} : { mountId: options.mountId }
449
- }), requestId, "ack");
455
+ });
456
+ expectResponse(response, requestId, "ack");
450
457
  },
451
458
  async commandExtension(ext, data) {
452
459
  await this.requestExtension(ext, data);
@@ -474,12 +481,13 @@ function createTrayHandle(transport, appId, trayId, nextRequestId = createReques
474
481
  },
475
482
  async destroy() {
476
483
  const requestId = nextRequestId();
477
- expectResponse(await transport.request({
484
+ const response = await transport.request({
478
485
  type: "destroy-tray",
479
486
  requestId,
480
487
  appId,
481
488
  trayId
482
- }), requestId, "ack");
489
+ });
490
+ expectResponse(response, requestId, "ack");
483
491
  }
484
492
  };
485
493
  if (!isOpenTrayEventSource(transport)) return handle;
@@ -517,6 +525,11 @@ const attachEventfulTrayHandle = (handle, source, appId, nextMountId) => {
517
525
  onTrayDoubleClick(handler) {
518
526
  return listen("trayDoubleClick", handler);
519
527
  },
528
+ onAppReopenRequested(handler) {
529
+ return source.onEvent((frame) => {
530
+ if (frame.type === "app-event" && frame.event.type === "reopenRequested" && frame.event.appId === appId) handler(frame.event);
531
+ });
532
+ },
520
533
  extend(extension, options) {
521
534
  const context = createTrayExtensionContext(eventful, appId, extension, options, nextMountId);
522
535
  const capability = extension.extend(eventful, context, options);
@@ -529,19 +542,21 @@ const isOwnedTrayEvent = (event, appId, trayId) => event.type !== "ready" && eve
529
542
  const isOpenTrayEventSource = (transport) => "onEvent" in transport && typeof transport.onEvent === "function";
530
543
  const resolveDefaultAppRef = async (transport, nextRequestId) => {
531
544
  const requestId = nextRequestId();
532
- return expectResponse(await transport.request({
545
+ const response = await transport.request({
533
546
  type: "resolve-default-app",
534
547
  requestId
535
- }), requestId, "default-app").app;
548
+ });
549
+ return expectResponse(response, requestId, "default-app").app;
536
550
  };
537
551
  const createAppHandle = (transport, appId, nextRequestId) => {
538
552
  const requestIdentity = async () => {
539
553
  const requestId = nextRequestId();
540
- return expectResponse(await transport.request({
554
+ const response = await transport.request({
541
555
  type: "get-app-identity",
542
556
  requestId,
543
557
  appId
544
- }), requestId, "app-identity").identity;
558
+ });
559
+ return expectResponse(response, requestId, "app-identity").identity;
545
560
  };
546
561
  return {
547
562
  appId,
@@ -550,12 +565,13 @@ const createAppHandle = (transport, appId, nextRequestId) => {
550
565
  },
551
566
  async setName(name) {
552
567
  const requestId = nextRequestId();
553
- expectResponse(await transport.request({
568
+ const response = await transport.request({
554
569
  type: "set-app-name",
555
570
  requestId,
556
571
  appId,
557
572
  name
558
- }), requestId, "ack");
573
+ });
574
+ expectResponse(response, requestId, "ack");
559
575
  return (await requestIdentity()).appName;
560
576
  },
561
577
  async getAppIcon() {
@@ -570,23 +586,25 @@ const createAppHandle = (transport, appId, nextRequestId) => {
570
586
  if (identity.appIcon === void 0) selectAppIconVariant([], iconOrVariant);
571
587
  else selectAppIconVariant(identity.appIcon, iconOrVariant);
572
588
  const requestId = nextRequestId();
573
- expectResponse(await transport.request({
589
+ const response = await transport.request({
574
590
  type: "set-app-icon-variant",
575
591
  requestId,
576
592
  appId,
577
593
  variant: iconOrVariant
578
- }), requestId, "ack");
594
+ });
595
+ expectResponse(response, requestId, "ack");
579
596
  return;
580
597
  }
581
598
  if (iconOrVariant !== null) await validateAppIcon(iconOrVariant);
582
599
  const appIcon = iconOrVariant === null ? null : normalizeAppIcon(iconOrVariant);
583
600
  const requestId = nextRequestId();
584
- expectResponse(await transport.request({
601
+ const response = await transport.request({
585
602
  type: "set-app-icon",
586
603
  requestId,
587
604
  appId,
588
605
  appIcon
589
- }), requestId, "ack");
606
+ });
607
+ expectResponse(response, requestId, "ack");
590
608
  }
591
609
  };
592
610
  };
@@ -655,6 +673,7 @@ const requestIdOf = (frame) => {
655
673
  case "error": return frame.requestId;
656
674
  case "ready":
657
675
  case "event":
676
+ case "app-event":
658
677
  case "ext-event": return;
659
678
  }
660
679
  };
@@ -768,14 +787,16 @@ const resolveBrokerCommand = async (paths, options = {}) => {
768
787
  if (platform !== "darwin") return commandForBinary(installed.binary, paths);
769
788
  if (options.appBundle === void 0) return commandForBinary(installed.binary, paths);
770
789
  if (installed.carrierTemplate === void 0) throw missingDarwinCarrierError(target, installed, arch);
771
- return commandForBinary(await ensureDarwinBundle(options, paths, installed.binary, installed.carrierTemplate), paths);
790
+ const binary = await ensureDarwinBundle(options, paths, installed.binary, installed.carrierTemplate);
791
+ return commandForBinary(binary, paths);
772
792
  }
773
793
  const sourceDir = options.sourceDir ?? dirname(fileURLToPath(sourceUrl));
774
794
  const workspaceRoot = await (options.findWorkspaceRoot ?? findWorkspaceRoot)(sourceDir);
775
795
  if (workspaceRoot !== void 0) {
776
796
  const binary = await (options.ensureDevBrokerBinary ?? ensureDevBrokerBinary)(workspaceRoot, paths, platform);
777
797
  if (platform !== "darwin") return commandForBinary(binary, paths);
778
- return commandForBinary(options.appBundle === void 0 ? binary : await ensureDarwinBundle(options, paths, binary, await (options.ensureDevDarwinCarrierTemplate ?? ensureDevDarwinCarrierTemplate)(workspaceRoot, paths)), paths);
798
+ const carrierBinary = options.appBundle === void 0 ? binary : await ensureDarwinBundle(options, paths, binary, await (options.ensureDevDarwinCarrierTemplate ?? ensureDevDarwinCarrierTemplate)(workspaceRoot, paths));
799
+ return commandForBinary(carrierBinary, paths);
779
800
  }
780
801
  throw new MissingPlatformBrokerBinaryError(installed.binaryPath === void 0 ? `unable to resolve OpenTray broker binary for ${platform}/${arch}; install "${target.packageName}" for this platform or set OPENTRAY_BROKER_BIN` : `unable to resolve OpenTray broker binary for ${platform}/${arch}; package "${target.packageName}" was found but binary "${installed.binaryPath}" is not accessible; restage the runtime package or set OPENTRAY_BROKER_BIN`, platform, arch, {
781
802
  packageName: target.packageName,
@@ -928,7 +949,10 @@ function isNodeError$1(error) {
928
949
  //#endregion
929
950
  //#region src/daemon/lifecycle.ts
930
951
  const DAEMON_STDIO_ENV = "OPENTRAY_DAEMON_STDIO";
931
- const DEFAULT_DAEMON_LOCK_TIMEOUT_MS = 5e3;
952
+ const DAEMON_POLL_INTERVAL_MS = 50;
953
+ const DEFAULT_DAEMON_READY_TIMEOUT_MS = 1e4;
954
+ const DEFAULT_DAEMON_LOCK_TIMEOUT_MS = 15e3;
955
+ const ABANDONED_RECLAIM_CLAIM_MS = 1e3;
932
956
  const createNodeDaemonDriver = (cliEntrypoint, options = {}) => ({
933
957
  async resolveBroker(paths) {
934
958
  return resolveBrokerArtifact(paths, {
@@ -941,19 +965,71 @@ const createNodeDaemonDriver = (cliEntrypoint, options = {}) => ({
941
965
  return isProcessAlive(pid);
942
966
  },
943
967
  async spawnBroker(paths, broker) {
944
- const child = spawn(broker.command, broker.args, {
945
- cwd: broker.cwd,
946
- detached: true,
947
- env: {
948
- ...process.env,
949
- OPENTRAY_DAEMON_HOME: paths.homeDir,
950
- OPENTRAY_DAEMON_PACKAGE_VERSION: paths.packageVersion,
951
- OPENTRAY_DAEMON_CLI_ENTRYPOINT: cliEntrypoint,
952
- OPENTRAY_DAEMON_APP_ID: paths.appId,
953
- OPENTRAY_DAEMON_APP_NAME: paths.appName,
954
- OPENTRAY_DAEMON_CALLER_LABEL: paths.callerLabel
955
- },
956
- stdio: resolveBrokerStdio(process.env[DAEMON_STDIO_ENV])
968
+ const env = options.env ?? process.env;
969
+ const stdio = resolveBrokerStdio(env[DAEMON_STDIO_ENV]);
970
+ if (stdio === "log") await mkdir(paths.runtimeDir, { recursive: true });
971
+ const logHandle = stdio === "log" ? await open(paths.brokerLog, "a") : void 0;
972
+ if (logHandle !== void 0) await logHandle.appendFile(`${JSON.stringify({
973
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
974
+ event: "broker-spawn",
975
+ command: broker.command,
976
+ cwd: broker.cwd ?? process.cwd(),
977
+ appId: paths.appId,
978
+ appName: paths.appName
979
+ })}\n`, "utf8");
980
+ if (isAbsolute(broker.command)) try {
981
+ await access(broker.command, process.platform === "win32" ? constants.F_OK : constants.X_OK);
982
+ } catch (error) {
983
+ if (stdio === "log") await appendFile(paths.brokerLog, `${JSON.stringify({
984
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
985
+ event: "broker-spawn-error",
986
+ command: broker.command,
987
+ error: error instanceof Error ? error.message : String(error)
988
+ })}\n`, "utf8").catch(() => void 0);
989
+ await logHandle?.close();
990
+ return 0;
991
+ }
992
+ let child;
993
+ try {
994
+ child = spawn(broker.command, broker.args, {
995
+ cwd: broker.cwd,
996
+ detached: true,
997
+ env: {
998
+ ...env,
999
+ OPENTRAY_DAEMON_HOME: paths.homeDir,
1000
+ OPENTRAY_DAEMON_PACKAGE_VERSION: paths.packageVersion,
1001
+ OPENTRAY_DAEMON_CLI_ENTRYPOINT: cliEntrypoint,
1002
+ OPENTRAY_DAEMON_APP_ID: paths.appId,
1003
+ OPENTRAY_DAEMON_APP_NAME: paths.appName,
1004
+ OPENTRAY_DAEMON_CALLER_LABEL: paths.callerLabel
1005
+ },
1006
+ stdio: logHandle === void 0 ? stdio === "log" ? "ignore" : stdio : [
1007
+ "ignore",
1008
+ logHandle.fd,
1009
+ logHandle.fd
1010
+ ]
1011
+ });
1012
+ } finally {
1013
+ await logHandle?.close();
1014
+ }
1015
+ child.on("error", (error) => {
1016
+ if (stdio !== "log") return;
1017
+ appendFile(paths.brokerLog, `${JSON.stringify({
1018
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1019
+ event: "broker-spawn-error",
1020
+ command: broker.command,
1021
+ error: error.message
1022
+ })}\n`, "utf8").catch(() => void 0);
1023
+ });
1024
+ child.once("exit", (code, signal) => {
1025
+ if (stdio !== "log") return;
1026
+ appendFile(paths.brokerLog, `${JSON.stringify({
1027
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1028
+ event: "broker-exit",
1029
+ command: broker.command,
1030
+ code,
1031
+ signal
1032
+ })}\n`, "utf8").catch(() => void 0);
957
1033
  });
958
1034
  child.unref();
959
1035
  const pid = child.pid ?? 0;
@@ -966,11 +1042,13 @@ const createNodeDaemonDriver = (cliEntrypoint, options = {}) => ({
966
1042
  }
967
1043
  });
968
1044
  const resolveBrokerStdio = (value) => {
969
- return value === "inherit" ? "inherit" : "ignore";
1045
+ if (value === "inherit") return "inherit";
1046
+ if (value === "ignore") return "ignore";
1047
+ return "log";
970
1048
  };
971
- const startDaemon = async ({ paths, driver, lockTimeoutMs = DEFAULT_DAEMON_LOCK_TIMEOUT_MS }) => {
1049
+ const startDaemon = async ({ paths, driver, lockTimeoutMs = DEFAULT_DAEMON_LOCK_TIMEOUT_MS, readinessTimeoutMs = DEFAULT_DAEMON_READY_TIMEOUT_MS }) => {
972
1050
  await mkdir(paths.runtimeDir, { recursive: true });
973
- const lock = await acquireLock(paths.lockFile, lockTimeoutMs);
1051
+ const lock = await acquireDaemonLock(paths.lockFile, lockTimeoutMs);
974
1052
  try {
975
1053
  const existingPid = await readPid(paths.pidFile);
976
1054
  let existingAlive = existingPid !== void 0 && await driver.isAlive(existingPid);
@@ -986,7 +1064,8 @@ const startDaemon = async ({ paths, driver, lockTimeoutMs = DEFAULT_DAEMON_LOCK_
986
1064
  } else throw error;
987
1065
  }
988
1066
  if (existingPid !== void 0 && existingAlive) {
989
- if (readyMatchesBroker(await readReadyMetadata(paths.readyFile), paths, existingPid, broker)) return {
1067
+ const ready = await readReadyMetadata(paths.readyFile);
1068
+ if (readyMatchesBroker(ready, paths, existingPid, broker)) return {
990
1069
  status: "already-running",
991
1070
  pid: existingPid,
992
1071
  paths,
@@ -1000,7 +1079,7 @@ const startDaemon = async ({ paths, driver, lockTimeoutMs = DEFAULT_DAEMON_LOCK_
1000
1079
  if (!Number.isInteger(pid) || pid <= 0) throw new Error("daemon broker process did not expose a valid pid");
1001
1080
  await writeFile(paths.pidFile, `${pid}\n`, "utf8");
1002
1081
  try {
1003
- await waitForReadyFile(paths, driver, pid, broker);
1082
+ await waitForReadyFile(paths, driver, pid, broker, readinessTimeoutMs);
1004
1083
  } catch (error) {
1005
1084
  if (await driver.isAlive(pid)) {
1006
1085
  await driver.stop(pid);
@@ -1037,19 +1116,22 @@ const cleanupRuntimeFiles = async (paths) => {
1037
1116
  const waitUntilStopped = async (driver, pid) => {
1038
1117
  for (let attempt = 0; attempt < 20; attempt += 1) {
1039
1118
  if (!await driver.isAlive(pid)) return true;
1040
- await sleep(50);
1119
+ await sleep(DAEMON_POLL_INTERVAL_MS);
1041
1120
  }
1042
1121
  return false;
1043
1122
  };
1044
- const waitForReadyFile = async (paths, driver, pid, broker) => {
1045
- for (let attempt = 0; attempt < 40; attempt += 1) {
1123
+ const waitForReadyFile = async (paths, driver, pid, broker, timeoutMs) => {
1124
+ const deadline = Date.now() + timeoutMs;
1125
+ while (true) {
1046
1126
  const ready = await readReadyMetadata(paths.readyFile);
1047
1127
  if (readyMatchesBroker(ready, paths, pid, broker)) return;
1048
1128
  if (ready !== void 0) throw new Error(`daemon readiness artifact identity mismatch: expected=${JSON.stringify(broker.artifactIdentity)} actual=${JSON.stringify(ready.brokerArtifactIdentity)}`);
1049
- if (!await driver.isAlive(pid)) throw new Error(`daemon broker exited before readiness: pid=${pid}; readyFile=${paths.readyFile}; set ${DAEMON_STDIO_ENV}=inherit to inspect broker stderr`);
1050
- await sleep(50);
1129
+ if (!await driver.isAlive(pid)) throw new Error(`daemon broker exited before readiness: pid=${pid}; readyFile=${paths.readyFile}; brokerLog=${paths.brokerLog}; set ${DAEMON_STDIO_ENV}=inherit to mirror broker stderr`);
1130
+ const remainingMs = deadline - Date.now();
1131
+ if (remainingMs <= 0) break;
1132
+ await sleep(Math.min(DAEMON_POLL_INTERVAL_MS, remainingMs));
1051
1133
  }
1052
- throw new Error(`timed out waiting for daemon readiness: ${paths.readyFile}`);
1134
+ throw new Error(`timed out waiting for daemon readiness after ${timeoutMs}ms: pid=${pid}; readyFile=${paths.readyFile}; brokerLog=${paths.brokerLog}; set ${DAEMON_STDIO_ENV}=inherit to mirror broker stderr`);
1053
1135
  };
1054
1136
  const readReadyMetadata = async (readyFile) => {
1055
1137
  let value;
@@ -1080,19 +1162,25 @@ const executablePathsEqual = (left, right, platform = process.platform) => {
1080
1162
  const normalize = (value) => value.replaceAll("\\", "/").replace(/^\/\/?\?\//u, "").toLowerCase();
1081
1163
  return normalize(left) === normalize(right);
1082
1164
  };
1083
- const acquireLock = async (lockFile, timeoutMs) => {
1165
+ const acquireDaemonLock = async (lockFile, timeoutMs) => {
1084
1166
  const deadline = Date.now() + timeoutMs;
1167
+ const owner = `${JSON.stringify({
1168
+ pid: process.pid,
1169
+ token: randomUUID()
1170
+ })}\n`;
1085
1171
  while (Date.now() <= deadline) try {
1086
1172
  const handle = await open(lockFile, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY);
1087
- await handle.writeFile(`${process.pid}\n`, "utf8");
1173
+ await handle.writeFile(owner, "utf8");
1088
1174
  return { async release() {
1089
1175
  await handle.close();
1176
+ if (await readLockSource(lockFile) !== owner) return;
1090
1177
  await unlink(lockFile).catch((error) => {
1091
1178
  if (!isNodeError(error) || error.code !== "ENOENT") throw error;
1092
1179
  });
1093
1180
  } };
1094
1181
  } catch (error) {
1095
1182
  if (isNodeError(error) && error.code === "EEXIST") {
1183
+ if (await reclaimDeadLockOwner(lockFile)) continue;
1096
1184
  await sleep(25);
1097
1185
  continue;
1098
1186
  }
@@ -1100,12 +1188,72 @@ const acquireLock = async (lockFile, timeoutMs) => {
1100
1188
  }
1101
1189
  throw new Error(`timed out acquiring daemon lock: ${lockFile}`);
1102
1190
  };
1191
+ const reclaimDeadLockOwner = async (lockFile) => {
1192
+ const observed = await readLockSource(lockFile);
1193
+ if (observed === void 0) return true;
1194
+ const owner = parseLockOwner(observed);
1195
+ if (owner === void 0 || isProcessAlive(owner.pid)) return false;
1196
+ const claimFile = `${lockFile}.reclaim-${createHash("sha256").update(observed).digest("hex").slice(0, 16)}`;
1197
+ try {
1198
+ await link(lockFile, claimFile);
1199
+ } catch (error) {
1200
+ if (isNodeError(error) && error.code === "ENOENT") return true;
1201
+ if (isNodeError(error) && error.code === "EEXIST") {
1202
+ await clearAbandonedReclaimClaim(claimFile);
1203
+ return false;
1204
+ }
1205
+ throw error;
1206
+ }
1207
+ try {
1208
+ if (await readLockSource(lockFile) !== observed || isProcessAlive(owner.pid)) return false;
1209
+ try {
1210
+ await unlink(lockFile);
1211
+ return true;
1212
+ } catch (error) {
1213
+ if (isNodeError(error) && error.code === "ENOENT") return true;
1214
+ throw error;
1215
+ }
1216
+ } finally {
1217
+ await unlink(claimFile).catch((error) => {
1218
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
1219
+ });
1220
+ }
1221
+ };
1222
+ const clearAbandonedReclaimClaim = async (claimFile) => {
1223
+ try {
1224
+ const claim = await stat(claimFile);
1225
+ if (Date.now() - claim.ctimeMs < ABANDONED_RECLAIM_CLAIM_MS) return;
1226
+ await unlink(claimFile);
1227
+ } catch (error) {
1228
+ if (!isNodeError(error) || error.code !== "ENOENT") throw error;
1229
+ }
1230
+ };
1231
+ const readLockSource = async (lockFile) => {
1232
+ try {
1233
+ return await readFile(lockFile, "utf8");
1234
+ } catch (error) {
1235
+ if (isNodeError(error) && error.code === "ENOENT") return void 0;
1236
+ throw error;
1237
+ }
1238
+ };
1239
+ const parseLockOwner = (source) => {
1240
+ const legacyPid = Number(source.trim());
1241
+ if (Number.isInteger(legacyPid) && legacyPid > 0) return { pid: legacyPid };
1242
+ let value;
1243
+ try {
1244
+ value = JSON.parse(source);
1245
+ } catch {
1246
+ return;
1247
+ }
1248
+ if (!isRecord(value) || typeof value.pid !== "number" || !Number.isInteger(value.pid) || value.pid <= 0 || typeof value.token !== "string" || value.token.length === 0) return;
1249
+ return { pid: value.pid };
1250
+ };
1103
1251
  const isProcessAlive = (pid) => {
1104
1252
  try {
1105
1253
  process.kill(pid, 0);
1106
1254
  return true;
1107
- } catch {
1108
- return false;
1255
+ } catch (error) {
1256
+ return !(isNodeError(error) && error.code === "ESRCH");
1109
1257
  }
1110
1258
  };
1111
1259
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -1144,7 +1292,8 @@ const resolveDaemonPaths = ({ homeDir, packageVersion, callerLabel, appId, appNa
1144
1292
  endpoint,
1145
1293
  pidFile: `${runtimeDir}/broker.pid`,
1146
1294
  lockFile: `${runtimeDir}/broker.lock`,
1147
- readyFile: `${runtimeDir}/ready.json`
1295
+ readyFile: `${runtimeDir}/ready.json`,
1296
+ brokerLog: `${runtimeDir}/broker.log`
1148
1297
  };
1149
1298
  };
1150
1299
  const normalizeAppIdentityField$1 = (value, fallback) => {
@@ -1188,12 +1337,37 @@ const connectLocalBroker = async (options = {}) => {
1188
1337
  const connection = new LocalBrokerConnection(await connectSocket(endpoint), endpoint, callerLabel);
1189
1338
  try {
1190
1339
  await connection.init(clientVersion, options.protocolVersion ?? PROTOCOL_VERSION$1, expectedBrokerArtifactIdentity);
1340
+ if (appBundle !== void 0 && options.appLaunch !== void 0) {
1341
+ await updateDarwinAppLaunchDescriptor(appBundle.path, options.appLaunch);
1342
+ const convergence = await convergeDarwinAppBundleIdentity({
1343
+ currentBundlePath: appBundle.path,
1344
+ appId: paths.appId,
1345
+ managedAppsRoot: resolve(paths.homeDir, ".opentray/apps"),
1346
+ legacyRuntimeRoot: resolve(paths.homeDir, ".opentray"),
1347
+ legacyBundlePaths: [resolve(paths.runtimeDir, "darwin-carrier/OpenTray.app")]
1348
+ });
1349
+ await appendBrokerDiagnostic(paths.brokerLog, {
1350
+ event: "bundle-identity-convergence",
1351
+ removed: convergence.removed,
1352
+ skippedLiveOwner: convergence.skippedLiveOwner,
1353
+ failedUnregister: convergence.failedUnregister
1354
+ });
1355
+ }
1191
1356
  } catch (error) {
1192
1357
  await connection.close();
1193
1358
  throw error;
1194
1359
  }
1195
1360
  return connection;
1196
1361
  };
1362
+ const appendBrokerDiagnostic = async (path, value) => {
1363
+ try {
1364
+ await mkdir(dirname(path), { recursive: true });
1365
+ await appendFile(path, `${JSON.stringify({
1366
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1367
+ ...value
1368
+ })}\n`, "utf8");
1369
+ } catch {}
1370
+ };
1197
1371
  const resolveDarwinAppBundleOptions = (configured, paths, packageIdentity) => {
1198
1372
  if (packageIdentity === void 0) throw new Error("Darwin app bundle resolution requires the caller package identity");
1199
1373
  const configuredPath = configured?.path;
@@ -1320,7 +1494,7 @@ var LocalBrokerConnection = class {
1320
1494
  this.pending.delete(requestId);
1321
1495
  return;
1322
1496
  }
1323
- if (frame.type === "event" || frame.type === "ext-event") for (const listener of this.listeners) listener(frame);
1497
+ if (frame.type === "event" || frame.type === "app-event" || frame.type === "ext-event") for (const listener of this.listeners) listener(frame);
1324
1498
  }
1325
1499
  write(frame) {
1326
1500
  this.socket.write(`${JSON.stringify(frame)}\n`);
@@ -1352,6 +1526,7 @@ const responseRequestId = (frame) => {
1352
1526
  case "runtime-host-health": return frame.requestId;
1353
1527
  case "ready":
1354
1528
  case "event":
1529
+ case "app-event":
1355
1530
  case "ext-event":
1356
1531
  case "error": return;
1357
1532
  }
@@ -1479,14 +1654,48 @@ const registerClickId = (state, id) => {
1479
1654
  state.seenClickIds = seen;
1480
1655
  };
1481
1656
  //#endregion
1657
+ //#region src/app-launch.ts
1658
+ const normalizeAppLaunch = (configured, context = {}) => {
1659
+ const currentCwd = context.cwd ?? process.cwd();
1660
+ if (configured === void 0 || configured === null) return {
1661
+ schemaVersion: 1,
1662
+ command: validateRequiredText(context.execPath ?? process.execPath, "process.execPath"),
1663
+ args: [...(context.argv ?? process.argv).slice(1)].map((arg, index) => validateArg(arg, `process.argv[${index + 1}]`)),
1664
+ cwd: validateRequiredText(currentCwd, "process.cwd()")
1665
+ };
1666
+ const command = validateRequiredText(configured.command, "appLaunch.command");
1667
+ const cwd = resolve(currentCwd, configured.cwd === void 0 ? currentCwd : validateRequiredText(configured.cwd, "appLaunch.cwd"));
1668
+ const args = [...configured.args ?? []].map((arg, index) => validateArg(arg, `appLaunch.args[${index}]`));
1669
+ return {
1670
+ schemaVersion: 1,
1671
+ command: resolvePathLikeCommand(command, currentCwd),
1672
+ args,
1673
+ cwd
1674
+ };
1675
+ };
1676
+ const resolvePathLikeCommand = (command, cwd) => {
1677
+ if (isAbsolute(command) || command.includes("/") || command.includes("\\")) return resolve(cwd, command);
1678
+ return command;
1679
+ };
1680
+ const validateRequiredText = (value, field) => {
1681
+ if (value.trim().length === 0 || value.includes("\0")) throw new Error(`${field} must be a non-empty string without NUL`);
1682
+ return value;
1683
+ };
1684
+ const validateArg = (value, field) => {
1685
+ if (value.includes("\0")) throw new Error(`${field} must be a string without NUL`);
1686
+ return value;
1687
+ };
1688
+ //#endregion
1482
1689
  //#region src/sdk.ts
1483
1690
  const createTray = async (options, runtimeOptions = {}) => {
1484
1691
  if (runtimeOptions.appIcon !== void 0) await validateAppIcon(runtimeOptions.appIcon);
1485
1692
  const appIcon = runtimeOptions.appIcon === void 0 ? void 0 : normalizeAppIcon(runtimeOptions.appIcon);
1486
1693
  const normalized = normalizeCreateTrayOptions(options);
1694
+ const appLaunch = normalizeAppLaunch(runtimeOptions.appLaunch);
1487
1695
  const connection = await connectLocalBroker({
1488
1696
  ...runtimeOptions,
1489
- ...appIcon === void 0 ? {} : { appIcon }
1697
+ ...appIcon === void 0 ? {} : { appIcon },
1698
+ appLaunch
1490
1699
  });
1491
1700
  try {
1492
1701
  const tray = await createClient(connection, { appOptions: {
@@ -1532,7 +1741,8 @@ const wrapCreateTrayHandle = (tray, state) => {
1532
1741
  state.menuUnsubscribe = bindMenuClickHandlers(tray, normalized.handlers);
1533
1742
  },
1534
1743
  extend(extension, options) {
1535
- return wrapCreateTrayHandle(tray.extend(extension, options), state);
1744
+ const extended = tray.extend(extension, options);
1745
+ return wrapCreateTrayHandle(extended, state);
1536
1746
  },
1537
1747
  async destroy() {
1538
1748
  state.destroyPromise ??= (async () => {