@capgo/capacitor-updater 8.51.3 → 8.51.5
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/android/build.gradle +1 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +322 -78
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +22 -17
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +2 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +252 -56
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +6 -2
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -50,7 +50,7 @@ repositories {
|
|
|
50
50
|
|
|
51
51
|
dependencies {
|
|
52
52
|
def work_version = "2.10.5"
|
|
53
|
-
def lifecycle_version = "2.
|
|
53
|
+
def lifecycle_version = "2.11.0"
|
|
54
54
|
implementation "androidx.work:work-runtime:$work_version"
|
|
55
55
|
implementation "androidx.lifecycle:lifecycle-process:$lifecycle_version"
|
|
56
56
|
implementation "com.google.android.gms:play-services-tasks:18.4.1"
|
|
@@ -146,7 +146,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
146
146
|
static final int APPLICATION_EXIT_REASON_USER_REQUESTED = 10;
|
|
147
147
|
static final int APPLICATION_EXIT_REASON_DEPENDENCY_DIED = 12;
|
|
148
148
|
|
|
149
|
-
private final String pluginVersion = "8.51.
|
|
149
|
+
private final String pluginVersion = "8.51.5";
|
|
150
150
|
private static final String DELAY_CONDITION_PREFERENCES = "";
|
|
151
151
|
|
|
152
152
|
private SharedPreferences.Editor editor;
|
|
@@ -3990,7 +3990,7 @@ public class CapacitorUpdaterPlugin extends Plugin {
|
|
|
3990
3990
|
|
|
3991
3991
|
new AlertDialog.Builder(getActivity())
|
|
3992
3992
|
.setTitle("Preview started")
|
|
3993
|
-
.setMessage("Shake
|
|
3993
|
+
.setMessage("shake".equals(this.shakeMenuGesture) ? "Shake to open menu." : "Three-finger pinch to open menu.")
|
|
3994
3994
|
.setPositiveButton("Got it", (dialog, which) -> dialog.dismiss())
|
|
3995
3995
|
.show();
|
|
3996
3996
|
} catch (final Exception e) {
|
|
@@ -112,11 +112,19 @@ public class CapgoUpdater {
|
|
|
112
112
|
// Cached key ID calculated once from publicKey
|
|
113
113
|
private String cachedKeyId = "";
|
|
114
114
|
|
|
115
|
-
//
|
|
116
|
-
|
|
115
|
+
// Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
|
|
116
|
+
// Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
|
|
117
|
+
private static final Object rateLimitStateLock = new Object();
|
|
118
|
+
private static long rateLimitBlockedUntilMs = 0L;
|
|
119
|
+
private static String rateLimitBlockedError = "too_many_requests";
|
|
120
|
+
private static String rateLimitBlockedMessage = "Too many requests";
|
|
117
121
|
|
|
118
|
-
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
119
|
-
|
|
122
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
|
|
123
|
+
// Released again when the send fails, so a later 429 can retry it.
|
|
124
|
+
private static boolean rateLimitStatisticSent = false;
|
|
125
|
+
|
|
126
|
+
// Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
|
|
127
|
+
private static final long MAX_RATE_LIMIT_WINDOW_MS = 24 * 60 * 60 * 1000L;
|
|
120
128
|
|
|
121
129
|
// Stats batching - queue events and send max once per second
|
|
122
130
|
private final List<QueuedStatsEvent> statsQueue = new CopyOnWriteArrayList<>();
|
|
@@ -386,7 +394,7 @@ public class CapgoUpdater {
|
|
|
386
394
|
io.execute(() -> cacheBundleFiles(id));
|
|
387
395
|
}
|
|
388
396
|
|
|
389
|
-
|
|
397
|
+
void cacheBundleFiles(final String id) {
|
|
390
398
|
if (this.activity == null) {
|
|
391
399
|
logger.debug("Skip delta cache population: activity is null");
|
|
392
400
|
return;
|
|
@@ -408,13 +416,25 @@ public class CapgoUpdater {
|
|
|
408
416
|
return;
|
|
409
417
|
}
|
|
410
418
|
|
|
419
|
+
final File builtinFolder = new File(this.activity.getFilesDir(), "public");
|
|
420
|
+
|
|
411
421
|
final List<File> files = new ArrayList<>();
|
|
412
422
|
collectFiles(bundleDir, files);
|
|
423
|
+
final int bundlePrefixLength = bundleDir.getAbsolutePath().length() + 1;
|
|
413
424
|
for (File file : files) {
|
|
414
425
|
final String checksum = CryptoCipher.calcChecksum(file);
|
|
415
426
|
if (checksum.isEmpty()) {
|
|
416
427
|
continue;
|
|
417
428
|
}
|
|
429
|
+
|
|
430
|
+
// Builtin is already a permanent reuse source (see isManifestEntryAvailableLocally),
|
|
431
|
+
// so there's no need to also duplicate a byte-identical file into the delta cache.
|
|
432
|
+
final String relativePath = file.getAbsolutePath().substring(bundlePrefixLength);
|
|
433
|
+
final File builtinFile = new File(builtinFolder, relativePath);
|
|
434
|
+
if (verifyChecksum(builtinFile, checksum)) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
418
438
|
final String cacheName = checksum + "_" + file.getName();
|
|
419
439
|
final File cacheFile = new File(cacheDir, cacheName);
|
|
420
440
|
if (cacheFile.exists()) {
|
|
@@ -1913,30 +1933,185 @@ public class CapgoUpdater {
|
|
|
1913
1933
|
return json;
|
|
1914
1934
|
}
|
|
1915
1935
|
|
|
1936
|
+
private static final class RemoteBlockResult {
|
|
1937
|
+
|
|
1938
|
+
final boolean blocked;
|
|
1939
|
+
final String error;
|
|
1940
|
+
final String message;
|
|
1941
|
+
|
|
1942
|
+
RemoteBlockResult(final boolean blocked, final String error, final String message) {
|
|
1943
|
+
this.blocked = blocked;
|
|
1944
|
+
this.error = error;
|
|
1945
|
+
this.message = message;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1916
1949
|
/**
|
|
1917
|
-
*
|
|
1950
|
+
* Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
|
|
1951
|
+
* All 429s use the same temporary client block — no sticky latch until restart.
|
|
1918
1952
|
*/
|
|
1919
|
-
private
|
|
1920
|
-
if (response.code()
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1953
|
+
private RemoteBlockResult checkAndHandleRateLimitResponse(Response response, String responseData) {
|
|
1954
|
+
if (response == null || response.code() != 429) {
|
|
1955
|
+
return new RemoteBlockResult(false, "", "");
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
final String parsedError = parseRemoteError(responseData);
|
|
1959
|
+
final String parsedMessage = parseRemoteMessage(responseData);
|
|
1960
|
+
final String errorCode = parsedError.isEmpty() ? "too_many_requests" : parsedError;
|
|
1961
|
+
final String message = parsedMessage.isEmpty() ? "Too many requests" : parsedMessage;
|
|
1962
|
+
|
|
1963
|
+
final long retryUntilMs = resolveRateLimitBlockedUntilMs(response, responseData);
|
|
1964
|
+
synchronized (rateLimitStateLock) {
|
|
1965
|
+
if (retryUntilMs > rateLimitBlockedUntilMs) {
|
|
1966
|
+
rateLimitBlockedUntilMs = retryUntilMs;
|
|
1967
|
+
rateLimitBlockedError = errorCode;
|
|
1968
|
+
rateLimitBlockedMessage = message;
|
|
1969
|
+
} else if (rateLimitBlockedUntilMs <= 0L) {
|
|
1970
|
+
rateLimitBlockedError = errorCode;
|
|
1971
|
+
rateLimitBlockedMessage = message;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// Claim last, and only when there is somewhere to send it, so a 429 burst with no
|
|
1976
|
+
// stats URL does not claim and release the latch once per response.
|
|
1977
|
+
if ("too_many_requests".equals(errorCode) && !this.previewSession && this.hasStatsUrl() && claimRateLimitStatistic()) {
|
|
1978
|
+
sendRateLimitStatistic();
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
final long nowMs = System.currentTimeMillis();
|
|
1982
|
+
final long retryAfter = Math.max(0L, (Math.max(retryUntilMs, nowMs) - nowMs + 999L) / 1000L);
|
|
1983
|
+
logger.warn("Received 429 (" + errorCode + "). Honouring Retry-After: " + retryAfter + "s.");
|
|
1984
|
+
return new RemoteBlockResult(true, errorCode, message);
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
private String parseRemoteError(final String responseData) {
|
|
1988
|
+
if (responseData == null || responseData.isEmpty()) {
|
|
1989
|
+
return "";
|
|
1990
|
+
}
|
|
1991
|
+
try {
|
|
1992
|
+
final JSONObject json = new JSONObject(responseData);
|
|
1993
|
+
return json.optString("error", "");
|
|
1994
|
+
} catch (JSONException ignored) {
|
|
1995
|
+
return "";
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
private String parseRemoteMessage(final String responseData) {
|
|
2000
|
+
if (responseData == null || responseData.isEmpty()) {
|
|
2001
|
+
return "";
|
|
2002
|
+
}
|
|
2003
|
+
try {
|
|
2004
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2005
|
+
return json.optString("message", "");
|
|
2006
|
+
} catch (JSONException ignored) {
|
|
2007
|
+
return "";
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
private long resolveRateLimitBlockedUntilMs(final Response response, final String responseData) {
|
|
2012
|
+
final long nowMs = System.currentTimeMillis();
|
|
2013
|
+
final double candidate = rawRateLimitDeadlineMs(response, responseData, nowMs);
|
|
2014
|
+
// NaN and past deadlines mean "no client-side block"; anything further out is capped.
|
|
2015
|
+
if (!(candidate > nowMs)) {
|
|
2016
|
+
return 0L;
|
|
2017
|
+
}
|
|
2018
|
+
return (long) Math.min(candidate, (double) nowMs + MAX_RATE_LIMIT_WINDOW_MS);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
private double rawRateLimitDeadlineMs(final Response response, final String responseData, final long nowMs) {
|
|
2022
|
+
final String header = response.header("Retry-After");
|
|
2023
|
+
if (header != null) {
|
|
2024
|
+
try {
|
|
2025
|
+
final double seconds = Double.parseDouble(header.trim());
|
|
2026
|
+
if (seconds >= 0) {
|
|
2027
|
+
return nowMs + seconds * 1000d;
|
|
2028
|
+
}
|
|
2029
|
+
} catch (NumberFormatException ignored) {
|
|
2030
|
+
// Fall through to body fields
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
if (responseData != null && !responseData.isEmpty()) {
|
|
2035
|
+
try {
|
|
2036
|
+
final JSONObject json = new JSONObject(responseData);
|
|
2037
|
+
final JSONObject moreInfo = json.optJSONObject("moreInfo");
|
|
2038
|
+
if (moreInfo != null && moreInfo.has("retryAfterSeconds")) {
|
|
2039
|
+
final double retryAfter = moreInfo.getDouble("retryAfterSeconds");
|
|
2040
|
+
if (retryAfter >= 0) {
|
|
2041
|
+
return nowMs + retryAfter * 1000d;
|
|
2042
|
+
}
|
|
2043
|
+
} else if (json.has("retryAfterSeconds")) {
|
|
2044
|
+
final double retryAfter = json.getDouble("retryAfterSeconds");
|
|
2045
|
+
if (retryAfter >= 0) {
|
|
2046
|
+
return nowMs + retryAfter * 1000d;
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
if (moreInfo != null && moreInfo.has("rateLimitResetAt")) {
|
|
2050
|
+
return moreInfo.getDouble("rateLimitResetAt");
|
|
2051
|
+
} else if (json.has("rateLimitResetAt")) {
|
|
2052
|
+
return json.getDouble("rateLimitResetAt");
|
|
2053
|
+
}
|
|
2054
|
+
} catch (JSONException ignored) {
|
|
2055
|
+
// No retry hint
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
// No retry hint — do not hold a client-side block; allow immediate retry to the worker
|
|
2060
|
+
return 0d;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private static boolean claimRateLimitStatistic() {
|
|
2064
|
+
synchronized (rateLimitStateLock) {
|
|
2065
|
+
if (rateLimitStatisticSent) {
|
|
2066
|
+
return false;
|
|
2067
|
+
}
|
|
2068
|
+
rateLimitStatisticSent = true;
|
|
1929
2069
|
return true;
|
|
1930
2070
|
}
|
|
1931
|
-
return false;
|
|
1932
2071
|
}
|
|
1933
2072
|
|
|
1934
2073
|
/**
|
|
1935
|
-
*
|
|
2074
|
+
* Give the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
2075
|
+
*/
|
|
2076
|
+
private static void releaseRateLimitStatisticClaim() {
|
|
2077
|
+
synchronized (rateLimitStateLock) {
|
|
2078
|
+
rateLimitStatisticSent = false;
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
private boolean hasStatsUrl() {
|
|
2083
|
+
final String url = this.statsUrl;
|
|
2084
|
+
return url != null && !url.isEmpty();
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
private boolean isRemoteBlocked() {
|
|
2088
|
+
synchronized (rateLimitStateLock) {
|
|
2089
|
+
if (rateLimitBlockedUntilMs <= 0L) {
|
|
2090
|
+
return false;
|
|
2091
|
+
}
|
|
2092
|
+
if (System.currentTimeMillis() >= rateLimitBlockedUntilMs) {
|
|
2093
|
+
rateLimitBlockedUntilMs = 0L;
|
|
2094
|
+
return false;
|
|
2095
|
+
}
|
|
2096
|
+
return true;
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
private RemoteBlockResult remoteBlockedClientError() {
|
|
2101
|
+
synchronized (rateLimitStateLock) {
|
|
2102
|
+
return new RemoteBlockResult(true, rateLimitBlockedError, rateLimitBlockedMessage);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* Send a statistic about rate limiting.
|
|
2108
|
+
* Dispatched through OkHttp so no caller thread waits on the request.
|
|
1936
2109
|
*/
|
|
1937
2110
|
private void sendRateLimitStatistic() {
|
|
1938
2111
|
String statsUrl = this.statsUrl;
|
|
1939
2112
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2113
|
+
// The URL was cleared after the claim was taken; nothing went out, so hand it back.
|
|
2114
|
+
releaseRateLimitStatisticClaim();
|
|
1940
2115
|
return;
|
|
1941
2116
|
}
|
|
1942
2117
|
|
|
@@ -1952,17 +2127,33 @@ public class CapgoUpdater {
|
|
|
1952
2127
|
.post(RequestBody.create(json.toString(), MediaType.get("application/json")))
|
|
1953
2128
|
.build();
|
|
1954
2129
|
|
|
1955
|
-
// Send synchronously to ensure it goes out before the flag is set
|
|
1956
2130
|
// User-Agent header is automatically added by DownloadService.sharedClient interceptor
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
2131
|
+
DownloadService.sharedClient.newCall(request).enqueue(
|
|
2132
|
+
new okhttp3.Callback() {
|
|
2133
|
+
@Override
|
|
2134
|
+
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2135
|
+
releaseRateLimitStatisticClaim();
|
|
2136
|
+
logger.error("Failed to send rate limit statistic");
|
|
2137
|
+
logger.debug("Error: " + e.getMessage());
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
@Override
|
|
2141
|
+
public void onResponse(@NonNull Call call, @NonNull Response response) {
|
|
2142
|
+
// The body is unused here; closing the Response closes it.
|
|
2143
|
+
try (response) {
|
|
2144
|
+
if (response.isSuccessful()) {
|
|
2145
|
+
logger.info("Rate limit statistic sent");
|
|
2146
|
+
} else {
|
|
2147
|
+
releaseRateLimitStatisticClaim();
|
|
2148
|
+
logger.error("Error sending rate limit statistic");
|
|
2149
|
+
logger.debug("Response code: " + response.code());
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
1963
2153
|
}
|
|
1964
|
-
|
|
2154
|
+
);
|
|
1965
2155
|
} catch (final Exception e) {
|
|
2156
|
+
releaseRateLimitStatisticClaim();
|
|
1966
2157
|
logger.error("Failed to send rate limit statistic");
|
|
1967
2158
|
logger.debug("Error: " + e.getMessage());
|
|
1968
2159
|
}
|
|
@@ -2001,7 +2192,27 @@ public class CapgoUpdater {
|
|
|
2001
2192
|
|
|
2002
2193
|
if (jsonResponse != null && (jsonResponse.has("error") || jsonResponse.has("kind"))) {
|
|
2003
2194
|
if (statusCode == 429) {
|
|
2004
|
-
checkAndHandleRateLimitResponse(response);
|
|
2195
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2196
|
+
Map<String, Object> retError = new HashMap<>();
|
|
2197
|
+
retError.put(
|
|
2198
|
+
"error",
|
|
2199
|
+
rateLimit.error.isEmpty() ? jsonResponse.optString("error", "too_many_requests") : rateLimit.error
|
|
2200
|
+
);
|
|
2201
|
+
retError.put(
|
|
2202
|
+
"message",
|
|
2203
|
+
rateLimit.message.isEmpty() ? jsonResponse.optString("message", "Too many requests") : rateLimit.message
|
|
2204
|
+
);
|
|
2205
|
+
if (jsonResponse.has("kind") && !jsonResponse.isNull("kind")) {
|
|
2206
|
+
retError.put("kind", jsonResponse.getString("kind"));
|
|
2207
|
+
} else {
|
|
2208
|
+
retError.put("kind", "failed");
|
|
2209
|
+
}
|
|
2210
|
+
if (jsonResponse.has("version") && !jsonResponse.isNull("version")) {
|
|
2211
|
+
retError.put("version", jsonResponse.getString("version"));
|
|
2212
|
+
}
|
|
2213
|
+
retError.put("statusCode", statusCode);
|
|
2214
|
+
callback.callback(retError);
|
|
2215
|
+
return;
|
|
2005
2216
|
}
|
|
2006
2217
|
Map<String, Object> retError = new HashMap<>();
|
|
2007
2218
|
if (jsonResponse.has("error") && !jsonResponse.isNull("error")) {
|
|
@@ -2023,11 +2234,12 @@ public class CapgoUpdater {
|
|
|
2023
2234
|
return;
|
|
2024
2235
|
}
|
|
2025
2236
|
|
|
2026
|
-
// Check for 429 rate limit
|
|
2027
|
-
|
|
2237
|
+
// Check for 429 rate limit without JSON body
|
|
2238
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2239
|
+
if (rateLimit.blocked) {
|
|
2028
2240
|
Map<String, Object> retError = new HashMap<>();
|
|
2029
|
-
retError.put("message",
|
|
2030
|
-
retError.put("error",
|
|
2241
|
+
retError.put("message", rateLimit.message);
|
|
2242
|
+
retError.put("error", rateLimit.error);
|
|
2031
2243
|
retError.put("kind", "failed");
|
|
2032
2244
|
retError.put("statusCode", statusCode);
|
|
2033
2245
|
callback.callback(retError);
|
|
@@ -2080,6 +2292,16 @@ public class CapgoUpdater {
|
|
|
2080
2292
|
}
|
|
2081
2293
|
|
|
2082
2294
|
public void getLatest(final String updateUrl, final String channel, final String appIdOverride, final Callback callback) {
|
|
2295
|
+
if (isRemoteBlocked()) {
|
|
2296
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2297
|
+
logger.debug("Skipping getLatest due to remote block (" + blocked.error + ").");
|
|
2298
|
+
final Map<String, Object> retError = new HashMap<>();
|
|
2299
|
+
retError.put("message", blocked.message);
|
|
2300
|
+
retError.put("error", blocked.error);
|
|
2301
|
+
retError.put("kind", "failed");
|
|
2302
|
+
callback.callback(retError);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2083
2305
|
JSONObject json;
|
|
2084
2306
|
try {
|
|
2085
2307
|
json = this.createInfoObject(appIdOverride);
|
|
@@ -2149,12 +2371,12 @@ public class CapgoUpdater {
|
|
|
2149
2371
|
return;
|
|
2150
2372
|
}
|
|
2151
2373
|
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
logger.debug("Skipping setChannel due to
|
|
2374
|
+
if (isRemoteBlocked()) {
|
|
2375
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2376
|
+
logger.debug("Skipping setChannel due to remote block (" + blocked.error + ").");
|
|
2155
2377
|
final Map<String, Object> retError = new HashMap<>();
|
|
2156
|
-
retError.put("message",
|
|
2157
|
-
retError.put("error",
|
|
2378
|
+
retError.put("message", blocked.message);
|
|
2379
|
+
retError.put("error", blocked.error);
|
|
2158
2380
|
callback.callback(retError);
|
|
2159
2381
|
return;
|
|
2160
2382
|
}
|
|
@@ -2208,12 +2430,12 @@ public class CapgoUpdater {
|
|
|
2208
2430
|
}
|
|
2209
2431
|
|
|
2210
2432
|
public void getChannel(final Callback callback, final SharedPreferences.Editor editor, final String defaultChannelKey) {
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
logger.debug("Skipping getChannel due to
|
|
2433
|
+
if (isRemoteBlocked()) {
|
|
2434
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2435
|
+
logger.debug("Skipping getChannel due to remote block (" + blocked.error + ").");
|
|
2214
2436
|
final Map<String, Object> retError = new HashMap<>();
|
|
2215
|
-
retError.put("message",
|
|
2216
|
-
retError.put("error",
|
|
2437
|
+
retError.put("message", blocked.message);
|
|
2438
|
+
retError.put("error", blocked.error);
|
|
2217
2439
|
callback.callback(retError);
|
|
2218
2440
|
return;
|
|
2219
2441
|
}
|
|
@@ -2258,25 +2480,18 @@ public class CapgoUpdater {
|
|
|
2258
2480
|
@Override
|
|
2259
2481
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2260
2482
|
try (ResponseBody responseBody = response.body()) {
|
|
2261
|
-
|
|
2262
|
-
|
|
2483
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
2484
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, responseData);
|
|
2485
|
+
if (rateLimit.blocked) {
|
|
2263
2486
|
Map<String, Object> retError = new HashMap<>();
|
|
2264
|
-
retError.put("message",
|
|
2265
|
-
retError.put("error",
|
|
2487
|
+
retError.put("message", rateLimit.message);
|
|
2488
|
+
retError.put("error", rateLimit.error);
|
|
2266
2489
|
callback.callback(retError);
|
|
2267
2490
|
return;
|
|
2268
2491
|
}
|
|
2269
2492
|
|
|
2270
2493
|
if (response.code() == 400) {
|
|
2271
|
-
if (
|
|
2272
|
-
Map<String, Object> retError = new HashMap<>();
|
|
2273
|
-
retError.put("message", "Empty response body");
|
|
2274
|
-
retError.put("error", "no_response_body");
|
|
2275
|
-
callback.callback(retError);
|
|
2276
|
-
return;
|
|
2277
|
-
}
|
|
2278
|
-
String data = responseBody.string();
|
|
2279
|
-
if (data.contains("channel_not_found") && !defaultChannel.isEmpty()) {
|
|
2494
|
+
if (responseData.contains("channel_not_found") && !defaultChannel.isEmpty()) {
|
|
2280
2495
|
Map<String, Object> ret = new HashMap<>();
|
|
2281
2496
|
ret.put("channel", defaultChannel);
|
|
2282
2497
|
ret.put("status", "default");
|
|
@@ -2294,14 +2509,13 @@ public class CapgoUpdater {
|
|
|
2294
2509
|
return;
|
|
2295
2510
|
}
|
|
2296
2511
|
|
|
2297
|
-
if (
|
|
2512
|
+
if (responseData.isEmpty()) {
|
|
2298
2513
|
Map<String, Object> retError = new HashMap<>();
|
|
2299
2514
|
retError.put("message", "Empty response body");
|
|
2300
2515
|
retError.put("error", "no_response_body");
|
|
2301
2516
|
callback.callback(retError);
|
|
2302
2517
|
return;
|
|
2303
2518
|
}
|
|
2304
|
-
String responseData = responseBody.string();
|
|
2305
2519
|
JSONObject jsonResponse = new JSONObject(responseData);
|
|
2306
2520
|
|
|
2307
2521
|
// Check for server-side errors first
|
|
@@ -2359,12 +2573,12 @@ public class CapgoUpdater {
|
|
|
2359
2573
|
}
|
|
2360
2574
|
|
|
2361
2575
|
public void listChannels(final Callback callback) {
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
logger.debug("Skipping listChannels due to
|
|
2576
|
+
if (isRemoteBlocked()) {
|
|
2577
|
+
final RemoteBlockResult blocked = remoteBlockedClientError();
|
|
2578
|
+
logger.debug("Skipping listChannels due to remote block (" + blocked.error + ").");
|
|
2365
2579
|
final Map<String, Object> retError = new HashMap<>();
|
|
2366
|
-
retError.put("message",
|
|
2367
|
-
retError.put("error",
|
|
2580
|
+
retError.put("message", blocked.message);
|
|
2581
|
+
retError.put("error", blocked.error);
|
|
2368
2582
|
callback.callback(retError);
|
|
2369
2583
|
return;
|
|
2370
2584
|
}
|
|
@@ -2423,11 +2637,12 @@ public class CapgoUpdater {
|
|
|
2423
2637
|
@Override
|
|
2424
2638
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2425
2639
|
try (ResponseBody responseBody = response.body()) {
|
|
2426
|
-
|
|
2427
|
-
|
|
2640
|
+
final String data = responseBody != null ? responseBody.string() : "";
|
|
2641
|
+
final RemoteBlockResult rateLimit = checkAndHandleRateLimitResponse(response, data);
|
|
2642
|
+
if (rateLimit.blocked) {
|
|
2428
2643
|
Map<String, Object> retError = new HashMap<>();
|
|
2429
|
-
retError.put("message",
|
|
2430
|
-
retError.put("error",
|
|
2644
|
+
retError.put("message", rateLimit.message);
|
|
2645
|
+
retError.put("error", rateLimit.error);
|
|
2431
2646
|
callback.callback(retError);
|
|
2432
2647
|
return;
|
|
2433
2648
|
}
|
|
@@ -2440,14 +2655,13 @@ public class CapgoUpdater {
|
|
|
2440
2655
|
return;
|
|
2441
2656
|
}
|
|
2442
2657
|
|
|
2443
|
-
if (
|
|
2658
|
+
if (data.isEmpty()) {
|
|
2444
2659
|
Map<String, Object> retError = new HashMap<>();
|
|
2445
2660
|
retError.put("message", "Empty response body");
|
|
2446
2661
|
retError.put("error", "no_response_body");
|
|
2447
2662
|
callback.callback(retError);
|
|
2448
2663
|
return;
|
|
2449
2664
|
}
|
|
2450
|
-
String data = responseBody.string();
|
|
2451
2665
|
|
|
2452
2666
|
try {
|
|
2453
2667
|
Map<String, Object> ret = parseListChannelsResponse(data);
|
|
@@ -2542,12 +2756,6 @@ public class CapgoUpdater {
|
|
|
2542
2756
|
return;
|
|
2543
2757
|
}
|
|
2544
2758
|
|
|
2545
|
-
// Check if rate limit was exceeded
|
|
2546
|
-
if (rateLimitExceeded) {
|
|
2547
|
-
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.");
|
|
2548
|
-
return;
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
2759
|
String statsUrl = this.statsUrl;
|
|
2552
2760
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2553
2761
|
return;
|
|
@@ -2569,7 +2777,10 @@ public class CapgoUpdater {
|
|
|
2569
2777
|
return;
|
|
2570
2778
|
}
|
|
2571
2779
|
|
|
2572
|
-
|
|
2780
|
+
// Same lock as the flush transaction, so an event added mid-flush is never dropped.
|
|
2781
|
+
synchronized (statsQueue) {
|
|
2782
|
+
statsQueue.add(new QueuedStatsEvent(json, onSent));
|
|
2783
|
+
}
|
|
2573
2784
|
ensureStatsTimerStarted();
|
|
2574
2785
|
}
|
|
2575
2786
|
|
|
@@ -2589,9 +2800,17 @@ public class CapgoUpdater {
|
|
|
2589
2800
|
return;
|
|
2590
2801
|
}
|
|
2591
2802
|
|
|
2803
|
+
// While Retry-After is active, keep stats queued and skip the network call.
|
|
2804
|
+
if (isRemoteBlocked()) {
|
|
2805
|
+
logger.debug("Deferring stats flush until Retry-After expires.");
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2592
2809
|
String statsUrl = this.statsUrl;
|
|
2593
2810
|
if (statsUrl == null || statsUrl.isEmpty()) {
|
|
2594
|
-
statsQueue
|
|
2811
|
+
synchronized (statsQueue) {
|
|
2812
|
+
statsQueue.clear();
|
|
2813
|
+
}
|
|
2595
2814
|
return;
|
|
2596
2815
|
}
|
|
2597
2816
|
|
|
@@ -2620,6 +2839,8 @@ public class CapgoUpdater {
|
|
|
2620
2839
|
new okhttp3.Callback() {
|
|
2621
2840
|
@Override
|
|
2622
2841
|
public void onFailure(@NonNull Call call, @NonNull IOException e) {
|
|
2842
|
+
// Keep events for a later flush attempt.
|
|
2843
|
+
requeueStatsEvents(eventsToSend);
|
|
2623
2844
|
logger.error("Failed to send stats batch");
|
|
2624
2845
|
logger.debug("Error: " + e.getMessage());
|
|
2625
2846
|
}
|
|
@@ -2627,8 +2848,9 @@ public class CapgoUpdater {
|
|
|
2627
2848
|
@Override
|
|
2628
2849
|
public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException {
|
|
2629
2850
|
try (ResponseBody responseBody = response.body()) {
|
|
2630
|
-
|
|
2631
|
-
if (checkAndHandleRateLimitResponse(response)) {
|
|
2851
|
+
final String responseData = responseBody != null ? responseBody.string() : "";
|
|
2852
|
+
if (checkAndHandleRateLimitResponse(response, responseData).blocked) {
|
|
2853
|
+
requeueStatsEvents(eventsToSend);
|
|
2632
2854
|
return;
|
|
2633
2855
|
}
|
|
2634
2856
|
|
|
@@ -2636,8 +2858,13 @@ public class CapgoUpdater {
|
|
|
2636
2858
|
logger.info("Stats batch sent successfully");
|
|
2637
2859
|
logger.debug("Sent " + eventCount + " events");
|
|
2638
2860
|
runStatsCallbacks(eventsToSend);
|
|
2639
|
-
} else {
|
|
2861
|
+
} else if (isTransientStatsFailure(response.code())) {
|
|
2862
|
+
requeueStatsEvents(eventsToSend);
|
|
2640
2863
|
logger.error("Error sending stats batch");
|
|
2864
|
+
logger.debug("Retrying later, response code: " + response.code());
|
|
2865
|
+
} else {
|
|
2866
|
+
// Permanent rejection: retrying would loop forever and block the queue.
|
|
2867
|
+
logger.error("Dropping stats batch after permanent error");
|
|
2641
2868
|
logger.debug("Response code: " + response.code());
|
|
2642
2869
|
}
|
|
2643
2870
|
}
|
|
@@ -2646,6 +2873,23 @@ public class CapgoUpdater {
|
|
|
2646
2873
|
);
|
|
2647
2874
|
}
|
|
2648
2875
|
|
|
2876
|
+
/**
|
|
2877
|
+
* Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
|
|
2878
|
+
*/
|
|
2879
|
+
private static boolean isTransientStatsFailure(final int statusCode) {
|
|
2880
|
+
return statusCode == 429 || statusCode == 408 || statusCode >= 500;
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
private void requeueStatsEvents(final List<QueuedStatsEvent> events) {
|
|
2884
|
+
if (events == null || events.isEmpty()) {
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
synchronized (statsQueue) {
|
|
2888
|
+
statsQueue.addAll(0, events);
|
|
2889
|
+
}
|
|
2890
|
+
ensureStatsTimerStarted();
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2649
2893
|
private void runStatsCallbacks(final List<QueuedStatsEvent> sentEvents) {
|
|
2650
2894
|
for (final QueuedStatsEvent sentEvent : sentEvents) {
|
|
2651
2895
|
if (sentEvent.onSent == null) {
|
|
@@ -793,8 +793,20 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
|
|
|
793
793
|
|
|
794
794
|
String latestUrl = getString(latestRes, "url");
|
|
795
795
|
|
|
796
|
-
|
|
797
|
-
|
|
796
|
+
Object manifestObj = latestRes.get("manifest");
|
|
797
|
+
JSONArray manifestArray = null;
|
|
798
|
+
if (manifestObj instanceof JSONArray) {
|
|
799
|
+
manifestArray = (JSONArray) manifestObj;
|
|
800
|
+
} else if (manifestObj instanceof List) {
|
|
801
|
+
manifestArray = new JSONArray((List<?>) manifestObj);
|
|
802
|
+
}
|
|
803
|
+
final boolean hasManifest = manifestArray != null && manifestArray.length() > 0;
|
|
804
|
+
|
|
805
|
+
// Check if there's an actual update available. A manifest-only
|
|
806
|
+
// response legitimately has no URL (the files come from the
|
|
807
|
+
// manifest, not a zip), so only report "already on latest" when
|
|
808
|
+
// the URL is empty AND there is no manifest to download from.
|
|
809
|
+
if ("up_to_date".equals(latestKind) || ((latestUrl == null || latestUrl.isEmpty()) && !hasManifest)) {
|
|
798
810
|
activity.runOnUiThread(() -> {
|
|
799
811
|
progressDialog.dismiss();
|
|
800
812
|
showSuccess("Channel set to " + channelName + ". Already on latest version.");
|
|
@@ -817,25 +829,18 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
|
|
|
817
829
|
|
|
818
830
|
String sessionKey = getString(latestRes, "sessionKey");
|
|
819
831
|
String checksum = getString(latestRes, "checksum");
|
|
820
|
-
|
|
832
|
+
|
|
833
|
+
// A manifest-only response has no zip URL; downloadManifest
|
|
834
|
+
// tolerates the placeholder URL the plugin already uses.
|
|
835
|
+
final String downloadUrl =
|
|
836
|
+
latestUrl == null || latestUrl.isEmpty() ? "https://404.capgo.app/no.zip" : latestUrl;
|
|
821
837
|
|
|
822
838
|
// Download the update
|
|
823
839
|
try {
|
|
824
840
|
BundleInfo bundle;
|
|
825
|
-
if (
|
|
826
|
-
JSONArray manifestArray = null;
|
|
827
|
-
if (manifestObj instanceof JSONArray) {
|
|
828
|
-
manifestArray = (JSONArray) manifestObj;
|
|
829
|
-
} else if (manifestObj instanceof List) {
|
|
830
|
-
manifestArray = new JSONArray((List<?>) manifestObj);
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
if (manifestArray == null) {
|
|
834
|
-
throw new IllegalArgumentException("Invalid manifest format");
|
|
835
|
-
}
|
|
836
|
-
|
|
841
|
+
if (hasManifest) {
|
|
837
842
|
bundle = updater.downloadManifest(
|
|
838
|
-
|
|
843
|
+
downloadUrl,
|
|
839
844
|
versionForUi,
|
|
840
845
|
sessionKey != null ? sessionKey : "",
|
|
841
846
|
checksum != null ? checksum : "",
|
|
@@ -843,7 +848,7 @@ public class ShakeMenu implements ShakeDetector.Listener, ThreeFingerPinchDetect
|
|
|
843
848
|
);
|
|
844
849
|
} else {
|
|
845
850
|
bundle = updater.download(
|
|
846
|
-
|
|
851
|
+
downloadUrl,
|
|
847
852
|
versionForUi,
|
|
848
853
|
sessionKey != null ? sessionKey : "",
|
|
849
854
|
checksum != null ? checksum : ""
|
|
@@ -92,7 +92,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
92
92
|
CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
|
|
93
93
|
]
|
|
94
94
|
public var implementation = CapgoUpdater()
|
|
95
|
-
private let pluginVersion: String = "8.51.
|
|
95
|
+
private let pluginVersion: String = "8.51.5"
|
|
96
96
|
private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
97
97
|
static let updateUrlDefault = "https://plugin.capgo.app/updates"
|
|
98
98
|
static let statsUrlDefault = "https://plugin.capgo.app/stats"
|
|
@@ -2754,7 +2754,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
2754
2754
|
|
|
2755
2755
|
let alert = UIAlertController(
|
|
2756
2756
|
title: "Preview started",
|
|
2757
|
-
message:
|
|
2757
|
+
message: self.shakeMenuGesture == Self.shakeMenuGestureThreeFingerPinch ? "Three-finger pinch to open menu." : "Shake to open menu.",
|
|
2758
2758
|
preferredStyle: .alert
|
|
2759
2759
|
)
|
|
2760
2760
|
alert.addAction(UIAlertAction(title: "Got it", style: .default))
|
|
@@ -49,12 +49,20 @@ import UIKit
|
|
|
49
49
|
// Cached key ID calculated once from publicKey
|
|
50
50
|
private var cachedKeyId: String?
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
// Temporary 429 block until this epoch ms (Retry-After / rateLimitResetAt). No sticky latch.
|
|
53
|
+
// Guarded by rateLimitStateLock so concurrent 429s cannot shorten the window or mix metadata.
|
|
54
|
+
private static let rateLimitStateLock = NSLock()
|
|
55
|
+
private static var rateLimitBlockedUntilMs: Double = 0
|
|
56
|
+
private static var rateLimitBlockedError: String = "too_many_requests"
|
|
57
|
+
private static var rateLimitBlockedMessage: String = "Too many requests"
|
|
58
|
+
|
|
59
|
+
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop.
|
|
60
|
+
// Released again when the send fails, so a later 429 can retry it.
|
|
56
61
|
private static var rateLimitStatisticSent = false
|
|
57
62
|
|
|
63
|
+
// Upper bound for a client-side 429 block, so a bogus Retry-After cannot block the app for days.
|
|
64
|
+
private static let maxRateLimitWindowMs: Double = 24 * 60 * 60 * 1000
|
|
65
|
+
|
|
58
66
|
// Stats batching - queue events and send max once per second
|
|
59
67
|
private var statsQueue: [QueuedStatsEvent] = []
|
|
60
68
|
private let statsQueueLock = NSLock()
|
|
@@ -421,26 +429,149 @@ import UIKit
|
|
|
421
429
|
}
|
|
422
430
|
}
|
|
423
431
|
|
|
432
|
+
private struct RemoteBlockResult {
|
|
433
|
+
let blocked: Bool
|
|
434
|
+
let error: String
|
|
435
|
+
let message: String
|
|
436
|
+
}
|
|
437
|
+
|
|
424
438
|
/**
|
|
425
|
-
*
|
|
439
|
+
* Handle HTTP 429 responses by honouring Retry-After / rateLimitResetAt.
|
|
440
|
+
* All 429s use the same temporary client block — no sticky latch until restart.
|
|
426
441
|
*/
|
|
427
|
-
private func checkAndHandleRateLimitResponse(
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
442
|
+
private func checkAndHandleRateLimitResponse(
|
|
443
|
+
statusCode: Int?,
|
|
444
|
+
data: Data? = nil,
|
|
445
|
+
response: HTTPURLResponse? = nil
|
|
446
|
+
) -> RemoteBlockResult {
|
|
447
|
+
guard statusCode == 429 else {
|
|
448
|
+
return RemoteBlockResult(blocked: false, error: "", message: "")
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
let parsed = parseRemoteError(from: data)
|
|
452
|
+
let errorCode = parsed.error.isEmpty ? "too_many_requests" : parsed.error
|
|
453
|
+
let message = parsed.message.isEmpty ? "Too many requests" : parsed.message
|
|
454
|
+
|
|
455
|
+
let retryUntilMs = resolveRateLimitBlockedUntilMs(data: data, response: response)
|
|
456
|
+
CapgoUpdater.recordRateLimitBlock(untilMs: retryUntilMs, error: errorCode, message: message)
|
|
457
|
+
|
|
458
|
+
// Claim last, and only when there is somewhere to send it, so a 429 burst with no
|
|
459
|
+
// stats URL does not claim and release the latch once per response.
|
|
460
|
+
if errorCode == "too_many_requests" && !previewSession && !statsUrl.isEmpty && CapgoUpdater.claimRateLimitStatistic() {
|
|
461
|
+
DispatchQueue.global(qos: .utility).async {
|
|
462
|
+
self.sendRateLimitStatistic()
|
|
438
463
|
}
|
|
439
|
-
CapgoUpdater.rateLimitExceeded = true
|
|
440
|
-
logger.warn("Rate limit exceeded (429). Stopping all stats and channel requests until app restart.")
|
|
441
|
-
return true
|
|
442
464
|
}
|
|
443
|
-
|
|
465
|
+
|
|
466
|
+
let nowMs = Date().timeIntervalSince1970 * 1000
|
|
467
|
+
let retryAfter = CapgoUpdater.retryAfterSecondsForLog(untilMs: retryUntilMs, nowMs: nowMs)
|
|
468
|
+
logger.warn("Received 429 (\(errorCode)). Honouring Retry-After: \(retryAfter)s.")
|
|
469
|
+
return RemoteBlockResult(blocked: true, error: errorCode, message: message)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/// Stores the block deadline and its metadata together, keeping the longest deadline
|
|
473
|
+
/// so a concurrent 429 with a shorter window cannot cut the block short.
|
|
474
|
+
private static func recordRateLimitBlock(untilMs: Double, error: String, message: String) {
|
|
475
|
+
rateLimitStateLock.lock()
|
|
476
|
+
defer { rateLimitStateLock.unlock() }
|
|
477
|
+
if untilMs > rateLimitBlockedUntilMs {
|
|
478
|
+
rateLimitBlockedUntilMs = untilMs
|
|
479
|
+
rateLimitBlockedError = error
|
|
480
|
+
rateLimitBlockedMessage = message
|
|
481
|
+
} else if rateLimitBlockedUntilMs <= 0 {
|
|
482
|
+
rateLimitBlockedError = error
|
|
483
|
+
rateLimitBlockedMessage = message
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/// Seconds left in the block, clamped and finite so the Int conversion can never trap.
|
|
488
|
+
private static func retryAfterSecondsForLog(untilMs: Double, nowMs: Double) -> Int {
|
|
489
|
+
let seconds = ((untilMs - nowMs) / 1000).rounded(.up)
|
|
490
|
+
guard seconds.isFinite, seconds > 0 else {
|
|
491
|
+
return 0
|
|
492
|
+
}
|
|
493
|
+
return Int(min(seconds, maxRateLimitWindowMs / 1000))
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/// Returns true for the first 429 only, so the rate-limit statistic is sent once.
|
|
497
|
+
private static func claimRateLimitStatistic() -> Bool {
|
|
498
|
+
rateLimitStateLock.lock()
|
|
499
|
+
defer { rateLimitStateLock.unlock() }
|
|
500
|
+
if rateLimitStatisticSent {
|
|
501
|
+
return false
|
|
502
|
+
}
|
|
503
|
+
rateLimitStatisticSent = true
|
|
504
|
+
return true
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/// Gives the claim back when the statistic never made it out, so a later 429 can retry it.
|
|
508
|
+
private static func releaseRateLimitStatisticClaim() {
|
|
509
|
+
rateLimitStateLock.lock()
|
|
510
|
+
defer { rateLimitStateLock.unlock() }
|
|
511
|
+
rateLimitStatisticSent = false
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private func parseRemoteError(from data: Data?) -> (error: String, message: String) {
|
|
515
|
+
guard let data = data,
|
|
516
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
517
|
+
return ("", "")
|
|
518
|
+
}
|
|
519
|
+
let error = json["error"] as? String ?? ""
|
|
520
|
+
let message = json["message"] as? String ?? ""
|
|
521
|
+
return (error, message)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private func resolveRateLimitBlockedUntilMs(data: Data?, response: HTTPURLResponse?) -> Double {
|
|
525
|
+
let nowMs = Date().timeIntervalSince1970 * 1000
|
|
526
|
+
let candidate = rawRateLimitDeadlineMs(data: data, response: response, nowMs: nowMs)
|
|
527
|
+
// NaN and past deadlines mean "no client-side block"; anything further out is capped.
|
|
528
|
+
guard candidate > nowMs else {
|
|
529
|
+
return 0
|
|
530
|
+
}
|
|
531
|
+
return min(candidate, nowMs + CapgoUpdater.maxRateLimitWindowMs)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private func rawRateLimitDeadlineMs(data: Data?, response: HTTPURLResponse?, nowMs: Double) -> Double {
|
|
535
|
+
if let header = response?.value(forHTTPHeaderField: "Retry-After")?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
536
|
+
let seconds = Double(header), seconds >= 0 {
|
|
537
|
+
return nowMs + seconds * 1000
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if let data = data,
|
|
541
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
542
|
+
let moreInfo = json["moreInfo"] as? [String: Any]
|
|
543
|
+
if let retryAfter = (moreInfo?["retryAfterSeconds"] as? NSNumber)?.doubleValue
|
|
544
|
+
?? (json["retryAfterSeconds"] as? NSNumber)?.doubleValue,
|
|
545
|
+
retryAfter >= 0 {
|
|
546
|
+
return nowMs + retryAfter * 1000
|
|
547
|
+
}
|
|
548
|
+
if let resetAt = (moreInfo?["rateLimitResetAt"] as? NSNumber)?.doubleValue
|
|
549
|
+
?? (json["rateLimitResetAt"] as? NSNumber)?.doubleValue {
|
|
550
|
+
return resetAt
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// No retry hint — do not hold a client-side block; allow immediate retry to the worker
|
|
555
|
+
return 0
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private func isRemoteBlocked() -> Bool {
|
|
559
|
+
CapgoUpdater.rateLimitStateLock.lock()
|
|
560
|
+
defer { CapgoUpdater.rateLimitStateLock.unlock() }
|
|
561
|
+
if CapgoUpdater.rateLimitBlockedUntilMs <= 0 {
|
|
562
|
+
return false
|
|
563
|
+
}
|
|
564
|
+
if Date().timeIntervalSince1970 * 1000 >= CapgoUpdater.rateLimitBlockedUntilMs {
|
|
565
|
+
CapgoUpdater.rateLimitBlockedUntilMs = 0
|
|
566
|
+
return false
|
|
567
|
+
}
|
|
568
|
+
return true
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private func remoteBlockedClientError() -> (error: String, message: String) {
|
|
572
|
+
CapgoUpdater.rateLimitStateLock.lock()
|
|
573
|
+
defer { CapgoUpdater.rateLimitStateLock.unlock() }
|
|
574
|
+
return (CapgoUpdater.rateLimitBlockedError, CapgoUpdater.rateLimitBlockedMessage)
|
|
444
575
|
}
|
|
445
576
|
|
|
446
577
|
/**
|
|
@@ -450,6 +581,8 @@ import UIKit
|
|
|
450
581
|
*/
|
|
451
582
|
private func sendRateLimitStatistic() {
|
|
452
583
|
guard !statsUrl.isEmpty else {
|
|
584
|
+
// The URL was cleared after the claim was taken; nothing went out, so hand it back.
|
|
585
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
453
586
|
return
|
|
454
587
|
}
|
|
455
588
|
|
|
@@ -468,10 +601,16 @@ import UIKit
|
|
|
468
601
|
encoding: JSONEncoding.default,
|
|
469
602
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
470
603
|
).responseData { response in
|
|
604
|
+
let statusCode = response.response?.statusCode
|
|
471
605
|
switch response.result {
|
|
472
|
-
case .success:
|
|
606
|
+
case .success where (200...299).contains(statusCode ?? 0):
|
|
473
607
|
self.logger.info("Rate limit statistic sent")
|
|
608
|
+
case .success:
|
|
609
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
610
|
+
self.logger.error("Error sending rate limit statistic")
|
|
611
|
+
self.logger.debug("Response code: \(statusCode.map(String.init) ?? "nil")")
|
|
474
612
|
case let .failure(error):
|
|
613
|
+
CapgoUpdater.releaseRateLimitStatisticClaim()
|
|
475
614
|
self.logger.error("Error sending rate limit statistic")
|
|
476
615
|
self.logger.debug("Error: \(error.localizedDescription)")
|
|
477
616
|
}
|
|
@@ -825,6 +964,14 @@ import UIKit
|
|
|
825
964
|
|
|
826
965
|
public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
|
|
827
966
|
let latest: AppVersion = AppVersion()
|
|
967
|
+
if isRemoteBlocked() {
|
|
968
|
+
let blocked = remoteBlockedClientError()
|
|
969
|
+
logger.debug("Skipping getLatest due to remote block (\(blocked.error)).")
|
|
970
|
+
latest.message = blocked.message
|
|
971
|
+
latest.error = blocked.error
|
|
972
|
+
latest.kind = "failed"
|
|
973
|
+
return latest
|
|
974
|
+
}
|
|
828
975
|
func applyLatestResponse(_ value: AppVersionDec?) {
|
|
829
976
|
if let url = value?.url {
|
|
830
977
|
latest.url = url
|
|
@@ -905,9 +1052,14 @@ import UIKit
|
|
|
905
1052
|
return latest
|
|
906
1053
|
}
|
|
907
1054
|
|
|
908
|
-
|
|
909
|
-
latest.
|
|
910
|
-
|
|
1055
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
1056
|
+
statusCode: latest.statusCode,
|
|
1057
|
+
data: data,
|
|
1058
|
+
response: result.response
|
|
1059
|
+
)
|
|
1060
|
+
if rateLimit.blocked {
|
|
1061
|
+
latest.message = rateLimit.message
|
|
1062
|
+
latest.error = rateLimit.error
|
|
911
1063
|
latest.kind = "failed"
|
|
912
1064
|
return latest
|
|
913
1065
|
}
|
|
@@ -2419,11 +2571,11 @@ import UIKit
|
|
|
2419
2571
|
return setChannel
|
|
2420
2572
|
}
|
|
2421
2573
|
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
logger.debug("Skipping setChannel due to
|
|
2425
|
-
setChannel.message =
|
|
2426
|
-
setChannel.error =
|
|
2574
|
+
if isRemoteBlocked() {
|
|
2575
|
+
let blocked = remoteBlockedClientError()
|
|
2576
|
+
logger.debug("Skipping setChannel due to remote block (\(blocked.error)).")
|
|
2577
|
+
setChannel.message = blocked.message
|
|
2578
|
+
setChannel.error = blocked.error
|
|
2427
2579
|
return setChannel
|
|
2428
2580
|
}
|
|
2429
2581
|
|
|
@@ -2448,9 +2600,14 @@ import UIKit
|
|
|
2448
2600
|
|
|
2449
2601
|
let result = performRequest(request, label: "setChannel")
|
|
2450
2602
|
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2603
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2604
|
+
statusCode: result.response?.statusCode,
|
|
2605
|
+
data: result.data,
|
|
2606
|
+
response: result.response
|
|
2607
|
+
)
|
|
2608
|
+
if rateLimit.blocked {
|
|
2609
|
+
setChannel.message = rateLimit.message
|
|
2610
|
+
setChannel.error = rateLimit.error
|
|
2454
2611
|
return setChannel
|
|
2455
2612
|
}
|
|
2456
2613
|
|
|
@@ -2509,10 +2666,11 @@ import UIKit
|
|
|
2509
2666
|
func getChannel(defaultChannelKey: String? = nil) -> GetChannel {
|
|
2510
2667
|
let getChannel: GetChannel = GetChannel()
|
|
2511
2668
|
// Check if rate limit was exceeded
|
|
2512
|
-
if
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
getChannel.
|
|
2669
|
+
if isRemoteBlocked() {
|
|
2670
|
+
let blocked = remoteBlockedClientError()
|
|
2671
|
+
logger.debug("Skipping getChannel due to remote block (\(blocked.error)).")
|
|
2672
|
+
getChannel.message = blocked.message
|
|
2673
|
+
getChannel.error = blocked.error
|
|
2516
2674
|
return getChannel
|
|
2517
2675
|
}
|
|
2518
2676
|
|
|
@@ -2536,9 +2694,14 @@ import UIKit
|
|
|
2536
2694
|
|
|
2537
2695
|
let result = performRequest(request, label: "getChannel")
|
|
2538
2696
|
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2697
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2698
|
+
statusCode: result.response?.statusCode,
|
|
2699
|
+
data: result.data,
|
|
2700
|
+
response: result.response
|
|
2701
|
+
)
|
|
2702
|
+
if rateLimit.blocked {
|
|
2703
|
+
getChannel.message = rateLimit.message
|
|
2704
|
+
getChannel.error = rateLimit.error
|
|
2542
2705
|
return getChannel
|
|
2543
2706
|
}
|
|
2544
2707
|
|
|
@@ -2616,9 +2779,10 @@ import UIKit
|
|
|
2616
2779
|
let listChannels: ListChannels = ListChannels()
|
|
2617
2780
|
|
|
2618
2781
|
// Check if rate limit was exceeded
|
|
2619
|
-
if
|
|
2620
|
-
|
|
2621
|
-
listChannels.error
|
|
2782
|
+
if isRemoteBlocked() {
|
|
2783
|
+
let blocked = remoteBlockedClientError()
|
|
2784
|
+
logger.debug("Skipping listChannels due to remote block (\(blocked.error)).")
|
|
2785
|
+
listChannels.error = blocked.error
|
|
2622
2786
|
return listChannels
|
|
2623
2787
|
}
|
|
2624
2788
|
|
|
@@ -2652,8 +2816,13 @@ import UIKit
|
|
|
2652
2816
|
|
|
2653
2817
|
let result = performRequest(request, label: "listChannels")
|
|
2654
2818
|
|
|
2655
|
-
|
|
2656
|
-
|
|
2819
|
+
let rateLimit = self.checkAndHandleRateLimitResponse(
|
|
2820
|
+
statusCode: result.response?.statusCode,
|
|
2821
|
+
data: result.data,
|
|
2822
|
+
response: result.response
|
|
2823
|
+
)
|
|
2824
|
+
if rateLimit.blocked {
|
|
2825
|
+
listChannels.error = rateLimit.error
|
|
2657
2826
|
return listChannels
|
|
2658
2827
|
}
|
|
2659
2828
|
|
|
@@ -2737,12 +2906,6 @@ import UIKit
|
|
|
2737
2906
|
return
|
|
2738
2907
|
}
|
|
2739
2908
|
|
|
2740
|
-
// Check if rate limit was exceeded
|
|
2741
|
-
if CapgoUpdater.rateLimitExceeded {
|
|
2742
|
-
logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
|
|
2743
|
-
return
|
|
2744
|
-
}
|
|
2745
|
-
|
|
2746
2909
|
guard !statsUrl.isEmpty else {
|
|
2747
2910
|
return
|
|
2748
2911
|
}
|
|
@@ -2795,6 +2958,12 @@ import UIKit
|
|
|
2795
2958
|
}
|
|
2796
2959
|
|
|
2797
2960
|
private func flushStatsQueue() {
|
|
2961
|
+
// While Retry-After is active, keep stats queued and skip the network call.
|
|
2962
|
+
if isRemoteBlocked() {
|
|
2963
|
+
logger.debug("Deferring stats flush until Retry-After expires.")
|
|
2964
|
+
return
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2798
2967
|
statsQueueLock.lock()
|
|
2799
2968
|
guard !statsQueue.isEmpty else {
|
|
2800
2969
|
statsQueueLock.unlock()
|
|
@@ -2805,7 +2974,6 @@ import UIKit
|
|
|
2805
2974
|
statsQueueLock.unlock()
|
|
2806
2975
|
|
|
2807
2976
|
let eventsToSend = queuedEvents.map(\.event)
|
|
2808
|
-
let onSentCallbacks = queuedEvents.compactMap(\.onSent)
|
|
2809
2977
|
|
|
2810
2978
|
operationQueue.maxConcurrentOperationCount = 1
|
|
2811
2979
|
|
|
@@ -2818,15 +2986,23 @@ import UIKit
|
|
|
2818
2986
|
encoder: JSONParameterEncoder.default,
|
|
2819
2987
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
2820
2988
|
).responseData { response in
|
|
2821
|
-
// Check for 429 rate limit
|
|
2822
|
-
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode) {
|
|
2989
|
+
// Check for 429 rate limit — requeue so events are not lost.
|
|
2990
|
+
if self.checkAndHandleRateLimitResponse(statusCode: response.response?.statusCode, data: response.data, response: response.response).blocked {
|
|
2991
|
+
self.requeueStatsEvents(queuedEvents)
|
|
2823
2992
|
semaphore.signal()
|
|
2824
2993
|
return
|
|
2825
2994
|
}
|
|
2826
2995
|
|
|
2827
2996
|
if let statusCode = response.response?.statusCode, !(200...299).contains(statusCode) {
|
|
2828
|
-
|
|
2829
|
-
|
|
2997
|
+
if CapgoUpdater.isTransientStatsFailure(statusCode) {
|
|
2998
|
+
self.requeueStatsEvents(queuedEvents)
|
|
2999
|
+
self.logger.error("Error sending stats batch")
|
|
3000
|
+
self.logger.debug("Retrying later, response code: \(statusCode)")
|
|
3001
|
+
} else {
|
|
3002
|
+
// Permanent rejection: retrying would loop forever and block the queue.
|
|
3003
|
+
self.logger.error("Dropping stats batch after permanent error")
|
|
3004
|
+
self.logger.debug("Response code: \(statusCode)")
|
|
3005
|
+
}
|
|
2830
3006
|
semaphore.signal()
|
|
2831
3007
|
return
|
|
2832
3008
|
}
|
|
@@ -2835,8 +3011,9 @@ import UIKit
|
|
|
2835
3011
|
case .success:
|
|
2836
3012
|
self.logger.info("Stats batch sent successfully")
|
|
2837
3013
|
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
2838
|
-
|
|
3014
|
+
self.runStatsCallbacks(queuedEvents)
|
|
2839
3015
|
case let .failure(error):
|
|
3016
|
+
self.requeueStatsEvents(queuedEvents)
|
|
2840
3017
|
self.logger.error("Error sending stats batch")
|
|
2841
3018
|
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
2842
3019
|
}
|
|
@@ -2847,6 +3024,25 @@ import UIKit
|
|
|
2847
3024
|
operationQueue.addOperation(operation)
|
|
2848
3025
|
}
|
|
2849
3026
|
|
|
3027
|
+
/// Only 429, request timeout and 5xx are worth retrying; other 4xx are permanent rejections.
|
|
3028
|
+
private static func isTransientStatsFailure(_ statusCode: Int) -> Bool {
|
|
3029
|
+
return statusCode == 429 || statusCode == 408 || statusCode >= 500
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
private func runStatsCallbacks(_ sentEvents: [QueuedStatsEvent]) {
|
|
3033
|
+
for sentEvent in sentEvents {
|
|
3034
|
+
sentEvent.onSent?()
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
private func requeueStatsEvents(_ events: [QueuedStatsEvent]) {
|
|
3039
|
+
guard !events.isEmpty else { return }
|
|
3040
|
+
statsQueueLock.lock()
|
|
3041
|
+
statsQueue.insert(contentsOf: events, at: 0)
|
|
3042
|
+
statsQueueLock.unlock()
|
|
3043
|
+
ensureStatsTimerStarted()
|
|
3044
|
+
}
|
|
3045
|
+
|
|
2850
3046
|
public func getBundleInfo(id: String?) -> BundleInfo {
|
|
2851
3047
|
var trueId = BundleInfo.VERSION_UNKNOWN
|
|
2852
3048
|
if id != nil {
|
|
@@ -726,8 +726,12 @@ extension UIWindow {
|
|
|
726
726
|
return
|
|
727
727
|
}
|
|
728
728
|
|
|
729
|
-
// Check if there's an actual update available
|
|
730
|
-
|
|
729
|
+
// Check if there's an actual update available. A manifest-only
|
|
730
|
+
// response legitimately has no URL (the files come from the
|
|
731
|
+
// manifest, not a zip), so only report "already on latest" when
|
|
732
|
+
// the URL is empty AND there is no manifest to download from.
|
|
733
|
+
let hasManifest = !(latest.manifest?.isEmpty ?? true)
|
|
734
|
+
if latestKind == "up_to_date" || (latest.url.isEmpty && !hasManifest) {
|
|
731
735
|
DispatchQueue.main.async {
|
|
732
736
|
progressAlert.dismiss(animated: true) {
|
|
733
737
|
self.showSuccess(message: "Channel set to \(name). Already on latest version.", plugin: plugin)
|