@sigx/lynx-updates 0.6.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/LICENSE +21 -0
- package/README.md +162 -0
- package/android/com/sigx/updates/UpdateDownloader.kt +154 -0
- package/android/com/sigx/updates/UpdateStore.kt +367 -0
- package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -0
- package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -0
- package/android/com/sigx/updates/UpdatesEventBus.kt +54 -0
- package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -0
- package/android/com/sigx/updates/UpdatesModule.kt +235 -0
- package/dist/controller.d.ts +31 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +344 -0
- package/dist/controller.js.map +1 -0
- package/dist/events.d.ts +18 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +61 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +41 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +161 -0
- package/dist/native.js.map +1 -0
- package/dist/provider/static-manifest.d.ts +66 -0
- package/dist/provider/static-manifest.d.ts.map +1 -0
- package/dist/provider/static-manifest.js +173 -0
- package/dist/provider/static-manifest.js.map +1 -0
- package/dist/state.d.ts +23 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +73 -0
- package/dist/state.js.map +1 -0
- package/dist/types.d.ts +203 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/dist/updates.d.ts +45 -0
- package/dist/updates.d.ts.map +1 -0
- package/dist/updates.js +67 -0
- package/dist/updates.js.map +1 -0
- package/dist/use-updates.d.ts +16 -0
- package/dist/use-updates.d.ts.map +1 -0
- package/dist/use-updates.js +29 -0
- package/dist/use-updates.js.map +1 -0
- package/ios/UpdateDownloader.swift +152 -0
- package/ios/UpdateStore.swift +286 -0
- package/ios/UpdatesBundleResolver.swift +15 -0
- package/ios/UpdatesEventBus.swift +59 -0
- package/ios/UpdatesLifecyclePublisher.swift +48 -0
- package/ios/UpdatesModule.swift +178 -0
- package/package.json +59 -0
- package/signalx-module.json +35 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global reactive store + event fan-out for the updates state machine.
|
|
3
|
+
* The controller is the only writer; UI reads via `useUpdates()` or
|
|
4
|
+
* `Updates.getState()`, imperative code subscribes via `Updates.addListener`.
|
|
5
|
+
*/
|
|
6
|
+
import { signal } from '@sigx/lynx';
|
|
7
|
+
const INITIAL_RUNNING = {
|
|
8
|
+
updateId: null,
|
|
9
|
+
version: '',
|
|
10
|
+
embeddedVersion: '',
|
|
11
|
+
runtimeVersion: 'unknown',
|
|
12
|
+
isEmbedded: true,
|
|
13
|
+
isFirstLaunchAfterUpdate: false,
|
|
14
|
+
didRollBack: false,
|
|
15
|
+
rolledBackUpdateId: null,
|
|
16
|
+
};
|
|
17
|
+
function initialState() {
|
|
18
|
+
return {
|
|
19
|
+
status: 'idle',
|
|
20
|
+
manifest: null,
|
|
21
|
+
progress: null,
|
|
22
|
+
mandatory: false,
|
|
23
|
+
error: null,
|
|
24
|
+
currentlyRunning: { ...INITIAL_RUNNING },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The store — a deeply reactive proxy (object signal). @internal */
|
|
28
|
+
export const store = signal(initialState());
|
|
29
|
+
const listeners = new Set();
|
|
30
|
+
export function addListener(fn) {
|
|
31
|
+
listeners.add(fn);
|
|
32
|
+
return () => listeners.delete(fn);
|
|
33
|
+
}
|
|
34
|
+
/** Emit an event to subscribers (never throws). @internal */
|
|
35
|
+
export function emit(event) {
|
|
36
|
+
for (const fn of [...listeners]) {
|
|
37
|
+
try {
|
|
38
|
+
fn(event);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
console.warn('[updates] event listener threw:', err);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Fully detached manifest copy — `metadata` is the only nested field, so
|
|
47
|
+
* cloning it makes the snapshot deep. @internal
|
|
48
|
+
*/
|
|
49
|
+
export function cloneManifest(manifest) {
|
|
50
|
+
if (!manifest)
|
|
51
|
+
return null;
|
|
52
|
+
return {
|
|
53
|
+
...manifest,
|
|
54
|
+
...(manifest.metadata ? { metadata: { ...manifest.metadata } } : {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Snapshot of the current state (plain object, detached from the proxy). */
|
|
58
|
+
export function getStateSnapshot() {
|
|
59
|
+
return {
|
|
60
|
+
status: store.status,
|
|
61
|
+
manifest: cloneManifest(store.manifest),
|
|
62
|
+
progress: store.progress ? { ...store.progress } : null,
|
|
63
|
+
mandatory: store.mandatory,
|
|
64
|
+
error: store.error,
|
|
65
|
+
currentlyRunning: { ...store.currentlyRunning },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Test-only: reset the store and drop all listeners. @internal */
|
|
69
|
+
export function __resetForTests() {
|
|
70
|
+
store.$set(initialState());
|
|
71
|
+
listeners.clear();
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"state.js","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAGpC,MAAM,eAAe,GAAsB;IACvC,QAAQ,EAAE,IAAI;IACd,OAAO,EAAE,EAAE;IACX,eAAe,EAAE,EAAE;IACnB,cAAc,EAAE,SAAS;IACzB,UAAU,EAAE,IAAI;IAChB,wBAAwB,EAAE,KAAK;IAC/B,WAAW,EAAE,KAAK;IAClB,kBAAkB,EAAE,IAAI;CAC3B,CAAC;AAEF,SAAS,YAAY;IACjB,OAAO;QACH,MAAM,EAAE,MAAM;QACd,QAAQ,EAAE,IAAI;QACd,QAAQ,EAAE,IAAI;QACd,SAAS,EAAE,KAAK;QAChB,KAAK,EAAE,IAAI;QACX,gBAAgB,EAAE,EAAE,GAAG,eAAe,EAAE;KAC3C,CAAC;AACN,CAAC;AAED,qEAAqE;AACrE,MAAM,CAAC,MAAM,KAAK,GAAG,MAAM,CAAe,YAAY,EAAE,CAAC,CAAC;AAG1D,MAAM,SAAS,GAAG,IAAI,GAAG,EAAY,CAAC;AAEtC,MAAM,UAAU,WAAW,CAAC,EAAY;IACpC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAClB,OAAO,GAAG,EAAE,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AACtC,CAAC;AAED,6DAA6D;AAC7D,MAAM,UAAU,IAAI,CAAC,KAAmB;IACpC,KAAK,MAAM,EAAE,IAAI,CAAC,GAAG,SAAS,CAAC,EAAE,CAAC;QAC9B,IAAI,CAAC;YACD,EAAE,CAAC,KAAK,CAAC,CAAC;QACd,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,OAAO,CAAC,IAAI,CAAC,iCAAiC,EAAE,GAAG,CAAC,CAAC;QACzD,CAAC;IACL,CAAC;AACL,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,QAAkC;IAC5D,IAAI,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC3B,OAAO;QACH,GAAG,QAAQ;QACX,GAAG,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,EAAE,GAAG,QAAQ,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvE,CAAC;AACN,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,gBAAgB;IAC5B,OAAO;QACH,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,QAAQ,EAAE,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC;QACvC,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI;QACvD,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,gBAAgB,EAAE,EAAE,GAAG,KAAK,CAAC,gBAAgB,EAAE;KAClD,CAAC;AACN,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,eAAe;IAC3B,KAAK,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC,CAAC;IAC3B,SAAS,CAAC,KAAK,EAAE,CAAC;AACtB,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for `@sigx/lynx-updates` — the manifest shape, the pluggable
|
|
3
|
+
* `UpdateProvider` backend contract, and the reactive state machine.
|
|
4
|
+
*/
|
|
5
|
+
export type UpdatePlatform = 'android' | 'ios';
|
|
6
|
+
/** A single published update, as resolved by a provider. */
|
|
7
|
+
export interface UpdateManifest {
|
|
8
|
+
/**
|
|
9
|
+
* Unique update identity. Content-addressed by convention:
|
|
10
|
+
* `sha256.slice(0, 16)` (what `sigx updates:publish` emits).
|
|
11
|
+
*/
|
|
12
|
+
id: string;
|
|
13
|
+
/** Human-readable JS version, e.g. `'1.4.2'`. */
|
|
14
|
+
version: string;
|
|
15
|
+
/**
|
|
16
|
+
* Native runtime fingerprint this bundle requires. Compared against the
|
|
17
|
+
* installed binary's fingerprint — a mismatch means the update needs a
|
|
18
|
+
* newer native build (store release) and is surfaced as `incompatible`.
|
|
19
|
+
*/
|
|
20
|
+
runtimeVersion: string;
|
|
21
|
+
/** URL of the `.lynx.bundle` artifact (absolute, or relative to the manifest URL). */
|
|
22
|
+
bundleUrl: string;
|
|
23
|
+
/** Hex SHA-256 of the bundle bytes — verified natively after download. */
|
|
24
|
+
sha256: string;
|
|
25
|
+
/** App is blocked (UI) and the update force-installed when true. */
|
|
26
|
+
mandatory: boolean;
|
|
27
|
+
/** ISO-8601 publish timestamp — newest wins during selection. */
|
|
28
|
+
createdAt?: string;
|
|
29
|
+
/** Free-form metadata surfaced to UI (e.g. `releaseNotes`). */
|
|
30
|
+
metadata?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
/** Client context handed to providers on every check. */
|
|
33
|
+
export interface UpdateCheckContext {
|
|
34
|
+
platform: UpdatePlatform;
|
|
35
|
+
/** Installed binary's native fingerprint (authoritative, from the native module). */
|
|
36
|
+
runtimeVersion: string;
|
|
37
|
+
/** Id of the currently running OTA update, or null when on the embedded bundle. */
|
|
38
|
+
currentUpdateId: string | null;
|
|
39
|
+
/** Version of the embedded (store-shipped) bundle. */
|
|
40
|
+
embeddedVersion: string;
|
|
41
|
+
/** Release channel, when configured. */
|
|
42
|
+
channel: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
export type UpdateCheckResult = {
|
|
45
|
+
type: 'update-available';
|
|
46
|
+
manifest: UpdateManifest;
|
|
47
|
+
} | {
|
|
48
|
+
type: 'up-to-date';
|
|
49
|
+
}
|
|
50
|
+
/** Provider found an update but its runtimeVersion doesn't match this binary. */
|
|
51
|
+
| {
|
|
52
|
+
type: 'incompatible';
|
|
53
|
+
manifest: UpdateManifest;
|
|
54
|
+
};
|
|
55
|
+
/** How the bundle bytes should be fetched (always downloaded natively). */
|
|
56
|
+
export interface DownloadSpec {
|
|
57
|
+
url: string;
|
|
58
|
+
sha256: string;
|
|
59
|
+
headers?: Record<string, string>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pluggable update backend. The built-in {@link StaticManifestProvider}
|
|
63
|
+
* covers static-host JSON manifests; protocol backends (auth, signed
|
|
64
|
+
* manifests, Expo Updates protocol, …) implement this interface in their own
|
|
65
|
+
* package — no core changes needed.
|
|
66
|
+
*/
|
|
67
|
+
export interface UpdateProvider {
|
|
68
|
+
readonly name: string;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the best available update for this client, or up-to-date.
|
|
71
|
+
* Providers SHOULD pre-filter on `ctx.platform`, `ctx.channel` and
|
|
72
|
+
* `ctx.runtimeVersion`; core re-validates `runtimeVersion` and downgrades
|
|
73
|
+
* a mismatch to `{ type: 'incompatible' }` regardless.
|
|
74
|
+
*/
|
|
75
|
+
checkForUpdate(ctx: UpdateCheckContext): Promise<UpdateCheckResult>;
|
|
76
|
+
/**
|
|
77
|
+
* Optional: customize how the bundle is fetched (auth headers, signed
|
|
78
|
+
* URLs, alternate CDN). Default: `{ url: manifest.bundleUrl, sha256:
|
|
79
|
+
* manifest.sha256 }`. The byte transfer + SHA-256 verification always
|
|
80
|
+
* happen natively (streamed to disk, never through the JS bridge).
|
|
81
|
+
*/
|
|
82
|
+
resolveDownload?(manifest: UpdateManifest, ctx: UpdateCheckContext): Promise<DownloadSpec>;
|
|
83
|
+
}
|
|
84
|
+
export type UpdateStatus = 'idle' | 'checking' | 'up-to-date' | 'available' | 'incompatible' | 'downloading' | 'ready' | 'applying' | 'error';
|
|
85
|
+
export interface DownloadProgress {
|
|
86
|
+
receivedBytes: number;
|
|
87
|
+
/** null when the server sent no Content-Length. */
|
|
88
|
+
totalBytes: number | null;
|
|
89
|
+
}
|
|
90
|
+
/** What this process is actually running, as reported by the native module. */
|
|
91
|
+
export interface CurrentUpdateInfo {
|
|
92
|
+
/** null → running the embedded (store-shipped) bundle. */
|
|
93
|
+
updateId: string | null;
|
|
94
|
+
version: string;
|
|
95
|
+
/**
|
|
96
|
+
* The store-shipped app version (Android versionName / iOS
|
|
97
|
+
* CFBundleShortVersionString — `version` from signalx.config.ts).
|
|
98
|
+
* Always present, whichever bundle is running.
|
|
99
|
+
*/
|
|
100
|
+
embeddedVersion: string;
|
|
101
|
+
runtimeVersion: string;
|
|
102
|
+
isEmbedded: boolean;
|
|
103
|
+
/** True on the first launch running a freshly applied update. */
|
|
104
|
+
isFirstLaunchAfterUpdate: boolean;
|
|
105
|
+
/** True when native rolled back because the previous launch never reached markReady. */
|
|
106
|
+
didRollBack: boolean;
|
|
107
|
+
/** Id of the update that was rolled back at this startup (null when none). */
|
|
108
|
+
rolledBackUpdateId: string | null;
|
|
109
|
+
}
|
|
110
|
+
export interface UpdatesState {
|
|
111
|
+
status: UpdateStatus;
|
|
112
|
+
/** Set from `available` onward. */
|
|
113
|
+
manifest: UpdateManifest | null;
|
|
114
|
+
/** Non-null only while downloading. */
|
|
115
|
+
progress: DownloadProgress | null;
|
|
116
|
+
/** True → UI should block until the update is installed. */
|
|
117
|
+
mandatory: boolean;
|
|
118
|
+
error: UpdatesError | null;
|
|
119
|
+
currentlyRunning: CurrentUpdateInfo;
|
|
120
|
+
}
|
|
121
|
+
export type UpdatesEvent = {
|
|
122
|
+
type: 'checkStarted';
|
|
123
|
+
} | {
|
|
124
|
+
type: 'upToDate';
|
|
125
|
+
} | {
|
|
126
|
+
type: 'updateAvailable';
|
|
127
|
+
manifest: UpdateManifest;
|
|
128
|
+
}
|
|
129
|
+
/** An update exists but requires a newer native build (store release). */
|
|
130
|
+
| {
|
|
131
|
+
type: 'incompatibleUpdate';
|
|
132
|
+
manifest: UpdateManifest;
|
|
133
|
+
} | {
|
|
134
|
+
type: 'downloadStarted';
|
|
135
|
+
manifest: UpdateManifest;
|
|
136
|
+
} | {
|
|
137
|
+
type: 'downloadProgress';
|
|
138
|
+
progress: DownloadProgress;
|
|
139
|
+
} | {
|
|
140
|
+
type: 'updateReady';
|
|
141
|
+
manifest: UpdateManifest;
|
|
142
|
+
} | {
|
|
143
|
+
type: 'applying';
|
|
144
|
+
} | {
|
|
145
|
+
type: 'rolledBack';
|
|
146
|
+
fromUpdateId: string;
|
|
147
|
+
} | {
|
|
148
|
+
type: 'error';
|
|
149
|
+
error: UpdatesError;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* - `'silent'` — auto check + download; staged update applies on next
|
|
153
|
+
* cold launch. (default)
|
|
154
|
+
* - `'immediate'` — auto check + download, then `apply()` as soon as the
|
|
155
|
+
* download is ready (in-place reload).
|
|
156
|
+
* - `'manual'` — nothing automatic; the app drives check/download/apply.
|
|
157
|
+
*/
|
|
158
|
+
export type UpdateMode = 'silent' | 'immediate' | 'manual';
|
|
159
|
+
export interface UpdatesConfig {
|
|
160
|
+
/**
|
|
161
|
+
* Provider instance, or shorthand for the built-in static-manifest
|
|
162
|
+
* provider (`{ url }` → fetch that JSON manifest).
|
|
163
|
+
*/
|
|
164
|
+
provider: UpdateProvider | {
|
|
165
|
+
url: string;
|
|
166
|
+
headers?: Record<string, string>;
|
|
167
|
+
};
|
|
168
|
+
/** Release channel. Default: the baked `__SIGX_UPDATES_CHANNEL__` define (usually 'production'). */
|
|
169
|
+
channel?: string;
|
|
170
|
+
/** Update mode. Default `'silent'`. */
|
|
171
|
+
mode?: UpdateMode;
|
|
172
|
+
/** When to auto-check (ignored in `'manual'`). Default `['launch']`. */
|
|
173
|
+
checkOn?: Array<'launch' | 'foreground'>;
|
|
174
|
+
/**
|
|
175
|
+
* Mandatory updates always block (`state.mandatory`) and auto-apply once
|
|
176
|
+
* ready, in EVERY mode — including `'manual'` — unless this is false.
|
|
177
|
+
* Default true.
|
|
178
|
+
*/
|
|
179
|
+
honorMandatory?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* true (default): `markReady()` is called automatically shortly after
|
|
182
|
+
* `configure()` — "JS booted" is the health bar for rollback. Set false
|
|
183
|
+
* to gate on your own signal (first screen rendered, critical fetch OK)
|
|
184
|
+
* and call `Updates.markReady()` yourself.
|
|
185
|
+
*/
|
|
186
|
+
autoMarkReady?: boolean;
|
|
187
|
+
/** Rollback tuning — persisted natively for subsequent launches. */
|
|
188
|
+
rollback?: {
|
|
189
|
+
/**
|
|
190
|
+
* Launch attempts a pending update gets before native rolls back.
|
|
191
|
+
* Default 2 (absorbs one user force-kill before markReady).
|
|
192
|
+
*/
|
|
193
|
+
maxFailedLaunches?: number;
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export type UpdatesErrorCode = 'check-failed' | 'download-failed' | 'download-in-progress' | 'hash-mismatch' | 'apply-failed' | 'no-view' | 'runtime-mismatch' | 'not-configured' | 'native-unavailable'
|
|
197
|
+
/** A native call failed without a more specific code. */
|
|
198
|
+
| 'native-error';
|
|
199
|
+
export declare class UpdatesError extends Error {
|
|
200
|
+
readonly code: UpdatesErrorCode;
|
|
201
|
+
constructor(code: UpdatesErrorCode, message: string);
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for `@sigx/lynx-updates` — the manifest shape, the pluggable
|
|
3
|
+
* `UpdateProvider` backend contract, and the reactive state machine.
|
|
4
|
+
*/
|
|
5
|
+
export class UpdatesError extends Error {
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'UpdatesError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AA6MH,MAAM,OAAO,YAAa,SAAQ,KAAK;IACnC,YACoB,IAAsB,EACtC,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;oBAHC,IAAI;QAIpB,IAAI,CAAC,IAAI,GAAG,cAAc,CAAC;IAC/B,CAAC;CACJ"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public `Updates` API — a thin facade over the controller, the store and
|
|
3
|
+
* the native module. See the package README for usage.
|
|
4
|
+
*/
|
|
5
|
+
import type { CurrentUpdateInfo, UpdateCheckResult, UpdateManifest, UpdatesConfig, UpdatesEvent, UpdatesState } from './types.js';
|
|
6
|
+
export declare const Updates: {
|
|
7
|
+
/**
|
|
8
|
+
* Configure the updates client. Idempotent and synchronous; must run
|
|
9
|
+
* before any other call — typically in `main.tsx` before `defineApp`.
|
|
10
|
+
* Kicks off the configured mode's automatic behavior on a deferred task
|
|
11
|
+
* (never blocks first paint). No-ops gracefully (with one warning) when
|
|
12
|
+
* the native module is absent (web preview, tests).
|
|
13
|
+
*/
|
|
14
|
+
readonly configure: (config: UpdatesConfig) => void;
|
|
15
|
+
/** Ask the provider for the best available update. Works in every mode. */
|
|
16
|
+
readonly checkForUpdate: () => Promise<UpdateCheckResult>;
|
|
17
|
+
/**
|
|
18
|
+
* Download + stage the given (or last-checked) update. Resolves when the
|
|
19
|
+
* update is verified on disk and staged for the next launch.
|
|
20
|
+
*/
|
|
21
|
+
readonly download: (manifest?: UpdateManifest) => Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Apply the staged update NOW via an in-place reload. On success the JS
|
|
24
|
+
* context is torn down, so this promise only ever rejects (on failure —
|
|
25
|
+
* the update stays staged for next launch).
|
|
26
|
+
*/
|
|
27
|
+
readonly apply: () => Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Health signal: commits the pending update so native stops counting
|
|
30
|
+
* launch attempts against it. Called automatically after configure()
|
|
31
|
+
* unless `autoMarkReady: false`. Safe to call repeatedly.
|
|
32
|
+
*/
|
|
33
|
+
readonly markReady: () => Promise<void>;
|
|
34
|
+
/** What this process is running (embedded vs. OTA, rollback flags). */
|
|
35
|
+
readonly getCurrentlyRunning: () => Promise<CurrentUpdateInfo>;
|
|
36
|
+
/** Drop all downloaded updates; the baked bundle loads on next launch. */
|
|
37
|
+
readonly clearUpdates: () => Promise<void>;
|
|
38
|
+
/** Snapshot of the reactive state (see `useUpdates()` for the live view). */
|
|
39
|
+
readonly getState: () => UpdatesState;
|
|
40
|
+
/** Subscribe to update lifecycle events. Returns an unsubscribe fn. */
|
|
41
|
+
readonly addListener: (fn: (event: UpdatesEvent) => void) => () => void;
|
|
42
|
+
/** True when the native Updates module is present in this runtime. */
|
|
43
|
+
readonly isAvailable: () => boolean;
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=updates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"updates.d.ts","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH,OAAO,KAAK,EACR,iBAAiB,EACjB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,YAAY,EACZ,YAAY,EACf,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,OAAO;IAChB;;;;;;OAMG;aACH,SAAS,WAAS,aAAa,KAAG,IAAI;IAItC,2EAA2E;aAC3E,cAAc,QAAI,OAAO,CAAC,iBAAiB,CAAC;IAI5C;;;OAGG;aACH,QAAQ,cAAY,cAAc,KAAG,OAAO,CAAC,IAAI,CAAC;IAIlD;;;;OAIG;aACH,KAAK,QAAI,OAAO,CAAC,IAAI,CAAC;IAItB;;;;OAIG;aACH,SAAS,QAAI,OAAO,CAAC,IAAI,CAAC;IAI1B,uEAAuE;aACvE,mBAAmB,QAAI,OAAO,CAAC,iBAAiB,CAAC;IAIjD,0EAA0E;aAC1E,YAAY,QAAI,OAAO,CAAC,IAAI,CAAC;IAI7B,6EAA6E;aAC7E,QAAQ,QAAI,YAAY;IAIxB,uEAAuE;aACvE,WAAW,OAAK,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,KAAG,MAAM,IAAI;IAI1D,sEAAsE;aACtE,WAAW,QAAI,OAAO;CAGhB,CAAC"}
|
package/dist/updates.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public `Updates` API — a thin facade over the controller, the store and
|
|
3
|
+
* the native module. See the package README for usage.
|
|
4
|
+
*/
|
|
5
|
+
import * as controller from './controller.js';
|
|
6
|
+
import { getCurrentUpdate, nativeAvailable } from './native.js';
|
|
7
|
+
import { addListener, getStateSnapshot } from './state.js';
|
|
8
|
+
export const Updates = {
|
|
9
|
+
/**
|
|
10
|
+
* Configure the updates client. Idempotent and synchronous; must run
|
|
11
|
+
* before any other call — typically in `main.tsx` before `defineApp`.
|
|
12
|
+
* Kicks off the configured mode's automatic behavior on a deferred task
|
|
13
|
+
* (never blocks first paint). No-ops gracefully (with one warning) when
|
|
14
|
+
* the native module is absent (web preview, tests).
|
|
15
|
+
*/
|
|
16
|
+
configure(config) {
|
|
17
|
+
controller.configure(config);
|
|
18
|
+
},
|
|
19
|
+
/** Ask the provider for the best available update. Works in every mode. */
|
|
20
|
+
checkForUpdate() {
|
|
21
|
+
return controller.checkForUpdate();
|
|
22
|
+
},
|
|
23
|
+
/**
|
|
24
|
+
* Download + stage the given (or last-checked) update. Resolves when the
|
|
25
|
+
* update is verified on disk and staged for the next launch.
|
|
26
|
+
*/
|
|
27
|
+
download(manifest) {
|
|
28
|
+
return controller.download(manifest);
|
|
29
|
+
},
|
|
30
|
+
/**
|
|
31
|
+
* Apply the staged update NOW via an in-place reload. On success the JS
|
|
32
|
+
* context is torn down, so this promise only ever rejects (on failure —
|
|
33
|
+
* the update stays staged for next launch).
|
|
34
|
+
*/
|
|
35
|
+
apply() {
|
|
36
|
+
return controller.apply();
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* Health signal: commits the pending update so native stops counting
|
|
40
|
+
* launch attempts against it. Called automatically after configure()
|
|
41
|
+
* unless `autoMarkReady: false`. Safe to call repeatedly.
|
|
42
|
+
*/
|
|
43
|
+
markReady() {
|
|
44
|
+
return controller.markReady();
|
|
45
|
+
},
|
|
46
|
+
/** What this process is running (embedded vs. OTA, rollback flags). */
|
|
47
|
+
getCurrentlyRunning() {
|
|
48
|
+
return getCurrentUpdate();
|
|
49
|
+
},
|
|
50
|
+
/** Drop all downloaded updates; the baked bundle loads on next launch. */
|
|
51
|
+
clearUpdates() {
|
|
52
|
+
return controller.clearUpdates();
|
|
53
|
+
},
|
|
54
|
+
/** Snapshot of the reactive state (see `useUpdates()` for the live view). */
|
|
55
|
+
getState() {
|
|
56
|
+
return getStateSnapshot();
|
|
57
|
+
},
|
|
58
|
+
/** Subscribe to update lifecycle events. Returns an unsubscribe fn. */
|
|
59
|
+
addListener(fn) {
|
|
60
|
+
return addListener(fn);
|
|
61
|
+
},
|
|
62
|
+
/** True when the native Updates module is present in this runtime. */
|
|
63
|
+
isAvailable() {
|
|
64
|
+
return nativeAvailable();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=updates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,UAAU,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAU3D,MAAM,CAAC,MAAM,OAAO,GAAG;IACnB;;;;;;OAMG;IACH,SAAS,CAAC,MAAqB;QAC3B,UAAU,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;IACjC,CAAC;IAED,2EAA2E;IAC3E,cAAc;QACV,OAAO,UAAU,CAAC,cAAc,EAAE,CAAC;IACvC,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,QAAyB;QAC9B,OAAO,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;IACzC,CAAC;IAED;;;;OAIG;IACH,KAAK;QACD,OAAO,UAAU,CAAC,KAAK,EAAE,CAAC;IAC9B,CAAC;IAED;;;;OAIG;IACH,SAAS;QACL,OAAO,UAAU,CAAC,SAAS,EAAE,CAAC;IAClC,CAAC;IAED,uEAAuE;IACvE,mBAAmB;QACf,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,0EAA0E;IAC1E,YAAY;QACR,OAAO,UAAU,CAAC,YAAY,EAAE,CAAC;IACrC,CAAC;IAED,6EAA6E;IAC7E,QAAQ;QACJ,OAAO,gBAAgB,EAAE,CAAC;IAC9B,CAAC;IAED,uEAAuE;IACvE,WAAW,CAAC,EAAiC;QACzC,OAAO,WAAW,CAAC,EAAE,CAAC,CAAC;IAC3B,CAAC;IAED,sEAAsE;IACtE,WAAW;QACP,OAAO,eAAe,EAAE,CAAC;IAC7B,CAAC;CACK,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Computed } from '@sigx/lynx';
|
|
2
|
+
import type { UpdatesState } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* BG-reactive updates state for components (the `useKeyboard()` idiom).
|
|
5
|
+
* Re-evaluates whenever the controller transitions the state machine —
|
|
6
|
+
* status, manifest, download progress, mandatory flag, errors.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const updates = useUpdates();
|
|
10
|
+
* return () => updates.value.status === 'downloading'
|
|
11
|
+
* ? <Progress value={percent(updates.value.progress)} />
|
|
12
|
+
* : null;
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export declare function useUpdates(): Computed<UpdatesState>;
|
|
16
|
+
//# sourceMappingURL=use-updates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-updates.d.ts","sourceRoot":"","sources":["../src/use-updates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAErD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE/C;;;;;;;;;;;GAWG;AACH,wBAAgB,UAAU,IAAI,QAAQ,CAAC,YAAY,CAAC,CAanD"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { computed } from '@sigx/lynx';
|
|
2
|
+
import { cloneManifest, store } from './state.js';
|
|
3
|
+
/**
|
|
4
|
+
* BG-reactive updates state for components (the `useKeyboard()` idiom).
|
|
5
|
+
* Re-evaluates whenever the controller transitions the state machine —
|
|
6
|
+
* status, manifest, download progress, mandatory flag, errors.
|
|
7
|
+
*
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const updates = useUpdates();
|
|
10
|
+
* return () => updates.value.status === 'downloading'
|
|
11
|
+
* ? <Progress value={percent(updates.value.progress)} />
|
|
12
|
+
* : null;
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export function useUpdates() {
|
|
16
|
+
// Detached snapshots (matching Updates.getState()) — the controller is
|
|
17
|
+
// the only writer, and handing out live proxy references would let a
|
|
18
|
+
// consumer mutate the state machine by accident. The clones also read
|
|
19
|
+
// every field, so the computed re-evaluates on any nested change.
|
|
20
|
+
return computed(() => ({
|
|
21
|
+
status: store.status,
|
|
22
|
+
manifest: cloneManifest(store.manifest),
|
|
23
|
+
progress: store.progress ? { ...store.progress } : null,
|
|
24
|
+
mandatory: store.mandatory,
|
|
25
|
+
error: store.error,
|
|
26
|
+
currentlyRunning: { ...store.currentlyRunning },
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=use-updates.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-updates.js","sourceRoot":"","sources":["../src/use-updates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAiB,MAAM,YAAY,CAAC;AACrD,OAAO,EAAE,aAAa,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAGlD;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,UAAU;IACtB,uEAAuE;IACvE,qEAAqE;IACrE,sEAAsE;IACtE,kEAAkE;IAClE,OAAO,QAAQ,CAAC,GAAG,EAAE,CAAC,CAAC;QACnB,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,QAAQ,EAAE,aAAa,CAAC,KAAK,CAAC,QAAQ,CAAC;QACvC,QAAQ,EAAE,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,IAAI;QACvD,SAAS,EAAE,KAAK,CAAC,SAAS;QAC1B,KAAK,EAAE,KAAK,CAAC,KAAK;QAClB,gBAAgB,EAAE,EAAE,GAAG,KAAK,CAAC,gBAAgB,EAAE;KAClD,CAAC,CAAC,CAAC;AACR,CAAC"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CryptoKit
|
|
3
|
+
|
|
4
|
+
/// Streaming bundle downloader: bytes go straight to `tmp/<id>.partial`
|
|
5
|
+
/// with an incremental SHA-256, then atomically move into `updates/<id>/`
|
|
6
|
+
/// once the hash matches. Single-flight — concurrent calls beyond the first
|
|
7
|
+
/// fail fast.
|
|
8
|
+
final class UpdateDownloader: NSObject, URLSessionDataDelegate {
|
|
9
|
+
|
|
10
|
+
private static let inFlightLock = NSLock()
|
|
11
|
+
private static var inFlight = false
|
|
12
|
+
|
|
13
|
+
private let partialURL: URL
|
|
14
|
+
private var output: FileHandle?
|
|
15
|
+
private var hasher = SHA256()
|
|
16
|
+
private var receivedBytes: Int64 = 0
|
|
17
|
+
private var totalBytes: Int64?
|
|
18
|
+
private var lastProgressAt = Date.distantPast
|
|
19
|
+
private var result: String? = "Download did not complete"
|
|
20
|
+
private let done = DispatchSemaphore(value: 0)
|
|
21
|
+
|
|
22
|
+
private init(partialURL: URL) {
|
|
23
|
+
self.partialURL = partialURL
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Synchronous (call off the main thread). Returns nil on success or an
|
|
27
|
+
/// error message (prefixed with E_* codes the module maps to the bridge).
|
|
28
|
+
static func download(
|
|
29
|
+
url: String,
|
|
30
|
+
expectedSha256: String,
|
|
31
|
+
updateId: String,
|
|
32
|
+
headers: [String: String],
|
|
33
|
+
manifestJson: String,
|
|
34
|
+
) -> String? {
|
|
35
|
+
let store = UpdateStore.shared
|
|
36
|
+
|
|
37
|
+
// Already on disk and intact → success without a byte transferred.
|
|
38
|
+
if FileManager.default.fileExists(atPath: store.bundleFile(updateId).path),
|
|
39
|
+
store.verifySha256(updateId) {
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
inFlightLock.lock()
|
|
44
|
+
if inFlight {
|
|
45
|
+
inFlightLock.unlock()
|
|
46
|
+
return "E_DOWNLOAD_IN_PROGRESS: another download is running"
|
|
47
|
+
}
|
|
48
|
+
inFlight = true
|
|
49
|
+
inFlightLock.unlock()
|
|
50
|
+
defer {
|
|
51
|
+
inFlightLock.lock()
|
|
52
|
+
inFlight = false
|
|
53
|
+
inFlightLock.unlock()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
guard let requestURL = URL(string: url) else {
|
|
57
|
+
return "Download failed: invalid URL \(url)"
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let fm = FileManager.default
|
|
61
|
+
try? fm.createDirectory(at: store.tmpDir, withIntermediateDirectories: true)
|
|
62
|
+
let partial = store.tmpDir.appendingPathComponent("\(updateId).partial")
|
|
63
|
+
fm.createFile(atPath: partial.path, contents: nil)
|
|
64
|
+
|
|
65
|
+
let downloader = UpdateDownloader(partialURL: partial)
|
|
66
|
+
guard let handle = try? FileHandle(forWritingTo: partial) else {
|
|
67
|
+
return "Download failed: cannot open staging file"
|
|
68
|
+
}
|
|
69
|
+
downloader.output = handle
|
|
70
|
+
|
|
71
|
+
var request = URLRequest(url: requestURL, timeoutInterval: 30)
|
|
72
|
+
for (key, value) in headers {
|
|
73
|
+
request.setValue(value, forHTTPHeaderField: key)
|
|
74
|
+
}
|
|
75
|
+
let session = URLSession(configuration: .ephemeral, delegate: downloader, delegateQueue: nil)
|
|
76
|
+
session.dataTask(with: request).resume()
|
|
77
|
+
downloader.done.wait()
|
|
78
|
+
session.finishTasksAndInvalidate()
|
|
79
|
+
|
|
80
|
+
if let failure = downloader.result {
|
|
81
|
+
try? fm.removeItem(at: partial)
|
|
82
|
+
return failure
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let actual = downloader.hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
86
|
+
guard actual == expectedSha256.lowercased() else {
|
|
87
|
+
try? fm.removeItem(at: partial)
|
|
88
|
+
return "E_HASH_MISMATCH: expected \(expectedSha256), got \(actual)"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Promote: metadata first, bundle move last.
|
|
92
|
+
let dir = store.updateDir(updateId)
|
|
93
|
+
try? fm.removeItem(at: dir)
|
|
94
|
+
do {
|
|
95
|
+
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
96
|
+
var meta = (try? JSONSerialization.jsonObject(with: Data(manifestJson.utf8))) as? [String: Any] ?? [:]
|
|
97
|
+
meta["sha256"] = expectedSha256.lowercased()
|
|
98
|
+
meta["sizeBytes"] = downloader.receivedBytes
|
|
99
|
+
meta["sourceUrl"] = url
|
|
100
|
+
meta["downloadedAt"] = Int(Date().timeIntervalSince1970 * 1000)
|
|
101
|
+
let metaData = try JSONSerialization.data(withJSONObject: meta)
|
|
102
|
+
try metaData.write(to: store.updateJsonFile(updateId), options: .atomic)
|
|
103
|
+
try fm.moveItem(at: partial, to: store.bundleFile(updateId))
|
|
104
|
+
} catch {
|
|
105
|
+
try? fm.removeItem(at: partial)
|
|
106
|
+
return "Download failed: \(error.localizedDescription)"
|
|
107
|
+
}
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// MARK: - URLSessionDataDelegate
|
|
112
|
+
|
|
113
|
+
func urlSession(
|
|
114
|
+
_ session: URLSession, dataTask: URLSessionDataTask,
|
|
115
|
+
didReceive response: URLResponse,
|
|
116
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void,
|
|
117
|
+
) {
|
|
118
|
+
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
|
119
|
+
result = "Download failed: HTTP \(http.statusCode)"
|
|
120
|
+
completionHandler(.cancel)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
totalBytes = response.expectedContentLength >= 0 ? response.expectedContentLength : nil
|
|
124
|
+
completionHandler(.allow)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
128
|
+
output?.write(data)
|
|
129
|
+
hasher.update(data: data)
|
|
130
|
+
receivedBytes += Int64(data.count)
|
|
131
|
+
let now = Date()
|
|
132
|
+
if now.timeIntervalSince(lastProgressAt) >= 0.15 {
|
|
133
|
+
lastProgressAt = now
|
|
134
|
+
UpdatesEventBus.shared.emitProgress(receivedBytes: receivedBytes, totalBytes: totalBytes)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
139
|
+
try? output?.close()
|
|
140
|
+
if let error {
|
|
141
|
+
// A cancel from didReceive(response:) already set a specific message.
|
|
142
|
+
if result == "Download did not complete" || result == nil {
|
|
143
|
+
result = "Download failed: \(error.localizedDescription)"
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
result = nil
|
|
147
|
+
UpdatesEventBus.shared.emitProgress(
|
|
148
|
+
receivedBytes: receivedBytes, totalBytes: totalBytes ?? receivedBytes)
|
|
149
|
+
}
|
|
150
|
+
done.signal()
|
|
151
|
+
}
|
|
152
|
+
}
|