@everframe/react-native 0.9.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/EverframeRN.podspec +88 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +505 -0
- package/android/build.gradle.kts +209 -0
- package/android/settings.gradle.kts +8 -0
- package/android/src/main/AndroidManifest.xml +9 -0
- package/android/src/main/java/dev/everframe/rn/EverframeModule.kt +1670 -0
- package/android/src/main/java/dev/everframe/rn/EverframePackage.kt +40 -0
- package/android/src/main/java/dev/everframe/rn/EverframeSensitiveViewManager.kt +20 -0
- package/android/src/main/java/dev/everframe/rn/RnVideoPrivacyAdapter.kt +65 -0
- package/dist/NativeEverframe.d.ts +308 -0
- package/dist/NativeEverframe.js +1 -0
- package/dist/browser-ZQAY6B4M.js +1 -0
- package/dist/chunk-LPIKPZNX.js +1 -0
- package/dist/chunk-QRPINMCR.js +1 -0
- package/dist/chunk-QZRTTQRS.js +1 -0
- package/dist/chunk-XFLFA52R.js +1 -0
- package/dist/chunk-XUSV3KOD.js +1 -0
- package/dist/index.d.ts +1006 -0
- package/dist/index.js +51 -0
- package/dist/integrations/console.d.ts +8 -0
- package/dist/integrations/console.js +1 -0
- package/dist/integrations/react-native-video.d.ts +44 -0
- package/dist/integrations/react-native-video.js +1 -0
- package/dist/integrations/react-navigation.d.ts +21 -0
- package/dist/integrations/react-navigation.js +1 -0
- package/dist/integrations/theoplayer.d.ts +37 -0
- package/dist/integrations/theoplayer.js +1 -0
- package/dist/types-D5e4kWaw.d.ts +12 -0
- package/ios/Sources/EverframeBridge.swift +1043 -0
- package/ios/Sources/EverframeEventEmitter.swift +204 -0
- package/ios/Sources/EverframeModule.h +36 -0
- package/ios/Sources/EverframeModule.mm +489 -0
- package/ios/Sources/RemoteVitalsBridge.swift +99 -0
- package/package.json +121 -0
- package/src/NativeEverframe.ts +439 -0
- package/src/codegen-types.ts +11 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 ScriptX
|
|
3
|
+
//
|
|
4
|
+
// Swift glue layer for the React Native TurboModule bridge.
|
|
5
|
+
//
|
|
6
|
+
// CONTRACT (Plan 06-02 + D-05/D-07 flip 2026-05-11 — DO NOT widen without a
|
|
7
|
+
// new D-decision):
|
|
8
|
+
//
|
|
9
|
+
// The RN bridge surface is the Phase 06 5-method TurboModule (configure,
|
|
10
|
+
// openReporter, registerSensitiveRect, setExtra, attachReactTree). The native
|
|
11
|
+
// EFReporterPresenter owns the entire report UX (capture + annotate + redact +
|
|
12
|
+
// submit) on iOS/iPadOS. On tvOS the on-device modal reporter was removed —
|
|
13
|
+
// tvOS hosts call the companion start API and route reporting through the
|
|
14
|
+
// phone-companion (QR → phone browser SPA) flow instead, so openReporter()
|
|
15
|
+
// on tvOS resolves to .cancelled by way of the absent presenter installer.
|
|
16
|
+
//
|
|
17
|
+
// 1. configure(opts) → start Everframe.shared with EverframeConfig
|
|
18
|
+
// + install EFReporterPresenter resolver
|
|
19
|
+
// 2. openReporter() → await Everframe.shared.report.open()
|
|
20
|
+
// resolves with { status, reportId? }
|
|
21
|
+
// 3. registerSensitiveRect(tag) → SensitiveRectRegistry.mark(view)
|
|
22
|
+
//
|
|
23
|
+
// Strict invariants:
|
|
24
|
+
// • NO new public method is added to `Everframe.shared`.
|
|
25
|
+
// • Every method guards on `Everframe.shared.captureGate`.
|
|
26
|
+
// • The presenter resolver is installed exactly once per process (idempotent
|
|
27
|
+
// installer + a local flag for defense-in-depth).
|
|
28
|
+
import Foundation
|
|
29
|
+
import Combine
|
|
30
|
+
import os.log
|
|
31
|
+
import UIKit
|
|
32
|
+
import EverframeKit
|
|
33
|
+
import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03 of Phase 06.1)
|
|
34
|
+
|
|
35
|
+
@objc public final class EverframeBridge: NSObject {
|
|
36
|
+
|
|
37
|
+
private static let log = OSLog(subsystem: "dev.everframe.rn", category: "bridge")
|
|
38
|
+
|
|
39
|
+
// One-shot resolver install flag. `EFReporterPresenter.installResolver()`
|
|
40
|
+
// itself is idempotent at the sdk-ios layer, but a local guard avoids the
|
|
41
|
+
// os_log spam and the redundant `report.isPresenting` rewire.
|
|
42
|
+
nonisolated(unsafe) private static var resolverInstalled = false
|
|
43
|
+
private static let resolverInstallLock = NSLock()
|
|
44
|
+
|
|
45
|
+
// MARK: - configure
|
|
46
|
+
|
|
47
|
+
@objc public static func configure(
|
|
48
|
+
appId: NSString,
|
|
49
|
+
endpoint: NSString,
|
|
50
|
+
networkBodiesDisabled: Bool,
|
|
51
|
+
installIdentifierDisabled: Bool,
|
|
52
|
+
attachPinUi: NSString?,
|
|
53
|
+
companionDeviceId: NSString?,
|
|
54
|
+
companionBadgeEnabled: Bool,
|
|
55
|
+
shakeToReportEnabled: Bool,
|
|
56
|
+
companionBadgePosition: NSString?,
|
|
57
|
+
theme: NSDictionary?,
|
|
58
|
+
vitalsEnabled: NSNumber?,
|
|
59
|
+
vitalsSampleRate: NSNumber?,
|
|
60
|
+
vitalsCaptureSourceQuery: NSNumber?
|
|
61
|
+
) throws {
|
|
62
|
+
// `endpoint` arg is retained for ObjC ABI compatibility with the
|
|
63
|
+
// codegen .mm — value is ignored. The SDK bakes the ingest URL at
|
|
64
|
+
// compile time (sdk-ios IngestEndpoint).
|
|
65
|
+
_ = endpoint
|
|
66
|
+
os_log("[everframe] configure appId=%{public}@", log: log, type: .info, appId as String)
|
|
67
|
+
// Polarity flips exactly once, here: JS sends the veto
|
|
68
|
+
// (`networkBodiesDisabled`), native stores the permission.
|
|
69
|
+
var cfg = EverframeConfig(appId: appId as String)
|
|
70
|
+
cfg.capture.networkBodies = !networkBodiesDisabled
|
|
71
|
+
// Polarity flips exactly once, here: JS sends the veto
|
|
72
|
+
// (`installIdentifierDisabled`), native stores the permission —
|
|
73
|
+
// identical to `networkBodies` above.
|
|
74
|
+
cfg.installIdentifierEnabled = !installIdentifierDisabled
|
|
75
|
+
// Explicit companion device-identity override (naming spec
|
|
76
|
+
// 2026-08-24) — read straight off the flat ConfigOpts field, same
|
|
77
|
+
// shape as attachPinUi below. nil/absent falls through to the
|
|
78
|
+
// Keychain-then-stored-UUID chain inside CompanionDeviceId.resolve.
|
|
79
|
+
cfg.companionDeviceId = companionDeviceId as String?
|
|
80
|
+
// Companion on-screen name badge (naming spec 2026-08-24, controller
|
|
81
|
+
// ruling / Task 6b) — read straight off the flat ConfigOpts fields,
|
|
82
|
+
// same shape as companionDeviceId above. `companionBadgeEnabled` is
|
|
83
|
+
// already defaulted to `true` by the .mm's `.value_or(true)`;
|
|
84
|
+
// `companionBadgePosition` stays the raw string here — parsed into
|
|
85
|
+
// `CompanionBadgePosition` only at `startCompanion()`, since a
|
|
86
|
+
// position can't affect anything before a relay client (and its
|
|
87
|
+
// badge) exist, mirroring `companionAttachPinUi` below.
|
|
88
|
+
cfg.companionBadgeEnabled = companionBadgeEnabled
|
|
89
|
+
cfg.shakeToReportEnabled = shakeToReportEnabled
|
|
90
|
+
cfg.companionBadgePosition = companionBadgePosition as String?
|
|
91
|
+
// Inline reporter theme (reporter branding spec 2026-08-25, RN
|
|
92
|
+
// slice). The .mm collapses the 8 flat ConfigOpts `theme*` wire
|
|
93
|
+
// fields into one role-keyed dictionary (nil when the host set no
|
|
94
|
+
// theme at all), so this ObjC seam gains ONE parameter instead of
|
|
95
|
+
// eight. Passthrough only — ThemeResolver revalidates hex per-field
|
|
96
|
+
// at presentation time and the server watermark entitlement gates
|
|
97
|
+
// rendering; an invalid or unentitled theme changes nothing here.
|
|
98
|
+
cfg.theme = Self.parseTheme(theme)
|
|
99
|
+
// Session Vitals (spec 2026-09-06) — the three flat ConfigOpts fields
|
|
100
|
+
// the .mm reads off `opts`. PRESENT FIELDS ONLY: each assignment is
|
|
101
|
+
// guarded, so an absent field keeps `VitalsConfig`'s own default
|
|
102
|
+
// rather than being written with a coerced zero value. That is why
|
|
103
|
+
// they cross as `NSNumber?` and not `Bool`/`Double` — absent must stay
|
|
104
|
+
// distinguishable from `false`/`0` (nil `enabled` follows the
|
|
105
|
+
// dashboard toggle; nil `sampleRate` follows the server rate).
|
|
106
|
+
if let vitalsEnabled { cfg.vitals.enabled = vitalsEnabled.boolValue }
|
|
107
|
+
if let vitalsSampleRate { cfg.vitals.sampleRate = vitalsSampleRate.doubleValue }
|
|
108
|
+
if let vitalsCaptureSourceQuery { cfg.vitals.captureSourceQuery = vitalsCaptureSourceQuery.boolValue }
|
|
109
|
+
// The attach-PIN UI mode for startCompanion() to read (spec 2026-08-19). configure()
|
|
110
|
+
// precedes startCompanion() in every host's lifecycle, so this is the natural place to
|
|
111
|
+
// parse the flat ConfigOpts string into the native enum; startCompanion is where it is
|
|
112
|
+
// actually consumed (RelayWSClient's announce arg + the suppression seam), since a mode
|
|
113
|
+
// can't affect anything before a relay client exists. Parsed into a LOCAL: the gate
|
|
114
|
+
// below compares it against the stashed value, and only the start path adopts it.
|
|
115
|
+
let newAttachPinUi = Self.parseAttachPinUi(attachPinUi as String?)
|
|
116
|
+
// Codex round-6, H1 — IDEMPOTENT configure, twin of `EverframeModule.configure`'s gate on
|
|
117
|
+
// Android, and decided against the INSTALLED config rather than a cache of the last
|
|
118
|
+
// options this bridge started with.
|
|
119
|
+
//
|
|
120
|
+
// `Everframe.shared.start(...)` SUPERSEDES the running SDK: the outgoing controller's
|
|
121
|
+
// shutdown detaches every integration it had announced, so a Provider that merely
|
|
122
|
+
// REMOUNTS with the same config used to kill every live player registration for
|
|
123
|
+
// nothing. An identical configuration installs identical state, so the cheapest correct
|
|
124
|
+
// answer is not to start at all.
|
|
125
|
+
//
|
|
126
|
+
// Round-5 kept a cached snapshot of the options it last started with, and a cache can
|
|
127
|
+
// disagree with the singleton it claims to describe: two overlapping configures (A then
|
|
128
|
+
// B) can interleave so the cache says A while the SDK runs B, and a native host calling
|
|
129
|
+
// `Everframe.shared.start(configB)` between two bridge configures of A reproduces it with
|
|
130
|
+
// no concurrency at all — the second A matches the stale cached A, skips, and leaves B
|
|
131
|
+
// installed under a JS host that believes it configured A. `currentConfig` cannot drift:
|
|
132
|
+
// it IS the installed state.
|
|
133
|
+
//
|
|
134
|
+
// `captureGate` is the second term: a killed SDK must be restarted even by an identical
|
|
135
|
+
// config, or the host is left with a dead SDK and no way to revive it.
|
|
136
|
+
//
|
|
137
|
+
// Codex round-7, I3 — the comparison is the WHOLE config (`EverframeConfig` is now
|
|
138
|
+
// `Equatable`, matching Android's `data class`), not a rendering of the fields this
|
|
139
|
+
// bridge happens to set. Round-6 hand-built a string snapshot here, which meant any
|
|
140
|
+
// config field the snapshot's author had not listed — a new one, or one a native host
|
|
141
|
+
// set directly — read as "unchanged" and silently suppressed a start that was needed.
|
|
142
|
+
// `attachPinUi` is the one term that is NOT part of the config (it lands in module
|
|
143
|
+
// state), so it is compared separately.
|
|
144
|
+
//
|
|
145
|
+
// Everything else this method does still runs on the skip path — the presenter-resolver
|
|
146
|
+
// install and the crash-metadata prime — because both are idempotent and neither
|
|
147
|
+
// depends on a fresh start. The attach-PIN stash is the exception, and only nominally:
|
|
148
|
+
// on the skip path it would write the value it already holds.
|
|
149
|
+
let unchanged = Everframe.shared.captureGate
|
|
150
|
+
&& Everframe.shared.currentConfig == cfg
|
|
151
|
+
&& companionAttachPinUi == newAttachPinUi
|
|
152
|
+
if unchanged {
|
|
153
|
+
os_log("[everframe] configure: SDK already running this exact config — skipping start()",
|
|
154
|
+
log: log, type: .debug)
|
|
155
|
+
} else {
|
|
156
|
+
try Everframe.shared.start(config: cfg)
|
|
157
|
+
// Adopted only on the start path — the skip path has already established that the
|
|
158
|
+
// stashed value equals this one.
|
|
159
|
+
companionAttachPinUi = newAttachPinUi
|
|
160
|
+
}
|
|
161
|
+
installPresenterResolverIfNeeded()
|
|
162
|
+
// Spec 2026-09-17 setExtra-resolver — installed unconditionally,
|
|
163
|
+
// every configure(), NOT gated behind startCompanion() the way the
|
|
164
|
+
// companion Combine subscriptions are: this must also cover the
|
|
165
|
+
// plain in-app openReporter() path and native shake, both of which
|
|
166
|
+
// funnel through Everframe.shared.report.open() →
|
|
167
|
+
// __consumePendingAttachments() with no companion involvement at
|
|
168
|
+
// all. See `installExtraResolveHook()`'s own doc comment.
|
|
169
|
+
installExtraResolveHook()
|
|
170
|
+
// Task 13: prime the device-metadata cache off the main queue so a
|
|
171
|
+
// crash arriving shortly after configure() still has device context.
|
|
172
|
+
CrashReporter.prime()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// Build `ReporterThemeOptions` from the .mm's role-keyed dictionary
|
|
176
|
+
/// (reporter branding spec 2026-08-25, RN slice). `nil` dictionary — the
|
|
177
|
+
/// host set no theme — maps to `nil` options so `EverframeConfig.theme`
|
|
178
|
+
/// stays "host did nothing"; per-role absence is preserved the same way.
|
|
179
|
+
/// Values are NOT validated here: `ThemeResolver` owns hex validation
|
|
180
|
+
/// per-field at presentation time.
|
|
181
|
+
private static func parseTheme(_ raw: NSDictionary?) -> ReporterThemeOptions? {
|
|
182
|
+
guard let raw else { return nil }
|
|
183
|
+
func role(_ key: String) -> String? { raw[key] as? String }
|
|
184
|
+
return ReporterThemeOptions(
|
|
185
|
+
background: role("background"),
|
|
186
|
+
surface: role("surface"),
|
|
187
|
+
border: role("border"),
|
|
188
|
+
text: role("text"),
|
|
189
|
+
textMuted: role("textMuted"),
|
|
190
|
+
accent: role("accent"),
|
|
191
|
+
accentForeground: role("accentForeground"),
|
|
192
|
+
destructive: role("destructive")
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Coerce the flat `ConfigOpts.attachPinUi` string into `AttachPinUi`.
|
|
197
|
+
/// `nil` (field absent) and any unrecognised value both degrade to
|
|
198
|
+
/// `.builtin` — the safe default matching "host did nothing special".
|
|
199
|
+
private static func parseAttachPinUi(_ raw: String?) -> AttachPinUi {
|
|
200
|
+
guard let raw, let mode = AttachPinUi(rawValue: raw) else { return .builtin }
|
|
201
|
+
return mode
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Coerce the flat `ConfigOpts.companionBadgePosition` string into
|
|
205
|
+
/// `CompanionBadgePosition` (naming spec 2026-08-24, controller ruling /
|
|
206
|
+
/// Task 6b). `nil` (field absent) and any unrecognised value both
|
|
207
|
+
/// degrade to `.bottomRight` — matches `CompanionBadgeOptions.position`'s
|
|
208
|
+
/// own default, so an absent field behaves exactly like never touching
|
|
209
|
+
/// the option at all.
|
|
210
|
+
private static func parseCompanionBadgePosition(_ raw: String?) -> CompanionBadgePosition {
|
|
211
|
+
switch raw {
|
|
212
|
+
case "bottom-left": return .bottomLeft
|
|
213
|
+
case "top-right": return .topRight
|
|
214
|
+
case "top-left": return .topLeft
|
|
215
|
+
default: return .bottomRight
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Wires `EFReporterPresenter` as the resolver for `Everframe.shared.report
|
|
220
|
+
/// .open()`. Called from configure(); the first call installs, subsequent
|
|
221
|
+
/// calls no-op. tvOS has no on-device modal — reporting goes through the
|
|
222
|
+
/// phone-companion flow (QR → phone browser) — so the presenter type
|
|
223
|
+
/// itself is `#if !os(tvOS)`-gated in EverframeReporterUI. Mirror that
|
|
224
|
+
/// gate here so the bridge compiles for the tvOS slice.
|
|
225
|
+
private static func installPresenterResolverIfNeeded() {
|
|
226
|
+
#if !os(tvOS)
|
|
227
|
+
resolverInstallLock.lock()
|
|
228
|
+
let alreadyInstalled = resolverInstalled
|
|
229
|
+
resolverInstalled = true
|
|
230
|
+
resolverInstallLock.unlock()
|
|
231
|
+
guard !alreadyInstalled else { return }
|
|
232
|
+
DispatchQueue.main.async {
|
|
233
|
+
MainActor.assumeIsolated {
|
|
234
|
+
EFReporterPresenter.installResolver()
|
|
235
|
+
}
|
|
236
|
+
os_log("[everframe] installed EFReporterPresenter resolver", log: log, type: .info)
|
|
237
|
+
}
|
|
238
|
+
#else
|
|
239
|
+
os_log("[everframe] tvOS — EFReporterPresenter not installed; reporting routes through phone companion", log: log, type: .info)
|
|
240
|
+
#endif
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// MARK: - Extra-resolver ask-and-wait (spec 2026-09-17 setExtra-resolver)
|
|
244
|
+
|
|
245
|
+
/// Identity token for the CURRENTLY installed
|
|
246
|
+
/// `Everframe.__pendingExtraResolveHook` closure (D1 fix — stale-teardown
|
|
247
|
+
/// race). Bumped every time `installExtraResolveHook()` (re)installs the
|
|
248
|
+
/// hook; `resetExtraResolverForTeardown(_:)` below compares a caller's
|
|
249
|
+
/// captured value against this to decide whether ITS installation is
|
|
250
|
+
/// still the live one. See that method's doc comment for the race this
|
|
251
|
+
/// closes and why a plain counter — not a `Bool` — is what makes the
|
|
252
|
+
/// comparison possible at all.
|
|
253
|
+
nonisolated(unsafe) private static var extraResolveHookGeneration: Int = 0
|
|
254
|
+
private static let extraResolveHookInstallLock = NSLock()
|
|
255
|
+
|
|
256
|
+
/// Installs `awaitJsExtraResolve` onto `Everframe.__pendingExtraResolveHook`
|
|
257
|
+
/// (see that property's doc comment on Everframe.swift). Called from
|
|
258
|
+
/// EVERY `configure()`, UNCONDITIONALLY — mirrors Android's
|
|
259
|
+
/// `EverframeModule.init` block installing its own `extraResolveHook`
|
|
260
|
+
/// field unconditionally at every module construction ("the newest
|
|
261
|
+
/// instance's closure always wins", per that field's doc comment),
|
|
262
|
+
/// applied here at iOS's per-`configure()`-call granularity since
|
|
263
|
+
/// `EverframeBridge` has no per-instance construction point of its own —
|
|
264
|
+
/// every method on it is `static`.
|
|
265
|
+
///
|
|
266
|
+
/// D1 fix: this used to be `installExtraResolveHookIfNeeded()`, gated by
|
|
267
|
+
/// a once-per-process `Bool` flag so a second call was a no-op. That
|
|
268
|
+
/// flag is exactly what let a STALE module instance's delayed
|
|
269
|
+
/// `-invalidate` clobber a FRESH instance's install: once the stale
|
|
270
|
+
/// teardown reset the flag to false and nil'd the hook, nothing was left
|
|
271
|
+
/// to set it again — `configure()` had already run for the current
|
|
272
|
+
/// bundle and nothing calls it a second time. See
|
|
273
|
+
/// `resetExtraResolverForTeardown(_:)`'s doc comment for the full race.
|
|
274
|
+
///
|
|
275
|
+
/// Reinstalling has no observable side effect beyond the generation
|
|
276
|
+
/// bump: `awaitJsExtraResolve` captures no per-call state — it reads
|
|
277
|
+
/// only process-static vars at invocation time — so calling this on
|
|
278
|
+
/// every `configure()` is cheap, harmless churn, same as Android already
|
|
279
|
+
/// accepts on every module construction.
|
|
280
|
+
///
|
|
281
|
+
/// Returns the fresh generation so `EverframeModule.mm`'s `-configure:`
|
|
282
|
+
/// can capture it (via `currentExtraResolveHookGeneration()` below) and
|
|
283
|
+
/// hand it back to `resetExtraResolverForTeardown(_:)` at `-invalidate`
|
|
284
|
+
/// time. `@discardableResult` because `configure()` itself has no use
|
|
285
|
+
/// for the value — only the ObjC caller does, via the separate accessor.
|
|
286
|
+
@discardableResult
|
|
287
|
+
private static func installExtraResolveHook() -> Int {
|
|
288
|
+
extraResolveHookInstallLock.lock()
|
|
289
|
+
extraResolveHookGeneration += 1
|
|
290
|
+
let generation = extraResolveHookGeneration
|
|
291
|
+
extraResolveHookInstallLock.unlock()
|
|
292
|
+
Everframe.__pendingExtraResolveHook = { await awaitJsExtraResolve() }
|
|
293
|
+
return generation
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// ObjC-visible read of whichever generation is live RIGHT NOW — i.e.
|
|
297
|
+
/// whatever the most recent `installExtraResolveHook()` call minted.
|
|
298
|
+
/// `EverframeModule.mm`'s `-configure:` calls this immediately after
|
|
299
|
+
/// `configureWithAppId:...:error:` (which, on success or on the
|
|
300
|
+
/// already-running-unchanged skip path, calls `installExtraResolveHook()`
|
|
301
|
+
/// internally) and stashes the result on its own instance ivar for
|
|
302
|
+
/// `-invalidate` to hand back later. 0 means no `configure()` call has
|
|
303
|
+
/// ever successfully reached the install site.
|
|
304
|
+
@objc public static func currentExtraResolveHookGeneration() -> Int {
|
|
305
|
+
extraResolveHookInstallLock.lock()
|
|
306
|
+
defer { extraResolveHookInstallLock.unlock() }
|
|
307
|
+
return extraResolveHookGeneration
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// JS presence flag — see `setExtraResolverActive`'s own doc comment on
|
|
311
|
+
/// `NativeEverframe.ts`'s `Spec.setExtraResolverActive`. `nonisolated
|
|
312
|
+
/// (unsafe)` + a lock (not `@Volatile` — Swift has no such attribute):
|
|
313
|
+
/// written from the JS thread (`setExtraResolverActive` below) and read
|
|
314
|
+
/// from `awaitJsExtraResolve`, which runs wherever
|
|
315
|
+
/// `__consumePendingAttachments()`'s caller happens to run.
|
|
316
|
+
nonisolated(unsafe) private static var extraResolverActive = false
|
|
317
|
+
private static let extraResolverActiveLock = NSLock()
|
|
318
|
+
|
|
319
|
+
/// JS calls this from EVERY `setExtra` call — see the full contract on
|
|
320
|
+
/// `NativeEverframe.ts`'s `Spec.setExtraResolverActive`.
|
|
321
|
+
@objc public static func setExtraResolverActive(_ active: Bool) {
|
|
322
|
+
extraResolverActiveLock.lock()
|
|
323
|
+
extraResolverActive = active
|
|
324
|
+
extraResolverActiveLock.unlock()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Per-correlation_id continuation map, released by
|
|
328
|
+
/// `signalExtraResolverReady`. Mirrors `CompanionCaptureBridge`'s
|
|
329
|
+
/// `pendingAttachContinuations` exactly (same lock-guarded dictionary,
|
|
330
|
+
/// same "the timeout resumes it if JS never calls back" shape).
|
|
331
|
+
nonisolated(unsafe) private static var pendingExtraResolveContinuations:
|
|
332
|
+
[String: CheckedContinuation<Void, Never>] = [:]
|
|
333
|
+
private static let pendingExtraResolveLock = NSLock()
|
|
334
|
+
|
|
335
|
+
/// Installed onto `Everframe.__pendingExtraResolveHook`. No-ops
|
|
336
|
+
/// IMMEDIATELY — no event, no wait — unless JS has a resolver CURRENTLY
|
|
337
|
+
/// registered (`extraResolverActive`), which is what keeps a
|
|
338
|
+
/// string/object-only host's report path exactly as fast as before this
|
|
339
|
+
/// feature landed (requirement: "no behaviour change whatsoever").
|
|
340
|
+
///
|
|
341
|
+
/// Otherwise mirrors `CompanionCaptureBridge.awaitJsReactTreeAttach`
|
|
342
|
+
/// exactly: send `EverframeEventEmitter.sendExtraResolveRequested`, then
|
|
343
|
+
/// suspend up to 250ms for JS's `signalExtraResolverReady` ack.
|
|
344
|
+
private static func awaitJsExtraResolve() async {
|
|
345
|
+
extraResolverActiveLock.lock()
|
|
346
|
+
let active = extraResolverActive
|
|
347
|
+
extraResolverActiveLock.unlock()
|
|
348
|
+
guard active else { return }
|
|
349
|
+
|
|
350
|
+
let correlationId = UUID().uuidString
|
|
351
|
+
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
352
|
+
pendingExtraResolveLock.lock()
|
|
353
|
+
pendingExtraResolveContinuations[correlationId] = cont
|
|
354
|
+
pendingExtraResolveLock.unlock()
|
|
355
|
+
// Send AFTER registering the continuation so a fast JS callback
|
|
356
|
+
// can't lose the race — same ordering `awaitJsReactTreeAttach`
|
|
357
|
+
// uses.
|
|
358
|
+
EverframeEventEmitter.sendExtraResolveRequested(correlationId)
|
|
359
|
+
// 250ms bounded fallback — resume if JS hasn't called back by then.
|
|
360
|
+
DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
|
|
361
|
+
resumeAndDropPendingExtraResolve(correlationId: correlationId)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/// JS handshake ack: called once the `everframe.extra.resolveRequested`
|
|
367
|
+
/// listener has — if a resolver is registered — pushed a freshly
|
|
368
|
+
/// resolved value through `setExtra` above. Idempotent — a late signal
|
|
369
|
+
/// after the 250ms fallback already fired is a harmless no-op, exactly
|
|
370
|
+
/// like `signalCompanionReportRequestReady` below.
|
|
371
|
+
@objc public static func signalExtraResolverReady(_ correlationId: NSString) {
|
|
372
|
+
resumeAndDropPendingExtraResolve(correlationId: correlationId as String)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private static func resumeAndDropPendingExtraResolve(correlationId: String) {
|
|
376
|
+
pendingExtraResolveLock.lock()
|
|
377
|
+
let cont = pendingExtraResolveContinuations.removeValue(forKey: correlationId)
|
|
378
|
+
pendingExtraResolveLock.unlock()
|
|
379
|
+
cont?.resume()
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Module-instance teardown fix (review finding F4, spec 2026-09-17
|
|
383
|
+
/// setExtra-resolver). `extraResolverActive` is a process-static
|
|
384
|
+
/// (above), so nothing was ever clearing it on its own. Scenario this
|
|
385
|
+
/// fixes: an RN surface embedded in a native host, whose JS context is
|
|
386
|
+
/// torn down WITHOUT `runtime.unmount()` running (a hard reload, or the
|
|
387
|
+
/// host tearing down its `RCTBridge`/turbo module manager directly) — RN
|
|
388
|
+
/// still calls `-invalidate` on every module that responds to it (see
|
|
389
|
+
/// `EverframeModule.mm`'s own header comment), but `runtime.ts`'s
|
|
390
|
+
/// `unmount()` (the thing that would otherwise flip
|
|
391
|
+
/// `setExtraResolverActive(false)`) never runs. Left uncleared,
|
|
392
|
+
/// `extraResolverActive` stays `true` forever: EVERY later report —
|
|
393
|
+
/// even a pure-native one, with no live RN bridge at all — pays the
|
|
394
|
+
/// full 250ms bounded wait for a `signalExtraResolverReady` ack that
|
|
395
|
+
/// will never arrive. Fail-open still holds (the wait still resolves
|
|
396
|
+
/// the report), but the "zero added latency for a non-resolver host"
|
|
397
|
+
/// contract this feature promised breaks silently.
|
|
398
|
+
///
|
|
399
|
+
/// Called from `-[EverframeModule invalidate]`, mirroring
|
|
400
|
+
/// `RemoteVitalsBridge.reset()`'s own lifetime doc comment.
|
|
401
|
+
///
|
|
402
|
+
/// IDENTITY GUARD (D1 fix — stale-teardown race). `generation` is the
|
|
403
|
+
/// value `EverframeModule.mm`'s `-configure:` captured via
|
|
404
|
+
/// `currentExtraResolveHookGeneration()` right after ITS OWN
|
|
405
|
+
/// `configure()` call, stashed on that ObjC instance and handed back
|
|
406
|
+
/// here at `-invalidate` time. RN dispatches `-invalidate` on a
|
|
407
|
+
/// module's method queue, so a STALE module instance's `-invalidate`
|
|
408
|
+
/// can be DELIVERED AFTER a reloaded bundle's fresh module instance has
|
|
409
|
+
/// already run `configure()` and installed a new hook (bumping
|
|
410
|
+
/// `extraResolveHookGeneration`). Before this guard, the stale
|
|
411
|
+
/// instance's teardown unconditionally nil'd `Everframe
|
|
412
|
+
/// .__pendingExtraResolveHook` and cleared `extraResolverActive` — the
|
|
413
|
+
/// FRESH hook, out from under the new bundle. Because that fresh
|
|
414
|
+
/// `configure()` already ran and nothing calls it a second time,
|
|
415
|
+
/// nothing was left to reinstall: JS then calls `setExtra(resolver)`,
|
|
416
|
+
/// sets `extraResolverActive = true`, and native never asks again —
|
|
417
|
+
/// `extra` silently vanishes from every report for the rest of the
|
|
418
|
+
/// process, with no error anywhere.
|
|
419
|
+
///
|
|
420
|
+
/// `generation == extraResolveHookGeneration` is true only when THIS
|
|
421
|
+
/// caller's installation is still the live one — exactly mirroring
|
|
422
|
+
/// Android's `Everframe.__pendingExtraResolveHook === extraResolveHook`
|
|
423
|
+
/// reference check (`EverframeModule.kt:570-574`). Android gets that
|
|
424
|
+
/// identity for free because each module INSTANCE owns a distinct
|
|
425
|
+
/// closure object it can compare by reference; `EverframeBridge` is
|
|
426
|
+
/// entirely `static` and has no such object, so a monotonically
|
|
427
|
+
/// incrementing generation counter plays the same role. A `generation`
|
|
428
|
+
/// of 0 (this caller's own `configure()` never successfully reached the
|
|
429
|
+
/// install site) also no-ops, same as a stale one.
|
|
430
|
+
///
|
|
431
|
+
/// When the guard passes: any continuations still pending (a report
|
|
432
|
+
/// mid-ask-and-wait at teardown time) are resumed immediately instead of
|
|
433
|
+
/// idling out their own 250ms fallback, since nothing will ever answer
|
|
434
|
+
/// them now. When it fails, pending continuations are left alone too —
|
|
435
|
+
/// they may belong to the FRESH instance's own in-flight ask, and its
|
|
436
|
+
/// own 250ms fallback (already scheduled) is what will resolve them if
|
|
437
|
+
/// JS doesn't.
|
|
438
|
+
@objc public static func resetExtraResolverForTeardown(_ generation: Int) {
|
|
439
|
+
extraResolveHookInstallLock.lock()
|
|
440
|
+
let isCurrentInstallation = generation != 0 && generation == extraResolveHookGeneration
|
|
441
|
+
extraResolveHookInstallLock.unlock()
|
|
442
|
+
guard isCurrentInstallation else {
|
|
443
|
+
os_log("[everframe] resetExtraResolverForTeardown: stale instance (generation %ld) — a newer configure() is already live; leaving it untouched",
|
|
444
|
+
log: log, type: .info, generation)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
extraResolverActiveLock.lock()
|
|
449
|
+
extraResolverActive = false
|
|
450
|
+
extraResolverActiveLock.unlock()
|
|
451
|
+
|
|
452
|
+
Everframe.__pendingExtraResolveHook = nil
|
|
453
|
+
|
|
454
|
+
pendingExtraResolveLock.lock()
|
|
455
|
+
let stillPending = pendingExtraResolveContinuations
|
|
456
|
+
pendingExtraResolveContinuations.removeAll()
|
|
457
|
+
pendingExtraResolveLock.unlock()
|
|
458
|
+
for (_, cont) in stillPending {
|
|
459
|
+
cont.resume()
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/// Install-race fix (spec 2026-08-19 review finding 3). `configure()`
|
|
464
|
+
/// (above) queues the PIN presenter install via
|
|
465
|
+
/// `installPresenterResolverIfNeeded()`'s `DispatchQueue.main.async` —
|
|
466
|
+
/// note that call marks `resolverInstalled = true` BEFORE the async block
|
|
467
|
+
/// actually runs, so that flag alone cannot tell a caller here whether
|
|
468
|
+
/// `CompanionPinPresenter.install()` (which sets
|
|
469
|
+
/// `CompanionAPI.__builtinPinUiInstalled`) has genuinely finished.
|
|
470
|
+
///
|
|
471
|
+
/// `startCompanion()` builds `RelayWSClient` and calls `connect()`
|
|
472
|
+
/// synchronously right after this; `connect()`'s announce path reads
|
|
473
|
+
/// `__builtinPinUiInstalled` from a freshly-spawned `Task` that can run
|
|
474
|
+
/// before the main-queue-dispatched install block does, especially early
|
|
475
|
+
/// in a host's launch sequence when the main queue is already busy. A
|
|
476
|
+
/// host that calls `startCompanion()` immediately after `configure()`
|
|
477
|
+
/// resolves — the ordinary RN lifecycle — would then read `false` for
|
|
478
|
+
/// the capability check for the ENTIRE session, so `.builtin` mode would
|
|
479
|
+
/// never advertise `supportsAttachPin` even though the presenter finishes
|
|
480
|
+
/// installing moments later.
|
|
481
|
+
///
|
|
482
|
+
/// Fix: synchronously hop to MainActor and call
|
|
483
|
+
/// `CompanionPinPresenter.install()` directly — the same call
|
|
484
|
+
/// `EFReporterPresenter.installResolver()` itself makes — and WAIT for
|
|
485
|
+
/// it, before `startCompanion()` constructs the client. `install()` is
|
|
486
|
+
/// `@MainActor` and idempotent (its own internal `installed` flag), so
|
|
487
|
+
/// this is cheap on every call after the first: the common case (install
|
|
488
|
+
/// already genuinely finished by the time `startCompanion()` runs) pays
|
|
489
|
+
/// only a MainActor hop, not real work. Scoped to the smallest change
|
|
490
|
+
/// that closes the race — `installPresenterResolverIfNeeded()`'s async
|
|
491
|
+
/// dispatch and its own `resolverInstalled` flag are untouched, since
|
|
492
|
+
/// `EFReporterPresenter.installResolver()` also wires the full modal
|
|
493
|
+
/// reporter (openReporter's resolver), which this call does not need to
|
|
494
|
+
/// duplicate or wait for.
|
|
495
|
+
///
|
|
496
|
+
/// Gate: `#if canImport(UIKit)`, matching `CompanionPinPresenter.swift`'s
|
|
497
|
+
/// own gate — NOT `#if !os(tvOS)`. Round-2 review finding 2: tvOS hosts
|
|
498
|
+
/// are a PRIMARY consumer of companion (see `CompanionPinPresenter.swift`
|
|
499
|
+
/// header + `RelayWSClient`'s), and only `EFReporterPresenter` (the full
|
|
500
|
+
/// modal reporter, iOS-only — tvOS has no on-device modal) needs the
|
|
501
|
+
/// `!os(tvOS)` exclusion `installPresenterResolverIfNeeded()` above
|
|
502
|
+
/// correctly keeps. Gating THIS call the same way silently left RN-tvOS
|
|
503
|
+
/// builtin hosts with `CompanionAPI.__builtinPinUiInstalled` permanently
|
|
504
|
+
/// false, so `startCompanion()`'s announce always reported
|
|
505
|
+
/// `supportsAttachPin: false` and every tvOS host fell back to legacy
|
|
506
|
+
/// one-click attach even though `CompanionPinPresenter` builds and runs
|
|
507
|
+
/// fine there.
|
|
508
|
+
private static func ensurePinPresenterInstalledSync() {
|
|
509
|
+
#if canImport(UIKit)
|
|
510
|
+
if Thread.isMainThread {
|
|
511
|
+
MainActor.assumeIsolated {
|
|
512
|
+
CompanionPinPresenter.install()
|
|
513
|
+
}
|
|
514
|
+
} else {
|
|
515
|
+
DispatchQueue.main.sync {
|
|
516
|
+
MainActor.assumeIsolated {
|
|
517
|
+
CompanionPinPresenter.install()
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
#endif
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// MARK: - openReporter
|
|
525
|
+
|
|
526
|
+
/// Opens the native reporter modal and resolves with the user's outcome.
|
|
527
|
+
/// Routes through `Everframe.shared.report.open()` so any host-installed
|
|
528
|
+
/// observers (`report.isPresenting`) fire on the same code path used by
|
|
529
|
+
/// non-RN consumers.
|
|
530
|
+
///
|
|
531
|
+
/// Result NSDictionary shape (matches JS facade ReporterResult):
|
|
532
|
+
/// - { status: "submitted", reportId: "<uuid>" }
|
|
533
|
+
/// - { status: "queued", reportId: "<uuid>" }
|
|
534
|
+
/// - { status: "cancelled" }
|
|
535
|
+
@objc public static func openReporter(
|
|
536
|
+
completion: @escaping (NSDictionary?, NSError?) -> Void
|
|
537
|
+
) {
|
|
538
|
+
os_log("[everframe] openReporter.enter", log: log, type: .info)
|
|
539
|
+
guard Everframe.shared.captureGate else {
|
|
540
|
+
completion(nil, NSError(
|
|
541
|
+
domain: "EverframeBridge", code: 1100,
|
|
542
|
+
userInfo: [NSLocalizedDescriptionKey: "Everframe not started (captureGate=false). Call configure() first."]
|
|
543
|
+
))
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Defense-in-depth: configure should have installed already, but a
|
|
548
|
+
// stray openReporter before configure (race in host useEffect order)
|
|
549
|
+
// shouldn't deadlock on the un-installed resolver.
|
|
550
|
+
installPresenterResolverIfNeeded()
|
|
551
|
+
|
|
552
|
+
Task {
|
|
553
|
+
do {
|
|
554
|
+
let result = try await Everframe.shared.report.open()
|
|
555
|
+
let dict: NSDictionary
|
|
556
|
+
switch result {
|
|
557
|
+
case .submitted(let id):
|
|
558
|
+
dict = ["status": "submitted", "reportId": id.uuidString]
|
|
559
|
+
case .queued(let id):
|
|
560
|
+
dict = ["status": "queued", "reportId": id.uuidString]
|
|
561
|
+
case .cancelled:
|
|
562
|
+
dict = ["status": "cancelled"]
|
|
563
|
+
}
|
|
564
|
+
completion(dict, nil)
|
|
565
|
+
} catch {
|
|
566
|
+
completion(nil, error as NSError)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// MARK: - registerSensitiveRect
|
|
572
|
+
|
|
573
|
+
/// Mark a view as sensitive. Routed through MainActor.assumeIsolated so the
|
|
574
|
+
/// outer @objc static stays non-MainActor (MainActor-isolated @objc statics
|
|
575
|
+
/// silently drop from the generated bridge header on Xcode 26.4 + Swift 5.10
|
|
576
|
+
/// framework targets). Parameter is NSObject? rather than UIView? to also
|
|
577
|
+
/// dodge the parallel UIView-typed-@objc-static skip path. The .mm callers
|
|
578
|
+
/// always pre-dispatch to main and pass a real UIView; the inner cast is
|
|
579
|
+
/// belt-and-suspenders.
|
|
580
|
+
@objc(markSensitive:)
|
|
581
|
+
public static func markSensitive(_ view: NSObject) {
|
|
582
|
+
dispatchPrecondition(condition: .onQueue(.main))
|
|
583
|
+
guard let uiView = view as? UIView else {
|
|
584
|
+
os_log("[everframe] markSensitive: expected UIView, got %{public}@; ignoring",
|
|
585
|
+
log: log, type: .error, String(describing: type(of: view)))
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
MainActor.assumeIsolated {
|
|
589
|
+
guard Everframe.shared.captureGate else { return }
|
|
590
|
+
os_log("[everframe] markSensitive view=%{public}@", log: log, type: .info, uiView)
|
|
591
|
+
SensitiveRectRegistry.mark(uiView)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// MARK: - reportCrash (Task 13 — crash/error reporting spec 2026-07-18)
|
|
596
|
+
|
|
597
|
+
/// Crash/error reporting. Synchronous: the RN fatal path must finish
|
|
598
|
+
/// persisting before RN's default handler aborts. Identifies itself as
|
|
599
|
+
/// `everframe-react-native` (distinct from `CrashReporter.captureFacts`'s
|
|
600
|
+
/// `"everframe-ios"` default) so envelopes are attributable to the RN SDK.
|
|
601
|
+
@objc public static func reportCrash(_ crashJson: NSString) -> Bool {
|
|
602
|
+
CrashReporter.captureFacts(json: crashJson as String, sdkName: "everframe-react-native")
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/// Explicit handled capture acknowledges the core's completed encrypted enqueue.
|
|
606
|
+
@objc public static func captureHandledException(_ crashJson: NSString) -> Bool {
|
|
607
|
+
CrashReporter.captureHandledFacts(json: crashJson as String, sdkName: "everframe-react-native")
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// MARK: - setExtra
|
|
611
|
+
|
|
612
|
+
/// Forward an opaque string from JS into Everframe.shared.setExtra.
|
|
613
|
+
/// Empty string clears the pending extra.
|
|
614
|
+
@objc public static func setExtra(_ value: NSString) {
|
|
615
|
+
let s = value as String
|
|
616
|
+
if s.isEmpty {
|
|
617
|
+
Everframe.shared.clearExtra()
|
|
618
|
+
} else {
|
|
619
|
+
Everframe.shared.setExtra(s)
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// MARK: - addBreadcrumb (Plan 4 / Task 14)
|
|
624
|
+
|
|
625
|
+
/// Manual breadcrumb escape hatch. Forwards straight to
|
|
626
|
+
/// `Everframe.shared.addBreadcrumb(message:kind:level:data:)` (Task 5) —
|
|
627
|
+
/// NO validation/coercion happens here. That singleton already owns
|
|
628
|
+
/// kind/level/data coercion AND gating (no-op pre-start / while killed),
|
|
629
|
+
/// exactly like `setExtra`/`attachReactTree` above never re-check
|
|
630
|
+
/// `captureGate` themselves. `data` crosses as an `NSDictionary?` — the
|
|
631
|
+
/// codegen-typed `UnsafeObject` boundary for freeform objects; a
|
|
632
|
+
/// non-dictionary payload (shouldn't happen via codegen, but fail-soft
|
|
633
|
+
/// regardless) is treated as absent rather than crashing the bridge.
|
|
634
|
+
@objc public static func addBreadcrumb(
|
|
635
|
+
_ message: NSString,
|
|
636
|
+
kind: NSString?,
|
|
637
|
+
level: NSString?,
|
|
638
|
+
data: NSDictionary?
|
|
639
|
+
) {
|
|
640
|
+
let resolvedData = data as? [String: Any]
|
|
641
|
+
Everframe.shared.addBreadcrumb(
|
|
642
|
+
message: message as String,
|
|
643
|
+
kind: kind as String?,
|
|
644
|
+
level: level as String?,
|
|
645
|
+
data: resolvedData
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// MARK: - setUser (self-declared identity — spec 2026-08-12)
|
|
650
|
+
|
|
651
|
+
/// Forward a host-declared user from JS into `Everframe.shared.setUser`.
|
|
652
|
+
/// Pure pass-through: the native singleton owns storage — NO
|
|
653
|
+
/// validation/coercion happens here, same contract as `setExtra`/
|
|
654
|
+
/// `attachReactTree` above. `nil` clears the active user (the bridge
|
|
655
|
+
/// forbids `T | null`, so JS expresses "clear" by calling with no
|
|
656
|
+
/// argument, which crosses as `nil` here). A missing key on the incoming
|
|
657
|
+
/// dictionary must become `nil`, never an empty string — that's what
|
|
658
|
+
/// keeps the Task 9 email-only `EFUser` shape (nullable `id`) usable.
|
|
659
|
+
///
|
|
660
|
+
/// External review, finding 2 (Serious) — checked, and safe as written.
|
|
661
|
+
/// The Android sibling had a real defect here: `ReadableMap.getString`
|
|
662
|
+
/// THROWS on a non-string value, `txGuardVoid` swallowed the throw before
|
|
663
|
+
/// `Everframe.setUser` was reached, and `setUser({ id: 12345 })` therefore
|
|
664
|
+
/// left the PREVIOUS account installed. The conditional casts below cannot
|
|
665
|
+
/// do that — `NSDictionary` subscripting doesn't throw and `as? String`
|
|
666
|
+
/// yields `nil` for a number/dictionary/array — so a bad field degrades to
|
|
667
|
+
/// a partial (or empty) user and `Everframe.shared.setUser` is ALWAYS
|
|
668
|
+
/// reached, which is what makes the call replace rather than preserve. The
|
|
669
|
+
/// JS facade projects to these three string fields before the bridge
|
|
670
|
+
/// anyway (`src/user-projection.ts`); this is the second layer, aligned in
|
|
671
|
+
/// behaviour with Android's `stringOrNull` helper.
|
|
672
|
+
@objc public static func setUser(_ user: NSDictionary?) {
|
|
673
|
+
guard let user else {
|
|
674
|
+
Everframe.shared.setUser(nil)
|
|
675
|
+
return
|
|
676
|
+
}
|
|
677
|
+
Everframe.shared.setUser(EFUser(
|
|
678
|
+
id: user["id"] as? String,
|
|
679
|
+
email: user["email"] as? String,
|
|
680
|
+
displayName: user["displayName"] as? String
|
|
681
|
+
))
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// MARK: - recordScreen (screen markers — spec 2026-07-14)
|
|
685
|
+
|
|
686
|
+
/// Navigation screen marker. Forwards straight to
|
|
687
|
+
/// `Everframe.shared.recordScreen(_:data:)` — NO validation/coercion
|
|
688
|
+
/// here (blank-name drop, from→to derivation, gating, and data coercion
|
|
689
|
+
/// all live in the core singleton). Fail-soft on a non-dictionary
|
|
690
|
+
/// payload, same as `addBreadcrumb`.
|
|
691
|
+
@objc public static func recordScreen(
|
|
692
|
+
_ name: NSString,
|
|
693
|
+
data: NSDictionary?
|
|
694
|
+
) {
|
|
695
|
+
Everframe.shared.recordScreen(
|
|
696
|
+
name as String,
|
|
697
|
+
data: data as? [String: Any]
|
|
698
|
+
)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// MARK: - Session Vitals (spec 2026-09-06)
|
|
702
|
+
//
|
|
703
|
+
// NOT HERE. The five vitals shims and their `RemotePlayerRegistry` used to live on this
|
|
704
|
+
// class as `static` funcs over a `private static let` registry — one map for the whole
|
|
705
|
+
// PROCESS. Codex round-1, C4 moved them to `RemoteVitalsBridge`, one instance per
|
|
706
|
+
// `EverframeModule` instance, because React Native does not guarantee one module
|
|
707
|
+
// instance: a process-wide registry made two React instances share a token space (the
|
|
708
|
+
// second instance's `rp1` was silently refused) and made either one's `invalidate`
|
|
709
|
+
// tear down the OTHER's players. See RemoteVitalsBridge.swift for the full rationale;
|
|
710
|
+
// Android's module has always owned its registry as an instance field, and this is now
|
|
711
|
+
// the same shape.
|
|
712
|
+
|
|
713
|
+
@objc(markSensitiveViewOrWarn:)
|
|
714
|
+
public static func markSensitiveViewOrWarn(_ view: NSObject?) {
|
|
715
|
+
dispatchPrecondition(condition: .onQueue(.main))
|
|
716
|
+
guard let v = view else {
|
|
717
|
+
os_log("[everframe] markSensitive: view not found for tag (registry no-op)",
|
|
718
|
+
log: log, type: .info)
|
|
719
|
+
return
|
|
720
|
+
}
|
|
721
|
+
markSensitive(v)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// MARK: - Companion (Plan 06.2-11)
|
|
725
|
+
//
|
|
726
|
+
// Bridge-side ownership of:
|
|
727
|
+
// • At most ONE live RelayWSClient against the configured ingest endpoint.
|
|
728
|
+
// • Combine subscriptions on `Everframe.shared.companion.$state`,
|
|
729
|
+
// `.$pairUrl`, `.$code` and `.$attachedUserName` that forward into
|
|
730
|
+
// `EverframeEventEmitter`.
|
|
731
|
+
//
|
|
732
|
+
// The Combine subscriptions are installed lazily on first `startCompanion`
|
|
733
|
+
// and kept across stop/start cycles (the companion observable survives the
|
|
734
|
+
// WS lifetime — see CompanionAPI.swift). This avoids missing the initial
|
|
735
|
+
// values that may already be present from a prior start.
|
|
736
|
+
//
|
|
737
|
+
// Concurrency: a single NSLock guards `companionClient` + `companionCancellables`
|
|
738
|
+
// mirrors the RelayWSClient internal locking pattern. NEVER called from
|
|
739
|
+
// RN's JS thread directly — the .mm shim hops to MainActor below to satisfy
|
|
740
|
+
// both `Everframe.shared.start` semantics and the RelayWSClient init/teardown
|
|
741
|
+
// contract.
|
|
742
|
+
|
|
743
|
+
private static let companionLock = NSLock()
|
|
744
|
+
nonisolated(unsafe) private static var companionClient: RelayWSClient?
|
|
745
|
+
/// The host's configured attach-PIN UI mode (spec 2026-08-19), parsed
|
|
746
|
+
/// from `ConfigOpts.attachPinUi` at `configure()` time. Defaults to
|
|
747
|
+
/// `.builtin` — the pre-existing-behaviour default for a host that never
|
|
748
|
+
/// set the flag (including every host on an older RN SDK version whose
|
|
749
|
+
/// `configure()` call carries no such field at all).
|
|
750
|
+
nonisolated(unsafe) private static var companionAttachPinUi: AttachPinUi = .builtin
|
|
751
|
+
// `CompanionCaptureBridge` subscribes to the report.request notification
|
|
752
|
+
// posted by RelayWSClient and runs the capture+send dance. Without an
|
|
753
|
+
// instance alive, report.request fires into the void → phone hangs on
|
|
754
|
+
// "Capturing the TV screen" forever (Plan 06.2-11 bugfix).
|
|
755
|
+
nonisolated(unsafe) private static var companionCaptureBridge: CompanionCaptureBridge?
|
|
756
|
+
nonisolated(unsafe) private static var companionCancellables: Set<AnyCancellable> = []
|
|
757
|
+
nonisolated(unsafe) private static var companionSubscriptionsInstalled = false
|
|
758
|
+
|
|
759
|
+
/// Open a relay WS connection and start forwarding
|
|
760
|
+
/// `Everframe.shared.companion` signals to the JS event emitter. Idempotent:
|
|
761
|
+
/// if a client is already live this is a no-op — the socket is NOT
|
|
762
|
+
/// recreated. Call `stopCompanion()` first to tear the existing connection
|
|
763
|
+
/// down before a subsequent `startCompanion()` opens a fresh one.
|
|
764
|
+
@objc public static func startCompanion(_ endpoint: NSString) {
|
|
765
|
+
// The JS-side `endpoint` arg is ignored — RelayWSClient defaults
|
|
766
|
+
// to the build-time-baked `IngestEndpoint.url`.
|
|
767
|
+
_ = endpoint
|
|
768
|
+
|
|
769
|
+
// MUST run before `RelayWSClient` is constructed below — see
|
|
770
|
+
// `ensurePinPresenterInstalledSync()`'s doc comment for the race this
|
|
771
|
+
// closes (finding 3, spec 2026-08-19 review).
|
|
772
|
+
ensurePinPresenterInstalledSync()
|
|
773
|
+
|
|
774
|
+
// Combine subscriptions install once and survive stop/start cycles;
|
|
775
|
+
// safe to (re-)call on every start (self-guarded). MUST run OUTSIDE
|
|
776
|
+
// `companionLock` — it self-acquires the same non-reentrant lock.
|
|
777
|
+
installCompanionSubscriptionsIfNeeded()
|
|
778
|
+
|
|
779
|
+
companionLock.lock()
|
|
780
|
+
// Idempotent: a live client means a prior startCompanion already
|
|
781
|
+
// opened the socket. Do NOT recreate it — callers must stopCompanion()
|
|
782
|
+
// first. Holding the lock across the check + install keeps two racing
|
|
783
|
+
// starts from both constructing a client (belt-and-suspenders; the
|
|
784
|
+
// .mm shim already serialises this on MainActor).
|
|
785
|
+
if companionClient != nil {
|
|
786
|
+
companionLock.unlock()
|
|
787
|
+
os_log("[everframe] startCompanion ignored — already running; call stopCompanion() first",
|
|
788
|
+
log: log, type: .info)
|
|
789
|
+
return
|
|
790
|
+
}
|
|
791
|
+
os_log("[everframe] startCompanion (endpoint arg ignored)",
|
|
792
|
+
log: log, type: .info)
|
|
793
|
+
// Runtime suppression of the built-in PIN presenter (spec 2026-08-19,
|
|
794
|
+
// controller ruling): a runtime flag, not an install-site gate.
|
|
795
|
+
// `EFReporterPresenter.installResolver()` always installs
|
|
796
|
+
// `CompanionPinPresenter` (see `installPresenterResolverIfNeeded()`
|
|
797
|
+
// above) so `__builtinPinUiInstalled` stays an honest capability
|
|
798
|
+
// signal regardless of the host's chosen mode — only the PRESENTING
|
|
799
|
+
// is silenced, here, at the one site where the mode is already known
|
|
800
|
+
// (configure() precedes startCompanion() in every host's lifecycle).
|
|
801
|
+
Everframe.shared.companion.__setBuiltinPinUiSuppressed(companionAttachPinUi != .builtin)
|
|
802
|
+
// Companion discovery (spec 2026-08-07): announcing is what makes this
|
|
803
|
+
// device appear in the dashboard's Companion tab. It is opt-in on the
|
|
804
|
+
// SDK key the host ALREADY configured via `configure()` — we never ask
|
|
805
|
+
// for a second one. A nil key takes the ticketless path: RelayWSClient
|
|
806
|
+
// builds no announcer and connects to plain `/relay/tv`, byte-identical
|
|
807
|
+
// to pre-companion behaviour. Announce must never be able to break
|
|
808
|
+
// reporting — no key means "no dashboard presence", never "no socket".
|
|
809
|
+
let client = RelayWSClient(
|
|
810
|
+
companion: Everframe.shared.companion,
|
|
811
|
+
sdkKey: configuredSdkKey(),
|
|
812
|
+
deviceLabel: companionDeviceLabel(),
|
|
813
|
+
attachPinUi: companionAttachPinUi,
|
|
814
|
+
// Naming spec 2026-08-24 — read off the same `currentConfig`
|
|
815
|
+
// snapshot `configuredSdkKey()` above already reads, not a
|
|
816
|
+
// second configuration surface. nil falls through to
|
|
817
|
+
// RelayWSClient's own Keychain-then-stored-UUID chain.
|
|
818
|
+
companionDeviceId: Everframe.shared.currentConfig?.companionDeviceId,
|
|
819
|
+
// Companion on-screen name badge (naming spec 2026-08-24,
|
|
820
|
+
// controller ruling / Task 6b) — same `currentConfig` snapshot,
|
|
821
|
+
// read fresh at start time (a mode/toggle can't affect anything
|
|
822
|
+
// before a relay client and its badge exist). `?? true` covers
|
|
823
|
+
// the same "never configured" edge `configuredSdkKey()`'s own
|
|
824
|
+
// optional chain already tolerates.
|
|
825
|
+
companionBadge: CompanionBadgeOptions(
|
|
826
|
+
enabled: Everframe.shared.currentConfig?.companionBadgeEnabled ?? true,
|
|
827
|
+
position: Self.parseCompanionBadgePosition(Everframe.shared.currentConfig?.companionBadgePosition)
|
|
828
|
+
)
|
|
829
|
+
)
|
|
830
|
+
// Wire the capture bridge BEFORE connecting so the report.request
|
|
831
|
+
// notification observer is registered before the first frame can fire.
|
|
832
|
+
let captureBridge = CompanionCaptureBridge(client: client)
|
|
833
|
+
companionClient = client
|
|
834
|
+
companionCaptureBridge = captureBridge
|
|
835
|
+
companionLock.unlock()
|
|
836
|
+
|
|
837
|
+
client.connect()
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/// The SDK key the host already configured through `configure()`, or nil
|
|
841
|
+
/// when there isn't a usable one. Read straight off `Everframe.shared`'s
|
|
842
|
+
/// own config snapshot — deliberately NOT a second place a key can be
|
|
843
|
+
/// configured, and NOT a new public surface (`currentConfig` is already
|
|
844
|
+
/// public and read-only).
|
|
845
|
+
///
|
|
846
|
+
/// Returns nil when `Everframe.start` never ran, and when the host
|
|
847
|
+
/// configured a blank/whitespace-only key. Both are "no dashboard
|
|
848
|
+
/// presence", never "no socket": RelayWSClient skips the announce hop
|
|
849
|
+
/// entirely on a nil key and reporting is unaffected.
|
|
850
|
+
private static func configuredSdkKey() -> String? {
|
|
851
|
+
guard let appId = Everframe.shared.currentConfig?.appId,
|
|
852
|
+
!appId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
853
|
+
else { return nil }
|
|
854
|
+
return appId
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/// Non-personal device label for the dashboard's device list — "Apple TV",
|
|
858
|
+
/// "iPhone", "iPad". Deliberately `UIDevice.current.model` and NOT
|
|
859
|
+
/// `UIDevice.current.name`, which is commonly a person's name
|
|
860
|
+
/// ("Aurimas's Apple TV") and would leak into a shared dashboard list as a
|
|
861
|
+
/// side effect of turning companion on. The display code is the real
|
|
862
|
+
/// disambiguator; the label is only a convenience.
|
|
863
|
+
private static func companionDeviceLabel() -> String? {
|
|
864
|
+
let model = UIDevice.current.model
|
|
865
|
+
return model.isEmpty ? nil : model
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/// Test-only — object identity of the live companion client, so tests can
|
|
869
|
+
/// prove a repeat `startCompanion()` is a no-op (same instance, not
|
|
870
|
+
/// recreated). Nil when no client is live.
|
|
871
|
+
static func _companionClientIdentityForTesting() -> ObjectIdentifier? {
|
|
872
|
+
companionLock.lock()
|
|
873
|
+
defer { companionLock.unlock() }
|
|
874
|
+
return companionClient.map(ObjectIdentifier.init)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/// JS handshake: called from the RN-bridge ObjC shim once the JS-side
|
|
878
|
+
/// `everframe.companion.reportRequested` listener is ready for capture to
|
|
879
|
+
/// proceed. Releases the CompanionCaptureBridge's bounded wait.
|
|
880
|
+
@objc public static func signalCompanionReportRequestReady(_ correlationId: NSString) {
|
|
881
|
+
CompanionCaptureBridge.signalReactTreeAttached(correlationId: correlationId as String)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/// Disconnect the active companion WS client (if any) and drop the
|
|
885
|
+
/// reference. Combine subscriptions stay alive across stop/start cycles
|
|
886
|
+
/// because the underlying CompanionAPI singleton on `Everframe.shared`
|
|
887
|
+
/// outlives the WS — replaying any state changes that happen between
|
|
888
|
+
/// stop and the next start would otherwise be lost from JS's view.
|
|
889
|
+
@objc public static func stopCompanion() {
|
|
890
|
+
os_log("[everframe] stopCompanion", log: log, type: .info)
|
|
891
|
+
companionLock.lock()
|
|
892
|
+
let client = companionClient
|
|
893
|
+
companionClient = nil
|
|
894
|
+
// Drop the capture bridge so its NotificationCenter observers
|
|
895
|
+
// deregister (CompanionCaptureBridge cleans up in deinit).
|
|
896
|
+
companionCaptureBridge = nil
|
|
897
|
+
companionLock.unlock()
|
|
898
|
+
client?.disconnect()
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/// Install Combine sinks on `Everframe.shared.companion.$state`,
|
|
902
|
+
/// `.$pairUrl`, `.$code` and `.$attachedUserName` that forward to
|
|
903
|
+
/// `EverframeEventEmitter`. Idempotent — the
|
|
904
|
+
/// `companionSubscriptionsInstalled` flag guards re-entry.
|
|
905
|
+
///
|
|
906
|
+
/// `@Published` publishers fire `.sink` synchronously on the writer's
|
|
907
|
+
/// thread (URLSession delegate queue, our `__setState` callsite). The
|
|
908
|
+
/// emitter's `sendEvent(withName:body:)` is thread-safe (RN bridge
|
|
909
|
+
/// enqueues onto the JS thread), so no main-thread hop is needed.
|
|
910
|
+
private static func installCompanionSubscriptionsIfNeeded() {
|
|
911
|
+
companionLock.lock()
|
|
912
|
+
let alreadyInstalled = companionSubscriptionsInstalled
|
|
913
|
+
companionSubscriptionsInstalled = true
|
|
914
|
+
companionLock.unlock()
|
|
915
|
+
guard !alreadyInstalled else { return }
|
|
916
|
+
|
|
917
|
+
let companion = Everframe.shared.companion
|
|
918
|
+
var bag = Set<AnyCancellable>()
|
|
919
|
+
companion.$state
|
|
920
|
+
.sink { state in
|
|
921
|
+
// CompanionState.rawValue is camelCase (Swift's default for
|
|
922
|
+
// String-backed enums) — `.reportInProgress` would cross to
|
|
923
|
+
// JS as "reportInProgress" and silently fail the union match
|
|
924
|
+
// in `src/companion.ts` (CompanionState = 'report_in_progress'
|
|
925
|
+
// | ...). Map to snake_case at the bridge boundary so both
|
|
926
|
+
// platforms emit the same JS-facing strings. Android does the
|
|
927
|
+
// equivalent via `CompanionState.toRnString()` in
|
|
928
|
+
// EverframeModule.kt.
|
|
929
|
+
EverframeEventEmitter.sendState(Self.rnString(for: state))
|
|
930
|
+
}
|
|
931
|
+
.store(in: &bag)
|
|
932
|
+
companion.$pairUrl
|
|
933
|
+
.sink { url in
|
|
934
|
+
EverframeEventEmitter.sendPairUrl(url)
|
|
935
|
+
}
|
|
936
|
+
.store(in: &bag)
|
|
937
|
+
|
|
938
|
+
// Companion discovery (spec 2026-08-07). `CompanionState.swift` also
|
|
939
|
+
// posts `.everframeCompanionCodeChange` /
|
|
940
|
+
// `.everframeCompanionAttachedUserNameChange` for UIKit/ObjC hosts, but
|
|
941
|
+
// we take the `@Published` route here for the same reason `$pairUrl`
|
|
942
|
+
// does: a `Published.Publisher` replays its CURRENT value to a new
|
|
943
|
+
// subscriber, so JS still learns a code that was already set by a
|
|
944
|
+
// prior start. NotificationCenter has no replay and posts only on a
|
|
945
|
+
// real change — subscribing there would silently lose exactly the
|
|
946
|
+
// initial values this install-once-and-survive-stop/start design
|
|
947
|
+
// exists to preserve.
|
|
948
|
+
companion.$code
|
|
949
|
+
.sink { code in
|
|
950
|
+
EverframeEventEmitter.sendCode(code)
|
|
951
|
+
}
|
|
952
|
+
.store(in: &bag)
|
|
953
|
+
companion.$attachedUserName
|
|
954
|
+
.sink { name in
|
|
955
|
+
EverframeEventEmitter.sendAttachedUserName(name)
|
|
956
|
+
}
|
|
957
|
+
.store(in: &bag)
|
|
958
|
+
|
|
959
|
+
// Resolved device display name (naming spec 2026-08-24). Same
|
|
960
|
+
// replay-on-subscribe rationale as `$code`/`$attachedUserName`
|
|
961
|
+
// above — a `Published` publisher replays its CURRENT value to a
|
|
962
|
+
// new subscriber, so JS still learns a name that was already
|
|
963
|
+
// resolved by a prior announce.
|
|
964
|
+
companion.$resolvedName
|
|
965
|
+
.sink { name in
|
|
966
|
+
EverframeEventEmitter.sendResolvedName(name)
|
|
967
|
+
}
|
|
968
|
+
.store(in: &bag)
|
|
969
|
+
|
|
970
|
+
// Attach-PIN challenge (spec 2026-08-19). Same replay-on-subscribe
|
|
971
|
+
// rationale as `$code`/`$attachedUserName` above — a `Published`
|
|
972
|
+
// publisher replays its CURRENT value to a new subscriber, so a
|
|
973
|
+
// `'custom'`-mode host that starts JS-side listening after the
|
|
974
|
+
// challenge already arrived still learns about it.
|
|
975
|
+
companion.$attachChallenge
|
|
976
|
+
.sink { challenge in
|
|
977
|
+
EverframeEventEmitter.sendAttachChallenge(challenge.map {
|
|
978
|
+
["code": $0.code, "requestedByName": $0.requestedByName, "ttlMs": $0.ttlMs]
|
|
979
|
+
})
|
|
980
|
+
}
|
|
981
|
+
.store(in: &bag)
|
|
982
|
+
|
|
983
|
+
// Forward `report.request` arrival to JS so the companion module
|
|
984
|
+
// can acknowledge before the bridge proceeds with native capture
|
|
985
|
+
// (see CompanionCaptureBridge.awaitJsReactTreeAttach). The
|
|
986
|
+
// CompanionCaptureBridge posts this notification at the top of
|
|
987
|
+
// handleReportRequest and then awaits the JS callback up to
|
|
988
|
+
// 250ms before falling through.
|
|
989
|
+
NotificationCenter.default.publisher(
|
|
990
|
+
for: CompanionCaptureBridge.shouldAttachReactTreeNotification
|
|
991
|
+
)
|
|
992
|
+
.sink { note in
|
|
993
|
+
guard let corrId = note.userInfo?["correlation_id"] as? String else { return }
|
|
994
|
+
EverframeEventEmitter.sendReportRequested(corrId)
|
|
995
|
+
}
|
|
996
|
+
.store(in: &bag)
|
|
997
|
+
|
|
998
|
+
companionLock.lock()
|
|
999
|
+
companionCancellables = bag
|
|
1000
|
+
companionLock.unlock()
|
|
1001
|
+
os_log("[everframe] installed companion Combine subscriptions",
|
|
1002
|
+
log: log, type: .info)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/// Map `CompanionState` to the snake_case payload string the JS facade
|
|
1006
|
+
/// expects (`'unpaired' | 'paired' | 'report_in_progress' |
|
|
1007
|
+
/// 'phone_disconnected'`). MUST match `EverframeModule.toRnString()` on
|
|
1008
|
+
/// Android. Not `@objc` — `CompanionState` is a Swift enum that does not
|
|
1009
|
+
/// bridge to ObjC; consumers call this from Swift only (the .mm shim sends
|
|
1010
|
+
/// already-stringified payloads to the event emitter).
|
|
1011
|
+
public static func rnString(for state: CompanionState) -> String {
|
|
1012
|
+
switch state {
|
|
1013
|
+
case .unpaired: return "unpaired"
|
|
1014
|
+
case .paired: return "paired"
|
|
1015
|
+
case .reportInProgress: return "report_in_progress"
|
|
1016
|
+
case .phoneDisconnected: return "phone_disconnected"
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// MARK: - Test seams (internal access via @testable import)
|
|
1021
|
+
|
|
1022
|
+
/// Whether a RelayWSClient is currently owned by the bridge. The test
|
|
1023
|
+
/// target uses this to assert that `startCompanion(...)` installs the
|
|
1024
|
+
/// client and `stopCompanion()` drops it.
|
|
1025
|
+
@objc public static func _hasCompanionClientForTesting() -> Bool {
|
|
1026
|
+
companionLock.lock()
|
|
1027
|
+
defer { companionLock.unlock() }
|
|
1028
|
+
return companionClient != nil
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/// Test-only reset for the Combine subscription latch + cancellables —
|
|
1032
|
+
/// XCTest creates a fresh bridge state per test method.
|
|
1033
|
+
@objc public static func _resetCompanionForTesting() {
|
|
1034
|
+
companionLock.lock()
|
|
1035
|
+
companionClient?.disconnect()
|
|
1036
|
+
companionClient = nil
|
|
1037
|
+
companionCancellables.removeAll()
|
|
1038
|
+
companionSubscriptionsInstalled = false
|
|
1039
|
+
companionAttachPinUi = .builtin
|
|
1040
|
+
companionLock.unlock()
|
|
1041
|
+
Everframe.shared.companion.__setBuiltinPinUiSuppressed(false)
|
|
1042
|
+
}
|
|
1043
|
+
}
|