whipdesk 0.1.1 → 0.1.3

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/agent.cjs CHANGED
@@ -18909,7 +18909,7 @@ var require_view = __commonJS({
18909
18909
  var dirname3 = path.dirname;
18910
18910
  var basename2 = path.basename;
18911
18911
  var extname = path.extname;
18912
- var join10 = path.join;
18912
+ var join11 = path.join;
18913
18913
  var resolve = path.resolve;
18914
18914
  module2.exports = View;
18915
18915
  function View(name, options) {
@@ -18971,12 +18971,12 @@ var require_view = __commonJS({
18971
18971
  };
18972
18972
  View.prototype.resolve = function resolve2(dir, file) {
18973
18973
  var ext = this.ext;
18974
- var path2 = join10(dir, file);
18974
+ var path2 = join11(dir, file);
18975
18975
  var stat2 = tryStat(path2);
18976
18976
  if (stat2 && stat2.isFile()) {
18977
18977
  return path2;
18978
18978
  }
18979
- path2 = join10(dir, basename2(file, ext), "index" + ext);
18979
+ path2 = join11(dir, basename2(file, ext), "index" + ext);
18980
18980
  stat2 = tryStat(path2);
18981
18981
  if (stat2 && stat2.isFile()) {
18982
18982
  return path2;
@@ -22802,7 +22802,7 @@ var require_send = __commonJS({
22802
22802
  var Stream = require("stream");
22803
22803
  var util = require("util");
22804
22804
  var extname = path.extname;
22805
- var join10 = path.join;
22805
+ var join11 = path.join;
22806
22806
  var normalize = path.normalize;
22807
22807
  var resolve = path.resolve;
22808
22808
  var sep2 = path.sep;
@@ -22974,7 +22974,7 @@ var require_send = __commonJS({
22974
22974
  return res;
22975
22975
  }
22976
22976
  parts = path2.split(sep2);
22977
- path2 = normalize(join10(root, path2));
22977
+ path2 = normalize(join11(root, path2));
22978
22978
  } else {
22979
22979
  if (UP_PATH_REGEXP.test(path2)) {
22980
22980
  debug('malicious path "%s"', path2);
@@ -23107,7 +23107,7 @@ var require_send = __commonJS({
23107
23107
  if (err) return self.onStatError(err);
23108
23108
  return self.error(404);
23109
23109
  }
23110
- var p = join10(path2, self._index[i]);
23110
+ var p = join11(path2, self._index[i]);
23111
23111
  debug('stat "%s"', p);
23112
23112
  fs.stat(p, function(err2, stat2) {
23113
23113
  if (err2) return next(err2);
@@ -27641,7 +27641,7 @@ var require_websocket_server = __commonJS({
27641
27641
 
27642
27642
  // src/index.ts
27643
27643
  var import_node_readline2 = require("node:readline");
27644
- var import_node_os8 = require("node:os");
27644
+ var import_node_os9 = require("node:os");
27645
27645
 
27646
27646
  // src/config.ts
27647
27647
  var import_node_crypto = require("node:crypto");
@@ -27693,7 +27693,7 @@ function isPackaged() {
27693
27693
  }
27694
27694
 
27695
27695
  // src/version.ts
27696
- var AGENT_VERSION = "0.1.1";
27696
+ var AGENT_VERSION = "0.1.3";
27697
27697
 
27698
27698
  // src/config.ts
27699
27699
  var here = (0, import_node_path2.dirname)((0, import_node_url2.fileURLToPath)(__whipdesk_meta_url));
@@ -28274,12 +28274,20 @@ async function readNsScreens() {
28274
28274
  return null;
28275
28275
  }
28276
28276
  }
28277
+ function friendlyDisplayName(raw, index) {
28278
+ const s = String(raw ?? "").trim();
28279
+ if (!s) return `Display ${index + 1}`;
28280
+ const m = /DISPLAY(\d+)/i.exec(s);
28281
+ if (m) return `Display ${m[1]}`;
28282
+ if (/^\\\\/.test(s)) return `Display ${index + 1}`;
28283
+ return s;
28284
+ }
28277
28285
  async function listBaseDisplays() {
28278
28286
  try {
28279
28287
  const displays = await import_screenshot_desktop.default.listDisplays();
28280
28288
  return displays.map((d, index) => ({
28281
28289
  id: typeof d.id === "number" ? d.id : index,
28282
- name: String(d.name ?? `Display ${index + 1}`),
28290
+ name: friendlyDisplayName(d.name, index),
28283
28291
  primary: Boolean(d.primary) || index === 0
28284
28292
  }));
28285
28293
  } catch (error) {
@@ -28446,7 +28454,7 @@ function buildWelcome(ctx) {
28446
28454
  return {
28447
28455
  type: "welcome",
28448
28456
  protocol: PROTOCOL_VERSION,
28449
- agent: { version: AGENT_VERSION, platform: (0, import_node_os2.platform)(), hostname: (0, import_node_os2.hostname)() },
28457
+ agent: { version: AGENT_VERSION, platform: (0, import_node_os2.platform)(), hostname: (0, import_node_os2.hostname)(), hdr: ctx.hdrActive || void 0 },
28450
28458
  screen: ctx.screen,
28451
28459
  capture: { fps: ctx.config.fps, quality: ctx.config.quality, maxWidth: ctx.config.maxWidth },
28452
28460
  capabilities: {
@@ -28541,11 +28549,96 @@ async function dispatch(ctx, msg, controller) {
28541
28549
  }
28542
28550
 
28543
28551
  // src/capture/encoder.ts
28544
- var import_node_child_process2 = require("node:child_process");
28552
+ var import_node_child_process3 = require("node:child_process");
28545
28553
  var import_node_dgram = require("node:dgram");
28546
28554
  var import_node_util2 = require("node:util");
28547
28555
  var import_ffmpeg_static = __toESM(require("ffmpeg-static"), 1);
28548
- var exec2 = (0, import_node_util2.promisify)(import_node_child_process2.execFile);
28556
+
28557
+ // src/capture/hdr-win.ts
28558
+ var import_node_child_process2 = require("node:child_process");
28559
+ var cached = null;
28560
+ var inflight = null;
28561
+ function invalidateHdrCache() {
28562
+ cached = null;
28563
+ }
28564
+ async function windowsHdrState(maxAgeMs = 15e3) {
28565
+ if (process.platform !== "win32") return null;
28566
+ if (cached && Date.now() - cached.at < maxAgeMs) return cached.state;
28567
+ if (inflight) return inflight;
28568
+ inflight = probe().then((state) => {
28569
+ if (state) cached = { state, at: Date.now() };
28570
+ return state;
28571
+ }).finally(() => {
28572
+ inflight = null;
28573
+ });
28574
+ return inflight;
28575
+ }
28576
+ async function probe() {
28577
+ return new Promise((resolve) => {
28578
+ (0, import_node_child_process2.execFile)(
28579
+ "powershell",
28580
+ ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", PROBE_PS],
28581
+ { timeout: 8e3, windowsHide: true },
28582
+ (err, stdout) => {
28583
+ if (err) {
28584
+ log.debug("hdr probe failed:", err.message);
28585
+ return resolve(null);
28586
+ }
28587
+ let supported = false;
28588
+ let active = false;
28589
+ let sawAny = false;
28590
+ for (const line of String(stdout).split(/\r?\n/)) {
28591
+ const m = /^([01]) ([01])$/.exec(line.trim());
28592
+ if (!m) continue;
28593
+ sawAny = true;
28594
+ if (m[1] === "1") supported = true;
28595
+ if (m[2] === "1") active = true;
28596
+ }
28597
+ resolve(sawAny ? { supported, active } : null);
28598
+ }
28599
+ );
28600
+ });
28601
+ }
28602
+ var PROBE_PS = `
28603
+ $src = @'
28604
+ using System;
28605
+ using System.Runtime.InteropServices;
28606
+ public static class WdHdr {
28607
+ [StructLayout(LayoutKind.Sequential)] public struct LUID { public uint Low; public int High; }
28608
+ [StructLayout(LayoutKind.Sequential)] public struct PATH_SOURCE { public LUID adapterId; public uint id; public uint modeInfoIdx; public uint statusFlags; }
28609
+ [StructLayout(LayoutKind.Sequential)] public struct RATIONAL { public uint num; public uint den; }
28610
+ [StructLayout(LayoutKind.Sequential)] public struct PATH_TARGET { public LUID adapterId; public uint id; public uint modeInfoIdx; public uint outputTechnology; public uint rotation; public uint scaling; public RATIONAL refreshRate; public uint scanLineOrdering; public int targetAvailable; public uint statusFlags; }
28611
+ [StructLayout(LayoutKind.Sequential)] public struct PATH_INFO { public PATH_SOURCE sourceInfo; public PATH_TARGET targetInfo; public uint flags; }
28612
+ [StructLayout(LayoutKind.Sequential, Size = 64)] public struct MODE_INFO { public uint infoType; public uint id; public LUID adapterId; }
28613
+ [StructLayout(LayoutKind.Sequential)] public struct DEVICE_INFO_HEADER { public uint type; public uint size; public LUID adapterId; public uint id; }
28614
+ [StructLayout(LayoutKind.Sequential)] public struct GET_ADVANCED_COLOR_INFO { public DEVICE_INFO_HEADER header; public uint value; public uint colorEncoding; public uint bitsPerColorChannel; }
28615
+ [DllImport("user32.dll")] public static extern int GetDisplayConfigBufferSizes(uint flags, out uint numPaths, out uint numModes);
28616
+ [DllImport("user32.dll")] public static extern int QueryDisplayConfig(uint flags, ref uint numPaths, [Out] PATH_INFO[] paths, ref uint numModes, [Out] MODE_INFO[] modes, IntPtr topologyId);
28617
+ [DllImport("user32.dll")] public static extern int DisplayConfigGetDeviceInfo(ref GET_ADVANCED_COLOR_INFO info);
28618
+ public static void Probe() {
28619
+ uint np, nm;
28620
+ if (GetDisplayConfigBufferSizes(2, out np, out nm) != 0) return;
28621
+ var paths = new PATH_INFO[np];
28622
+ var modes = new MODE_INFO[nm];
28623
+ if (QueryDisplayConfig(2, ref np, paths, ref nm, modes, IntPtr.Zero) != 0) return;
28624
+ for (int i = 0; i < np; i++) {
28625
+ var q = new GET_ADVANCED_COLOR_INFO();
28626
+ q.header.type = 9;
28627
+ q.header.size = (uint)Marshal.SizeOf(typeof(GET_ADVANCED_COLOR_INFO));
28628
+ q.header.adapterId = paths[i].targetInfo.adapterId;
28629
+ q.header.id = paths[i].targetInfo.id;
28630
+ if (DisplayConfigGetDeviceInfo(ref q) == 0)
28631
+ Console.WriteLine(((q.value & 1) != 0 ? "1" : "0") + " " + ((q.value & 2) != 0 ? "1" : "0"));
28632
+ }
28633
+ }
28634
+ }
28635
+ '@
28636
+ Add-Type -TypeDefinition $src -Language CSharp
28637
+ [WdHdr]::Probe()
28638
+ `.trim();
28639
+
28640
+ // src/capture/encoder.ts
28641
+ var exec2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
28549
28642
  var VIDEO_PAYLOAD_TYPE = 96;
28550
28643
  var VIDEO_CLOCK_RATE = 9e4;
28551
28644
  var encoderName;
@@ -28597,29 +28690,55 @@ async function avScreenIndex(displayId) {
28597
28690
  const map = await avScreenMapPromise;
28598
28691
  return map.get(displayId) ?? map.get(0) ?? null;
28599
28692
  }
28600
- async function captureDeviceFor(displayIndex, fps) {
28693
+ var HDR_TONEMAP = ",tonemap=tonemap=hable:desat=0:peak=10,zscale=tin=linear:pin=bt709:t=bt709:p=bt709:m=bt709:r=tv,format=yuv420p";
28694
+ async function captureDeviceFor(displayIndex, fps, winFallback) {
28601
28695
  const r = String(Math.max(1, Math.min(60, Math.round(fps))));
28696
+ const ts2 = ["-fflags", "+genpts", "-use_wallclock_as_timestamps", "1"];
28602
28697
  if (process.platform === "darwin") {
28603
28698
  const idx = await avScreenIndex(displayIndex);
28604
28699
  if (idx == null) return null;
28605
- return ["-f", "avfoundation", "-capture_cursor", "1", "-pixel_format", "nv12", "-framerate", r, "-i", `${idx}:none`];
28700
+ return {
28701
+ inputArgs: [...ts2, "-f", "avfoundation", "-capture_cursor", "1", "-pixel_format", "nv12", "-framerate", r, "-i", `${idx}:none`],
28702
+ hwPrefix: "",
28703
+ postFilter: ""
28704
+ };
28606
28705
  }
28607
28706
  if (process.platform === "win32") {
28608
- return ["-f", "gdigrab", "-framerate", r, "-i", "desktop"];
28707
+ if (winFallback) {
28708
+ return { inputArgs: [...ts2, "-f", "gdigrab", "-framerate", r, "-i", "desktop"], hwPrefix: "", postFilter: "" };
28709
+ }
28710
+ const idx = Math.max(0, Math.round(displayIndex));
28711
+ const hdr = (await windowsHdrState())?.active === true;
28712
+ if (hdr) {
28713
+ return {
28714
+ inputArgs: ["-f", "lavfi", "-i", `ddagrab=output_idx=${idx}:framerate=${r}:draw_mouse=1:output_fmt=rgbaf16`],
28715
+ // gbrpf32le BEFORE the split/crop/scale: swscale handles planar-float scaling everywhere,
28716
+ // while direct rgbaf16 scaling is spottier across builds. Tone-mapping itself stays
28717
+ // per-branch AFTER the scale (postFilter) so the heavy math runs at ≤maxWidth, not 4K.
28718
+ hwPrefix: "hwdownload,format=rgbaf16,format=gbrpf32le,",
28719
+ postFilter: HDR_TONEMAP,
28720
+ winHdr: true
28721
+ };
28722
+ }
28723
+ return {
28724
+ inputArgs: ["-f", "lavfi", "-i", `ddagrab=output_idx=${idx}:framerate=${r}:draw_mouse=1`],
28725
+ hwPrefix: "hwdownload,format=bgra,",
28726
+ postFilter: ""
28727
+ };
28609
28728
  }
28610
28729
  if (process.platform === "linux") {
28611
- return ["-f", "x11grab", "-framerate", r, "-i", process.env.DISPLAY || ":0.0"];
28730
+ return { inputArgs: [...ts2, "-f", "x11grab", "-framerate", r, "-i", process.env.DISPLAY || ":0.0"], hwPrefix: "", postFilter: "" };
28612
28731
  }
28613
28732
  return null;
28614
28733
  }
28615
28734
  function videoFilter(crop, maxWidth) {
28616
- const scale = `scale=min(${Math.round(maxWidth)}\\,iw):-2`;
28735
+ const scale = `scale=trunc(min(${Math.round(maxWidth)}\\,iw)/2)*2:-2`;
28617
28736
  if (isFull(crop)) return scale;
28618
28737
  const c = crop;
28619
28738
  return `crop=iw*${c.w.toFixed(4)}:ih*${c.h.toFixed(4)}:iw*${c.x.toFixed(4)}:ih*${c.y.toFixed(4)},${scale}`;
28620
28739
  }
28621
28740
  function overviewFilter(ov) {
28622
- return `scale=min(${Math.round(ov.width)}\\,iw):-2,fps=${Math.max(1, Math.round(ov.fps))}`;
28741
+ return `scale=trunc(min(${Math.round(ov.width)}\\,iw)/2)*2:-2,fps=${Math.max(1, Math.round(ov.fps))}`;
28623
28742
  }
28624
28743
  function sameCrop(a, b) {
28625
28744
  if (isFull(a) && isFull(b)) return true;
@@ -28650,12 +28769,21 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28650
28769
  spawnAt = 0;
28651
28770
  lastPacketAt = 0;
28652
28771
  everGotPacket = false;
28772
+ /** Whether the CURRENT spawn has delivered any packet — distinguishes a capture that "went
28773
+ * silent" from one that never started (the avfoundation zero-frame deadlock) in stall logs. */
28774
+ spawnGotPacket = false;
28775
+ /** Consecutive stall-restarts whose spawn never produced a frame (resets on any packet). */
28776
+ framelessRestarts = 0;
28777
+ /** Resolves the restart loop's bounded wait as soon as the current spawn's first packet lands. */
28778
+ firstPacketWaiter = null;
28653
28779
  deadRestarts = 0;
28654
28780
  erroredOut = false;
28655
28781
  stopped = false;
28656
28782
  restarting = false;
28657
28783
  /** Config the running ffmpeg was built from; lets a restart detect that `cfg` moved during it. */
28658
28784
  appliedCfg = null;
28785
+ /** Windows only: set once ddagrab fails to produce a frame, switching capture to gdigrab. */
28786
+ winCaptureFallback = false;
28659
28787
  /** Don't declare a not-yet-producing capture dead until it's had this long to start up. */
28660
28788
  static FIRST_FRAME_GRACE_MS = 6e3;
28661
28789
  /** Once it HAS produced frames, restart if it goes silent this long (display sleep, etc.). */
@@ -28674,7 +28802,7 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28674
28802
  this.activeListener = cb;
28675
28803
  }
28676
28804
  async start() {
28677
- await this.spawn();
28805
+ await this.restart();
28678
28806
  }
28679
28807
  /** Change the zoom crop or target display. A no-op when nothing changed, so a redundant viewport
28680
28808
  * echo never restarts ffmpeg. If a restart is already running (the controller settled a new zoom
@@ -28695,23 +28823,35 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28695
28823
  if (this.stopped) return false;
28696
28824
  const cfg = this.cfg;
28697
28825
  const enc = await pickH264Encoder();
28698
- const capture = await captureDeviceFor(cfg.displayIndex, cfg.fps);
28826
+ const capture = await captureDeviceFor(cfg.displayIndex, cfg.fps, this.winCaptureFallback);
28699
28827
  if (!enc || !import_ffmpeg_static.default || !capture) {
28700
28828
  if (!capture) log.warn("no direct screen-capture device on this platform \u2014 screen sharing unavailable");
28701
28829
  this.fail();
28702
28830
  return false;
28703
28831
  }
28704
28832
  const ov = !isFull(cfg.crop) && cfg.overview ? cfg.overview : null;
28705
- let sock;
28833
+ let sock = null;
28706
28834
  let sockOv = null;
28707
28835
  try {
28708
28836
  sock = await bindUdp();
28709
- if (ov) sockOv = await bindUdp();
28837
+ if (ov) {
28838
+ sockOv = await bindUdp();
28839
+ for (let tries = 0; tries < 4; tries++) {
28840
+ const p = sock.address().port;
28841
+ const q = sockOv.address().port;
28842
+ if (Math.abs(p - q) > 1) break;
28843
+ const again = await bindUdp();
28844
+ closeQuietly(sockOv);
28845
+ sockOv = again;
28846
+ }
28847
+ }
28710
28848
  } catch {
28849
+ if (sock) closeQuietly(sock);
28711
28850
  if (sockOv) closeQuietly(sockOv);
28712
28851
  this.fail();
28713
28852
  return false;
28714
28853
  }
28854
+ if (!sock) return false;
28715
28855
  if (this.stopped) {
28716
28856
  closeQuietly(sock);
28717
28857
  if (sockOv) closeQuietly(sockOv);
@@ -28720,7 +28860,7 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28720
28860
  const port = sock.address().port;
28721
28861
  const portOv = sockOv ? sockOv.address().port : 0;
28722
28862
  const preset = enc === "libx264" ? ["-preset", "ultrafast", "-tune", "zerolatency"] : ["-realtime", "1"];
28723
- const input = ["-hide_banner", "-loglevel", "error", "-fflags", "+genpts", "-use_wallclock_as_timestamps", "1", ...capture, "-an"];
28863
+ const input = ["-hide_banner", "-loglevel", "error", ...capture.inputArgs, "-an"];
28724
28864
  const h264Out = (kbps, gop, ssrc, outPort) => [
28725
28865
  "-fps_mode",
28726
28866
  "vfr",
@@ -28731,6 +28871,12 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28731
28871
  "yuv420p",
28732
28872
  "-g",
28733
28873
  String(gop),
28874
+ // Also key on WALL-CLOCK time, not just frame count: with VFR a static screen yields few output
28875
+ // frames, so a frames-based GOP can leave a lost IDR unrepaired "until something moves". Forcing
28876
+ // a keyframe ~1s (by PTS, which advances via -use_wallclock_as_timestamps) guarantees the client
28877
+ // can always re-sync within ~1s even with no PLI and a frozen desktop.
28878
+ "-force_key_frames",
28879
+ "expr:gte(t,n_forced*1)",
28734
28880
  "-b:v",
28735
28881
  kbpsArg(kbps),
28736
28882
  "-maxrate",
@@ -28741,26 +28887,30 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28741
28887
  String(VIDEO_PAYLOAD_TYPE),
28742
28888
  "-ssrc",
28743
28889
  ssrc,
28890
+ // buffer_size = SO_SNDBUF: keyframe bursts must not overflow the send side either (the
28891
+ // receive side is widened in bindUdp — see the black-bottom-of-keyframe note there).
28744
28892
  "-f",
28745
28893
  "rtp",
28746
- `rtp://127.0.0.1:${outPort}?pkt_size=1200`
28894
+ `rtp://127.0.0.1:${outPort}?pkt_size=1200&buffer_size=1048576`
28747
28895
  ];
28748
28896
  const mainGop = Math.max(10, Math.round(cfg.fps));
28749
28897
  const args = ov ? [
28750
28898
  ...input,
28751
28899
  // The full captured frame fans into two encodes: [m] the sharp crop, [o] the small overview.
28900
+ // hwPrefix (ddagrab) downloads once, before the split, so both branches get RAM frames.
28901
+ // postFilter (HDR tone map) runs per-branch AFTER the scale — cheap at output size.
28752
28902
  "-filter_complex",
28753
- `[0:v]split=2[m][o];[m]${videoFilter(cfg.crop, cfg.maxWidth)}[mainout];[o]${overviewFilter(ov)}[ovout]`,
28903
+ `[0:v]${capture.hwPrefix}split=2[m][o];[m]${videoFilter(cfg.crop, cfg.maxWidth)}${capture.postFilter}[mainout];[o]${overviewFilter(ov)}${capture.postFilter}[ovout]`,
28754
28904
  "-map",
28755
28905
  "[mainout]",
28756
28906
  ...h264Out(cfg.kbps, mainGop, "1", port),
28757
28907
  "-map",
28758
28908
  "[ovout]",
28759
28909
  ...h264Out(ov.kbps, Math.max(1, Math.round(ov.fps)), "2", portOv)
28760
- ] : [...input, "-vf", videoFilter(cfg.crop, cfg.maxWidth), ...h264Out(cfg.kbps, mainGop, "1", port)];
28910
+ ] : [...input, "-vf", `${capture.hwPrefix}${videoFilter(cfg.crop, cfg.maxWidth)}${capture.postFilter}`, ...h264Out(cfg.kbps, mainGop, "1", port)];
28761
28911
  let proc;
28762
28912
  try {
28763
- proc = (0, import_node_child_process2.spawn)(import_ffmpeg_static.default, args, { stdio: ["ignore", "ignore", "pipe"] });
28913
+ proc = (0, import_node_child_process3.spawn)(import_ffmpeg_static.default, args, { stdio: ["ignore", "ignore", "pipe"] });
28764
28914
  } catch (error) {
28765
28915
  log.warn("video encoder failed to start:", error.message);
28766
28916
  closeQuietly(sock);
@@ -28774,14 +28924,18 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28774
28924
  this.appliedCfg = cfg;
28775
28925
  this.spawnAt = Date.now();
28776
28926
  this.lastPacketAt = Date.now();
28927
+ this.spawnGotPacket = false;
28777
28928
  const spawnCrop = cfg.crop;
28778
28929
  let firstPacket = true;
28779
28930
  sock.on("message", (msg) => {
28780
28931
  this.lastPacketAt = Date.now();
28781
28932
  this.everGotPacket = true;
28933
+ this.spawnGotPacket = true;
28782
28934
  this.deadRestarts = 0;
28783
28935
  if (firstPacket) {
28784
28936
  firstPacket = false;
28937
+ this.framelessRestarts = 0;
28938
+ this.firstPacketWaiter?.();
28785
28939
  this.activeListener?.(spawnCrop);
28786
28940
  }
28787
28941
  this.listener?.(msg);
@@ -28797,13 +28951,26 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28797
28951
  for (const line of d.toString().split("\n")) {
28798
28952
  const t = line.trim();
28799
28953
  if (!t || /^objc\[|NSKVONotifying|not linked into application/.test(t)) continue;
28954
+ if (process.platform === "win32" && /AcquireNextFrame failed|Output parameters changed|Requested output format unavailable/i.test(t)) {
28955
+ invalidateHdrCache();
28956
+ }
28800
28957
  log.debug("ffmpeg:", t.slice(0, 200));
28801
28958
  }
28802
28959
  });
28803
28960
  proc.on("error", (e) => log.warn("video encoder process error:", e.message));
28961
+ proc.on("exit", (code, sig) => {
28962
+ if (this.proc !== proc || this.stopped) return;
28963
+ log.debug(`ffmpeg exited unexpectedly (${code ?? sig ?? "?"})`);
28964
+ const delay2 = Date.now() - this.spawnAt < 2e3 ? 2e3 : 400;
28965
+ const t = setTimeout(() => {
28966
+ if (this.proc === proc && !this.stopped) void this.restart();
28967
+ }, delay2);
28968
+ t.unref?.();
28969
+ });
28804
28970
  this.startHealthMonitor();
28971
+ const cropDesc = isFull(cfg.crop) ? "full desktop" : `zoomed ${cfg.crop.x.toFixed(2)},${cfg.crop.y.toFixed(2)} ${cfg.crop.w.toFixed(2)}x${cfg.crop.h.toFixed(2)}`;
28805
28972
  log.debug(
28806
- `video: H.264 capture started (${enc}, ${kbpsArg(cfg.kbps)}@${cfg.fps}fps, ${cfg.crop ? "zoomed" : "full desktop"}${ov ? " + overview" : ""})`
28973
+ `video: H.264 capture started (${enc}, ${kbpsArg(cfg.kbps)}@${cfg.fps}fps, ${cropDesc}${ov ? " + overview" : ""}${capture.winHdr ? ", HDR desktop tone-mapped to SDR" : ""})`
28807
28974
  );
28808
28975
  return true;
28809
28976
  }
@@ -28817,13 +28984,23 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28817
28984
  if (this.stopped || this.restarting || !this.proc) return;
28818
28985
  const now = Date.now();
28819
28986
  if (this.everGotPacket) {
28820
- if (now - this.lastPacketAt > _ScreenCaptureSession.STALL_MS) {
28821
- log.debug("screen capture stalled \u2014 restarting");
28987
+ const stallMs = this.spawnGotPacket ? _ScreenCaptureSession.STALL_MS : Math.min(4e3 + this.framelessRestarts * 1500, 1e4);
28988
+ if (now - this.lastPacketAt > stallMs) {
28989
+ if (!this.spawnGotPacket) this.framelessRestarts += 1;
28990
+ log.debug(
28991
+ `screen capture stalled \u2014 restarting (${this.spawnGotPacket ? "went silent" : `spawn never produced a frame after ${Math.round(stallMs / 1e3)}s`})`
28992
+ );
28822
28993
  void this.restart();
28823
28994
  }
28824
28995
  return;
28825
28996
  }
28826
28997
  if (now - this.spawnAt < _ScreenCaptureSession.FIRST_FRAME_GRACE_MS) return;
28998
+ if (process.platform === "win32" && !this.winCaptureFallback) {
28999
+ this.winCaptureFallback = true;
29000
+ log.debug("screen capture: ddagrab produced no frame \u2014 falling back to gdigrab");
29001
+ void this.restart();
29002
+ return;
29003
+ }
28827
29004
  if (++this.deadRestarts > _ScreenCaptureSession.MAX_DEAD_RESTARTS) {
28828
29005
  this.kill();
28829
29006
  this.fail();
@@ -28838,13 +29015,58 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28838
29015
  this.restarting = true;
28839
29016
  try {
28840
29017
  do {
28841
- this.kill();
29018
+ await this.killAndWait();
28842
29019
  if (!await this.spawn()) break;
29020
+ await this.waitForFirstPacket(3e3);
28843
29021
  } while (!this.stopped && this.appliedCfg !== null && !sameConfig(this.cfg, this.appliedCfg));
28844
29022
  } finally {
28845
29023
  this.restarting = false;
28846
29024
  }
28847
29025
  }
29026
+ /**
29027
+ * Kill the current ffmpeg and WAIT until it has actually EXITED — plus a short macOS beat for
29028
+ * WindowServer to release its screen-capture session — before the caller spawns the next one.
29029
+ *
29030
+ * kill() alone is fire-and-forget: signal delivery and the OS-side capture teardown are both
29031
+ * asynchronous, so under re-crop churn (pan swipe after swipe) the next ffmpeg starts while the
29032
+ * old capture session is still registered — the documented avfoundation deadlock where BOTH
29033
+ * captures yield zero frames. Worse, the health monitor's recovery restart repeats the same
29034
+ * unserialized kill→spawn every 5s, re-triggering the overlap each time: the observed
29035
+ * "stalled — restarting" loop with no "crop live" ever following, dead for tens of seconds.
29036
+ * Serializing exit→settle→spawn removes the overlap entirely, at ~150ms per re-crop.
29037
+ */
29038
+ async killAndWait() {
29039
+ const proc = this.proc;
29040
+ const exited = proc && proc.exitCode === null && proc.signalCode === null ? new Promise((resolve) => {
29041
+ const cap = setTimeout(resolve, 2500);
29042
+ cap.unref?.();
29043
+ proc.once("exit", () => {
29044
+ clearTimeout(cap);
29045
+ resolve();
29046
+ });
29047
+ }) : null;
29048
+ this.kill();
29049
+ if (exited) {
29050
+ await exited;
29051
+ if (process.platform === "darwin") await new Promise((r) => setTimeout(r, 150));
29052
+ }
29053
+ }
29054
+ /** Bounded wait for the current spawn's first RTP packet (resolves immediately if it already
29055
+ * produced, on stop, or at the cap). See the restart loop for why replacing a capture that
29056
+ * hasn't produced yet is dangerous on macOS. */
29057
+ waitForFirstPacket(capMs) {
29058
+ if (this.spawnGotPacket || this.stopped) return Promise.resolve();
29059
+ return new Promise((resolve) => {
29060
+ const done = () => {
29061
+ clearTimeout(cap);
29062
+ if (this.firstPacketWaiter === done) this.firstPacketWaiter = null;
29063
+ resolve();
29064
+ };
29065
+ const cap = setTimeout(done, capMs);
29066
+ cap.unref?.();
29067
+ this.firstPacketWaiter = done;
29068
+ });
29069
+ }
28848
29070
  fail() {
28849
29071
  if (this.erroredOut) return;
28850
29072
  this.erroredOut = true;
@@ -28858,11 +29080,22 @@ var ScreenCaptureSession = class _ScreenCaptureSession {
28858
29080
  }
28859
29081
  const proc = this.proc;
28860
29082
  this.proc = null;
28861
- if (proc) {
29083
+ if (proc && proc.exitCode === null && proc.signalCode === null) {
28862
29084
  try {
28863
- proc.kill("SIGKILL");
29085
+ proc.kill("SIGTERM");
28864
29086
  } catch {
28865
29087
  }
29088
+ const hardKill = setTimeout(
29089
+ () => {
29090
+ try {
29091
+ proc.kill("SIGKILL");
29092
+ } catch {
29093
+ }
29094
+ },
29095
+ this.spawnGotPacket ? 1500 : 700
29096
+ );
29097
+ hardKill.unref?.();
29098
+ proc.once("exit", () => clearTimeout(hardKill));
28866
29099
  }
28867
29100
  if (this.sock) {
28868
29101
  closeQuietly(this.sock);
@@ -28880,6 +29113,10 @@ function bindUdp() {
28880
29113
  sock.once("error", reject);
28881
29114
  sock.bind(0, "127.0.0.1", () => {
28882
29115
  sock.removeListener("error", reject);
29116
+ try {
29117
+ sock.setRecvBufferSize(4 * 1024 * 1024);
29118
+ } catch {
29119
+ }
28883
29120
  resolve(sock);
28884
29121
  });
28885
29122
  });
@@ -28989,7 +29226,15 @@ var VideoHub = class {
28989
29226
  if (this.opts.onCropActive) session.onActive((crop) => this.opts.onCropActive(crop));
28990
29227
  await session.start();
28991
29228
  if (this.paused || this.sinks.size === 0 && this.overviewSinks.size === 0) session.stop();
28992
- else this.session = session;
29229
+ else {
29230
+ this.session = session;
29231
+ void session.reconfigure({
29232
+ displayIndex: this.opts.displayIndex(),
29233
+ crop: this.crop,
29234
+ kbps: this.kbps,
29235
+ fps: this.fps
29236
+ });
29237
+ }
28993
29238
  })().finally(() => this.starting = null);
28994
29239
  return this.starting;
28995
29240
  }
@@ -29125,6 +29370,9 @@ async function answerWebRtcOffer(ctx, offerSdp, onClosed, opts = {}) {
29125
29370
  pc.connectionStateChange.subscribe((s) => {
29126
29371
  if (s === "failed" || s === "closed" || s === "disconnected") teardown();
29127
29372
  });
29373
+ pc.iceConnectionStateChange.subscribe((s) => {
29374
+ if (s === "failed" || s === "closed" || s === "disconnected") teardown();
29375
+ });
29128
29376
  await pc.setRemoteDescription({ type: "offer", sdp: offerSdp });
29129
29377
  if (offerVideoCount > 0 && canSendVideo && ctx.video) {
29130
29378
  for (let i = 0; i < offerVideoCount; i++) {
@@ -29147,9 +29395,9 @@ async function answerWebRtcOffer(ctx, offerSdp, onClosed, opts = {}) {
29147
29395
  }
29148
29396
  };
29149
29397
  (isOverview ? overviewSinks : sinks).push(sink);
29150
- const run2 = () => void (isOverview ? ctx.video.attachOverview(sink) : ctx.video.attach(sink));
29151
- if (authenticated) run2();
29152
- else pendingAttach.push(run2);
29398
+ const run3 = () => void (isOverview ? ctx.video.attachOverview(sink) : ctx.video.attach(sink));
29399
+ if (authenticated) run3();
29400
+ else pendingAttach.push(run3);
29153
29401
  } catch (error) {
29154
29402
  log.warn(`video track ${i} setup failed:`, error.message);
29155
29403
  }
@@ -29341,12 +29589,15 @@ function printSetupReminder() {
29341
29589
  console.log(" Setup / permissions:");
29342
29590
  for (const line of lines) console.log(line);
29343
29591
  console.log("");
29592
+ console.log(" Troubleshooting: relaunch with --verbose (e.g. `whipdesk --verbose`) for detailed");
29593
+ console.log(" capture/network logs \u2014 attach them when reporting an issue.");
29594
+ console.log("");
29344
29595
  }
29345
29596
 
29346
29597
  // src/server.ts
29347
29598
  var import_node_http = require("node:http");
29348
- var import_node_fs9 = require("node:fs");
29349
- var import_node_path11 = require("node:path");
29599
+ var import_node_fs10 = require("node:fs");
29600
+ var import_node_path12 = require("node:path");
29350
29601
  var import_node_url3 = require("node:url");
29351
29602
  var import_express = __toESM(require_express2(), 1);
29352
29603
 
@@ -29405,12 +29656,12 @@ var ScreenCapturer = class {
29405
29656
  };
29406
29657
 
29407
29658
  // src/input/applescript.ts
29408
- var import_node_child_process3 = require("node:child_process");
29659
+ var import_node_child_process4 = require("node:child_process");
29409
29660
  var import_node_util3 = require("node:util");
29410
- var exec3 = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
29661
+ var exec3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
29411
29662
  async function createAppleScriptBackend() {
29412
29663
  if (process.platform !== "darwin") return null;
29413
- const run2 = (script) => exec3("osascript", ["-e", script]).then(() => void 0);
29664
+ const run3 = (script) => exec3("osascript", ["-e", script]).then(() => void 0);
29414
29665
  return {
29415
29666
  name: "applescript (keyboard-only)",
29416
29667
  canMouse: false,
@@ -29431,17 +29682,17 @@ async function createAppleScriptBackend() {
29431
29682
  async scroll() {
29432
29683
  },
29433
29684
  async typeText(text, submit) {
29434
- if (text) await run2(`tell application "System Events" to keystroke ${asAppleString(text)}`);
29435
- if (submit) await run2(`tell application "System Events" to key code 36`);
29685
+ if (text) await run3(`tell application "System Events" to keystroke ${asAppleString(text)}`);
29686
+ if (submit) await run3(`tell application "System Events" to key code 36`);
29436
29687
  },
29437
29688
  async keyTap(name, modifiers = []) {
29438
29689
  const using = modifiers.map(modifierClause).filter((m) => m !== null).join(", ");
29439
29690
  const usingClause = using ? ` using {${using}}` : "";
29440
29691
  const code = KEY_CODES[name.toLowerCase()];
29441
29692
  if (code !== void 0) {
29442
- await run2(`tell application "System Events" to key code ${code}${usingClause}`);
29693
+ await run3(`tell application "System Events" to key code ${code}${usingClause}`);
29443
29694
  } else if (name.length === 1) {
29444
- await run2(`tell application "System Events" to keystroke ${asAppleString(name)}${usingClause}`);
29695
+ await run3(`tell application "System Events" to keystroke ${asAppleString(name)}${usingClause}`);
29445
29696
  }
29446
29697
  }
29447
29698
  };
@@ -29805,7 +30056,7 @@ function meanDiff(a, b) {
29805
30056
  }
29806
30057
 
29807
30058
  // src/presence.ts
29808
- var import_node_child_process4 = require("node:child_process");
30059
+ var import_node_child_process5 = require("node:child_process");
29809
30060
  var Presence = class {
29810
30061
  watchers = 0;
29811
30062
  start() {
@@ -29815,7 +30066,7 @@ var Presence = class {
29815
30066
  if (process.platform !== "darwin") return;
29816
30067
  const escape2 = (s) => s.replace(/[\\"]/g, "\\$&");
29817
30068
  const script = `display notification "${escape2(body)}" with title "${escape2(title)}"`;
29818
- (0, import_node_child_process4.execFile)("osascript", ["-e", script], () => void 0);
30069
+ (0, import_node_child_process5.execFile)("osascript", ["-e", script], () => void 0);
29819
30070
  }
29820
30071
  setTitle(text) {
29821
30072
  if (process.stdout.isTTY) process.stdout.write(`\x1B]2;${text}\x07`);
@@ -29847,7 +30098,7 @@ var Presence = class {
29847
30098
  };
29848
30099
 
29849
30100
  // src/power/keep-awake.ts
29850
- var import_node_child_process5 = require("node:child_process");
30101
+ var import_node_child_process6 = require("node:child_process");
29851
30102
  var import_node_os4 = require("node:os");
29852
30103
  var KeepAwake = class {
29853
30104
  child = null;
@@ -29890,15 +30141,15 @@ var KeepAwake = class {
29890
30141
  spawnBlocker() {
29891
30142
  switch ((0, import_node_os4.platform)()) {
29892
30143
  case "darwin":
29893
- return (0, import_node_child_process5.spawn)("caffeinate", ["-i", "-w", String(process.pid)], { stdio: "ignore" });
30144
+ return (0, import_node_child_process6.spawn)("caffeinate", ["-i", "-w", String(process.pid)], { stdio: "ignore" });
29894
30145
  case "win32":
29895
- return (0, import_node_child_process5.spawn)(
30146
+ return (0, import_node_child_process6.spawn)(
29896
30147
  "powershell",
29897
30148
  ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", WINDOWS_KEEP_AWAKE],
29898
30149
  { stdio: "ignore", windowsHide: true }
29899
30150
  );
29900
30151
  case "linux":
29901
- return (0, import_node_child_process5.spawn)(
30152
+ return (0, import_node_child_process6.spawn)(
29902
30153
  "systemd-inhibit",
29903
30154
  [
29904
30155
  "--what=sleep:idle",
@@ -29923,7 +30174,7 @@ var WINDOWS_KEEP_AWAKE = [
29923
30174
  ].join(" ");
29924
30175
 
29925
30176
  // src/power/wake-display.ts
29926
- var import_node_child_process6 = require("node:child_process");
30177
+ var import_node_child_process7 = require("node:child_process");
29927
30178
  var import_node_os5 = require("node:os");
29928
30179
  var DisplayWake = class {
29929
30180
  hold = null;
@@ -29944,21 +30195,22 @@ var DisplayWake = class {
29944
30195
  this.stopHold();
29945
30196
  }
29946
30197
  }
29947
- /** Turn the display on right now (best-effort, never throws). */
29948
- wake() {
30198
+ /** Turn the display on right now (best-effort, never throws). `holdSeconds` keeps it on that
30199
+ * long on macOS (scheduled actions need the panel up for the whole wake→click→type sequence). */
30200
+ wake(holdSeconds = 5) {
29949
30201
  try {
29950
30202
  switch ((0, import_node_os5.platform)()) {
29951
30203
  case "darwin":
29952
- (0, import_node_child_process6.spawn)("caffeinate", ["-u", "-t", "5"], { stdio: "ignore" }).unref();
30204
+ (0, import_node_child_process7.spawn)("caffeinate", ["-u", "-t", String(holdSeconds)], { stdio: "ignore" }).unref();
29953
30205
  break;
29954
30206
  case "win32":
29955
- (0, import_node_child_process6.spawn)("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", WINDOWS_WAKE], {
30207
+ (0, import_node_child_process7.spawn)("powershell", ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", WINDOWS_WAKE], {
29956
30208
  stdio: "ignore",
29957
30209
  windowsHide: true
29958
30210
  }).unref();
29959
30211
  break;
29960
30212
  case "linux":
29961
- (0, import_node_child_process6.spawn)("sh", ["-c", "xset s reset; xset dpms force on"], { stdio: "ignore" }).unref();
30213
+ (0, import_node_child_process7.spawn)("sh", ["-c", "xset s reset; xset dpms force on"], { stdio: "ignore" }).unref();
29962
30214
  break;
29963
30215
  }
29964
30216
  } catch (e) {
@@ -29969,10 +30221,10 @@ var DisplayWake = class {
29969
30221
  try {
29970
30222
  switch ((0, import_node_os5.platform)()) {
29971
30223
  case "darwin":
29972
- this.hold = (0, import_node_child_process6.spawn)("caffeinate", ["-d", "-w", String(process.pid)], { stdio: "ignore" });
30224
+ this.hold = (0, import_node_child_process7.spawn)("caffeinate", ["-d", "-w", String(process.pid)], { stdio: "ignore" });
29973
30225
  break;
29974
30226
  case "win32":
29975
- this.hold = (0, import_node_child_process6.spawn)(
30227
+ this.hold = (0, import_node_child_process7.spawn)(
29976
30228
  "powershell",
29977
30229
  ["-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", WINDOWS_HOLD],
29978
30230
  { stdio: "ignore", windowsHide: true }
@@ -30024,6 +30276,37 @@ var WINDOWS_HOLD = [
30024
30276
  "while ($true) { Start-Sleep -Seconds 3600 }"
30025
30277
  ].join(" ");
30026
30278
 
30279
+ // src/power/session-lock.ts
30280
+ var import_node_child_process8 = require("node:child_process");
30281
+ var import_node_os6 = require("node:os");
30282
+ async function isSessionLocked() {
30283
+ try {
30284
+ switch ((0, import_node_os6.platform)()) {
30285
+ case "darwin": {
30286
+ const out = await run("ioreg", ["-n", "Root", "-d1"]);
30287
+ const m = /"IOConsoleLocked"\s*=\s*(Yes|No)/.exec(out);
30288
+ return m ? m[1] === "Yes" : null;
30289
+ }
30290
+ case "win32": {
30291
+ const out = await run("tasklist", ["/FI", "IMAGENAME eq LogonUI.exe", "/NH"]);
30292
+ return /LogonUI\.exe/i.test(out);
30293
+ }
30294
+ default:
30295
+ return null;
30296
+ }
30297
+ } catch {
30298
+ return null;
30299
+ }
30300
+ }
30301
+ function run(cmd, args) {
30302
+ return new Promise((resolve, reject) => {
30303
+ (0, import_node_child_process8.execFile)(cmd, args, { timeout: 4e3, windowsHide: true }, (err, stdout) => {
30304
+ if (err) reject(err);
30305
+ else resolve(String(stdout));
30306
+ });
30307
+ });
30308
+ }
30309
+
30027
30310
  // src/security/setup.ts
30028
30311
  var import_node_readline = require("node:readline");
30029
30312
 
@@ -30073,9 +30356,9 @@ var PinGuard = class _PinGuard {
30073
30356
  get salt() {
30074
30357
  return this.record?.salt ?? "";
30075
30358
  }
30076
- /** Persist a new PIN (>= 4 chars). Stores only the stretched key. */
30359
+ /** Persist a new PIN (>= 6 chars). Stores only the stretched key. */
30077
30360
  setPin(pin) {
30078
- if (pin.length < 4) throw new Error("PIN must be at least 4 characters");
30361
+ if (pin.length < 6) throw new Error("PIN must be at least 6 characters");
30079
30362
  const salt = (0, import_node_crypto5.randomBytes)(16).toString("hex");
30080
30363
  const key = stretch(pin, salt, ITERATIONS);
30081
30364
  this.record = { salt, iterations: ITERATIONS, key };
@@ -30382,9 +30665,9 @@ var import_node_path9 = require("node:path");
30382
30665
 
30383
30666
  // src/monitor/agents.ts
30384
30667
  var import_node_fs7 = require("node:fs");
30385
- var import_node_os6 = require("node:os");
30668
+ var import_node_os7 = require("node:os");
30386
30669
  var import_node_path7 = require("node:path");
30387
- var home = (0, import_node_os6.homedir)();
30670
+ var home = (0, import_node_os7.homedir)();
30388
30671
  var has = (tokens, name) => tokens.includes(name);
30389
30672
  function claudeProjectDir(cwd) {
30390
30673
  return (0, import_node_path7.join)(home, ".claude", "projects", cwd.replace(/[/\\.]/g, "-"));
@@ -30445,8 +30728,8 @@ function readLastJsonlLine(path) {
30445
30728
  }
30446
30729
  function vsCodeUserDirs() {
30447
30730
  const variants = ["Code", "Code - Insiders", "VSCodium"];
30448
- if ((0, import_node_os6.platform)() === "darwin") return variants.map((v) => (0, import_node_path7.join)(home, "Library", "Application Support", v, "User"));
30449
- if ((0, import_node_os6.platform)() === "win32") {
30731
+ if ((0, import_node_os7.platform)() === "darwin") return variants.map((v) => (0, import_node_path7.join)(home, "Library", "Application Support", v, "User"));
30732
+ if ((0, import_node_os7.platform)() === "win32") {
30450
30733
  const appData = process.env.APPDATA || (0, import_node_path7.join)(home, "AppData", "Roaming");
30451
30734
  return variants.map((v) => (0, import_node_path7.join)(appData, v, "User"));
30452
30735
  }
@@ -30488,11 +30771,12 @@ function vsCodeCopilotInstalled() {
30488
30771
  } catch {
30489
30772
  }
30490
30773
  }
30774
+ if (!value) value = vsCodeChatSessionDirs().length > 0;
30491
30775
  copilotInstalledCache = { value, at: now };
30492
30776
  return value;
30493
30777
  }
30494
30778
  function isVsCodeExtensionHost(cmd) {
30495
- return cmd.includes("--type=extensionhost") || cmd.includes("extensionhostprocess");
30779
+ return cmd.includes("--type=extensionhost") || cmd.includes("extensionhostprocess") || cmd.includes("node.mojom.nodeservice") && cmd.includes("--inspect-port");
30496
30780
  }
30497
30781
  var AGENTS = [
30498
30782
  {
@@ -30575,11 +30859,11 @@ function agentLabel(kind) {
30575
30859
  }
30576
30860
 
30577
30861
  // src/monitor/processes.ts
30578
- var import_node_child_process7 = require("node:child_process");
30579
- var import_node_os7 = require("node:os");
30580
- function run(cmd, args, timeoutMs = 5e3) {
30862
+ var import_node_child_process9 = require("node:child_process");
30863
+ var import_node_os8 = require("node:os");
30864
+ function run2(cmd, args, timeoutMs = 5e3) {
30581
30865
  return new Promise((resolve) => {
30582
- (0, import_node_child_process7.execFile)(cmd, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout) => {
30866
+ (0, import_node_child_process9.execFile)(cmd, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout) => {
30583
30867
  resolve(err && !stdout ? "" : stdout.toString());
30584
30868
  });
30585
30869
  });
@@ -30594,7 +30878,7 @@ function tokenize(command) {
30594
30878
  return out;
30595
30879
  }
30596
30880
  async function listUnix() {
30597
- const out = await run("ps", ["-axww", "-o", "pid=,ppid=,pcpu=,tty=,command="]);
30881
+ const out = await run2("ps", ["-axww", "-o", "pid=,ppid=,pcpu=,tty=,command="]);
30598
30882
  const procs = [];
30599
30883
  for (const line of out.split("\n")) {
30600
30884
  const m = line.match(/^\s*(\d+)\s+(\d+)\s+([\d.]+)\s+(\S+)\s+(.*)$/);
@@ -30614,7 +30898,7 @@ async function listUnix() {
30614
30898
  }
30615
30899
  async function listWindows() {
30616
30900
  const script = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine,Name | ConvertTo-Csv -NoTypeInformation";
30617
- const out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], 9e3);
30901
+ const out = await run2("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], 9e3);
30618
30902
  const procs = [];
30619
30903
  for (const line of out.split(/\r?\n/)) {
30620
30904
  const m = line.match(/^"(\d+)","(\d*)",(.*)$/);
@@ -30638,16 +30922,16 @@ async function listWindows() {
30638
30922
  return procs;
30639
30923
  }
30640
30924
  function listProcesses() {
30641
- return (0, import_node_os7.platform)() === "win32" ? listWindows() : listUnix();
30925
+ return (0, import_node_os8.platform)() === "win32" ? listWindows() : listUnix();
30642
30926
  }
30643
30927
  async function processCwd(pid) {
30644
- const os = (0, import_node_os7.platform)();
30928
+ const os = (0, import_node_os8.platform)();
30645
30929
  if (os === "win32") return "";
30646
30930
  if (os === "linux") {
30647
- const out2 = await run("readlink", [`/proc/${pid}/cwd`], 2e3);
30931
+ const out2 = await run2("readlink", [`/proc/${pid}/cwd`], 2e3);
30648
30932
  return out2.trim();
30649
30933
  }
30650
- const out = await run("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], 3e3);
30934
+ const out = await run2("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], 3e3);
30651
30935
  for (const line of out.split("\n")) if (line.startsWith("n")) return line.slice(1).trim();
30652
30936
  return "";
30653
30937
  }
@@ -30824,8 +31108,8 @@ var SessionMonitor = class {
30824
31108
  this.timer.unref?.();
30825
31109
  }
30826
31110
  async cwdFor(pid) {
30827
- const cached = this.cwdCache.get(pid);
30828
- if (cached !== void 0) return cached;
31111
+ const cached2 = this.cwdCache.get(pid);
31112
+ if (cached2 !== void 0) return cached2;
30829
31113
  const cwd = await processCwd(pid).catch(() => "");
30830
31114
  this.cwdCache.set(pid, cwd);
30831
31115
  return cwd;
@@ -31036,6 +31320,30 @@ function saveAlwaysAgents(stateDir2, agents) {
31036
31320
  }
31037
31321
  }
31038
31322
 
31323
+ // src/timer-store.ts
31324
+ var import_node_fs9 = require("node:fs");
31325
+ var import_node_path11 = require("node:path");
31326
+ var FILE2 = "timers.json";
31327
+ function loadTimers(stateDir2) {
31328
+ try {
31329
+ const raw = (0, import_node_fs9.readFileSync)((0, import_node_path11.join)(stateDir2, FILE2), "utf8");
31330
+ const parsed = JSON.parse(raw);
31331
+ if (!Array.isArray(parsed.timers)) return [];
31332
+ return parsed.timers.filter(
31333
+ (t) => !!t && typeof t.id === "string" && typeof t.fireAtMs === "number"
31334
+ );
31335
+ } catch {
31336
+ return [];
31337
+ }
31338
+ }
31339
+ function saveTimers(stateDir2, timers) {
31340
+ try {
31341
+ (0, import_node_fs9.mkdirSync)(stateDir2, { recursive: true });
31342
+ (0, import_node_fs9.writeFileSync)((0, import_node_path11.join)(stateDir2, FILE2), JSON.stringify({ timers }), { mode: 384 });
31343
+ } catch {
31344
+ }
31345
+ }
31346
+
31039
31347
  // src/util/update-check.ts
31040
31348
  async function checkForUpdate(current) {
31041
31349
  try {
@@ -31078,8 +31386,8 @@ function announceUpdate(latest, current, notify) {
31078
31386
  }
31079
31387
 
31080
31388
  // src/server.ts
31081
- var here2 = (0, import_node_path11.dirname)((0, import_node_url3.fileURLToPath)(__whipdesk_meta_url));
31082
- var webDist = isPackaged() ? (0, import_node_path11.join)(here2, "mobile-web") : (0, import_node_path11.join)(here2, "..", "..", "mobile-web", "dist");
31389
+ var here2 = (0, import_node_path12.dirname)((0, import_node_url3.fileURLToPath)(__whipdesk_meta_url));
31390
+ var webDist = isPackaged() ? (0, import_node_path12.join)(here2, "mobile-web") : (0, import_node_path12.join)(here2, "..", "..", "mobile-web", "dist");
31083
31391
  async function startAgent() {
31084
31392
  const config = loadConfig();
31085
31393
  const capturer = new ScreenCapturer({ quality: config.quality, maxWidth: config.maxWidth });
@@ -31169,6 +31477,7 @@ async function startAgent() {
31169
31477
  let captureFailing = false;
31170
31478
  let viewport = { x: 0, y: 0, w: 1, h: 1 };
31171
31479
  const timers = /* @__PURE__ */ new Map();
31480
+ const persistTimers = () => saveTimers(config.stateDir, [...timers.values()]);
31172
31481
  const listTimers = () => [...timers.values()].map((t) => ({ id: t.id, label: t.label, fireAtMs: t.fireAtMs, hasAction: !!t.action }));
31173
31482
  const broadcastTimers = () => {
31174
31483
  const msg = { type: "timers", timers: listTimers() };
@@ -31190,13 +31499,46 @@ async function startAgent() {
31190
31499
  const t = timers.get(id);
31191
31500
  if (!t) return;
31192
31501
  timers.delete(id);
31502
+ persistTimers();
31193
31503
  broadcastTimers();
31194
31504
  const a = t.action;
31195
31505
  if (a) {
31196
31506
  try {
31197
31507
  log.info(`timer "${t.label || id}" firing${a.kind ? ` (${a.kind})` : ""}`);
31508
+ displayWake.wake(30);
31509
+ await delay(3e3);
31510
+ const locked = await isSessionLocked();
31511
+ if (locked === true) {
31512
+ throw new Error(
31513
+ "the screen is locked, so the input would go to the lock screen instead of your app. For unattended scheduled work, set the machine to not require a password immediately after display sleep."
31514
+ );
31515
+ }
31198
31516
  if (typeof a.x === "number" && typeof a.y === "number") {
31199
- await input.click(a.button ?? "left", false, a.x, a.y);
31517
+ const pinnedId = t.displayId;
31518
+ let restoreInput = null;
31519
+ if (pinnedId !== void 0 && pinnedId !== activeDisplay) {
31520
+ const pinned = displays.find((d) => d.id === pinnedId);
31521
+ if (!pinned || pinned.width <= 0 || pinned.height <= 0) {
31522
+ throw new Error(`the display it was scheduled on ([${pinnedId}]) is no longer connected`);
31523
+ }
31524
+ input.setActiveDisplay({
31525
+ originX: pinned.originX,
31526
+ originY: pinned.originY,
31527
+ width: pinned.width,
31528
+ height: pinned.height
31529
+ });
31530
+ restoreInput = () => {
31531
+ const cur = displays.find((d) => d.id === activeDisplay);
31532
+ input.setActiveDisplay(
31533
+ cur && cur.width > 0 && cur.height > 0 ? { originX: cur.originX, originY: cur.originY, width: cur.width, height: cur.height } : null
31534
+ );
31535
+ };
31536
+ }
31537
+ try {
31538
+ await input.click(a.button ?? "left", false, a.x, a.y);
31539
+ } finally {
31540
+ restoreInput?.();
31541
+ }
31200
31542
  if (a.kind === "key" || a.kind === "text") await delay(250);
31201
31543
  }
31202
31544
  if (a.kind === "key" && a.key) await input.keyTap(a.key);
@@ -31205,8 +31547,8 @@ async function startAgent() {
31205
31547
  const message = error.message ?? String(error);
31206
31548
  log.warn("timer action failed:", message);
31207
31549
  hub.emit({
31208
- title: t.label || "WhipDesk timer",
31209
- body: `Timer reached zero, but its scheduled action failed: ${message.slice(0, 120)}`,
31550
+ title: t.label || "Scheduled work",
31551
+ body: `Time's up, but the scheduled action failed: ${message.slice(0, 120)}`,
31210
31552
  level: "error",
31211
31553
  source: "timer"
31212
31554
  });
@@ -31214,12 +31556,29 @@ async function startAgent() {
31214
31556
  }
31215
31557
  }
31216
31558
  hub.emit({
31217
- title: t.label || "WhipDesk timer",
31218
- body: a ? "Timer reached zero \u2014 scheduled action sent." : "Timer reached zero.",
31559
+ title: t.label || "Scheduled work",
31560
+ body: a ? "Time's up \u2014 scheduled action done." : "Time's up.",
31219
31561
  level: "success",
31220
31562
  source: "timer"
31221
31563
  });
31222
31564
  };
31565
+ for (const t of loadTimers(config.stateDir)) {
31566
+ if (t.fireAtMs <= Date.now()) {
31567
+ hub.emit({
31568
+ title: t.label || "Scheduled work",
31569
+ body: "This came due while WhipDesk was not running \u2014 its action was NOT performed.",
31570
+ level: "error",
31571
+ source: "timer"
31572
+ });
31573
+ } else {
31574
+ timers.set(t.id, t);
31575
+ }
31576
+ }
31577
+ persistTimers();
31578
+ if (timers.size > 0) {
31579
+ ensureTimerTicker();
31580
+ log.info(`restored ${timers.size} pending timer${timers.size === 1 ? "" : "s"} from disk`);
31581
+ }
31223
31582
  const captureOnce = async () => {
31224
31583
  if (regionWatcher.count === 0) return "idle";
31225
31584
  try {
@@ -31279,6 +31638,7 @@ async function startAgent() {
31279
31638
  displays,
31280
31639
  videoAvailable,
31281
31640
  video,
31641
+ hdrActive: false,
31282
31642
  get activeDisplay() {
31283
31643
  return activeDisplay;
31284
31644
  },
@@ -31286,6 +31646,7 @@ async function startAgent() {
31286
31646
  addController(controller) {
31287
31647
  controllers.add(controller);
31288
31648
  log.info(`controller connected (${controllers.size} active)`);
31649
+ void windowsHdrState().then((s) => ctx.hdrActive = s?.active === true);
31289
31650
  displayWake.setActive(true);
31290
31651
  for (const c of controllers) c.send({ type: "presence", watchers: controllers.size });
31291
31652
  controller.send({ type: "watchers", regions: regionWatcher.list() });
@@ -31384,13 +31745,15 @@ async function startAgent() {
31384
31745
  addTimer(msg) {
31385
31746
  const fireInMs = Math.max(1e3, Math.min(msg.fireInMs, 7 * 24 * 36e5));
31386
31747
  const fireAtMs = Date.now() + fireInMs;
31387
- timers.set(msg.id, { id: msg.id, label: msg.label, fireAtMs, action: msg.action });
31748
+ timers.set(msg.id, { id: msg.id, label: msg.label, fireAtMs, action: msg.action, displayId: activeDisplay });
31749
+ persistTimers();
31388
31750
  ensureTimerTicker();
31389
31751
  broadcastTimers();
31390
31752
  log.info(`timer "${msg.label}" in ${Math.round(fireInMs / 1e3)}s${msg.action ? ` (+${msg.action.kind})` : ""}`);
31391
31753
  },
31392
31754
  removeTimer(id) {
31393
31755
  if (!timers.delete(id)) return;
31756
+ persistTimers();
31394
31757
  broadcastTimers();
31395
31758
  },
31396
31759
  listTimers() {
@@ -31474,9 +31837,9 @@ async function startAgent() {
31474
31837
  }
31475
31838
  res.json({ ok: true });
31476
31839
  });
31477
- if ((0, import_node_fs9.existsSync)(webDist)) {
31840
+ if ((0, import_node_fs10.existsSync)(webDist)) {
31478
31841
  app.use(import_express.default.static(webDist));
31479
- app.get("/*splat", (_req, res) => res.sendFile((0, import_node_path11.join)(webDist, "index.html")));
31842
+ app.get("/*splat", (_req, res) => res.sendFile((0, import_node_path12.join)(webDist, "index.html")));
31480
31843
  } else {
31481
31844
  log.warn(`mobile-web build not found at ${webDist} \u2014 run "npm run build:web"`);
31482
31845
  app.get(
@@ -31499,6 +31862,14 @@ async function startAgent() {
31499
31862
  }
31500
31863
  log.info(`listening on :${config.port} (capture: ${capturer.backend}, input: ${input.name})`);
31501
31864
  log.info(pin.isSet ? "connection PIN: required" : "connection PIN: NONE (set one in a terminal)");
31865
+ void windowsHdrState().then((s) => {
31866
+ ctx.hdrActive = s?.active === true;
31867
+ if (s?.active) {
31868
+ log.warn(
31869
+ "HDR is ON on this desktop \u2014 the remote stream is tone-mapped to SDR and colors may look washed out. Turn HDR off if it bothers you."
31870
+ );
31871
+ }
31872
+ });
31502
31873
  return { server, config, presence, keepAwake, ctx };
31503
31874
  }
31504
31875
  function isLevel(value) {
@@ -31562,8 +31933,8 @@ async function main() {
31562
31933
  registry = await startDeviceRegistry({
31563
31934
  rtdb,
31564
31935
  identity,
31565
- name: (0, import_node_os8.hostname)(),
31566
- platform: (0, import_node_os8.platform)(),
31936
+ name: (0, import_node_os9.hostname)(),
31937
+ platform: (0, import_node_os9.platform)(),
31567
31938
  version: AGENT_VERSION,
31568
31939
  getLan: () => ({ ip: getLanIp(), port: config.port, token: config.token })
31569
31940
  });