@apex-inc/capacitor-plugin 0.3.9 → 2.1.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/android/build.gradle +4 -2
- package/android/src/main/AndroidManifest.xml +35 -3
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +403 -6
- package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +122 -0
- package/android/src/main/java/inc/apex/capacitor/ApexFirebaseMessagingService.kt +99 -0
- package/android/src/main/java/inc/apex/capacitor/NativeBatchSender.kt +260 -0
- package/dist/batch-sender.d.ts +12 -0
- package/dist/batch-sender.d.ts.map +1 -1
- package/dist/batch-sender.js +25 -0
- package/dist/batch-sender.js.map +1 -1
- package/dist/cart-helpers.d.ts +63 -0
- package/dist/cart-helpers.d.ts.map +1 -0
- package/dist/cart-helpers.js +50 -0
- package/dist/cart-helpers.js.map +1 -0
- package/dist/definitions.d.ts +142 -2
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/batch-sender.d.ts +12 -0
- package/dist/esm/batch-sender.d.ts.map +1 -1
- package/dist/esm/batch-sender.js +25 -0
- package/dist/esm/batch-sender.js.map +1 -1
- package/dist/esm/cart-helpers.d.ts +63 -0
- package/dist/esm/cart-helpers.d.ts.map +1 -0
- package/dist/esm/cart-helpers.js +44 -0
- package/dist/esm/cart-helpers.js.map +1 -0
- package/dist/esm/definitions.d.ts +142 -2
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/events.d.ts +85 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +96 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +4 -5
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +19 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/offline-queue.d.ts +15 -0
- package/dist/esm/offline-queue.d.ts.map +1 -1
- package/dist/esm/offline-queue.js +35 -0
- package/dist/esm/offline-queue.js.map +1 -1
- package/dist/esm/screen-view.d.ts +18 -0
- package/dist/esm/screen-view.d.ts.map +1 -0
- package/dist/esm/screen-view.js +28 -0
- package/dist/esm/screen-view.js.map +1 -0
- package/dist/esm/web.d.ts +29 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +161 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/events.d.ts +85 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +99 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -2
- package/dist/index.js.map +1 -1
- package/dist/offline-queue.d.ts +15 -0
- package/dist/offline-queue.d.ts.map +1 -1
- package/dist/offline-queue.js +35 -0
- package/dist/offline-queue.js.map +1 -1
- package/dist/screen-view.d.ts +18 -0
- package/dist/screen-view.d.ts.map +1 -0
- package/dist/screen-view.js +31 -0
- package/dist/screen-view.js.map +1 -0
- package/dist/web.d.ts +29 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +161 -0
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +124 -0
- package/ios/Sources/ApexCapacitorPlugin/BatchSender.swift +18 -0
- package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +19 -0
- package/ios/Sources/ApexCapacitorPlugin/PushNotificationManager.swift +47 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +280 -20
- package/package.json +1 -1
|
@@ -60,6 +60,18 @@ public final class PushNotificationManager: NSObject {
|
|
|
60
60
|
private let notificationCenter: NotificationCenter
|
|
61
61
|
private var pendingCompletion: Completion?
|
|
62
62
|
|
|
63
|
+
/// Fired on EVERY token arrival, regardless of whether the
|
|
64
|
+
/// registration was kicked off by an explicit
|
|
65
|
+
/// `requestAuthorizationAndRegister` call or by `silentRegisterIfAuthorized`.
|
|
66
|
+
///
|
|
67
|
+
/// The plugin uses this to forward the token to JS listeners and to
|
|
68
|
+
/// re-register the token with the Apex backend. Setting this from
|
|
69
|
+
/// `initialize` lets us silently refresh the token on every cold
|
|
70
|
+
/// start when permission is already granted — without this hook the
|
|
71
|
+
/// merchant would have to manually tap "Enable push" after every
|
|
72
|
+
/// fresh Xcode install just to surface the token in the dashboard.
|
|
73
|
+
public var onTokenReceived: ((String) -> Void)?
|
|
74
|
+
|
|
63
75
|
public init(
|
|
64
76
|
center: UNUserNotificationCenter = .current(),
|
|
65
77
|
application: UIApplication = .shared,
|
|
@@ -113,6 +125,36 @@ public final class PushNotificationManager: NSObject {
|
|
|
113
125
|
}
|
|
114
126
|
}
|
|
115
127
|
|
|
128
|
+
/// Re-register for remote notifications WITHOUT prompting when
|
|
129
|
+
/// permission has already been granted. iOS's permission survives
|
|
130
|
+
/// app reinstalls (including Xcode debug rebuilds) but the sample
|
|
131
|
+
/// app's in-memory "have we registered yet?" state does not — so
|
|
132
|
+
/// merchants kept telling us push permission was "resetting on every
|
|
133
|
+
/// build". The OS hadn't actually reset anything; we just weren't
|
|
134
|
+
/// surfacing the fact that permission was still granted.
|
|
135
|
+
///
|
|
136
|
+
/// Calling this on `initialize` re-fires `didRegisterForRemoteNotifications`
|
|
137
|
+
/// with the device's current token, which flows through `onTokenReceived`
|
|
138
|
+
/// and refreshes the dashboard registration with the latest plugin
|
|
139
|
+
/// metadata (including the APNs environment field that older builds
|
|
140
|
+
/// didn't report).
|
|
141
|
+
///
|
|
142
|
+
/// No-op when permission is denied or hasn't been requested yet —
|
|
143
|
+
/// the user has to take an explicit action in those cases.
|
|
144
|
+
public func silentRegisterIfAuthorized() {
|
|
145
|
+
center.getNotificationSettings { [weak self] settings in
|
|
146
|
+
guard let self = self else { return }
|
|
147
|
+
switch settings.authorizationStatus {
|
|
148
|
+
case .authorized, .provisional, .ephemeral:
|
|
149
|
+
DispatchQueue.main.async {
|
|
150
|
+
self.application.registerForRemoteNotifications()
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
116
158
|
// MARK: - AppDelegate bridge
|
|
117
159
|
|
|
118
160
|
private func observeAppDelegateBridge() {
|
|
@@ -137,6 +179,11 @@ public final class PushNotificationManager: NSObject {
|
|
|
137
179
|
}
|
|
138
180
|
let hex = PushTokenHexFormatter.format(data)
|
|
139
181
|
resolvePending(.init(permission: .granted, token: hex))
|
|
182
|
+
// Fire after resolvePending so the JS Promise for an explicit
|
|
183
|
+
// `Apex.registerForPushNotifications()` resolves first, then
|
|
184
|
+
// the plugin's persistent `onTokenReceived` hook runs to forward
|
|
185
|
+
// to listeners + the Apex backend.
|
|
186
|
+
onTokenReceived?(hex)
|
|
140
187
|
}
|
|
141
188
|
|
|
142
189
|
@objc private func handleDidFail(_: Notification) {
|
|
@@ -14,6 +14,9 @@ import Foundation
|
|
|
14
14
|
import Capacitor
|
|
15
15
|
import UIKit
|
|
16
16
|
import UserNotifications
|
|
17
|
+
#if canImport(StoreKit)
|
|
18
|
+
import StoreKit
|
|
19
|
+
#endif
|
|
17
20
|
|
|
18
21
|
@objc(ApexCapacitorPlugin)
|
|
19
22
|
public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificationCenterDelegate {
|
|
@@ -30,6 +33,7 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
30
33
|
CAPPluginMethod(name: "getInstallReferrer", returnType: CAPPluginReturnPromise),
|
|
31
34
|
CAPPluginMethod(name: "getVisitorId", returnType: CAPPluginReturnPromise),
|
|
32
35
|
CAPPluginMethod(name: "setVisitorId", returnType: CAPPluginReturnPromise),
|
|
36
|
+
CAPPluginMethod(name: "identify", returnType: CAPPluginReturnPromise),
|
|
33
37
|
CAPPluginMethod(name: "updateConversionValue", returnType: CAPPluginReturnPromise),
|
|
34
38
|
CAPPluginMethod(name: "getInitialDeepLink", returnType: CAPPluginReturnPromise),
|
|
35
39
|
CAPPluginMethod(name: "getDeviceInfo", returnType: CAPPluginReturnPromise),
|
|
@@ -70,12 +74,25 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
70
74
|
private var debug = false
|
|
71
75
|
private var apiBaseUrl: String = ""
|
|
72
76
|
private var projectKey: String = ""
|
|
77
|
+
// Phase 2-SDK — optional workspace-bound API key forwarded as
|
|
78
|
+
// `x-apex-api-key` on every event POST and on `identify()`.
|
|
79
|
+
private var apiKey: String? = nil
|
|
73
80
|
|
|
74
81
|
// MMP-205 — native batch sender. Drains offlineQueue to /api/events.
|
|
75
82
|
// Until 0.3.6 this was a no-op; events accumulated on disk and never
|
|
76
83
|
// reached the server. See friction-log F6.
|
|
77
84
|
private var batchSender: NativeBatchSender?
|
|
78
85
|
private var flushTimer: Timer?
|
|
86
|
+
|
|
87
|
+
// MMP-061 — cached release channel + source. Detected once per
|
|
88
|
+
// launch in `initialize()` and stamped on every event + push token
|
|
89
|
+
// registration. iOS authoritative path uses AppTransaction (iOS
|
|
90
|
+
// 16+) or appStoreReceiptURL.lastPathComponent fallback. Re-detect
|
|
91
|
+
// on `app_open` events since a re-install over an existing build
|
|
92
|
+
// can change the receipt path.
|
|
93
|
+
private var releaseChannel: String = "unknown"
|
|
94
|
+
private var releaseChannelSource: String = "auto"
|
|
95
|
+
private var lastReleaseChannelDetectedAt: Date = Date(timeIntervalSince1970: 0)
|
|
79
96
|
// MMP-207 — debounced flush. Without this, every `track()` call
|
|
80
97
|
// kicked a POST, so 8 quick taps = 8 separate single-event HTTP
|
|
81
98
|
// requests. With debounce, rapid-fire events coalesce into one
|
|
@@ -178,6 +195,29 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
178
195
|
apiBaseUrl = call.getString("apiUrl") ?? "https://api.apex.inc"
|
|
179
196
|
testMode = call.getBool("testMode") ?? false
|
|
180
197
|
debug = call.getBool("debug") ?? false
|
|
198
|
+
// Phase 2-SDK — workspace-bound API key (optional). Stamped on
|
|
199
|
+
// every outbound request as `x-apex-api-key` so the server can
|
|
200
|
+
// authorize quarantine-mode auto-stitch.
|
|
201
|
+
let rawKey = call.getString("apiKey") ?? ""
|
|
202
|
+
apiKey = rawKey.isEmpty ? nil : rawKey
|
|
203
|
+
|
|
204
|
+
// MMP-061 — release channel detection. iOS uses the receipt URL
|
|
205
|
+
// + StoreKit 2 AppTransaction. Any JS-supplied override on iOS
|
|
206
|
+
// is ignored (receipt URL is authoritative) but we log a warn
|
|
207
|
+
// so the discrepancy is visible during development.
|
|
208
|
+
let jsOverride = call.getString("releaseChannel")
|
|
209
|
+
if let override = jsOverride, !override.isEmpty {
|
|
210
|
+
if debug {
|
|
211
|
+
print("[apex-capacitor] WARN: ignoring releaseChannel='\(override)' override on iOS — AppTransaction / receipt URL is authoritative. Server will record releaseChannelSource=auto.")
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
let detected = detectReleaseChannelSync()
|
|
215
|
+
releaseChannel = detected
|
|
216
|
+
releaseChannelSource = "auto"
|
|
217
|
+
lastReleaseChannelDetectedAt = Date()
|
|
218
|
+
if debug {
|
|
219
|
+
print("[apex-capacitor] releaseChannel detected = \(detected)")
|
|
220
|
+
}
|
|
181
221
|
let queueDir = FileManager.default
|
|
182
222
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
|
183
223
|
.first!
|
|
@@ -195,15 +235,43 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
195
235
|
apiBaseUrl: apiBaseUrl,
|
|
196
236
|
projectKey: projectKey,
|
|
197
237
|
platformHeader: "ios",
|
|
238
|
+
apiKey: apiKey,
|
|
198
239
|
debug: debug
|
|
199
240
|
)
|
|
200
241
|
scheduleFlushTimer()
|
|
201
242
|
observeForegroundForFlush()
|
|
202
243
|
kickFlush(reason: "initialize")
|
|
203
244
|
|
|
245
|
+
// Push token persistence (post-MMP-177 fix): iOS keeps notification
|
|
246
|
+
// permission across reinstalls, but the dashboard / sample-app UI
|
|
247
|
+
// forgets the token between launches. By installing a persistent
|
|
248
|
+
// `onTokenReceived` hook AND triggering a silent re-register when
|
|
249
|
+
// permission is already granted, we (a) surface the token on
|
|
250
|
+
// every cold start without re-prompting the user and (b) re-upload
|
|
251
|
+
// it to the backend with the latest metadata — critical because
|
|
252
|
+
// older plugin builds didn't ship the `environment` field, so
|
|
253
|
+
// legacy tokens were getting routed to the wrong APNs endpoint.
|
|
254
|
+
pushManager.onTokenReceived = { [weak self] token in
|
|
255
|
+
self?.handleTokenArrived(token)
|
|
256
|
+
}
|
|
257
|
+
pushManager.silentRegisterIfAuthorized()
|
|
258
|
+
|
|
204
259
|
call.resolve()
|
|
205
260
|
}
|
|
206
261
|
|
|
262
|
+
/// Forward a freshly-arrived APNs device token to JS listeners and
|
|
263
|
+
/// register it with the Apex backend. Shared by the explicit
|
|
264
|
+
/// `registerForPushNotifications()` path and the silent re-register
|
|
265
|
+
/// path triggered by `initialize` so neither one drifts ahead of
|
|
266
|
+
/// the other.
|
|
267
|
+
private func handleTokenArrived(_ token: String) {
|
|
268
|
+
notifyListeners("pushTokenReceived", data: [
|
|
269
|
+
"token": token,
|
|
270
|
+
"platform": "ios"
|
|
271
|
+
])
|
|
272
|
+
registerTokenWithApex(token: token)
|
|
273
|
+
}
|
|
274
|
+
|
|
207
275
|
/// Schedules a periodic 30s flush so events that arrive in bursts
|
|
208
276
|
/// (or during background) leave the device on a steady cadence.
|
|
209
277
|
private func scheduleFlushTimer() {
|
|
@@ -266,24 +334,12 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
266
334
|
|
|
267
335
|
@objc public func registerForPushNotifications(_ call: CAPPluginCall) {
|
|
268
336
|
pushManager.requestAuthorizationAndRegister { [weak self] outcome in
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if let token = outcome.token {
|
|
276
|
-
self.notifyListeners("pushTokenReceived", data: [
|
|
277
|
-
"token": token,
|
|
278
|
-
"platform": "ios"
|
|
279
|
-
])
|
|
280
|
-
// Auto-register with the Apex backend so the dashboard
|
|
281
|
-
// sees this device immediately. The wizard relies on
|
|
282
|
-
// this so the test-push step finds a registered token
|
|
283
|
-
// without the customer wiring anything explicitly.
|
|
284
|
-
self.registerTokenWithApex(token: token)
|
|
285
|
-
}
|
|
286
|
-
|
|
337
|
+
// `handleTokenArrived` is invoked by the manager's
|
|
338
|
+
// `onTokenReceived` hook installed during `initialize`, so
|
|
339
|
+
// we DON'T re-fire it here — doing so would double-register
|
|
340
|
+
// the token with the Apex backend. We only resolve the
|
|
341
|
+
// JS promise with the registration outcome.
|
|
342
|
+
_ = self
|
|
287
343
|
call.resolve([
|
|
288
344
|
"permission": outcome.permission.rawValue,
|
|
289
345
|
"token": outcome.token as Any
|
|
@@ -291,6 +347,108 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
291
347
|
}
|
|
292
348
|
}
|
|
293
349
|
|
|
350
|
+
/// Detect the APNs environment this build was provisioned against.
|
|
351
|
+
///
|
|
352
|
+
/// iOS issues two kinds of device tokens for the same physical device —
|
|
353
|
+
/// sandbox tokens for development builds (Xcode debug + Xcode-archive
|
|
354
|
+
/// development distributions) and production tokens for everything else
|
|
355
|
+
/// (TestFlight, App Store). The two are routed to different APNs
|
|
356
|
+
/// endpoints; sending a sandbox token to the production endpoint (or
|
|
357
|
+
/// vice versa) results in `BadDeviceToken` and the push is dropped.
|
|
358
|
+
///
|
|
359
|
+
/// We report the environment alongside the token at registration time
|
|
360
|
+
/// so the server-side dispatcher can route each push to the right
|
|
361
|
+
/// endpoint without the merchant guessing.
|
|
362
|
+
///
|
|
363
|
+
/// Detection strategy:
|
|
364
|
+
/// - `#if DEBUG` — Xcode debug builds always use sandbox APNs.
|
|
365
|
+
/// - Release builds: inspect the embedded provisioning profile's
|
|
366
|
+
/// `aps-environment` entitlement when accessible; default to
|
|
367
|
+
/// "production" for App Store / TestFlight distributions.
|
|
368
|
+
private func detectApnsEnvironment() -> String {
|
|
369
|
+
#if DEBUG
|
|
370
|
+
return "sandbox"
|
|
371
|
+
#else
|
|
372
|
+
// Release builds: try the embedded provisioning profile's
|
|
373
|
+
// aps-environment entitlement. If we can't read it (App Store
|
|
374
|
+
// builds typically can't), assume production — TestFlight + App
|
|
375
|
+
// Store both use the production APNs gateway.
|
|
376
|
+
if let profilePath = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"),
|
|
377
|
+
let profileData = try? Data(contentsOf: URL(fileURLWithPath: profilePath)),
|
|
378
|
+
let profileString = String(data: profileData, encoding: .ascii) {
|
|
379
|
+
if profileString.contains("aps-environment") &&
|
|
380
|
+
profileString.range(of: "<string>development</string>", options: .literal) != nil {
|
|
381
|
+
return "sandbox"
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return "production"
|
|
385
|
+
#endif
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/// MMP-061 — detect the build's `releaseChannel`. This is orthogonal
|
|
389
|
+
/// to `detectApnsEnvironment()`: a TestFlight build is `releaseChannel
|
|
390
|
+
/// = "testflight"` but its APNs environment is `"production"` (TestFlight
|
|
391
|
+
/// uses the prod APNs gateway). Filtering test-pushes by APNs
|
|
392
|
+
/// environment alone would route them to App Store users too.
|
|
393
|
+
///
|
|
394
|
+
/// Detection waterfall (order matters):
|
|
395
|
+
/// 1. **iOS Simulator** — always `xcode-debug`, regardless of any
|
|
396
|
+
/// receipt or AppTransaction signal. `sandboxReceipt` is also
|
|
397
|
+
/// present in the simulator, so without this guard every
|
|
398
|
+
/// simulator-driven event would tag as `testflight`.
|
|
399
|
+
/// 2. **`#if DEBUG`** — Xcode-attached debug build → `xcode-debug`.
|
|
400
|
+
/// AppTransaction.shared is `.unverified` for unsigned dev
|
|
401
|
+
/// builds; this case is the cheapest one to handle first.
|
|
402
|
+
/// 3. **StoreKit 2 AppTransaction** (iOS 16+) — the canonical
|
|
403
|
+
/// authoritative signal. Maps `.xcode` → `xcode-debug`,
|
|
404
|
+
/// `.sandbox` → `testflight`, `.production` → `app-store`.
|
|
405
|
+
/// Best-effort: if AppTransaction fails to validate (offline,
|
|
406
|
+
/// .unverified), fall through to step 4 rather than returning
|
|
407
|
+
/// `unknown`.
|
|
408
|
+
/// 4. **`appStoreReceiptURL.lastPathComponent`** fallback — works
|
|
409
|
+
/// offline and on iOS 15 and below. `"sandboxReceipt"` → TestFlight
|
|
410
|
+
/// (we already excluded simulator + DEBUG above so this is the
|
|
411
|
+
/// genuine TestFlight case). `"receipt"` → `app-store`. Anything
|
|
412
|
+
/// else (nil URL, enterprise / in-house distribution) → `unknown`.
|
|
413
|
+
///
|
|
414
|
+
/// Note: we make this synchronous so `initialize()` can stamp the
|
|
415
|
+
/// channel before the first event lands. AppTransaction.shared is an
|
|
416
|
+
/// async API — we fall through to the receipt-URL path inside the
|
|
417
|
+
/// sync flow rather than blocking, and a follow-up async refresh
|
|
418
|
+
/// could be wired in if validation drift turns up in the wild.
|
|
419
|
+
private func detectReleaseChannelSync() -> String {
|
|
420
|
+
// Step 1 — iOS Simulator. Critical bug-prevention check; the
|
|
421
|
+
// simulator's `sandboxReceipt` would otherwise look identical
|
|
422
|
+
// to a real TestFlight build.
|
|
423
|
+
#if targetEnvironment(simulator)
|
|
424
|
+
return "xcode-debug"
|
|
425
|
+
#endif
|
|
426
|
+
|
|
427
|
+
// Step 2 — Xcode-attached debug build.
|
|
428
|
+
#if DEBUG
|
|
429
|
+
return "xcode-debug"
|
|
430
|
+
#endif
|
|
431
|
+
|
|
432
|
+
// Step 3 + 4 below run only on release builds on real devices.
|
|
433
|
+
|
|
434
|
+
// Step 4 — receipt URL fallback (works offline, all iOS versions).
|
|
435
|
+
// The receipt is set at install time and re-set after a re-install,
|
|
436
|
+
// so re-detecting on `app_open` keeps it fresh after a TestFlight
|
|
437
|
+
// → App Store upgrade on the same device.
|
|
438
|
+
if let receiptURL = Bundle.main.appStoreReceiptURL {
|
|
439
|
+
let last = receiptURL.lastPathComponent
|
|
440
|
+
if last == "sandboxReceipt" {
|
|
441
|
+
return "testflight"
|
|
442
|
+
}
|
|
443
|
+
if last == "receipt" {
|
|
444
|
+
return "app-store"
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Enterprise / in-house distribution / missing receipt → unknown.
|
|
449
|
+
return "unknown"
|
|
450
|
+
}
|
|
451
|
+
|
|
294
452
|
private func registerTokenWithApex(token: String) {
|
|
295
453
|
guard !apiBaseUrl.isEmpty, !projectKey.isEmpty else {
|
|
296
454
|
if debug { print("[apex-capacitor] skipping push-token registration: plugin not initialized") }
|
|
@@ -299,6 +457,8 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
299
457
|
guard let url = URL(string: "\(apiBaseUrl)/api/mobile/push-token") else { return }
|
|
300
458
|
let bundleId = Bundle.main.bundleIdentifier ?? ""
|
|
301
459
|
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
|
460
|
+
let environment = detectApnsEnvironment()
|
|
461
|
+
if debug { print("[apex-capacitor] registering push token with environment=\(environment) releaseChannel=\(releaseChannel)") }
|
|
302
462
|
let body: [String: Any] = [
|
|
303
463
|
"projectKey": projectKey,
|
|
304
464
|
"visitorId": visitorId,
|
|
@@ -306,7 +466,13 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
306
466
|
"token": token,
|
|
307
467
|
"bundleId": bundleId,
|
|
308
468
|
"sdkVersion": "0.3.0",
|
|
309
|
-
"appVersion": appVersion
|
|
469
|
+
"appVersion": appVersion,
|
|
470
|
+
"environment": environment,
|
|
471
|
+
// MMP-061 — orthogonal to `environment`. TestFlight tokens
|
|
472
|
+
// are `releaseChannel: "testflight"` AND `environment:
|
|
473
|
+
// "production"` because TestFlight uses prod APNs.
|
|
474
|
+
"releaseChannel": releaseChannel,
|
|
475
|
+
"releaseChannelSource": releaseChannelSource
|
|
310
476
|
]
|
|
311
477
|
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return }
|
|
312
478
|
var request = URLRequest(url: url)
|
|
@@ -353,6 +519,78 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
353
519
|
call.resolve()
|
|
354
520
|
}
|
|
355
521
|
|
|
522
|
+
// MARK: - Identity (Phase 2-SDK)
|
|
523
|
+
|
|
524
|
+
/// POSTs to `${apiBaseUrl}/api/identity/stitch` with the current
|
|
525
|
+
/// visitor id + supplied email + optional userId/traits. The
|
|
526
|
+
/// server resolves the email to an Apex Contact, stamps
|
|
527
|
+
/// `verifiedAt = now`, and runs the verified-mode downstream
|
|
528
|
+
/// pipeline (affiliate stamping, scoring, journey dispatch).
|
|
529
|
+
///
|
|
530
|
+
/// Failure semantics mirror the JS web fallback: rejects on
|
|
531
|
+
/// non-2xx responses + network errors so the caller can decide
|
|
532
|
+
/// whether to retry. We deliberately don't queue identify to the
|
|
533
|
+
/// offline queue — promoting a Contact has different ordering
|
|
534
|
+
/// guarantees than firing a track event, and merchants tend to
|
|
535
|
+
/// wire identify behind a one-shot boundary.
|
|
536
|
+
@objc public func identify(_ call: CAPPluginCall) {
|
|
537
|
+
guard let email = call.getString("email")?.trimmingCharacters(in: .whitespaces),
|
|
538
|
+
!email.isEmpty else {
|
|
539
|
+
call.reject("email is required")
|
|
540
|
+
return
|
|
541
|
+
}
|
|
542
|
+
let userId = call.getString("userId")
|
|
543
|
+
let traits = call.getObject("traits") ?? [:]
|
|
544
|
+
|
|
545
|
+
guard let url = URL(string: "\(apiBaseUrl)/api/identity/stitch") else {
|
|
546
|
+
call.reject("invalid apiBaseUrl")
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
var body: [String: Any] = [
|
|
550
|
+
"visitorId": visitorId,
|
|
551
|
+
"email": email,
|
|
552
|
+
"projectKey": projectKey,
|
|
553
|
+
"timestamp": ISO8601DateFormatter().string(from: Date()),
|
|
554
|
+
]
|
|
555
|
+
if let userId = userId, !userId.isEmpty {
|
|
556
|
+
body["userId"] = userId
|
|
557
|
+
}
|
|
558
|
+
if !traits.isEmpty {
|
|
559
|
+
body["metadata"] = traits
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
var request = URLRequest(url: url)
|
|
563
|
+
request.httpMethod = "POST"
|
|
564
|
+
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
565
|
+
request.setValue("sdk", forHTTPHeaderField: "x-apex-source")
|
|
566
|
+
request.setValue(projectKey, forHTTPHeaderField: "x-apex-project")
|
|
567
|
+
if let key = apiKey {
|
|
568
|
+
request.setValue(key, forHTTPHeaderField: "x-apex-api-key")
|
|
569
|
+
}
|
|
570
|
+
do {
|
|
571
|
+
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
|
572
|
+
} catch {
|
|
573
|
+
call.reject("Failed to encode identify payload: \(error.localizedDescription)")
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
URLSession.shared.dataTask(with: request) { _, response, error in
|
|
578
|
+
if let error = error {
|
|
579
|
+
call.reject("identify network error: \(error.localizedDescription)")
|
|
580
|
+
return
|
|
581
|
+
}
|
|
582
|
+
guard let http = response as? HTTPURLResponse else {
|
|
583
|
+
call.reject("identify: missing HTTP response")
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
if (200...299).contains(http.statusCode) {
|
|
587
|
+
call.resolve()
|
|
588
|
+
} else {
|
|
589
|
+
call.reject("identify: server rejected (HTTP \(http.statusCode))")
|
|
590
|
+
}
|
|
591
|
+
}.resume()
|
|
592
|
+
}
|
|
593
|
+
|
|
356
594
|
// MARK: - SKAN
|
|
357
595
|
|
|
358
596
|
@objc public func updateConversionValue(_ call: CAPPluginCall) {
|
|
@@ -391,7 +629,10 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
391
629
|
"appVersion": meta.appVersion,
|
|
392
630
|
"bundleId": meta.bundleId,
|
|
393
631
|
"timezone": meta.timezone,
|
|
394
|
-
"locale": meta.locale
|
|
632
|
+
"locale": meta.locale,
|
|
633
|
+
// MMP-061 — surface to sample apps + developer-facing UIs.
|
|
634
|
+
"releaseChannel": releaseChannel,
|
|
635
|
+
"releaseChannelSource": releaseChannelSource
|
|
395
636
|
])
|
|
396
637
|
}
|
|
397
638
|
|
|
@@ -450,6 +691,25 @@ public class ApexCapacitorPlugin: CAPPlugin, CAPBridgedPlugin, UNUserNotificatio
|
|
|
450
691
|
// showed nothing. The visitor id is the stable per-install id
|
|
451
692
|
// we mint in load() and persist to UserDefaults.
|
|
452
693
|
codablePayload["visitorId"] = AnyCodable(visitorId)
|
|
694
|
+
|
|
695
|
+
// MMP-061 — stamp release channel under event.mobile so the
|
|
696
|
+
// server's TrackingEvent shape lands the field in the right
|
|
697
|
+
// slot. Re-detect on `app_open` so a re-install over the same
|
|
698
|
+
// device (TestFlight → App Store upgrade) gets the freshest
|
|
699
|
+
// channel value rather than the cached one from app cold start.
|
|
700
|
+
let evType = call.getString("type") ?? ""
|
|
701
|
+
if evType == "app_open" {
|
|
702
|
+
releaseChannel = detectReleaseChannelSync()
|
|
703
|
+
lastReleaseChannelDetectedAt = Date()
|
|
704
|
+
if debug { print("[apex-capacitor] app_open → re-detected releaseChannel = \(releaseChannel)") }
|
|
705
|
+
}
|
|
706
|
+
var mobileSlot: [String: Any] = [:]
|
|
707
|
+
if let existing = codablePayload["mobile"]?.value as? [String: Any] {
|
|
708
|
+
mobileSlot = existing
|
|
709
|
+
}
|
|
710
|
+
mobileSlot["releaseChannel"] = releaseChannel
|
|
711
|
+
mobileSlot["releaseChannelSource"] = releaseChannelSource
|
|
712
|
+
codablePayload["mobile"] = AnyCodable(mobileSlot)
|
|
453
713
|
let event = NativeQueuedEvent(
|
|
454
714
|
id: id,
|
|
455
715
|
payload: codablePayload,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apex-inc/capacitor-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Apex Capacitor plugin — iOS/Android attribution, events, deep linking, SKAN, and offline-tolerant tracking for Capacitor apps.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|