leiao 1.0.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/package.json +32 -0
- package/src/analytics.ts +261 -0
- package/src/auth.ts +93 -0
- package/src/core/config.ts +122 -0
- package/src/core/index.ts +4 -0
- package/src/core/module.ts +5 -0
- package/src/core/request.ts +84 -0
- package/src/device.ts +373 -0
- package/src/identity.ts +112 -0
- package/src/index.ts +28 -0
- package/src/modules/push.ts +2 -0
- package/src/push.ts +239 -0
- package/src/runtime.ts +124 -0
- package/src/storage.ts +82 -0
- package/src/update-ui.tsx +315 -0
- package/src/updates/client.ts +50 -0
- package/src/updates/index.ts +162 -0
- package/src/updates/installer.ts +130 -0
- package/src/updates/native.ts +53 -0
- package/src/updates/state.ts +37 -0
- package/src/updates/store.ts +43 -0
- package/src/updates/types.ts +61 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {useCallback, useEffect, useState, type ReactNode} from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Modal,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import {updates} from './updates';
|
|
11
|
+
import type {PromptTheme, StoreUpdateInfo, UpdateState} from './updates/types';
|
|
12
|
+
|
|
13
|
+
export type UpdatePromptTheme = PromptTheme;
|
|
14
|
+
|
|
15
|
+
export type UpdatePromptContext = {
|
|
16
|
+
info: StoreUpdateInfo;
|
|
17
|
+
theme: UpdatePromptTheme;
|
|
18
|
+
busy: boolean;
|
|
19
|
+
error: string;
|
|
20
|
+
openStore: () => void;
|
|
21
|
+
dismiss: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type UpdatePromptProps = {
|
|
25
|
+
/** 不传则跟随控制台「设置 → 通用」里配置的样式。 */
|
|
26
|
+
theme?: UpdatePromptTheme;
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
render?: (ctx: UpdatePromptContext) => ReactNode;
|
|
29
|
+
component?: (ctx: UpdatePromptContext) => ReactNode;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function resolveTheme(info: StoreUpdateInfo, fallback?: UpdatePromptTheme): UpdatePromptTheme {
|
|
33
|
+
if (fallback) {
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
36
|
+
if (info.promptTheme === 'sheet' || info.promptTheme === 'banner' || info.promptTheme === 'dialog') {
|
|
37
|
+
return info.promptTheme;
|
|
38
|
+
}
|
|
39
|
+
return 'dialog';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function BuiltIn({ctx}: {ctx: UpdatePromptContext}) {
|
|
43
|
+
const {info, theme, busy, error, openStore, dismiss} = ctx;
|
|
44
|
+
const title = info.mandatory ? '需要更新后才能继续' : '请到商店更新';
|
|
45
|
+
const version = info.appVersion || '';
|
|
46
|
+
const primaryLabel = busy ? '正在打开商店' : error ? '重试' : '前往商店';
|
|
47
|
+
const card = (
|
|
48
|
+
<View style={[styles.card, theme === 'dialog' && styles.cardDialog, theme === 'sheet' && styles.cardSheet]}>
|
|
49
|
+
{theme === 'sheet' ? <View style={styles.handle} /> : null}
|
|
50
|
+
<Text style={styles.kicker}>商店更新</Text>
|
|
51
|
+
<Text style={styles.title}>{title}</Text>
|
|
52
|
+
{version ? (
|
|
53
|
+
<View style={styles.metaRow}>
|
|
54
|
+
<View style={styles.chip}>
|
|
55
|
+
<Text style={styles.chipText}>{version}</Text>
|
|
56
|
+
</View>
|
|
57
|
+
</View>
|
|
58
|
+
) : null}
|
|
59
|
+
<Text style={styles.notes}>{info.notes || '当前安装的原生版本过旧,请到商店安装新包。'}</Text>
|
|
60
|
+
<Pressable
|
|
61
|
+
accessibilityRole="button"
|
|
62
|
+
disabled={busy}
|
|
63
|
+
onPress={openStore}
|
|
64
|
+
style={({pressed}) => [styles.primary, pressed && styles.pressed, busy && styles.disabled]}>
|
|
65
|
+
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.primaryText}>{primaryLabel}</Text>}
|
|
66
|
+
</Pressable>
|
|
67
|
+
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
68
|
+
{info.mandatory ? null : (
|
|
69
|
+
<Pressable accessibilityRole="button" disabled={busy} onPress={dismiss} style={styles.ghost}>
|
|
70
|
+
<Text style={styles.ghostText}>稍后再说</Text>
|
|
71
|
+
</Pressable>
|
|
72
|
+
)}
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (theme === 'banner') {
|
|
77
|
+
return (
|
|
78
|
+
<View style={styles.bannerWrap} pointerEvents="box-none">
|
|
79
|
+
<View style={styles.banner}>
|
|
80
|
+
<View style={styles.bannerCopy}>
|
|
81
|
+
<Text style={styles.bannerTitle}>{title}</Text>
|
|
82
|
+
<Text style={styles.bannerSub} numberOfLines={1}>
|
|
83
|
+
商店更新
|
|
84
|
+
{version ? ` · ${version}` : ''}
|
|
85
|
+
{info.notes ? ` · ${info.notes}` : ''}
|
|
86
|
+
</Text>
|
|
87
|
+
</View>
|
|
88
|
+
<Pressable
|
|
89
|
+
accessibilityRole="button"
|
|
90
|
+
disabled={busy}
|
|
91
|
+
onPress={openStore}
|
|
92
|
+
style={({pressed}) => [styles.bannerBtn, pressed && styles.pressed]}>
|
|
93
|
+
{busy ? <ActivityIndicator color="#fff" size="small" /> : <Text style={styles.bannerBtnText}>{error ? '重试' : '更新'}</Text>}
|
|
94
|
+
</Pressable>
|
|
95
|
+
{info.mandatory ? null : (
|
|
96
|
+
<Pressable accessibilityRole="button" onPress={dismiss} style={styles.bannerSkip}>
|
|
97
|
+
<Text style={styles.bannerSkipText}>×</Text>
|
|
98
|
+
</Pressable>
|
|
99
|
+
)}
|
|
100
|
+
</View>
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Modal visible animationType={theme === 'sheet' ? 'slide' : 'fade'} transparent onRequestClose={info.mandatory ? undefined : dismiss}>
|
|
107
|
+
<View style={[styles.backdrop, theme === 'sheet' && styles.backdropSheet]}>
|
|
108
|
+
<Pressable style={StyleSheet.absoluteFill} disabled={info.mandatory || busy} onPress={dismiss} />
|
|
109
|
+
{card}
|
|
110
|
+
</View>
|
|
111
|
+
</Modal>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 只在整包/商店更新时出现的弹层,样式跟随控制台配置(居中 / 底部卡片 / 顶部条)。
|
|
117
|
+
* 热更全程静默安装,永远不会经过这里。不自己发请求,只订阅 SDK 状态。
|
|
118
|
+
*/
|
|
119
|
+
export function UpdatePrompt({theme, children, render, component}: UpdatePromptProps) {
|
|
120
|
+
const [state, setState] = useState<UpdateState>(() => updates.getState());
|
|
121
|
+
const [busy, setBusy] = useState(false);
|
|
122
|
+
const [error, setError] = useState('');
|
|
123
|
+
|
|
124
|
+
useEffect(() => updates.onState(next => {
|
|
125
|
+
setState(next);
|
|
126
|
+
if (next.phase !== 'store-required') {
|
|
127
|
+
setBusy(false);
|
|
128
|
+
setError('');
|
|
129
|
+
}
|
|
130
|
+
}), []);
|
|
131
|
+
|
|
132
|
+
const openStore = useCallback(async () => {
|
|
133
|
+
if (busy) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
setBusy(true);
|
|
137
|
+
setError('');
|
|
138
|
+
try {
|
|
139
|
+
await updates.openStore();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
setError(err instanceof Error ? err.message : '打开商店失败,请重试');
|
|
142
|
+
} finally {
|
|
143
|
+
setBusy(false);
|
|
144
|
+
}
|
|
145
|
+
}, [busy]);
|
|
146
|
+
|
|
147
|
+
const dismiss = useCallback(() => {
|
|
148
|
+
if (busy) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
updates.dismiss();
|
|
152
|
+
}, [busy]);
|
|
153
|
+
|
|
154
|
+
const ctx: UpdatePromptContext | null =
|
|
155
|
+
state.phase === 'store-required'
|
|
156
|
+
? {info: state.info, theme: resolveTheme(state.info, theme), busy, error, openStore, dismiss}
|
|
157
|
+
: null;
|
|
158
|
+
const custom = ctx ? (render || component)?.(ctx) : null;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<View style={styles.root}>
|
|
162
|
+
{children}
|
|
163
|
+
{ctx ? custom ?? <BuiltIn ctx={ctx} /> : null}
|
|
164
|
+
</View>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const styles = StyleSheet.create({
|
|
169
|
+
root: {flex: 1},
|
|
170
|
+
backdrop: {
|
|
171
|
+
flex: 1,
|
|
172
|
+
backgroundColor: 'rgba(15, 17, 21, 0.46)',
|
|
173
|
+
alignItems: 'center',
|
|
174
|
+
justifyContent: 'center',
|
|
175
|
+
padding: 24,
|
|
176
|
+
},
|
|
177
|
+
backdropSheet: {
|
|
178
|
+
justifyContent: 'flex-end',
|
|
179
|
+
padding: 0,
|
|
180
|
+
},
|
|
181
|
+
card: {
|
|
182
|
+
width: '100%',
|
|
183
|
+
maxWidth: 420,
|
|
184
|
+
backgroundColor: '#fff',
|
|
185
|
+
paddingHorizontal: 22,
|
|
186
|
+
paddingTop: 22,
|
|
187
|
+
paddingBottom: 18,
|
|
188
|
+
},
|
|
189
|
+
cardDialog: {
|
|
190
|
+
borderRadius: 20,
|
|
191
|
+
},
|
|
192
|
+
cardSheet: {
|
|
193
|
+
maxWidth: '100%',
|
|
194
|
+
borderTopLeftRadius: 22,
|
|
195
|
+
borderTopRightRadius: 22,
|
|
196
|
+
paddingTop: 10,
|
|
197
|
+
paddingBottom: 28,
|
|
198
|
+
},
|
|
199
|
+
handle: {
|
|
200
|
+
alignSelf: 'center',
|
|
201
|
+
width: 36,
|
|
202
|
+
height: 4,
|
|
203
|
+
borderRadius: 999,
|
|
204
|
+
backgroundColor: '#e5e5e5',
|
|
205
|
+
marginBottom: 16,
|
|
206
|
+
},
|
|
207
|
+
kicker: {
|
|
208
|
+
color: '#8a8f98',
|
|
209
|
+
fontSize: 12,
|
|
210
|
+
fontWeight: '600',
|
|
211
|
+
letterSpacing: 0.4,
|
|
212
|
+
marginBottom: 6,
|
|
213
|
+
},
|
|
214
|
+
title: {
|
|
215
|
+
color: '#111',
|
|
216
|
+
fontSize: 22,
|
|
217
|
+
fontWeight: '700',
|
|
218
|
+
lineHeight: 28,
|
|
219
|
+
},
|
|
220
|
+
metaRow: {
|
|
221
|
+
flexDirection: 'row',
|
|
222
|
+
alignItems: 'center',
|
|
223
|
+
gap: 8,
|
|
224
|
+
marginTop: 12,
|
|
225
|
+
},
|
|
226
|
+
chip: {
|
|
227
|
+
backgroundColor: '#f4f4f5',
|
|
228
|
+
borderRadius: 999,
|
|
229
|
+
paddingHorizontal: 10,
|
|
230
|
+
paddingVertical: 4,
|
|
231
|
+
},
|
|
232
|
+
chipText: {
|
|
233
|
+
color: '#111',
|
|
234
|
+
fontSize: 12,
|
|
235
|
+
fontWeight: '600',
|
|
236
|
+
},
|
|
237
|
+
notes: {
|
|
238
|
+
color: '#4b5563',
|
|
239
|
+
fontSize: 15,
|
|
240
|
+
lineHeight: 22,
|
|
241
|
+
marginTop: 14,
|
|
242
|
+
marginBottom: 10,
|
|
243
|
+
},
|
|
244
|
+
error: {
|
|
245
|
+
color: '#b42318',
|
|
246
|
+
fontSize: 13,
|
|
247
|
+
lineHeight: 18,
|
|
248
|
+
marginTop: 10,
|
|
249
|
+
textAlign: 'center',
|
|
250
|
+
},
|
|
251
|
+
primary: {
|
|
252
|
+
height: 48,
|
|
253
|
+
borderRadius: 12,
|
|
254
|
+
backgroundColor: '#111',
|
|
255
|
+
alignItems: 'center',
|
|
256
|
+
justifyContent: 'center',
|
|
257
|
+
},
|
|
258
|
+
primaryText: {
|
|
259
|
+
color: '#fff',
|
|
260
|
+
fontSize: 16,
|
|
261
|
+
fontWeight: '600',
|
|
262
|
+
},
|
|
263
|
+
ghost: {
|
|
264
|
+
height: 44,
|
|
265
|
+
alignItems: 'center',
|
|
266
|
+
justifyContent: 'center',
|
|
267
|
+
marginTop: 4,
|
|
268
|
+
},
|
|
269
|
+
ghostText: {
|
|
270
|
+
color: '#667085',
|
|
271
|
+
fontSize: 15,
|
|
272
|
+
},
|
|
273
|
+
pressed: {opacity: 0.86},
|
|
274
|
+
disabled: {opacity: 0.7},
|
|
275
|
+
bannerWrap: {
|
|
276
|
+
position: 'absolute',
|
|
277
|
+
left: 12,
|
|
278
|
+
right: 12,
|
|
279
|
+
top: 54,
|
|
280
|
+
},
|
|
281
|
+
banner: {
|
|
282
|
+
flexDirection: 'row',
|
|
283
|
+
alignItems: 'center',
|
|
284
|
+
gap: 10,
|
|
285
|
+
backgroundColor: '#111',
|
|
286
|
+
borderRadius: 16,
|
|
287
|
+
paddingLeft: 14,
|
|
288
|
+
paddingRight: 8,
|
|
289
|
+
paddingVertical: 10,
|
|
290
|
+
shadowColor: '#000',
|
|
291
|
+
shadowOpacity: 0.18,
|
|
292
|
+
shadowRadius: 16,
|
|
293
|
+
shadowOffset: {width: 0, height: 8},
|
|
294
|
+
elevation: 6,
|
|
295
|
+
},
|
|
296
|
+
bannerCopy: {flex: 1, minWidth: 0},
|
|
297
|
+
bannerTitle: {color: '#fff', fontSize: 14, fontWeight: '700'},
|
|
298
|
+
bannerSub: {color: 'rgba(255,255,255,0.72)', fontSize: 12, marginTop: 2},
|
|
299
|
+
bannerBtn: {
|
|
300
|
+
backgroundColor: '#fff',
|
|
301
|
+
borderRadius: 999,
|
|
302
|
+
paddingHorizontal: 12,
|
|
303
|
+
minHeight: 32,
|
|
304
|
+
alignItems: 'center',
|
|
305
|
+
justifyContent: 'center',
|
|
306
|
+
},
|
|
307
|
+
bannerBtnText: {color: '#111', fontSize: 13, fontWeight: '700'},
|
|
308
|
+
bannerSkip: {
|
|
309
|
+
width: 28,
|
|
310
|
+
height: 32,
|
|
311
|
+
alignItems: 'center',
|
|
312
|
+
justifyContent: 'center',
|
|
313
|
+
},
|
|
314
|
+
bannerSkipText: {color: 'rgba(255,255,255,0.72)', fontSize: 18, lineHeight: 20},
|
|
315
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {getConfig} from '../core/config';
|
|
2
|
+
import {request} from '../core/request';
|
|
3
|
+
import {ready as deviceReady} from '../identity';
|
|
4
|
+
import {installedBundleVersion, platformOf} from './native';
|
|
5
|
+
import type {CheckResponse} from './types';
|
|
6
|
+
|
|
7
|
+
export type ReportStatus = 'downloaded' | 'applied' | 'failed' | 'opened';
|
|
8
|
+
|
|
9
|
+
/** 调 /v2/check。Web 平台直接返回不支持。 */
|
|
10
|
+
export async function checkUpdate(channel: string): Promise<CheckResponse> {
|
|
11
|
+
const platform = platformOf();
|
|
12
|
+
if (platform === 'web') {
|
|
13
|
+
return {kind: 'none', reason: 'unsupported', notes: '热更新只支持 iOS / Android'};
|
|
14
|
+
}
|
|
15
|
+
const config = getConfig();
|
|
16
|
+
const deviceId = await deviceReady();
|
|
17
|
+
return request<CheckResponse>('/v2/check', {
|
|
18
|
+
query: {
|
|
19
|
+
platform,
|
|
20
|
+
channel,
|
|
21
|
+
bundleVersion: await installedBundleVersion(),
|
|
22
|
+
appVersion: config.appVersion,
|
|
23
|
+
deviceId,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function reportUpdate(input: {
|
|
29
|
+
releaseId?: string;
|
|
30
|
+
status: ReportStatus;
|
|
31
|
+
deviceId: string;
|
|
32
|
+
fromVersion: number;
|
|
33
|
+
toVersion: number;
|
|
34
|
+
channel: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
}): Promise<void> {
|
|
37
|
+
await request('/v2/ota/report', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
body: {
|
|
40
|
+
releaseId: input.releaseId,
|
|
41
|
+
status: input.status,
|
|
42
|
+
deviceId: input.deviceId,
|
|
43
|
+
fromVersion: input.fromVersion,
|
|
44
|
+
toVersion: input.toVersion,
|
|
45
|
+
platform: platformOf(),
|
|
46
|
+
channel: input.channel,
|
|
47
|
+
error: input.error || '',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type {HotUpdateOptions, LeiaoConfig} from '../core/config';
|
|
2
|
+
import type {LeiaoModule} from '../core/module';
|
|
3
|
+
import {ready as deviceReady} from '../identity';
|
|
4
|
+
import {checkUpdate, reportUpdate} from './client';
|
|
5
|
+
import {installHotUpdate, restartApp, rollbackBundle} from './installer';
|
|
6
|
+
import {installedBundleVersion, native, platformOf} from './native';
|
|
7
|
+
import {getState, onState, resetState, setState} from './state';
|
|
8
|
+
import {openStoreUrl} from './store';
|
|
9
|
+
import type {CheckResponse, UpdateState, UpdateStateListener} from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 更新模块。热更全程静默:start() 后自动检查、下载、安装。
|
|
13
|
+
* 只有商店整包更新会进入 store-required 状态,交给 UpdatePrompt 或业务方 UI。
|
|
14
|
+
*/
|
|
15
|
+
class UpdatesModule implements LeiaoModule {
|
|
16
|
+
private options: HotUpdateOptions = {channel: 'production'};
|
|
17
|
+
private resumeBound = false;
|
|
18
|
+
private syncing = false;
|
|
19
|
+
|
|
20
|
+
configure(config: LeiaoConfig): void {
|
|
21
|
+
const given = typeof config.hotUpdate === 'object' ? config.hotUpdate : {};
|
|
22
|
+
this.options = {channel: 'production', ...given};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
reset() {
|
|
26
|
+
this.options = {channel: 'production'};
|
|
27
|
+
this.resumeBound = false;
|
|
28
|
+
this.syncing = false;
|
|
29
|
+
resetState();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
platform() {
|
|
33
|
+
return platformOf();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
channel(): string {
|
|
37
|
+
return this.options.channel || 'production';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getInstalledVersion(): Promise<number> {
|
|
41
|
+
return installedBundleVersion();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getState(): UpdateState {
|
|
45
|
+
return getState();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 订阅状态,立即回放当前状态,返回取消函数。 */
|
|
49
|
+
onState(listener: UpdateStateListener): () => void {
|
|
50
|
+
return onState(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 只查不装,不改状态机。 */
|
|
54
|
+
check(): Promise<CheckResponse> {
|
|
55
|
+
return checkUpdate(this.channel());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 驱动状态机走一轮:热更静默安装,商店更新置 store-required。 */
|
|
59
|
+
async sync(): Promise<UpdateState> {
|
|
60
|
+
if (this.platform() === 'web' || this.syncing) {
|
|
61
|
+
return getState();
|
|
62
|
+
}
|
|
63
|
+
const phase = getState().phase;
|
|
64
|
+
if (phase === 'downloading' || phase === 'installing' || phase === 'ready') {
|
|
65
|
+
return getState();
|
|
66
|
+
}
|
|
67
|
+
this.syncing = true;
|
|
68
|
+
try {
|
|
69
|
+
setState({phase: 'checking'});
|
|
70
|
+
const info = await checkUpdate(this.channel());
|
|
71
|
+
if (info.kind === 'store') {
|
|
72
|
+
setState({phase: 'store-required', info});
|
|
73
|
+
return getState();
|
|
74
|
+
}
|
|
75
|
+
if (info.kind === 'none') {
|
|
76
|
+
setState({phase: 'up-to-date', reason: info.reason});
|
|
77
|
+
return getState();
|
|
78
|
+
}
|
|
79
|
+
setState({phase: 'downloading', info, progress: 0});
|
|
80
|
+
try {
|
|
81
|
+
await installHotUpdate(info, this.channel(), percent => {
|
|
82
|
+
if (percent >= 90) {
|
|
83
|
+
setState({phase: 'installing', info});
|
|
84
|
+
} else {
|
|
85
|
+
setState({phase: 'downloading', info, progress: percent});
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
setState({phase: 'error', message: error instanceof Error ? error.message : String(error), info});
|
|
90
|
+
return getState();
|
|
91
|
+
}
|
|
92
|
+
const restart = this.options.restart !== false;
|
|
93
|
+
setState({phase: 'ready', info, restarting: restart});
|
|
94
|
+
if (restart) {
|
|
95
|
+
setTimeout(() => restartApp(), 400);
|
|
96
|
+
}
|
|
97
|
+
return getState();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
setState({phase: 'error', message: error instanceof Error ? error.message : String(error)});
|
|
100
|
+
return getState();
|
|
101
|
+
} finally {
|
|
102
|
+
this.syncing = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** init 后自动调用:启动即同步,回前台再同步。 */
|
|
107
|
+
start(): void {
|
|
108
|
+
if (this.platform() === 'web') {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.sync().catch(() => undefined);
|
|
112
|
+
const rn = native();
|
|
113
|
+
if (this.resumeBound || !rn?.AppState) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.resumeBound = true;
|
|
117
|
+
rn.AppState.addEventListener('change', (state: string) => {
|
|
118
|
+
if (state === 'active') {
|
|
119
|
+
this.sync().catch(() => undefined);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 打开商店。不传 url 时用当前 store-required 状态里的地址。 */
|
|
125
|
+
async openStore(url?: string): Promise<void> {
|
|
126
|
+
const state = getState();
|
|
127
|
+
const info = state.phase === 'store-required' ? state.info : null;
|
|
128
|
+
const target = url || info?.storeUrl || '';
|
|
129
|
+
await openStoreUrl(target);
|
|
130
|
+
if (info) {
|
|
131
|
+
const deviceId = info.deviceId || (await deviceReady());
|
|
132
|
+
const fromVersion = await installedBundleVersion();
|
|
133
|
+
await reportUpdate({
|
|
134
|
+
releaseId: info.releaseId,
|
|
135
|
+
status: 'opened',
|
|
136
|
+
deviceId,
|
|
137
|
+
fromVersion,
|
|
138
|
+
toVersion: 0,
|
|
139
|
+
channel: this.channel(),
|
|
140
|
+
}).catch(() => undefined);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 关掉商店更新提示(强制更新时无效)。 */
|
|
145
|
+
dismiss(): void {
|
|
146
|
+
const state = getState();
|
|
147
|
+
if (state.phase === 'store-required' && !state.info.mandatory) {
|
|
148
|
+
setState({phase: 'idle'});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
restart(): void {
|
|
153
|
+
restartApp();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
rollback(): Promise<void> {
|
|
157
|
+
return rollbackBundle();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const updates = new UpdatesModule();
|
|
162
|
+
export type {CheckResponse, HotUpdateInfo, NoUpdateInfo, PromptTheme, StoreUpdateInfo, UpdateState, UpdateStateListener} from './types';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {getConfig} from '../core/config';
|
|
2
|
+
import {ready as deviceReady} from '../identity';
|
|
3
|
+
import {reportUpdate} from './client';
|
|
4
|
+
import {blobUtil, installedBundleVersion, nativeHotUpdate} from './native';
|
|
5
|
+
import type {HotUpdateInfo} from './types';
|
|
6
|
+
|
|
7
|
+
// React Native 全局提供 XMLHttpRequest,服务端 tsconfig 没有 DOM lib
|
|
8
|
+
declare const XMLHttpRequest: {
|
|
9
|
+
new (): {
|
|
10
|
+
open(method: string, url: string, async: boolean): void;
|
|
11
|
+
responseType: string;
|
|
12
|
+
timeout: number;
|
|
13
|
+
status: number;
|
|
14
|
+
response: unknown;
|
|
15
|
+
onload: (() => void) | null;
|
|
16
|
+
ontimeout: (() => void) | null;
|
|
17
|
+
onerror: (() => void) | null;
|
|
18
|
+
send(): void;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
23
|
+
let binary = '';
|
|
24
|
+
const chunk = 0x8000;
|
|
25
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
26
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
27
|
+
}
|
|
28
|
+
return btoa(binary);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function downloadBytes(url: string, timeoutMs: number, attempts = 3): Promise<Uint8Array> {
|
|
32
|
+
let last: Error | undefined;
|
|
33
|
+
for (let index = 0; index < attempts; index += 1) {
|
|
34
|
+
try {
|
|
35
|
+
return await downloadOnce(url, timeoutMs);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
last = error instanceof Error ? error : new Error('下载失败');
|
|
38
|
+
if (index < attempts - 1) {
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 400 * (index + 1)));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
throw last || new Error('下载失败');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function downloadOnce(url: string, timeoutMs: number): Promise<Uint8Array> {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const xhr = new XMLHttpRequest();
|
|
49
|
+
xhr.open('GET', url, true);
|
|
50
|
+
xhr.responseType = 'arraybuffer';
|
|
51
|
+
xhr.timeout = timeoutMs;
|
|
52
|
+
xhr.onload = () => {
|
|
53
|
+
if (xhr.status >= 200 && xhr.status < 300 && xhr.response) {
|
|
54
|
+
resolve(new Uint8Array(xhr.response as ArrayBuffer));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
reject(new Error(`下载失败 HTTP ${xhr.status || 0}`));
|
|
58
|
+
};
|
|
59
|
+
xhr.ontimeout = () => reject(new Error('下载超时'));
|
|
60
|
+
xhr.onerror = () => reject(new Error('下载失败'));
|
|
61
|
+
xhr.send();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 下载 → 校验 → 安装热更包。进度回调 0-100。安装成功后由调用方决定是否重启。 */
|
|
66
|
+
export async function installHotUpdate(
|
|
67
|
+
info: HotUpdateInfo,
|
|
68
|
+
channel: string,
|
|
69
|
+
onProgress: (percent: number) => void,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (!info.downloadUrl) {
|
|
72
|
+
throw new Error('没有可下载的热更包');
|
|
73
|
+
}
|
|
74
|
+
const config = getConfig();
|
|
75
|
+
const deviceId = info.deviceId || (await deviceReady());
|
|
76
|
+
const fromVersion = await installedBundleVersion();
|
|
77
|
+
let finished = false;
|
|
78
|
+
try {
|
|
79
|
+
onProgress(1);
|
|
80
|
+
const bytes = await downloadBytes(info.downloadUrl, Math.max(config.timeoutMs || 12000, 30000));
|
|
81
|
+
onProgress(80);
|
|
82
|
+
await reportUpdate({releaseId: info.releaseId, status: 'downloaded', deviceId, fromVersion, toVersion: info.bundleVersion, channel});
|
|
83
|
+
const ReactNativeBlobUtil = blobUtil();
|
|
84
|
+
const dest = `${ReactNativeBlobUtil.fs.dirs.CacheDir}/${Date.now()}_hotupdate.zip`;
|
|
85
|
+
await ReactNativeBlobUtil.fs.writeFile(dest, bytesToBase64(bytes), 'base64');
|
|
86
|
+
onProgress(90);
|
|
87
|
+
if (info.sha256) {
|
|
88
|
+
const hash = String(await ReactNativeBlobUtil.fs.hash(dest, 'sha256')).toLowerCase();
|
|
89
|
+
if (hash !== info.sha256.toLowerCase()) {
|
|
90
|
+
throw new Error('热更包校验失败,未安装');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const nativeOta = nativeHotUpdate();
|
|
94
|
+
const ok = await nativeOta.setupBundlePath(dest, undefined, info.bundleVersion, 3, {
|
|
95
|
+
notes: info.notes,
|
|
96
|
+
sha256: info.sha256,
|
|
97
|
+
});
|
|
98
|
+
if (!ok) {
|
|
99
|
+
throw new Error('安装热更包失败');
|
|
100
|
+
}
|
|
101
|
+
await nativeOta.setCurrentVersion(info.bundleVersion);
|
|
102
|
+
onProgress(100);
|
|
103
|
+
await reportUpdate({releaseId: info.releaseId, status: 'applied', deviceId, fromVersion, toVersion: info.bundleVersion, channel});
|
|
104
|
+
finished = true;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (!finished) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
await reportUpdate({
|
|
109
|
+
releaseId: info.releaseId,
|
|
110
|
+
status: 'failed',
|
|
111
|
+
deviceId,
|
|
112
|
+
fromVersion,
|
|
113
|
+
toVersion: info.bundleVersion,
|
|
114
|
+
channel,
|
|
115
|
+
error: message,
|
|
116
|
+
}).catch(() => undefined);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function restartApp() {
|
|
123
|
+
nativeHotUpdate().resetApp();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function rollbackBundle(): Promise<void> {
|
|
127
|
+
const nativeOta = nativeHotUpdate();
|
|
128
|
+
await nativeOta.rollbackToPreviousBundle();
|
|
129
|
+
nativeOta.resetApp();
|
|
130
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function native(): {
|
|
2
|
+
Platform?: {OS: string};
|
|
3
|
+
AppState?: {addEventListener: Function};
|
|
4
|
+
Linking?: {canOpenURL?: (href: string) => Promise<boolean>; openURL: (href: string) => Promise<void>};
|
|
5
|
+
} | null {
|
|
6
|
+
try {
|
|
7
|
+
return require('react-native');
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function nativeHotUpdate() {
|
|
14
|
+
return require('react-native-ota-hot-update').default;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function blobUtil() {
|
|
18
|
+
return require('react-native-blob-util').default;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 测试钩子:在没有 React Native 的环境里模拟平台与已装版本。 */
|
|
22
|
+
export const nativeHooks: {
|
|
23
|
+
platform: (() => 'ios' | 'android' | 'web') | null;
|
|
24
|
+
installedVersion: (() => Promise<number>) | null;
|
|
25
|
+
} = {
|
|
26
|
+
platform: null,
|
|
27
|
+
installedVersion: null,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function platformOf(): 'ios' | 'android' | 'web' {
|
|
31
|
+
if (nativeHooks.platform) {
|
|
32
|
+
return nativeHooks.platform();
|
|
33
|
+
}
|
|
34
|
+
const rn = native();
|
|
35
|
+
if (!rn?.Platform) {
|
|
36
|
+
return 'web';
|
|
37
|
+
}
|
|
38
|
+
return rn.Platform.OS === 'android' ? 'android' : rn.Platform.OS === 'ios' ? 'ios' : 'web';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function installedBundleVersion(): Promise<number> {
|
|
42
|
+
if (nativeHooks.installedVersion) {
|
|
43
|
+
return nativeHooks.installedVersion();
|
|
44
|
+
}
|
|
45
|
+
if (!native()) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return Number(await nativeHotUpdate().getCurrentVersion()) || 0;
|
|
50
|
+
} catch {
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
}
|