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.