PageSourceSearch

https://motion-staging.fly.dev/assets/dialer/connect_adapter-cdbae5f0.js

js motion-staging.fly.dev collected 2026-10-03 08:29:41 UTC 34,518 bytes, 624 lines download raw bytes

1// Thin facade over amazon-connect-streams (the global `connect`). Everything the dialer state
2// machine touches goes through here, so the SDK can be stubbed in tests and swapped wholesale if the
3// go/no-go spike forces a different mount strategy (e.g. a detached popup window). It owns the three
4// fragile, TRD-flagged concerns:
5//   1. initCCP runs exactly once per page session — Streams does not support re-initialization.
6//   2. the ~700KB library loads lazily, injected as a CLASSIC <script> only when the dialer is used.
7//      (It's a UMD/global bundle — loading it via ESM `import()` breaks its internal refs, e.g.
8//      "publishTelemetryEvent is not defined". A classic script is how AWS ships it.)
9//   3. a single-tab lock (BroadcastChannel + localStorage) so only one tab drives the softphone;
10//      multiple live softphones break Connect's WebRTC.
11
12const OWNER_KEY = "dialer:softphone-owner"
13const LOCK_NAME = "dialer:softphone-lock" // Web Lock the owner HOLDS for its lifetime (throttle-proof liveness)
14const CHANNEL = "dialer-softphone"
15const HEARTBEAT_MS = 4000
16const STALE_MS = 12000 // heartbeat-freshness window — only a FALLBACK where Web Locks is unavailable
17
18export default class ConnectAdapter {
19  constructor() {
20    this.connect = null
21    this.agent = null
22    this.handlers = {}
23    this.currentCallId = null
24    this.currentContactId = null // active Connect contactId — lets a stale registerCall be ignored
25    this.connectedAt = null      // ms timestamp of the current call's connect, for a nav-proof timer
26    this.party = { name: "", number: "" }
27    this.sessionLostPending = false // agent-data auth died mid-call → defer teardown until the call ends
28    this.isOwner = false
29    this.subscribed = false
30    this.ccpExhausted = false // set once a live session's auth is exhausted → next connect reloads the page
31    this.tabConflictNoticed = false // "active in another tab" was auto-shown this page session (don't re-nag on nav)
32    this.channel = null
33    this.heartbeat = null
34    this._releaseOwnershipLock = null // resolves the held Web Lock's promise, freeing it
35    this._lockRequested = false       // guards against requesting the Web Lock twice
36    this.tabId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
37    this._release = this.releaseLock.bind(this)
38  }
39
40  // Bring up the softphone. Returns { started, reason } so the controller can render the right state
41  // (running / "active in another tab" / load failure) without knowing any Streams internals.
42  async init(container, config, handlers = {}) {
43    this.handlers = handlers
44    this.ccpUrl = config.ccpUrl // kept for openLogin() once initCCP has run (it is single-shot)
45
46    if (!(await this.acquireLock())) {
47      handlers.onTabConflict?.()
48      return { started: false, reason: "another-tab" }
49    }
50
51    // Permanent-element Stimulus controllers can re-run connect() on a Turbo morph; never re-init.
52    if (window.__dialerCcpInitialized && window.connect?.core) {
53      this.connect = window.connect
54      this.bindAgentAndContact()
55      return { started: true, reused: true }
56    }
57
58    try {
59      await this.loadStreams(config.streamsUrl) // injects a classic <script>; sets global `connect`
60    } catch {
61      handlers.onError?.(new Error("Couldn't load the softphone library"))
62      return { started: false, reason: "load-failed" }
63    }
64    this.connect = window.connect
65    if (!this.connect?.core) {
66      handlers.onError?.(new Error("amazon-connect-streams failed to load"))
67      return { started: false, reason: "load-failed" }
68    }
69
70    // A simultaneous-open race may have demoted us to non-owner while the (async) library loaded.
71    // Bail before initCCP so we never bring up a second, competing CCP.
72    if (!this.isOwner) {
73      handlers.onTabConflict?.()
74      return { started: false, reason: "another-tab" }
75    }
76
77    this.connect.core.initCCP(container, {
78      ccpUrl: config.ccpUrl,
79      region: config.region,
80      // We drive sign-in ourselves (openLogin) from inside the user's click, so the browser opens a
81      // real POPUP window that we close on success. Streams' built-in loginPopup opens only AFTER an
82      // async load-timeout — outside the click gesture — so the browser downgrades it to a background
83      // tab that lingers (and an unauthenticated CCP iframe just spins on LoadTimeout). Disable it here.
84      loginPopup: false,
85      // Raised from the 3s/5s defaults — the first embedded init can take >5s (see logs), which
86      // otherwise trips a premature login-popup / LoadTimeout on slow loads.
87      ccpAckTimeout: 5000,
88      ccpSynTimeout: 3000,
89      ccpLoadTimeout: 15000,
90      softphone: { allowFramedSoftphone: true, disableRingtone: false },
91      pageOptions: { enableAudioDeviceSettings: true, enablePhoneTypeSettings: true }
92    })
93    window.__dialerCcpInitialized = true
94
95    this.bindAgentAndContact()
96    this.connect.core.onSoftphoneError?.((e) => handlers.onError?.(e))
97    // Session state is driven ONLY by Streams' real auth lifecycle — these fire strictly on genuine
98    // auth failure/recovery, never during a healthy session (unlike a freshness watchdog, which
99    // false-fired during Connect's ~30s idle poll gaps). Semantics:
100    //   onAuthFail            = auth failed, Streams is RETRYING  → "Reconnecting…"
101    //   *RetriesExhausted     = Streams GAVE UP                   → "session expired"
102    //   onAccessDenied        = access revoked (unrecoverable)    → "session expired"
103    //   onAuthorizeSuccess    = re-authorized (or initial connect)→ recovered
104    //
105    // REACTIVE BY DESIGN: Streams exposes NO proactive "logged out / session expired" event — the
106    // embedded CCP is passive and only discovers a dead session when its next AUTHENTICATED request
107    // 401s (its ~30s agent-data poll, or an agent action such as setState/dial). So after a logout
108    // there can be up to a couple minutes of silence with no signal, until something forces an auth'd
109    // request — though ANY agent action detects it instantly. We accept that for now.
110    //
111    // TODO (Option B — proactive staleness backstop, if we want the idle panel to self-correct): add a
112    // low-frequency watchdog that escalates STRAIGHT to "session expired" once the last healthy agent
113    // pulse (onStateChange/onRefresh) is stale beyond ~90-120s. That's 3-4x the ~30s healthy long-poll
114    // gap, so — unlike the earlier 20s watchdog that false-fired during normal idle — it can only trip
115    // on a genuinely dead session, and must escalate ONLY to "expired" (never a spurious "Reconnecting…").
116    // The removed lastAgentPulse / agentPulseAgeMs plumbing (see git history) is the starting point.
117    const authTrouble = (why) => { try { console.info("[dialer] auth trouble via", why) } catch { /* noop */ } ; handlers.onSessionTrouble?.() }
118    const sessionLost = (why) => { try { console.info("[dialer] session lost via", why) } catch { /* noop */ } ; handlers.onAuthFail?.() }
119    this.connect.core.onAuthFail?.(() => authTrouble("onAuthFail"))
120    this.connect.core.onCTIAuthorizeRetriesExhausted?.(() => sessionLost("CTIAuthorizeRetriesExhausted"))
121    this.connect.core.onAuthorizeRetriesExhausted?.(() => sessionLost("AuthorizeRetriesExhausted"))
122    // Fires after the CCP iframe-refresh cap (locally patched from 10 → 2 in the vendored bundle, see
123    // config/importmap.rb) — so a dead-session reconnect gives up after ~2 attempts, not ~2.5 minutes.
124    this.connect.core.onIframeRetriesExhausted?.(() => sessionLost("IframeRetriesExhausted"))
125    this.connect.core.onAccessDenied?.(() => sessionLost("AccessDenied"))
126    this.connect.core.onAuthorizeSuccess?.(() => { try { console.info("[dialer] recovery signal: onAuthorizeSuccess") } catch { /* noop */ } ; handlers.onSessionRestored?.() })
127    return { started: true }
128  }
129
130  // Open (or, via the fixed window NAME, re-focus) the Connect sign-in in a NEW TAB, and KEEP the handle
131  // so closeLogin() can auto-close it once the embedded CCP adopts the session. No size features → the
132  // browser makes it a tab, not a popup. The fixed name means a repeat call (auto-open + the manual button)
133  // targets the SAME tab instead of stacking new ones. Must run inside a click gesture so it isn't blocked;
134  // after the agent signs in there, the hidden iframe's polling detects the session and onAgent fires — no
135  // page reload. Returns true if a tab handle was obtained.
136  // ccpUrl is normally captured in init(), but connectPhone opens the sign-in tab in the click gesture
137  // BEFORE init() runs (init is async and would lose the gesture), so the caller passes the URL directly.
138  // We remember it so a later closeLogin()/openLogin() works even before init has stored it.
139  openLogin(ccpUrl = this.ccpUrl) {
140    this.ccpUrl = ccpUrl || this.ccpUrl
141    if (!this.ccpUrl) { try { console.warn("[dialer] openLogin: no ccpUrl") } catch { /* noop */ } return false }
142    const w = window.open(this.ccpUrl, "connect-login")
143    this.loginWindow = (!w || w.closed) ? null : w
144    return !!this.loginWindow
145  }
146
147  // Close the sign-in popup once the hidden CCP has adopted the new session — avoids a lingering
148  // window and a second, competing CCP. Called on the authenticated (onAgent) signal.
149  closeLogin() {
150    try { this.loginWindow?.close() } catch { /* already gone */ }
151    this.loginWindow = null
152  }
153
154
155  // Load amazon-connect-streams as a classic (non-module) script so its UMD global initializes
156  // correctly. Idempotent: reuses the tag/global if already present.
157  loadStreams(url) {
158    if (window.connect?.core) return Promise.resolve()
159    if (!url) return Promise.reject(new Error("missing streams url"))
160    // Cache the in-flight load so concurrent callers share one request. A FAILED load must remove the
161    // <script> element and clear this cache: otherwise the next connectPhone() hits the "existing" branch
162    // below, re-adds load/error listeners to an element whose events already fired, and the promise never
163    // settles — init() then awaits forever and the panel is stuck on "Connecting…" until a full reload.
164    if (this._streamsLoad) return this._streamsLoad
165    this._streamsLoad = new Promise((resolve, reject) => {
166      const existing = document.getElementById("amazon-connect-streams-lib")
167      if (existing) {
168        if (window.connect) return resolve()
169        existing.addEventListener("load", () => resolve(), { once: true })
170        existing.addEventListener("error", () => { existing.remove(); reject(new Error("streams load error")) }, { once: true })
171        return
172      }
173      const script = document.createElement("script")
174      script.id = "amazon-connect-streams-lib"
175      script.src = url
176      script.async = true
177      script.onload = () => resolve()
178      script.onerror = () => { script.remove(); reject(new Error("streams load error")) }
179      document.head.appendChild(script)
180    })
181    this._streamsLoad.catch(() => { this._streamsLoad = null }) // a failed load must not poison retries
182    return this._streamsLoad
183  }
184
185  bindAgentAndContact() {
186    // Subscribe exactly once — this may be called again when Turbo re-connects the controller on
187    // navigation, and connect.agent/contact would otherwise stack duplicate handlers.
188    if (this.subscribed || !this.connect) return
189    this.subscribed = true
190    this.connect.agent((agent) => {
191      this.agent = agent
192      this.handlers.onAgent?.(agent)
193      agent.onStateChange((state) => this.handlers.onAgentStateChange?.(state))
194      // No agent-level trouble/recovery hooks here, on purpose:
195      //  • agent.onError fires on CALL errors (missed/failed call) — not session trouble.
196      //  • agent.onRefresh fires even on STALE/cached snapshots, so it FALSELY "recovered" a fully
197      //    logged-out session. Recovery is detected ONLY by a genuine core.onAuthorizeSuccess.
198    })
199
200    this.connect.contact((contact) => {
201      this.handlers.onContact?.(contact)
202      contact.onConnecting(() => this.handlers.onConnecting?.(contact))
203      contact.onConnected(() => this.handlers.onConnected?.(contact))
204      contact.onEnded(() => this.handlers.onEnded?.(contact))
205      contact.onDestroy(() => this.handlers.onDestroy?.(contact))
206      contact.onRefresh((c) => this.handlers.onRefresh?.(c))
207    })
208  }
209
210  // ----- call controls (no-ops until an agent/contact exists, so the UI can call them freely) -----
211  //
212  // `locked` is the agent-identity kill switch: when the CCP is signed in as a DIFFERENT agent than the
213  // Motion user is mapped to, the controller sets adapter.locked = true so EVERY telep
213hony action below
214  // (dial, answer, status change, transfer, tab takeover) becomes an inert no-op — the wrong agent must
215  // not be drivable from Motion at all. The controller forces Offline before locking (see
216  // blockForAgentMismatch), and clears the lock once a matching agent signs in.
217
218  dial(e164Number, { queueARN } = {}) {
219    if (this.locked || !this.agent || !this.connect) return
220    const endpoint = this.connect.Endpoint.byPhoneNumber(e164Number)
221    const params = { failure: (err) => this.handlers.onError?.(err) }
222    if (queueARN) params.queueARN = queueARN // else Connect uses the routing profile's default outbound queue
223    this.agent.connect(endpoint, params)
224  }
225
226  accept() { if (this.locked) return; this.currentContact()?.accept() }
227  reject() { if (this.locked) return; this.currentContact()?.reject() }
228  clearContact() { if (this.locked) return; this.currentContact()?.clear() } // completes ACW so the agent returns routable
229  hangup() { if (this.locked) return; this.currentConnection()?.destroy() }
230  hold() { if (this.locked) return; this.currentConnection()?.hold() }
231  resume() { if (this.locked) return; this.currentConnection()?.resume() }
232
233  // Connect's ACTUAL hold state for the live customer leg — read straight from the connection, never
234  // our optimistic UI flag. The controller reconciles the hold button against this on every onRefresh,
235  // so a resume()/hold() that silently no-ops (e.g. the agent-data conduit died mid-call) can't leave
236  // the UI claiming the opposite of what the customer hears.
237  isOnHold() {
238    try { return !!this.currentConnection()?.isOnHold?.() } catch { return false }
239  }
240
241  // Does THIS CCP hold the live softphone (WebRTC) audio for the contact? amazon-connect-streams keeps
242  // one RTCSession per active softphone call on the SoftphoneManager, keyed by contactId — a tab that
243  // ADOPTED an in-progress contact has the manager but NO session for it (WebRTC media never migrates).
244  // This is the DIRECT signal for "is there audio here", used to override the ring/dial heuristic so an
245  // inbound AUTO-ANSWER call (no connecting/accept event) isn't mislabeled "another window".
246  // Returns true ONLY on positive confirmation; null on any uncertainty (no manager yet, unknown shape,
247  // or a session that may still be negotiating) so callers fall back to their heuristic and a not-yet-up
248  // session is never read as "no audio". Field name (_rtcSessions) has been stable in streams for years;
249  // guarded so a shape change degrades to null, not a throw.
250  hasLocalMedia(contact) {
251    try {
252      const contactId = contact?.getContactId?.()
253      if (!contactId) return null
254      const mgr = this.connect?.core?.getSoftphoneManager?.()
255      if (!mgr) return null
256      const sessions = mgr._rtcSessions || mgr.rtcSessions
257      if (!sessions) return null
258      if (sessions[contactId]) return true
259      // Some builds key sessions by connectionId; match on the session's own callId/contactId instead.
260      const match = Object.values(sessions).some(
261        (s) => s && (s.callId === contactId || s.contactId === contactId || s._callId === contactId)
262      )
263      return match ? true : null // absence ⇒ UNKNOWN (maybe still negotiating), never a hard "no media"
264    } catch { return null }
265  }
266
267  mute() { if (this.locked) return; this.agent?.mute() }
268  unmute() { if (this.locked) return; this.agent?.unmute() }
269  sendDigits(digits) { if (this.locked) return; this.currentConnection()?.sendDigits(digits) }
270  setAvailable() { this.setAgentState("Available") }
271  setOffline() { this.setAgentState("Offline") }
272
273  // The agent's SELECTABLE Connect states for the status menu — Available + any custom not-routable
274  // statuses + Offline — as [{ name, type }] (type is a connect.AgentStateType constant). Empty until
275  // the agent is bound; the UI keeps its static fallback menu until this returns a real list.
276  agentStates() {
277    const agent = this.freshAgent()
278    const states = agent?.getAgentStates?.() || []
279    return states.filter((s) => s?.name).map((s) => ({ name: s.name, type: s.type }))
280  }
281
282  // The agent's CURRENT state name (for marking the active menu row). Null until bound.
283  currentStateName() {
284    try { return this.freshAgent()?.getState?.()?.name || null } catch { return null }
285  }
286
287  // The live agent snapshot. A reference captured once from connect.agent() can go stale for
288  // getAgentStates()/setState once the routing profile finishes syncing — setState then silently
289  // no-ops, which is why setting Available only worked when re-fetching the agent by hand from the
290  // console. new connect.Agent() always returns the agent bound to Streams' current store; fall back to
291  // the stored ref only if it can't be constructed yet.
292  freshAgent() {
293    try { if (this.connect?.Agent) return new this.connect.Agent() } catch { /* not initialized yet */ }
294    return this.agent
295  }
296
297  // Ask Connect to change the agent state. Reports success AND failure so the UI reflects only what
298  // Connect actually accepted (never a phantom "Available" over an un-routable agent).
299  setAgentState(name, { onSuccess, onFailure } = {}) {
300    if (this.locked) return // identity mismatch → the agent's status can't be changed from Motion
301    const agent = this.freshAgent()
302    const states = agent?.getAgentStates?.() || []
303    // Only "Available" is routable; every other status is simply "not routable". If a requested away
304    // status (e.g. Lunch/Break) doesn't exist on this instance, gracefully fall back to the always-
305    // present Offline so the agent still lands un-routable instead of erroring — the dialer works even
306    // on an instance that only has the two built-in statuses (Available + Offline).
307    const state = states.find((s) => s.name === name) ||
308                  (name !== "Available" ? states.find((s) => s.name === "Offline") : null)
309    if (!state) { onFailure?.(new Error(`state "${name}" not available yet, reconnect your phone`)); return }
310    agent.setState(state, {
311      success: () => onSuccess?.(),
312      failure: (e) => onFailure?.(e)
313    })
314  }
315
316  currentContact() {
317    return this.agent?.getContacts?.(this.connect.ContactType.VOICE)?.[0]
318  }
319
320  currentConnection() {
321    const contact = this.currentContact()
322    return contact?.getActiveInitialConnection?.() || contact?.getAgentConnection?.()
323  }
324
325  // ----- transfer / quick connects (F8) -----
326
327  // The agent's quick connects — transfer destinations during a call AND speed-dial numbers when idle
328  // (F4.1, one cache two uses). Passes the routing-profile queues + the default outbound queue so both
329  // team and outbound quick connects come back. Each live connect.Endpoint is flattened to a plain
330  // descriptor (with `.raw` kept for the SDK calls that need the original), so the state machine and its
331  // tests never touch a `connect` global.
332  getEndpoints({ success, failure } = {}) {
333    const agent = this.freshAgent()
334    if (!agent?.getEndpoints) { failure?.(new Error("agent not ready")); return }
335    let arns = []
336    try {
337      arns = agent.getAllQueueARNs?.() || []
338      const outbound = agent.getRoutingProfile?.()?.defaultOutboundQueue?.queueARN
339      if (outbound && !arns.includes(outbound)) arns = arns.concat(outbound)
340    } catch { /* fall through with whatever ARNs we have */ }
341    agent.getEndpoints(arns, {
342      success: (data) => success?.((data?.endpoints || data?.addresses || []).map((e) => this.describeEndpoint(e))),
343      failure: (err) => failure?.(err)
344    })
345  }
346
347  // Flatten a live connect.Endpoint to a plain, serializable descriptor. `type` is "agent" | "queue" |
348  // "phone_number"; `raw` is the original endpoint (addConnection / agent.connect need the live object).
349  describeEndpoint(endpoint) {
350    return {
351      type: endpoint.type,
352      name: endpoint.name || "",
353      phoneNumber: endpoint.phoneNumber || "",
354      endpointARN: endpoint.endpointARN || endpoint.endpointId || "",
355      raw: endpoint
356    }
357  }
358
359  // A phone-number endpoint for an external transfer or free-text dial (needs no Connect config, D3).
360  phoneEndpoint(e164) { return this.connect?.Endpoint?.byPhoneNumber(e164) }
361
362  // Add the transfer destination as a third connection. This AUTO-HOLDS the customer, and NOTHING
363  // un-holds them automatically — every exit must call resumeCustomer() (TRD §2.3.3, the on-hold trap).
364  // `endpoint` must be a live object: a phoneEndpoint(), or the `.raw` from a getEndpoints() descriptor.
365  addConnection(endpoint, { success, failure } = {}) {
366    if (this.locked) return
367    const contact = this.currentContact()
368    if (!contact) { failure?.(new Error("no active contact")); return }
369    try { console.info("[dialer/transfer] addConnection →", endpoint?.type, endpoint?.name || endpoint?.phoneNumber, endpoint?.endpointARN || endpoint?.endpointId) } catch { /* diag */ }
370    contact.addConnection(endpoint, {
371      success: () => { try { console.info("[dialer/transfer] addConnection OK — Connect accepted the leg; watch legs for the destination") } catch { /* diag */ } success?.() },
372      failure: (e) => { try { console.error("[dialer/transfer] addConnection FAILED —", e) } catch { /* diag */ } failure?.(e) }
373    })
374  }
375
376  conferenceConnections({ success, failure } = {}) {          // Join → 3-way
377    if (this.locked) return
378    this.currentContact()?.conferenceConnections({ success: () => success?.(), failure: (e) => failure?.(e) })
379  }
380
381  toggleActiveConnections({ success, failure } = {}) {        // Swap which party the agent is talking to
382    if (this.locked) return
383    this.currentContact()?.toggleActiveConnections({ success: () => success?.(), failure: (e) => failure?.(e) })
384  }
385
386  // Drop the agent's own leg — customer and destination stay connected (warm complete / cold hand-off).
387  completeTransfer() { if (this.locked) return; this.currentContact()?.getAgentConnection?.()?.destroy() }
388
389  // Tear down the transfer destination (cancel / recover). Pair with resumeCustomer() — see below.
390  cancelThirdConnection() { if (this.locked) return; this.thirdConnection()?.destroy() }
391
392  // The transfer-destination connection in ANY state (connecting or connected), so the dialing→consulting
393  // detection works before the destination answers. Filtering getConnections() covers every state;
394  // getSingleActiveThirdPartyConnection() only returns already-active ones (kept as a hint, TRD §2.3.1).
395  thirdConnection() {
396    const contact = this.currentContact()
397    if (!contact) return null
398    // Identify the third leg by connectionId, NOT object identity. Streams returns a FRESH Connection
399    // wrapper on every getConnections()/getAgentConnection()/getInitialConnection() call, so `c !== agentC`
400    // never excludes the agent/customer legs — the old code returned the agent's OWN (already-connected)
401    // leg as the "third party", flipping the UI to consulting/"Speaking" the instant you dial (before the
402    // destination ever rings) and pointing swap/cancel at the wrong connection.
403    const agentId = contact.getAgentConnection?.()?.getConnectionId?.()
404    const initialId = contact.getInitialConnection?.()?.getConnectionId?.()
405    const conns = contact.getConnections?.() || []
406    const third = conns.find((c) => {
407      const id = c.getConnectionId?.()
408      return id != null && id !== agentId && id !== initialId
409    })
410    return third || contact.getSingleActiveThirdPartyConnection?.() || null
411  }
412
413  // Take the customer OFF hold. addConnection held them and destroying the third party does NOT un-hold
414  // them — the single most likely way to strand a customer in silence (TRD §2.3.3). Idempotent.
415  resumeCustomer() {
416    if (this.locked) return
417    const initial = this.currentContact()?.getInitialConnection?.()
418    if (initial?.isOnHold?.()) initial.resume()
419  }
420
421  // Plain-boolean snapshot of the transfer connections for the state machine to inspect on onRefresh —
422  // no live objects, so the machine stays unit-testable against a fake.
423  transferSnapshot() {
424    const contact = this.currentContact()
425    const third = this.thirdConnection()
426    const snap = {
427      hasThird: !!third,
428      thirdConnected: !!third?.isConnected?.(),
429      thirdActive: !!third?.isActive?.(),
430      customerOnHold: !!contact?.getInitialConnection?.()?.isOnHold?.()
431    }
432    // Diagnostic (only runs while a transfer is in progress — the controller calls this on onRefresh only
433    // then). Shows exactly what Streams reports for every leg, so a transfer that never rings the
434    // destination is visible: the destination leg should appear and progress connecting → connected.
435    try {
436      const legs = (contact?.getConnections?.() || []).map((c) => ({
437        id: c.getConnectionId?.(), type: c.getType?.(), status: c.getStatus?.()?.type,
438        connected: c.isConnected?.(), connecting: c.isConnecting?.(), hold: c.isOnHold?.(), active: c.isActive?.()
439      }))
440      console.info("[dialer/transfer]", JSON.stringify(snap), "legs=", JSON.stringify(legs))
441    } catch { /* diagnostics only — never break the snapshot */ }
442    return snap
443  }
444
445  // ----- single-tab lock -----
446
447  // Become the softphone owner unless another LIVE tab already is. Liveness is judged by the Web Lock the
448  // owner holds (see ownedByLiveTab) — robust against background-tab timer throttling, which can stall the
449  // heartbeat and make a live owner's key look falsely stale. Async because the Web Locks query is async.
450  async acquireLock() {
451    const existing = this.readOwner()
452    if (existing && existing.tabId !== this.tabId && await this.ownedByLiveTab(existing)) {
453      this.openChannel() // so we still hear the owner's "release"/"take-over" and can react
454      this.watchForRelease()
455      return false
456    }
457    this.writeOwner()
458    this.isOwner = true
459    this.holdOwnershipLock() // hold the Web Lock so other tabs can see we're the live owner
460    this.startHeartbeat()    // keep the key fresh (carries our tabId; also the no-Web-Locks fallback signal)
461    this.openChannel()
462    window.addEventListener("beforeunload", this._release)
463    this.channel?.postMessage({ type: "claim", tabId: this.tabId }) // let a simultaneous owner tie-break
464    return true
465  }
466
467  // Is the current owner key backed by a tab that's actually still alive? Prefer the Web Locks API: the
468  // owner holds LOCK_NAME for its lifetime and the browser reports it held even while that tab is
469  // backgrounded/throttled, and frees it the instant the tab closes or crashes. Only where Web Locks is
470  // unavailable do we fall back to heartbeat freshness (the old, throttling-prone signal).
471  async ownedByLiveTab(existing) {
472    const held = await this.ownerLockHeld()
473    if (held !== null) return held
474    return Date.now() - existing.ts < STALE_MS
475  }
476
477  async ownerLockHeld() {
478    try {
479      if (!navigator.locks?.query) return null
480      const { held } = await navigator.locks.query()
481      return (held || []).some((l) => l.name === LOCK_NAME)
482    } catch { return null }
483  }
484
485  // Hold LOCK_NAME until we relinquish. The request promise stays pending so the browser keeps the lock
486  // ours; it is released either explicitly (releaseOwnershipLock) or automatically when this tab dies.
487  holdOwnershipLock() {
488    try {
489      if (!navigator.locks?.request || this._lockRequested) return
490      this._lockRequested = true
491      navigator.locks.request(LOCK_NAME, () => new Promise((release) => { this._releaseOwnershipLock = release }))
492    } catch { this._lockRequested = false /* Web Locks unavailable — heartbeat fallback covers liveness */ }
493  }
494
495  releaseOwnershipLock() {
496    try { this._releaseOwnershipLock?.() } catch { /* already released */ }
497    this._releaseOwnershipLock = null
498    this._lockRequested = false
499  }
500
501  // Explicit user takeover ("Use dialer in this tab"): seize ownership from whoever holds it and tell them
502  // to stand down. Unlike acquireLock this does NOT check liveness — the user has decided this tab wins.
503  // The old owner receives "take-over" and reloads (the reliable way to tear a CCP down).
504  forceClaim() {
505    if (this.locked) return // mismatched agent → don't let this tab seize/activate the softphone
506    this.openChannel()
507    this.channel?.postMessage({ type: "take-over", tabId: this.tabId })
508    this.writeOwner()
509    this.isOwner = true
510    this.holdOwnershipLock()
511    this.startHeartbeat()
512    window.addEventListener("beforeunload", this._release)
513  }
514
515  // Ask-before-claim: request the softphone from the current owner rather than seizing it. The owner
516  // GRANTS unless it's on a live call (then it DENIES and keeps the call) — so a takeover can never
517  // reload the owner out from under an active customer, stranding them on hold. The answer arrives
518  // async via onTakeOverGranted / onTakeOverDenied; the controller also times out to a forceClaim if
519  // the owner never answers (crashed/closed → nothing live to protect).
520  requestTakeOver() {
521    if (this.locked) return // mismatched agent → don't let this tab seize/activate the softphone
522    this.openChannel()
523    this.channel?.postMessage({ type: "take-over-request", tabId: this.tabId })
524  }
525
526  readOwner() {
527    try { return JSON.parse(localStorage.getItem(OWNER_KEY)) } catch { return null }
528  }
529
530  writeOwner() {
531    try { localStorage.setItem(OWNER_KEY, JSON.stringify({ tabId: this.tabId, ts: Date.now() })) } catch { /* private mode */ }
532  }
533
534  startHeartbeat() {
535    if (this.heartbeat) return // already beating (e.g. forceClaim then autoReconnect both acquire)
536    this.heartbeat = setInterval(() => { if (this.isOwner) this.writeOwner() }, HEARTBEAT_MS)
537  }
538
539  openChannel() {
540    if (this.channel || typeof BroadcastChannel === "undefined") return
541    this.channel = new BroadcastChannel(CHANNEL)
542    this.channel.onmessage = (e) => {
543      const msg = e.data
544      if (!msg || msg.tabId === this.tabId) return
545      switch (msg.type) {
546        case "claim":
547          // Announce our ownership so a tab that acquired the lock at the same instant can arbitrate.
548          if (this.isOwner) this.channel.postMessage({ type: "owned", tabId: this.tabId })
549          break
550        case "owned":
551          // Two tabs both think they own the softphone (a simultaneous-acquire race). Break the tie
552          // deterministically: the LOWER tabId keeps ownership, the higher one relinquishes — so we
553          // never run two live CCPs (which corrupts Connect's WebRTC).
554          if (this.isOwner) {
555            if (this.tabId < msg.tabId) this.writeOwner() // we win — reassert the lock key now
556            else this.relinquishLock()                    // we lose — hand off to the lower tabId
557          }
558          break
559        case "take-over":
560          // Another tab is explicitly taking the softphone. If we're the owner, step down HARD so we
561          // never run two live CCPs — the controller reloads to tear our CCP down.
562          if (this.isOwner) { this.relinquishLock(); this.handlers.onTabTaken?.() }
563          break
564        case "take-over-request":
565          // A tab is politely asking to take the softphone. Only the owner answers. Deny while we're on
566          // a live call (onTakeOverRequest → true) so we don't reload and strand the customer; otherwise
567          // grant and step down exactly like the unconditional take-over above.
568          if (!this.isOwner) break
569          if (this.handlers.onTakeOverRequest?.()) {
570            this.channel.postMessage({ type: "take-over-denied", to: msg.tabId, tabId: this.tabId })
571          } else {
572            this.channel.postMessage({ type: "take-over-granted", to: msg.tabId, tabId: this.tabId })
573            this.relinquishLock()
574            this.handlers.onTabTaken?.()
575          }
576          break
577        case "take-over-granted":
578          if (msg.to === this.tabId) this.handlers.onTakeOverGranted?.()
579          break
580        case "take-over-denied":
581          if (msg.to === this.tabId) this.handlers.onTakeOverDenied?.()
582          break
583        case "release":
584          this.handlers.onTabReleased?.()
585          break
586      }
587    }
588  }
589
590  // Give up ownership: stop the heartbeat, free the Web Lock, drop our unload listener, tell the UI, and
591  // start watching for the (new) owner to eventually release. Used for the tie-break loser AND take-over.
592  relinquishLock() {
593    if (!this.isOwner) return
594    this.isOwner = false
595    clearInterval(this.heartbeat); this.heartbeat = null
596    this.releaseOwnershipLock()
597    window.removeEventListener("beforeunload", this._release)
598    this.handlers.onTabConflict?.()
599    this.watchForRelease()
600  }
601
602  // A non-owner tab watches for the owner releasing the lock, so it can offer to take over.
603  watchForRelease() {
604    if (this._watchingRelease) return // idempotent — acquireLock can fail more than once across navs
605    this._watchingRelease = true
606    window.addEventListener("storage", (e) => {
607      if (e.key === OWNER_KEY && !e.newValue) this.handlers.onTabReleased?.()
608    })
609  }
610
611  releaseLock() {
612    if (this.isOwner) {
613      try { if (this.readOwner()?.tabId === this.tabId) localStorage.removeItem(OWNER_KEY) } catch { /* ignore */ }
614      this.channel?.postMessage({ type: "release", tabId: this.tabId })
615    }
616    this.releaseOwnershipLock()
617    // Null both refs after tearing them down. The adapter is a window singleton, so a later
618    // acquireLock() would see a truthy this.heartbeat (startHeartbeat early-returns → stale owner key)
619    // and a CLOSED this.channel (openChannel early-returns → no more takeover messages). releaseLock
620    // also runs on beforeunload, which fires on a CANCELED navigation, so this state is reachable.
621    clearInterval(this.heartbeat); this.heartbeat = null
622    this.channel?.close(); this.channel = null
623  }
624}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.