@scalebun/react-native 1.10.5 → 1.10.7
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/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
- package/dist/scalebun.full.js +485 -51
- package/dist/scalebun.slim.js +485 -51
- package/ios/Core/DeviceIdentity.swift +50 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
- package/ios/Ota/ScaleBunOtaModule.swift +67 -0
- package/ios/Profiler/ScaleBunProfilerModule.swift +2 -8
- package/ios/ReplaySdk.swift +3 -1
- package/lib/commonjs/analytics/EventTracker.js +22 -0
- package/lib/commonjs/core/config/schema.js +34 -1
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/core/context/device.js +5 -0
- package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +24 -0
- package/lib/commonjs/features/journey/uiState.js +87 -0
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +248 -0
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +40 -0
- package/lib/commonjs/features/ota/crypto/loadSha512.js +40 -0
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +121 -0
- package/lib/commonjs/features/ota/signature.js +87 -27
- package/lib/commonjs/metro/serializerCompose.js +32 -0
- package/lib/commonjs/public/ScaleBunFacade.js +108 -11
- package/lib/module/analytics/EventTracker.js +22 -0
- package/lib/module/core/config/schema.js +34 -1
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/core/context/device.js +5 -0
- package/lib/module/features/journey/ScaleBunDebugRoot.js +24 -0
- package/lib/module/features/journey/uiState.js +79 -0
- package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
- package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
- package/lib/module/features/ota/crypto/loadSha512.js +34 -0
- package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
- package/lib/module/features/ota/signature.js +87 -27
- package/lib/module/metro/serializerCompose.js +32 -0
- package/lib/module/public/ScaleBunFacade.js +109 -12
- package/lib/typescript/analytics/EventTracker.d.ts +15 -0
- package/lib/typescript/core/config/schema.d.ts +2 -0
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/core/context/device.d.ts +1 -0
- package/lib/typescript/features/journey/uiState.d.ts +53 -0
- package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
- package/lib/typescript/features/ota/signature.d.ts +22 -7
- package/lib/typescript/public/ScaleBunFacade.d.ts +43 -6
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
- package/package.json +18 -3
- package/src/analytics/EventTracker.ts +19 -0
- package/src/core/config/schema.ts +30 -3
- package/src/core/constants/version.ts +1 -1
- package/src/core/context/device.ts +6 -0
- package/src/features/journey/ScaleBunDebugRoot.tsx +22 -0
- package/src/features/journey/uiState.ts +84 -0
- package/src/features/ota/OtaTypes.ts +51 -0
- package/src/features/ota/crypto/builtinVerifier.ts +257 -0
- package/src/features/ota/crypto/loadEd25519.ts +41 -0
- package/src/features/ota/crypto/loadSha512.ts +35 -0
- package/src/features/ota/crypto/nativeVerifier.ts +117 -0
- package/src/features/ota/signature.ts +108 -25
- package/src/metro/serializerCompose.ts +38 -2
- package/src/public/ScaleBunFacade.ts +128 -14
- package/src/specs/NativeScaleBunOta.ts +24 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The ONE place that answers "which iPhone is this?".
|
|
5
|
+
*
|
|
6
|
+
* iOS ships no API for the name a person would recognise. `UIDevice.current.model` returns the
|
|
7
|
+
* FAMILY — the literal string "iPhone" or "iPad" — which is identical on an iPhone 6 and an
|
|
8
|
+
* iPhone 17 Pro, so a dashboard grouping by it sees one bucket holding every iOS device ever.
|
|
9
|
+
* `ReplaySdk.getDeviceInfo` sent exactly that, which is why the replay lane could never tell two
|
|
10
|
+
* iPhones apart.
|
|
11
|
+
*
|
|
12
|
+
* The hardware identifier from `uname()` — "iPhone16,2" — is the only thing the OS will tell us,
|
|
13
|
+
* and it is unique per model. It is not the marketing name and this module does not pretend
|
|
14
|
+
* otherwise: turning "iPhone16,2" into "iPhone 15 Pro Max" needs a lookup table, and that table
|
|
15
|
+
* belongs on the server, where a device released after this binary shipped can be named without
|
|
16
|
+
* waiting for every app to adopt a new SDK and ship to the App Store.
|
|
17
|
+
*
|
|
18
|
+
* `ScaleBunProfilerModule` already had this function privately; both lanes now share it so they
|
|
19
|
+
* cannot drift into reporting two different models for one device.
|
|
20
|
+
*/
|
|
21
|
+
@objc(ScaleBunDeviceIdentity)
|
|
22
|
+
public final class ScaleBunDeviceIdentity: NSObject {
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The hardware identifier, e.g. "iPhone16,2" / "iPad14,3".
|
|
26
|
+
*
|
|
27
|
+
* On the simulator `uname` reports the HOST architecture ("x86_64", "arm64"), so the
|
|
28
|
+
* simulator's own hint is preferred when present — otherwise every developer's simulator
|
|
29
|
+
* session lands in the dashboard as a device model named after a CPU.
|
|
30
|
+
*/
|
|
31
|
+
@objc public static func modelIdentifier() -> String {
|
|
32
|
+
if let simulator = ProcessInfo.processInfo
|
|
33
|
+
.environment["SIMULATOR_MODEL_IDENTIFIER"], !simulator.isEmpty {
|
|
34
|
+
return simulator
|
|
35
|
+
}
|
|
36
|
+
var systemInfo = utsname()
|
|
37
|
+
uname(&systemInfo)
|
|
38
|
+
let identifier = withUnsafePointer(to: &systemInfo.machine) {
|
|
39
|
+
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
40
|
+
String(validatingUTF8: $0) ?? ""
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Empty rather than "Unknown": an absent measurement must not arrive looking like a value.
|
|
44
|
+
return identifier
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// No manufacturer constant here on purpose. iOS's manufacturer is answerable without the
|
|
48
|
+
// bridge — the platform being iOS entails it — so staticDeviceContext() sets it on the FIRST
|
|
49
|
+
// event rather than waiting for a native round-trip that may never come back.
|
|
50
|
+
}
|
|
@@ -40,6 +40,12 @@ RCT_EXTERN_METHOD(revertToPrevious:(RCTPromiseResolveBlock)resolve
|
|
|
40
40
|
|
|
41
41
|
RCT_EXTERN_METHOD(restartApp)
|
|
42
42
|
|
|
43
|
+
RCT_EXTERN_METHOD(verifyEd25519:(NSString *)messageHex
|
|
44
|
+
signatureHex:(NSString *)signatureHex
|
|
45
|
+
publicKeyHex:(NSString *)publicKeyHex
|
|
46
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
47
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
48
|
+
|
|
43
49
|
@end
|
|
44
50
|
|
|
45
51
|
// Download-progress event channel. Swift subclass of RCTEventEmitter; only
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import React
|
|
3
3
|
import CommonCrypto
|
|
4
|
+
import CryptoKit
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* React Native bridge for OTA bundle management (`ScaleBunOta`) on iOS.
|
|
@@ -252,4 +253,70 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
|
|
|
252
253
|
RCTTriggerReloadCommandListeners("ScaleBun OTA update")
|
|
253
254
|
}
|
|
254
255
|
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Verify a detached ed25519 signature with PLATFORM crypto (CryptoKit).
|
|
259
|
+
*
|
|
260
|
+
* Why native: the JS verifier lives inside the very bundle it protects —
|
|
261
|
+
* one malicious bundle can neuter it for every update after. CryptoKit is
|
|
262
|
+
* outside the bundle's reach, and unlike Android there is no OS floor to
|
|
263
|
+
* worry about: Curve25519 signing shipped in iOS 13 and the podspec
|
|
264
|
+
* already requires 13.4.
|
|
265
|
+
*
|
|
266
|
+
* Contract (mirrors `src/specs/NativeScaleBunOta.ts`): inputs are
|
|
267
|
+
* lowercase hex pre-validated by the JS caller; the message is the RAW 32
|
|
268
|
+
* BYTES of the bundle SHA-256 (the CLI signs digest bytes, not hex text);
|
|
269
|
+
* resolves 'valid' / 'invalid' / 'unavailable'. A wrong signature is
|
|
270
|
+
* 'invalid', never a rejection; anything unexpected is 'unavailable' so
|
|
271
|
+
* machinery failure can never read as a pass.
|
|
272
|
+
*
|
|
273
|
+
* ⚪ UNCOMPILED — no macOS toolchain has been available in this workspace
|
|
274
|
+
* (the standing gap: BsPatch.swift shipped the same way). The algorithm is
|
|
275
|
+
* pinned by `tools/ed25519-parity` against the live production fixture;
|
|
276
|
+
* this file still needs one Xcode build before an iOS consumer ships it.
|
|
277
|
+
*/
|
|
278
|
+
@objc(verifyEd25519:signatureHex:publicKeyHex:resolve:reject:)
|
|
279
|
+
func verifyEd25519(_ messageHex: NSString,
|
|
280
|
+
signatureHex: NSString,
|
|
281
|
+
publicKeyHex: NSString,
|
|
282
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
283
|
+
reject: @escaping RCTPromiseRejectBlock) {
|
|
284
|
+
guard let message = ScaleBunOtaModule.bytesFromHex(messageHex as String),
|
|
285
|
+
let signature = ScaleBunOtaModule.bytesFromHex(signatureHex as String),
|
|
286
|
+
let rawKey = ScaleBunOtaModule.bytesFromHex(publicKeyHex as String),
|
|
287
|
+
rawKey.count == 32 else {
|
|
288
|
+
// Malformed input from our own caller is not a forgery verdict.
|
|
289
|
+
resolve("unavailable")
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
do {
|
|
293
|
+
let key = try Curve25519.Signing.PublicKey(rawRepresentation: rawKey)
|
|
294
|
+
resolve(key.isValidSignature(signature, for: message) ? "valid" : "invalid")
|
|
295
|
+
} catch {
|
|
296
|
+
// Bytes that are not a curve point cannot have signed anything.
|
|
297
|
+
resolve("invalid")
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Hex → Data; nil for odd length or a non-hex character.
|
|
302
|
+
private static func bytesFromHex(_ hex: String) -> Data? {
|
|
303
|
+
let chars = Array(hex.utf8)
|
|
304
|
+
guard !chars.isEmpty, chars.count % 2 == 0 else { return nil }
|
|
305
|
+
var out = Data(capacity: chars.count / 2)
|
|
306
|
+
for i in stride(from: 0, to: chars.count, by: 2) {
|
|
307
|
+
guard let hi = ScaleBunOtaModule.hexNibble(chars[i]),
|
|
308
|
+
let lo = ScaleBunOtaModule.hexNibble(chars[i + 1]) else { return nil }
|
|
309
|
+
out.append((hi << 4) | lo)
|
|
310
|
+
}
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private static func hexNibble(_ c: UInt8) -> UInt8? {
|
|
315
|
+
switch c {
|
|
316
|
+
case 0x30...0x39: return c - 0x30 // 0-9
|
|
317
|
+
case 0x61...0x66: return c - 0x61 + 10 // a-f
|
|
318
|
+
case 0x41...0x46: return c - 0x41 + 10 // A-F
|
|
319
|
+
default: return nil
|
|
320
|
+
}
|
|
321
|
+
}
|
|
255
322
|
}
|
|
@@ -392,15 +392,9 @@ class ScaleBunProfilerModule: RCTEventEmitter {
|
|
|
392
392
|
sendEvent(withName: "ScaleBunProfiler_Capabilities", body: buildCapabilities())
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
/** Shared with the replay lane so the two cannot report different models for one device. */
|
|
395
396
|
private func deviceModel() -> String {
|
|
396
|
-
|
|
397
|
-
uname(&systemInfo)
|
|
398
|
-
let modelCode = withUnsafePointer(to: &systemInfo.machine) {
|
|
399
|
-
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
|
400
|
-
String(validatingUTF8: $0) ?? "Unknown"
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
return modelCode
|
|
397
|
+
return ScaleBunDeviceIdentity.modelIdentifier()
|
|
404
398
|
}
|
|
405
399
|
|
|
406
400
|
private func isDebugBuild() -> Bool {
|
package/ios/ReplaySdk.swift
CHANGED
|
@@ -572,7 +572,9 @@ class ReplaySdk: RCTEventEmitter {
|
|
|
572
572
|
resolve([
|
|
573
573
|
"platform": "ios",
|
|
574
574
|
"osVersion": device.systemVersion,
|
|
575
|
-
|
|
575
|
+
// NOT device.model — that is the family ("iPhone"), identical on every iPhone ever
|
|
576
|
+
// made. ScaleBunDeviceIdentity reports the hardware identifier, which is unique per model.
|
|
577
|
+
"deviceModel": ScaleBunDeviceIdentity.modelIdentifier(),
|
|
576
578
|
"screenWidth": Int(screen.bounds.width * screen.scale),
|
|
577
579
|
"screenHeight": Int(screen.bounds.height * screen.scale),
|
|
578
580
|
"pixelDensity": screen.scale,
|
|
@@ -341,6 +341,28 @@ class EventTracker {
|
|
|
341
341
|
this.persistQueue();
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Fold in device facts that were not knowable at init.
|
|
346
|
+
*
|
|
347
|
+
* `Platform.constants` answers Android synchronously, so its model and manufacturer ride the
|
|
348
|
+
* very first event. iOS exposes neither there — only the native bridge knows, and the bridge is
|
|
349
|
+
* async and may not be installed at all. Without this the iOS half of every install reported no
|
|
350
|
+
* model whatsoever.
|
|
351
|
+
*
|
|
352
|
+
* `buildEnvelope` reads `cfg.context` per event, so applying it here reaches every event from
|
|
353
|
+
* the next one onward. Detected keys WIN over whatever the integrator configured, for the same
|
|
354
|
+
* reason `mergeDeviceContext` inverts the usual precedence: a measurement must not be
|
|
355
|
+
* overridable by a guess. Empty patches are ignored so a bridge that answered with nothing
|
|
356
|
+
* cannot blank a value the static path already found.
|
|
357
|
+
*/
|
|
358
|
+
applyDetectedContext(patch) {
|
|
359
|
+
if (!patch || Object.keys(patch).length === 0) return;
|
|
360
|
+
this.cfg.context = {
|
|
361
|
+
...(this.cfg.context ?? {}),
|
|
362
|
+
...patch
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
344
366
|
// ─── internals ─────────────────────────────────────────────────────────────
|
|
345
367
|
|
|
346
368
|
buildEnvelope(eventName, properties) {
|
|
@@ -295,6 +295,14 @@ const SHAPE = {
|
|
|
295
295
|
kind: 'str',
|
|
296
296
|
opt: true
|
|
297
297
|
},
|
|
298
|
+
// Rotation support: several pinned keys, any of which may verify a
|
|
299
|
+
// bundle. Ship a build pinning [old, new], re-sign server-side with
|
|
300
|
+
// new, drop old next release — no emergency store submission when a
|
|
301
|
+
// key must be replaced. Merged with publicSigningKey by the facade.
|
|
302
|
+
publicSigningKeys: {
|
|
303
|
+
kind: 'strArr',
|
|
304
|
+
opt: true
|
|
305
|
+
},
|
|
298
306
|
mandatoryBlocksUi: bool(false)
|
|
299
307
|
}
|
|
300
308
|
}
|
|
@@ -334,7 +342,7 @@ function parseField(field, input, path, issues) {
|
|
|
334
342
|
present: true
|
|
335
343
|
};
|
|
336
344
|
// Required with no default (matches the base schemas' type errors on undefined).
|
|
337
|
-
return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
|
|
345
|
+
return fail(field.kind === 'bool' ? 'Expected boolean, received undefined' : field.kind === 'num' ? 'Expected number, received undefined' : field.kind === 'str' ? 'Expected string, received undefined' : field.kind === 'strArr' ? 'Expected array, received undefined' : field.kind === 'enum' ? "Invalid enum value. Expected " + field.values.map(v => `'${v}'`).join(' | ') : 'Expected object, received undefined');
|
|
338
346
|
}
|
|
339
347
|
switch (field.kind) {
|
|
340
348
|
case 'any':
|
|
@@ -360,6 +368,31 @@ function parseField(field, input, path, issues) {
|
|
|
360
368
|
value: input,
|
|
361
369
|
present: true
|
|
362
370
|
};
|
|
371
|
+
case 'strArr':
|
|
372
|
+
{
|
|
373
|
+
// Mirrors the vendored ArraySchema exactly: element failures are
|
|
374
|
+
// reported at their index and fail the whole field (no partial
|
|
375
|
+
// arrays reach the output).
|
|
376
|
+
if (!Array.isArray(input)) return fail(`Expected array, received ${typeofName(input)}`);
|
|
377
|
+
let allOk = true;
|
|
378
|
+
for (let i = 0; i < input.length; i++) {
|
|
379
|
+
if (typeof input[i] !== 'string') {
|
|
380
|
+
issues.push({
|
|
381
|
+
path: [...path, i],
|
|
382
|
+
message: `Expected string, received ${typeofName(input[i])}`
|
|
383
|
+
});
|
|
384
|
+
allOk = false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return allOk ? {
|
|
388
|
+
ok: true,
|
|
389
|
+
value: [...input],
|
|
390
|
+
present: true
|
|
391
|
+
} : {
|
|
392
|
+
ok: false,
|
|
393
|
+
present: true
|
|
394
|
+
};
|
|
395
|
+
}
|
|
363
396
|
case 'num':
|
|
364
397
|
if (typeof input !== 'number' || Number.isNaN(input)) {
|
|
365
398
|
return fail(`Expected number, received ${typeofName(input)}`);
|
|
@@ -9,5 +9,5 @@ exports.SDK_VERSION = void 0;
|
|
|
9
9
|
* can attribute telemetry to the SDK build that produced it.
|
|
10
10
|
* Keep in sync with package.json "version".
|
|
11
11
|
*/
|
|
12
|
-
const SDK_VERSION = exports.SDK_VERSION = '1.10.
|
|
12
|
+
const SDK_VERSION = exports.SDK_VERSION = '1.10.7';
|
|
13
13
|
//# sourceMappingURL=version.js.map
|
|
@@ -104,6 +104,10 @@ function staticDeviceContext() {
|
|
|
104
104
|
if (osVersion) out.os_version = String(osVersion);
|
|
105
105
|
if (c.Model) out.device_model = String(c.Model);
|
|
106
106
|
if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);else if (c.Brand) out.manufacturer = String(c.Brand);
|
|
107
|
+
// iOS exposes no manufacturer because it does not need to: every device running iOS is Apple's.
|
|
108
|
+
// A tautology, not a guess — and it means the field is populated on event #1 rather than waiting
|
|
109
|
+
// for a bridge that may never answer.
|
|
110
|
+
else if (out.platform_os === 'ios') out.manufacturer = 'Apple';
|
|
107
111
|
return out;
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -113,6 +117,7 @@ function nativeDeviceContext(info) {
|
|
|
113
117
|
if (!info) return out;
|
|
114
118
|
if (info.osVersion) out.os_version = info.osVersion;
|
|
115
119
|
if (info.deviceModel) out.device_model = info.deviceModel;
|
|
120
|
+
if (info.manufacturer) out.manufacturer = info.manufacturer;
|
|
116
121
|
return out;
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -504,9 +504,33 @@ function ScaleBunDebugRoot({
|
|
|
504
504
|
} = require('../navigation/AutoScreenDetector');
|
|
505
505
|
screen = AutoScreenDetector.getInstance().getCurrentScreen() || undefined;
|
|
506
506
|
} catch {/* no-throw */}
|
|
507
|
+
/**
|
|
508
|
+
* UI STATE, read HERE and not later.
|
|
509
|
+
*
|
|
510
|
+
* A tap belongs to the surface that was on screen when the finger landed: tapping the
|
|
511
|
+
* filter button while the sheet is DOWN belongs to `closed`, because that is what the
|
|
512
|
+
* user was looking at when they reached for it. Reading it after the handler has run
|
|
513
|
+
* moves every "open the thing" tap into the state it created — the one state it
|
|
514
|
+
* certainly does not belong to.
|
|
515
|
+
*
|
|
516
|
+
* Declared-only on this platform (see uiState.ts): absent means not captured, which
|
|
517
|
+
* the dashboard keeps distinct from "nothing was open".
|
|
518
|
+
*/
|
|
519
|
+
let ui;
|
|
520
|
+
try {
|
|
521
|
+
const {
|
|
522
|
+
uiStateSignature
|
|
523
|
+
} = require('./uiState');
|
|
524
|
+
ui = uiStateSignature();
|
|
525
|
+
} catch {/* no-throw: a tap must never be lost to state capture */}
|
|
507
526
|
(0, _automaticEvents.emitAutomaticEvent)('element_interacted', {
|
|
508
527
|
gesture_type: gestureType,
|
|
509
528
|
screen_name: screen,
|
|
529
|
+
/* THIS MAP ENUMERATES. A field added to the payload and forgotten here reaches the
|
|
530
|
+
backend as undefined with no error anywhere — the recurring defect class in this
|
|
531
|
+
codebase. `ui` is the key the grid aggregate reads for state, byte-identical to
|
|
532
|
+
the web SDK's, so one dashboard control queries both platforms. */
|
|
533
|
+
ui,
|
|
510
534
|
// testID/nativeID is an author-controlled stable identifier.
|
|
511
535
|
// Accessibility labels and rendered text are deliberately omitted.
|
|
512
536
|
target_id: targetInfo?.testId,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.clearUiState = clearUiState;
|
|
7
|
+
exports.setUiState = setUiState;
|
|
8
|
+
exports.uiStateSignature = uiStateSignature;
|
|
9
|
+
/**
|
|
10
|
+
* UI STATE AT INTERACTION TIME — React Native.
|
|
11
|
+
*
|
|
12
|
+
* A screen is not just `Cart`. It is `Cart` with the filter sheet down, or up; with the address form
|
|
13
|
+
* expanded, or collapsed. Those are different interaction surfaces and a tap on one must never be
|
|
14
|
+
* counted on another — heat from the open sheet painted over the closed layout is a picture of
|
|
15
|
+
* something that never existed.
|
|
16
|
+
*
|
|
17
|
+
* HOW THIS DIFFERS FROM THE WEB, and the difference is not an omission:
|
|
18
|
+
*
|
|
19
|
+
* The web SDK ALSO detects state automatically, by reading `aria-expanded` and open `<dialog>`
|
|
20
|
+
* elements out of the live DOM. Those are platform-defined, so reading them is a measurement.
|
|
21
|
+
*
|
|
22
|
+
* React Native has no equivalent. There is no queryable tree of accessibility state that says a
|
|
23
|
+
* bottom sheet is up; a sheet is a component with a boolean in someone's store, and the only place
|
|
24
|
+
* that boolean exists is the host's own code. Guessing at it — from a modal's presence in the tree, or
|
|
25
|
+
* from a component name — would produce a state key that is right on some apps and silently wrong on
|
|
26
|
+
* others, and a wrong state key partitions taps into buckets corresponding to nothing.
|
|
27
|
+
*
|
|
28
|
+
* So on RN, state is DECLARED and never inferred. `ScaleBun.setUiState('filter-sheet', 'open')` is the
|
|
29
|
+
* whole mechanism, and its absence means "not captured" rather than "nothing was open". That
|
|
30
|
+
* distinction is carried all the way to the dashboard: a tap with no declared state is NULL, not '',
|
|
31
|
+
* and cannot be selected as a state or counted as one. Reporting it as "nothing was open" would be
|
|
32
|
+
* asserting a measurement nobody took.
|
|
33
|
+
*
|
|
34
|
+
* PAIRED WITH: whatzbug-web-sdk/packages/web/src/features/journey/uiState.ts — same wire format
|
|
35
|
+
* (`name:value` pairs, sorted, `;`-joined, <= 96 chars), same separator stripping, same explicit-wins
|
|
36
|
+
* precedence, so one dashboard control queries both platforms.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Host-declared state dimensions. The only source on this platform. */
|
|
40
|
+
const declared = new Map();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `;` and `:` are the wire separators and cannot appear inside a name or value; the length is bounded.
|
|
44
|
+
*
|
|
45
|
+
* Names and values are IDENTIFIERS, not content. They become aggregation keys in the dashboard, so a
|
|
46
|
+
* value carrying a person's name or a cart total would put user data into a query key — and into every
|
|
47
|
+
* chart legend built from it.
|
|
48
|
+
*/
|
|
49
|
+
const clean = s => typeof s === 'string' ? s.replace(/[;:|]/g, '').trim().slice(0, 32) : '';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Declare the state of one UI dimension.
|
|
53
|
+
*
|
|
54
|
+
* Call it when the state CHANGES, not on every render: the value is read at interaction time, so it
|
|
55
|
+
* only has to be correct by the time the next tap lands.
|
|
56
|
+
*/
|
|
57
|
+
function setUiState(name, value) {
|
|
58
|
+
const n = clean(name);
|
|
59
|
+
const v = clean(value);
|
|
60
|
+
if (n && v) declared.set(n, v);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stop reporting a dimension — or, with no name, all of them.
|
|
65
|
+
*
|
|
66
|
+
* Later taps carry no value for it, which is not the same as carrying a value. Clearing all is what a
|
|
67
|
+
* screen unmount wants: declarations from the previous screen would otherwise follow the user onto a
|
|
68
|
+
* surface where they mean nothing, and every tap there would be filed under a state that was not on
|
|
69
|
+
* screen.
|
|
70
|
+
*/
|
|
71
|
+
function clearUiState(name) {
|
|
72
|
+
if (name === undefined) declared.clear();else declared.delete(clean(name));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The signature for the interaction happening RIGHT NOW, or undefined when nothing is declared.
|
|
77
|
+
*
|
|
78
|
+
* undefined rather than '' deliberately — see the header. '' is the web's "we looked and nothing was
|
|
79
|
+
* open"; on RN there is nothing to look at, so the honest answer is silence.
|
|
80
|
+
*/
|
|
81
|
+
function uiStateSignature() {
|
|
82
|
+
if (!declared.size) return undefined;
|
|
83
|
+
/* SORTED, or the same surface produces different signatures depending on the order the host happened
|
|
84
|
+
to declare things in, and every count fragments into several that mean nothing. */
|
|
85
|
+
return [...declared.keys()].sort().map(k => `${k}:${declared.get(k)}`).join(';').slice(0, 96);
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=uiState.js.map
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports._resetBuiltinVerifier = _resetBuiltinVerifier;
|
|
7
|
+
exports.decodeSignature = decodeSignature;
|
|
8
|
+
exports.getBuiltinVerifier = getBuiltinVerifier;
|
|
9
|
+
var _loadEd = require("./loadEd25519");
|
|
10
|
+
var _loadSha = require("./loadSha512");
|
|
11
|
+
var _internalLogger = require("../../../core/logger/internalLogger");
|
|
12
|
+
/**
|
|
13
|
+
* Built-in ed25519 verifier, assembled from OPTIONAL peer dependencies.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS REPLACES. Pinning `ota.publicSigningKey` used to require the host
|
|
16
|
+
* app to write its own `verifySignature` — roughly a hundred lines of hex and
|
|
17
|
+
* base64 decoding wrapped around a crypto library. That put the single most
|
|
18
|
+
* security-critical operation in the product, the one deciding whether remote
|
|
19
|
+
* code is authentic before it executes, in code the SDK could neither test nor
|
|
20
|
+
* audit. Three failure modes all landed on the app author:
|
|
21
|
+
*
|
|
22
|
+
* - A verifier that always returns `true` silently disables the feature, and
|
|
23
|
+
* nothing can detect it.
|
|
24
|
+
* - @noble's hash-provider property moved between major versions, so a
|
|
25
|
+
* routine dependency bump made every update fail closed with no signal
|
|
26
|
+
* pointing at the cause.
|
|
27
|
+
* - Hand-written base64 decoders tend to omit base64url, so a signature
|
|
28
|
+
* containing `-` or `_` is rejected as a forgery.
|
|
29
|
+
*
|
|
30
|
+
* All three are now the SDK's problem, which is where they belong. A host that
|
|
31
|
+
* installs `@noble/ed25519` and `@noble/hashes` gets verification by pinning a
|
|
32
|
+
* key and writing no code at all.
|
|
33
|
+
*
|
|
34
|
+
* WHY OPTIONAL AND NOT A HARD DEPENDENCY. Most apps never adopt bundle
|
|
35
|
+
* signing, and they should not carry curve arithmetic they will not run. The
|
|
36
|
+
* `verifySignature` hook remains supported and still WINS over this, for teams
|
|
37
|
+
* with their own crypto policy or a native implementation to delegate to.
|
|
38
|
+
*
|
|
39
|
+
* WHAT THIS DOES NOT FIX. Verification still happens in JS, inside the very
|
|
40
|
+
* bundle it protects. An attacker who lands one malicious bundle by other means
|
|
41
|
+
* can neuter the check for every update after it. Closing that needs native
|
|
42
|
+
* verification — CryptoKit on iOS (13.4+, already the deployment target) and
|
|
43
|
+
* `Signature.getInstance("Ed25519")` on Android API 33+, with a fallback below.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const ED25519_SIGNATURE_BYTES = 64;
|
|
47
|
+
const ED25519_PUBLIC_KEY_BYTES = 32;
|
|
48
|
+
|
|
49
|
+
/** Hex → bytes. Returns null rather than throwing; callers treat null as "reject". */
|
|
50
|
+
function hexToBytes(hex) {
|
|
51
|
+
const clean = hex.trim().toLowerCase().replace(/^0x/, '');
|
|
52
|
+
if (clean.length === 0 || clean.length % 2 !== 0 || /[^0-9a-f]/.test(clean)) return null;
|
|
53
|
+
const out = new Uint8Array(clean.length / 2);
|
|
54
|
+
for (let i = 0; i < out.length; i++) {
|
|
55
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Base64 → bytes, accepting base64url as well.
|
|
63
|
+
*
|
|
64
|
+
* The `-`/`_` handling is the point. A verifier that omits it rejects a
|
|
65
|
+
* perfectly valid signature as a forgery the moment the signing side emits
|
|
66
|
+
* base64url, and the resulting symptom — updates silently stop installing —
|
|
67
|
+
* looks nothing like a decoding bug.
|
|
68
|
+
*/
|
|
69
|
+
function base64ToBytes(b64) {
|
|
70
|
+
const s = b64.trim().replace(/-/g, '+').replace(/_/g, '/').replace(/=+$/, '');
|
|
71
|
+
if (s.length === 0) return null;
|
|
72
|
+
const out = [];
|
|
73
|
+
let buffer = 0;
|
|
74
|
+
let bits = 0;
|
|
75
|
+
for (const ch of s) {
|
|
76
|
+
const idx = B64_ALPHABET.indexOf(ch);
|
|
77
|
+
if (idx === -1) return null;
|
|
78
|
+
buffer = buffer << 6 | idx;
|
|
79
|
+
bits += 6;
|
|
80
|
+
if (bits >= 8) {
|
|
81
|
+
bits -= 8;
|
|
82
|
+
out.push(buffer >> bits & 0xff);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Uint8Array.from(out);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decode a detached signature that may arrive hex- or base64-encoded.
|
|
90
|
+
*
|
|
91
|
+
* Tries the unambiguous case first: 128 hex characters is exactly 64 bytes and
|
|
92
|
+
* cannot be anything else. Otherwise base64, then hex as a last resort. Only a
|
|
93
|
+
* result of exactly 64 bytes is accepted, so a string that decodes under the
|
|
94
|
+
* wrong scheme is rejected rather than fed to the curve code as garbage.
|
|
95
|
+
*/
|
|
96
|
+
function decodeSignature(sig) {
|
|
97
|
+
const trimmed = sig.trim();
|
|
98
|
+
if (/^(0x)?[0-9a-fA-F]{128}$/.test(trimmed)) {
|
|
99
|
+
const hex = hexToBytes(trimmed);
|
|
100
|
+
if (hex && hex.length === ED25519_SIGNATURE_BYTES) return hex;
|
|
101
|
+
}
|
|
102
|
+
const b64 = base64ToBytes(trimmed);
|
|
103
|
+
if (b64 && b64.length === ED25519_SIGNATURE_BYTES) return b64;
|
|
104
|
+
const hex = hexToBytes(trimmed);
|
|
105
|
+
if (hex && hex.length === ED25519_SIGNATURE_BYTES) return hex;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Give @noble its SHA-512 provider.
|
|
111
|
+
*
|
|
112
|
+
* The property it reads moved between major versions — v2 wants
|
|
113
|
+
* `etc.sha512Sync`, v3 wants `hashes.sha512` — so both are populated when
|
|
114
|
+
* present. Getting this wrong does not fail loudly: verification simply throws
|
|
115
|
+
* from inside the curve code and the outer catch converts it to "rejected",
|
|
116
|
+
* which is indistinguishable from an actual forgery. That ambiguity is exactly
|
|
117
|
+
* what made this a bad thing to ask app authors to maintain.
|
|
118
|
+
*/
|
|
119
|
+
function wireSha512(ed, sha512) {
|
|
120
|
+
// VARIADIC ON PURPOSE. @noble calls its hash provider with several byte
|
|
121
|
+
// arrays to be hashed as one concatenated message. A wrapper taking a
|
|
122
|
+
// single argument hashes only the first, silently produces the wrong
|
|
123
|
+
// digest, and every genuine signature is then reported as INVALID — no
|
|
124
|
+
// error, no exception, just an authenticity check that always says no.
|
|
125
|
+
// Verified against a real CLI-produced signature; the single-argument form
|
|
126
|
+
// failed every positive case while passing every negative one, which is
|
|
127
|
+
// the most misleading shape a bug of this kind can take.
|
|
128
|
+
const concat = parts => {
|
|
129
|
+
if (parts.length === 1) return parts[0];
|
|
130
|
+
let total = 0;
|
|
131
|
+
for (const p of parts) total += p.length;
|
|
132
|
+
const out = new Uint8Array(total);
|
|
133
|
+
let off = 0;
|
|
134
|
+
for (const p of parts) {
|
|
135
|
+
out.set(p, off);
|
|
136
|
+
off += p.length;
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
const sync = (...msgs) => sha512(concat(msgs));
|
|
141
|
+
const asyncFn = async (...msgs) => sha512(concat(msgs));
|
|
142
|
+
|
|
143
|
+
// EACH BRANCH GETS ITS OWN try. Wrapping both in one is a trap, and it cost
|
|
144
|
+
// an afternoon: under v3 the `hashes` assignment succeeds and the `etc`
|
|
145
|
+
// object is FROZEN, so the v2-shaped assignment throws "object is not
|
|
146
|
+
// extensible" — discarding a verifier that was, by then, already correctly
|
|
147
|
+
// wired. Only "neither shape took" means unavailable.
|
|
148
|
+
let wired = false;
|
|
149
|
+
try {
|
|
150
|
+
if (ed.hashes && typeof ed.hashes === 'object') {
|
|
151
|
+
if (typeof ed.hashes.sha512 !== 'function') ed.hashes.sha512 = sync;
|
|
152
|
+
if (typeof ed.hashes.sha512Async !== 'function') ed.hashes.sha512Async = asyncFn;
|
|
153
|
+
wired = typeof ed.hashes.sha512 === 'function';
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
/* frozen or absent — try the other shape */
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
if (ed.etc && typeof ed.etc === 'object') {
|
|
160
|
+
if (typeof ed.etc.sha512Sync !== 'function') ed.etc.sha512Sync = sync;
|
|
161
|
+
if (typeof ed.etc.sha512Async !== 'function') ed.etc.sha512Async = asyncFn;
|
|
162
|
+
wired = wired || typeof ed.etc.sha512Sync === 'function';
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
/* frozen or absent — `wired` already records whether the other shape took */
|
|
166
|
+
}
|
|
167
|
+
return wired;
|
|
168
|
+
}
|
|
169
|
+
let resolved;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The built-in verifier, or null when the optional deps are not installed.
|
|
173
|
+
* Resolution is cached, including the negative result — a missing dependency
|
|
174
|
+
* does not become present at runtime, and retrying the require on every update
|
|
175
|
+
* check would be pure overhead.
|
|
176
|
+
*/
|
|
177
|
+
function getBuiltinVerifier() {
|
|
178
|
+
if (resolved !== undefined) return resolved;
|
|
179
|
+
const ed = (0, _loadEd.loadEd25519)();
|
|
180
|
+
const sha512 = (0, _loadSha.loadSha512)();
|
|
181
|
+
if (!ed || !sha512) {
|
|
182
|
+
resolved = null;
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (!wireSha512(ed, sha512)) {
|
|
186
|
+
// A shape neither branch matched — a @noble major this SDK predates.
|
|
187
|
+
// Report "no verifier", which is actionable, rather than letting every
|
|
188
|
+
// update fail deep inside the curve code with an opaque error.
|
|
189
|
+
_internalLogger.logger.error('[OTA] @noble/ed25519 is installed but its SHA-512 provider could not be ' + 'wired (unrecognised version shape). Supply `ota.verifySignature` ' + 'yourself, or pin a @noble/ed25519 version this SDK supports.');
|
|
190
|
+
resolved = null;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
resolved = async ({
|
|
194
|
+
messageHex,
|
|
195
|
+
signature,
|
|
196
|
+
publicKey
|
|
197
|
+
}) => {
|
|
198
|
+
const message = hexToBytes(messageHex);
|
|
199
|
+
const pub = hexToBytes(publicKey);
|
|
200
|
+
const sig = decodeSignature(signature);
|
|
201
|
+
if (!message) {
|
|
202
|
+
_internalLogger.logger.error('[OTA] Bundle SHA-256 is not valid hex — refusing to stage.');
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if (!pub || pub.length !== ED25519_PUBLIC_KEY_BYTES) {
|
|
206
|
+
// Worth naming precisely: a mistyped or wrongly-encoded key would
|
|
207
|
+
// otherwise present as "every update is a forgery".
|
|
208
|
+
_internalLogger.logger.error(`[OTA] ota.publicSigningKey must be ${ED25519_PUBLIC_KEY_BYTES} bytes of hex ` + `(${ED25519_PUBLIC_KEY_BYTES * 2} characters) — got ` + `${pub ? `${pub.length} bytes` : 'a value that is not hex'}.`);
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
if (!sig) {
|
|
212
|
+
_internalLogger.logger.error('[OTA] Bundle signature is not a 64-byte hex/base64 value.');
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// @noble THROWS rather than returning false for bytes that are not a
|
|
217
|
+
// well-formed point — which is most forgeries, since a tampered
|
|
218
|
+
// signature usually decodes to garbage rather than to a valid-but-wrong
|
|
219
|
+
// point. Swallowing that here is deliberate: from the SDK's side a
|
|
220
|
+
// malformed signature IS a rejection, and letting it escape would
|
|
221
|
+
// surface as `verifier_threw`, the outcome reserved for a HOST verifier
|
|
222
|
+
// misbehaving. Conflating "this bundle is forged" with "your verifier
|
|
223
|
+
// is broken" would point every investigation in the wrong direction.
|
|
224
|
+
try {
|
|
225
|
+
// SYNC FIRST, and this is not a style preference. @noble's
|
|
226
|
+
// `verifyAsync` reaches for `crypto.subtle` — "crypto.subtle must be
|
|
227
|
+
// defined, consider polyfill" — which React Native does not provide
|
|
228
|
+
// and neither does a plain Node test environment. The sync `verify`
|
|
229
|
+
// uses the SHA-512 provider wired above and needs no WebCrypto, so
|
|
230
|
+
// it is the path that actually works on a phone. Verification is a
|
|
231
|
+
// few hundred microseconds over a 32-byte digest, once per update
|
|
232
|
+
// check, so there is nothing to gain from the async form anyway.
|
|
233
|
+
if (typeof ed.verify === 'function') return ed.verify(sig, message, pub);
|
|
234
|
+
if (typeof ed.verifyAsync === 'function') return await ed.verifyAsync(sig, message, pub);
|
|
235
|
+
return false;
|
|
236
|
+
} catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
__DEV__ && _internalLogger.logger.debug('[OTA] Using built-in ed25519 verifier (@noble/ed25519 detected).');
|
|
241
|
+
return resolved;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Test seam — clears the cached resolution. */
|
|
245
|
+
function _resetBuiltinVerifier() {
|
|
246
|
+
resolved = undefined;
|
|
247
|
+
}
|
|
248
|
+
//# sourceMappingURL=builtinVerifier.js.map
|