@otakit/capacitor-updater 1.0.0 → 1.1.0
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/README.md +35 -7
- package/android/src/main/java/com/updatekit/updater/BundleStore.java +4 -0
- package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +121 -10
- package/dist/esm/definitions.d.ts +7 -1
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +154 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,8 @@ Capacitor OTA updater plugin for OtaKit.
|
|
|
7
7
|
- checks the manifest endpoint for a newer bundle
|
|
8
8
|
- downloads and verifies OTA bundles
|
|
9
9
|
- stages updates safely
|
|
10
|
-
- activates them on the next launch or immediately
|
|
10
|
+
- activates them on the next launch, next resume, or immediately
|
|
11
|
+
- checks for updates on cold start and app resume (configurable interval)
|
|
11
12
|
- also supports fully manual update prompts when the app wants control
|
|
12
13
|
- requires `notifyAppReady()` as the success handshake
|
|
13
14
|
- rolls back automatically if the new bundle does not prove healthy
|
|
@@ -45,6 +46,7 @@ plugins: {
|
|
|
45
46
|
appReadyTimeout: 10000,
|
|
46
47
|
// Optional:
|
|
47
48
|
// channel: "staging",
|
|
49
|
+
// updateMode: "next-resume",
|
|
48
50
|
// updateMode: "manual",
|
|
49
51
|
// updateMode: "immediate",
|
|
50
52
|
}
|
|
@@ -74,14 +76,32 @@ In the hosted path, managed signing keys are already built in.
|
|
|
74
76
|
|
|
75
77
|
## Update modes
|
|
76
78
|
|
|
79
|
+
All automatic modes check for updates on **cold start** (always) and **app
|
|
80
|
+
resume**.
|
|
81
|
+
|
|
82
|
+
### Production modes
|
|
83
|
+
|
|
84
|
+
- `next-launch` (default)
|
|
85
|
+
check and download in the background on cold start and resume.
|
|
86
|
+
activate the staged bundle only on the next cold start.
|
|
87
|
+
zero disruption during a session — the user never sees a surprise reload.
|
|
88
|
+
|
|
89
|
+
- `next-resume` (more eager)
|
|
90
|
+
check and download in the background on cold start and resume.
|
|
91
|
+
activate the staged bundle on the next resume or cold start.
|
|
92
|
+
|
|
93
|
+
### Manual mode
|
|
94
|
+
|
|
77
95
|
- `manual`
|
|
78
|
-
no automatic
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
no automatic checks, no automatic staged activation.
|
|
97
|
+
the app integration drives everything via `check()`, `download()`, `apply()`, or `update()`.
|
|
98
|
+
|
|
99
|
+
### Dev/debug mode
|
|
100
|
+
|
|
81
101
|
- `immediate`
|
|
82
|
-
|
|
102
|
+
checks, downloads, and activates in one shot as soon as possible on cold start and resume (the user may briefly see the previous version before a reload).
|
|
103
|
+
primarily for development and testing — not recommended for production.
|
|
83
104
|
|
|
84
|
-
The default is `next-launch`.
|
|
85
105
|
|
|
86
106
|
## Runtime model
|
|
87
107
|
|
|
@@ -127,7 +147,8 @@ await OtaKit.notifyAppReady();
|
|
|
127
147
|
```
|
|
128
148
|
|
|
129
149
|
The plugin handles checking, downloading, activation, and rollback based on
|
|
130
|
-
`updateMode`.
|
|
150
|
+
`updateMode`. It checks on cold start and every time the app comes back from
|
|
151
|
+
the background (throttled by `checkInterval`).
|
|
131
152
|
|
|
132
153
|
For most apps, this is the entire runtime integration.
|
|
133
154
|
|
|
@@ -154,6 +175,13 @@ await OtaKit.apply();
|
|
|
154
175
|
Use the split flow when the app wants to download in the background and switch
|
|
155
176
|
only after explicit user confirmation.
|
|
156
177
|
|
|
178
|
+
## Throttle
|
|
179
|
+
|
|
180
|
+
All server checks are rate-limited by `checkInterval` (default 10 min).
|
|
181
|
+
This applies to automatic resume checks and to manual `check()` / `download()`
|
|
182
|
+
calls. Within the interval, calls return the staged bundle if one exists, or
|
|
183
|
+
null.
|
|
184
|
+
|
|
157
185
|
## Retention and deletion
|
|
158
186
|
|
|
159
187
|
- the builtin, current, fallback, and staged bundles are protected
|
|
@@ -21,6 +21,7 @@ import java.nio.charset.StandardCharsets;
|
|
|
21
21
|
import java.security.MessageDigest;
|
|
22
22
|
import java.util.concurrent.ExecutorService;
|
|
23
23
|
import java.util.concurrent.Executors;
|
|
24
|
+
import java.util.concurrent.atomic.AtomicBoolean;
|
|
24
25
|
|
|
25
26
|
@CapacitorPlugin(name = "OtaKit")
|
|
26
27
|
public class UpdaterPlugin extends Plugin {
|
|
@@ -39,11 +40,17 @@ public class UpdaterPlugin extends Plugin {
|
|
|
39
40
|
private String appId;
|
|
40
41
|
private String channel;
|
|
41
42
|
private java.util.List<ManifestVerifier.KeyEntry> manifestKeys = new java.util.ArrayList<>();
|
|
43
|
+
private long checkIntervalMs = 600_000;
|
|
44
|
+
private final AtomicBoolean checkInProgress = new AtomicBoolean(false);
|
|
42
45
|
private static final String DEFAULT_UPDATE_URL = "https://www.otakit.app/api/v1";
|
|
43
46
|
private static final String API_PATH_SUFFIX = "/api/v1";
|
|
44
47
|
private static final String UPDATE_MODE_MANUAL = "manual";
|
|
45
48
|
private static final String UPDATE_MODE_NEXT_LAUNCH = "next-launch";
|
|
49
|
+
private static final String UPDATE_MODE_NEXT_RESUME = "next-resume";
|
|
46
50
|
private static final String UPDATE_MODE_IMMEDIATE = "immediate";
|
|
51
|
+
private static final String KEY_LAST_CHECK_TIMESTAMP = "last_check_timestamp";
|
|
52
|
+
private static final String TRIGGER_LAUNCH = "launch";
|
|
53
|
+
private static final String TRIGGER_RESUME = "resume";
|
|
47
54
|
|
|
48
55
|
@Override
|
|
49
56
|
public void load() {
|
|
@@ -112,6 +119,7 @@ public class UpdaterPlugin extends Plugin {
|
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
this.appReadyTimeoutMs = Math.max(1000, getConfig().getInt("appReadyTimeout", 10_000));
|
|
122
|
+
this.checkIntervalMs = Math.max(600_000, getConfig().getInt("checkInterval", 600_000));
|
|
115
123
|
|
|
116
124
|
BundleInfo current = store.getCurrentBundle();
|
|
117
125
|
if (current.status == BundleStatus.TRIAL) {
|
|
@@ -135,26 +143,84 @@ public class UpdaterPlugin extends Plugin {
|
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
if (isAutomaticUpdateMode()) {
|
|
138
|
-
|
|
146
|
+
runAutomaticUpdate(TRIGGER_LAUNCH);
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
149
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
150
|
+
@Override
|
|
151
|
+
protected void handleOnResume() {
|
|
152
|
+
super.handleOnResume();
|
|
153
|
+
if (isAutomaticUpdateMode()) {
|
|
154
|
+
runAutomaticUpdate(TRIGGER_RESUME);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private void runAutomaticUpdate(String trigger) {
|
|
159
|
+
// Resume-only guards
|
|
160
|
+
if (TRIGGER_RESUME.equals(trigger)) {
|
|
161
|
+
if (UPDATE_MODE_MANUAL.equals(updateMode)) return;
|
|
162
|
+
|
|
163
|
+
// next-resume and immediate: activate staged bundle on resume without server check
|
|
164
|
+
if ((UPDATE_MODE_NEXT_RESUME.equals(updateMode) || UPDATE_MODE_IMMEDIATE.equals(updateMode))) {
|
|
165
|
+
String stagedId = store.getStagedBundleId();
|
|
166
|
+
if (stagedId != null && store.getBundle(stagedId) != null) {
|
|
147
167
|
activateStagedBundleForReload();
|
|
148
168
|
reloadWebView();
|
|
169
|
+
return;
|
|
149
170
|
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (shouldThrottleCheck()) return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Acquire in-flight guard (submit-time)
|
|
177
|
+
if (!checkInProgress.compareAndSet(false, true)) return;
|
|
178
|
+
|
|
179
|
+
if (UPDATE_MODE_IMMEDIATE.equals(updateMode)) {
|
|
180
|
+
// Immediate: check+download, activate if found
|
|
181
|
+
executor.execute(() -> {
|
|
182
|
+
try {
|
|
183
|
+
BundleInfo result = performCheckAndDownload(null, true);
|
|
184
|
+
recordCheckTimestamp();
|
|
185
|
+
if (result != null) {
|
|
186
|
+
activateStagedBundleForReload();
|
|
187
|
+
reloadWebView();
|
|
188
|
+
}
|
|
189
|
+
} catch (Exception e) {
|
|
190
|
+
android.util.Log.w("OtaKit", "immediate update failed (" + trigger + ")", e);
|
|
191
|
+
} finally {
|
|
192
|
+
checkInProgress.set(false);
|
|
153
193
|
}
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// next-launch / next-resume: background check+download (fire-and-forget)
|
|
199
|
+
executor.execute(() -> {
|
|
200
|
+
try {
|
|
201
|
+
performCheckAndDownload(null, true);
|
|
202
|
+
recordCheckTimestamp();
|
|
203
|
+
} catch (Exception ignored) {
|
|
204
|
+
// Check failed — timestamp not recorded, will retry on next trigger
|
|
205
|
+
} finally {
|
|
206
|
+
checkInProgress.set(false);
|
|
154
207
|
}
|
|
155
208
|
});
|
|
156
209
|
}
|
|
157
210
|
|
|
211
|
+
private boolean shouldThrottleCheck() {
|
|
212
|
+
long lastCheck = store.getPrefs().getLong(KEY_LAST_CHECK_TIMESTAMP, 0);
|
|
213
|
+
if (lastCheck <= 0) return false;
|
|
214
|
+
long elapsed = System.currentTimeMillis() - lastCheck;
|
|
215
|
+
return elapsed < checkIntervalMs;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private void recordCheckTimestamp() {
|
|
219
|
+
store.getPrefs().edit()
|
|
220
|
+
.putLong(KEY_LAST_CHECK_TIMESTAMP, System.currentTimeMillis())
|
|
221
|
+
.apply();
|
|
222
|
+
}
|
|
223
|
+
|
|
158
224
|
private boolean shouldActivateStagedOnLaunch() {
|
|
159
225
|
return !UPDATE_MODE_MANUAL.equals(updateMode);
|
|
160
226
|
}
|
|
@@ -175,12 +241,15 @@ public class UpdaterPlugin extends Plugin {
|
|
|
175
241
|
if (UPDATE_MODE_MANUAL.equals(raw)) {
|
|
176
242
|
return UPDATE_MODE_MANUAL;
|
|
177
243
|
}
|
|
244
|
+
if (UPDATE_MODE_NEXT_RESUME.equals(raw)) {
|
|
245
|
+
return UPDATE_MODE_NEXT_RESUME;
|
|
246
|
+
}
|
|
178
247
|
if (UPDATE_MODE_IMMEDIATE.equals(raw)) {
|
|
179
248
|
return UPDATE_MODE_IMMEDIATE;
|
|
180
249
|
}
|
|
181
250
|
|
|
182
251
|
android.util.Log.w(
|
|
183
|
-
"
|
|
252
|
+
"OtaKit",
|
|
184
253
|
"Unknown updateMode '" + raw + "', defaulting to 'next-launch'"
|
|
185
254
|
);
|
|
186
255
|
return UPDATE_MODE_NEXT_LAUNCH;
|
|
@@ -209,10 +278,34 @@ public class UpdaterPlugin extends Plugin {
|
|
|
209
278
|
|
|
210
279
|
@PluginMethod
|
|
211
280
|
public void check(PluginCall call) {
|
|
281
|
+
// Respect throttle — return staged bundle as a LatestVersion if available, else null
|
|
282
|
+
if (shouldThrottleCheck() || !checkInProgress.compareAndSet(false, true)) {
|
|
283
|
+
String stagedId = store.getStagedBundleId();
|
|
284
|
+
if (stagedId != null) {
|
|
285
|
+
BundleInfo staged = store.getBundle(stagedId);
|
|
286
|
+
if (staged != null) {
|
|
287
|
+
JSObject result = new JSObject();
|
|
288
|
+
result.put("version", staged.version);
|
|
289
|
+
result.put("url", "");
|
|
290
|
+
result.put("sha256", staged.sha256 != null ? staged.sha256 : "");
|
|
291
|
+
result.put("size", 0);
|
|
292
|
+
result.put("downloaded", true);
|
|
293
|
+
if (staged.releaseId != null) {
|
|
294
|
+
result.put("releaseId", staged.releaseId);
|
|
295
|
+
}
|
|
296
|
+
call.resolve(result);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
call.resolve((JSObject) null);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
212
303
|
String targetChannel = resolveTargetChannel(null);
|
|
213
304
|
executor.execute(() -> {
|
|
214
305
|
try {
|
|
215
306
|
ManifestClient.LatestManifest latest = fetchLatest(targetChannel);
|
|
307
|
+
// check() does NOT record timestamp — only download() and automatic paths do.
|
|
308
|
+
// This keeps check() -> download() working without the throttle blocking download().
|
|
216
309
|
if (latest == null) {
|
|
217
310
|
call.resolve((JSObject) null);
|
|
218
311
|
} else {
|
|
@@ -221,15 +314,31 @@ public class UpdaterPlugin extends Plugin {
|
|
|
221
314
|
}
|
|
222
315
|
} catch (Exception e) {
|
|
223
316
|
call.reject("check failed: " + e.getMessage());
|
|
317
|
+
} finally {
|
|
318
|
+
checkInProgress.set(false);
|
|
224
319
|
}
|
|
225
320
|
});
|
|
226
321
|
}
|
|
227
322
|
|
|
228
323
|
@PluginMethod
|
|
229
324
|
public void download(PluginCall call) {
|
|
325
|
+
// Respect throttle — return staged info if available, else null
|
|
326
|
+
if (shouldThrottleCheck() || !checkInProgress.compareAndSet(false, true)) {
|
|
327
|
+
String stagedId = store.getStagedBundleId();
|
|
328
|
+
if (stagedId != null) {
|
|
329
|
+
BundleInfo staged = store.getBundle(stagedId);
|
|
330
|
+
if (staged != null) {
|
|
331
|
+
call.resolve(staged.toJSObject());
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
call.resolve((JSObject) null);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
230
338
|
executor.execute(() -> {
|
|
231
339
|
try {
|
|
232
340
|
BundleInfo bundle = performCheckAndDownload(null, true);
|
|
341
|
+
recordCheckTimestamp();
|
|
233
342
|
if (bundle == null) {
|
|
234
343
|
call.resolve((JSObject) null);
|
|
235
344
|
} else {
|
|
@@ -237,6 +346,8 @@ public class UpdaterPlugin extends Plugin {
|
|
|
237
346
|
}
|
|
238
347
|
} catch (Exception e) {
|
|
239
348
|
call.reject("download failed: " + e.getMessage());
|
|
349
|
+
} finally {
|
|
350
|
+
checkInProgress.set(false);
|
|
240
351
|
}
|
|
241
352
|
});
|
|
242
353
|
}
|
|
@@ -57,7 +57,7 @@ export interface OtaKitState {
|
|
|
57
57
|
export interface OtaKitDebugState extends OtaKitState {
|
|
58
58
|
fallback: BundleInfo;
|
|
59
59
|
}
|
|
60
|
-
export type OtaKitUpdateMode = 'manual' | 'next-launch' | 'immediate';
|
|
60
|
+
export type OtaKitUpdateMode = 'manual' | 'next-launch' | 'next-resume' | 'immediate';
|
|
61
61
|
export interface OtaKitManifestKey {
|
|
62
62
|
kid: string;
|
|
63
63
|
key: string;
|
|
@@ -72,6 +72,12 @@ export interface OtaKitConfig {
|
|
|
72
72
|
channel?: string;
|
|
73
73
|
/** Overall update behavior. Defaults to next-launch. */
|
|
74
74
|
updateMode?: OtaKitUpdateMode;
|
|
75
|
+
/**
|
|
76
|
+
* Minimum milliseconds between automatic update checks.
|
|
77
|
+
* Applies to both resume-triggered and manual check()/download() calls.
|
|
78
|
+
* Debug APIs bypass this throttle. Defaults to 600000 (10 min).
|
|
79
|
+
*/
|
|
80
|
+
checkInterval?: number;
|
|
75
81
|
/** Milliseconds to wait for notifyAppReady(). Defaults to 10000. */
|
|
76
82
|
appReadyTimeout?: number;
|
|
77
83
|
/** Custom API base URL for self-hosted or custom servers. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D;;GAEG;AACH,oBAAY,YAAY;IACtB,+BAA+B;IAC/B,OAAO,YAAY;IACnB,iDAAiD;IACjD,OAAO,YAAY;IACnB,mCAAmC;IACnC,KAAK,UAAU;IACf,wBAAwB;IACxB,OAAO,YAAY;IACnB,yDAAyD;IACzD,KAAK,UAAU;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,+BAA+B;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,mCAAmC;IACnC,MAAM,EAAE,YAAY,CAAC;IACrB,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,qBAAqB;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,mBAAmB;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,uBAAuB;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,uDAAuD;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,oCAAoC;IACpC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,UAAU,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,UAAU,CAAC;IACpB,MAAM,EAAE,UAAU,GAAG,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,gBAAiB,SAAQ,WAAW;IACnD,QAAQ,EAAE,UAAU,CAAC;CACtB;AAED,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,aAAa,GAAG,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"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"}
|
|
@@ -7,9 +7,15 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
7
7
|
private enum UpdateMode: String {
|
|
8
8
|
case manual
|
|
9
9
|
case nextLaunch = "next-launch"
|
|
10
|
+
case nextResume = "next-resume"
|
|
10
11
|
case immediate
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
private enum Trigger {
|
|
15
|
+
case launch
|
|
16
|
+
case resume
|
|
17
|
+
}
|
|
18
|
+
|
|
13
19
|
public let identifier = "UpdaterPlugin"
|
|
14
20
|
public let jsName = "OtaKit"
|
|
15
21
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -39,8 +45,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
39
45
|
private var channel: String?
|
|
40
46
|
private var manifestKeys: [(kid: String, key: Data)] = []
|
|
41
47
|
private var trialTimeoutWorkItem: DispatchWorkItem?
|
|
48
|
+
private var checkIntervalMs: Int = 600_000
|
|
49
|
+
private let isCheckInProgress = NSLock()
|
|
50
|
+
private var checkInProgress = false
|
|
51
|
+
private var foregroundObserver: NSObjectProtocol?
|
|
42
52
|
private static let defaultUpdateURL = "https://www.otakit.app/api/v1"
|
|
43
53
|
private static let apiPathSuffix = "/api/v1"
|
|
54
|
+
private static let lastCheckTimestampKey = "otakit_last_check_timestamp"
|
|
44
55
|
|
|
45
56
|
public override func load() {
|
|
46
57
|
let envUpdateUrl = ProcessInfo.processInfo.environment["OTAKIT_SERVER_URL"]
|
|
@@ -55,6 +66,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
55
66
|
updateMode = resolveUpdateMode(configured: updateModeRaw)
|
|
56
67
|
downloader = Downloader(allowInsecureUrls: allowInsecureUrls)
|
|
57
68
|
appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
|
|
69
|
+
checkIntervalMs = max(600_000, getConfig().getInt("checkInterval", 600_000))
|
|
58
70
|
|
|
59
71
|
let rawKeysValue = getConfig().getArray("manifestKeys")
|
|
60
72
|
if let rawKeys = rawKeysValue as? [[String: String]] {
|
|
@@ -99,31 +111,124 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
99
111
|
}
|
|
100
112
|
|
|
101
113
|
if isAutomaticUpdateMode() {
|
|
102
|
-
|
|
114
|
+
runAutomaticUpdate(trigger: .launch)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if isAutomaticUpdateMode() {
|
|
118
|
+
foregroundObserver = NotificationCenter.default.addObserver(
|
|
119
|
+
forName: UIApplication.willEnterForegroundNotification,
|
|
120
|
+
object: nil,
|
|
121
|
+
queue: .main
|
|
122
|
+
) { [weak self] _ in
|
|
123
|
+
self?.handleAppWillEnterForeground()
|
|
124
|
+
}
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
128
|
+
deinit {
|
|
129
|
+
if let observer = foregroundObserver {
|
|
130
|
+
NotificationCenter.default.removeObserver(observer)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func handleAppWillEnterForeground() {
|
|
135
|
+
runAutomaticUpdate(trigger: .resume)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private func runAutomaticUpdate(trigger: Trigger) {
|
|
139
|
+
// Resume-only guards
|
|
140
|
+
if trigger == .resume {
|
|
141
|
+
if updateMode == .manual { return }
|
|
142
|
+
|
|
143
|
+
// next-resume and immediate: activate staged bundle on resume without server check
|
|
144
|
+
if (updateMode == .nextResume || updateMode == .immediate),
|
|
145
|
+
let stagedId = store.getStagedBundleId(),
|
|
146
|
+
store.getBundle(id: stagedId) != nil {
|
|
147
|
+
activateStagedBundleForReload()
|
|
148
|
+
reloadWebView()
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if shouldThrottleCheck() { return }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Acquire in-flight guard (submit-time)
|
|
156
|
+
guard claimCheckInProgress() else { return }
|
|
157
|
+
|
|
158
|
+
// Immediate mode on cold start: block until check+download+activate completes
|
|
159
|
+
if updateMode == .immediate && trigger == .launch {
|
|
111
160
|
Task {
|
|
112
|
-
|
|
161
|
+
defer { releaseCheckInProgress() }
|
|
162
|
+
do {
|
|
163
|
+
let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
164
|
+
recordCheckTimestamp()
|
|
165
|
+
if result != nil {
|
|
166
|
+
activateStagedBundleForReload()
|
|
167
|
+
reloadWebView()
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
print("[OtaKit] immediate startup update failed: \(error.localizedDescription)")
|
|
171
|
+
}
|
|
113
172
|
}
|
|
114
|
-
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Immediate mode on resume: background check+download, activate when done
|
|
177
|
+
if updateMode == .immediate && trigger == .resume {
|
|
115
178
|
Task {
|
|
179
|
+
defer { releaseCheckInProgress() }
|
|
116
180
|
do {
|
|
117
|
-
let
|
|
118
|
-
|
|
181
|
+
let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
182
|
+
recordCheckTimestamp()
|
|
183
|
+
if result != nil {
|
|
119
184
|
activateStagedBundleForReload()
|
|
120
185
|
reloadWebView()
|
|
121
186
|
}
|
|
122
187
|
} catch {
|
|
123
|
-
print("[
|
|
188
|
+
print("[OtaKit] immediate resume update failed: \(error.localizedDescription)")
|
|
124
189
|
}
|
|
125
190
|
}
|
|
191
|
+
return
|
|
126
192
|
}
|
|
193
|
+
|
|
194
|
+
// next-launch / next-resume: background check+download (fire-and-forget)
|
|
195
|
+
Task {
|
|
196
|
+
defer { releaseCheckInProgress() }
|
|
197
|
+
do {
|
|
198
|
+
_ = try await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
199
|
+
recordCheckTimestamp()
|
|
200
|
+
} catch {
|
|
201
|
+
// Check failed — timestamp not recorded, will retry on next trigger
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private func shouldThrottleCheck() -> Bool {
|
|
207
|
+
let lastCheck = UserDefaults.standard.double(forKey: UpdaterPlugin.lastCheckTimestampKey)
|
|
208
|
+
guard lastCheck > 0 else { return false }
|
|
209
|
+
let elapsed = Date().timeIntervalSince1970 * 1000 - lastCheck
|
|
210
|
+
return elapsed < Double(checkIntervalMs)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private func recordCheckTimestamp() {
|
|
214
|
+
UserDefaults.standard.set(
|
|
215
|
+
Date().timeIntervalSince1970 * 1000,
|
|
216
|
+
forKey: UpdaterPlugin.lastCheckTimestampKey
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func claimCheckInProgress() -> Bool {
|
|
221
|
+
isCheckInProgress.lock()
|
|
222
|
+
defer { isCheckInProgress.unlock() }
|
|
223
|
+
if checkInProgress { return false }
|
|
224
|
+
checkInProgress = true
|
|
225
|
+
return true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private func releaseCheckInProgress() {
|
|
229
|
+
isCheckInProgress.lock()
|
|
230
|
+
defer { isCheckInProgress.unlock() }
|
|
231
|
+
checkInProgress = false
|
|
127
232
|
}
|
|
128
233
|
|
|
129
234
|
private func shouldActivateStagedOnLaunch() -> Bool {
|
|
@@ -141,10 +246,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
141
246
|
return .nextLaunch
|
|
142
247
|
case UpdateMode.manual.rawValue:
|
|
143
248
|
return .manual
|
|
249
|
+
case UpdateMode.nextResume.rawValue:
|
|
250
|
+
return .nextResume
|
|
144
251
|
case UpdateMode.immediate.rawValue:
|
|
145
252
|
return .immediate
|
|
146
253
|
default:
|
|
147
|
-
print("[
|
|
254
|
+
print("[OtaKit] Unknown updateMode '\(raw)', defaulting to 'next-launch'")
|
|
148
255
|
return .nextLaunch
|
|
149
256
|
}
|
|
150
257
|
}
|
|
@@ -189,10 +296,33 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
189
296
|
}
|
|
190
297
|
|
|
191
298
|
@objc func check(_ call: CAPPluginCall) {
|
|
299
|
+
// Respect throttle — return staged bundle as a LatestVersion if available, else null
|
|
300
|
+
if shouldThrottleCheck() || !claimCheckInProgress() {
|
|
301
|
+
if let stagedId = store.getStagedBundleId(),
|
|
302
|
+
let staged = store.getBundle(id: stagedId) {
|
|
303
|
+
var payload: [String: Any] = [
|
|
304
|
+
"version": staged.version,
|
|
305
|
+
"url": "",
|
|
306
|
+
"sha256": staged.sha256 ?? "",
|
|
307
|
+
"size": 0,
|
|
308
|
+
"downloaded": true,
|
|
309
|
+
]
|
|
310
|
+
if let releaseId = staged.releaseId {
|
|
311
|
+
payload["releaseId"] = releaseId
|
|
312
|
+
}
|
|
313
|
+
call.resolve(payload)
|
|
314
|
+
} else {
|
|
315
|
+
call.resolve()
|
|
316
|
+
}
|
|
317
|
+
return
|
|
318
|
+
}
|
|
192
319
|
let targetChannel = resolveTargetChannel(nil)
|
|
193
320
|
Task {
|
|
321
|
+
defer { releaseCheckInProgress() }
|
|
194
322
|
do {
|
|
195
323
|
let latest = try await fetchLatest(channel: targetChannel)
|
|
324
|
+
// check() does NOT record timestamp — only download() and automatic paths do.
|
|
325
|
+
// This keeps check() -> download() working without the throttle blocking download().
|
|
196
326
|
if let latest {
|
|
197
327
|
let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
|
|
198
328
|
call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
|
|
@@ -225,9 +355,21 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
225
355
|
}
|
|
226
356
|
|
|
227
357
|
@objc func download(_ call: CAPPluginCall) {
|
|
358
|
+
// Respect throttle — return staged info if available, else null
|
|
359
|
+
if shouldThrottleCheck() || !claimCheckInProgress() {
|
|
360
|
+
if let stagedId = store.getStagedBundleId(),
|
|
361
|
+
let staged = store.getBundle(id: stagedId) {
|
|
362
|
+
call.resolve(staged.toDictionary())
|
|
363
|
+
} else {
|
|
364
|
+
call.resolve()
|
|
365
|
+
}
|
|
366
|
+
return
|
|
367
|
+
}
|
|
228
368
|
Task {
|
|
369
|
+
defer { releaseCheckInProgress() }
|
|
229
370
|
do {
|
|
230
371
|
let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
|
|
372
|
+
recordCheckTimestamp()
|
|
231
373
|
if let bundle {
|
|
232
374
|
call.resolve(bundle.toDictionary())
|
|
233
375
|
} else {
|