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/src/device.ts ADDED
@@ -0,0 +1,373 @@
1
+ import {getConfig} from './core/config';
2
+ import {request} from './core/request';
3
+ import {ready as deviceReady} from './identity';
4
+
5
+ export type DeviceProfile = {
6
+ platform: 'ios' | 'android' | 'web';
7
+ brand: string;
8
+ manufacturer: string;
9
+ model: string;
10
+ osName: string;
11
+ osVersion: string;
12
+ locale: string;
13
+ timezone: string;
14
+ screen: string;
15
+ network: string;
16
+ channel: string;
17
+ debug: boolean;
18
+ isEmulator: boolean;
19
+ isTablet: boolean;
20
+ };
21
+
22
+ type Rn = {
23
+ Platform?: {
24
+ OS: string;
25
+ Version?: string | number;
26
+ isPad?: boolean;
27
+ constants?: Record<string, unknown>;
28
+ };
29
+ Dimensions?: {get: (key: string) => {width: number; height: number}};
30
+ PixelRatio?: {get: () => number};
31
+ I18nManager?: {localeIdentifier?: string; getConstants?: () => {localeIdentifier?: string}};
32
+ };
33
+
34
+ type Extra = {
35
+ userId?: string;
36
+ alias?: string;
37
+ tags?: string[];
38
+ vendorChannel?: string;
39
+ vendorToken?: string;
40
+ fcmToken?: string;
41
+ apnsToken?: string;
42
+ webPush?: unknown;
43
+ bundleVersion?: number;
44
+ };
45
+
46
+ let cached: Omit<DeviceProfile, 'network' | 'channel'> | null = null;
47
+ let network = '';
48
+ let networkWatching = false;
49
+
50
+ function native(): Rn | null {
51
+ try {
52
+ return require('react-native');
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function text(value: unknown) {
59
+ return String(value || '').trim();
60
+ }
61
+
62
+ function timezone() {
63
+ try {
64
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || '';
65
+ } catch {
66
+ return '';
67
+ }
68
+ }
69
+
70
+ function localeOf(rn: Rn | null) {
71
+ const fromI18n = text(rn?.I18nManager?.getConstants?.()?.localeIdentifier || rn?.I18nManager?.localeIdentifier);
72
+ if (fromI18n) {
73
+ return fromI18n.replace('_', '-');
74
+ }
75
+ try {
76
+ return Intl.DateTimeFormat().resolvedOptions().locale || '';
77
+ } catch {
78
+ return typeof navigator !== 'undefined' ? text(navigator.language) : '';
79
+ }
80
+ }
81
+
82
+ function screenOf(rn: Rn | null) {
83
+ try {
84
+ const size = rn?.Dimensions?.get('screen');
85
+ const scale = rn?.PixelRatio?.get() || 1;
86
+ if (size) {
87
+ return `${Math.round(size.width)}x${Math.round(size.height)}@${scale}`;
88
+ }
89
+ } catch {
90
+ //
91
+ }
92
+ const screen = (globalThis as {screen?: {width?: number; height?: number}}).screen;
93
+ if (screen?.width && screen?.height) {
94
+ return `${Math.round(screen.width)}x${Math.round(screen.height)}`;
95
+ }
96
+ return '';
97
+ }
98
+
99
+ function isDebug() {
100
+ return typeof __DEV__ !== 'undefined' && Boolean(__DEV__);
101
+ }
102
+
103
+ function isEmulator(constants: Record<string, unknown>, platform: string, model: string) {
104
+ const info = optionalInfo();
105
+ if (typeof info?.isEmulatorSync === 'function') {
106
+ try {
107
+ return Boolean(info.isEmulatorSync());
108
+ } catch {
109
+ //
110
+ }
111
+ }
112
+ if (platform !== 'android') {
113
+ return false;
114
+ }
115
+ const fingerprint = text(constants.Fingerprint).toLowerCase();
116
+ const key = `${model} ${text(constants.Brand)} ${text(constants.Product)}`.toLowerCase();
117
+ return fingerprint.includes('generic') || fingerprint.includes('emulator') || key.includes('sdk') || key.includes('emulator');
118
+ }
119
+
120
+ function isTablet(rn: Rn, platform: string) {
121
+ if (platform === 'ios') {
122
+ return Boolean(rn.Platform?.isPad);
123
+ }
124
+ try {
125
+ const size = rn.Dimensions?.get('screen');
126
+ if (size) {
127
+ return Math.min(size.width, size.height) >= 600;
128
+ }
129
+ } catch {
130
+ //
131
+ }
132
+ return text(rn.Platform?.constants?.uiMode).toLowerCase() === 'tablet';
133
+ }
134
+
135
+ function optionalInfo() {
136
+ try {
137
+ return require('react-native-device-info') as {
138
+ getBrand?: () => string;
139
+ getModel?: () => string;
140
+ getSystemName?: () => string;
141
+ getSystemVersion?: () => string;
142
+ getManufacturerSync?: () => string;
143
+ getDeviceId?: () => string;
144
+ isEmulatorSync?: () => boolean;
145
+ };
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function optionalNetInfo() {
152
+ try {
153
+ return require('@react-native-community/netinfo').default as {
154
+ fetch: () => Promise<{type?: string}>;
155
+ addEventListener: (fn: (state: {type?: string}) => void) => void;
156
+ };
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ export function watchNetwork() {
163
+ if (networkWatching) {
164
+ return;
165
+ }
166
+ networkWatching = true;
167
+ const net = optionalNetInfo();
168
+ if (!net) {
169
+ return;
170
+ }
171
+ net.fetch().then(state => {
172
+ network = text(state?.type);
173
+ }).catch(() => undefined);
174
+ net.addEventListener(state => {
175
+ network = text(state?.type);
176
+ });
177
+ }
178
+
179
+ function normalizeBrand(raw: string, platform: string) {
180
+ if (platform === 'web') {
181
+ return 'web';
182
+ }
183
+ if (platform === 'ios') {
184
+ return 'apple';
185
+ }
186
+ const key = raw.toLowerCase().replace(/[^a-z0-9]+/g, '');
187
+ const aliases: Array<[string, string]> = [
188
+ ['huawei', 'huawei'],
189
+ ['honor', 'honor'],
190
+ ['xiaomi', 'xiaomi'],
191
+ ['redmi', 'xiaomi'],
192
+ ['poco', 'xiaomi'],
193
+ ['oppo', 'oppo'],
194
+ ['oneplus', 'oppo'],
195
+ ['realme', 'oppo'],
196
+ ['vivo', 'vivo'],
197
+ ['iqoo', 'vivo'],
198
+ ['meizu', 'meizu'],
199
+ ['samsung', 'samsung'],
200
+ ['google', 'google'],
201
+ ['pixel', 'google'],
202
+ ];
203
+ for (const [alias, brand] of aliases) {
204
+ if (key.includes(alias)) {
205
+ return brand;
206
+ }
207
+ }
208
+ return key ? 'other' : 'other';
209
+ }
210
+
211
+ function webProfile(): DeviceProfile {
212
+ const nav = globalThis.navigator as
213
+ | {userAgent?: string; language?: string; userAgentData?: {platform?: string}}
214
+ | undefined;
215
+ const ua = text(nav?.userAgent);
216
+ let osName = 'Web';
217
+ let osVersion = '';
218
+ if (/Windows NT 10/i.test(ua)) {
219
+ osName = 'Windows';
220
+ osVersion = '10+';
221
+ } else if (/Mac OS X (\d+[._]\d+)/i.test(ua)) {
222
+ osName = 'macOS';
223
+ osVersion = RegExp.$1.replace('_', '.');
224
+ } else if (/Android (\d+(?:\.\d+)?)/i.test(ua)) {
225
+ osName = 'Android';
226
+ osVersion = RegExp.$1;
227
+ } else if (/CPU (?:iPhone )?OS (\d+[._]\d+)/i.test(ua)) {
228
+ osName = 'iOS';
229
+ osVersion = RegExp.$1.replace('_', '.');
230
+ } else if (nav?.userAgentData?.platform) {
231
+ osName = String(nav.userAgentData.platform);
232
+ }
233
+ let model = 'Browser';
234
+ if (/Edg\//.test(ua)) {
235
+ model = 'Edge';
236
+ } else if (/Chrome\//.test(ua)) {
237
+ model = 'Chrome';
238
+ } else if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) {
239
+ model = 'Safari';
240
+ } else if (/Firefox\//.test(ua)) {
241
+ model = 'Firefox';
242
+ }
243
+ return {
244
+ platform: 'web',
245
+ brand: 'web',
246
+ manufacturer: 'web',
247
+ model,
248
+ osName,
249
+ osVersion,
250
+ locale: localeOf(null),
251
+ timezone: timezone(),
252
+ screen: screenOf(null),
253
+ network: '',
254
+ channel: '',
255
+ debug: isDebug(),
256
+ isEmulator: false,
257
+ isTablet: false,
258
+ };
259
+ }
260
+
261
+ function iosModel(constants: Record<string, unknown>, isPad?: boolean) {
262
+ const idiom = text(constants.interfaceIdiom).toLowerCase();
263
+ if (idiom === 'pad' || isPad) {
264
+ return 'iPad';
265
+ }
266
+ if (idiom === 'tv') {
267
+ return 'Apple TV';
268
+ }
269
+ if (idiom === 'vision') {
270
+ return 'Apple Vision';
271
+ }
272
+ return 'iPhone';
273
+ }
274
+
275
+ export function collectDeviceProfile(): DeviceProfile {
276
+ const live = liveFields();
277
+ if (!cached) {
278
+ cached = collectStatic();
279
+ }
280
+ return {...cached, ...live};
281
+ }
282
+
283
+ function liveFields() {
284
+ let current = network;
285
+ if (!current && typeof navigator !== 'undefined') {
286
+ const connection = (navigator as {connection?: {effectiveType?: string}; onLine?: boolean}).connection;
287
+ if (navigator.onLine === false) {
288
+ current = 'none';
289
+ } else {
290
+ current = text(connection?.effectiveType) || (navigator.onLine ? 'online' : '');
291
+ }
292
+ }
293
+ let channel = '';
294
+ try {
295
+ channel = String(getConfig().channel || '');
296
+ } catch {
297
+ channel = '';
298
+ }
299
+ return {network: current, channel};
300
+ }
301
+
302
+ function collectStatic(): Omit<DeviceProfile, 'network' | 'channel'> {
303
+ const rn = native();
304
+ if (!rn?.Platform) {
305
+ const web = webProfile();
306
+ const {network: _n, channel: _c, ...rest} = web;
307
+ return rest;
308
+ }
309
+ const os = rn.Platform.OS;
310
+ const platform = os === 'android' ? 'android' : os === 'ios' ? 'ios' : 'web';
311
+ if (platform === 'web') {
312
+ const web = webProfile();
313
+ const {network: _n, channel: _c, ...rest} = web;
314
+ return rest;
315
+ }
316
+ const constants = rn.Platform.constants || {};
317
+ const info = optionalInfo();
318
+ const manufacturer = text(
319
+ info?.getManufacturerSync?.() ||
320
+ info?.getBrand?.() ||
321
+ constants.Manufacturer ||
322
+ constants.Brand ||
323
+ (platform === 'ios' ? 'Apple' : ''),
324
+ );
325
+ const model = text(
326
+ info?.getModel?.() ||
327
+ constants.Model ||
328
+ constants.model ||
329
+ info?.getDeviceId?.() ||
330
+ (platform === 'ios' ? iosModel(constants, rn.Platform.isPad) : ''),
331
+ );
332
+ const osName = text(info?.getSystemName?.() || constants.systemName || (platform === 'ios' ? 'iOS' : 'Android'));
333
+ const osVersion = text(
334
+ info?.getSystemVersion?.() ||
335
+ constants.osVersion ||
336
+ constants.Release ||
337
+ (platform === 'android' ? constants.Release : rn.Platform.Version),
338
+ );
339
+ return {
340
+ platform,
341
+ brand: normalizeBrand(manufacturer || model, platform),
342
+ manufacturer: manufacturer || (platform === 'ios' ? 'Apple' : ''),
343
+ model,
344
+ osName,
345
+ osVersion,
346
+ locale: localeOf(rn),
347
+ timezone: timezone(),
348
+ screen: screenOf(rn),
349
+ debug: isDebug(),
350
+ isEmulator: isEmulator(constants, platform, model),
351
+ isTablet: isTablet(rn, platform),
352
+ };
353
+ }
354
+
355
+ export function getDeviceProfile() {
356
+ return collectDeviceProfile();
357
+ }
358
+
359
+ export async function registerDevice(extra: Extra = {}) {
360
+ const config = getConfig();
361
+ const deviceId = await deviceReady();
362
+ const profile = collectDeviceProfile();
363
+ await request('/v1/devices/register', {
364
+ method: 'POST',
365
+ body: {
366
+ deviceId,
367
+ appVersion: config.appVersion,
368
+ ...profile,
369
+ ...extra,
370
+ },
371
+ });
372
+ return deviceId;
373
+ }
@@ -0,0 +1,112 @@
1
+ import type {LeiaoConfig} from './core/config';
2
+ import {readText, writeText} from './storage';
3
+
4
+ const DEVICE_FILE = 'leiao-device-id';
5
+
6
+ export type DeviceIdentity = {
7
+ deviceId: string;
8
+ isNew: boolean;
9
+ previousVersion: string;
10
+ };
11
+
12
+ type Stored = {
13
+ deviceId: string;
14
+ appVersion: string;
15
+ };
16
+
17
+ let cached = '';
18
+ let pending: Promise<string> | null = null;
19
+ let override = '';
20
+ let appVersion = '';
21
+ let identity: DeviceIdentity | null = null;
22
+
23
+ export function configureIdentity(config: LeiaoConfig) {
24
+ appVersion = String(config.appVersion || '');
25
+ const fromPush = config.push && typeof config.push === 'object' ? config.push.deviceId : '';
26
+ override = String(config.deviceId || config.clientId || fromPush || '').trim();
27
+ pending = null;
28
+ cached = override;
29
+ identity = null;
30
+ }
31
+
32
+ export function resetIdentity() {
33
+ cached = '';
34
+ pending = null;
35
+ override = '';
36
+ appVersion = '';
37
+ identity = null;
38
+ }
39
+
40
+ export function getDeviceId() {
41
+ return cached;
42
+ }
43
+
44
+ export function getIdentity() {
45
+ return identity;
46
+ }
47
+
48
+ export function ready(): Promise<string> {
49
+ if (cached && identity) {
50
+ return Promise.resolve(cached);
51
+ }
52
+ if (!pending) {
53
+ pending = loadOrCreate().catch(error => {
54
+ pending = null;
55
+ throw error;
56
+ });
57
+ }
58
+ return pending;
59
+ }
60
+
61
+ function newDeviceId() {
62
+ const cryptoObj = (globalThis as unknown as {crypto?: {randomUUID?: () => string}}).crypto;
63
+ const uuid = cryptoObj?.randomUUID
64
+ ? cryptoObj.randomUUID()
65
+ : `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
66
+ return `dev_${uuid.replace(/-/g, '')}`;
67
+ }
68
+
69
+ function parseStored(raw: string): Stored | null {
70
+ const text = String(raw || '').trim();
71
+ if (!text) {
72
+ return null;
73
+ }
74
+ if (text.startsWith('{')) {
75
+ try {
76
+ const data = JSON.parse(text) as Partial<Stored>;
77
+ if (data.deviceId) {
78
+ return {deviceId: String(data.deviceId), appVersion: String(data.appVersion || '')};
79
+ }
80
+ } catch {
81
+ return null;
82
+ }
83
+ return null;
84
+ }
85
+ return {deviceId: text, appVersion: ''};
86
+ }
87
+
88
+ async function loadOrCreate() {
89
+ if (override) {
90
+ cached = override;
91
+ identity = {deviceId: override, isNew: false, previousVersion: ''};
92
+ await persist(cached, appVersion);
93
+ return cached;
94
+ }
95
+ const stored = parseStored(await readText(DEVICE_FILE));
96
+ if (stored) {
97
+ cached = stored.deviceId;
98
+ identity = {deviceId: cached, isNew: false, previousVersion: stored.appVersion};
99
+ if (stored.appVersion !== appVersion) {
100
+ await persist(cached, appVersion);
101
+ }
102
+ return cached;
103
+ }
104
+ cached = newDeviceId();
105
+ identity = {deviceId: cached, isNew: true, previousVersion: ''};
106
+ await persist(cached, appVersion);
107
+ return cached;
108
+ }
109
+
110
+ async function persist(deviceId: string, version: string) {
111
+ await writeText(DEVICE_FILE, JSON.stringify({deviceId, appVersion: version} satisfies Stored));
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ export type {HotUpdateOptions, LeiaoConfig, PushOptions} from './core/config';
2
+ export type {
3
+ CheckResponse,
4
+ HotUpdateInfo,
5
+ NoUpdateInfo,
6
+ PromptTheme,
7
+ StoreUpdateInfo,
8
+ UpdateState,
9
+ UpdateStateListener,
10
+ } from './updates/types';
11
+ export type {UpdatePromptContext, UpdatePromptProps, UpdatePromptTheme} from './update-ui';
12
+ export {UpdatePrompt} from './update-ui';
13
+ export type {PushListener, PushMessage, VendorTokenInput} from './push';
14
+ export {ReservedEvents, analytics} from './analytics';
15
+ export type {DeviceIdentity} from './identity';
16
+ export type {DeviceProfile} from './device';
17
+ export {auth} from './auth';
18
+ export {updates} from './updates';
19
+ export {push, detectBrand} from './push';
20
+ export type {InitResult} from './runtime';
21
+
22
+ import {Leiao as Runtime} from './runtime';
23
+ import {UpdatePrompt} from './update-ui';
24
+
25
+ export const Leiao = {
26
+ ...Runtime,
27
+ UpdatePrompt,
28
+ };
@@ -0,0 +1,2 @@
1
+ export {detectBrand, push} from '../push';
2
+ export type {PushListener, PushMessage, VendorTokenInput} from '../push';