@everframe/react-native 1.5.0 → 1.6.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.
@@ -1,5 +1,5 @@
1
- import { E as ErrorCaptureStatus, P as PromiseRejectionStatus, R as ReportDeliveryStatus, c as ReporterTrigger, k as PermissionResult, L as LaunchContext, e as InteractionHookOptions, C as ControlChangeInput, S as ScrollInput, d as InteractionTargetAnnotation } from './launch-C8wbMunK.js';
2
- export { f as ErrorCaptureCounters, g as ErrorCaptureOutcome, h as ErrorCapturePath, H as HelperType, I as InteractionPolicyOptions, l as PromiseRejectionCounters, m as RejectionReason, n as ReportCaptureOutcome, o as ReportCapturePath, p as ReportCapturePathStatus, q as ReportDeliveryReason, s as ReportQueueOperation, t as ReportQueueStatus, u as ReportStorageFailure, v as ReportTransportOrigin, w as ReportTransportOutcome, x as ReportTransportStatus, a as ReporterOpenOptions } from './launch-C8wbMunK.js';
1
+ import { E as ErrorCaptureStatus, P as PromiseRejectionStatus, R as ReportDeliveryStatus, c as ReporterTrigger, k as PermissionResult, L as LaunchContext, e as InteractionHookOptions, C as ControlChangeInput, S as ScrollInput, d as InteractionTargetAnnotation } from './launch-aXtOOfNU.js';
2
+ export { f as ErrorCaptureCounters, g as ErrorCaptureOutcome, h as ErrorCapturePath, H as HelperType, I as InteractionPolicyOptions, l as PromiseRejectionCounters, m as RejectionReason, n as ReportCaptureOutcome, o as ReportCapturePath, p as ReportCapturePathStatus, q as ReportDeliveryReason, s as ReportQueueOperation, t as ReportQueueStatus, u as ReportStorageFailure, v as ReportTransportOrigin, w as ReportTransportOutcome, x as ReportTransportStatus, a as ReporterOpenOptions } from './launch-aXtOOfNU.js';
3
3
  import * as React from 'react';
4
4
  export * from '@everframe/react';
5
5
  import 'zod';
@@ -1013,8 +1013,7 @@ declare class EverframeNotMountedError extends Error {
1013
1013
  /**
1014
1014
  * Rejected by `open()` when the host turned the reporter off
1015
1015
  * (`reporter: { disabled: true }`, a crash-only session). The session keeps
1016
- * this for its whole life, including after a companion bond upgrades it:
1017
- * reports then come through the companion, not through `open()`. Same name
1016
+ * this for its whole life. Same name
1018
1017
  * as the web SDK's error of the same purpose.
1019
1018
  */
1020
1019
  declare class EverframeReporterDisabledError extends Error {
@@ -9,10 +9,9 @@
9
9
  // The RN bridge surface is the Phase 06 5-method TurboModule (configure,
10
10
  // openReporter, registerSensitiveRect, setExtra). The native
11
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.
12
+ // submit) on iOS/iPadOS. On tvOS the on-device modal reporter was removed,
13
+ // so openReporter() on tvOS resolves to .cancelled by way of the absent
14
+ // presenter installer.
16
15
  //
17
16
  // 1. configure(opts) → start Everframe.shared with EverframeConfig
18
17
  // + install EFReporterPresenter resolver
@@ -55,12 +54,8 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
55
54
  networkBodiesDisabled: Bool,
56
55
  installIdentifierDisabled: Bool,
57
56
  crashReportingDisabled: Bool,
58
- attachPinUi: NSString?,
59
- companionDeviceId: NSString?,
60
- companionBadgeEnabled: Bool,
61
57
  shakeToReportEnabled: Bool,
62
58
  quickCaptureEnabled: Bool,
63
- companionBadgePosition: NSString?,
64
59
  theme: NSDictionary?,
65
60
  vitalsEnabled: NSNumber?,
66
61
  vitalsSampleRate: NSNumber?,
@@ -95,26 +90,11 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
95
90
  // Polarity flips exactly once, here: JS sends the veto
96
91
  // (`crashReportingDisabled`), native stores the permission.
97
92
  cfg.capture.crash = !crashReportingDisabled
98
- // Explicit companion device-identity override (naming spec
99
- // 2026-08-24) — read straight off the flat ConfigOpts field, same
100
- // shape as attachPinUi below. nil/absent falls through to the
101
- // Keychain-then-stored-UUID chain inside CompanionDeviceId.resolve.
102
- cfg.companionDeviceId = companionDeviceId as String?
103
- // Companion on-screen name badge (naming spec 2026-08-24, controller
104
- // ruling / Task 6b) — read straight off the flat ConfigOpts fields,
105
- // same shape as companionDeviceId above. `companionBadgeEnabled` is
106
- // already defaulted to `true` by the .mm's `.value_or(true)`;
107
- // `companionBadgePosition` stays the raw string here — parsed into
108
- // `CompanionBadgePosition` only at `startCompanion()`, since a
109
- // position can't affect anything before a relay client (and its
110
- // badge) exist, mirroring `companionAttachPinUi` below.
111
- cfg.companionBadgeEnabled = companionBadgeEnabled
112
93
  cfg.shakeToReportEnabled = shakeToReportEnabled
113
94
  cfg.quickCaptureEnabled = quickCaptureEnabled
114
95
  // Polarity flips exactly once, here: JS sends the crash-only switch
115
96
  // (`reporterDisabled`), native stores the permission.
116
97
  cfg.reporterEnabled = !reporterDisabled
117
- cfg.companionBadgePosition = companionBadgePosition as String?
118
98
  // Inline reporter theme (reporter branding spec 2026-08-25, RN
119
99
  // slice). The .mm collapses the 8 flat ConfigOpts `theme*` wire
120
100
  // fields into one role-keyed dictionary (nil when the host set no
@@ -135,13 +115,6 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
135
115
  if let vitalsCaptureSourceQuery { cfg.vitals.captureSourceQuery = vitalsCaptureSourceQuery.boolValue }
136
116
  // Absent stays nil, so the native SDK detects a debug build itself; present wins.
137
117
  cfg.debugBuild = debugBuild?.boolValue
138
- // The attach-PIN UI mode for startCompanion() to read (spec 2026-08-19). configure()
139
- // precedes startCompanion() in every host's lifecycle, so this is the natural place to
140
- // parse the flat ConfigOpts string into the native enum; startCompanion is where it is
141
- // actually consumed (RelayWSClient's announce arg + the suppression seam), since a mode
142
- // can't affect anything before a relay client exists. A changed value forces native
143
- // reconfiguration even when the SDK configuration itself compares equal.
144
- let newAttachPinUi = Self.parseAttachPinUi(attachPinUi as String?)
145
118
  let health: ReleaseHealthConfiguration?
146
119
  if releaseHealthEnabled, let loaded = releaseHealthLoadedBuildId {
147
120
  // The SDK reads the app version itself; the bridge carries the loaded bundle, the user ID and
@@ -158,11 +131,9 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
158
131
  // the unchanged-configuration comparison.
159
132
  cfg.releaseHealth = nil
160
133
  // Native code atomically checks installed config and reserves its exact owner.
161
- // An identical configure preserves the session; a changed attach-PIN mode still
162
- // requires the same full restart as the other native configuration changes.
134
+ // An identical configure preserves the session.
163
135
  let applyHealth = try Everframe.shared.configureAndPrepareReleaseHealth(
164
- config: cfg, health: health, forceRestart: companionAttachPinUi != newAttachPinUi)
165
- companionAttachPinUi = newAttachPinUi
136
+ config: cfg, health: health, forceRestart: false)
166
137
  // Reservation is tied to this configure's exact native start generation. Later
167
138
  // configure/disable or a native restart invalidates the asynchronous operation.
168
139
  if let applyHealth {
@@ -170,12 +141,9 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
170
141
  }
171
142
  installPresenterResolverIfNeeded()
172
143
  // Spec 2026-09-17 setExtra-resolver — installed unconditionally,
173
- // every configure(), NOT gated behind startCompanion() the way the
174
- // companion Combine subscriptions are: this must also cover the
175
- // plain in-app openReporter() path and native shake, both of which
176
- // funnel through Everframe.shared.report.open() →
177
- // __consumePendingAttachments() with no companion involvement at
178
- // all. See `installExtraResolveHook()`'s own doc comment.
144
+ // every configure(): this must cover the plain in-app openReporter()
145
+ // path and native shake, both of which funnel through
146
+ // Everframe.shared.report.open() → __consumePendingAttachments(). See `installExtraResolveHook()`'s own doc comment.
179
147
  installExtraResolveHook()
180
148
  // Task 13: prime the device-metadata cache off the main queue so a
181
149
  // crash arriving shortly after configure() still has device context.
@@ -203,35 +171,10 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
203
171
  )
204
172
  }
205
173
 
206
- /// Coerce the flat `ConfigOpts.attachPinUi` string into `AttachPinUi`.
207
- /// `nil` (field absent) and any unrecognised value both degrade to
208
- /// `.builtin` — the safe default matching "host did nothing special".
209
- private static func parseAttachPinUi(_ raw: String?) -> AttachPinUi {
210
- guard let raw, let mode = AttachPinUi(rawValue: raw) else { return .builtin }
211
- return mode
212
- }
213
-
214
- /// Coerce the flat `ConfigOpts.companionBadgePosition` string into
215
- /// `CompanionBadgePosition` (naming spec 2026-08-24, controller ruling /
216
- /// Task 6b). `nil` (field absent) and any unrecognised value both
217
- /// degrade to `.bottomRight` — matches `CompanionBadgeOptions.position`'s
218
- /// own default, so an absent field behaves exactly like never touching
219
- /// the option at all.
220
- private static func parseCompanionBadgePosition(_ raw: String?) -> CompanionBadgePosition {
221
- switch raw {
222
- case "bottom-left": return .bottomLeft
223
- case "top-right": return .topRight
224
- case "top-left": return .topLeft
225
- default: return .bottomRight
226
- }
227
- }
228
-
229
174
  /// Wires `EFReporterPresenter` as the resolver for `Everframe.shared.report
230
175
  /// .open()`. Called from configure(); the first call installs, subsequent
231
- /// calls no-op. tvOS has no on-device modal — reporting goes through the
232
- /// phone-companion flow (QR → phone browser) — so EverframeReporterUI's
233
- /// tvOS `installResolver()` is a no-op. The gate here skips that call and
234
- /// logs where tvOS reporting goes instead.
176
+ /// calls no-op. tvOS has no on-device modal, so EverframeReporterUI's
177
+ /// tvOS `installResolver()` is a no-op. The gate here skips that call.
235
178
  private static func installPresenterResolverIfNeeded() {
236
179
  #if !os(tvOS)
237
180
  resolverInstallLock.lock()
@@ -246,7 +189,7 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
246
189
  os_log("[everframe] installed EFReporterPresenter resolver", log: log, type: .info)
247
190
  }
248
191
  #else
249
- os_log("[everframe] tvOS — EFReporterPresenter not installed; reporting routes through phone companion", log: log, type: .info)
192
+ os_log("[everframe] tvOS — EFReporterPresenter not installed (no on-device reporter)", log: log, type: .info)
250
193
  #endif
251
194
  }
252
195
 
@@ -335,9 +278,8 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
335
278
  }
336
279
 
337
280
  /// Per-correlation_id continuation map, released by
338
- /// `signalExtraResolverReady`. Mirrors `CompanionCaptureBridge`'s
339
- /// `pendingAttachContinuations` exactly (same lock-guarded dictionary,
340
- /// same "the timeout resumes it if JS never calls back" shape).
281
+ /// `signalExtraResolverReady`. A lock-guarded dictionary; the timeout
282
+ /// resumes a continuation if JS never calls back.
341
283
  nonisolated(unsafe) private static var pendingExtraResolveContinuations:
342
284
  [String: CheckedContinuation<Void, Never>] = [:]
343
285
  private static let pendingExtraResolveLock = NSLock()
@@ -348,8 +290,7 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
348
290
  /// string/object-only host's report path exactly as fast as before this
349
291
  /// feature landed (requirement: "no behaviour change whatsoever").
350
292
  ///
351
- /// Otherwise mirrors `CompanionCaptureBridge.awaitJsReactTreeAttach`
352
- /// exactly: send `EverframeEventEmitter.sendExtraResolveRequested`, then
293
+ /// Otherwise send `EverframeEventEmitter.sendExtraResolveRequested`, then
353
294
  /// suspend up to 250ms for JS's `signalExtraResolverReady` ack.
354
295
  private static func awaitJsExtraResolve() async {
355
296
  extraResolverActiveLock.lock()
@@ -363,8 +304,7 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
363
304
  pendingExtraResolveContinuations[correlationId] = cont
364
305
  pendingExtraResolveLock.unlock()
365
306
  // Send AFTER registering the continuation so a fast JS callback
366
- // can't lose the race — same ordering `awaitJsReactTreeAttach`
367
- // uses.
307
+ // can't lose the race.
368
308
  EverframeEventEmitter.sendExtraResolveRequested(correlationId)
369
309
  // 250ms bounded fallback — resume if JS hasn't called back by then.
370
310
  DispatchQueue.global().asyncAfter(deadline: .now() + 0.25) {
@@ -376,8 +316,7 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
376
316
  /// JS handshake ack: called once the `everframe.extra.resolveRequested`
377
317
  /// listener has — if a resolver is registered — pushed a freshly
378
318
  /// resolved value through `setExtra` above. Idempotent — a late signal
379
- /// after the 250ms fallback already fired is a harmless no-op, exactly
380
- /// like `signalCompanionReportRequestReady` below.
319
+ /// after the 250ms fallback already fired is a harmless no-op.
381
320
  @objc public static func signalExtraResolverReady(_ correlationId: NSString) {
382
321
  resumeAndDropPendingExtraResolve(correlationId: correlationId as String)
383
322
  }
@@ -470,67 +409,6 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
470
409
  }
471
410
  }
472
411
 
473
- /// Install-race fix (spec 2026-08-19 review finding 3). `configure()`
474
- /// (above) queues the PIN presenter install via
475
- /// `installPresenterResolverIfNeeded()`'s `DispatchQueue.main.async` —
476
- /// note that call marks `resolverInstalled = true` BEFORE the async block
477
- /// actually runs, so that flag alone cannot tell a caller here whether
478
- /// `CompanionPinPresenter.install()` (which sets
479
- /// `CompanionAPI.__builtinPinUiInstalled`) has genuinely finished.
480
- ///
481
- /// `startCompanion()` builds `RelayWSClient` and calls `connect()`
482
- /// synchronously right after this; `connect()`'s announce path reads
483
- /// `__builtinPinUiInstalled` from a freshly-spawned `Task` that can run
484
- /// before the main-queue-dispatched install block does, especially early
485
- /// in a host's launch sequence when the main queue is already busy. A
486
- /// host that calls `startCompanion()` immediately after `configure()`
487
- /// resolves — the ordinary RN lifecycle — would then read `false` for
488
- /// the capability check for the ENTIRE session, so `.builtin` mode would
489
- /// never advertise `supportsAttachPin` even though the presenter finishes
490
- /// installing moments later.
491
- ///
492
- /// Fix: synchronously hop to MainActor and call
493
- /// `CompanionPinPresenter.install()` directly — the same call
494
- /// `EFReporterPresenter.installResolver()` itself makes — and WAIT for
495
- /// it, before `startCompanion()` constructs the client. `install()` is
496
- /// `@MainActor` and idempotent (its own internal `installed` flag), so
497
- /// this is cheap on every call after the first: the common case (install
498
- /// already genuinely finished by the time `startCompanion()` runs) pays
499
- /// only a MainActor hop, not real work. Scoped to the smallest change
500
- /// that closes the race — `installPresenterResolverIfNeeded()`'s async
501
- /// dispatch and its own `resolverInstalled` flag are untouched, since
502
- /// `EFReporterPresenter.installResolver()` also wires the full modal
503
- /// reporter (openReporter's resolver), which this call does not need to
504
- /// duplicate or wait for.
505
- ///
506
- /// Gate: `#if canImport(UIKit)`, matching `CompanionPinPresenter.swift`'s
507
- /// own gate — NOT `#if !os(tvOS)`. Round-2 review finding 2: tvOS hosts
508
- /// are a PRIMARY consumer of companion (see `CompanionPinPresenter.swift`
509
- /// header + `RelayWSClient`'s), and only `EFReporterPresenter` (the full
510
- /// modal reporter, iOS-only — tvOS has no on-device modal) needs the
511
- /// `!os(tvOS)` exclusion `installPresenterResolverIfNeeded()` above
512
- /// correctly keeps. Gating THIS call the same way silently left RN-tvOS
513
- /// builtin hosts with `CompanionAPI.__builtinPinUiInstalled` permanently
514
- /// false, so `startCompanion()`'s announce always reported
515
- /// `supportsAttachPin: false` and every tvOS host fell back to legacy
516
- /// one-click attach even though `CompanionPinPresenter` builds and runs
517
- /// fine there.
518
- private static func ensurePinPresenterInstalledSync() {
519
- #if canImport(UIKit)
520
- if Thread.isMainThread {
521
- MainActor.assumeIsolated {
522
- CompanionPinPresenter.install()
523
- }
524
- } else {
525
- DispatchQueue.main.sync {
526
- MainActor.assumeIsolated {
527
- CompanionPinPresenter.install()
528
- }
529
- }
530
- }
531
- #endif
532
- }
533
-
534
412
  // MARK: - openReporter
535
413
 
536
414
  /// Opens the native reporter modal and resolves with the user's outcome.
@@ -792,322 +670,4 @@ import EverframeReporterUI // tvOS slice present per Package.swift:12 (TV-03
792
670
  }
793
671
  markSensitive(v)
794
672
  }
795
-
796
- // MARK: - Companion (Plan 06.2-11)
797
- //
798
- // Bridge-side ownership of:
799
- // • At most ONE live RelayWSClient against the configured ingest endpoint.
800
- // • Combine subscriptions on `Everframe.shared.companion.$state`,
801
- // `.$pairUrl`, `.$code` and `.$attachedUserName` that forward into
802
- // `EverframeEventEmitter`.
803
- //
804
- // The Combine subscriptions are installed lazily on first `startCompanion`
805
- // and kept across stop/start cycles (the companion observable survives the
806
- // WS lifetime — see CompanionAPI.swift). This avoids missing the initial
807
- // values that may already be present from a prior start.
808
- //
809
- // Concurrency: a single NSLock guards `companionClient` + `companionCancellables`
810
- // mirrors the RelayWSClient internal locking pattern. NEVER called from
811
- // RN's JS thread directly — the .mm shim hops to MainActor below to satisfy
812
- // both `Everframe.shared.start` semantics and the RelayWSClient init/teardown
813
- // contract.
814
-
815
- private static let companionLock = NSLock()
816
- nonisolated(unsafe) private static var companionClient: RelayWSClient?
817
- /// The host's configured attach-PIN UI mode (spec 2026-08-19), parsed
818
- /// from `ConfigOpts.attachPinUi` at `configure()` time. Defaults to
819
- /// `.builtin` for a host that never sets the flag.
820
- nonisolated(unsafe) private static var companionAttachPinUi: AttachPinUi = .builtin
821
- // `CompanionCaptureBridge` subscribes to the report.request notification
822
- // posted by RelayWSClient and runs the capture+send dance. Without an
823
- // instance alive, report.request fires into the void → phone hangs on
824
- // "Capturing the TV screen" forever (Plan 06.2-11 bugfix).
825
- nonisolated(unsafe) private static var companionCaptureBridge: CompanionCaptureBridge?
826
- nonisolated(unsafe) private static var companionCancellables: Set<AnyCancellable> = []
827
- nonisolated(unsafe) private static var companionSubscriptionsInstalled = false
828
-
829
- /// Open a relay WS connection and start forwarding
830
- /// `Everframe.shared.companion` signals to the JS event emitter. Idempotent:
831
- /// if a client is already live this is a no-op — the socket is NOT
832
- /// recreated. Call `stopCompanion()` first to tear the existing connection
833
- /// down before a subsequent `startCompanion()` opens a fresh one.
834
- @objc public static func startCompanion(_ endpoint: NSString) {
835
- // The JS-side `endpoint` arg is ignored — RelayWSClient defaults
836
- // to the build-time-baked `IngestEndpoint.url`.
837
- _ = endpoint
838
-
839
- // MUST run before `RelayWSClient` is constructed below — see
840
- // `ensurePinPresenterInstalledSync()`'s doc comment for the race this
841
- // closes (finding 3, spec 2026-08-19 review).
842
- ensurePinPresenterInstalledSync()
843
-
844
- // Combine subscriptions install once and survive stop/start cycles;
845
- // safe to (re-)call on every start (self-guarded). MUST run OUTSIDE
846
- // `companionLock` — it self-acquires the same non-reentrant lock.
847
- installCompanionSubscriptionsIfNeeded()
848
-
849
- companionLock.lock()
850
- // Idempotent: a live client means a prior startCompanion already
851
- // opened the socket. Do NOT recreate it — callers must stopCompanion()
852
- // first. Holding the lock across the check + install keeps two racing
853
- // starts from both constructing a client (belt-and-suspenders; the
854
- // .mm shim already serialises this on MainActor).
855
- if companionClient != nil {
856
- companionLock.unlock()
857
- os_log("[everframe] startCompanion ignored — already running; call stopCompanion() first",
858
- log: log, type: .info)
859
- return
860
- }
861
- os_log("[everframe] startCompanion (endpoint arg ignored)",
862
- log: log, type: .info)
863
- // Runtime suppression of the built-in PIN presenter (spec 2026-08-19,
864
- // controller ruling): a runtime flag, not an install-site gate.
865
- // `EFReporterPresenter.installResolver()` always installs
866
- // `CompanionPinPresenter` (see `installPresenterResolverIfNeeded()`
867
- // above) so `__builtinPinUiInstalled` stays an honest capability
868
- // signal regardless of the host's chosen mode — only the PRESENTING
869
- // is silenced, here, at the one site where the mode is already known
870
- // (configure() precedes startCompanion() in every host's lifecycle).
871
- Everframe.shared.companion.__setBuiltinPinUiSuppressed(companionAttachPinUi != .builtin)
872
- // Companion discovery (spec 2026-08-07): announcing is what makes this
873
- // device appear in the dashboard's Companion tab. It is opt-in on the
874
- // SDK key the host ALREADY configured via `configure()` — we never ask
875
- // for a second one. A nil key takes the ticketless path: RelayWSClient
876
- // builds no announcer and connects to plain `/relay/tv`, byte-identical
877
- // to pre-companion behaviour. Announce must never be able to break
878
- // reporting — no key means "no dashboard presence", never "no socket".
879
- let client = RelayWSClient(
880
- companion: Everframe.shared.companion,
881
- sdkKey: configuredSdkKey(),
882
- deviceLabel: companionDeviceLabel(),
883
- attachPinUi: companionAttachPinUi,
884
- // Naming spec 2026-08-24 — read off the same `currentConfig`
885
- // snapshot `configuredSdkKey()` above already reads, not a
886
- // second configuration surface. nil falls through to
887
- // RelayWSClient's own Keychain-then-stored-UUID chain.
888
- companionDeviceId: Everframe.shared.currentConfig?.companionDeviceId,
889
- // Companion on-screen name badge (naming spec 2026-08-24,
890
- // controller ruling / Task 6b) — same `currentConfig` snapshot,
891
- // read fresh at start time (a mode/toggle can't affect anything
892
- // before a relay client and its badge exist). `?? true` covers
893
- // the same "never configured" edge `configuredSdkKey()`'s own
894
- // optional chain already tolerates.
895
- companionBadge: CompanionBadgeOptions(
896
- enabled: Everframe.shared.currentConfig?.companionBadgeEnabled ?? true,
897
- position: Self.parseCompanionBadgePosition(Everframe.shared.currentConfig?.companionBadgePosition)
898
- )
899
- )
900
- // Wire the capture bridge BEFORE connecting so the report.request
901
- // notification observer is registered before the first frame can fire.
902
- let captureBridge = CompanionCaptureBridge(client: client)
903
- companionClient = client
904
- companionCaptureBridge = captureBridge
905
- companionLock.unlock()
906
-
907
- client.connect()
908
- }
909
-
910
- /// The SDK key the host already configured through `configure()`, or nil
911
- /// when there isn't a usable one. Read straight off `Everframe.shared`'s
912
- /// own config snapshot — deliberately NOT a second place a key can be
913
- /// configured, and NOT a new public surface (`currentConfig` is already
914
- /// public and read-only).
915
- ///
916
- /// Returns nil when `Everframe.start` never ran, and when the host
917
- /// configured a blank/whitespace-only key. Both are "no dashboard
918
- /// presence", never "no socket": RelayWSClient skips the announce hop
919
- /// entirely on a nil key and reporting is unaffected.
920
- private static func configuredSdkKey() -> String? {
921
- guard let sdkKey = Everframe.shared.currentConfig?.sdkKey,
922
- !sdkKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
923
- else { return nil }
924
- return sdkKey
925
- }
926
-
927
- /// Non-personal device label for the dashboard's device list — "Apple TV",
928
- /// "iPhone", "iPad". Deliberately `UIDevice.current.model` and NOT
929
- /// `UIDevice.current.name`, which is commonly a person's name
930
- /// ("Aurimas's Apple TV") and would leak into a shared dashboard list as a
931
- /// side effect of turning companion on. The display code is the real
932
- /// disambiguator; the label is only a convenience.
933
- private static func companionDeviceLabel() -> String? {
934
- let model = UIDevice.current.model
935
- return model.isEmpty ? nil : model
936
- }
937
-
938
- /// Test-only — object identity of the live companion client, so tests can
939
- /// prove a repeat `startCompanion()` is a no-op (same instance, not
940
- /// recreated). Nil when no client is live.
941
- static func _companionClientIdentityForTesting() -> ObjectIdentifier? {
942
- companionLock.lock()
943
- defer { companionLock.unlock() }
944
- return companionClient.map(ObjectIdentifier.init)
945
- }
946
-
947
- /// JS handshake: called from the RN-bridge ObjC shim once the JS-side
948
- /// `everframe.companion.reportRequested` listener is ready for capture to
949
- /// proceed. Releases the CompanionCaptureBridge's bounded wait.
950
- @objc public static func signalCompanionReportRequestReady(_ correlationId: NSString) {
951
- CompanionCaptureBridge.signalReactTreeAttached(correlationId: correlationId as String)
952
- }
953
-
954
- /// Disconnect the active companion WS client (if any) and drop the
955
- /// reference. Combine subscriptions stay alive across stop/start cycles
956
- /// because the underlying CompanionAPI singleton on `Everframe.shared`
957
- /// outlives the WS — replaying any state changes that happen between
958
- /// stop and the next start would otherwise be lost from JS's view.
959
- @objc public static func stopCompanion() {
960
- os_log("[everframe] stopCompanion", log: log, type: .info)
961
- companionLock.lock()
962
- let client = companionClient
963
- companionClient = nil
964
- // Drop the capture bridge so its NotificationCenter observers
965
- // deregister (CompanionCaptureBridge cleans up in deinit).
966
- companionCaptureBridge = nil
967
- companionLock.unlock()
968
- client?.disconnect()
969
- }
970
-
971
- /// Install Combine sinks on `Everframe.shared.companion.$state`,
972
- /// `.$pairUrl`, `.$code` and `.$attachedUserName` that forward to
973
- /// `EverframeEventEmitter`. Idempotent — the
974
- /// `companionSubscriptionsInstalled` flag guards re-entry.
975
- ///
976
- /// `@Published` publishers fire `.sink` synchronously on the writer's
977
- /// thread (URLSession delegate queue, our `__setState` callsite). The
978
- /// emitter's `sendEvent(withName:body:)` is thread-safe (RN bridge
979
- /// enqueues onto the JS thread), so no main-thread hop is needed.
980
- private static func installCompanionSubscriptionsIfNeeded() {
981
- companionLock.lock()
982
- let alreadyInstalled = companionSubscriptionsInstalled
983
- companionSubscriptionsInstalled = true
984
- companionLock.unlock()
985
- guard !alreadyInstalled else { return }
986
-
987
- let companion = Everframe.shared.companion
988
- var bag = Set<AnyCancellable>()
989
- companion.$state
990
- .sink { state in
991
- // CompanionState.rawValue is camelCase (Swift's default for
992
- // String-backed enums) — `.reportInProgress` would cross to
993
- // JS as "reportInProgress" and silently fail the union match
994
- // in `src/companion.ts` (CompanionState = 'report_in_progress'
995
- // | ...). Map to snake_case at the bridge boundary so both
996
- // platforms emit the same JS-facing strings. Android does the
997
- // equivalent via `CompanionState.toRnString()` in
998
- // EverframeModule.kt.
999
- EverframeEventEmitter.sendState(Self.rnString(for: state))
1000
- }
1001
- .store(in: &bag)
1002
- companion.$pairUrl
1003
- .sink { url in
1004
- EverframeEventEmitter.sendPairUrl(url)
1005
- }
1006
- .store(in: &bag)
1007
-
1008
- // Companion discovery (spec 2026-08-07). `CompanionState.swift` also
1009
- // posts `.everframeCompanionCodeChange` /
1010
- // `.everframeCompanionAttachedUserNameChange` for UIKit/ObjC hosts, but
1011
- // we take the `@Published` route here for the same reason `$pairUrl`
1012
- // does: a `Published.Publisher` replays its CURRENT value to a new
1013
- // subscriber, so JS still learns a code that was already set by a
1014
- // prior start. NotificationCenter has no replay and posts only on a
1015
- // real change — subscribing there would silently lose exactly the
1016
- // initial values this install-once-and-survive-stop/start design
1017
- // exists to preserve.
1018
- companion.$code
1019
- .sink { code in
1020
- EverframeEventEmitter.sendCode(code)
1021
- }
1022
- .store(in: &bag)
1023
- companion.$attachedUserName
1024
- .sink { name in
1025
- EverframeEventEmitter.sendAttachedUserName(name)
1026
- }
1027
- .store(in: &bag)
1028
-
1029
- // Resolved device display name (naming spec 2026-08-24). Same
1030
- // replay-on-subscribe rationale as `$code`/`$attachedUserName`
1031
- // above — a `Published` publisher replays its CURRENT value to a
1032
- // new subscriber, so JS still learns a name that was already
1033
- // resolved by a prior announce.
1034
- companion.$resolvedName
1035
- .sink { name in
1036
- EverframeEventEmitter.sendResolvedName(name)
1037
- }
1038
- .store(in: &bag)
1039
-
1040
- // Attach-PIN challenge (spec 2026-08-19). Same replay-on-subscribe
1041
- // rationale as `$code`/`$attachedUserName` above — a `Published`
1042
- // publisher replays its CURRENT value to a new subscriber, so a
1043
- // `'custom'`-mode host that starts JS-side listening after the
1044
- // challenge already arrived still learns about it.
1045
- companion.$attachChallenge
1046
- .sink { challenge in
1047
- EverframeEventEmitter.sendAttachChallenge(challenge.map {
1048
- ["code": $0.code, "requestedByName": $0.requestedByName, "ttlMs": $0.ttlMs]
1049
- })
1050
- }
1051
- .store(in: &bag)
1052
-
1053
- // Forward `report.request` arrival to JS so the companion module
1054
- // can acknowledge before the bridge proceeds with native capture
1055
- // (see CompanionCaptureBridge.awaitJsReactTreeAttach). The
1056
- // CompanionCaptureBridge posts this notification at the top of
1057
- // handleReportRequest and then awaits the JS callback up to
1058
- // 250ms before falling through.
1059
- NotificationCenter.default.publisher(
1060
- for: CompanionCaptureBridge.shouldAttachReactTreeNotification
1061
- )
1062
- .sink { note in
1063
- guard let corrId = note.userInfo?["correlation_id"] as? String else { return }
1064
- EverframeEventEmitter.sendReportRequested(corrId)
1065
- }
1066
- .store(in: &bag)
1067
-
1068
- companionLock.lock()
1069
- companionCancellables = bag
1070
- companionLock.unlock()
1071
- os_log("[everframe] installed companion Combine subscriptions",
1072
- log: log, type: .info)
1073
- }
1074
-
1075
- /// Map `CompanionState` to the snake_case payload string the JS facade
1076
- /// expects (`'unpaired' | 'paired' | 'report_in_progress' |
1077
- /// 'phone_disconnected'`). MUST match `EverframeModule.toRnString()` on
1078
- /// Android. Not `@objc` — `CompanionState` is a Swift enum that does not
1079
- /// bridge to ObjC; consumers call this from Swift only (the .mm shim sends
1080
- /// already-stringified payloads to the event emitter).
1081
- public static func rnString(for state: CompanionState) -> String {
1082
- switch state {
1083
- case .unpaired: return "unpaired"
1084
- case .paired: return "paired"
1085
- case .reportInProgress: return "report_in_progress"
1086
- case .phoneDisconnected: return "phone_disconnected"
1087
- }
1088
- }
1089
-
1090
- // MARK: - Test seams (internal access via @testable import)
1091
-
1092
- /// Whether a RelayWSClient is currently owned by the bridge. The test
1093
- /// target uses this to assert that `startCompanion(...)` installs the
1094
- /// client and `stopCompanion()` drops it.
1095
- @objc public static func _hasCompanionClientForTesting() -> Bool {
1096
- companionLock.lock()
1097
- defer { companionLock.unlock() }
1098
- return companionClient != nil
1099
- }
1100
-
1101
- /// Test-only reset for the Combine subscription latch + cancellables —
1102
- /// XCTest creates a fresh bridge state per test method.
1103
- @objc public static func _resetCompanionForTesting() {
1104
- companionLock.lock()
1105
- companionClient?.disconnect()
1106
- companionClient = nil
1107
- companionCancellables.removeAll()
1108
- companionSubscriptionsInstalled = false
1109
- companionAttachPinUi = .builtin
1110
- companionLock.unlock()
1111
- Everframe.shared.companion.__setBuiltinPinUiSuppressed(false)
1112
- }
1113
673
  }