@capgo/capacitor-updater 8.51.8 → 8.51.9

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.
@@ -15,8 +15,6 @@ import java.io.FileInputStream;
15
15
  import java.net.HttpURLConnection;
16
16
  import java.net.URL;
17
17
  import java.nio.channels.FileChannel;
18
- import java.nio.file.Files;
19
- import java.nio.file.StandardCopyOption;
20
18
  import java.security.MessageDigest;
21
19
  import java.util.ArrayList;
22
20
  import java.util.Arrays;
@@ -30,6 +28,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
30
28
  import java.util.concurrent.atomic.AtomicLong;
31
29
  import okhttp3.Call;
32
30
  import okhttp3.Callback;
31
+ import okhttp3.Dispatcher;
33
32
  import okhttp3.Interceptor;
34
33
  import okhttp3.MediaType;
35
34
  import okhttp3.OkHttpClient;
@@ -38,11 +37,6 @@ import okhttp3.Request;
38
37
  import okhttp3.RequestBody;
39
38
  import okhttp3.Response;
40
39
  import okhttp3.ResponseBody;
41
- import okio.Buffer;
42
- import okio.BufferedSink;
43
- import okio.BufferedSource;
44
- import okio.Okio;
45
- import okio.Source;
46
40
  import org.brotli.dec.BrotliInputStream;
47
41
  import org.json.JSONArray;
48
42
  import org.json.JSONObject;
@@ -78,6 +72,8 @@ public class DownloadService extends Worker {
78
72
  public static final String DEFAULT_CHANNEL = "default_channel";
79
73
  public static final String IS_PROD = "is_prod";
80
74
  public static final String IS_EMULATOR = "is_emulator";
75
+ // Match HTTP dispatcher so 64 workers actually fetch in parallel (HTTP/2 multiplexes).
76
+ private static final int MANIFEST_MAX_CONCURRENT_FILES = 64;
81
77
  private static final String UPDATE_FILE = "update.dat";
82
78
 
83
79
  // Shared OkHttpClient to prevent resource leaks
@@ -88,7 +84,11 @@ public class DownloadService extends Worker {
88
84
 
89
85
  // Initialize shared client with User-Agent interceptor
90
86
  static {
87
+ Dispatcher dispatcher = new Dispatcher();
88
+ dispatcher.setMaxRequests(MANIFEST_MAX_CONCURRENT_FILES);
89
+ dispatcher.setMaxRequestsPerHost(MANIFEST_MAX_CONCURRENT_FILES);
91
90
  sharedClient = new OkHttpClient.Builder()
91
+ .dispatcher(dispatcher)
92
92
  .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
93
93
  .addInterceptor((chain) -> {
94
94
  Request originalRequest = chain.request();
@@ -203,7 +203,7 @@ public class DownloadService extends Worker {
203
203
  if (isManifest) {
204
204
  JSONArray manifest = DataManager.getInstance().getAndClearManifest();
205
205
  if (manifest != null) {
206
- handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest.toString());
206
+ handleManifestDownload(id, documentsDir, dest, version, sessionKey, publicKey, manifest);
207
207
  return createSuccessResult(dest, version, sessionKey, checksum, true);
208
208
  } else {
209
209
  logger.error("Manifest is null");
@@ -291,7 +291,7 @@ public class DownloadService extends Worker {
291
291
  String version,
292
292
  String sessionKey,
293
293
  String publicKey,
294
- String manifestString
294
+ JSONArray manifest
295
295
  ) {
296
296
  try {
297
297
  logger.debug("handleManifestDownload");
@@ -299,7 +299,6 @@ public class DownloadService extends Worker {
299
299
  // Send stats for manifest download start
300
300
  sendStatsAsync("download_manifest_start", version);
301
301
 
302
- JSONArray manifest = new JSONArray(manifestString);
303
302
  File destFolder = new File(documentsDir, dest);
304
303
  File cacheFolder = new File(getApplicationContext().getCacheDir(), "capgo_downloads");
305
304
  File builtinFolder = new File(getApplicationContext().getFilesDir(), "public");
@@ -316,9 +315,7 @@ public class DownloadService extends Worker {
316
315
  final AtomicLong completedFiles = new AtomicLong(0);
317
316
  final AtomicBoolean hasError = new AtomicBoolean(false);
318
317
 
319
- // Use more threads for I/O-bound operations
320
- int threadCount = Math.min(64, Math.max(32, totalFiles));
321
- ExecutorService executor = Executors.newFixedThreadPool(threadCount);
318
+ ExecutorService executor = Executors.newFixedThreadPool(Math.min(MANIFEST_MAX_CONCURRENT_FILES, Math.max(1, totalFiles)));
322
319
  List<Future<?>> futures = new ArrayList<>();
323
320
 
324
321
  for (int i = 0; i < totalFiles; i++) {
@@ -361,8 +358,11 @@ public class DownloadService extends Worker {
361
358
  continue;
362
359
  }
363
360
  String cacheBaseName = new File(isBrotli ? targetFileName : fileName).getName();
364
- File cacheFile = new File(cacheFolder, finalFileHash + "_" + cacheBaseName);
365
- final File legacyCacheFile = isBrotli ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
361
+ final File cacheFile = CapgoUpdater.isSafeCacheHash(finalFileHash)
362
+ ? new File(cacheFolder, finalFileHash + "_" + cacheBaseName)
363
+ : null;
364
+ final File legacyCacheFile =
365
+ isBrotli && cacheFile != null ? new File(cacheFolder, finalFileHash + "_" + new File(fileName).getName()) : null;
366
366
 
367
367
  // Ensure parent directories of the target file exist
368
368
  if (!Objects.requireNonNull(targetFile.getParentFile()).exists() && !targetFile.getParentFile().mkdirs()) {
@@ -622,17 +622,13 @@ public class DownloadService extends Worker {
622
622
  * This handles the race condition where OS can delete cache files between exists() check and copy.
623
623
  */
624
624
  private boolean tryCopyFromCache(File source, File dest, String expectedHash) {
625
- // First quick check - if file doesn't exist, don't bother
626
- if (!source.exists()) {
625
+ // First quick check - if file doesn't exist or was truncated, don't bother
626
+ if (!CapgoUpdater.isReusableCacheFile(source, expectedHash)) {
627
627
  return false;
628
628
  }
629
629
 
630
- // Verify checksum before copy
631
- if (!verifyChecksum(source, expectedHash)) {
632
- return false;
633
- }
634
-
635
- // Try to copy - if it fails (file deleted by OS between check and copy), return false
630
+ // Hash is in the cache file name and was verified when written.
631
+ // Re-hashing here would re-read every reused file on low-RAM devices.
636
632
  try {
637
633
  copyFile(source, dest);
638
634
  return true;
@@ -649,23 +645,29 @@ public class DownloadService extends Worker {
649
645
  throw new IOException("Failed to create parent directory: " + parent.getAbsolutePath());
650
646
  }
651
647
 
652
- final File tempFile = new File(parent, dest.getName() + ".capgo_tmp");
653
- try (
654
- FileInputStream inStream = new FileInputStream(source);
655
- FileOutputStream outStream = new FileOutputStream(tempFile);
656
- FileChannel inChannel = inStream.getChannel();
657
- FileChannel outChannel = outStream.getChannel()
658
- ) {
659
- inChannel.transferTo(0, inChannel.size(), outChannel);
660
- }
661
-
648
+ final File tempFile = File.createTempFile("capgo-", ".tmp", parent);
662
649
  try {
663
- Files.move(tempFile.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
664
- } catch (IOException e) {
650
+ try (
651
+ FileInputStream inStream = new FileInputStream(source);
652
+ FileOutputStream outStream = new FileOutputStream(tempFile);
653
+ FileChannel inChannel = inStream.getChannel();
654
+ FileChannel outChannel = outStream.getChannel()
655
+ ) {
656
+ long size = inChannel.size();
657
+ long pos = 0;
658
+ while (pos < size) {
659
+ long transferred = inChannel.transferTo(pos, size - pos, outChannel);
660
+ if (transferred <= 0) {
661
+ throw new IOException("Failed to copy file: " + source.getAbsolutePath());
662
+ }
663
+ pos += transferred;
664
+ }
665
+ }
666
+ CryptoCipher.replaceFile(tempFile, dest);
667
+ } finally {
665
668
  if (tempFile.exists()) {
666
669
  tempFile.delete();
667
670
  }
668
- throw e;
669
671
  }
670
672
  }
671
673
 
@@ -715,35 +717,16 @@ public class DownloadService extends Worker {
715
717
 
716
718
  // Only decompress if file has .br extension
717
719
  if (isBrotli) {
718
- // Use new decompression method with atomic write
719
- try (FileInputStream fis = new FileInputStream(compressedFile)) {
720
- byte[] compressedData = new byte[(int) compressedFile.length()];
721
- int offset = 0;
722
- int bytesRead;
723
- while (
724
- offset < compressedData.length &&
725
- (bytesRead = fis.read(compressedData, offset, compressedData.length - offset)) != -1
726
- ) {
727
- offset += bytesRead;
728
- }
729
- byte[] decompressedData;
730
- try {
731
- decompressedData = decompressBrotli(compressedData, targetFile.getName());
732
- } catch (IOException e) {
733
- sendStatsAsync(
734
- "download_manifest_brotli_fail",
735
- getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
736
- );
737
- throw e;
738
- }
739
-
740
- // Write decompressed data atomically
741
- try (java.io.ByteArrayInputStream bais = new java.io.ByteArrayInputStream(decompressedData)) {
742
- writeFileAtomic(finalTargetFile, bais, null);
743
- }
720
+ try {
721
+ decompressBrotli(compressedFile, finalTargetFile, targetFile.getName());
722
+ } catch (IOException e) {
723
+ sendStatsAsync(
724
+ "download_manifest_brotli_fail",
725
+ getInputData().getString(VERSION) + ":" + finalTargetFile.getName()
726
+ );
727
+ throw e;
744
728
  }
745
729
  } else {
746
- // Just copy the file without decompression using atomic operation
747
730
  try (FileInputStream fis = new FileInputStream(compressedFile)) {
748
731
  writeFileAtomic(finalTargetFile, fis, null);
749
732
  }
@@ -758,8 +741,10 @@ public class DownloadService extends Worker {
758
741
  // Verify checksum
759
742
  if (calculatedHash.equalsIgnoreCase(expectedHash)) {
760
743
  // Only cache if checksum is correct - use atomic copy
761
- try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
762
- writeFileAtomic(cacheFile, fis, expectedHash);
744
+ if (cacheFile != null) {
745
+ try (FileInputStream fis = new FileInputStream(finalTargetFile)) {
746
+ writeFileAtomic(cacheFile, fis, null);
747
+ }
763
748
  }
764
749
  } else {
765
750
  finalTargetFile.delete();
@@ -815,75 +800,118 @@ public class DownloadService extends Worker {
815
800
  return sb.toString();
816
801
  }
817
802
 
818
- private byte[] decompressBrotli(byte[] data, String fileName) throws IOException {
819
- // Validate input
820
- if (data == null) {
821
- logger.error("Error: Null data received for " + fileName);
822
- throw new IOException("Null data received");
803
+ static void decompressBrotli(File input, File output, String fileName) throws IOException {
804
+ File parent = output.getParentFile();
805
+ if (parent != null) {
806
+ parent.mkdirs();
823
807
  }
824
-
825
- // Handle empty files
826
- if (data.length == 0) {
827
- return new byte[0];
808
+ long length = input.length();
809
+ if (length == 0) {
810
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), null);
811
+ return;
828
812
  }
829
813
 
830
- // Handle the special EMPTY_BROTLI_STREAM case
831
- if (data.length == 3 && data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06) {
832
- return new byte[0];
814
+ byte[] head = new byte[(int) Math.min(3, length)];
815
+ byte last = 0;
816
+ try (RandomAccessFile raf = new RandomAccessFile(input, "r")) {
817
+ raf.readFully(head);
818
+ if (length >= 1) {
819
+ raf.seek(length - 1);
820
+ last = raf.readByte();
821
+ }
833
822
  }
834
823
 
835
- // For small files, check if it's a minimal Brotli wrapper
836
- if (data.length > 3) {
837
- try {
838
- // Handle our minimal wrapper pattern
839
- if (data[0] == 0x1B && data[1] == 0x00 && data[2] == 0x06 && data[data.length - 1] == 0x03) {
840
- return Arrays.copyOfRange(data, 3, data.length - 1);
841
- }
824
+ if (length == 3 && head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06) {
825
+ writeFileAtomic(output, new ByteArrayInputStream(new byte[0]), null);
826
+ return;
827
+ }
842
828
 
843
- // Handle brotli.compress minimal wrapper (quality 0)
844
- if (data[0] == 0x0b && data[1] == 0x02 && data[2] == (byte) 0x80 && data[data.length - 1] == 0x03) {
845
- return Arrays.copyOfRange(data, 3, data.length - 1);
829
+ if (length > 3 && last == 0x03) {
830
+ boolean emptyWrapper = head[0] == 0x1B && head[1] == 0x00 && head[2] == 0x06;
831
+ boolean qualityZeroWrapper = head[0] == 0x0b && head[1] == 0x02 && head[2] == (byte) 0x80;
832
+ if (emptyWrapper || qualityZeroWrapper) {
833
+ try (FileInputStream fis = new FileInputStream(input)) {
834
+ long skipped = 0;
835
+ while (skipped < 3) {
836
+ long n = fis.skip(3 - skipped);
837
+ if (n <= 0) {
838
+ break;
839
+ }
840
+ skipped += n;
841
+ }
842
+ writeFileAtomic(output, new BoundedInputStream(fis, length - 4), null);
846
843
  }
847
- } catch (ArrayIndexOutOfBoundsException e) {
848
- logger.error("Error: Malformed data for " + fileName);
849
- throw new IOException("Malformed data structure");
844
+ return;
850
845
  }
851
846
  }
852
847
 
853
- // For all other cases, try standard decompression
854
- try (
855
- ByteArrayInputStream bis = new ByteArrayInputStream(data);
856
- BrotliInputStream brotliInputStream = new BrotliInputStream(bis);
857
- ByteArrayOutputStream bos = new ByteArrayOutputStream()
858
- ) {
859
- byte[] buffer = new byte[8192];
860
- int len;
861
- while ((len = brotliInputStream.read(buffer)) != -1) {
862
- bos.write(buffer, 0, len);
863
- }
864
- return bos.toByteArray();
848
+ try (FileInputStream fis = new FileInputStream(input); BrotliInputStream brotliInputStream = new BrotliInputStream(fis)) {
849
+ writeFileAtomic(output, brotliInputStream, null);
865
850
  } catch (IOException e) {
866
851
  logger.error("Error: Brotli process failed for " + fileName + ". Status: " + e.getMessage());
867
- // Add hex dump for debugging
868
852
  StringBuilder hexDump = new StringBuilder();
869
- for (int i = 0; i < Math.min(32, data.length); i++) {
870
- hexDump.append(String.format("%02x ", data[i]));
853
+ try (FileInputStream peek = new FileInputStream(input)) {
854
+ byte[] prefix = new byte[(int) Math.min(32, length)];
855
+ int n = peek.read(prefix);
856
+ for (int i = 0; i < n; i++) {
857
+ hexDump.append(String.format("%02x ", prefix[i]));
858
+ }
871
859
  }
872
- logger.error("Error: Raw data (" + fileName + "): " + hexDump.toString());
860
+ logger.error("Error: Raw data (" + fileName + "): " + hexDump);
873
861
  throw e;
874
862
  }
875
863
  }
876
864
 
865
+ private static final class BoundedInputStream extends FilterInputStream {
866
+
867
+ private long remaining;
868
+
869
+ BoundedInputStream(InputStream in, long remaining) {
870
+ super(in);
871
+ this.remaining = remaining;
872
+ }
873
+
874
+ @Override
875
+ public int read() throws IOException {
876
+ if (remaining <= 0) {
877
+ return -1;
878
+ }
879
+ int value = super.read();
880
+ if (value >= 0) {
881
+ remaining--;
882
+ }
883
+ return value;
884
+ }
885
+
886
+ @Override
887
+ public int read(byte[] b, int off, int len) throws IOException {
888
+ if (remaining <= 0) {
889
+ return -1;
890
+ }
891
+ int capped = (int) Math.min(len, remaining);
892
+ int n = super.read(b, off, capped);
893
+ if (n > 0) {
894
+ remaining -= n;
895
+ }
896
+ return n;
897
+ }
898
+ }
899
+
877
900
  /**
878
- * Atomically write data to a file using OkIO
901
+ * Atomically write a stream to a file using the RAM-ladder IO buffer.
879
902
  */
880
- private void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
881
- File tempFile = new File(targetFile.getParent(), targetFile.getName() + ".tmp");
903
+ static void writeFileAtomic(File targetFile, InputStream inputStream, String expectedChecksum) throws IOException {
904
+ File tempFile = File.createTempFile("capgo-", ".tmp", targetFile.getParentFile());
882
905
 
883
906
  try {
884
- // Write to temp file first using OkIO
885
- try (BufferedSink sink = Okio.buffer(Okio.sink(tempFile)); BufferedSource source = Okio.buffer(Okio.source(inputStream))) {
886
- sink.writeAll(source);
907
+ // Okio's default segment is 8KB. Copy with the RAM-ladder buffer so
908
+ // 8MB wrapper unwraps are not 1000 tiny writes.
909
+ byte[] buffer = new byte[CryptoCipher.ioBufferBytes()];
910
+ try (FileOutputStream fos = new FileOutputStream(tempFile)) {
911
+ int n;
912
+ while ((n = inputStream.read(buffer)) != -1) {
913
+ fos.write(buffer, 0, n);
914
+ }
887
915
  }
888
916
 
889
917
  // Verify checksum if provided
@@ -895,8 +923,8 @@ public class DownloadService extends Worker {
895
923
  }
896
924
  }
897
925
 
898
- // Atomic rename (on same filesystem)
899
- Files.move(tempFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
926
+ // Atomic rename (on same filesystem). renameTo works on API 24; Files.move does not.
927
+ CryptoCipher.replaceFile(tempFile, targetFile);
900
928
  } catch (Exception e) {
901
929
  // Clean up temp file on error
902
930
  if (tempFile.exists()) {
@@ -1,6 +1,7 @@
1
1
  import Foundation
2
2
  import CommonCrypto
3
3
  import CryptoKit
4
+ import Darwin
4
5
 
5
6
  ///
6
7
  /// Constants
@@ -83,4 +84,122 @@ public struct AES128Key {
83
84
  return nil
84
85
  }
85
86
  }
87
+
88
+ /// AES-CBC file-to-file. Never holds the whole ciphertext in RAM.
89
+ func decrypt(from source: URL, to destination: URL) throws {
90
+ var cryptor: CCCryptorRef?
91
+ let createStatus: CCCryptorStatus = aes128Key.withUnsafeBytes { keyBytes in
92
+ initVector.withUnsafeBytes { ivBytes in
93
+ guard let keyPtr = keyBytes.baseAddress, let ivPtr = ivBytes.baseAddress else {
94
+ return CCCryptorStatus(kCCParamError)
95
+ }
96
+ return CCCryptorCreate(
97
+ CCOperation(kCCDecrypt),
98
+ AESConstants.aesAlgorithm,
99
+ AESConstants.aesOptions,
100
+ keyPtr,
101
+ keyBytes.count,
102
+ ivPtr,
103
+ &cryptor
104
+ )
105
+ }
106
+ }
107
+ guard createStatus == kCCSuccess, let cryptor else {
108
+ logger.error("Failed to create AES cryptor")
109
+ throw NSError(domain: "AESDecryptError", code: Int(createStatus), userInfo: nil)
110
+ }
111
+ defer {
112
+ CCCryptorRelease(cryptor)
113
+ }
114
+
115
+ guard let input = InputStream(url: source) else {
116
+ throw NSError(domain: "AESDecryptError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to open AES source"])
117
+ }
118
+ input.open()
119
+ defer {
120
+ input.close()
121
+ }
122
+
123
+ let tempURL = destination.deletingLastPathComponent().appendingPathComponent("capgo-aes-\(UUID().uuidString).tmp")
124
+ let fileManager = FileManager.default
125
+ fileManager.createFile(atPath: tempURL.path, contents: nil)
126
+ let output = try FileHandle(forWritingTo: tempURL)
127
+ defer {
128
+ try? output.close()
129
+ try? fileManager.removeItem(at: tempURL)
130
+ }
131
+
132
+ let bufferSize = CryptoCipher.ioBufferBytes()
133
+ let outBufSize = bufferSize + kCCBlockSizeAES128
134
+ var inBuf = [UInt8](repeating: 0, count: bufferSize)
135
+ var outBuf = [UInt8](repeating: 0, count: outBufSize)
136
+ let outFd = output.fileDescriptor
137
+
138
+ while true {
139
+ let n = inBuf.withUnsafeMutableBufferPointer { ptr in
140
+ input.read(ptr.baseAddress!, maxLength: ptr.count)
141
+ }
142
+ if n == 0 {
143
+ break
144
+ }
145
+ if n < 0 {
146
+ throw input.streamError ?? NSError(domain: "AESDecryptError", code: 2, userInfo: [NSLocalizedDescriptionKey: "AES stream read failed"])
147
+ }
148
+ var moved: size_t = 0
149
+ let status: CCCryptorStatus = inBuf.withUnsafeBufferPointer { inRaw in
150
+ outBuf.withUnsafeMutableBytes { outRaw in
151
+ CCCryptorUpdate(
152
+ cryptor,
153
+ inRaw.baseAddress,
154
+ n,
155
+ outRaw.baseAddress,
156
+ outBufSize,
157
+ &moved
158
+ )
159
+ }
160
+ }
161
+ guard status == kCCSuccess else {
162
+ logger.error("AES stream update failed")
163
+ throw NSError(domain: "AESDecryptError", code: Int(status), userInfo: nil)
164
+ }
165
+ if moved > 0 {
166
+ try Self.writeAll(fd: outFd, buffer: &outBuf, count: moved)
167
+ }
168
+ }
169
+
170
+ var moved: size_t = 0
171
+ let finalStatus: CCCryptorStatus = outBuf.withUnsafeMutableBytes { outRaw in
172
+ CCCryptorFinal(cryptor, outRaw.baseAddress, outBufSize, &moved)
173
+ }
174
+ guard finalStatus == kCCSuccess else {
175
+ logger.error("AES stream finalize failed")
176
+ throw NSError(domain: "AESDecryptError", code: Int(finalStatus), userInfo: nil)
177
+ }
178
+ if moved > 0 {
179
+ try Self.writeAll(fd: outFd, buffer: &outBuf, count: moved)
180
+ }
181
+ try output.close()
182
+
183
+ do {
184
+ _ = try fileManager.replaceItemAt(destination, withItemAt: tempURL)
185
+ } catch {
186
+ if fileManager.fileExists(atPath: destination.path) {
187
+ throw error
188
+ }
189
+ try fileManager.moveItem(at: tempURL, to: destination)
190
+ }
191
+ }
192
+
193
+ private static func writeAll(fd: Int32, buffer: inout [UInt8], count: Int) throws {
194
+ var offset = 0
195
+ while offset < count {
196
+ let written = buffer.withUnsafeBufferPointer { ptr -> Int in
197
+ Darwin.write(fd, ptr.baseAddress!.advanced(by: offset), count - offset)
198
+ }
199
+ if written <= 0 {
200
+ throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "AES stream write failed"])
201
+ }
202
+ offset += written
203
+ }
204
+ }
86
205
  }
@@ -96,7 +96,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
96
  deinit {
97
97
  implementation.shutdown()
98
98
  }
99
- private let pluginVersion: String = "8.51.8"
99
+ private let pluginVersion: String = "8.51.9"
100
100
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
101
101
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
102
102
  static let statsUrlDefault = "https://plugin.capgo.app/stats"