@dropgate/core 2.1.0 → 2.2.0-beta.1

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.cjs CHANGED
@@ -600,201 +600,251 @@ var DropgateClient = class {
600
600
  encrypt,
601
601
  filenameOverride,
602
602
  onProgress,
603
+ onCancel,
603
604
  signal,
604
605
  timeouts = {},
605
606
  retry = {}
606
607
  } = opts;
607
- const progress = (evt) => {
608
+ const internalController = signal ? null : new AbortController();
609
+ const effectiveSignal = signal || internalController?.signal;
610
+ let uploadState = "initializing";
611
+ let currentUploadId = null;
612
+ let currentBaseUrl = null;
613
+ const uploadPromise = (async () => {
608
614
  try {
609
- if (onProgress) onProgress(evt);
610
- } catch {
611
- }
612
- };
613
- if (!this.cryptoObj?.subtle) {
614
- throw new DropgateValidationError(
615
- "Web Crypto API not available (crypto.subtle)."
616
- );
617
- }
618
- const fileSizeBytes = file.size;
619
- progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
620
- const compat = await this.checkCompatibility({
621
- host,
622
- port,
623
- secure,
624
- timeoutMs: timeouts.serverInfoMs ?? 5e3,
625
- signal
626
- });
627
- const { baseUrl, serverInfo } = compat;
628
- progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
629
- if (!compat.compatible) {
630
- throw new DropgateValidationError(compat.message);
631
- }
632
- const filename = filenameOverride ?? file.name ?? "file";
633
- if (!encrypt) {
634
- validatePlainFilename(filename);
635
- }
636
- this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
637
- let cryptoKey = null;
638
- let keyB64 = null;
639
- let transmittedFilename = filename;
640
- if (encrypt) {
641
- progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
642
- try {
643
- cryptoKey = await generateAesGcmKey(this.cryptoObj);
644
- keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
645
- transmittedFilename = await encryptFilenameToBase64(
646
- this.cryptoObj,
647
- filename,
648
- cryptoKey
615
+ const progress = (evt) => {
616
+ try {
617
+ if (onProgress) onProgress(evt);
618
+ } catch {
619
+ }
620
+ };
621
+ if (!this.cryptoObj?.subtle) {
622
+ throw new DropgateValidationError(
623
+ "Web Crypto API not available (crypto.subtle)."
624
+ );
625
+ }
626
+ const fileSizeBytes = file.size;
627
+ progress({ phase: "server-info", text: "Checking server...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
628
+ const compat = await this.checkCompatibility({
629
+ host,
630
+ port,
631
+ secure,
632
+ timeoutMs: timeouts.serverInfoMs ?? 5e3,
633
+ signal: effectiveSignal
634
+ });
635
+ const { baseUrl, serverInfo } = compat;
636
+ progress({ phase: "server-compat", text: compat.message, percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
637
+ if (!compat.compatible) {
638
+ throw new DropgateValidationError(compat.message);
639
+ }
640
+ const filename = filenameOverride ?? file.name ?? "file";
641
+ if (!encrypt) {
642
+ validatePlainFilename(filename);
643
+ }
644
+ this.validateUploadInputs({ file, lifetimeMs, encrypt, serverInfo });
645
+ let cryptoKey = null;
646
+ let keyB64 = null;
647
+ let transmittedFilename = filename;
648
+ if (encrypt) {
649
+ progress({ phase: "crypto", text: "Generating encryption key...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
650
+ try {
651
+ cryptoKey = await generateAesGcmKey(this.cryptoObj);
652
+ keyB64 = await exportKeyBase64(this.cryptoObj, cryptoKey);
653
+ transmittedFilename = await encryptFilenameToBase64(
654
+ this.cryptoObj,
655
+ filename,
656
+ cryptoKey
657
+ );
658
+ } catch (err) {
659
+ throw new DropgateError("Failed to prepare encryption.", {
660
+ code: "CRYPTO_PREP_FAILED",
661
+ cause: err
662
+ });
663
+ }
664
+ }
665
+ const totalChunks = Math.ceil(file.size / this.chunkSize);
666
+ const totalUploadSize = estimateTotalUploadSizeBytes(
667
+ file.size,
668
+ totalChunks,
669
+ encrypt
649
670
  );
650
- } catch (err) {
651
- throw new DropgateError("Failed to prepare encryption.", {
652
- code: "CRYPTO_PREP_FAILED",
653
- cause: err
671
+ progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
672
+ const initPayload = {
673
+ filename: transmittedFilename,
674
+ lifetime: lifetimeMs,
675
+ isEncrypted: Boolean(encrypt),
676
+ totalSize: totalUploadSize,
677
+ totalChunks
678
+ };
679
+ const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
680
+ method: "POST",
681
+ timeoutMs: timeouts.initMs ?? 15e3,
682
+ signal: effectiveSignal,
683
+ headers: {
684
+ "Content-Type": "application/json",
685
+ Accept: "application/json"
686
+ },
687
+ body: JSON.stringify(initPayload)
654
688
  });
655
- }
656
- }
657
- const totalChunks = Math.ceil(file.size / this.chunkSize);
658
- const totalUploadSize = estimateTotalUploadSizeBytes(
659
- file.size,
660
- totalChunks,
661
- encrypt
662
- );
663
- progress({ phase: "init", text: "Reserving server storage...", percent: 0, processedBytes: 0, totalBytes: fileSizeBytes });
664
- const initPayload = {
665
- filename: transmittedFilename,
666
- lifetime: lifetimeMs,
667
- isEncrypted: Boolean(encrypt),
668
- totalSize: totalUploadSize,
669
- totalChunks
670
- };
671
- const initRes = await fetchJson(this.fetchFn, `${baseUrl}/upload/init`, {
672
- method: "POST",
673
- timeoutMs: timeouts.initMs ?? 15e3,
674
- signal,
675
- headers: {
676
- "Content-Type": "application/json",
677
- Accept: "application/json"
678
- },
679
- body: JSON.stringify(initPayload)
680
- });
681
- if (!initRes.res.ok) {
682
- const errorJson = initRes.json;
683
- const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
684
- throw new DropgateProtocolError(msg, {
685
- details: initRes.json || initRes.text
686
- });
687
- }
688
- const initJson = initRes.json;
689
- const uploadId = initJson?.uploadId;
690
- if (!uploadId || typeof uploadId !== "string") {
691
- throw new DropgateProtocolError(
692
- "Server did not return a valid uploadId."
693
- );
694
- }
695
- const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
696
- const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
697
- const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
698
- for (let i = 0; i < totalChunks; i++) {
699
- if (signal?.aborted) {
700
- throw signal.reason || new DropgateAbortError();
701
- }
702
- const start = i * this.chunkSize;
703
- const end = Math.min(start + this.chunkSize, file.size);
704
- let chunkBlob = file.slice(start, end);
705
- const percentComplete = i / totalChunks * 100;
706
- const processedBytes = i * this.chunkSize;
707
- progress({
708
- phase: "chunk",
709
- text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
710
- percent: percentComplete,
711
- processedBytes,
712
- totalBytes: fileSizeBytes,
713
- chunkIndex: i,
714
- totalChunks
715
- });
716
- const chunkBuffer = await chunkBlob.arrayBuffer();
717
- let uploadBlob;
718
- if (encrypt && cryptoKey) {
719
- uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
720
- } else {
721
- uploadBlob = new Blob([chunkBuffer]);
722
- }
723
- if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
724
- throw new DropgateValidationError(
725
- "Chunk too large (client-side). Check chunk size settings."
689
+ if (!initRes.res.ok) {
690
+ const errorJson = initRes.json;
691
+ const msg = errorJson?.error || `Server initialisation failed: ${initRes.res.status}`;
692
+ throw new DropgateProtocolError(msg, {
693
+ details: initRes.json || initRes.text
694
+ });
695
+ }
696
+ const initJson = initRes.json;
697
+ const uploadId = initJson?.uploadId;
698
+ if (!uploadId || typeof uploadId !== "string") {
699
+ throw new DropgateProtocolError(
700
+ "Server did not return a valid uploadId."
701
+ );
702
+ }
703
+ currentUploadId = uploadId;
704
+ currentBaseUrl = baseUrl;
705
+ uploadState = "uploading";
706
+ const retries = Number.isFinite(retry.retries) ? retry.retries : 5;
707
+ const baseBackoffMs = Number.isFinite(retry.backoffMs) ? retry.backoffMs : 1e3;
708
+ const maxBackoffMs = Number.isFinite(retry.maxBackoffMs) ? retry.maxBackoffMs : 3e4;
709
+ for (let i = 0; i < totalChunks; i++) {
710
+ if (effectiveSignal?.aborted) {
711
+ throw effectiveSignal.reason || new DropgateAbortError();
712
+ }
713
+ const start = i * this.chunkSize;
714
+ const end = Math.min(start + this.chunkSize, file.size);
715
+ let chunkBlob = file.slice(start, end);
716
+ const percentComplete = i / totalChunks * 100;
717
+ const processedBytes = i * this.chunkSize;
718
+ progress({
719
+ phase: "chunk",
720
+ text: `Uploading chunk ${i + 1} of ${totalChunks}...`,
721
+ percent: percentComplete,
722
+ processedBytes,
723
+ totalBytes: fileSizeBytes,
724
+ chunkIndex: i,
725
+ totalChunks
726
+ });
727
+ const chunkBuffer = await chunkBlob.arrayBuffer();
728
+ let uploadBlob;
729
+ if (encrypt && cryptoKey) {
730
+ uploadBlob = await encryptToBlob(this.cryptoObj, chunkBuffer, cryptoKey);
731
+ } else {
732
+ uploadBlob = new Blob([chunkBuffer]);
733
+ }
734
+ if (uploadBlob.size > DEFAULT_CHUNK_SIZE + 1024) {
735
+ throw new DropgateValidationError(
736
+ "Chunk too large (client-side). Check chunk size settings."
737
+ );
738
+ }
739
+ const toHash = await uploadBlob.arrayBuffer();
740
+ const hashHex = await sha256Hex(this.cryptoObj, toHash);
741
+ const headers = {
742
+ "Content-Type": "application/octet-stream",
743
+ "X-Upload-ID": uploadId,
744
+ "X-Chunk-Index": String(i),
745
+ "X-Chunk-Hash": hashHex
746
+ };
747
+ const chunkUrl = `${baseUrl}/upload/chunk`;
748
+ await this.attemptChunkUpload(
749
+ chunkUrl,
750
+ {
751
+ method: "POST",
752
+ headers,
753
+ body: uploadBlob
754
+ },
755
+ {
756
+ retries,
757
+ backoffMs: baseBackoffMs,
758
+ maxBackoffMs,
759
+ timeoutMs: timeouts.chunkMs ?? 6e4,
760
+ signal: effectiveSignal,
761
+ progress,
762
+ chunkIndex: i,
763
+ totalChunks,
764
+ chunkSize: this.chunkSize,
765
+ fileSizeBytes
766
+ }
767
+ );
768
+ }
769
+ progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
770
+ uploadState = "completing";
771
+ const completeRes = await fetchJson(
772
+ this.fetchFn,
773
+ `${baseUrl}/upload/complete`,
774
+ {
775
+ method: "POST",
776
+ timeoutMs: timeouts.completeMs ?? 3e4,
777
+ signal: effectiveSignal,
778
+ headers: {
779
+ "Content-Type": "application/json",
780
+ Accept: "application/json"
781
+ },
782
+ body: JSON.stringify({ uploadId })
783
+ }
726
784
  );
785
+ if (!completeRes.res.ok) {
786
+ const errorJson = completeRes.json;
787
+ const msg = errorJson?.error || "Finalisation failed.";
788
+ throw new DropgateProtocolError(msg, {
789
+ details: completeRes.json || completeRes.text
790
+ });
791
+ }
792
+ const completeJson = completeRes.json;
793
+ const fileId = completeJson?.id;
794
+ if (!fileId || typeof fileId !== "string") {
795
+ throw new DropgateProtocolError(
796
+ "Server did not return a valid file id."
797
+ );
798
+ }
799
+ let downloadUrl = `${baseUrl}/${fileId}`;
800
+ if (encrypt && keyB64) {
801
+ downloadUrl += `#${keyB64}`;
802
+ }
803
+ progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
804
+ uploadState = "completed";
805
+ return {
806
+ downloadUrl,
807
+ fileId,
808
+ uploadId,
809
+ baseUrl,
810
+ ...encrypt && keyB64 ? { keyB64 } : {}
811
+ };
812
+ } catch (err) {
813
+ if (err instanceof Error && (err.name === "AbortError" || err.message?.includes("abort"))) {
814
+ uploadState = "cancelled";
815
+ onCancel?.();
816
+ } else {
817
+ uploadState = "error";
818
+ }
819
+ throw err;
727
820
  }
728
- const toHash = await uploadBlob.arrayBuffer();
729
- const hashHex = await sha256Hex(this.cryptoObj, toHash);
730
- const headers = {
731
- "Content-Type": "application/octet-stream",
732
- "X-Upload-ID": uploadId,
733
- "X-Chunk-Index": String(i),
734
- "X-Chunk-Hash": hashHex
735
- };
736
- const chunkUrl = `${baseUrl}/upload/chunk`;
737
- await this.attemptChunkUpload(
738
- chunkUrl,
739
- {
821
+ })();
822
+ const callCancelEndpoint = async (uploadId, baseUrl) => {
823
+ try {
824
+ await fetchJson(this.fetchFn, `${baseUrl}/upload/cancel`, {
740
825
  method: "POST",
741
- headers,
742
- body: uploadBlob
743
- },
744
- {
745
- retries,
746
- backoffMs: baseBackoffMs,
747
- maxBackoffMs,
748
- timeoutMs: timeouts.chunkMs ?? 6e4,
749
- signal,
750
- progress,
751
- chunkIndex: i,
752
- totalChunks,
753
- chunkSize: this.chunkSize,
754
- fileSizeBytes
755
- }
756
- );
757
- }
758
- progress({ phase: "complete", text: "Finalising upload...", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
759
- const completeRes = await fetchJson(
760
- this.fetchFn,
761
- `${baseUrl}/upload/complete`,
762
- {
763
- method: "POST",
764
- timeoutMs: timeouts.completeMs ?? 3e4,
765
- signal,
766
- headers: {
767
- "Content-Type": "application/json",
768
- Accept: "application/json"
769
- },
770
- body: JSON.stringify({ uploadId })
826
+ timeoutMs: 5e3,
827
+ headers: {
828
+ "Content-Type": "application/json",
829
+ Accept: "application/json"
830
+ },
831
+ body: JSON.stringify({ uploadId })
832
+ });
833
+ } catch {
771
834
  }
772
- );
773
- if (!completeRes.res.ok) {
774
- const errorJson = completeRes.json;
775
- const msg = errorJson?.error || "Finalisation failed.";
776
- throw new DropgateProtocolError(msg, {
777
- details: completeRes.json || completeRes.text
778
- });
779
- }
780
- const completeJson = completeRes.json;
781
- const fileId = completeJson?.id;
782
- if (!fileId || typeof fileId !== "string") {
783
- throw new DropgateProtocolError(
784
- "Server did not return a valid file id."
785
- );
786
- }
787
- let downloadUrl = `${baseUrl}/${fileId}`;
788
- if (encrypt && keyB64) {
789
- downloadUrl += `#${keyB64}`;
790
- }
791
- progress({ phase: "done", text: "Upload successful!", percent: 100, processedBytes: fileSizeBytes, totalBytes: fileSizeBytes });
835
+ };
792
836
  return {
793
- downloadUrl,
794
- fileId,
795
- uploadId,
796
- baseUrl,
797
- ...encrypt && keyB64 ? { keyB64 } : {}
837
+ result: uploadPromise,
838
+ cancel: (reason) => {
839
+ if (uploadState === "completed" || uploadState === "cancelled") return;
840
+ uploadState = "cancelled";
841
+ if (currentUploadId && currentBaseUrl) {
842
+ callCancelEndpoint(currentUploadId, currentBaseUrl).catch(() => {
843
+ });
844
+ }
845
+ internalController?.abort(new DropgateAbortError(reason || "Upload cancelled by user."));
846
+ },
847
+ getStatus: () => uploadState
798
848
  };
799
849
  }
800
850
  /**
@@ -1239,7 +1289,8 @@ async function startP2PSend(opts) {
1239
1289
  onProgress,
1240
1290
  onComplete,
1241
1291
  onError,
1242
- onDisconnect
1292
+ onDisconnect,
1293
+ onCancel
1243
1294
  } = opts;
1244
1295
  if (!file) {
1245
1296
  throw new DropgateValidationError("File is missing.");
@@ -1285,7 +1336,7 @@ async function startP2PSend(opts) {
1285
1336
  onProgress?.({ processedBytes: safeReceived, totalBytes: safeTotal, percent });
1286
1337
  };
1287
1338
  const safeError = (err) => {
1288
- if (state === "closed" || state === "completed") return;
1339
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1289
1340
  state = "closed";
1290
1341
  onError?.(err);
1291
1342
  cleanup();
@@ -1324,11 +1375,21 @@ async function startP2PSend(opts) {
1324
1375
  window.addEventListener("beforeunload", handleUnload);
1325
1376
  }
1326
1377
  const stop = () => {
1327
- if (state === "closed") return;
1328
- state = "closed";
1378
+ if (state === "closed" || state === "cancelled") return;
1379
+ const wasActive = state === "transferring" || state === "finishing";
1380
+ state = "cancelled";
1381
+ try {
1382
+ if (activeConn && activeConn.open) {
1383
+ activeConn.send({ t: "cancelled", message: "Sender cancelled the transfer." });
1384
+ }
1385
+ } catch {
1386
+ }
1387
+ if (wasActive && onCancel) {
1388
+ onCancel({ cancelledBy: "sender" });
1389
+ }
1329
1390
  cleanup();
1330
1391
  };
1331
- const isStopped = () => state === "closed";
1392
+ const isStopped = () => state === "closed" || state === "cancelled";
1332
1393
  peer.on("connection", (conn) => {
1333
1394
  if (state === "closed") return;
1334
1395
  if (activeConn) {
@@ -1398,6 +1459,13 @@ async function startP2PSend(opts) {
1398
1459
  }
1399
1460
  if (msg.t === "error") {
1400
1461
  safeError(new DropgateNetworkError(msg.message || "Receiver reported an error."));
1462
+ return;
1463
+ }
1464
+ if (msg.t === "cancelled") {
1465
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1466
+ state = "cancelled";
1467
+ onCancel?.({ cancelledBy: "receiver", message: msg.message });
1468
+ cleanup();
1401
1469
  }
1402
1470
  });
1403
1471
  conn.on("open", async () => {
@@ -1435,6 +1503,7 @@ async function startP2PSend(opts) {
1435
1503
  if (isStopped()) return;
1436
1504
  const slice = file.slice(offset, offset + chunkSize);
1437
1505
  const buf = await slice.arrayBuffer();
1506
+ if (isStopped()) return;
1438
1507
  conn.send(buf);
1439
1508
  sentBytes += buf.byteLength;
1440
1509
  if (dc) {
@@ -1484,14 +1553,14 @@ async function startP2PSend(opts) {
1484
1553
  safeError(err);
1485
1554
  });
1486
1555
  conn.on("close", () => {
1487
- if (state === "closed" || state === "completed") {
1556
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1488
1557
  cleanup();
1489
1558
  return;
1490
1559
  }
1491
1560
  if (state === "transferring" || state === "finishing") {
1492
- safeError(
1493
- new DropgateNetworkError("Receiver disconnected before transfer completed.")
1494
- );
1561
+ state = "cancelled";
1562
+ onCancel?.({ cancelledBy: "receiver" });
1563
+ cleanup();
1495
1564
  } else {
1496
1565
  activeConn = null;
1497
1566
  state = "listening";
@@ -1533,7 +1602,8 @@ async function startP2PReceive(opts) {
1533
1602
  onProgress,
1534
1603
  onComplete,
1535
1604
  onError,
1536
- onDisconnect
1605
+ onDisconnect,
1606
+ onCancel
1537
1607
  } = opts;
1538
1608
  if (!code) {
1539
1609
  throw new DropgateValidationError("No sharing code was provided.");
@@ -1590,7 +1660,7 @@ async function startP2PReceive(opts) {
1590
1660
  }
1591
1661
  };
1592
1662
  const safeError = (err) => {
1593
- if (state === "closed" || state === "completed") return;
1663
+ if (state === "closed" || state === "completed" || state === "cancelled") return;
1594
1664
  state = "closed";
1595
1665
  onError?.(err);
1596
1666
  cleanup();
@@ -1622,8 +1692,18 @@ async function startP2PReceive(opts) {
1622
1692
  window.addEventListener("beforeunload", handleUnload);
1623
1693
  }
1624
1694
  const stop = () => {
1625
- if (state === "closed") return;
1626
- state = "closed";
1695
+ if (state === "closed" || state === "cancelled") return;
1696
+ const wasActive = state === "transferring";
1697
+ state = "cancelled";
1698
+ try {
1699
+ if (activeConn && activeConn.open) {
1700
+ activeConn.send({ t: "cancelled", message: "Receiver cancelled the transfer." });
1701
+ }
1702
+ } catch {
1703
+ }
1704
+ if (wasActive && onCancel) {
1705
+ onCancel({ cancelledBy: "receiver" });
1706
+ }
1627
1707
  cleanup();
1628
1708
  };
1629
1709
  peer.on("error", (err) => {
@@ -1705,6 +1785,13 @@ async function startP2PReceive(opts) {
1705
1785
  if (msg.t === "error") {
1706
1786
  throw new DropgateNetworkError(msg.message || "Sender reported an error.");
1707
1787
  }
1788
+ if (msg.t === "cancelled") {
1789
+ if (state === "cancelled" || state === "closed" || state === "completed") return;
1790
+ state = "cancelled";
1791
+ onCancel?.({ cancelledBy: "sender", message: msg.message });
1792
+ cleanup();
1793
+ return;
1794
+ }
1708
1795
  return;
1709
1796
  }
1710
1797
  let bufPromise;
@@ -1750,12 +1837,14 @@ async function startP2PReceive(opts) {
1750
1837
  }
1751
1838
  });
1752
1839
  conn.on("close", () => {
1753
- if (state === "closed" || state === "completed") {
1840
+ if (state === "closed" || state === "completed" || state === "cancelled") {
1754
1841
  cleanup();
1755
1842
  return;
1756
1843
  }
1757
1844
  if (state === "transferring") {
1758
- safeError(new DropgateNetworkError("Sender disconnected during transfer."));
1845
+ state = "cancelled";
1846
+ onCancel?.({ cancelledBy: "sender" });
1847
+ cleanup();
1759
1848
  } else if (state === "negotiating") {
1760
1849
  state = "closed";
1761
1850
  cleanup();