@mega-yfue/eufy-sdk 0.2.0-beta.14 → 0.2.0-beta.16

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.js CHANGED
@@ -153,7 +153,7 @@ function tokenNotExpired(tokenExpiresAt, skewSec = 300) {
153
153
  return true;
154
154
  }
155
155
  function isSessionValid(s, skewSec = 300) {
156
- if (!s?.authToken || !s.shareKey || !s.keyIdent)
156
+ if (!s?.authToken || !s.shareKey || !s.keyIdent || !s.accountUserId)
157
157
  return false;
158
158
  return tokenNotExpired(s.tokenExpiresAt, skewSec);
159
159
  }
@@ -217,18 +217,33 @@ BLOCKED_ADDRESSES.addAddress("::1", "ipv6");
217
217
  BLOCKED_ADDRESSES.addSubnet("fc00::", 7, "ipv6");
218
218
  BLOCKED_ADDRESSES.addSubnet("fe80::", 10, "ipv6");
219
219
  var MediaDownloadError = class extends Error {
220
+ mediaFailure;
221
+ status;
222
+ constructor(message, mediaFailure, status) {
223
+ super(message);
224
+ this.name = "MediaDownloadError";
225
+ this.mediaFailure = mediaFailure;
226
+ if (status !== void 0)
227
+ this.status = status;
228
+ }
220
229
  };
230
+ function mediaFailureError(message, reason, cause) {
231
+ const error = new MediaDownloadError(message, reason);
232
+ if (cause !== void 0)
233
+ error.cause = cause;
234
+ return error;
235
+ }
221
236
  var MediaDownloadAuthenticationError = class extends Error {
222
237
  };
223
- function allowedMediaUrl(value, hostPattern) {
238
+ function allowedMediaUrl(value, hostPattern, reason) {
224
239
  let url;
225
240
  try {
226
241
  url = new URL(value);
227
242
  } catch {
228
- throw new MediaDownloadError("Media download rejected");
243
+ throw new MediaDownloadError("Media download rejected", reason);
229
244
  }
230
245
  if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.port !== "" || isIP(url.hostname) !== 0 || !hostPattern.test(url.hostname)) {
231
- throw new MediaDownloadError("Media download rejected");
246
+ throw new MediaDownloadError("Media download rejected", reason);
232
247
  }
233
248
  return url;
234
249
  }
@@ -243,7 +258,7 @@ async function assertPublicResolution(url, resolveHost2, signal) {
243
258
  try {
244
259
  addresses = await Promise.race([resolveHost2(url.hostname), aborted]);
245
260
  } catch {
246
- throw new MediaDownloadError("Media download rejected");
261
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
247
262
  } finally {
248
263
  signal.removeEventListener("abort", onAbort);
249
264
  }
@@ -251,14 +266,14 @@ async function assertPublicResolution(url, resolveHost2, signal) {
251
266
  const type = family === 6 ? "ipv6" : family === 4 ? "ipv4" : void 0;
252
267
  return type === void 0 || BLOCKED_ADDRESSES.check(address, type);
253
268
  })) {
254
- throw new MediaDownloadError("Media download rejected");
269
+ throw new MediaDownloadError("Media download rejected", "address-not-public");
255
270
  }
256
271
  }
257
272
  var resolveHost = (hostname) => lookup(hostname, { all: true, verbatim: true });
258
273
  async function readMediaBody(response) {
259
274
  const contentLength = response.headers.get("content-length");
260
275
  if (contentLength && /^\d+$/.test(contentLength) && BigInt(contentLength) > BigInt(MEDIA_DOWNLOAD_MAX_BYTES)) {
261
- throw new MediaDownloadError("Media download rejected");
276
+ throw new MediaDownloadError("Media download rejected", "too-large");
262
277
  }
263
278
  if (!response.body)
264
279
  return Buffer.alloc(0);
@@ -272,14 +287,14 @@ async function readMediaBody(response) {
272
287
  bytes2 += value.byteLength;
273
288
  if (bytes2 > MEDIA_DOWNLOAD_MAX_BYTES) {
274
289
  void reader2.cancel().catch(() => void 0);
275
- throw new MediaDownloadError("Media download rejected");
290
+ throw new MediaDownloadError("Media download rejected", "too-large");
276
291
  }
277
292
  chunks.push(value);
278
293
  }
279
294
  return Buffer.concat(chunks, bytes2);
280
295
  }
281
296
  async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetch, resolver = resolveHost) {
282
- const original = allowedMediaUrl(url, EUFY_MEDIA_HOST);
297
+ const original = allowedMediaUrl(url, EUFY_MEDIA_HOST, "url-not-allowed");
283
298
  const controller = new AbortController();
284
299
  const timeout = setTimeout(() => controller.abort(), MEDIA_DOWNLOAD_TIMEOUT_MS);
285
300
  try {
@@ -292,31 +307,33 @@ async function downloadMediaResource(url, authenticatedHeaders, fetchImpl = fetc
292
307
  if (originalResponse.status >= 300 && originalResponse.status < 400) {
293
308
  const location = originalResponse.headers.get("location");
294
309
  if (!location)
295
- throw new MediaDownloadError("Media download rejected");
296
- const target = allowedMediaUrl(location, EUFY_OBJECT_HOST);
310
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
311
+ const target = allowedMediaUrl(location, EUFY_OBJECT_HOST, "redirect-not-allowed");
297
312
  await assertPublicResolution(target, resolver, controller.signal);
298
313
  const redirectedResponse = await fetchImpl(target, { redirect: "manual", signal: controller.signal });
299
314
  if (redirectedResponse.status >= 300 && redirectedResponse.status < 400) {
300
- throw new MediaDownloadError("Media download rejected");
315
+ throw new MediaDownloadError("Media download rejected", "redirect-not-allowed");
316
+ }
317
+ if (redirectedResponse.status !== 200) {
318
+ throw new MediaDownloadError("Media download failed", "http-status", redirectedResponse.status);
301
319
  }
302
- if (redirectedResponse.status !== 200)
303
- throw new MediaDownloadError("Media download failed");
304
320
  return await readMediaBody(redirectedResponse);
305
321
  }
306
322
  if (originalResponse.status === 401 || originalResponse.status === 403) {
307
323
  throw new MediaDownloadAuthenticationError("Media authentication failed");
308
324
  }
309
- if (originalResponse.status !== 200)
310
- throw new MediaDownloadError("Media download failed");
325
+ if (originalResponse.status !== 200) {
326
+ throw new MediaDownloadError("Media download failed", "http-status", originalResponse.status);
327
+ }
311
328
  return await readMediaBody(originalResponse);
312
329
  } catch (error) {
313
330
  if (controller.signal.aborted)
314
- throw new MediaDownloadError("Media download timed out");
331
+ throw new MediaDownloadError("Media download timed out", "timeout");
315
332
  if (error instanceof MediaDownloadAuthenticationError)
316
333
  throw error;
317
334
  if (error instanceof MediaDownloadError)
318
335
  throw error;
319
- throw new MediaDownloadError("Media download failed");
336
+ throw new MediaDownloadError("Media download failed", "network");
320
337
  } finally {
321
338
  clearTimeout(timeout);
322
339
  }
@@ -375,8 +392,228 @@ function randomUserAgent(seed, model) {
375
392
  // dist/transport/http/decodeImageV1.js
376
393
  import { createDecipheriv as createDecipheriv2, createHash as createHash2 } from "node:crypto";
377
394
 
395
+ // dist/transport/http/jpeg-scan.js
396
+ var DHT = 196;
397
+ var SOS = 218;
398
+ var DRI = 221;
399
+ var RST_FIRST = 208;
400
+ var RST_LAST = 215;
401
+ var LUMA_SAMPLING = { 0: [1, 1], 1: [2, 1], 2: [2, 2] };
402
+ var ScanEnd = class extends Error {
403
+ };
404
+ function readHuffmanTables(segment, into) {
405
+ const end = 2 + (segment[2] << 8 | segment[3]);
406
+ let at = 4;
407
+ while (at < end) {
408
+ const id = segment[at];
409
+ const counts = segment.subarray(at + 1, at + 17);
410
+ let total = 0;
411
+ for (const count2 of counts)
412
+ total += count2;
413
+ const values = segment.slice(at + 17, at + 17 + total);
414
+ const minCode = new Int32Array(17);
415
+ const maxCode = new Int32Array(17).fill(-1);
416
+ const valPtr = new Int32Array(17);
417
+ let code = 0;
418
+ let index = 0;
419
+ for (let length = 1; length <= 16; length++) {
420
+ const count2 = counts[length - 1];
421
+ if (count2 > 0) {
422
+ valPtr[length] = index;
423
+ minCode[length] = code;
424
+ code += count2;
425
+ index += count2;
426
+ maxCode[length] = code - 1;
427
+ }
428
+ code <<= 1;
429
+ }
430
+ into.set(id, { minCode, maxCode, valPtr, values });
431
+ at += 17 + total;
432
+ }
433
+ }
434
+ var BitReader = class {
435
+ /** Byte offset of the next byte to read. */
436
+ position;
437
+ bits = 0;
438
+ count = 0;
439
+ /** Set when the reader stepped over a restart marker — the caller resets its DC predictors. */
440
+ restarted = false;
441
+ data;
442
+ constructor(data, start) {
443
+ this.data = data;
444
+ this.position = start;
445
+ }
446
+ /** The offset the last whole byte ended at — what "how much of the scan did this consume" reads. */
447
+ get consumed() {
448
+ return this.position;
449
+ }
450
+ readBit() {
451
+ if (this.count === 0) {
452
+ if (this.position >= this.data.length)
453
+ throw new ScanEnd("out of data");
454
+ let byte = this.data[this.position++];
455
+ if (byte === 255) {
456
+ const next = this.data[this.position];
457
+ if (next === 0) {
458
+ this.position++;
459
+ } else if (next !== void 0 && next >= RST_FIRST && next <= RST_LAST) {
460
+ this.position++;
461
+ this.restarted = true;
462
+ if (this.position >= this.data.length)
463
+ throw new ScanEnd("out of data");
464
+ byte = this.data[this.position++];
465
+ if (byte === 255)
466
+ throw new ScanEnd("marker after restart");
467
+ } else {
468
+ this.position--;
469
+ throw new ScanEnd("marker");
470
+ }
471
+ }
472
+ this.bits = byte;
473
+ this.count = 8;
474
+ }
475
+ this.count--;
476
+ return this.bits >> this.count & 1;
477
+ }
478
+ /** Read `length` bits, most significant first. */
479
+ readBits(length) {
480
+ let value = 0;
481
+ for (let i = 0; i < length; i++)
482
+ value = value << 1 | this.readBit();
483
+ return value;
484
+ }
485
+ /** Drop the current byte's remaining bits — what a restart interval boundary does. */
486
+ align() {
487
+ this.count = 0;
488
+ }
489
+ };
490
+ function decodeHuffman(reader2, table) {
491
+ let code = reader2.readBit();
492
+ for (let length = 1; length <= 16; length++) {
493
+ if (table.maxCode[length] >= 0 && code <= table.maxCode[length]) {
494
+ return table.values[table.valPtr[length] + code - table.minCode[length]];
495
+ }
496
+ code = code << 1 | reader2.readBit();
497
+ }
498
+ throw new ScanEnd("no huffman code of any length");
499
+ }
500
+ function extend(value, length) {
501
+ return value < 1 << length - 1 ? value - (1 << length) + 1 : value;
502
+ }
503
+ function decodeBlock(reader2, component) {
504
+ const dcLength = decodeHuffman(reader2, component.dc);
505
+ if (dcLength > 16)
506
+ throw new ScanEnd("dc magnitude out of range");
507
+ component.pred += dcLength === 0 ? 0 : extend(reader2.readBits(dcLength), dcLength);
508
+ let k = 1;
509
+ while (k < 64) {
510
+ const rs = decodeHuffman(reader2, component.ac);
511
+ const size = rs & 15;
512
+ const run = rs >> 4;
513
+ if (size === 0) {
514
+ if (run !== 15)
515
+ break;
516
+ k += 16;
517
+ } else {
518
+ k += run;
519
+ if (k > 63)
520
+ throw new ScanEnd("ac coefficient index out of range");
521
+ reader2.readBits(size);
522
+ k++;
523
+ }
524
+ }
525
+ return component.pred;
526
+ }
527
+ function scanEntropy(tail, subsampling, extraTables) {
528
+ const tables = /* @__PURE__ */ new Map();
529
+ for (const segment of extraTables)
530
+ readHuffmanTables(segment, tables);
531
+ let at = 0;
532
+ let restartInterval = 0;
533
+ let scanStart = -1;
534
+ let assignments = [];
535
+ while (at + 3 < tail.length) {
536
+ if (tail[at] !== 255)
537
+ return null;
538
+ const marker = tail[at + 1];
539
+ const length = tail[at + 2] << 8 | tail[at + 3];
540
+ if (marker === DHT) {
541
+ readHuffmanTables(tail.subarray(at, at + 2 + length), tables);
542
+ } else if (marker === DRI) {
543
+ restartInterval = tail[at + 4] << 8 | tail[at + 5];
544
+ } else if (marker === SOS) {
545
+ const count2 = tail[at + 4];
546
+ assignments = [];
547
+ for (let i = 0; i < count2; i++) {
548
+ const id = tail[at + 5 + i * 2];
549
+ const tableByte = tail[at + 6 + i * 2];
550
+ assignments.push({ id, dc: tableByte >> 4, ac: tableByte & 15 });
551
+ }
552
+ scanStart = at + 2 + length;
553
+ break;
554
+ }
555
+ at += 2 + length;
556
+ }
557
+ if (scanStart < 0 || assignments.length !== 3)
558
+ return null;
559
+ const [lumaH, lumaV] = LUMA_SAMPLING[subsampling] ?? LUMA_SAMPLING[0];
560
+ const components = [];
561
+ for (const [index, assignment] of assignments.entries()) {
562
+ const dc = tables.get(assignment.dc);
563
+ const ac = tables.get(16 | assignment.ac);
564
+ if (!dc || !ac)
565
+ return null;
566
+ components.push({ h: index === 0 ? lumaH : 1, v: index === 0 ? lumaV : 1, dc, ac, pred: 0 });
567
+ }
568
+ const reader2 = new BitReader(tail, scanStart);
569
+ let luma = new Int32Array(1024);
570
+ const lumaBlocks = lumaH * lumaV;
571
+ let mcus = 0;
572
+ let consumed = reader2.consumed;
573
+ let complete = false;
574
+ try {
575
+ for (; ; ) {
576
+ if (restartInterval > 0 && mcus > 0 && mcus % restartInterval === 0) {
577
+ reader2.align();
578
+ for (const component of components)
579
+ component.pred = 0;
580
+ }
581
+ if (reader2.restarted) {
582
+ reader2.restarted = false;
583
+ for (const component of components)
584
+ component.pred = 0;
585
+ }
586
+ let lumaSum = 0;
587
+ for (const component of components) {
588
+ for (let block = 0; block < component.h * component.v; block++) {
589
+ const dc = decodeBlock(reader2, component);
590
+ if (component === components[0])
591
+ lumaSum += dc;
592
+ }
593
+ }
594
+ if (mcus === luma.length) {
595
+ const grown = new Int32Array(luma.length * 2);
596
+ grown.set(luma);
597
+ luma = grown;
598
+ }
599
+ luma[mcus] = Math.round(lumaSum / lumaBlocks);
600
+ mcus++;
601
+ consumed = reader2.consumed;
602
+ if (consumed >= tail.length) {
603
+ complete = true;
604
+ break;
605
+ }
606
+ }
607
+ } catch (error) {
608
+ if (!(error instanceof ScanEnd))
609
+ throw error;
610
+ complete = tail.length - consumed <= 2;
611
+ }
612
+ return { mcus, luma: luma.subarray(0, mcus), complete };
613
+ }
614
+
378
615
  // dist/transport/http/decodeImageV2.js
379
- import { decode as jpegDecode, encode as jpegEncode } from "jpeg-js";
616
+ import { decode as jpegDecode } from "jpeg-js";
380
617
  var V2_PREFIX = "v2_eufysecurity:";
381
618
  var DC_CHROMA = Buffer.from([255, 196, 0, 31, 1]);
382
619
  var ZIGZAG = [
@@ -582,35 +819,6 @@ var DHT_AC_LUMA = Buffer.from("ffc400b5100002010303020403050504040000017d0102030
582
819
  var SOI = Buffer.from([255, 216]);
583
820
  var APP0 = Buffer.from("ffe000104a46494600010100000100010000", "hex");
584
821
  var MCU = { 0: [8, 8], 1: [16, 8], 2: [16, 16] };
585
- var LADDER = [
586
- [160, 90],
587
- [240, 135],
588
- [256, 144],
589
- [320, 180],
590
- [384, 216],
591
- [400, 225],
592
- [480, 270],
593
- [512, 288],
594
- [576, 324],
595
- [640, 360],
596
- [704, 396],
597
- [768, 432],
598
- [848, 480],
599
- [960, 540],
600
- [1024, 576],
601
- [1280, 720],
602
- [1600, 900],
603
- [1920, 1080],
604
- [176, 144],
605
- [320, 240],
606
- [352, 288],
607
- [480, 360],
608
- [640, 480],
609
- [800, 600],
610
- [1024, 768],
611
- [256, 480],
612
- [320, 384]
613
- ];
614
822
  function scaleQuant(base, quality) {
615
823
  const factor = quality < 50 ? Math.floor(5e3 / quality) : 200 - quality * 2;
616
824
  return base.map((v) => Math.min(255, Math.max(1, Math.floor((v * factor + 50) / 100))));
@@ -647,7 +855,7 @@ function sofSegment(width, height, subsampling) {
647
855
  1
648
856
  ]);
649
857
  }
650
- function buildHeader(width, height, subsampling, quality = 85) {
858
+ function buildHeader(width, height, subsampling, quality) {
651
859
  return Buffer.concat([
652
860
  SOI,
653
861
  APP0,
@@ -658,99 +866,107 @@ function buildHeader(width, height, subsampling, quality = 85) {
658
866
  DHT_AC_LUMA
659
867
  ]);
660
868
  }
661
- function decodeCandidate(tail, width, height, subsampling) {
869
+ var PROBE_MEMORY_MB = 24;
870
+ function decodeSpliced(header, tail) {
662
871
  try {
663
- const jpeg = Buffer.concat([buildHeader(width, height, subsampling), tail]);
664
- return jpegDecode(jpeg, { useTArray: true, maxMemoryUsageInMB: 128 });
872
+ return jpegDecode(Buffer.concat([header, tail]), { useTArray: true, maxMemoryUsageInMB: PROBE_MEMORY_MB });
665
873
  } catch {
666
874
  return null;
667
875
  }
668
876
  }
669
- function colorSpread(img) {
670
- const { width, height, data } = img;
671
- let sum = 0;
672
- let count2 = 0;
673
- for (let i = 0; i < width * height; i += 37) {
674
- const p = i * 4;
675
- const r = data[p];
676
- const g = data[p + 1];
677
- const b = data[p + 2];
678
- sum += Math.abs(r - g) + Math.abs(g - b) + Math.abs(b - r);
679
- count2++;
680
- }
681
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
682
- }
683
- function rowShear(img, rows) {
684
- const { width, data } = img;
685
- let sum = 0;
686
- let count2 = 0;
687
- for (let y = 1; y < rows; y++) {
688
- for (let x = 0; x < width; x += 4) {
689
- sum += Math.abs(data[(y * width + x) * 4] - data[((y - 1) * width + x) * 4]);
690
- count2++;
691
- }
877
+ var REFERENCE_QUALITY = 85;
878
+ var MAX_STRETCH = 8;
879
+ var CUTOFF_PERCENT = 0.5;
880
+ function trimmedRange(data, channel, total, cutoff) {
881
+ const hist = new Array(256).fill(0);
882
+ for (let i = 0; i < total; i++)
883
+ hist[data[i * 4 + channel]]++;
884
+ let remaining = Math.floor(total * cutoff / 100);
885
+ let lo = 0;
886
+ while (lo < 255 && remaining > 0) {
887
+ if (remaining < hist[lo])
888
+ break;
889
+ remaining -= hist[lo];
890
+ lo++;
692
891
  }
693
- return count2 ? sum / count2 : Number.POSITIVE_INFINITY;
892
+ remaining = Math.floor(total * cutoff / 100);
893
+ let hi = 255;
894
+ while (hi > 0 && remaining > 0) {
895
+ if (remaining < hist[hi])
896
+ break;
897
+ remaining -= hist[hi];
898
+ hi--;
899
+ }
900
+ return { lo, hi };
694
901
  }
695
- function autoContrast(data, width, height, cutoff = 0.5) {
696
- const total = width * height;
902
+ function contrastScale(img, cutoff = CUTOFF_PERCENT) {
903
+ const total = img.width * img.height;
904
+ if (total === 0)
905
+ return 1;
906
+ let stretch = Number.POSITIVE_INFINITY;
697
907
  for (let channel = 0; channel < 3; channel++) {
698
- const hist = new Array(256).fill(0);
699
- for (let i = 0; i < total; i++)
700
- hist[data[i * 4 + channel]]++;
701
- let remaining = Math.floor(total * cutoff / 100);
702
- for (let bin = 0; bin < 256 && remaining > 0; bin++) {
703
- if (remaining > hist[bin]) {
704
- remaining -= hist[bin];
705
- hist[bin] = 0;
706
- } else {
707
- hist[bin] -= remaining;
708
- remaining = 0;
709
- }
710
- }
711
- remaining = Math.floor(total * cutoff / 100);
712
- for (let bin = 255; bin >= 0 && remaining > 0; bin--) {
713
- if (remaining > hist[bin]) {
714
- remaining -= hist[bin];
715
- hist[bin] = 0;
716
- } else {
717
- hist[bin] -= remaining;
718
- remaining = 0;
719
- }
720
- }
721
- let lo = 0;
722
- while (lo < 256 && hist[lo] === 0)
723
- lo++;
724
- let hi = 255;
725
- while (hi >= 0 && hist[hi] === 0)
726
- hi--;
908
+ const { lo, hi } = trimmedRange(img.data, channel, total, cutoff);
727
909
  if (hi <= lo)
728
910
  continue;
729
- const scale = 255 / (hi - lo);
730
- const offset = -lo * scale;
731
- const lut = new Uint8Array(256);
732
- for (let v = 0; v < 256; v++)
733
- lut[v] = Math.min(255, Math.max(0, Math.trunc(v * scale + offset)));
734
- for (let i = 0; i < total; i++)
735
- data[i * 4 + channel] = lut[data[i * 4 + channel]];
736
- }
737
- }
738
- function maxHeight(tail, width, subsampling, seed) {
739
- const mcuHeight = MCU[subsampling][1];
740
- let height = Math.max(mcuHeight, Math.round(seed / mcuHeight) * mcuHeight);
741
- if (!decodeCandidate(tail, width, height, subsampling)) {
742
- while (height > mcuHeight && !decodeCandidate(tail, width, height, subsampling))
743
- height -= mcuHeight;
744
- } else {
745
- while (height < 4096 && decodeCandidate(tail, width, height + mcuHeight, subsampling))
746
- height += mcuHeight;
747
- }
748
- return height;
749
- }
750
- function ladderByMcuCount(subsampling) {
751
- const [mcuWidth, mcuHeight] = MCU[subsampling];
752
- const mcus = ([width, height]) => width / mcuWidth * (height / mcuHeight);
753
- return [...LADDER].sort((a, b) => mcus(a) - mcus(b));
911
+ if (lo < 128)
912
+ stretch = Math.min(stretch, 128 / (128 - lo));
913
+ if (hi > 128)
914
+ stretch = Math.min(stretch, 127 / (hi - 128));
915
+ }
916
+ if (!Number.isFinite(stretch))
917
+ return 1;
918
+ return Math.min(MAX_STRETCH, Math.max(1, stretch));
919
+ }
920
+ function qualityForStretch(stretch) {
921
+ const referenceFactor = REFERENCE_QUALITY < 50 ? Math.floor(5e3 / REFERENCE_QUALITY) : 200 - REFERENCE_QUALITY * 2;
922
+ const factor = referenceFactor * stretch;
923
+ const quality = factor <= 100 ? (200 - factor) / 2 : 5e3 / factor;
924
+ return Math.min(99, Math.max(1, Math.round(quality)));
925
+ }
926
+ var MAX_EDGE = 4096;
927
+ var MIN_ASPECT = 0.4;
928
+ var MAX_ASPECT = 4;
929
+ function mcuRowShear(luma, mcuWidth, mcuHeight) {
930
+ if (mcuHeight < 2)
931
+ return Number.POSITIVE_INFINITY;
932
+ let sum = 0;
933
+ for (let y = 1; y < mcuHeight; y++) {
934
+ for (let x = 0; x < mcuWidth; x++)
935
+ sum += Math.abs(luma[y * mcuWidth + x] - luma[(y - 1) * mcuWidth + x]);
936
+ }
937
+ return sum / (mcuWidth * (mcuHeight - 1));
938
+ }
939
+ function bestGeometry(luma, mcus, subsampling) {
940
+ const [mcuPixelWidth, mcuPixelHeight] = MCU[subsampling];
941
+ let best = null;
942
+ for (let mcuWidth = 1; mcuWidth <= mcus; mcuWidth++) {
943
+ if (mcus % mcuWidth !== 0)
944
+ continue;
945
+ const mcuHeight = mcus / mcuWidth;
946
+ const width = mcuWidth * mcuPixelWidth;
947
+ const height = mcuHeight * mcuPixelHeight;
948
+ if (width > MAX_EDGE || height > MAX_EDGE)
949
+ continue;
950
+ const aspect = width / height;
951
+ if (aspect < MIN_ASPECT || aspect > MAX_ASPECT)
952
+ continue;
953
+ const shear = mcuRowShear(luma, mcuWidth, mcuHeight);
954
+ if (!best || shear < best.shear)
955
+ best = { subsampling, width, height, shear };
956
+ }
957
+ return best;
958
+ }
959
+ function findGeometry(tail) {
960
+ let best = null;
961
+ for (const subsampling of [0, 1, 2]) {
962
+ const scan = scanEntropy(tail, subsampling, [DHT_DC_LUMA, DHT_AC_LUMA]);
963
+ if (!scan?.complete || scan.mcus < 1)
964
+ continue;
965
+ const geometry = bestGeometry(scan.luma, scan.mcus, subsampling);
966
+ if (geometry && (!best || geometry.shear < best.shear))
967
+ best = geometry;
968
+ }
969
+ return best;
754
970
  }
755
971
  function isV2Image(data) {
756
972
  return data.length >= V2_PREFIX.length && data.subarray(0, V2_PREFIX.length).toString("latin1") === V2_PREFIX;
@@ -762,53 +978,17 @@ function decodeImageV2(data) {
762
978
  if (cut < 0)
763
979
  return null;
764
980
  const tail = data.subarray(cut);
765
- let best = null;
766
- for (const subsampling2 of [2, 0, 1]) {
767
- let filled = null;
768
- for (const [width2, height2] of ladderByMcuCount(subsampling2)) {
769
- const img2 = decodeCandidate(tail, width2, height2, subsampling2);
770
- if (!img2)
771
- break;
772
- if (!filled || width2 * height2 > filled.width * filled.height)
773
- filled = { width: width2, height: height2, img: img2 };
774
- }
775
- if (!filled)
776
- continue;
777
- const spread = colorSpread(filled.img);
778
- if (!best || spread < best.spread)
779
- best = { spread, subsampling: subsampling2, width: filled.width, height: filled.height };
780
- }
781
- if (!best)
981
+ const geometry = findGeometry(tail);
982
+ if (!geometry)
782
983
  return null;
783
- const { subsampling, width: coarseWidth } = best;
784
- const [mcuWidth, mcuHeight] = MCU[subsampling];
785
- const coarseHeight = maxHeight(tail, coarseWidth, subsampling, best.height);
786
- const totalMcus = coarseWidth / mcuWidth * (coarseHeight / mcuHeight);
787
- let width = coarseWidth;
788
- let height = coarseHeight;
789
- let bestShear = Number.POSITIVE_INFINITY;
790
- const lowWidth = Math.max(mcuWidth * 4, Math.round(coarseWidth * 0.75 / mcuWidth) * mcuWidth);
791
- const highWidth = Math.round(coarseWidth * 1.25 / mcuWidth) * mcuWidth;
792
- for (let candidateWidth = lowWidth; candidateWidth <= highWidth; candidateWidth += mcuWidth) {
793
- const candidateHeight = Math.floor(totalMcus / (candidateWidth / mcuWidth)) * mcuHeight;
794
- if (candidateHeight < mcuHeight)
795
- continue;
796
- const img2 = decodeCandidate(tail, candidateWidth, candidateHeight, subsampling);
797
- if (!img2)
798
- continue;
799
- const shear = rowShear(img2, candidateHeight);
800
- if (shear < bestShear) {
801
- bestShear = shear;
802
- width = candidateWidth;
803
- height = candidateHeight;
804
- }
805
- }
806
- height = maxHeight(tail, width, subsampling, height);
807
- const img = decodeCandidate(tail, width, height, subsampling);
808
- if (!img)
984
+ const { width, height, subsampling } = geometry;
985
+ const reference = buildHeader(width, height, subsampling, REFERENCE_QUALITY);
986
+ const probe = decodeSpliced(reference, tail);
987
+ if (!probe)
809
988
  return null;
810
- autoContrast(img.data, img.width, img.height);
811
- return Buffer.from(jpegEncode({ data: img.data, width: img.width, height: img.height }, 90).data);
989
+ const stretch = contrastScale(probe);
990
+ const header = stretch > 1.01 ? buildHeader(width, height, subsampling, qualityForStretch(stretch)) : reference;
991
+ return Buffer.concat([header, tail]);
812
992
  }
813
993
 
814
994
  // dist/transport/http/decodeImageV1.js
@@ -955,7 +1135,7 @@ var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
955
1135
  var REAUTH_STABLE_MS = 10 * 6e4;
956
1136
  var CONTENDED_SESSION_HINT = "another client may be signed in with the same account and device identity, and each login displaces the other's session; give each client its own openudid";
957
1137
  function tokenRejected(code, msg) {
958
- return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid.*token|token.*(expired|error|not exist)|kicked|(?:token|session).*does not exist|unauthor/i.test(msg ?? "");
1138
+ return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
959
1139
  }
960
1140
  function withoutTokenEcho(text2) {
961
1141
  return text2.replace(/token\s*[=:]\s*"?[A-Za-z0-9._-]{8,}"?/gi, "token = <redacted>");
@@ -967,6 +1147,13 @@ var MegaHttpClient = class {
967
1147
  sessionKey;
968
1148
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
969
1149
  sessionKeys = /* @__PURE__ */ new Map();
1150
+ /**
1151
+ * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
1152
+ * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
1153
+ *
1154
+ * The `gtoken` header is hashed from `accountUserId`: that is the id the gateway recomputes the header
1155
+ * from, rejecting a disagreement with `"gtoken not equal userid error"`.
1156
+ */
970
1157
  auth_;
971
1158
  /** captcha_id of an in-flight challenge, held between login() and solveCaptcha(). */
972
1159
  pendingCaptchaId;
@@ -1032,7 +1219,12 @@ var MegaHttpClient = class {
1032
1219
  if (!isSessionValid(saved) || !saved)
1033
1220
  return void 0;
1034
1221
  this.region = saved.region;
1035
- this.auth_ = { userId: saved.userId, authToken: saved.authToken, geoKey: saved.geoKey };
1222
+ this.auth_ = {
1223
+ userId: saved.userId,
1224
+ accountUserId: saved.accountUserId,
1225
+ authToken: saved.authToken,
1226
+ geoKey: saved.geoKey
1227
+ };
1036
1228
  this.tokenExpiresAt = saved.tokenExpiresAt;
1037
1229
  this.sessionKey = {
1038
1230
  keyIdent: saved.keyIdent,
@@ -1095,7 +1287,16 @@ var MegaHttpClient = class {
1095
1287
  };
1096
1288
  }
1097
1289
  /**
1098
- * The account-credential headers every authed call carries — `x-auth-token` + `gtoken` (`md5(userId)`).
1290
+ * The id `gtoken` is hashed from — the account's own `user_id`, which is what the gateway recomputes the
1291
+ * header from. One place so the two header paths cannot drift on which of the session's ids that is.
1292
+ *
1293
+ * Call only where `auth_` is already established; every header path guards it.
1294
+ */
1295
+ gtokenUserId() {
1296
+ return this.auth_.accountUserId ?? this.auth_.userId;
1297
+ }
1298
+ /**
1299
+ * The account-credential headers every authed call carries — `x-auth-token` + `gtoken`.
1099
1300
  * One place so the signed path, the key-exchange and the bearer path can't drift on what "authed" means.
1100
1301
  */
1101
1302
  authTokenHeaders() {
@@ -1104,7 +1305,7 @@ var MegaHttpClient = class {
1104
1305
  return {
1105
1306
  "x-auth-token": this.auth_.authToken,
1106
1307
  authorization: this.auth_.authToken,
1107
- gtoken: gtoken(this.auth_.userId)
1308
+ gtoken: gtoken(this.gtokenUserId())
1108
1309
  };
1109
1310
  }
1110
1311
  /**
@@ -1408,7 +1609,7 @@ var MegaHttpClient = class {
1408
1609
  try {
1409
1610
  return await downloadMediaResource(url, {
1410
1611
  "x-auth-token": this.auth_.authToken,
1411
- gtoken: gtoken(this.auth_.userId),
1612
+ gtoken: gtoken(this.gtokenUserId()),
1412
1613
  "app-name": "eufy_mega",
1413
1614
  "model-type": "PHONE",
1414
1615
  "user-agent": this.mediaUserAgent
@@ -1420,9 +1621,20 @@ var MegaHttpClient = class {
1420
1621
  throw error;
1421
1622
  }
1422
1623
  }
1423
- /** Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available. */
1624
+ /**
1625
+ * Download push image bytes and decrypt a recognized v1 wrapper when its device key input is available.
1626
+ *
1627
+ * A decoder throw is tagged `decode-failed`: to anything downstream, the difference between "the
1628
+ * bytes never arrived" and "the bytes arrived and the wrapper would not decrypt" is the difference
1629
+ * between a network problem and a key problem, and one of them is this SDK's to fix.
1630
+ */
1424
1631
  async downloadImage(url, p2pDid) {
1425
- return normalizePushImage(await this.downloadMedia(url), p2pDid);
1632
+ const data = await this.downloadMedia(url);
1633
+ try {
1634
+ return normalizePushImage(data, p2pDid);
1635
+ } catch (error) {
1636
+ throw mediaFailureError("Push image could not be decoded", "decode-failed", error);
1637
+ }
1426
1638
  }
1427
1639
  /** The security-app data host for this region (face recognition, media, etc.). */
1428
1640
  securityAppHost() {
@@ -1699,17 +1911,19 @@ var MegaHttpClient = class {
1699
1911
  {
1700
1912
  const cap = Object.keys(res).filter((k) => /captcha|answer|picture|image|fa_/i.test(k));
1701
1913
  this.logger.debug("[mega] login resp keys:", Object.keys(res).join(","));
1914
+ this.logger.debug("[mega] ids agree:", res.ap_cloud_user_id === res.user_id);
1702
1915
  if (cap.length)
1703
1916
  this.logger.debug("[mega] captcha/fa:", JSON.stringify(Object.fromEntries(cap.map((k) => [k, res[k]]))));
1704
1917
  }
1705
1918
  const userId = res.ap_cloud_user_id ?? res.user_id ?? res.userId;
1919
+ const accountUserId = res.user_id ?? res.userId;
1706
1920
  const authToken = res.auth_token ?? res.token;
1707
1921
  if (!userId || !authToken)
1708
1922
  throw new Error(`login returned no session: ${JSON.stringify(res).slice(0, 200)}`);
1709
1923
  const faInfo = res.fa_info ?? {};
1710
1924
  const needs2fa = !isVerify && !!faInfo.info;
1711
1925
  if (needs2fa) {
1712
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1926
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1713
1927
  this.pendingCaptchaId = void 0;
1714
1928
  this.pending2fa = true;
1715
1929
  await this.sendVerifyCode(messageType);
@@ -1717,7 +1931,7 @@ var MegaHttpClient = class {
1717
1931
  }
1718
1932
  this.pendingCaptchaId = void 0;
1719
1933
  this.pending2fa = false;
1720
- this.auth_ = { userId, authToken, geoKey: res.geo_key };
1934
+ this.auth_ = { userId, accountUserId, authToken, geoKey: res.geo_key };
1721
1935
  this.tokenExpiresAt = Number(res.token_expires_at ?? 0) || 0;
1722
1936
  this.sessionKey = void 0;
1723
1937
  await this.ensureSessionKey();
@@ -1730,6 +1944,7 @@ var MegaHttpClient = class {
1730
1944
  return;
1731
1945
  this.store.save({
1732
1946
  userId: this.auth_.userId,
1947
+ accountUserId: this.auth_.accountUserId ?? this.auth_.userId,
1733
1948
  authToken: this.auth_.authToken,
1734
1949
  geoKey: this.auth_.geoKey,
1735
1950
  region: this.region,
@@ -12880,7 +13095,7 @@ function lz4BlockDecompress(block, size) {
12880
13095
  const end = block.length;
12881
13096
  let pos = 0;
12882
13097
  let written = 0;
12883
- const extend = (base) => {
13098
+ const extend2 = (base) => {
12884
13099
  let len = base;
12885
13100
  if (base !== 15)
12886
13101
  return len;
@@ -12895,7 +13110,7 @@ function lz4BlockDecompress(block, size) {
12895
13110
  };
12896
13111
  while (pos < end) {
12897
13112
  const token = block[pos++];
12898
- const literals = extend(token >> 4);
13113
+ const literals = extend2(token >> 4);
12899
13114
  if (literals === void 0)
12900
13115
  return void 0;
12901
13116
  if (pos + literals > end || written + literals > size)
@@ -12910,7 +13125,7 @@ function lz4BlockDecompress(block, size) {
12910
13125
  pos += 2;
12911
13126
  if (offset === 0 || offset > written)
12912
13127
  return void 0;
12913
- const match = extend(token & 15);
13128
+ const match = extend2(token & 15);
12914
13129
  if (match === void 0)
12915
13130
  return void 0;
12916
13131
  const length = match + 4;
@@ -23367,8 +23582,21 @@ var FileFcmStore = class {
23367
23582
  }
23368
23583
  };
23369
23584
 
23585
+ // dist/transport/media-failure.js
23586
+ var MEDIA_FAILURE_REASONS = [
23587
+ "url-not-allowed",
23588
+ "address-not-public",
23589
+ "redirect-not-allowed",
23590
+ "http-status",
23591
+ "too-large",
23592
+ "timeout",
23593
+ "network",
23594
+ "decode-failed"
23595
+ ];
23596
+
23370
23597
  // dist/transport/stored-image-cache.js
23371
23598
  var MAX_JPEG_BYTES = 10 * 1024 * 1024;
23599
+ var MAX_REMEMBERED_URLS = 64;
23372
23600
  var DIAGNOSTIC_INTERVAL_MS = 6e4;
23373
23601
  var StoredImageCache = class {
23374
23602
  downloader;
@@ -23385,7 +23613,13 @@ var StoredImageCache = class {
23385
23613
  this.clock = clock;
23386
23614
  this.isLifecycleError = isLifecycleError;
23387
23615
  }
23388
- /** Observe a normalized thumbnail URL and start acquisition eagerly. */
23616
+ /**
23617
+ * Observe a normalized thumbnail URL and start acquisition eagerly.
23618
+ *
23619
+ * A URL already inside this device's window of recent attempts is ignored, so one event arriving as
23620
+ * several pushes downloads one thumbnail. The window is a `Set`, which iterates in insertion order,
23621
+ * so the entry evicted once it is full is the oldest attempt.
23622
+ */
23389
23623
  observe(deviceKey, url) {
23390
23624
  let state = this.devices.get(deviceKey);
23391
23625
  if (!state) {
@@ -23400,6 +23634,12 @@ var StoredImageCache = class {
23400
23634
  if (state.seenUrls.has(url))
23401
23635
  return;
23402
23636
  state.seenUrls.add(url);
23637
+ while (state.seenUrls.size > MAX_REMEMBERED_URLS) {
23638
+ const oldest = state.seenUrls.values().next();
23639
+ if (oldest.done)
23640
+ break;
23641
+ state.seenUrls.delete(oldest.value);
23642
+ }
23403
23643
  state.queued = {
23404
23644
  deviceKey,
23405
23645
  url,
@@ -23449,7 +23689,7 @@ var StoredImageCache = class {
23449
23689
  } else if (image === void 0) {
23450
23690
  state.lifecycleError = void 0;
23451
23691
  state.reason = "download-failed";
23452
- this.diagnose(state, candidate, "download-failed");
23692
+ this.diagnose(state, candidate, "download-failed", error);
23453
23693
  } else if (!this.isValidJpeg(image)) {
23454
23694
  state.lifecycleError = void 0;
23455
23695
  state.reason = "invalid-image";
@@ -23465,7 +23705,7 @@ var StoredImageCache = class {
23465
23705
  isValidJpeg(image) {
23466
23706
  return Buffer.isBuffer(image) && image.length > 0 && image.length <= MAX_JPEG_BYTES && image.length >= 5 && image[0] === 255 && image[1] === 216 && image[2] === 255 && image[image.length - 2] === 255 && image[image.length - 1] === 217;
23467
23707
  }
23468
- diagnose(state, candidate, failure) {
23708
+ diagnose(state, candidate, failure, error) {
23469
23709
  const now2 = this.clock();
23470
23710
  const last = state.loggedFailures.get(failure);
23471
23711
  if (last !== void 0 && now2 - last < DIAGNOSTIC_INTERVAL_MS)
@@ -23473,11 +23713,23 @@ var StoredImageCache = class {
23473
23713
  state.loggedFailures.set(failure, now2);
23474
23714
  this.logger.warn("[stored-snapshot-cache] candidate failed", {
23475
23715
  class: failure,
23716
+ ...mediaFailureTag(error),
23476
23717
  observedAt: candidate.observedAt,
23477
23718
  retained: state.retained !== void 0
23478
23719
  });
23479
23720
  }
23480
23721
  };
23722
+ function mediaFailureTag(error) {
23723
+ if (typeof error !== "object" || error === null)
23724
+ return {};
23725
+ const { mediaFailure, status } = error;
23726
+ if (typeof mediaFailure !== "string")
23727
+ return {};
23728
+ const cause = MEDIA_FAILURE_REASONS.find((reason) => reason === mediaFailure);
23729
+ if (!cause)
23730
+ return {};
23731
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? { cause, status } : { cause };
23732
+ }
23481
23733
 
23482
23734
  // dist/client/device-registry.js
23483
23735
  function resolvedStationSn(raw, sn) {
@@ -26178,6 +26430,11 @@ var SolixClient = class {
26178
26430
  * Turn a decrypted `/passport/login` payload into an `ok`/`2fa` result, establishing the session on
26179
26431
  * `ok`. The passport marks a pending 2FA with a non-empty `fa_info.info`, and empties it once the code
26180
26432
  * has been satisfied.
26433
+ *
26434
+ * `gtoken` is hashed from `ap_cloud_user_id` where the reply carries one, `user_id` otherwise. Whether
26435
+ * this gateway recomputes the header from `user_id` specifically — as the mega gateway does, rejecting a
26436
+ * disagreement with `"gtoken not equal userid error"` — is unverified here: no Solix response has been
26437
+ * observed refusing the header, which is consistent with the two ids agreeing on the accounts seen.
26181
26438
  */
26182
26439
  classifyLogin(data, isVerify) {
26183
26440
  const userId = data.ap_cloud_user_id ?? data.user_id;
@@ -26747,7 +27004,6 @@ export {
26747
27004
  aesKey,
26748
27005
  asBool,
26749
27006
  assertNever,
26750
- autoContrast,
26751
27007
  bizChannelName,
26752
27008
  buildActions,
26753
27009
  buildAppShapedClientId,