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