@fingerprint79/react-native 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -0
- package/android/build.gradle +59 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/com/fingerprint79/rn/FpRnModule.kt +121 -0
- package/android/src/main/java/com/fingerprint79/rn/FpRnPackage.kt +19 -0
- package/dist/.tsbuildinfo-build +1 -0
- package/dist/client.d.ts +13 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/hooks.d.ts +27 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/index.cjs +3717 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +3717 -0
- package/dist/index.mjs.map +1 -0
- package/dist/native.d.ts +9 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/signals/hardware.d.ts +11 -0
- package/dist/signals/hardware.d.ts.map +1 -0
- package/dist/signals/index.d.ts +3 -0
- package/dist/signals/index.d.ts.map +1 -0
- package/dist/signals/intl.d.ts +5 -0
- package/dist/signals/intl.d.ts.map +1 -0
- package/dist/signals/mobile.d.ts +24 -0
- package/dist/signals/mobile.d.ts.map +1 -0
- package/dist/signals/network.d.ts +5 -0
- package/dist/signals/network.d.ts.map +1 -0
- package/dist/signals/persistence.d.ts +17 -0
- package/dist/signals/persistence.d.ts.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/types.d.ts.map +1 -0
- package/fingerprint79-react-native.podspec +26 -0
- package/ios/FpRnModule.m +16 -0
- package/ios/FpRnModule.swift +134 -0
- package/package.json +91 -0
- package/react-native.config.js +18 -0
- package/src/client.ts +89 -0
- package/src/context.tsx +82 -0
- package/src/hooks.ts +116 -0
- package/src/index.ts +20 -0
- package/src/native.ts +39 -0
- package/src/signals/hardware.ts +36 -0
- package/src/signals/index.ts +44 -0
- package/src/signals/intl.ts +63 -0
- package/src/signals/mobile.ts +23 -0
- package/src/signals/network.ts +51 -0
- package/src/signals/persistence.ts +77 -0
- package/src/signals/signals.test.ts +111 -0
- package/src/types.ts +54 -0
package/dist/native.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MobileNativePayload } from './signals/mobile.js';
|
|
2
|
+
/** True when the native module is actually linked. Drives a one-time
|
|
3
|
+
* warning the FpProvider emits in dev mode if missing. */
|
|
4
|
+
export declare function isNativeLinked(): boolean;
|
|
5
|
+
/** Collect mobile signals from the linked native module. Returns an empty
|
|
6
|
+
* object if the module isn't linked — the rest of the pipeline handles
|
|
7
|
+
* the absence by simply not emitting `signals.mobile`. */
|
|
8
|
+
export declare function collectNativeMobile(): Promise<MobileNativePayload>;
|
|
9
|
+
//# sourceMappingURL=native.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"native.d.ts","sourceRoot":"","sources":["../src/native.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAU/D;2DAC2D;AAC3D,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED;;2DAE2D;AAC3D,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,mBAAmB,CAAC,CAWxE"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ClientSignals } from '@fp/shared-types';
|
|
2
|
+
type HardwareBlock = NonNullable<ClientSignals['hardware']>;
|
|
3
|
+
/**
|
|
4
|
+
* Maps RN runtime info into the wire schema's `hardware` block. Browser
|
|
5
|
+
* fields that don't exist on mobile (oscpu, devicePixelRatio's CSS
|
|
6
|
+
* meaning, colorGamut from media query, ...) stay absent — Zod treats
|
|
7
|
+
* every leaf as optional.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collectHardware(): HardwareBlock;
|
|
10
|
+
export {};
|
|
11
|
+
//# sourceMappingURL=hardware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hardware.d.ts","sourceRoot":"","sources":["../../src/signals/hardware.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,KAAK,aAAa,GAAG,WAAW,CAAC,aAAa,CAAC,UAAU,CAAC,CAAC,CAAC;AAE5D;;;;;GAKG;AACH,wBAAgB,eAAe,IAAI,aAAa,CAmB/C"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/signals/index.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,wBAAsB,cAAc,IAAI,OAAO,CAAC,aAAa,CAAC,CAW7D"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intl.d.ts","sourceRoot":"","sources":["../../src/signals/intl.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,KAAK,SAAS,GAAG,WAAW,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;AAiBpD,wBAAgB,WAAW,IAAI,SAAS,CAUvC"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape of what the native bridge returns. Kept separate from the wire
|
|
3
|
+
* schema so the bridge can evolve without forcing a `@fp/shared-types`
|
|
4
|
+
* bump on every native change — anything new the native side starts
|
|
5
|
+
* sending is just ignored by Zod's `.strict()` until we plumb it
|
|
6
|
+
* through here.
|
|
7
|
+
*
|
|
8
|
+
* Field names mirror `ClientSignalsSchema.mobile` exactly so the only
|
|
9
|
+
* step the client does is `signals.mobile = { ...native }`.
|
|
10
|
+
*/
|
|
11
|
+
export interface MobileNativePayload {
|
|
12
|
+
os?: 'ios' | 'android';
|
|
13
|
+
osVersion?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
manufacturer?: string;
|
|
16
|
+
idfv?: string;
|
|
17
|
+
androidId?: string;
|
|
18
|
+
installId?: string;
|
|
19
|
+
totalMemoryMb?: number;
|
|
20
|
+
isTablet?: boolean;
|
|
21
|
+
bundleId?: string;
|
|
22
|
+
appVersion?: string;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=mobile.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mobile.d.ts","sourceRoot":"","sources":["../../src/signals/mobile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,MAAM,WAAW,mBAAmB;IAClC,EAAE,CAAC,EAAE,KAAK,GAAG,SAAS,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/signals/network.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,KAAK,YAAY,GAAG,WAAW,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,CAAC;AA0B1D,wBAAsB,cAAc,IAAI,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAmBxE"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous session-id accessor — returns whatever the SDK has in
|
|
3
|
+
* memory right now. On first SDK boot the restore path may not have
|
|
4
|
+
* finished yet, in which case we mint a new id immediately and overwrite
|
|
5
|
+
* with the persisted value if/when restore lands (we don't, because
|
|
6
|
+
* payloads have already gone out with the fresh id). Mirrors the
|
|
7
|
+
* trade-off FP Pro RN makes — the moment of app-cold-boot is the one
|
|
8
|
+
* weak spot in mobile session-id continuity.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getOrCreateSessionId(): string;
|
|
11
|
+
/**
|
|
12
|
+
* Best-effort hydrate from AsyncStorage. Call once at SDK boot; if it
|
|
13
|
+
* lands before the first `identify()` we use the persisted id, otherwise
|
|
14
|
+
* we keep the fresh one.
|
|
15
|
+
*/
|
|
16
|
+
export declare function restoreSessionId(): Promise<void>;
|
|
17
|
+
//# sourceMappingURL=persistence.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"persistence.d.ts","sourceRoot":"","sources":["../../src/signals/persistence.ts"],"names":[],"mappings":"AAwCA;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,IAAI,MAAM,CAO7C;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,IAAI,CAAC,CAahD"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public TypeScript surface — mirrors `apps/sdk-react/src/types.ts` so
|
|
3
|
+
* code that switches between web and RN sees the same option names.
|
|
4
|
+
*
|
|
5
|
+
* The mobile-specific bits (collected through the native bridge) are NOT
|
|
6
|
+
* exposed here — they're an implementation detail of the payload, never
|
|
7
|
+
* a knob the integrator turns. The schema lives in
|
|
8
|
+
* `@fp/shared-types/ingest.ts::ClientSignalsSchema.mobile`.
|
|
9
|
+
*/
|
|
10
|
+
export type FpEventType = 'pageview' | 'login' | 'signup' | 'action' | 'heartbeat';
|
|
11
|
+
export interface FpConfig {
|
|
12
|
+
/** Origin where the platform's collector is reachable (e.g.
|
|
13
|
+
* `https://yoursite.com/fpjs` proxied via a Cloudflare Worker). */
|
|
14
|
+
collectorUrl: string;
|
|
15
|
+
/** Tenant key from the dashboard's API Keys page. Forwarded as
|
|
16
|
+
* `X-Project-Key` so the collector routes events to the right project. */
|
|
17
|
+
projectKey: string;
|
|
18
|
+
/** Pre-shared X25519 public key (base64) — skips the bootstrap /v1/pk
|
|
19
|
+
* hop. Most consumers leave this undefined and let `ServerPublicKey\
|
|
20
|
+
* Cache` resolve it on first identify(). */
|
|
21
|
+
serverPublicKeyB64?: string;
|
|
22
|
+
/** Override the SDK version reported in the payload. */
|
|
23
|
+
sdkVersion?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface IdentifyOptions {
|
|
26
|
+
event?: FpEventType;
|
|
27
|
+
/** Opaque integrator-facing user id. Surfaces as «Linked ID» in the
|
|
28
|
+
* dashboard's Identification table. */
|
|
29
|
+
userId?: string;
|
|
30
|
+
/** Block the HTTP response until scoring finishes (~50-150 ms) so the
|
|
31
|
+
* result includes the real `visitorId` / `suspectScore`. Use for
|
|
32
|
+
* click-driven flows. Defaults to `true`. */
|
|
33
|
+
sync?: boolean;
|
|
34
|
+
}
|
|
35
|
+
export interface IdentifyResult {
|
|
36
|
+
v?: string;
|
|
37
|
+
eventId?: string;
|
|
38
|
+
result?: 'ok' | 'retry' | 'reject';
|
|
39
|
+
visitorId?: string;
|
|
40
|
+
userId?: string;
|
|
41
|
+
suspectScore?: number;
|
|
42
|
+
external?: {
|
|
43
|
+
flags?: string[];
|
|
44
|
+
linkedVisitors?: string[];
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export interface FpInstance {
|
|
48
|
+
identify: (opts?: IdentifyOptions) => Promise<IdentifyResult | null>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,GAAG,WAAW,CAAC;AAEnF,MAAM,WAAW,QAAQ;IACvB;wEACoE;IACpE,YAAY,EAAE,MAAM,CAAC;IACrB;+EAC2E;IAC3E,UAAU,EAAE,MAAM,CAAC;IACnB;;iDAE6C;IAC7C,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wDAAwD;IACxD,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB;4CACwC;IACxC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;kDAE8C;IAC9C,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED,MAAM,WAAW,cAAc;IAC7B,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE;QACT,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;QACjB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;KAC3B,CAAC;CACH;AAED,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,CAAC,IAAI,CAAC,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAAC;CACtE"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
# Podspec for @fingerprint79/react-native. Picked up by RN >=0.60 autolinking
|
|
4
|
+
# via react-native.config.js — consumers don't edit this file.
|
|
5
|
+
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
|
6
|
+
|
|
7
|
+
Pod::Spec.new do |s|
|
|
8
|
+
s.name = 'fingerprint79-react-native'
|
|
9
|
+
s.version = package['version']
|
|
10
|
+
s.summary = package['description']
|
|
11
|
+
s.license = package['license']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.authors = { 'Fingerprint Platform contributors' => 'noreply@example.com' }
|
|
14
|
+
s.source = { :git => package['repository']['url'], :tag => "v#{s.version}" }
|
|
15
|
+
|
|
16
|
+
# Mirrors FP Pro's minimum: iOS 12 (Swift 5.7+ is implied by Xcode 14).
|
|
17
|
+
s.platforms = { :ios => '12.0' }
|
|
18
|
+
s.swift_versions = ['5.7', '5.8', '5.9']
|
|
19
|
+
|
|
20
|
+
s.source_files = 'ios/**/*.{h,m,mm,swift}'
|
|
21
|
+
s.requires_arc = true
|
|
22
|
+
|
|
23
|
+
# RN core — every native module pulls this in. Autolinking ensures the
|
|
24
|
+
# consumer's React-Core pod resolves at the right version.
|
|
25
|
+
s.dependency 'React-Core'
|
|
26
|
+
end
|
package/ios/FpRnModule.m
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Obj-C bridge header — what RN_EXTERN_MODULE registers Swift classes as
|
|
2
|
+
// when the host app is still using the old bridge (RN <0.74). Auto-loaded
|
|
3
|
+
// by autolinking; the file's mere presence is all RN needs.
|
|
4
|
+
#import <React/RCTBridgeModule.h>
|
|
5
|
+
|
|
6
|
+
@interface RCT_EXTERN_MODULE(FpRnModule, NSObject)
|
|
7
|
+
|
|
8
|
+
RCT_EXTERN_METHOD(collect:(RCTPromiseResolveBlock)resolve
|
|
9
|
+
reject:(RCTPromiseRejectBlock)reject)
|
|
10
|
+
|
|
11
|
+
// `+requiresMainQueueSetup` is defined in Swift; declaring it here lets
|
|
12
|
+
// RN's bridge see the override at registration time on old RN versions
|
|
13
|
+
// that still introspect Obj-C method lists.
|
|
14
|
+
+ (BOOL)requiresMainQueueSetup;
|
|
15
|
+
|
|
16
|
+
@end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// FpRnModule.swift
|
|
2
|
+
//
|
|
3
|
+
// Native bridge for @fingerprint79/react-native on iOS. Exposes one method
|
|
4
|
+
// — `collect()` — that returns a dictionary the JS layer drops directly
|
|
5
|
+
// into `IngestPayload.signals.mobile`. Everything we collect here is
|
|
6
|
+
// either unreachable from JS (IDFV, Keychain UUID, sysctl model) or
|
|
7
|
+
// expensive to fetch reliably from RN's JS bridge (hardware.machine,
|
|
8
|
+
// physicalMemory).
|
|
9
|
+
//
|
|
10
|
+
// Identifier strategy mirrors what FingerprintJS Pro does:
|
|
11
|
+
// 1. `idfv` — UIDevice.identifierForVendor. Vendor-scoped UUID,
|
|
12
|
+
// stable until the user deletes every app from us.
|
|
13
|
+
// 2. `installId` — UUID we write to Keychain (kSecAttrAccessibleAfter\
|
|
14
|
+
// FirstUnlock). Survives app delete + reinstall when
|
|
15
|
+
// iCloud Keychain is enabled, which is the strongest
|
|
16
|
+
// Apple-sanctioned device-identifier we get without the
|
|
17
|
+
// AppTrackingTransparency prompt + IDFA.
|
|
18
|
+
//
|
|
19
|
+
// We do NOT touch IDFA / AppTrackingTransparency — that would force the
|
|
20
|
+
// host app into an ATT prompt and require an Info.plist string. Visitor
|
|
21
|
+
// continuity through Keychain is enough for the fraud-detection use case.
|
|
22
|
+
|
|
23
|
+
import Foundation
|
|
24
|
+
import UIKit
|
|
25
|
+
|
|
26
|
+
@objc(FpRnModule)
|
|
27
|
+
class FpRnModule: NSObject {
|
|
28
|
+
|
|
29
|
+
// MARK: - RN bridge plumbing
|
|
30
|
+
|
|
31
|
+
@objc static func requiresMainQueueSetup() -> Bool {
|
|
32
|
+
// Most of what we read is thread-safe (UIDevice / ProcessInfo / sysctl
|
|
33
|
+
// / Keychain). Returning false keeps us off the main thread and out
|
|
34
|
+
// of the RN startup critical path.
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@objc func methodQueue() -> DispatchQueue {
|
|
39
|
+
// Dedicated low-priority queue — `collect()` is called once per
|
|
40
|
+
// identify() so we don't need a thread per call.
|
|
41
|
+
return DispatchQueue(label: "com.fingerprint79.rn.collect", qos: .utility)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// MARK: - Public API
|
|
45
|
+
|
|
46
|
+
@objc(collect:reject:)
|
|
47
|
+
func collect(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
48
|
+
reject: @escaping RCTPromiseRejectBlock) {
|
|
49
|
+
let device = UIDevice.current
|
|
50
|
+
let info = ProcessInfo.processInfo
|
|
51
|
+
let bundle = Bundle.main
|
|
52
|
+
|
|
53
|
+
// Memory in MiB. `physicalMemory` is bytes; integer-divide and clamp.
|
|
54
|
+
let memMib = Int(info.physicalMemory / (1024 * 1024))
|
|
55
|
+
|
|
56
|
+
let payload: [String: Any] = [
|
|
57
|
+
"os": "ios",
|
|
58
|
+
"osVersion": device.systemVersion,
|
|
59
|
+
"model": Self.hardwareModel(),
|
|
60
|
+
"manufacturer": "Apple",
|
|
61
|
+
"idfv": device.identifierForVendor?.uuidString as Any,
|
|
62
|
+
"installId": Self.keychainInstallId(),
|
|
63
|
+
"totalMemoryMb": memMib,
|
|
64
|
+
"isTablet": device.userInterfaceIdiom == .pad,
|
|
65
|
+
"bundleId": bundle.bundleIdentifier as Any,
|
|
66
|
+
"appVersion":
|
|
67
|
+
bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String as Any,
|
|
68
|
+
]
|
|
69
|
+
resolve(payload)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: - Helpers
|
|
73
|
+
|
|
74
|
+
/// `sysctlbyname("hw.machine")` returns the model identifier
|
|
75
|
+
/// (`"iPhone15,2"`, `"iPad14,1"`). Marketing names are NOT exposed by
|
|
76
|
+
/// iOS — translation to "iPhone 14 Pro" happens server-side from a lookup
|
|
77
|
+
/// table the scoring worker holds.
|
|
78
|
+
private static func hardwareModel() -> String {
|
|
79
|
+
var size = 0
|
|
80
|
+
sysctlbyname("hw.machine", nil, &size, nil, 0)
|
|
81
|
+
var buf = [CChar](repeating: 0, count: size)
|
|
82
|
+
sysctlbyname("hw.machine", &buf, &size, nil, 0)
|
|
83
|
+
return String(cString: buf)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Service identifier for Keychain. Globally unique to us — never collides
|
|
87
|
+
// with the host app's own Keychain items. AccessibleAfterFirstUnlock so
|
|
88
|
+
// the value survives device reboot but stays protected before the user's
|
|
89
|
+
// first PIN entry.
|
|
90
|
+
private static let kcService = "com.fingerprint79.installId"
|
|
91
|
+
private static let kcAccount = "default"
|
|
92
|
+
|
|
93
|
+
/// Returns a stable installation UUID, generating + persisting on first
|
|
94
|
+
/// call. Failures (Keychain locked, simulator quirks) fall back to a
|
|
95
|
+
/// fresh UUID so the call site never sees `nil` — at worst we lose
|
|
96
|
+
/// cross-launch continuity.
|
|
97
|
+
private static func keychainInstallId() -> String {
|
|
98
|
+
if let existing = readKeychain() { return existing }
|
|
99
|
+
let fresh = UUID().uuidString
|
|
100
|
+
writeKeychain(value: fresh)
|
|
101
|
+
return fresh
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private static func readKeychain() -> String? {
|
|
105
|
+
let query: [String: Any] = [
|
|
106
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
107
|
+
kSecAttrService as String: kcService,
|
|
108
|
+
kSecAttrAccount as String: kcAccount,
|
|
109
|
+
kSecReturnData as String: true,
|
|
110
|
+
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
111
|
+
]
|
|
112
|
+
var item: AnyObject?
|
|
113
|
+
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
114
|
+
guard status == errSecSuccess,
|
|
115
|
+
let data = item as? Data,
|
|
116
|
+
let str = String(data: data, encoding: .utf8) else { return nil }
|
|
117
|
+
return str
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private static func writeKeychain(value: String) {
|
|
121
|
+
let data = value.data(using: .utf8)!
|
|
122
|
+
let attrs: [String: Any] = [
|
|
123
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
124
|
+
kSecAttrService as String: kcService,
|
|
125
|
+
kSecAttrAccount as String: kcAccount,
|
|
126
|
+
kSecValueData as String: data,
|
|
127
|
+
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
|
|
128
|
+
]
|
|
129
|
+
// Delete-then-add so SecItemAdd never trips on a stale duplicate (can
|
|
130
|
+
// happen after an OS upgrade migrated the keychain DB).
|
|
131
|
+
SecItemDelete(attrs as CFDictionary)
|
|
132
|
+
SecItemAdd(attrs as CFDictionary, nil)
|
|
133
|
+
}
|
|
134
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fingerprint79/react-native",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React Native binding for the Fingerprint Platform — JS hooks + native iOS (Swift) and Android (Kotlin) bridges. Collects mobile signals (IDFV, Keychain UUID, hardware) and ships sealed payloads to your self-hosted collector.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Fingerprint Platform contributors",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.mjs",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"react-native": "./src/index.ts",
|
|
12
|
+
"source": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"react-native": "./src/index.ts",
|
|
17
|
+
"import": "./dist/index.mjs",
|
|
18
|
+
"require": "./dist/index.cjs"
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"src",
|
|
25
|
+
"ios",
|
|
26
|
+
"android",
|
|
27
|
+
"react-native.config.js",
|
|
28
|
+
"fingerprint79-react-native.podspec",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": false,
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/igorao79/fingerprint.git",
|
|
36
|
+
"directory": "apps/sdk-react-native"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/igorao79/fingerprint/tree/main/apps/sdk-react-native#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/igorao79/fingerprint/issues"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"fingerprint",
|
|
44
|
+
"react-native",
|
|
45
|
+
"ios",
|
|
46
|
+
"android",
|
|
47
|
+
"device-fingerprint",
|
|
48
|
+
"mobile-fingerprint",
|
|
49
|
+
"visitor-id",
|
|
50
|
+
"anti-fraud",
|
|
51
|
+
"bot-detection",
|
|
52
|
+
"fp-rn"
|
|
53
|
+
],
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "vite build && tsc -p tsconfig.build.json",
|
|
59
|
+
"typecheck": "tsc -b --noEmit",
|
|
60
|
+
"lint": "eslint src",
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"clean": "rimraf dist .turbo *.tsbuildinfo",
|
|
63
|
+
"prepublishOnly": "pnpm build"
|
|
64
|
+
},
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"react": ">=18 <20",
|
|
67
|
+
"react-native": ">=0.72",
|
|
68
|
+
"react-native-localize": ">=3",
|
|
69
|
+
"@react-native-community/netinfo": ">=11",
|
|
70
|
+
"@react-native-async-storage/async-storage": ">=1.21"
|
|
71
|
+
},
|
|
72
|
+
"peerDependenciesMeta": {
|
|
73
|
+
"react-native-localize": { "optional": true },
|
|
74
|
+
"@react-native-community/netinfo": { "optional": true },
|
|
75
|
+
"@react-native-async-storage/async-storage": { "optional": true }
|
|
76
|
+
},
|
|
77
|
+
"dependencies": {},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@fp/crypto": "workspace:*",
|
|
80
|
+
"@fp/sdk-core": "workspace:*",
|
|
81
|
+
"@fp/shared-types": "workspace:*",
|
|
82
|
+
"@types/react": "^18.3.12",
|
|
83
|
+
"cbor-x": "^1.6.0",
|
|
84
|
+
"react": "^18.3.1",
|
|
85
|
+
"react-native": "^0.74.0",
|
|
86
|
+
"vite": "^5.4.11"
|
|
87
|
+
},
|
|
88
|
+
"engines": {
|
|
89
|
+
"node": ">=18"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// React Native autolinking manifest. RN >=0.60 reads this file in every
|
|
2
|
+
// installed dependency and wires the iOS pod + Android gradle module into
|
|
3
|
+
// the host app at `pod install` / `gradle assemble` time. Without this
|
|
4
|
+
// file the consumer would have to edit Podfile + settings.gradle by hand.
|
|
5
|
+
module.exports = {
|
|
6
|
+
dependency: {
|
|
7
|
+
platforms: {
|
|
8
|
+
ios: {
|
|
9
|
+
podspecPath: __dirname + '/fingerprint79-react-native.podspec',
|
|
10
|
+
},
|
|
11
|
+
android: {
|
|
12
|
+
sourceDir: __dirname + '/android',
|
|
13
|
+
packageImportPath: 'import com.fingerprint79.rn.FpRnPackage;',
|
|
14
|
+
packageInstance: 'new FpRnPackage()',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// FpRnClient — RN counterpart of `FpSdk` in sdk-browser. Owns the
|
|
2
|
+
// public-key cache, runs collectSignals on every identify(), seals the
|
|
3
|
+
// CBOR-encoded payload with X25519+AES-GCM (same crypto as the browser
|
|
4
|
+
// SDK — payloads from web and mobile are interchangeable at the
|
|
5
|
+
// collector), and POSTs to /v1/ingest.
|
|
6
|
+
|
|
7
|
+
import { encode as cborEncode } from 'cbor-x';
|
|
8
|
+
|
|
9
|
+
import { ServerPublicKeyCache, sendIngest } from '@fp/sdk-core';
|
|
10
|
+
import { seal } from '@fp/crypto';
|
|
11
|
+
|
|
12
|
+
import { collectSignals } from './signals/index.js';
|
|
13
|
+
import { getOrCreateSessionId, restoreSessionId } from './signals/persistence.js';
|
|
14
|
+
|
|
15
|
+
import type { IngestPayload, IngestResponse } from '@fp/shared-types';
|
|
16
|
+
import type { FpConfig, IdentifyOptions } from './types.js';
|
|
17
|
+
|
|
18
|
+
// Vite's `define` inlines this at build time from package.json.
|
|
19
|
+
declare const __SDK_VERSION__: string;
|
|
20
|
+
const SDK_VERSION: string =
|
|
21
|
+
typeof __SDK_VERSION__ === 'string' ? __SDK_VERSION__ : '0.0.0-dev';
|
|
22
|
+
|
|
23
|
+
export class FpRnClient {
|
|
24
|
+
private readonly pkCache: ServerPublicKeyCache;
|
|
25
|
+
private readonly config: FpConfig;
|
|
26
|
+
|
|
27
|
+
constructor(config: FpConfig) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.pkCache = new ServerPublicKeyCache(config.collectorUrl);
|
|
30
|
+
// Kick off persistence restore non-blocking. Late lands are fine —
|
|
31
|
+
// the first identify() may use a fresh id, all subsequent ones
|
|
32
|
+
// get the persisted one once AsyncStorage answers.
|
|
33
|
+
void restoreSessionId();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Warm-up the public-key cache. FpProvider awaits this in useEffect
|
|
37
|
+
* so the very first identify() call doesn't pay the /v1/pk hop and
|
|
38
|
+
* so auth misconfig surfaces as ready=false up-front. */
|
|
39
|
+
async warmUp(): Promise<void> {
|
|
40
|
+
await this.pkCache.getWithAlias();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async identify(opts: IdentifyOptions = {}): Promise<IngestResponse | null> {
|
|
44
|
+
const signals = await collectSignals();
|
|
45
|
+
const sessionId = getOrCreateSessionId();
|
|
46
|
+
const event = opts.event ?? 'pageview';
|
|
47
|
+
const syncMode = opts.sync ?? true;
|
|
48
|
+
|
|
49
|
+
const payload: IngestPayload = {
|
|
50
|
+
sdkVersion: SDK_VERSION,
|
|
51
|
+
ts: Date.now(),
|
|
52
|
+
sessionId,
|
|
53
|
+
// Mobile apps have no URL but the wire schema requires a string —
|
|
54
|
+
// use the bundle id (if collected via native) as a stable
|
|
55
|
+
// "namespace" so dashboard filters don't choke on every event
|
|
56
|
+
// sharing the same placeholder. Fall back to a sentinel scheme.
|
|
57
|
+
pageUrl: bundleAsUrl(signals.mobile?.bundleId) ?? 'rn://app',
|
|
58
|
+
referrer: '',
|
|
59
|
+
event,
|
|
60
|
+
signals,
|
|
61
|
+
};
|
|
62
|
+
if (opts.userId !== undefined) {
|
|
63
|
+
payload.accountHint = { userId: opts.userId };
|
|
64
|
+
}
|
|
65
|
+
if (syncMode) payload.sync = true;
|
|
66
|
+
|
|
67
|
+
const { key: serverKey, aliasPath } = await this.pkCache.getWithAlias();
|
|
68
|
+
const plaintext = new Uint8Array(cborEncode(payload));
|
|
69
|
+
const { wire } = await seal(serverKey.key, plaintext);
|
|
70
|
+
|
|
71
|
+
return await sendIngest({
|
|
72
|
+
collectorUrl: this.config.collectorUrl,
|
|
73
|
+
wire,
|
|
74
|
+
serverKeyId: serverKey.id,
|
|
75
|
+
sdkVersion: SDK_VERSION,
|
|
76
|
+
preferBeacon: false,
|
|
77
|
+
...(aliasPath ? { aliasPath } : {}),
|
|
78
|
+
projectKey: this.config.projectKey,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `pageUrl` is `.url()`-validated by Zod. Bundle ids like `com.acme.app`
|
|
84
|
+
* aren't URLs, so wrap them as a custom scheme — keeps the dashboard
|
|
85
|
+
* filterable per-app without false validation errors. */
|
|
86
|
+
function bundleAsUrl(bundleId: string | undefined): string | undefined {
|
|
87
|
+
if (!bundleId) return undefined;
|
|
88
|
+
return `rn://${bundleId}`;
|
|
89
|
+
}
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// FpProvider — RN mirror of `apps/sdk-react/src/context.tsx`. Unlike the
|
|
2
|
+
// web version we never wait for a global to appear; the SDK is JS code
|
|
3
|
+
// inside the same process, so we instantiate FpRnClient immediately and
|
|
4
|
+
// fire a no-op /v1/pk warm-up to surface auth errors early.
|
|
5
|
+
|
|
6
|
+
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
7
|
+
|
|
8
|
+
import { FpRnClient } from './client.js';
|
|
9
|
+
import { isNativeLinked } from './native.js';
|
|
10
|
+
|
|
11
|
+
import type { FpConfig } from './types.js';
|
|
12
|
+
|
|
13
|
+
interface FpProviderProps {
|
|
14
|
+
readonly config: FpConfig;
|
|
15
|
+
readonly children: ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface FpContextValue {
|
|
19
|
+
instance: FpRnClient | null;
|
|
20
|
+
ready: boolean;
|
|
21
|
+
error: Error | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const FpContext = createContext<FpContextValue>({
|
|
25
|
+
instance: null,
|
|
26
|
+
ready: false,
|
|
27
|
+
error: null,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export function FpProvider({ config, children }: FpProviderProps) {
|
|
31
|
+
// Stable instance for the provider's entire lifetime. Re-creating per
|
|
32
|
+
// render would discard the pk-fetcher's cache on every parent rerender.
|
|
33
|
+
const instance = useMemo(() => new FpRnClient(config), [config.collectorUrl, config.projectKey]);
|
|
34
|
+
const [state, setState] = useState<FpContextValue>({
|
|
35
|
+
instance: null,
|
|
36
|
+
ready: false,
|
|
37
|
+
error: null,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
if (!isNativeLinked() && __DEV__) {
|
|
43
|
+
console.warn(
|
|
44
|
+
'[@fingerprint79/react-native] Native module not linked. ' +
|
|
45
|
+
'Mobile signals (idfv / installId / hardware) will be absent. ' +
|
|
46
|
+
'Did you run `pod install` (iOS) and rebuild (Android)?',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
// Mark ready once the public-key fetch lands — that's the first
|
|
50
|
+
// request the SDK actually needs. Failures here surface as
|
|
51
|
+
// `ready: false, error: ...` so the UI can render a banner.
|
|
52
|
+
instance
|
|
53
|
+
.warmUp()
|
|
54
|
+
.then(() => {
|
|
55
|
+
if (cancelled) return;
|
|
56
|
+
setState({ instance, ready: true, error: null });
|
|
57
|
+
})
|
|
58
|
+
.catch((e: unknown) => {
|
|
59
|
+
if (cancelled) return;
|
|
60
|
+
setState({
|
|
61
|
+
instance: null,
|
|
62
|
+
ready: false,
|
|
63
|
+
error: e instanceof Error ? e : new Error(String(e)),
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
return () => {
|
|
67
|
+
cancelled = true;
|
|
68
|
+
};
|
|
69
|
+
}, [instance]);
|
|
70
|
+
|
|
71
|
+
return <FpContext.Provider value={state}>{children}</FpContext.Provider>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Internal — every hook routes through this. */
|
|
75
|
+
export function useFpContext(): FpContextValue {
|
|
76
|
+
return useContext(FpContext);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Module-level shim for `__DEV__` so this file typechecks outside Metro
|
|
80
|
+
// (which injects __DEV__ as a global). Library code is allowed to use
|
|
81
|
+
// it directly per RN convention.
|
|
82
|
+
declare const __DEV__: boolean;
|