@otakit/capacitor-updater 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Capacitor OTA updater plugin for OtaKit.
4
4
 
5
5
  ## What it does
6
6
 
7
- - checks the manifest endpoint for a newer bundle
7
+ - fetches the latest manifest for its release lane from the CDN
8
8
  - downloads and verifies OTA bundles
9
9
  - stages updates safely
10
10
  - activates them on the next launch, next resume, or immediately
@@ -14,6 +14,11 @@ Capacitor OTA updater plugin for OtaKit.
14
14
  - requires `notifyAppReady()` as the success handshake
15
15
  - rolls back automatically if the new bundle does not prove healthy
16
16
 
17
+ OtaKit publishes signed static manifests into object storage behind a CDN. The
18
+ plugin fetches the manifest for its `appId + channel + runtimeVersion` lane,
19
+ verifies it, compares it against the current and staged bundle locally, and
20
+ only downloads when the manifest actually points at something newer.
21
+
17
22
  For normal app code, the main public methods are:
18
23
 
19
24
  ```ts
@@ -35,9 +40,6 @@ For manual mode, `getState()` tells you if something is already staged,
35
40
  `check()` tells you whether a newer update exists, `download()` stages it, and
36
41
  `update()` is the one-shot helper that downloads and applies the newest update.
37
42
 
38
- Manual inspection and support methods like state inspection and reset live under
39
- `OtaKit.debug`.
40
-
41
43
  ## Hosted config
42
44
 
43
45
  ```ts
@@ -57,12 +59,14 @@ plugins: {
57
59
 
58
60
  Advanced overrides for self-hosting or custom trust only:
59
61
 
60
- - `serverUrl`
62
+ - `cdnUrl` for manifest and bundle delivery
63
+ - `ingestUrl` for event ingest requests
64
+ - `serverUrl` for self-hosted control-plane tooling such as the CLI. The native runtime uses `cdnUrl` and `ingestUrl` instead.
61
65
  - `manifestKeys`
62
66
  - `allowInsecureUrls`
63
67
 
64
- Hosted OtaKit already points at `https://otakit.app/api/v1` and already trusts
65
- the managed manifest signing keys.
68
+ Hosted OtaKit already points at the managed ingest service and CDN and already
69
+ trusts the managed manifest signing keys.
66
70
 
67
71
  ## Channels vs runtimeVersion
68
72
 
@@ -84,11 +88,12 @@ When `runtimeVersion` is set:
84
88
 
85
89
  The plugin does not just download from a URL and trust the result.
86
90
 
87
- 1. it fetches a manifest from the server
91
+ 1. it fetches the latest manifest from the CDN for its app + channel + runtimeVersion lane
88
92
  2. it verifies the manifest signature when manifest keys are configured
89
- 3. it downloads the bundle zip
90
- 4. it verifies the zip against the manifest `sha256`
91
- 5. it stages and activates the bundle
93
+ 3. it compares that manifest against the current and staged bundle already on the device
94
+ 4. if the manifest is newer, it downloads the bundle zip
95
+ 5. it verifies the zip against the manifest `sha256`
96
+ 6. it stages and activates the bundle
92
97
 
93
98
  In the hosted path, managed signing keys are already built in.
94
99
 
@@ -114,7 +119,7 @@ resume**.
114
119
  no automatic checks, no automatic staged activation.
115
120
  the app integration drives everything via `check()`, `download()`, `apply()`, or `update()`.
116
121
 
117
- ### Dev/debug mode
122
+ ### Development mode
118
123
 
119
124
  - `immediate`
120
125
  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).
@@ -165,8 +170,9 @@ await OtaKit.notifyAppReady();
165
170
  ```
166
171
 
167
172
  The plugin handles checking, downloading, activation, and rollback based on
168
- `updateMode`. It checks on cold start and every time the app comes back from
169
- the background (throttled by `checkInterval`).
173
+ `updateMode`. In `next-launch` and `next-resume`, it checks on cold start and
174
+ every time the app comes back from the background, throttled by `checkInterval`.
175
+ `immediate` bypasses that throttle.
170
176
 
171
177
  For most apps, this is the entire runtime integration.
172
178
 
@@ -195,10 +201,9 @@ only after explicit user confirmation.
195
201
 
196
202
  ## Throttle
197
203
 
198
- All server checks are rate-limited by `checkInterval` (default 10 min).
199
- This applies to automatic resume checks and to manual `check()` / `download()`
200
- calls. Within the interval, calls return the staged bundle if one exists, or
201
- null.
204
+ `checkInterval` (default 10 min) only applies to automatic checks in
205
+ `next-launch` and `next-resume`. Manual `check()` / `download()` calls are
206
+ always live, and `immediate` mode ignores the interval entirely.
202
207
 
203
208
  ## Retention and deletion
204
209
 
@@ -4,52 +4,57 @@ import java.io.OutputStream;
4
4
  import java.net.HttpURLConnection;
5
5
  import java.net.URL;
6
6
  import java.nio.charset.StandardCharsets;
7
+ import java.text.SimpleDateFormat;
8
+ import java.util.Date;
9
+ import java.util.Locale;
10
+ import java.util.TimeZone;
7
11
  import java.util.concurrent.ExecutorService;
8
12
  import java.util.concurrent.Executors;
13
+ import java.util.UUID;
9
14
  import org.json.JSONObject;
10
15
 
11
- final class StatsClient {
16
+ final class DeviceEventClient {
12
17
 
13
18
  private static final ExecutorService executor = Executors.newSingleThreadExecutor();
14
19
 
15
- private StatsClient() {}
20
+ private DeviceEventClient() {}
16
21
 
17
22
  static void send(
18
- String updateUrl,
23
+ String ingestUrl,
19
24
  String appId,
20
25
  String platform,
21
26
  String action,
22
27
  String bundleVersion,
23
28
  String channel,
29
+ String runtimeVersion,
24
30
  String releaseId,
25
31
  String nativeBuild,
26
- String errorMessage
32
+ String detail
27
33
  ) {
28
34
  executor.execute(() -> {
29
35
  HttpURLConnection connection = null;
30
36
  try {
31
- String base = updateUrl.replaceAll("/+$", "");
32
- URL url = new URL(base + "/stats");
37
+ String base = ingestUrl.replaceAll("/+$", "");
38
+ URL url = new URL(base + "/events");
33
39
 
34
40
  JSONObject payload = new JSONObject();
41
+ payload.put("eventId", UUID.randomUUID().toString());
42
+ payload.put("sentAt", iso8601Now());
35
43
  payload.put("platform", platform);
36
44
  payload.put("action", action);
37
- if (bundleVersion != null) {
38
- payload.put("bundleVersion", bundleVersion);
39
- }
45
+ payload.put("bundleVersion", bundleVersion);
40
46
  if (channel != null && !channel.isEmpty()) {
41
47
  payload.put("channel", channel);
42
48
  }
43
- if (releaseId != null && !releaseId.isEmpty()) {
44
- payload.put("releaseId", releaseId);
45
- }
46
- if (nativeBuild != null) {
47
- payload.put("nativeBuild", nativeBuild);
49
+ if (runtimeVersion != null && !runtimeVersion.isEmpty()) {
50
+ payload.put("runtimeVersion", runtimeVersion);
48
51
  }
49
- if (errorMessage != null) {
52
+ payload.put("releaseId", releaseId);
53
+ payload.put("nativeBuild", nativeBuild);
54
+ if (detail != null) {
50
55
  String truncated =
51
- errorMessage.length() > 500 ? errorMessage.substring(0, 500) : errorMessage;
52
- payload.put("errorMessage", truncated);
56
+ detail.length() > 500 ? detail.substring(0, 500) : detail;
57
+ payload.put("detail", truncated);
53
58
  }
54
59
 
55
60
  byte[] body = payload.toString().getBytes(StandardCharsets.UTF_8);
@@ -66,10 +71,10 @@ final class StatsClient {
66
71
  output.write(body);
67
72
  }
68
73
 
69
- // Fire and forget - just trigger the request
74
+ // Device events are best-effort and should never block the update flow.
70
75
  connection.getResponseCode();
71
76
  } catch (Exception ignored) {
72
- // Stats are best-effort, don't fail on errors
77
+ // Device events are best-effort, don't fail on errors
73
78
  } finally {
74
79
  if (connection != null) {
75
80
  connection.disconnect();
@@ -77,4 +82,10 @@ final class StatsClient {
77
82
  }
78
83
  });
79
84
  }
85
+
86
+ private static String iso8601Now() {
87
+ SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
88
+ formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
89
+ return formatter.format(new Date());
90
+ }
80
91
  }
@@ -7,18 +7,17 @@ import java.util.Locale;
7
7
 
8
8
  final class HostedManifestKeys {
9
9
 
10
- private static final String MANAGED_SERVER_URL = "https://www.otakit.app/api/v1";
10
+ private static final String MANAGED_CDN_URL = "https://cdn.otakit.app";
11
11
 
12
12
  private HostedManifestKeys() {}
13
13
 
14
- static boolean matchesManagedServer(String updateUrl) {
15
- if (updateUrl == null) {
14
+ static boolean matchesManagedManifestUrl(String cdnUrl) {
15
+ if (cdnUrl == null) {
16
16
  return false;
17
17
  }
18
18
 
19
- String normalized = updateUrl.trim().replaceAll("/+$", "").toLowerCase(Locale.ROOT);
20
- return normalized.equals(MANAGED_SERVER_URL)
21
- || normalized.equals("https://otakit.app/api/v1");
19
+ String normalized = cdnUrl.trim().replaceAll("/+$", "").toLowerCase(Locale.ROOT);
20
+ return normalized.equals(MANAGED_CDN_URL) || normalized.equals("https://www.otakit.app");
22
21
  }
23
22
 
24
23
  static List<ManifestVerifier.KeyEntry> createDefaultKeys() {
@@ -9,6 +9,9 @@ import org.json.JSONObject;
9
9
 
10
10
  final class ManifestClient {
11
11
 
12
+ private static final String BASE_CHANNEL_KEY = "__base__";
13
+ private static final String DEFAULT_RUNTIME_KEY = "__default__";
14
+
12
15
  static final class ManifestSignature {
13
16
 
14
17
  final String kid;
@@ -63,41 +66,33 @@ final class ManifestClient {
63
66
  }
64
67
 
65
68
  static LatestManifest fetchLatest(
66
- String updateUrl,
69
+ String cdnUrl,
67
70
  String appId,
68
71
  String channel,
69
- String currentVersion,
70
- String currentReleaseId,
71
72
  String runtimeVersion,
72
- String platform,
73
73
  boolean allowInsecureUrls,
74
74
  java.util.List<ManifestVerifier.KeyEntry> manifestKeys
75
75
  ) throws Exception {
76
- String base = updateUrl.replaceAll("/+$", "");
77
- URL url = new URL(base + "/manifest");
76
+ String base = cdnUrl.replaceAll("/+$", "");
77
+ String channelKey = channel != null && !channel.trim().isEmpty() ? channel.trim() : BASE_CHANNEL_KEY;
78
+ String runtimeKey =
79
+ runtimeVersion != null && !runtimeVersion.trim().isEmpty()
80
+ ? runtimeVersion.trim()
81
+ : DEFAULT_RUNTIME_KEY;
82
+ URL url = new URL(
83
+ base + "/manifests/" + appId + "/" + channelKey + "/" + runtimeKey + "/manifest.json"
84
+ );
78
85
 
79
86
  requireHTTPS(url, allowInsecureUrls);
80
87
 
81
88
  HttpURLConnection connection = (HttpURLConnection) url.openConnection();
82
89
  try {
83
90
  connection.setRequestMethod("GET");
84
- connection.setRequestProperty("X-App-Id", appId);
85
- connection.setRequestProperty("X-Platform", platform);
86
- if (channel != null && !channel.trim().isEmpty()) {
87
- connection.setRequestProperty("X-Channel", channel);
88
- }
89
- connection.setRequestProperty("X-Current-Version", currentVersion);
90
- if (currentReleaseId != null && !currentReleaseId.trim().isEmpty()) {
91
- connection.setRequestProperty("X-Release-Id", currentReleaseId);
92
- }
93
- if (runtimeVersion != null && !runtimeVersion.trim().isEmpty()) {
94
- connection.setRequestProperty("X-Runtime-Version", runtimeVersion);
95
- }
96
91
  connection.setConnectTimeout(15_000);
97
92
  connection.setReadTimeout(30_000);
98
93
 
99
94
  int status = connection.getResponseCode();
100
- if (status == 204) {
95
+ if (status == 404 || status == 204) {
101
96
  return null;
102
97
  }
103
98
  if (status != 200) {
@@ -125,14 +120,18 @@ final class ManifestClient {
125
120
  }
126
121
 
127
122
  ManifestSignature signature = parseSignature(json.optJSONObject("signature"));
128
- ManifestSignature signatureV2 = parseSignature(json.optJSONObject("signatureV2"));
129
123
 
130
124
  String releaseId = null;
131
125
  if (json.has("releaseId") && !json.isNull("releaseId")) {
132
- releaseId = json.getString("releaseId");
126
+ releaseId = json.getString("releaseId").trim();
127
+ }
128
+ if (releaseId != null && releaseId.isEmpty()) {
129
+ releaseId = null;
130
+ }
131
+ if (releaseId == null) {
132
+ throw new IllegalStateException("Manifest response missing required releaseId");
133
133
  }
134
134
 
135
- // Validate download URL scheme
136
135
  requireHTTPS(new URL(downloadUrl), allowInsecureUrls);
137
136
 
138
137
  if (manifestKeys == null || manifestKeys.isEmpty()) {
@@ -142,36 +141,23 @@ final class ManifestClient {
142
141
  );
143
142
  }
144
143
 
145
- // Verify manifest signature if signing keys are configured
146
144
  if (manifestKeys != null && !manifestKeys.isEmpty()) {
147
- if (signatureV2 != null) {
148
- ManifestVerifier.verify(
149
- appId,
150
- channel,
151
- platform,
152
- version,
153
- sha256,
154
- size,
155
- responseRuntimeVersion,
156
- signatureV2,
157
- manifestKeys
158
- );
159
- } else if (signature != null) {
160
- ManifestVerifier.verifyLegacy(
161
- appId,
162
- channel,
163
- platform,
164
- version,
165
- sha256,
166
- size,
167
- signature,
168
- manifestKeys
169
- );
170
- } else {
145
+ if (signature == null) {
171
146
  throw new IllegalStateException(
172
147
  "Manifest signature missing but signing keys are configured"
173
148
  );
174
149
  }
150
+
151
+ ManifestVerifier.verify(
152
+ appId,
153
+ channel,
154
+ version,
155
+ sha256,
156
+ size,
157
+ responseRuntimeVersion,
158
+ signature,
159
+ manifestKeys
160
+ );
175
161
  }
176
162
 
177
163
  return new LatestManifest(
@@ -30,7 +30,6 @@ final class ManifestVerifier {
30
30
  static void verify(
31
31
  String appId,
32
32
  String channel,
33
- String platform,
34
33
  String version,
35
34
  String sha256,
36
35
  int size,
@@ -41,7 +40,6 @@ final class ManifestVerifier {
41
40
  String payload = buildCanonicalPayload(
42
41
  appId,
43
42
  channel,
44
- platform,
45
43
  version,
46
44
  sha256,
47
45
  size,
@@ -53,30 +51,6 @@ final class ManifestVerifier {
53
51
  verifyPayload(payload, signature, trustedKeys);
54
52
  }
55
53
 
56
- static void verifyLegacy(
57
- String appId,
58
- String channel,
59
- String platform,
60
- String version,
61
- String sha256,
62
- int size,
63
- ManifestClient.ManifestSignature signature,
64
- List<KeyEntry> trustedKeys
65
- ) throws Exception {
66
- String payload = buildLegacyCanonicalPayload(
67
- appId,
68
- channel,
69
- platform,
70
- version,
71
- sha256,
72
- size,
73
- signature.kid,
74
- signature.iat,
75
- signature.exp
76
- );
77
- verifyPayload(payload, signature, trustedKeys);
78
- }
79
-
80
54
  private static void verifyPayload(
81
55
  String payload,
82
56
  ManifestClient.ManifestSignature signature,
@@ -120,7 +94,6 @@ final class ManifestVerifier {
120
94
  private static String buildCanonicalPayload(
121
95
  String appId,
122
96
  String channel,
123
- String platform,
124
97
  String version,
125
98
  String sha256,
126
99
  int size,
@@ -130,16 +103,13 @@ final class ManifestVerifier {
130
103
  int exp
131
104
  ) {
132
105
  return (
133
- "MANIFEST_V2\n" +
106
+ "MANIFEST\n" +
134
107
  "appId:" +
135
108
  appId +
136
109
  "\n" +
137
110
  "channel:" +
138
111
  (channel != null ? channel : "null") +
139
112
  "\n" +
140
- "platform:" +
141
- platform +
142
- "\n" +
143
113
  "version:" +
144
114
  version +
145
115
  "\n" +
@@ -163,50 +133,6 @@ final class ManifestVerifier {
163
133
  );
164
134
  }
165
135
 
166
- private static String buildLegacyCanonicalPayload(
167
- String appId,
168
- String channel,
169
- String platform,
170
- String version,
171
- String sha256,
172
- int size,
173
- String kid,
174
- int iat,
175
- int exp
176
- ) {
177
- return (
178
- "MANIFEST_V1\n" +
179
- "appId:" +
180
- appId +
181
- "\n" +
182
- "channel:" +
183
- (channel != null ? channel : "null") +
184
- "\n" +
185
- "platform:" +
186
- platform +
187
- "\n" +
188
- "version:" +
189
- version +
190
- "\n" +
191
- "sha256:" +
192
- sha256 +
193
- "\n" +
194
- "size:" +
195
- size +
196
- "\n" +
197
- "minNativeBuild:null" +
198
- "\n" +
199
- "kid:" +
200
- kid +
201
- "\n" +
202
- "iat:" +
203
- iat +
204
- "\n" +
205
- "exp:" +
206
- exp
207
- );
208
- }
209
-
210
136
  private static byte[] base64UrlDecode(String input) {
211
137
  // Convert base64url to standard base64
212
138
  String base64 = input.replace('-', '+').replace('_', '/');