diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..737f0b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +stage.ps1 text eol=lf +test/fixtures/windows-stage/stage-before-run-directory.ps1 -text +test/fixtures/windows-stage/stage-before-pid-cleanup.ps1 -text +test/fixtures/windows-stage/stage-before-restart-preflight.ps1 -text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67ebd96 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: Node.js CI + +on: + push: + branches: ['**'] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Node 24 / types, tests, build + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js 24 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + package-manager-cache: false + - name: Install locked dependencies + run: npm ci --no-audit --no-fund + - name: Check types + run: npm run typecheck + - name: Run tests + run: npm test + - name: Build all packages + run: npm run build + - name: Check built server delivery + run: npm run test:server-package + windows_scripts: + name: Windows / PowerShell startup + runs-on: windows-2025 + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js 24 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + package-manager-cache: false + - name: Check Windows startup scripts + shell: pwsh + run: node --test --test-timeout=120000 test/windowsStageStart.test.ts diff --git a/PLAN.md b/PLAN.md index 05a2aa7..f8a8998 100644 --- a/PLAN.md +++ b/PLAN.md @@ -820,3 +820,103 @@ N 路混音管線(decode→mix→encode)、不含自己的台上混音、lim 215. **最小 embed 明確確認關房(第194批,2026-10-16):** immutable193完整embed+SDK→validator→Hub十一例證明Leave只disconnect並保留controller grace/succession,既有closeRoom由controller判定且終止全房;功能只補獨立native danger button與全員離場/不可再加入的明確confirm,不改Leave。close自有client/room/pid/entry/tenure owner、revision/authority fence與exact attempt,確認前先reserve,render/dialog/SDK重入皆重查;觀測失權/換席/重連/能力消失永久撤銷當次確認,恢復只允許fresh click。own role/transferred先於state時先標記close與transfer兩側authority fence再render,避免其中一側setter重入未封鎖另一側;stage/mute/rename/無關participant不撤銷room-level consent。與transfer只互斥同步確認流程,marker跨owner退役保留至原dialog返回,不擴成全命令鎖。發送後不retry、不本機close/disconnect或ACK成功推論;terminal-before-heldACK可令promise disconnected而終止文案仍正確,新entry不受舊event/finally污染。錯誤限同owner/token/仍有效且feedback revision未變,SDK全域wire error不改。10檔範圍含3個inline fixture各加一個required selector,原assertions不弱化。無SDK/server/protocol/media/config/依賴變更、無真房間操作;完整controlled證據不代表native dialog/layout/keyboard/AT/硬體音訊驗收。 + + +216. **Widget 明確確認關房(第195批,2026-10-16):** 以PR22已合併main為parent,tree與immutable194基線相同;完整StageElement+公開SDK→validator→Hub十四例證明Leave保留controller grace/succession,而既有closeRoom只允許目前主控並終止全房。僅element原生獨立關房按鈕/說明與明確confirm,不改Leave、SDK或server。close自有client/room/pid/generation/tenure owner、exact attempt與error revision,實際shadow/actions/button containment及connected檢查防保留handler;觀測失格不讓同一次確認復活。own role/transferred先於state時,兩側pure authority notes在任何DOM sync前標記,避免舊getter讓同步重入越過資格;收到失格state或reconnecting/disconnected時也先撤銷舊確認,避免早期setter恢復snapshot使舊consent復活,原移交新tenure接納與target政策不變。關房與移交只互斥同步接納/確認,transfer使用可巢狀exact token集合,保留舊移交對話中換席後新移交的既有語義與preadmission reconciliation;不加等待ACK全命令鎖。錯誤限有效同owner/token且無較新feedback,舊finally不清新pending;不本機close、disconnect、成功提示或retry,terminal truth沿用原事件。五檔範圍為element源碼/測試及三份文件,無SDK/server/protocol/media/config/依賴變更、無真實房間操作。完整受控驗證不等於原生dialog/layout/keyboard/focus/AT或硬體音訊驗收。 + + +217. **Widget 名稱編輯器等值屬性寫入保護(第196批,2026-10-16):** published195完整元件、公開SDK/編輯按鈕→validator→Hub固定120次合法rename/roomName操作,兩元件各自本人/房名編輯器寫入5800→1560,省略4240次等值hidden/disabled/aria-busy,340次必要變更保持。僅四個既有sync/retire方法使用live DOM inequality guards,退役沿原鏈式順序先update.disabled再input.disabled;disabled與busy表達式在原本前序寫入之後求值。保留全部accepted textContent與draft value賦值、每次sync/owner/attempt/資格/authority fence、事件順序與error政策,不做signaturecache或skip sync。失敗必要寫入下次重試,宿主屬性破壞修復;pending ACK/錯誤/重連/失權與close/transfer早期fence回歸。等值setter呼叫減少且新增DOMread是明確可觀測差異,相關attribute mutation records可能減少但未量測native observer callback批次,不承諾任意宿主getter/setter副作用等價,也不宣稱native CPU/layout/focus/IME/AT或硬體音訊改善。無SDK/server/protocol/media/依賴變更。 + + +218. **Widget 房間席位摘要(第197批,2026-10-16):** immutable196完整StageElement+實際SDK→validator→Hub五例證明無房間席位顯示且容量語義不對稱:maxAudience限制全部非主控成員,maxSpeakers只限非主控台位,audienceCount不含台下主控。新增單一只讀原生paragraph,以公開state計算非主控總席位/上限、非主控台位/上限、觀眾與另計主控位置;不取私有名單、名稱、代碼或tokens,hands不重複計數、offline保留席位不過濾。只從當前權威state接受驗證後primitive projection,role/stage/hand/quality通知不推算;重連標示同owner上次快照,status單獨回復不當fresh。終止/斷線/離房/卸載/換client或席位先清空且隱藏,守generation與同步回呼替換,無舊owner復活。非法數值/上限/controller結構或超cap僅隱藏摘要,不擴SDK/server驗證或改其他控制。無新公有API/part/live-region/timer/網路命令/media操作,五檔範圍element源碼/測試+三文件。席位快照可能合併延後,不代表在線/listeners/音訊可聽或即時入場承諾;原生layout/AT/硬體音訊未驗收。 + + +219. **最小 embed 房間席位摘要(第198批,2026-10-16):** immutable197完整embed+公開SDK→validator→Hub五例確認缺少席位顯示、容量非對稱與舊terminal在新entry之後抵達。新增單一原生paragraph,非主控room=A+S、stage=S,各比既有maxAudience/maxSpeakers;主控額外席位與台上/下分列,hands不重算、offline grace不濾除。只在當前entry/client/controls/generation且detail等於實際state時接受安全primitive投影,status/delta/ACK不造fresh;重連保留同owner上次快照,receipt identity防舊object重放。取消/離房/新entry/終止先pure retirement;新paragraph setter必須位於close/transfer與entry權限撤銷/核對之後,每次setter後守revision/client/controls/generation/status/seat,舊continuation不得清新entry。僅局部必要entry/terminal排序,不作通用生命週期重寫。非法count/cap/controller結構清空摘要、不clamp或更改SDK/state/其他controls。10檔限定HTML/embed源碼/新測試/兩sharedfixtures/三inline fixture exactnode addition/README/PLAN,舊assertions不弱化;無SDK/server/widget/styles/media/config/依賴變更、無新command/timer/network。數字是可能延後的席位快照,非online/listeners/audible或入場保證;native layout/keyboard/AT與硬體音訊未驗收。 + + +220. **最小 embed 本人連線品質摘要(第199批,2026-10-16):** immutable198 完整 embed 與實際 SDK→validator→Hub 證明品質回報已抵達但無呈現入口。新增單一普通 paragraph,僅複製目前 entry/client/controls/room/participant 已入台觀察窗口的本人數值;非法或缺失欄位顯示破折號,缺少本人或重複本人項清空,不讀他人指標或私有資料。state 與 quality receipt identity 記錄包含拒絕事件,舊 object 不重放;重連、本人 audience role/stage:left、離房/終止/替換先 pure retirement,distinct 當前權威 state 與後續新 quality 才重新顯示。status/stagejoined/role 不自行恢復;同席位普通快照與保持台位的移交保留歷史,micerror/micready/mute/retry 不冒充傳輸歷史失效或恢復。新 DOM 位於既有 close/transfer 權限核對之後,每次變更 setter 後驗證 revision/owner/entry/status/identity/onStage,較新同步回呼優先,靜默資格變化先撤銷兩種 consent 再修正顯示。10 檔限定 embed HTML/源碼/新測試/五處 fixture 與 README/PLAN,不改 SDK/server/widget/protocol/media/styles/config/依賴;無新輪詢、timer、命令或媒體操作。回報無 timestamp/epoch,「最近收到」不保證量測新鮮度、擷取/播放成功或可聽,RTT 非端到端延遲。原生瀏覽器/鍵盤/AT/硬體音訊尚未驗收。 + + +221. **Widget 本人品質摘要生命週期隔離(第200批,2026-10-16):** immutable199 真 StageElement/SDK→Hub 證明本人 role→stage:left→state 前序通知中舊品質仍可見/被重放復活,且 reconnect retained state 可藉 status-only 與拒絕事件重放恢復。限定 widget quality 的 pure admission retirement、report-only clear 與 rendering 分離;本人 audience role/stageleft、重連/斷線/終止、離房換席撤銷資格,只有 distinct 當前權威 state 再加新 quality 才顯示,拒絕與接納 receipt identity 均記錄。保持 micerror 只清報告、下一份新 quality 不必等 state;保持同 client 合法 terminal 後同 ID 重入、stage-preserving transfer、duplicate-own 最後完整項、數值格式/原始 stage-quality 轉送及本人 primitive 隔離。保留原品質段落 text replacement 與同文字宿主子節點修復,不作等值寫入效能優化。manual leave 先 pure retirement,等 current=null 與既有權限撤銷後才 paint;每個 DOM 邊界驗證當前 ownership,含 nested 新 report 後又靜默失權的 tail,較新合法 entry 不被舊 continuation 覆寫。兩種 close/transfer consent 在修正 DOM 前撤銷,不作一般 pending/SDK 生命週期重寫。五檔 element 源碼/測試+三份文件,無 SDK/server/protocol/media/API/依賴變更或額外 timer/poll/request。事件無 timestamp/peer epoch,object replay 防護不代表線路量測 freshness;原生瀏覽器/AT/硬體音訊未驗收。 + + +222. **最小 embed 公開發言回報摘要(第201批,2026-10-16):** immutable200 完整 embed/真 SDK→validator→Hub 證明無 speaking 呈現、所有角色皆收既有回報、初次/恢復 state→speaking→ACK 與 reconnect retained getter。新增單一普通 speaking-summary paragraph,公開台上名稱/人數依 roster 順序顯示,waiting/raw empty/nonempty unmatched 分開,不依 connected/mute/capture 推斷發言。只保留安全 public ID/name primitives、已匹配 IDs 與 raw-empty 標誌,未知/離台 IDs 不延後復活;新 state 可更新名稱/刪除但不重加舊活動。本人或他人 stageleft/audience role 純移除並暫時抑制 ID,distinct 當前有效權威 state 才核對解除,仍須之後新 speaking 才重加;不撤銷本人作為觀眾的閱讀資格或其他發言者。state/report 拒絕 receipt 也記錄,status/getter/舊 object 不重開;本摘要的 malformed envelope/roster/getter projection failure 回 waiting,不能冒充沉默;不承諾既有其他控制能處理畸形公開 snapshot,其原有例外邊界不擴修。projection 完成且 revision 有效才套用;entry/terminal/reconnect pure retirement 先於 DOM,close/transfer 權限先核對,每次 setter/getter tail 依目前 owner 調和,nested 新回報後靜默失權不留下舊資料或復活 consent,新合法 entry 優先。10 檔 embed HTML/源碼/新測試/五處 fixture 加兩文件,不改 SDK/server/widget/media/styles/protocol/config/依賴,不增 timer/poll/command/media。無 timestamp/epoch,最近收到非即時可聽或音訊恢復保證;native layout/keyboard/AT/硬體音訊未驗收。 + + +223. **Widget 發言摘要生命週期隔離(第202批,2026-10-16):** immutable201 真按鈕→SDK→validator→Hub 的 active-media 路徑 role→speaking→stageleft→state→ACK 在 role callback 仍顯示剛下台本人;另分開 isolated delta/synthetic 新回報與重放證明 stale roster 再接納、prestate/reconnect rejected receipt 與 status-only 缺口,不混淆證據來源。限定 speaking owner/receipt/ID suppression,重連/終止/換席先 pure retire,distinct 當前權威 state 再新 speaking 才恢復;拒絕 receipt 也記錄。保留 SDK 新身分 state-before-status 時非接納的 WAITING placeholder,原測試不弱化;合法同 client terminal 後同 ID 重入可恢復。本人/他人 audience-role/stageleft 純移除並抑制 ID,新的有效權威 state 才核對解除,舊集合不自動重加;所有角色 readership 與其餘 ID 保持。保留 admitted owner 原 live roster 名稱映射/永久刪除、原文案/empty/malformed sparse array 規則、detail 原樣轉送、connected grace、micerror 不清 speaking、原 text replacement/同文字子節點修復與普通 setter 次數,不套用 embed 不同顯示政策。已知失效在可重入 getter 前純處理,Close/Transfer 權限早於 DOM;獨立重現 terminal helper getter→nested speaking paint 可先觸發舊控制,故限既有兩 authority helper 的 known-delta 純階段提前,使用已保存身分、原 paired sites 後再 reconcile,保留新 state/new consent 的較新 tenure,不新增 action policy。nested 新報告後靜默失權的 tail 依目前 owner 調和,新合法 tenure 優先。leave 先 pure retire、current=null 與既有權限撤銷後 paint,不作廣泛生命週期重寫。五檔 element 源碼/測試+三文件,無 SDK/server/media/protocol/API/依賴變更或新增 timer/poll/command。歷史 VAD 無 timestamp/epoch,非即時可聽/沉默/恢復證明;原生 browser/AT/實體音訊未驗收。 + + +224. **Widget 手動本人連線統計(第203批,2026-10-16):** immutable202 完整 widget/實際 SDK 證明既有 getStats 無 widget 入口且觀眾不具 publisher 品質摘要。新增獨立 connection-stats part、明確按鈕與普通文字,所有有效席位可單次讀取 receive loss/RTT/buffer 與 send loss/RTT;無輪詢、timer、自動讀取/重試、控制命令、媒體或權限操作。沿用 SDK 每方向最後符合 RTP 與共用差值基準,標示上次手動結果/SDK 取樣區間,非全串流聚合、按鈕間隔或端到端音訊延遲。只保存有限數值 primitive,缺失/非法 metric 為破折號,缺少方向為暫無;畸形 envelope/direction 或 throwing getter 固定局部失敗,保留歷史,不寫全域錯誤/quality/speaking。方法 getter 前 reserve attempt,lookup/call/settlement/projection/每個新 DOM setter 後核對 owner/revision/status/seat,拒 hidden/disabled/detached controls、舊 promise 與 stale finally。重連/終止/離房/卸載/換席先純退役;新的明確 click 可在有效席位恢復後讀取,不需 passive distinct state;普通角色/台位/mic 變更保留。native promise 不可取消,retained peer 遲到完成可能推進共用 SDK 基準,不改 SDK。Close/Transfer 權限先於修正 DOM;獨立重現 Leave/rejoin legacy setter 重入,新增僅本功能 departure admission/paint fence,直到既有 teardown 完成,不廣泛改生命週期或原權限政策。五檔 element 源碼/測試+三文件;新增100例,原測試完整保留,actual SDK 取樣/單位/共享基準與 lifecycle/consent/負面控制納入驗證。無 SDK/server/media/protocol/config/依賴變更;原生瀏覽器/鍵盤/AT/硬體音訊未驗收。 + + +225. **最小 embed 手動本人連線統計(第204批,2026-10-16):** immutable203 完整 embed/實際 SDK 證明缺少手動 getStats 入口,觀眾無 publisher 品質回報但可讀自身 peer 統計。新增獨立普通面板與明確按鈕,僅 receive loss/RTT/buffer、send loss/RTT 五指標;零保留、非法/缺失 metric 為破折號、方向缺少為暫無、畸形 envelope/direction 或 throwing getter 固定局部失敗。只存有限 primitive;不顯示 raw/codec/counter/其他人資訊,不改全域 error/quality/speaking。標示上次手動歷史與 SDK 取樣區間,last matching audio RTP 非 aggregate,其他呼叫者共用基準,RTT 非端到端/可聽保證。stats-only captured client/generation controls binding 在新 client 綁定時建立;join/retireEntry/retireControls/disconnected 先純退役,不能把舊 controls 套上新 entry 世代。reconnect 保留 binding 但清 owner/attempt/history,之後有效席位明確 click 可讀,不需新 passive state;disconnected controls 不自行復活。room/participant 更換隔離舊結果,同席 role/stage/mic 變更保留。方法 getter 前 reserve,lookup/call/settle/projection/每個新 DOM setter 後核對 token/binding/owner,舊 native promise 不能解鎖或覆寫新 entry;不宣稱取消其 SDK 取樣或共用基準更新。fixed DOM 用 exact document query identity+panel containment 防 detached/replaced action;departure 純階段不新 paint,Close/Transfer consent 核對先於新/修正 DOM,nested 新 winner 與 silent loss 都依當前 owner 調和,不廣修既有生命週期。十檔限定 HTML/embed source/新測試/五 fixture map 精確新增節點與 README/PLAN,原 assertions 保留;無 SDK/server/widget/media/protocol/config/依賴變更。無 timer/poll/auto read/retry/timeout/新媒體或權限操作;native browser/AT/硬體音訊未驗收。 + + +226. **Widget 手動統計保留已觀察的確認失效(第205批,2026-10-16):** immutable204 完整 StageElement+實際 SDK 六例證明新 stats reconciliation 讀到 reconnect/roomId/participantId 失效後,下一個公開 getter 恢復舊值,首輪 consistency continue 先於原 Close/Transfer helper,舊 pending consent 未 invalid 且各送出一次;既有100 stats測試全過,屬未覆蓋同步回呼排序缺口。僅新 stats reconcile/局部支援捕獲精確 pending token 與 authority,確實讀到的 status/身分失效先純標記原 token,再任何重讀/continue/DOM;recheck/throwing getter 同樣隔離。不同 nested 新 token/新合法 entry 不受舊 observation 波及,不以 stale note 作用到新 tenure。保留有效 Close/Transfer、觀眾閱讀統計、原 lifecycle/共享SDK基準/local feedback/departure paint fence;不改既有權限 helper 本體或一般命令政策、不加入 role/connected stats gate。五檔 source/append-only test+三文件,無 SDK/server/media/protocol/依賴變更。測試須含六反例、有效確認、較新token/同client合法重入、重讀/例外與舊promise/finally隔離;原生browser/AT/硬體音訊未驗收。 + + +227. **Widget 本機播放 ARIA 等值寫入省略(第206批,2026-10-16):** immutable205 重跑204發現證據,五例結果相同;限定 listen aria-pressed/volume aria-valuetext 兩個 setter 前讀目前 attribute 比較。實際 widget→公開SDK→validator→MockHub 的64秒普通presence/hand cooldown/mute序列72重繪,144等值ARIA寫入省略,新增144讀取/比較,216原text/value呼叫不變;四次slider8寫入降為4必要變更。120秒quiet keepalive原本0重繪,不宣稱idle或nativeCPU/time收益。五個hidden/disabled候選保持原setter,因effectiveBoolean相同仍可能需原字面canonicalization;textContent descendant替換、dirty input.value、style及所有lifecycle/媒體/控制政策不改。錯誤/缺失ARIA由live比較修復,等值observer通知刻意減少;不承諾任意宿主getter/setter副作用等價。五檔source兩site/append-only tests+三文件,完整原測試保留;actualSDK負載、必要變更、host repair、pending unlock/reconnect/retiredclient與Close/Transfer回歸納入驗證。無新timer/request/media/依賴;原生browser/AT/硬體音訊未驗收。 + + +228. **完整範例本人統計歷史換席退役(第207批,2026-10-16):** immutable206 完整sample+實際SDK→validator→MockHub/controlledRTC證明非主控grace到期後合法resume取得新participant與peer,舊已畫出的20%掉包/180ms/10ms及error class仍在新席位;新peer首讀失敗會持續留舊資料。原pending結果的generation guard正確,缺口僅保留DOM歷史。限定room/participant identity改變後清空connectionLine文字與error狀態,先執行原純actiongeneration失效,不改同席state/reconnect歷史與可見2秒輪詢、hidden/resume watermark/SDKdelta或失敗政策。新席位首讀失敗保持空白,之後成功只顯示本席結果;無額外getStats/timer/media。新clear setter後核對generation,舊continuation不再更改新身分class;實際SDK統計為async,不為任意host同步thenable增加presentation cache或原子保證,不重寫一般room/action lifecycle。五檔sample source/完整模組test/既有webActions的最小classList fixture支援+README/PLAN,原assertions保留;actual grace expiry、room-only/participant-only替換、oldpending/newfail/newsuccess、same-seat/hiddenbaseline及setter/actionownership納入驗證。原生browser/AT/硬體音訊未驗收。 + + +229. **完整範例方向別連線統計(第208批,2026-10-16):** immutable207 完整sample+實際SDK controlledRTC證明有效接收RTT43ms/傳送RTT100ms被合併為泛稱延遲43ms,且方向缺失與首樣本delta不可用無明確區別。僅既有refreshConnectionLine成功投影呈現五指標:receive loss/RTT/buffer、send loss/RTT;上次完成讀取/SDK取樣區間、RTT非端到端且非即時可聽保證。present方向缺metric顯示破折號,null方向暫無統計,bothnull仍整行清空並推進原watermark;零保留、loss一位小數、秒轉毫秒、buffer原毫秒,非有限轉換局部顯示破折號。不改SDK最後匹配RTP與共享delta基準,不冒充全串流聚合或固定2秒區間。紅警告原未round maxloss>=5保持,4.99顯示5.0但不紅,RTT不判健康。timer/hidden/same-seat reconnect/highercompletedsequence/失敗保留/207換席清歷史全部保持,無新增讀取/node/control/media。七檔main投影/新完整模組test/三既有精確文案expectation+README/PLAN,原數值與生命周期assertions不弱化,formatter留原slice內不擴fixture。原生browser/AT/硬體音訊未驗收,無延遲或音質改善宣稱。 + + +230. **完整範例舞台品質讀者下台即退役(第209批,2026-10-16):** immutable208 完整sample+實際SDK→validator→MockHub證明被移下、主動下台與台下主控移交三條真實role/stageleft→state次序,在晚到權威快照前仍展開顯示舊8%/2%/37ms品質,甚至移下提示已出現。限定舞台品質details的pure viewer-loss fence,own role audience/own stageleft非controller/own offstage outgoing transferred先保留失效事實再清報告/rows/text並hide/collapse;以event.role保留controller下台例外,onstage移交與他人delta不退休讀者。綁舊snapshot/seat,普通status/quality/notice/render不能借舊state重開,新的目前權威state再核資格,須新report才有數字。不套用widget完整receipt集合,不宣稱任意無epoch重放可辨識;未發現真實舊socket/Hub async report重灌,亦不改server收件政策。既有reconnect清報告但保留展開、rowqualitybadges、本人stats、timer/SDK/media皆保持;新reset/paint在原roomactiongeneration護欄後,舊continuation不得清較新合法entry,不廣修控制生命周期。四檔main/新完整模組test+README/PLAN,原assertions保留;實際三反例、controller/transfer例外、same-seat/newstate/report恢復、socket/reconnect、callback/actionownership及無額外操作列回歸。原生browser/AT/硬體音訊未驗收。 + + +231. **範例重連說話標記退役(第210批,2026-10-16):** immutable209 完整 sample+實際 SDK/validator/Hub/RoomMixer/MockMediaTransport 證明讀者控制 socket 中斷後,伺服器已無 VAD,列上「說話中」仍持續 30 秒;恢復期間修改房名造成新列重建,也會在新 speaking 回呼前從 SDK 保留集合重新點亮。只修 sample 的重連顯示窗口:清除目前列標記、阻止舊集合在恢復快照重新播回;處理當前恢復 state 後的新 speaking 回呼才恢復,空集合亦可。普通初入房/一般快照 seeding、逐差異索引更新、重複 ID 列、dirty slider 與 node identity 契約保持,不新增 timer/request/media 操作,不改 SDK 保留 media/最後集合政策。純目標狀態與 revision 先於 class setter,舊 continuation 不得覆寫較新事件/entry;保護限可觀測回呼,非任意宿主原子性。真正下台/移除/mute 已先收到 speaking prune,不廣修其政策。14 個不可變基線案例有兩個精確反例與十二正向控制;既有 speaking/roomRender 71 個通過。四檔限定 main、新測試、README、PLAN;原 assertions 保留。fresh 指恢復 state 後新 callback delivery,無 object replay 追蹤或 wire epoch/新鮮度保證;原生 browser/AT/實體音訊未驗收。 + + +232. **最小 embed 台下麥克風音量測試(第211批,2026-10-16):** immutable210 已有公開 startMicTest({signal})/MicTest level、stop、stopped 能力,完整 embed 缺 UI;十二個實際 SDK+fake media 探查與296既有 focused tests 通過。新增僅當前已進房且離台的明確測試/取消/停止按鈕與具名 meter,不回放、不發布舞台、不自動擷取或重啟。捕捉 entry/client/binding/room/participant 與 pending token,先純退役再 abort/stop/DOM,取消後保留 lane 至 promise settle,晚到 grant/handle 釋放;共享 micready 不是完成訊號。上台的早期本人通知先 fence 舊 snapshot,重連/離房/替換/terminal/pagehide/hidden 都退役,只停止自己擁有的測試,不影響正常台上麥克風。活躍且可見時約100ms讀一次 level,非有限值顯示0、有限值夾限0~1;自然停止清排程,getter/setter/cleanup重入不能借舊 owner 開新擷取或清新結果。input apply 先reserve再退役測試與重驗,不跟著選擇自動測試;未套用 draft 不代表實際輸入。缺舊SDK方法在明確點擊後固定local failure,不fallback擷取。僅embed源碼/HTML、新測試、必需fixtures與README/clientREADME/PLAN;既有無startMicTest靜態禁止因明確新功能窄調,其他assertions保留,無SDK/server/media/protocol/依賴變更。原生權限視窗/鍵盤/焦點/AT/實體音訊未驗收,取消不宣稱關閉native prompt。 + + +233. **Widget 台下麥克風音量測試(第212批,2026-10-16):** immutable211 完整 widget+實際 SDK fake media 證明無內建音量測試UI,公開 startMicTest({signal}) 已支援獨立本機level/stop/stopped/abort。新增僅當前 connected/joined/offstage owner 的明確測試、取消、停止與具名meter/local status;不回放、不發布、不自動擷取或重啟。每實例獨立 client-entry binding、pending lane、timer 與事件清理;純退役先於 stop/abort/DOM,重入不得用舊 client 配新 generation 開第二擷取,取消保持lane至settle並釋放晚到handle/grant。early own invite/stagejoined fence 等當前權威 state,狀態onstage/reconnect/Leave/terminal/detach/換entry/hidden/pagehide 清測試,保留正常stage mic及其他widget資源。一般playback屬性維持有效client/test,join屬性與手動Leave沿舊語意;input apply先reserve再停測試並重驗,不自動測新裝置。活躍可見時100ms讀level,finite夾限0~1、非法顯示0;natural stopped/error退役排程,getter/setter/cleanup重入以新owner為準。既有共享micready不是完成訊號,SDK全域error政策不變。舊非browser測試document的event方法僅在註冊邊界optional相容;新完整harness以EventTarget驗證真實註冊/移除與多實例。範圍element.ts、新獨立client test與README/clientREADME/PLAN,不改原tests/fixtures、SDK/server/media/protocol/依賴。受控驗證不代表native permission/keyboard/focus/AT/硬體音訊驗收。 + + +234. **Widget 公開台上成員快照(第213批,2026-10-16):** immutable212 完整widget+實際 SDK/validator/Hub/MockMedia 基線確認一般觀眾/講者看不到安靜台上成員,發言摘要與主控管理列為既有正向控制;public speakers 已帶台上順序、名稱/角色/mute/forceMute/connected。新增所有有效席位可展開的純details名單,預設收合、空台明示、同owner保留open;僅公開快照,不是在線/活動/可聽名單,離台controller不加列、斷線grace席保留。限定當前新state receipt複製最小projection,驗證唯一ID、有效名字/台上角色/旗標、最多1controller與noncontroller<=limits.maxSpeakers,無硬編碼8/9上限;不讀audience/code/token、不把整state序列化或反射私有屬性。reconnect保留copied歷史並標示,status不刷新;early membership/role通知等新state,不推測新順序。terminal/leave/detach/newentry/換席清空收合,無效projection failclosed;新事件/owner取代舊DOMcontinuation,原Close/Transfer pure fences先於新增getter/DOM。相同projection與無關quality/speaking不寫名單,keyedrows保留既有節點,無新timer/command/media。五檔element、新獨立測試及README/clientREADME/PLAN,原tests/fixtures與SDK/server/media/protocol/依賴保持。原生details/keyboard/focus/AT/layout/實體音訊未驗收。 + + +235. **最小 embed 公開台上成員快照(第214批,2026-10-16):** immutable213 完整 embed+實際 SDK/validator/Hub/MockMedia 的八組 audience/speaker、主控台上/台下、目標連線/grace 組合證明安靜公開台上名字未呈現;十個正向控制與659既有回歸通過。新增純 native details/summary/list,預設收合、所有已進房席位可讀,僅 public speakers 的安全文字/角色/mute/forceMute/connected 保留席位標示,依 server 順序、不加離台主控,不讀 audience/code/token 或整 state 序列化。有效 projection 驗證唯一ID/旗標/台上角色/controllerId、最多一個controller與其餘人數不超過 limits.maxSpeakers,無固定8/9上限;無效即清。entry/client/controls/room/participant 與新 exact-current state receipt 決定複製快照,事件與退休revision並守,getter中觀測失效又恢復不得續用舊receipt。reconnect保留歷史,status/early membership通知不推測完整名單;terminal/Leave/newentry/換席清空收合,sameowner保留open。原Close/Transfer pure fences先於新增getter/DOM;較新有限回呼取代舊paint,固定DOM身分在觀察時核對,不新增observer或任意宿主即時修復保證。keyed rows與相同投影省去名單寫入,無新timer/commands/media。範圍embed源碼/HTML、新完整模組test、必要fixture節點及README/clientREADME/PLAN;原assertions與SDK/server/media/protocol/依賴保持。原生details/keyboard/focus/AT/layout/硬體音訊未驗收。 + +236. **Widget 公開名單的前置回呼退休隔離(第215批,2026-10-16):** immutable214 完整widget證明前置speaking getter觀測room/participant/disconnected失效、已清空收合名單後,恢復outer state會在尾端重新接納舊receipt;三組負向失敗與十二組正常/新席位控制、1841原有回歸確認缺口。以純callback-entry pending receipt token跨越既有前置處理,觀測退休即撤銷;僅exact-current新state且有效改變的身分允許一次舊owner→新席位 provisional anchor,第二次失效永久撤銷該receipt。finally只清自己的token,巢狀新事件與throw不留下可重用允許。保留單一新席位state、重連歷史、相同投影節點與原Close/Transfer consent順序,不新增前置getter/DOM、不變更SDK/server/media/protocol/依賴。範圍element、新whole-module回歸與README/clientREADME/PLAN;原tests不修改。受控有限回呼測試不等於任意宿主終止保證;原生details/鍵盤/AT/硬體音訊仍未驗收。 + +237. **完整範例的公開席位快照與觀眾容量標示修正(第217批,2026-10-16):** immutable215完整sample+真SDK/validator/Hub驗證一講者+兩觀眾已滿3個一般席位且新join回room_full,但觀眾標頭顯示2/3;grace席位仍佔容量。改觀眾標頭為人數,新persistent muted段落呈現非主控總席/房限、非主控台位/台限、觀眾與另計主控位置,明示快照與斷線保留席位,非在線/可聽/准入保證。只投影必要公開欄位與有效唯一ID/角色/上限/計數,無效清該摘要;只接納未見且exact-current state,重連保留copied歷史,status不刷新,離房/終止/新entry/換席退役。新事件revision先保留、原room/action fences後才paint,有限同步setter重入由最新desired結果接管;不加入observer或任意宿主終止保證。摘要在main結構外,相同文字省寫,不改stage/action/dirtygain節點身份或audiencefilter。範圍main、新whole-module測試、兩份原測試中過時觀眾分母文字及README/PLAN;原斷言保留,無SDK/server/media/protocol/HTML/CSS/fixtures/依賴更動,無timer/command。原生layout/鍵盤/AT/音訊未驗收。 + +238. **完整範例邀請複製的呼叫前資格檢查(第218批,2026-10-16):** immutable217完整sample+真SDK受控clipboard重現detach/room/seat/code/role/Leave/terminal後的舊handler仍交付舊link並清新error,且可搶走有效pendingcopy的sequence;較早state listener在SDK已裝新state而舊button仍connected時也可程式化重現,非普通實體點擊發生率主張。copy專用exception-safe外層guard在run之前核對captured button/input當前身分、roomActionGeneration、公開的主控 role與room/participant、重新計算的effective URL;run的DOM重入後action再核對才遞增sequence/write。URL與createdRoom fallback共用原語意,不增加controllerId一致性政策或reconnect禁用。原完成隔離、同resource重畫/重疊copy保留;不改genericrun,不保證任意host DOM副作用回滾或取消已交付nativeclipboard。範圍main、新wholemoduletest、README/PLAN;原tests/fixtures、SDK/server/media/protocol/依賴/HTML/CSS不變,不改真實房碼或剪貼簿。原生clipboard/focus/keyboard/AT未驗收。 + +239. **SDK typed事件的一次性與signal訂閱選項(第219批,2026-10-16):** immutable218九組真SDK控制事件確認原on(type,listener)與EventTarget身分/去重/卸載正確;strictTS第三options參數被拒,原生addEventListener的typedCustomEvent需cast,故為便利功能而非既有錯誤修正。新增公開StageClientEventOptions僅once?/signal?,on第三可選參數明確投影這兩欄給nativeEventTarget,原callback與非capture取消函式不變;較寬options的capture/passive不讀不傳,不新增abortlistener或callbackwrapper。once/預先abort/中途abort/手動off/重入/原生interop由runtime語意決定,duplicatecallback不是獨立訂閱,Node不同signal細節用nativeoracle比較不升格為跨瀏覽器承諾。新增真SDK事件回歸與compilefixture,既有兩參數型別推論保持;只改publicon/helpertype與README/clientREADME/PLAN,不動requestqueue/協定/server/media/依賴/原測試。取消監聽不取消請求或媒體。原生瀏覽器/AT/實體音訊未驗收。 + +240. **Widget 主控舉手名單的本機名稱搜尋(第220批,2026-10-17):** immutable219完整widget+真StageClient/Room120筆申請呈現240個處理按鈕、3個同名列,沒有搜尋欄;7組原行為控制通過,feature gate缺少入口如預期失敗。新增native label/searchinput、清除按鈕與符合/總數/無結果文字;只比對decodeName後trim/case-insensitive substring,不搜尋ID/code/token或擴張audience資料。保留完整eligible keyedrows、server order與per-ID pending,只投影hidden/disabled;隱藏列的合成操作在pending/error/SDK之前拒絕,等待中隱藏不釋放操作。query/open在sameowner更新/reconnect保留,reconnect歷史禁處理;失權/room/seat/entry/detach/terminal清query/private UI,early role/transfer失權不能被舊snapshot復活。新增可觀察讀寫先保留owner/revision並維持原Close/Transfer pure fences順序,有限重入由新owner/state/query接管,不承諾任意宿主即時修復或無限回呼終止。範圍element、新wholemoduletest、README/clientREADME/PLAN;原tests/fixtures、SDKrequestqueue/cooldown/server/media/protocol/依賴不變,無timer/command/儲存。原生keyboard/focus/AT/長清單版面未驗收。 + + +241. **最小 embed 主控舉手名單本機名稱搜尋(第221批,2026-10-17):** immutable220 完整embed+真SDK/Room 的120筆申請呈現240按鈕,三個同名目標分散第20/70/120列且沒有搜尋入口;8組基線與72組原主持測試通過。新增native標籤搜尋欄、清除與符合/總數/無結果提示,僅將query trim後對解碼姓名做case-insensitive substring。保留完整eligible keyedrows、伺服器順序及per-ID pending,篩選只投影hidden/disabled,合成操作必須重新確認當前名稱與query資格;隱藏待完成列仍保留等待。sameowner更新/重連保留query與open,重連禁操作;失權、換房/席位/entry、離房與terminal清空,role/transfer通知仍不取代權威snapshot,保持原server裁決短窗。原Close/Transfer consent fences先於新增可觀察讀寫,有限同步回呼以新owner/state/query為準,不承諾任意宿主即時修復或無限回呼終止。範圍embed源碼/HTML、新whole-module test、必要fixtures節點及README/clientREADME/PLAN,不改SDK/server/media/protocol/依賴/requestqueue/cooldown;不新增timer、storage或媒體呼叫。原生keyboard/focus/AT/長清單版面與硬體音訊未驗收。 + + +242. **完整範例主控舉手佇列獨立名稱搜尋(第222批,2026-10-17):** immutable221 實際Room/sanitizer/decoder與完整sample模組(受控SDK命令)證明120筆申請、240個核准/婉拒按鈕不受原觀眾搜尋影響;觀眾符合列只有移交/踢除,原44組filter測試刻意保留此界線。新增獨立主控舉手搜尋、清除與符合/總數/無結果提示;query trim後與解碼名字採NFC/lowercase substring,不改觀眾搜尋或ID身分。persistent搜尋控制在原audience toolbar之後、破壞式main重畫之外,typing只投影目前hand rows,不重建main或重設dirtygain;同owner重畫/重連保留query,既有退役界線清除。保留原順序、同名ID、shared action keys與pending token;隱藏或退役hand rows的approve/reject/transfer/kick在run副作用前與confirm後重驗當前名字/query/row/owner,speaker/audience動作不變。維持原通知/snapshot政策與完整renderer架構,不改SDK/server/media/protocol/依賴/requestqueue/cooldown,無timer/storage/自動主持。範圍main、新whole-module test、README/PLAN及三份原測試中既有觀眾搜尋helper的ID限定、webActions抽取式harness新增依賴與DOM parent連結;原測試行為斷言不改。驗證限受控DOM與SDK,原生keyboard/focus/IME/AT/layout及硬體音訊未驗收。 + + +243. **Node 24 GitHub 自動驗證(第223批,2026-10-17):** immutable222 沒有任何受版控CI workflow,Gate1/Gate5要求的型別/測試與乾淨安裝建置缺少每次提交可重現的遠端檢查;不是缺少核心功能,也不宣稱企劃明訂GitHub Actions。使用者明確同意新增push/PR CI後,加入標準Ubuntu24.04/Node24單job,locked npm ci後依序四份types、全tests與build,20分鐘上限、同event/ref取消舊run。分支push排除tags、PR用預設事件與合併測試ref;exact-head交付結果另核push run。官方checkout/setup-node完整SHA釘選,contents只讀、不保留git認證、不用custom secrets/cache/artifacts/schedule/deploy,不改settings或branch protection。新增YAML結構與授權邊界回歸及README說明,無runtime/依賴/lockfile改動。npm test仍含既有codec/worker受控檢查,不稱純控制面;此CI不含audit/start/native WebRTC loopback/load/browser/AT/hardware/跨NAT,原Gate與HOLD仍獨立。發布後追蹤首次exact-head push CI至terminal,不能把本機PASS當遠端PASS。 + + +244. **範例建置資產支援既有靜態掛載前綴(第224批,2026-10-17):** immutable223 的真正 Vite 產物透過 createStaticHandler/本機 HTTP 掛在 `/demo/` 或 `/nested/demo/` 時,main/embed HTML 與 canonical 308 正常,但 HTML 的根絕對 `/assets/` JS/CSS 全回404;預設根掛載控制皆通過。以 web Vite `base: './'` 讓同一產物依文件位置解析靜態資產,保留根掛載、共用 chunk 與原 dev 行為;不另加伺服器 root fallback。新增隔離建置與真 HTTP 的根/單層/巢狀掛載回歸,涵蓋 index/embed、尾斜線與 query、產物 JS/CSS/模組依賴與 MIME/內容。完整範例邀請連結原已保留 location.pathname;WebSocket 預設 `/ws`/既有 wsPath 與 embed 指定主機 `/lib/stage-client.js` 語意保持,不由 static prefix 推測。範圍僅 Vite config、新建置交付測試與 README/PLAN;無 SDK/server/media/protocol/依賴/CI workflow/部署設定變更。驗證僅資產建置與 HTTP 解析,不代替原生瀏覽器執行、版面、鍵盤/AT/ICE/硬體聲音或原企劃整體 Gate。 + + +245. **已建置 client 的外部 consumer 交付驗證(第225批,2026-10-17):** immutable223/224 的 client 產物本身通過隔離 ESM 與 NodeNext/Bundler 嚴格型別探測,但既有 source tests 與建置指令未持久化驗證 package exports、宣告依賴閉包、auto-register entry 或兩入口共用身分。隔離產物的缺匯出、錯 types target、重複 client、少自動註冊、少宣告依賴及 import-time WebSocket 副作用六種 mutation 皆由 consumer probe 攔下,故補的是可重現交付檢查,不宣稱 SDK 現有功能損壞。新增 `test:client-package`,只複製 package.json/dist 到暫存 consumer,以真正 bare import 與受控 registry 核對必要公開匯出/身分/註冊,並用已安裝 TypeScript 執行 NodeNext、Bundler 兩種嚴格宣告與事件型別檢查(skipLibCheck:false、types:[])。缺產物明確失敗,無自動 build/install/repair/publish;子程序設期限與輸出界線、成功或失敗清理暫存內容。根 build 在 compress 後執行一次,npm test/typecheck 不依賴先有 dist;不更改既有 CI workflow、權限、觸發器或依賴。範圍新增 checker/fixtures/回歸、package scripts 及 README/clientREADME/PLAN,SDK/server/media/protocol保持。這是產物與受控 Node/型別驗證,不取代原生瀏覽器 custom elements/權限/AT/ICE/硬體音訊或原企劃整體 Gate;既有 HOLD 保留。 + + +246. **編譯後伺服器本機交付驗證(第226批,2026-10-17):** immutable225 的 source/VM 回歸與 build 沒有持久化啟動真正 dist/src/index.js;隔離產物探測正向通過,缺 JS、未改寫 .ts import、錯 CWD root/static base、自訂 wsPath 遺失、錯 hello version、readiness 503、非零退出及忽略 SIGTERM 九個 mutation 均被攔下。新增 Linux-only `test:server-package`,只複製交付產物及 package metadata、沿用已安裝 dependencies,在不同 CWD 用合成 absolute STAGE_CONFIG 啟動真正編譯入口。loopback 動態埠、自訂 static prefix/wsPath、空 STUN/TURN、無 room/peer;驗證 health/ready、靜態內容與 MIME、未入房 hello,以及 SIGTERM/socket1001/exit0。缺產物 fail,不自動 build/install/repair;I/O、輸出與程序有期限及回收。既有 Ubuntu push/PR CI 在 build 後執行,不把此 Linux 門檻加入跨平台一般 build,不讓 source tests 依賴 dist。只新增 checker/回歸、package script、現有 workflow step 與契約測試、README/PLAN,不改 server/SDK/media/protocol/依賴/權限/觸發器/部署設定。這是交付整合門檻,不宣稱現有 server 已損壞或整體 Gate5、Windows/macOS、TLS/ICE/原生瀏覽器/AT/硬體音訊驗收;既有 HOLD 維持。 + + +247. **Linux 控制腳本 PID 寫入失敗回收新程序(第227批,2026-10-17):** immutable226 真 shell/隔離 synthetic HTTP 服務證明 `.run/stage.pid` 為目錄,或一般非 root PID 檔建立遭拒時,start 在背景 Node 啟動後失敗退出,但服務仍持續監聽;status=3、stop=0 都稱未執行。正常 start/status/logs/restart/stop 為正向控制。僅將 cmd_start 的 PID printf/redirection 置於明確失敗分支,失敗時 KILL 並 wait 本次剛取得的 `$!`,回報含路徑錯誤,保留原無效路徑;不依賴 set-e 自動退出,不改一般 stop 的 TERM/timeout 或 restart 預檢,不延伸為並行啟動/PID 重用政策。新增隔離真程序與 shell 回歸,既有 fixture process allowlists 僅納入經審查的新操作並保留原斷言。範圍 stage.sh/測試/README/PLAN,無 Windows/server/SDK/media/protocol/依賴/部署設定改動。此為失敗啟動資源回收,不代表 systemd/macOS/Windows/原生瀏覽器/硬體音訊或完整部署驗收;既有 HOLD 保持。 + + +248. **Windows 首次啟動目錄及原生腳本 CI(第228批,2026-10-17):** immutable227 的 stage.ps1 在 Start-Process 日誌 redirection/PID 寫入前未建立 `.run`,乾淨副本有直接失敗路徑。明確新增目錄建立,保留既有目錄,衝突檔案於程序啟動前失敗;交付的 stage.ps1 加上 UTF-8 BOM,以符合 Windows PowerShell5.1 對非 ASCII 腳本的解碼要求,immutable baseline 仍保留原始無 BOM 位元組作對照,不只在 fixture 偷換編碼。兩條限定 .gitattributes 規則維持交付腳本 LF 與 baseline 原始 bytes,不改整庫換行或 runner git 設定。Test-Node 改用不含內嵌引號的 parseInt(process.versions.node),避開5.1 legacy native argument parsing,最低Node24不變。不改 Windows forced-stop、restart、設定與 PID 政策。使用者另已批准標準 Windows Actions job,新增 windows-2025/Node24/兩個既有 pinned actions 與唯一 targeted Node test,要求 Windows PowerShell5.1/PowerShell7 皆存在;不 install、deploy、改 execution policy/權限/觸發器。暫存 raw-script/synthetic Node 的首次啟動、既有目錄/衝突、路徑空格/不同 CWD、status/logs/stop/重複 start 與 immutable baseline 對照,僅清理 fixture 所有子程序。Linux 原生案例明確 skip,批次須發布後 exact-head Ubuntu 與 Windows CI 都成功才算完成,不以本地型別/source tests 代替 Windows 執行。無 server/SDK/media/protocol/依賴/main merge/部署變更,全部既有 HOLD 保持;Windows腳本檢查不代表全伺服器、瀏覽器/AT/ICE/硬體音訊或原企劃整體 Gate 驗收。 + + +249. **Windows PID 寫入失敗的本次啟動清理(第229批,2026-10-17):** immutable228 的原生 Windows PowerShell5.1/PowerShell7 測試已明確證明:受控子程序 ready 後注入 PID 目錄衝突,start 失敗但沒有 production stop,driver finally 前仍需回收該子程序。此為 synthetic 失敗路徑證據,不聲稱觀察到真實部署服務洩漏。僅包住成功 Start-Process 後的 PID Set-Content;寫入失敗時對本次回傳的 Process 物件執行清理、有限等待退出並保留原始錯誤,清理失敗/逾時另行報告,保留操作員的無效 PID 路徑。測試須在腳本返回且 harness finally 之前觀察子程序退出,immutable baseline 與候選分別證明需要/不需要 harness 回收,另守已退出與清理失敗路徑;fixture 的安全觀察物件不得預先改變 production 物件的 Handle 狀態。不改一般 stop/restart、PID 重用或並行啟動政策,不擴大 server/SDK/media/protocol、依賴或 CI 權限。完整本地 gates 與獨立 hash-bound 審查後,須 exact-head Ubuntu/Windows CI 均完成成功才算交付;Linux skip 不作原生通過。保留全部 HOLD 與原生瀏覽器/AT/ICE/硬體音訊未驗收界線。 + + +250. **Windows restart 先驗證替代啟動條件(第230批,2026-10-17):** immutable229 的 Windows restart 直接 Stop-Stage 後 Start-Stage,Node/建置入口/設定缺失等可預先偵測的問題會在舊程序停止後才失敗;Linux 已有 built checker 預檢。新增獨立 Restart-Stage,先檢查 Node 及原生命令退出狀態、已建置 server/check-config.js 檔案、沿用 Initialize-Config,再不帶 positional config 參數執行已建置檢查器,成功才使用既有 stop/start。保留原始 STAGE_CONFIG、專案根目錄相對路徑與非零退出結果;不改其他命令、一般 PID 身分/並行/stop 政策,不保證零停機、runtime 資源或檢查後檔案未變更。原生 Windows fixtures 比較 immutable baseline 與候選,必須在 finally 前觀察失敗候選保留舊程序/PID且沒有 stop 或替代 launch,成功重啟則確認受控程序替換,並涵蓋 Node 輸出24卻非零與 checker 失敗。沿用免安裝 Windows job,以受控 checker 驗證命令順序/參數/CWD/環境與退出語意;實際 built checker/schema 由原 Linux gates 驗證,不稱原生完整設定/server驗收。不變更 CI 設定、依賴、SDK/server/media/protocol。須獨立 hash-bound 審查、本地完整 gates 與 exact-head Ubuntu/Windows CI 終態成功,所有 HOLD 及 browser/AT/ICE/硬體音訊限制保持。 diff --git a/README.md b/README.md index 3031421..2cb3408 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,21 @@ node dist/src/check-config.js /path/to/config.yaml # 建置後也可直接執行 只驗證 YAML 與既有欄位/跨欄位規則,不啟動伺服器、worker 或網路連線,也不建立/改寫設定檔;不代表 TLS/靜態檔可讀、連接埠可綁定或 TURN 可達。成功 exit 0、讀取/驗證失敗 exit 1、用法錯誤 exit 2;`-h`/`--help` 顯示用法,`--` 後可指定以 `-` 開頭的檔名。明確路徑優先於 `STAGE_CONFIG`,相對路徑以目前工作目錄解析;未提供兩者時從專案根目錄找 `config.yaml`,不受目前目錄影響。沿用直接啟動的語意:空的 `STAGE_CONFIG` 不等同未設定,會指向目前目錄並讀取失敗。輸出不列出設定內容或 stack trace;共用 loader 的 YAML 診斷只保留錯誤種類與安全的行/列位置,原本僅有 warning 的 YAML 仍可通過既有 schema,但不再印出可能包含憑證的 warning/原文片段。 +### 已建置伺服器的本機交付檢查 + +Linux 上完成 `npm run build` 後,可執行 `npm run test:server-package`。此指令將 server、web、client 的建置產物與套件資訊複製到暫存交付目錄,沿用已安裝的相依套件(並非完全獨立的相依套件封裝),從另一個工作目錄真正啟動編譯後入口;不載入或修改你的 `config.yaml`。檢查使用獨立合成設定、本機 `127.0.0.1` 動態埠、自訂 WebSocket 路徑及靜態前綴,核對 health/ready、靜態內容、未進房的 WebSocket hello,以及 SIGTERM 後的連線關閉與程序退出。缺少產物會失敗,不自動建置或安裝。 + +指令為 Linux 驗證用途,既有 Ubuntu CI 在 build 後執行;一般 `npm run build` 不會啟動此服務,`npm test` 不依賴事先存在的 dist。程序、請求與輸出設有界線,失敗時回收自己啟動的程序及暫存目錄。它沒有建立房間或 WebRTC peer,也不連 STUN/TURN,不是 Windows/macOS 啟停、TLS、原生瀏覽器、實體音訊或跨網路驗收。 + +### 已建置 client 的外部整合檢查 + +根目錄 `npm run build` 在完成建置與預壓縮後,會執行 `npm run test:client-package`。也可在已有產物時單獨執行此指令:它只將 client 的 `package.json` 與 `dist` 複製到暫存 consumer,以真正的 `@stage-js/client`/`@stage-js/client/element` 匯入驗證公開匯出、兩入口共用身分與受控元件註冊,再以 NodeNext/Bundler 兩種模式嚴格檢查交付的型別宣告及事件推論。檢查不引用套件原始碼,不安裝或發布套件,也不自動補建置;產物缺少或檢查失敗會回非零狀態,暫存 consumer 在結束後清理。 + +`npm test` 與 `npm run typecheck` 仍可在尚未建置時執行;僅執行 workspace 的 client build 不會額外跑這項檢查。現有 GitHub CI 的完整 build 會包含它,不增加 workflow 權限。這是交付產物/受控 Node 載入與型別驗證,並不表示實際瀏覽器的元件互動、權限、音訊或跨網路已驗收。 + ### 靜態資源預壓縮 -`npm run build` 最後以 Node 內建 zlib 為 client/web 的 HTML、JS、CSS、JSON、SVG 與 webmanifest 產生較小的 `.br`/`.gz` 副本;不壓縮圖片、已編碼檔或 symlink,壓縮後較大的副本不保留。也可在兩個 workspace 都完成建置後單獨執行 `npm run build:compress`,或 `node scripts/precompress.ts <建置目錄>`;指令會列出原始/Brotli/gzip 位元組數。 +`npm run build` 完成編譯後,以 Node 內建 zlib 為 client/web 的 HTML、JS、CSS、JSON、SVG 與 webmanifest 產生較小的 `.br`/`.gz` 副本;不壓縮圖片、已編碼檔或 symlink,壓縮後較大的副本不保留。也可在兩個 workspace 都完成建置後單獨執行 `npm run build:compress`,或 `node scripts/precompress.ts <建置目錄>`;指令會列出原始/Brotli/gzip 位元組數。 伺服器依 `Accept-Encoding` 的權重與排除規則選擇可用版本(同權重優先 Brotli),附 `Vary: Accept-Encoding`、各版本獨立 ETag 與正確 Content-Length。HEAD 與 304 保持相同的編碼資訊,未預壓縮或副本比來源舊時退回原檔;用戶端明確排除所有可用版本才回 406。不會在請求期間壓縮,也不會重複壓縮 `.br`/`.gz` URL。ETag 使用所選版本的檔案身分、大小及奈秒修改/變更時間,避免保留大小與 mtime 的 release 檔案替換沿用舊驗證值;它仍是弱 metadata validator,不是內容雜湊。只使用 If-Modified-Since 的用戶端仍受 Last-Modified 秒級精度及保留 mtime 的限制。部署時請將原檔與副本一起更新;Vite 開發模式仍維持原工作流程。 @@ -74,6 +86,8 @@ node dist/src/check-config.js /path/to/config.yaml # 建置後也可直接執行 自訂 prefix 掛載(例如 `/demo/`)若 `index.html` 存在且解析後是掛載內的一般檔案,請求 `/demo` 會以 308 導向 `/demo/`,保留 query;這讓 HTML 的相對資產網址從正確目錄解析。GET/HEAD 共用此規則,redirect 使用同來源相對 Location 與 `Cache-Control: no-cache`,不依賴傳入 Host 建立絕對網址。預設 `/`、一般檔案與已帶斜線網址不變;缺少或越界的 index 不會產生 redirect。壓縮與 conditional request 在後續的 canonical URL 照常處理。 +完整範例的建置資產採相對路徑,同一份 `web/dist` 可掛在 `/`、`/demo/` 或 `/nested/demo/`;例如將 `server.static` 中 `dir: web/dist` 的 `mount` 改為 `/demo/`,即可開啟 `/demo/` 與 `/demo/embed.html`,不必為每個前綴重新建置。請保留目錄網址尾端的 `/`(上述伺服器導向會補上),並將整個建置目錄連同共用 chunks/CSS/壓縮副本一起提供。這只改變範例頁面的靜態資產位置:完整範例的 WebSocket 仍預設同來源 `/ws`,自訂路徑沿用 `wsPath`;embed 仍從所選 Stage.js 主機的 `/lib/stage-client.js` 載入 SDK。靜態 prefix 不會自動改寫這些服務端點。回歸測試會實際建置並透過本機 HTTP 檢查根目錄與多層前綴的頁面/資產,不代表原生瀏覽器、麥克風或跨網路驗收。 + ### 控制腳本 根目錄的 `stage.sh`(macOS/Linux)與 `stage.ps1`(Windows)包裝常用操作,背景執行時 pid/log 存放在 `.run/`: @@ -91,7 +105,15 @@ node dist/src/check-config.js /path/to/config.yaml # 建置後也可直接執行 `stage.sh` 只接受 PID 檔中的正十進位整數(1–2147483647,可含前導零與尾端換行;拒絕 NUL 等其他位元組)。內容無效或讀取失敗時,`start`/`status`/`stop`/`restart` 以 exit code 1 結束,保留檔案供檢查,不探測/發送訊號/啟動程序;有效但已不存在的 PID 仍清除,`status` 回 3、`stop` 成功返回。這只防止錯誤格式與 process-group selector,不驗證 PID 是否仍屬於原本的 Stage 程序,也不解決作業系統的 PID 重用。 -`stage.sh restart` 在送出 SIGTERM 前先檢查 Node 版本、已建置的 server/設定檢查器與設定語法/schema;檢查失敗時不停止目前程序。請先執行 `build`,舊版建置若沒有 `dist/src/check-config.js` 也會拒絕 restart。檢查器與 server 使用同一份已建置 schema,不改用可能較新的 source schema。原有 timeout/PID 驗證仍優先;設定準備沿用 `start`,因此缺少預設設定時仍可能先由範例建立 `config.yaml`,而 checker 本身不寫入設定。`STAGE_CONFIG` 原值會保留給 checker,relative path 從腳本所在專案目錄解析,空值仍依 server 語意驗證並失敗。通過後仍依序 stop/start,不保證零停機或啟動成功,也不預測 port、TLS/media 資源、相依套件的所有 runtime 路徑或檢查後的檔案變更。其他控制指令與 Windows 腳本維持原本行為。 +`stage.sh start` 若已啟動背景程序卻無法寫入 `.run/stage.pid`,會終止並回收這次剛啟動的程序,再回報失敗,避免留下 `status`/`stop` 找不到的服務。失敗的 PID 路徑保留供你檢查目錄或寫入權限;這是啟動失敗清理,不是一般 `stop` 的優雅停止流程。 + +`stage.ps1 start` 會在啟動程序前建立 `.run` 日誌/PID 目錄,支援乾淨下載後首次啟動;已存在的目錄保留,若同名路徑是檔案則在啟動前失敗。腳本以 UTF-8 BOM 保存,讓 Windows PowerShell 5.1 正確解讀繁體中文內容;PowerShell 7 亦使用同一份檔案。Node 主版本檢查使用不含內嵌引號的運算式,避免 5.1 的原生參數傳遞吃掉引號,最低需求仍為 Node.js 24。Windows 的 `stop` 仍使用程序終止,不宣稱提供 POSIX SIGTERM 的優雅關閉。 + +`stage.ps1 start` 若背景程序已啟動卻無法寫入 `.run/stage.pid`,會嘗試終止這次 `Start-Process` 回傳的程序並有限等待退出,再回報原始寫入失敗。若清理本身失敗或逾時,也會另外顯示清理錯誤;不宣稱此時已回收程序。無效的 PID 路徑保留供檢查,不透過它尋找其他程序,也不改一般 `stop` 的行為或解決並行啟動/PID 重用。 + +`stage.sh restart` 在送出 SIGTERM 前先檢查 Node 版本、已建置的 server/設定檢查器與設定語法/schema;檢查失敗時不停止目前程序。請先執行 `build`,舊版建置若沒有 `dist/src/check-config.js` 也會拒絕 restart。檢查器與 server 使用同一份已建置 schema,不改用可能較新的 source schema。原有 timeout/PID 驗證仍優先;設定準備沿用 `start`,因此缺少預設設定時仍可能先由範例建立 `config.yaml`,而 checker 本身不寫入設定。`STAGE_CONFIG` 原值會保留給 checker,relative path 從腳本所在專案目錄解析,空值仍依 server 語意驗證並失敗。通過後仍依序 stop/start,不保證零停機或啟動成功,也不預測 port、TLS/media 資源、相依套件的所有 runtime 路徑或檢查後的檔案變更。其他控制指令維持原本行為。 + +`stage.ps1 restart` 同樣在停止目前程序之前檢查 Node、已建置的 server/設定檢查器與設定。請先執行 `build`;缺少 `dist/src/check-config.js` 或檢查器回傳非零時不停止目前程序。它不向檢查器傳入另外推測的設定路徑,保留 `STAGE_CONFIG` 與專案根目錄的既有解析方式;缺少預設設定時仍可能從範例建立。通過後才執行原本的 stop/start,不保證零停機、埠號可用或後續啟動成功。Windows 原生 CI 以受控檢查器驗證這個順序、路徑與失敗結果;真實設定 schema 另由既有 Linux 測試驗證。 Windows 用 `.\stage.ps1 `,指令相同;Windows 無法對背景 node 送 SIGTERM,`stop` 為直接終止。 @@ -195,6 +217,8 @@ JSON frame,型別定義在 [`shared/protocol.ts`](shared/protocol.ts)。每個 | `rtc:answer`、`rtc:ice` | 伺服器端信令 | | `status` | `waiting`/`live` | +完整範例在控制連線重連時清除參與者列的「說話中」標記,避免把上次收到的集合持續當成目前活動;處理恢復後的房間快照,再收到新的 `speaking` 回呼後才恢復標記。普通進房與一般快照的既有顯示不變,不停止仍保留的音訊。回報沒有時間戳或 epoch,這個顯示邊界不保證資料即時或目前可聽。 + 連線品質收集每房間同時最多一輪;完成後再等待 `qualityIntervalMs` 啟動下一輪。個別 publisher 統計失敗只略過該人的本輪資料,其他人的回報照常;仍有 publisher 而全部統計失敗或缺失時不送空報告,但下一輪仍會重試。失敗不會被當作 0% 掉包,恢復後上行差量從上次成功樣本計算。最後一位停止發布後,既有已排程或執行中的輪次在完成時若仍無 publisher,會向當時的主控與台上者送一次 `quality: {participants: []}`,清除先前報告,之後不再排程;不新增清理輪詢。若已有新 publisher,不會以舊輪次的空結果清除它;離台再發布的舊統計結果仍會被忽略。關房不送此清空事件。清空依賴既有輪次執行及統計請求完成,未增加 timeout,也不保證一個 interval 內完成;停用品質回報時不另發事件。 個別統計請求的 rejection value 直接交給安全結構化 logger,不先字串化,避免不可轉字串的診斷值阻斷其他 publisher 的本輪資料與差量回報;任意自訂 logger/sink 自身拋錯仍不在此保證範圍。 `limits.joinTimeoutMs`、`rooms.controllerGraceMs`、`rooms.participantGraceMs` 的範圍為 0~2147483647 ms;`rooms.heartbeatIntervalMs` 為 1000~2147483647 ms。上限包含 2147483647(約 24.9 天);更大的值以前會被 Node timer 正規化成約 1 ms,現在啟動與離線 `config:check` 會拒絕該設定。0 的既有語意不變:join deadline 停用、participant 立即移除、controller 立即排程到期。這不限制只做經過時間比較的 `handRaiseIntervalMs`。 @@ -251,20 +275,34 @@ Web Component 的舞台列每次仍同步最新資格與等待狀態,但姓名 主控成功婉拒後,完整範例、最小 embed 與 Web Component 會向申請者顯示「主控已婉拒這次舉手申請。」獨立普通提示。它只由明確的私人 `hand:rejected` 事件產生,不把自行收回、過期或快照移除猜成婉拒,也不覆蓋錯誤。既有全房 `hand:withdraw`/`room:state` 保留,其他人不收到新通知;離線保留席位仍可被婉拒,但恢復連線不補發歷史。提示在伺服器接受新舉手、上台/成為主控、重連或離房/換席時清除,僅點擊重舉且尚未成功不抹去已知回饋,沒有自動重舉。舊版官方 SDK 安全忽略新事件,新版遇到舊伺服器不推測結果;第三方嚴格事件解析器需自行確認相容性。協定沒有逐次舉手 epoch,不能辨識任意重排的同房同人舊通知。驗證限受控 DOM/socket、公開 SDK 與 Hub;原生畫面及輔助科技宣讀未驗收。 +Web Component 的所有已進房使用者都可展開「台上成員快照」,查看伺服器公開的台上順序、名字、主控/講者角色、靜音/主控鎖定靜音與控制連線保留席位標記。安靜的講者也會列出;離台主控不加入此名單,斷線寬限中的台位仍保留,因此不是在線、正在發言或可聽名單。區塊預設收合,空台會明示;同席位更新保留展開選擇。只在收到目前席位的新房間快照後更新名單,重連保留上次複本並標示歷史,單獨連線狀態恢復不把舊資料當新資料;角色/台位通知先於完整快照時等新快照,不自行推測名單。離房、終止、卸載或換席清空並收合。它只讀公開 speakers 與必要身分/上限欄位,不加入觀眾私有名單、房間代碼或控制按鈕;相同投影與無關品質/說話事件不重建名單,也不新增輪詢或命令。可用 `::part(public-roster)` 設定外觀。受控驗證不代表原生 details、鍵盤/焦點、AT、版面或硬體音訊驗收。 + Web Component 進房後另提供明確的麥克風選擇流程:手動重新整理清單、選擇草稿、送出。列舉不擷取音訊;台下只預選下次擷取,健康台上沿用 SDK 的 replaceTrack 與靜音政策。刷新/送出互斥,重連保留草稿與等待但停用新操作,離房/換席退役舊結果;當前失敗可手動重試。清單不自動更新或切換,也不增加預覽。草稿不代表目前硬體,完成不宣稱已套用;若房間麥克風已失敗,仍須另按既有重試入口。「預設(依 SDK 設定)」遵循原 micConstraints。詳見 client README;受控測試不等同原生權限、鍵盤/AT 或實體音訊驗收。 +Web Component 也提供台下的「測試麥克風音量」,僅由已進房的使用者明確按下按鈕後開始本機測試,可能要求麥克風權限;它不回放、不發布到舞台,也不保證他人能聽見。等待可取消,開始後可停止;取消保留等待狀態至 SDK 請求結束,晚到授權的測試音軌會釋放,但不會關閉原生權限視窗。測試中約每 100 ms 更新音量,失敗只在測試區提示。上台、重連、離房/換席、元件卸載、送出麥克風選擇、頁面隱藏或離開都停止該元件自己的測試與讀取排程,不停止正常舞台麥克風或其他元件的測試;回到前景、重新掛載與更換輸入後不自動重啟。尚未送出的裝置草稿不改變測試輸入;一般收聽屬性變更維持原 client 與有效測試,變更加入設定則退役舊測試。自然結束後可明確重新測試;舊 SDK 缺少方法時顯示固定失敗,不另行擷取。原生按鈕、meter 標籤與控制順序經靜態檢查;實際權限視窗、鍵盤/焦點、AT 與硬體音訊尚未驗收。 + Web Component 已進房後也可用內建名稱欄與「更新名稱」留席更正本人名稱,所有角色皆可操作;目前名稱依伺服器快照,草稿在同席更新/重連時保留。待回覆或重連時不能重複送出,離房/換席會退役舊操作。此編輯不反寫 `name` attribute,明確重新加入仍使用最新屬性;自動重連則沿用 SDK 已接受的名稱。沒有新增擷取、儲存空間或名稱日誌;原生輸入與輔助科技仍需實際驗收。 舉手、麥克風與個人舞台操作也會在初次加入 ACK 前失聯、僅剩舊快照時停用;在轉送狀態事件的同步回呼中點擊舊按鈕,不會先清除錯誤或進入等待。離開房間仍可用於清理。 +Web Component 本人名稱與房名編輯器的 hidden/disabled/aria-busy 依目前 DOM 值比較,只在不同時寫入;仍執行每次同步、權限與等待核對,保留所有接受文字與草稿 value 賦值。固定 120 次實際 SDK/編輯按鈕經 Hub 的操作,受控編輯器寫入由 5,800 降至 1,560,340 次必要變更保持;這是省略等值 setter 呼叫的計數,不是原生瀏覽器速度量測。等值 setter/attribute observer 通知可能減少,並新增 DOM 讀取;不承諾任意宿主 getter/setter 改寫的副作用等價。 + 主控也可在 Web Component 的房間名稱欄按「更新房間名稱」,使用既有 `setRoomName()`,台下主控同樣可用。原始草稿由伺服器驗證,另列的目前房名只依快照更新;同席普通快照與暫時重連保留草稿,等待回覆/重連時停用送出。失去主控、終止連線、離房或換席會清理草稿與舊操作;再次取得主控時從目前房名初始化。不改 room/name 屬性、不重新加入,也不新增媒體操作或儲存。 -Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,等待新回報。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 +Web Component 也顯示最近快照的房間席位:主控以外的總人數/房間上限、其中台上人數/台位上限、觀眾人數,以及另計的主控台上/台下席位。房間上限包含一般觀眾與講者,台位上限不含主控,不能直接把觀眾數或台上總數各自除以上限。舉手不增加人數,暫時離線但仍保留的席位照計;數字不是在線/收聽人數或立即加入保證。重連顯示上次快照,須新權威快照才更新;離房、斷線或終止時清空。只讀既有公開狀態,不顯示個人名單/識別碼/代碼,不新增請求、輪詢或音訊操作;原生版面與輔助科技未驗收。 -Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、下台、換席或麥克風錯誤清除舊摘要,後續新的本人報告才重新顯示,`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 +Web Component 另以普通文字顯示最近收到的發言者回報,按目前台上順序列出名稱與人數;尚未收到、明確空集合與無法對應台上成員分開呈現。改名或移出台上隨快照更新,控制連線寬限中仍在台上的成員不被排除;觀眾與未知 ID 不會洩露。重連、離房或換席清除舊集合,須新的目前權威 state 與後續新回報才恢復;status-only 與已接收/拒絕事件物件重放不恢復。本人或他人下台/轉觀眾通知先移除該 ID,新的權威快照核對前,即使新回報也不借用舊台上名單;重新核對後仍須下一份新回報才重加。本人下台只移除本人活動,不撤銷作為觀眾閱讀其他人回報的資格;合法同 client 斷線重入仍支援。這是伺服器活動偵測,沒有時間戳,並非即時或可聽音訊保證,空集合也不證明沒有音訊;不增加輪詢、媒體工作或 live region,原生輔助科技與實際音訊尚未驗收。 + +Web Component 的本機收聽按鈕 `aria-pressed` 與音量滑桿 `aria-valuetext` 只在目前屬性文字不同時寫入;錯誤或缺失屬性仍會修復。其他 hidden/disabled、文字替換與滑桿 value 賦值保持原樣。受控實際 SDK/Hub 的 64 秒普通事件序列產生 72 次重繪,省去 144 次等值 ARIA setter,並新增 144 次屬性讀取;四次滑桿輸入由八次 ARIA 寫入降為四次必要變更。安靜的 120 秒 keepalive 原本就沒有重繪,這不是閒置 CPU 或原生瀏覽器速度改善的量測。等值屬性 observer 通知會減少;不承諾任意宿主 getter/setter 改寫的副作用等價,原生輔助科技未驗收。 + +Web Component 的台上者收到伺服器本人品質回報後,可看到最近收到的上/下行掉包率及 RTT;只顯示本人,觀眾/台下主控不顯示。缺失值為「—」,不代表 0;RTT 並非端到端音訊延遲。重連、本人下台/轉為觀眾、換席或終止會清除舊摘要;須先收到目前 client 的新權威台上 state,再收到新的本人報告才重新顯示。單獨 status 恢復、角色通知或重放已接收/拒絕的事件物件不能還原。相同 client 合法離房重入仍可由新 state 與新報告恢復。麥克風錯誤僅清除報告,保留合格台位的接收資格,因此不必另等 state,後續新本人報告可顯示;`micready` 不會還原。報告沒有時間戳或 peer 世代,因此數字不是即時健康或麥克風恢復證明;不增加輪詢或音訊操作。 + +Web Component 另有「更新我的連線統計」,觀眾與台上者都可手動讀取本人瀏覽器的接收掉包率/RTT/緩衝及傳送掉包率/RTT。只在明確按下時呼叫既有 `getStats()`,沒有輪詢或自動更新;數字標示為上次手動讀取及 SDK 取樣區間,RTT 不是端到端音訊延遲,也不證明目前可聽。每方向沿用 SDK 最後一筆符合的音訊 RTP 統計,並非全部串流加總;其他 `getStats()` 呼叫共用差值基準,因此區間不是兩次按鈕間隔。等待與讀取失敗保留上次結果,錯誤只顯示固定本地說明;缺失指標為「—」,沒有該方向則顯示暫無統計。重連、離房、終止或換席清除舊結果;恢復有效席位後可重新手動讀取,不自動恢復舊數字。既有原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 共用基準,但不能回寫已退役 UI。手動讀取的同步回呼若已觀察到重連或席位身分失效,當時尚未完成的關房/移交確認即失效;稍後 getter 恢復原值也不能沿用舊確認,但較新合法進房與重新確認不受舊操作影響。不改原有品質回報、媒體或權限,原生瀏覽器/輔助科技與實體音訊未驗收。 Web Component 的「離開房間」會停止本機收聽/擷取及自動重連,保留屬性與音量/收聽靜音偏好;同一元素更換屬性或移除再掛載後仍等待手動「重新加入」。重入採用最新屬性,不保證原台位/主控身分;關房/被踢不提供這個重入操作。離房先退休 client,因此與元件移除一樣不轉送其晚到事件(含 disconnect status),也不新增合成 SDK 狀態。此 UI 使用既有 disconnect,不代表伺服器房間立即關閉;受控 DOM/SDK 假媒體測試仍不等於真實瀏覽器音訊驗收。 +Web Component 的目前主控另有獨立「關閉房間」,台上與台下皆可用;先確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`。離開仍只清理本人連線並沿用主控寬限,關閉則由伺服器終止全房。重連、失權、控制項被移出元件或缺少確認能力時不能送出;確認途中曾失去資格,即使恢復也須重新確認。與移交僅互斥同步確認流程,不新增所有命令的等待鎖;原有換席後再次移交的行為保留。取消、送出與 ACK 不清既有錯誤,也不本機宣告成功、離房或自動重試;關閉文案依既有終止事件,舊請求結果不能污染新席位或較新錯誤。SDK 全域錯誤政策不變。受控完整元件/實際 SDK/Hub 驗證不代表原生確認視窗、版面、鍵盤/焦點/AT 或硬體音訊驗收。 + 連線中取消、關房與被踢會完整停止麥克風/麥克風測試並清除待回覆請求;同一個 client 可在取消後重用。Web Component 快速移除/重掛載時只接受最新一次連線的事件,音訊解鎖失敗可重試,成功後隱藏按鈕。 本機收聽可用 `setPlaybackVolume(0.5)`/`setPlaybackMuted(true)` 調整,`playbackVolume`/`playbackMuted` 即時讀取;不影響麥克風或其他人,重連時保留。範例前端的「音訊設定」也提供音量與靜音收聽控制。 @@ -283,7 +321,7 @@ Web Component 的「離開房間」會停止本機收聽/擷取及自動重連 範例前端的建立/加入房間表單共用一個等待鎖;任一請求進行中,兩個提交按鈕一起停用並標示 `aria-busy`,鍵盤提交也不會重複送出,完成或失敗後恢復。 尚未收到房間狀態時可按「取消連線」,透過 SDK `disconnect()` 結束本機等待;這次取消的建立/加入錯誤不顯示,原請求結束後才能重新提交。收到房間狀態後不再提供取消,請使用房內的離開控制;若此時建立/加入尚未收到 ACK,主動離房也不會把這次已放棄的等待顯示成連線失敗,仍待原請求結束才解除提交鎖。當前真正失敗與關房/被踢的既有診斷照常保留;取消不保證回滾伺服器已接受的操作。 -範例前端的個人靜音、上下台、舉手與主控管理按鈕,在等待回覆期間保留停用狀態與 `aria-busy`,不會因冷卻 tick 或房間快照重繪而再次送出。離房/切換身分會清除舊 pending 狀態,舊完成事件不會解鎖新房操作;核准/婉拒/移出舞台共用每人的 pending key,強制靜音與解除亦共用;控制權移交在全列表保留單一 pending,換碼與關房也受保護。舊房間換碼完成不會覆寫新房通知。主控調整講者的音量 slider 在重連期間停用,恢復房間狀態後重新開放;事件 handler 也拒絕重連中的程式化 change。本機收聽音量與靜音仍可操作。gain 不套用 pending 按鈕鎖,連線正常時仍逐次送出放開時的值;離房後才完成的舊 gain 請求不再顯示錯誤到入口/新房,當前房間的失敗仍正常顯示。 舉手倒數只在畫面可見且尚未歸零時排程下一個顯示秒數邊界;沒有倒數時不執行冷卻輪詢,房內連線統計仍每 2 秒更新;連線數字採目前畫面生命週期內「已成功完成中的最高請求序號」:較新的請求尚未完成,不阻擋較早成功的樣本;較新成功結果已顯示後,舊結果不能覆寫。成功的空結果也會清除數字並退役較舊結果,失敗則保留畫面、不推進此界線。輪詢頻率不變,離房或隱藏前的延遲結果仍不會更新新畫面;不新增 timeout、取消底層統計請求或限制未完成請求數量。 +範例前端的個人靜音、上下台、舉手與主控管理按鈕,在等待回覆期間保留停用狀態與 `aria-busy`,不會因冷卻 tick 或房間快照重繪而再次送出。離房/切換身分會清除舊 pending 狀態,舊完成事件不會解鎖新房操作;核准/婉拒/移出舞台共用每人的 pending key,強制靜音與解除亦共用;控制權移交在全列表保留單一 pending,換碼與關房也受保護。舊房間換碼完成不會覆寫新房通知。主控調整講者的音量 slider 在重連期間停用,恢復房間狀態後重新開放;事件 handler 也拒絕重連中的程式化 change。本機收聽音量與靜音仍可操作。gain 不套用 pending 按鈕鎖,連線正常時仍逐次送出放開時的值;離房後才完成的舊 gain 請求不再顯示錯誤到入口/新房,當前房間的失敗仍正常顯示。 舉手倒數只在畫面可見且尚未歸零時排程下一個顯示秒數邊界;沒有倒數時不執行冷卻輪詢,房內連線統計仍每 2 秒更新,分別呈現接收掉包/RTT/緩衝及傳送掉包/RTT;不再將兩個不同 RTT 合成一個泛稱延遲。畫面標示上次完成讀取與 SDK 取樣區間,RTT 不是端到端音訊延遲,也不保證即時或目前可聽。方向存在但指標缺少時顯示「—」,沒有該方向顯示暫無統計,兩方向皆無的成功結果仍清空整行;0 保留。SDK 每方向沿用最後符合的音訊 RTP,非全部串流加總;其他呼叫者共用差值基準,取樣區間不一定是 2 秒。紅色警告仍只按未四捨五入的掉包率至少 5% 判斷,因此 4.99% 顯示 5.0% 也不標紅;連線數字採目前畫面生命週期內「已成功完成中的最高請求序號」:較新的請求尚未完成,不阻擋較早成功的樣本;較新成功結果已顯示後,舊結果不能覆寫。成功的空結果也會清除數字並退役較舊結果,失敗則保留畫面、不推進此界線。房間或本人 participant 身分改變時,已顯示的舊席位數字與紅色警告立即清空;新席位第一次取樣失敗也不沿用舊歷史,直到其自身成功樣本才顯示。普通同席快照與保留原席位的重連仍保留歷史並沿用可見時輪詢,不重設 SDK 差值基準。輪詢頻率不變,離房或隱藏前的延遲結果仍不會更新新畫面;不新增 timeout、取消底層統計請求或限制未完成請求數量。 完整範例的房內連線資訊旁提供「重試音訊連線」:先前協商失敗但席位仍在時,可明確重送目前 peer 的 SDP offer,不必離房或用無關操作觸發快照。等待期間停用並保留跨重畫的 pending;重連時不接納新操作,離房/換席後的舊完成不更新新畫面。成功提示僅表示協商迴圈已收到 ACK、套用 answer 並處理緩衝 ICE,不表示已聽到聲音或 ICE 已連通;麥克風擷取與瀏覽器播放仍分別使用原「重試麥克風」及「點擊啟用音訊」。此入口不自動重試,不重新擷取、換 peer 或強制 ICE restart。SDK 的 `retryAudioConnection()` 會拒絕未進房、斷線、不可用 peer 及正在協商的呼叫;取消呼叫方等待不保證終止瀏覽器原生 SDP 操作,後續操作可能仍需等待原協商釋放。既有 error 事件與逾時政策保持;受控 SDK/Hub/DOM 驗證不等同原生瀏覽器、ICE、輔助科技或硬體音訊驗收。 @@ -297,12 +335,34 @@ Web Component 的「離開房間」會停止本機收聽/擷取及自動重連 SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法低限速(例如 `limits.controlPerSecond: 1`)下定期 ping 和自身操作相撞而被伺服器 1008 斷線。已有保留送出時段時省略該輪 ping,空閒才先保留控制間隔再送;正常回覆維持連線存活,完全無回覆的靜默期限仍優先檢查。不更動伺服器限流、ICE 排程或請求容量/ACK 期限。預設每秒 20 次的對照未重現此碰撞;驗證限本機真實 WebSocket/SDK 與受控或原生 timer,不宣稱原生瀏覽器或實體音訊驗收。 +Web Component 的主控「舉手申請」可用「搜尋舉手者名稱」縮小長佇列;輸入會以解碼後名字做去除首尾空白、不分大小寫的包含比對,並顯示符合/總申請數,沒有符合項目時另行提示。「清除搜尋」還原全部申請。同名者仍各自保留,順序不變;篩選只隱藏列,不撤銷舉手、不釋放等待中的處理,也不送出控制請求。只搜尋已收到的主控舉手名單,不搜尋參與者 ID 或擴張觀眾名單。一般同席更新與重連保留搜尋;重連的歷史名單仍不能處理。失去主控、離房、終止、換房/席位或移除元件時清除搜尋與名單。原生搜尋欄、鍵盤/焦點、AT 與長清單版面尚未驗收。 + +SDK 的 typed `client.on(type, listener, options?)` 可選 `{ once, signal }`,方便一次性監聽或以 AbortController 解除訂閱,同時保留事件 detail 型別與原本回傳的取消函式。它沿用原生 EventTarget 的 callback 身分,不包裝成獨立訂閱;取消只停止監聽,不取消請求、媒體或已執行的回呼。兩參數呼叫保持相容,其他原生選項仍用 addEventListener。 + 完整 API 見 [`packages/client/README.md`](packages/client/README.md);`web/embed.html` 是最小嵌入範例。 +完整範例的「複製邀請連結」在開始前也會核對目前房間、本人主控身分、有效邀請連結與當前按鈕/輸入欄位;舊畫面保留的 handler 或同步 state 回呼內的過期按鈕,不會提交舊連結或取代新複製工作的結果順位。這是受控程式化/同步重入邊界的防護,不表示一般實體點擊會插入同步狀態派發。有效同房重連仍可複製,同一有效連結的重畫與已開始複製的既有完成規則不變;不撤銷已交給瀏覽器的剪貼簿寫入。原生剪貼簿、焦點/鍵盤與 AT 尚未驗收。 + +完整範例也顯示「房間席位」快照:主控以外總人數/房間上限、其中台上人數/台位上限、觀眾數,以及另計主控的台上/台下位置。觀眾標頭只顯示人數,不再把全房上限當成觀眾專屬容量;例如一位講者與兩位觀眾已佔滿三個一般席位。斷線寬限席位仍計入,舉手不加人數,因此不是在線/收聽人數或入場保證。只有當前席位的新權威快照更新數字;重連保留複本並標示歷史,單獨狀態恢復不標新資料,離房、終止或換席清除舊摘要。摘要只讀必要公開欄位,不讀觀眾私有名單;既有舞台/操作節點與觀眾篩選保持,不新增輪詢、命令或媒體操作。原生版面、鍵盤/AT與實體音訊尚未驗收。 + +最小 embed 也顯示房間席位快照:主控以外總人數/房間上限、其中台上人數/台位上限、觀眾數與另計的主控台上/台下位置。房間上限包含一般觀眾與講者,台位上限不含主控;舉手不另計、斷線保留席位仍包含,因此不是在線/收聽人數或即時入場保證。只有目前進房流程的新權威 state 更新數字;重連保留同席位上次快照,單獨 status 恢復不標新資料。取消、離房、斷線、終止或替換 entry 清空,舊client的晚到狀態/終止事件不覆寫新加入。僅保存驗證後數量、上限與位置,不讀私有名單/代碼/token,不新增輪詢、命令或媒體操作;無效投影僅隱藏摘要。原生版面、鍵盤/AT與實體音訊尚未驗收。 + +Web Component 的台上成員快照:公開台上名單也會記住同一次 state 回呼較早觀測到的席位失效:即使較晚讀取又恢復舊身分,也不重新顯示已清空的舊快照。真正的新房間/新席位仍可由單一新權威快照建立;若這個新席位在同一次處理中再次失效,須等待新的快照。這是顯示生命週期修正,不增加命令或媒體操作。 + +最小 embed 也可展開「台上成員快照」,讓觀眾與講者看到安靜的台上成員。名單只呈現公開快照中的順序、名字、主控/講者角色、靜音/主控鎖定靜音與控制連線保留席位;離台主控不加入,斷線寬限席位仍包含。這不是在線、正在發言或可聽名單,不含觀眾私有名單、代碼或控制按鈕。區塊預設收合,空台明示;同席位更新保留展開選擇,相同投影與無關說話/品質事件保留原名單節點。重連保存上次快照並標示歷史,只有目前席位的新權威 state 更新名單;單獨 status 與先於完整快照的角色/台位通知不推測新成員。離房、終止、換席或新加入流程清空並收合。沒有新增命令、輪詢或媒體操作;原生 details、鍵盤/焦點、AT、版面與硬體音訊尚未驗收。 + +最小 embed 另提供「更新我的連線統計」,所有有效已進房席位(含觀眾)都能明確讀取一次本人瀏覽器的接收掉包率/RTT/緩衝與傳送掉包率/RTT。只沿用既有 `getStats()`,不輪詢、不自動呼叫,也不改品質回報或音訊。每方向取 SDK 最後符合的音訊 RTP,非全部串流加總;數字標示為上次手動讀取與 SDK 取樣區間,其他呼叫者共用差值基準,因此不是按鈕間隔,RTT 不是端到端音訊延遲或可聽保證。0 保留,缺失/非法指標為「—」,缺少方向為暫無統計。等待與失敗保留歷史,固定失敗提示只在此區顯示,不覆寫全域錯誤。重連清除舊結果;有效席位恢復後可手動再讀,status 本身不讀取。離房、取消、斷線、終止與新進房流程退役舊操作;既有 embed 斷線後 controls 不自行恢復,重新送出進房才建立新操作入口。不同 client 的統計不共用頁面 audio 裝置操作的等待鎖,舊 promise 不能回寫新畫面;原生讀取無取消能力,保留 peer 的遲到完成仍可能推進 SDK 差值基準。普通角色/台位/麥克風變化不抹去同席歷史,不新增權限、擷取、播放、命令或 live region。原生版面、鍵盤/AT 與實體音訊尚未驗收。 + +最小 embed 的台上使用者可查看「最近收到」的本人連線品質:上行/下行掉包與 RTT;RTT 不是端到端音訊延遲。資料只來自既有伺服器品質回報,不另輪詢或操作媒體。缺少或非法欄位顯示「—」,沒有本人回報便隱藏;不顯示其他人的數值,台下主控也不借用別人的回報。重連、本人下台、離房或替換進房流程會清除,重新取得權威台上快照後,仍須再收到品質回報才顯示。相同席位的麥克風失敗/準備完成、靜音或重試不抹除已收到的傳輸歷史,也不產生新數字。回報沒有量測時間或媒體世代,因此「最近收到」不代表最新量測、音訊已恢復或可聽;原生 layout、鍵盤/AT 與實體音訊未驗收。 + +最小 embed 的所有房內角色也可查看「最近收到的發言回報」:依目前公開台上名單顯示名稱與人數,觀眾與台下主控同樣可看,斷線寬限內仍在台上的成員不因控制連線離線而被濾除。尚未收到報告、明確空回報,以及非空回報但沒有可對應台上成員會分開顯示;未知/離台 ID 不顯示或留待未來自動復活。下台/轉觀眾通知先移除該 ID,直到新的權威快照核對前,新回報也不借用舊台上名單;重新出現仍需後續新回報。重連、離房或替換進房流程清空,status 恢復與 retained speaking getter 不當新資料。名稱只取公開名單並安全顯示文字,不讀私有觀眾/代碼/token;相同名稱的不同成員仍各計一人。這是歷史偵測回報,不保證此刻發言、可聽播放、麥克風恢復或零音訊;不增加輪詢、計時器或媒體操作,原生瀏覽器/鍵盤/AT 與硬體音訊未驗收。 + 最小 embed 的目前主控另可明確「關閉房間」;台上與台下主控皆可用。按下先確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,同意後才呼叫既有 `closeRoom()`。這與「離開房間」不同:離開只斷線,房間仍可能保留主控寬限並交接;關閉會使所有人離場。重連或缺少確認能力時不能送出,缺少 SDK 方法時不提供替代關房方式。確認途中曾失權、換房/席位、重連或能力消失,即使隨即恢復也需重新確認;與控制權移交只互斥同時開啟的確認流程,不新增所有命令的全域鎖。取消、送出與 ACK 本身不清其他錯誤,不自動重試、離房或本機宣告成功;既有終止事件仍依原流程清理舊錯誤並顯示「房間已關閉」。終止事件先於 ACK 時請求可因斷線拒絕,但不應以過期拒絕覆蓋關閉結果;舊席位結果也不污染新加入。伺服器仍裁決權限,SDK 全域錯誤政策不變。驗證限受控完整頁面/實際 SDK/Hub,原生確認視窗、版面、鍵盤/焦點/AT 與硬體音訊未驗收。 最小 embed 的目前主控可展開「舉手申請」,依伺服器快照順序查看具名申請並明確「核准上台」或「婉拒」;經控制權移交或自動接任的台下主控也可使用。每個目標的兩個動作共用等待鎖,其他目標仍可操作;快照先移除申請不代表請求已完成,同 ID 再出現也不提前解鎖。重連保留上次名單、展開與等待但停用,失權、離房、終止或新加入會清理舊席位結果。離線申請保留在原順序,滿台/離線的核准錯誤仍由伺服器裁決,不因此封鎖婉拒;沒有樂觀移除、自動主持或額外媒體呼叫。舉手面板只顯示申請名單,不加入完整觀眾名單。SDK 全域錯誤與通知先於快照的既有界線保持,原生 details/鍵盤/焦點/AT 與實體音訊尚未驗收。 +最小 embed 的舉手面板可用「搜尋舉手者名稱」做本機名稱篩選,並顯示符合/總數;「清除搜尋」恢復全部申請。搜尋字串去除首尾空白後,與解碼後姓名做不分大小寫的子字串比對;不搜尋 ID、房間代碼或完整觀眾名單。同名者仍是不同目標,伺服器順序、原列與每人等待鎖保留;隱藏待完成列不會解除等待,篩選本身不送主持命令。相同主控席位更新與重連保留搜尋字串,重連期間仍不能處理申請;失權、換房/席位、離房或新加入會清空搜尋與名單。角色/移交通知不取代權威快照;通知先於快照的短窗沿用原主持政策,由伺服器裁決。原生搜尋欄與按鈕具有標準操作語意;受控測試尚不代表實際鍵盤/焦點、AT 或長清單版面驗收。 + 最小 embed 的主控另可展開「舞台管理」,對其他目前台上發言者明確「鎖定靜音/解除靜音鎖定」或「移下台」,台下主控也可使用。解鎖保留對方自行靜音設定;未自行靜音者可能恢復既有麥克風上行,不能把解鎖理解為保證仍無聲。移下台後仍留在房間收聽,不是踢出;暫時離線保留席位也可管理。每個目標共用等待鎖,快照先移除後同 ID 回台仍等原操作完成,沒有控制權移交等待時,其他目標與舉手主持保持獨立。重連保留上次名單、展開及等待但停用,失權或換席清理舊結果;權限與競態仍由伺服器裁決,不樂觀更動席位或自動重試。不新增踢人或完整觀眾管理,也不新增主控擷取/播放;被移下台者沿用 SDK 停止自己的 track 並更新收聽協商。原生瀏覽器、鍵盤/焦點/AT 與硬體音訊未驗收。 最小 embed 可在同一舞台列按「移交控制權」,具名確認後交給其他已連線台上發言者。原主控失去主持權限,但保留目前台位、留在房間,不會自動離開或關房;只按離房仍遵循原主控寬限與自動接任規則,不能指定接班者。任何舞台靜音/移下台/增益調整尚未完成時不能移交,確認或移交等待時整個舞台列表互鎖;舉手主持與個人控制等其他入口仍各自獨立。取消不清原錯誤,確認途中觀測到失權、重連或目標失格,即使立即恢復也需重新確認;相關通知先於快照時只暫停移交,既有靜音/移下台裁決不變。目標在送出後下台仍可能依既有伺服器規則合法接任,不保證原子式「執行時仍台上」。SDK/server 權限不變;原生確認視窗、鍵盤/焦點/AT 與硬體音訊尚未驗收。 @@ -311,6 +371,8 @@ SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法 最小 embed 的舞台列也會比較目前 DOM 值,省去相同 gain 界限、停用/等待屬性及移交控制的重複寫入;原有兩次移交資格核對與每次同步保留,不快取快照或跳過事件。受控完整 embed 經公開 SDK/validator/Hub 的 24 筆改名、房名、同值增益與舉手/收回操作,1/3/8 講者的等值列寫入由 264/792/2,112 降為 0;草稿 value 與原節點/handler 身分均維持。權威增益、講者名稱、靜音或等待改變仍做必要更新,宿主改過的值也會修正;面板與列退役寫入不在零寫入範圍。這是受控 DOM 工作量,尚未量測原生瀏覽器延遲、CPU、layout、焦點或 AT 改善。 +最小 embed 另提供台下的「測試麥克風音量」:只有已進房且目前離台時,明確按鈕才呼叫既有 `startMicTest({ signal })`,可能要求瀏覽器麥克風權限。它只顯示本機音量,不回放、不送上舞台,也不保證他人能聽見。等待時可取消,取得音軌後可停止;取消不會關閉原生權限視窗,但稍後授權取得的測試音軌會由 SDK 釋放。測試中約每 100 ms 讀取一次音量,停止、自然結束、上台、重連、離房/換席、送出麥克風選擇、頁面隱藏或離開都清理測試與讀取排程,不停止正常舞台麥克風。返回前景或選擇裝置後不自動重啟,必須再按測試。裝置草稿並未套用時,測試仍沿用 SDK 目前輸入設定。失敗只在測試區顯示固定提示,可手動重試;舊 SDK 缺少方法時同樣安全失敗,不改用其他擷取方式。原生按鈕與標籤支援標準操作語意,但受控 DOM/SDK 測試不等同實際權限視窗、鍵盤/焦點、AT 或硬體音訊驗收。 + 最小 embed 已進房時可手動重新整理揚聲器/耳機清單,再明確送出輸出裝置選擇;選單本身只是草稿,空值表示送出系統預設,完成不代表已播放或能聽見聲音。清單名稱安全顯示,缺失草稿保留但不能送出,不自動枚舉、播放、擷取或儲存裝置。舊 SDK 缺少所需 API 或瀏覽器不支援時顯示系統音訊設定說明。頁面所有替代 client 共用同一 audio 元件,因此草稿/清單及單一刷新/送出等待鎖跨離房、新加入與 client 替換保留;離房不會取消已送交瀏覽器的 setSinkId,也不會重設實際輸出。新席位需等待舊操作結束才可再次操作,舊席位結果不回填新錯誤或清單;永不完成的操作仍等待,沒有 timeout 或取消保證。原生權限、輸出硬體、鍵盤與輔助科技尚未驗收,受控測試不代表可聽音訊。 主機使用自訂 `server.wsPath` 時,可填「WebSocket 路徑」或以 `?wsPath=%2Fcontrol` 預填,缺省/空白仍用 `/ws`;模組仍從同一主機的 `/lib/stage-client.js` 載入。路徑必須以單一 `/` 開頭,不接受跨主機 authority、scheme、反斜線、query/fragment、控制字元、內部空白或一般/`%2e` 編碼的 dot segment。安全的百分比編碼保留為 pathname,不會再解碼成另一個主機;query 本身先依 URLSearchParams 解碼一次,例如預填字面 `%2F` 路徑片段要寫 `%252F`。Unicode 路徑依 URL 規則序列化,因此伺服器 `wsPath` 須與序列化後 pathname 相符,不保證任意輸入 byte 原樣保留。每次送出在任何等待前,一起擷取當時的主機、路徑、房間 ID、代碼與名稱;等待舊連線清理、模組載入或 hello 期間改欄位,只影響下一次明確送出,不把新房間資料混入舊主機的加入請求。房間 ID/代碼/名稱沿用原 trim,空白代碼仍省略;驗證失敗不載入模組或建立連線。此頁會執行所選主機提供的 JavaScript,請只使用可信任主機;送出快照避免操作時序混用,不是對惡意模組的隔離。 @@ -335,6 +397,15 @@ SDK 的應用層 keepalive 會與控制請求共用送出間隔,避免合法 ## 測試與驗證 +### GitHub 自動驗證 + +[Node.js CI](.github/workflows/ci.yml) 在分支 push 與 pull request 開啟、更新或重新開啟時,自動使用標準 Ubuntu 24.04 runner/Node.js 24,依序執行 `npm ci --no-audit --no-fund`、`npm run typecheck`(四份型別設定)、`npm test`、`npm run build` 與 `npm run test:server-package`。只跑分支 push,不跑 tag、排程或部署;失敗即停止,單次 job 最多 20 分鐘。同一事件種類/ref 的新執行取代舊執行,push 與 PR 不互相取消。PR 預設檢查 GitHub 的合併測試 ref,push 檢查該提交,兩者結果要分別查看。 + +另有標準 Windows Server 2025 job,使用相同 Node.js 24 與固定 actions,僅執行 `node --test --test-timeout=120000 test/windowsStageStart.test.ts`,不安裝專案相依套件、不建置或部署服務。它要求 runner 的 Windows PowerShell 5.1 與 PowerShell 7 都存在,透過暫存腳本與受控 Node 程序檢查首次啟動、日誌/PID、狀態與停止;未修版本的失敗亦列入對照。此 job 最多 10 分鐘,Linux 上相應原生案例明確跳過,不能把跳過算成 Windows 通過。Windows job 的成功僅代表這些控制腳本情境,不是整個伺服器/WebRTC/硬體音訊的 Windows 驗收。 + +官方 actions 固定完整 commit SHA;token 僅 `contents: read`,checkout 不保留認證,不使用自訂 secrets、快取或上傳建置產物,也不更動分支保護。這是可重現的安裝、型別、既有自動測試與建置檢查;另外包含 Linux 編譯後入口的本機啟停與 HTTP/WebSocket hello 交付檢查;沒有執行 `npm audit`、完整部署驗收、werift loopback、負載測試、原生瀏覽器/AT/硬體音訊或跨 NAT 驗收,不能當作全部 Gate 通過。需要的媒體驗證仍依 AGENTS.md 手動完成。首次或外部貢獻者的 PR 是否需批准執行,仍由 GitHub 的既有設定決定。 + + ```bash npm run typecheck # 伺服器+client+web npm test # 控制面流程 1–4、不變量、斷線寬限與回座、信令與政策競態、ws 邊界(驗證、限流、尺寸、轉義、靜態檔、准入)、混音器與時鐘、下行分級/共用編碼/靜音 DTX、worker 重生 @@ -367,7 +438,7 @@ npm audit 完整範例的「點擊啟用音訊」會在播放要求尚未完成時維持停用與 `aria-busy`,即使房間快照、上台邀請或其他提示重新繪製,也不會再次呼叫播放。失敗仍顯示原有錯誤並可手動重試;離房或換身分撤銷舊操作,信令重連保留目前收聽與 pending 狀態。播放呼叫仍同步發生於點擊處理內,不新增自動重試或改變瀏覽器 autoplay 規則。驗證使用完整範例與公開 SDK 的受控 audio/socket,未驗證原生瀏覽器音訊效果。 -完整範例提供可展開的「舞台連線品質」面板,只對主控與台上者顯示。使用既有伺服器品質事件,列出目前發言者最近收到的上行/下行掉包率與 RTT;缺少或無效數值顯示「—」,尚未收到資料時顯示等待提示,不增加輪詢。RTT 不是端到端音訊延遲,回報也沒有時間戳,不能視為即時音訊保證。有效更新保留展開狀態與對應列;離房、換身分或失去檢視資格清空並收合。重連時清空面板資料、保留展開狀態,恢復後等待下一次收到的回報;該回報的收集仍可能早於恢復連線。名稱取目前舞台名單並以文字呈現,未知/已離台的 ID 不顯示;既有品質警示不變。驗證涵蓋模型/Hub 收件角色與受控 DOM 生命週期,未完成原生瀏覽器、鍵盤或輔助科技驗收。 +完整範例提供可展開的「舞台連線品質」面板,只對主控與台上者顯示。使用既有伺服器品質事件,列出目前發言者最近收到的上行/下行掉包率與 RTT;缺少或無效數值顯示「—」,尚未收到資料時顯示等待提示,不增加輪詢。RTT 不是端到端音訊延遲,回報也沒有時間戳,不能視為即時音訊保證。有效更新保留展開狀態與對應列;離房、換身分或失去檢視資格清空並收合。本人轉為觀眾、收到非主控下台通知,或台下主控已移交控制權時,即使其後的房間快照尚未抵達,也先清空、隱藏並收合;普通重繪或仍保留的舊快照不會重新開啟。新的目前權威快照重新核對資格後,仍須新品質回報才有數字。主控下台仍可閱讀、保留台位的控制權移交與其他人的下台不撤銷本人的閱讀資格。這是現有通知順序的畫面隔離,不替沒有房間世代/時間戳的事件新增任意重放保證。仍具檢視資格時,重連會清空面板資料並保留展開狀態,恢復後等待下一次收到的回報;該回報的收集仍可能早於恢復連線。名稱取目前舞台名單並以文字呈現,未知/已離台的 ID 不顯示;既有品質警示不變。驗證涵蓋模型/Hub 收件角色與受控 DOM 生命週期,未完成原生瀏覽器、鍵盤或輔助科技驗收。 範例的品質警示沿用每次 render 的 participant→rows 索引,只更新前次/本次品質報告涉及的人,省去每則事件掃描所有觀眾列;省略的人仍會移除舊警示,同人出現在舉手與觀眾名單時會一併更新。`test/webQuality.test.ts` 以 301 人、593 個顯示列、100 次單人品質報告計數:完整列查詢 100 → 0 次,badge 查詢 59,300 → 100 次。這是 fake DOM 操作數,不是瀏覽器 CPU/延遲量測;房間重繪、品質資料本身的更新頻率與 badge 文案不變。 @@ -387,6 +458,8 @@ npm audit 主控的完整範例提供「篩選觀眾名稱」:只在已授權的觀眾名單中,以顯示名稱做去除查詢首尾空白、不分大小寫的字面子字串比對;查詢與解碼後名稱先採 NFC 正規化,讓組合/分解形式相同的字元(例如 é 或韓文字)一致,與伺服器名稱正規化對齊,不移除重音或做 NFKC 相容字元折疊;中文與重複名稱皆保留原順序,不比對 participant ID。輸入只隱藏/顯示既有觀眾列,不重建整個房間,也不影響舞台、舉手佇列、pending 操作或伺服器名單。空白查詢與「清除篩選」恢復全部觀眾,另顯示符合筆數或無結果提示。查詢僅留在記憶體,同席快照與重連保留;換房、換身分、失去主控/名單權限或離房清除,不寫網址或 storage。篩掉的觀眾操作與舊已移除列不能藉程式 click 繞過目前角色/pending 檢查。搜尋欄位在房間重畫時保留同一連接節點;原生焦點、輸入法、鍵盤/輔助科技與版面仍需實際瀏覽器驗收,測試只驗證受控 DOM 行為。 +完整範例另提供獨立的「搜尋舉手者名稱」,讓主控在長舉手佇列中找出待核准或婉拒的申請;它不共用觀眾搜尋字串,也不改變觀眾搜尋結果。查詢去除首尾空白,與解碼姓名一起做 NFC 正規化及不分大小寫的字面子字串比對;不搜尋 ID、代碼或未舉手觀眾。符合/總數與無結果提示僅反映目前已收到的申請;清除搜尋還原全部列,同名目標與伺服器順序保持。篩選只隱藏既有舉手列,不重畫整個房間、不重設尚未送出的音量草稿,也不解除待完成操作;隱藏列的核准、婉拒、移交及踢除皆不可從舊回呼送出。相同主控席位的快照重畫與重連保留查詢,重連仍不能主持;換房/席位、失去主控、離房或回到加入畫面清除。角色通知與權威快照的原有裁決方式保持。搜尋不送命令、不自動選人、不寫網址或儲存資料;原生焦點、輸入法、鍵盤/AT 與長清單版面仍未驗收。 + ### 本機控制面容量煙霧測試 `node scripts/bench-control-plane.ts` 只在 `127.0.0.1` 啟動臨時伺服器,依範本的 300 位觀眾上限,以每批 50 條真實 WebSocket 驗證進房、Mock SDP、滿房拒絕、10 位斷線回座與全體關機通知。30 秒硬期限避免卡住;完成即關閉連線與伺服器。輸出 ACK 延遲分位數、CPU、取樣 RSS 高點、事件迴圈延遲與訊息量,並檢查可用的輸入/輸出超限計數器為零(舊版無該指標時輸出 `null`)。 diff --git a/package.json b/package.json index bcc61ff..6435419 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,17 @@ "web" ], "scripts": { - "build": "npm run build:server && npm run build -w packages/client && npm run build -w web && npm run build:compress", + "build": "npm run build:server && npm run build -w packages/client && npm run build -w web && npm run build:compress && npm run test:client-package", "build:server": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit -p tsconfig.json && npm run typecheck -w packages/client && npm run typecheck -w web", "test": "node --test --test-timeout=20000 \"test/**/*.test.ts\" && npm run test -w packages/client", + "test:client-package": "node scripts/check-client-package.ts", "start": "node dist/src/index.js", "config:check": "node src/check-config.ts", "dev": "node --watch src/index.ts", "dev:web": "npm run dev -w web", - "build:compress": "node scripts/precompress.ts packages/client/dist web/dist" + "build:compress": "node scripts/precompress.ts packages/client/dist web/dist", + "test:server-package": "node scripts/check-server-package.ts" }, "dependencies": { "@evan/opus": "1.0.3", diff --git a/packages/client/README.md b/packages/client/README.md index 7f5cadf..25e8295 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -2,6 +2,8 @@ 建置:`npm run build -w packages/client`。無框架與執行期依賴。 +已有產物時可從倉庫根目錄執行 `npm run test:client-package`,以只含 `package.json`/`dist` 的暫存外部 consumer 驗證兩個 package 入口、共用匯出身分、受控自動註冊及 NodeNext/Bundler 嚴格型別宣告。根目錄 `npm run build` 最後會自動執行;workspace build 本身仍只建置。此檢查不自動建置/安裝/發布,不讀取原始碼作為套件替代,缺少或無效產物會失敗;受控 Node 與型別通過不代表原生瀏覽器/麥克風/音訊驗收。 + 導覽:[快速開始](#快速開始) · [房間操作](#房間操作) · [請求期限](#請求期限) · [事件與狀態](#事件與狀態) · [連線生命週期](#連線生命週期) · [本機收聽音量](#本機收聽音量) · [麥克風](#音訊裝置與麥克風測試) · [取消預覽](#取消麥克風預覽) · [音訊統計](#音訊統計) · [Web Component](#web-componentstage-client) · [測試](#信令整合測試) ## 快速開始 @@ -36,6 +38,8 @@ 最小 `web/embed.html` 提供目前主控專用的「舉手申請」收合面板,台上與台下主控皆可明確呼叫既有 `approve(id)`/`reject(id)`。名單只取目前權威 `state.hands`,按原順序與 participant ID 重用列;姓名安全解碼後以 textContent 顯示,相同姓名不混成同一人,暫時離線申請保留並標示。未加入、一般觀眾與發言者沒有此主持入口;舊 SDK 缺少方法時不會強行呼叫。 +最小 embed 的舉手面板可用「搜尋舉手者名稱」做本機名稱篩選,並顯示符合/總數;「清除搜尋」恢復全部申請。搜尋字串去除首尾空白後,與解碼後姓名做不分大小寫的子字串比對;不搜尋 ID、房間代碼或完整觀眾名單。同名者仍是不同目標,伺服器順序、原列與每人等待鎖保留;隱藏待完成列不會解除等待,篩選本身不送主持命令。相同主控席位更新與重連保留搜尋字串,重連期間仍不能處理申請;失權、換房/席位、離房或新加入會清空搜尋與名單。角色/移交通知不取代權威快照;通知先於快照的短窗沿用原主持政策,由伺服器裁決。原生搜尋欄與按鈕具有標準操作語意;受控測試尚不代表實際鍵盤/焦點、AT 或長清單版面驗收。 + 同一目標的核准與婉拒共用等待鎖,其他目標仍能操作;送出前先保留 token,再核對當前 client/加入世代/房間/席位/主控任期及原列。快照可在 ACK 前移除申請,但不提前解除等待;同 ID 再次出現仍等原請求結束,不樂觀更動名單。重連保留上次名單、展開與 pending,但暫停新操作;失權、斷線、離房、取消、新加入、關房或被踢會退役並清空面板。舊 promise 不重建舊畫面、不解除新等待;目前失敗可再次明確點擊重試,開始或成功不清除其他錯誤。 伺服器仍判定權限、目標舉手狀態、連線與舞台容量。滿台/離線時核准可能回 `stage_full`/`conflict`,但仍可婉拒;不按本機人數一起封鎖兩個動作。`hand`/`role`/`transferred` 通知不是另一份成員真值,快照尚未更新的短窗仍由伺服器裁決;協定沒有每次舉手 epoch,不能辨認任意延遲通知中的每次重新舉手。既有 SDK 全域 error 沒有請求 ID,仍照常回報,沒有完整舊 wire 錯誤隔離保證。舉手面板沒有完整觀眾名單、自動選人/重試、timer、storage 或主持者額外 capture/play;獲核准者仍走原本上台流程。受控完整模組/公開 SDK/validator/Hub 測試不代表原生 details、鍵盤、焦點、AT、WebRTC 或硬體音訊驗收。 @@ -114,7 +118,19 @@ hello、內部 SDP/ICE 信令及自動回座不占用此上限,仍遵守原 ## 事件與狀態 -`state`、`me`、`status`、`speaking`(目前說話中的 participantId 集合)為即時 getters。`on(type, listener)` 回傳取消訂閱函式;事件也可透過 EventTarget 的 `addEventListener` 使用。事件:`state`、`status`、`created`、`closed`(`{roomId, reason?}`;`reason: 'shutdown'` 表示伺服器停止或重啟,而非主控關房)、`kicked`(被主控踢出;已斷線且不會自動重連)、`hand`、`invite`、`stagejoined`、`stageleft`、`transferred`、`mic`、`role`、`speaking`(說話中集合;伺服器於成功建立/加入/恢復連線的 state 之後、ACK 之前先同步目前集合,之後隨集合變動更新)、`quality`(主控與台上者每隔數秒收到所有上行者的 `{participantId, uplinkLossPercent?, downlinkLossPercent?, rttMs?}`)、`error`、`micerror`、`micready`、`audioblocked`。`quality` 是本輪完整集合;伺服器既有品質輪次完成時若已無發布者,會送空陣列供清除舊資料,之後停止排程。仍有發布者但統計全部失敗/缺失時不因此送空陣列。清空不增加 timer 或 timeout,未完成的統計請求仍可能延後事件;關房或停用品質回報不另發此事件。狀態為 waiting/live/reconnecting/disconnected。名稱先用 `decodeName()` 還原伺服器的五種 HTML entities,再以 `textContent` 顯示,勿使用 innerHTML。 +`state`、`me`、`status`、`speaking`(目前說話中的 participantId 集合)為即時 getters。`on(type, listener, options?)` 回傳取消訂閱函式;事件也可透過 EventTarget 的 `addEventListener` 使用。事件:`state`、`status`、`created`、`closed`(`{roomId, reason?}`;`reason: 'shutdown'` 表示伺服器停止或重啟,而非主控關房)、`kicked`(被主控踢出;已斷線且不會自動重連)、`hand`、`invite`、`stagejoined`、`stageleft`、`transferred`、`mic`、`role`、`speaking`(說話中集合;伺服器於成功建立/加入/恢復連線的 state 之後、ACK 之前先同步目前集合,之後隨集合變動更新)、`quality`(主控與台上者每隔數秒收到所有上行者的 `{participantId, uplinkLossPercent?, downlinkLossPercent?, rttMs?}`)、`error`、`micerror`、`micready`、`audioblocked`。`quality` 是本輪完整集合;伺服器既有品質輪次完成時若已無發布者,會送空陣列供清除舊資料,之後停止排程。仍有發布者但統計全部失敗/缺失時不因此送空陣列。清空不增加 timer 或 timeout,未完成的統計請求仍可能延後事件;關房或停用品質回報不另發此事件。狀態為 waiting/live/reconnecting/disconnected。名稱先用 `decodeName()` 還原伺服器的五種 HTML entities,再以 `textContent` 顯示,勿使用 innerHTML。 + +`on` 的第三個參數可用公開型別 `StageClientEventOptions` 指定 `once?: boolean` 與 `signal?: AbortSignal`,同時保留事件 `detail` 的型別推論: + +```ts +const controller = new AbortController(); +const off = client.on('state', ({ detail }) => { + console.log(detail.roomId); +}, { once: true, signal: controller.signal }); +// off() 或 controller.abort() 可解除尚未觸發的訂閱。 +``` + +`once` 沿用原生 EventTarget,在第一次回呼前移除;已 abort 的 signal 不啟用訂閱。取消訂閱與 abort 只影響監聽,不取消進房、控制請求、媒體或已開始執行的回呼。省略 options 的兩參數呼叫保持原行為。只轉交 once/signal;即使傳入結構上較寬的物件,也不轉交 capture/passive,回傳的取消函式固定對應非 capture 監聽。相同事件與相同 callback 沿用宿主原生 EventTarget 的註冊身分/去重規則,不是彼此獨立的訂閱;多次註冊搭配不同 signal 的細節依執行環境,不保證 Node 與所有瀏覽器一致。需要其他原生選項仍可用 addEventListener。受控 Node/型別驗證不等於原生瀏覽器或音訊驗收。 舉手/收回的 `hand` 事件保持伺服器順序。`raiseHand()`/`withdrawHand()` 成功時,發起者已收到更新後的自身狀態;其他人的完整 `state` 可在下一輪事件迴圈合併送達,請以 `state` 事件讀取最新完整排隊名單,不假設每個 `hand` 事件各有一份緊接的完整快照。 @@ -215,13 +231,18 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 草稿不是目前實際擷取裝置:公開 client 可另改設定,且離台/回台等生命週期可使 setInputDevice 正常完成卻未採用該選擇。完成只恢復按鈕,不顯示「已套用」或硬體成功;不新增 SDK API、預覽、儲存、屬性或自動重試。可用 ::part(microphone) 設定外觀。驗證限公開 SDK 與受控 DOM/socket/media,原生下拉鍵盤、AT、權限與實體音訊未驗收。 -- 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,恢復快照後須再收到新回報,不從 SDK 保留的 getter 還原。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 -- 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、下台、離房/換席及房間麥克風錯誤會清除,`micready` 不會還原舊值,必須再收到本人報告;一般同席快照保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 +- 發言者摘要:所有已進房者可看到最近收到的伺服器發言偵測回報,按目前台上順序列出名稱與人數。尚未收到時顯示等待,明確空集合才顯示「未偵測到發言」;回報中的 ID 無法對應台上成員時另作說明,不公開未知 ID 或觀眾名稱。名稱依目前快照安全顯示,改名與移出台上會同步更新;已移除或當下無法對應的 ID 不會因之後上台而復活舊活動,須等新回報;控制連線暫時斷開但仍在台上的寬限席位不因此排除。重連、終止連線、離房或換席清除舊集合,只有新的目前權威 state 才重新接納後續新回報;status、任意重繪與已接收/拒絕的事件物件重放不開啟接收資格,不從 SDK 保留的 getter 還原。新身分快照在 SDK status 回呼先可讀時,仍可先顯示等待,但這個提示不授權提前採用回報。公開同 client 合法斷線/終止後重新加入同房同身分仍可由新 state 與新回報恢復。本人或他人的下台/轉觀眾通知先移除並暫時抑制該 ID;新的權威快照核對前,新回報不能借用舊台上名單,解除抑制也不重加舊活動,須之後新回報才重加。本人轉為觀眾仍可讀其他台上者,micerror/micready/收聽設定不冒充房間活動變化。這是伺服器活動偵測(VAD),沒有時間戳,不保證即時或實際可聽見音訊;空集合也不是無音訊的證明。本機收聽靜音、音量、自動播放與傳輸狀態可能不同。不增加輪詢或媒體操作,原 `stage-speaking` detail 仍原樣轉送;摘要為普通文字,未新增頻繁宣讀的 live region。可用 `::part(speaking)` 設定外觀;原生畫面、輔助科技與實體音訊仍需驗收。 +- 本人連線品質:台上者收到自己的 `quality` 報告後,顯示最近收到的上/下行掉包率及 RTT;缺失或無效值顯示「—」,0 仍顯示 0,不據此推斷健康。只取本人 ID,不顯示其他發言者資料;觀眾與台下主控隱藏。重連、本人下台/轉為觀眾、離房/換席或終止會撤銷舊接收資格並清空;新的本人報告須在目前 client 的新權威台上 state 之後才可採用。status、角色/上台通知與任意重繪不開啟資格,已接收或拒絕的 state/quality 事件物件重放也不算新收件。公開 client 合法斷線後再加入同房同身分仍支援,新 state 加新報告即可顯示,並非永久封鎖該 client。房間麥克風錯誤僅清空報告,仍合格時可直接接收下一份新本人報告而不需另等 state;`micready` 不還原舊值。一般同席快照及保留台位的主控/講者移交保留已收到的數字。這是歷史回報而非即時狀態,RTT 不是端到端音訊延遲;事件沒有時間戳/peer 世代,麥克風錯誤後才到達的報告也不證明已恢復。伺服器無新報告時不自動推測過期或良好。沒有額外輪詢、媒體操作或控制請求,`stage-quality` 照原物件轉送;原生畫面、輔助科技與音訊仍需驗收。 +- 手動本人連線統計:所有有效已進房席位皆可按「更新我的連線統計」,明確呼叫一次既有 `getStats()`;觀眾也可讀取,不以台位、麥克風或 connected 旗標過濾。顯示接收掉包率/RTT/緩衝及傳送掉包率/RTT,掉包率一位小數、RTT 秒轉整數毫秒、緩衝沿用毫秒;0 保留,缺失/無效數值為「—」,缺少方向顯示暫無統計。每方向是 SDK 最後一筆符合的音訊 RTP,並非全部串流加總。不展示 bitrate、jitter、codec、原始報告或例外。數字是上次手動讀取的歷史結果,標示 SDK 取樣區間;其他呼叫者共用 SDK 差值基準,區間不等於兩次按鈕間隔,RTT 也不是端到端音訊延遲。等待時停用按鈕並標示 aria-busy,保留歷史;失敗顯示本功能固定說明並保留歷史,不覆寫全域錯誤、品質或發言摘要。重連/斷線/終止/離房/卸載/換席撤銷舊讀取和結果;恢復有效席位後可明確再讀,不需等待新 state,也不由 status 自動讀取。一般同席角色、台位或麥克風變更保留歷史。原生 promise 無取消能力,保留 peer 的舊讀取仍可能推進 SDK 共用基準;UI 只隔離舊結果,沒有新增 timeout、重試或基準重設。統計核對已讀到目前連線/房間/本人身分失效時,會在下一個可重入 getter 前撤銷當時的舊關房/移交確認;同步回呼把狀態改回來也不能復活該確認。只影響精確的原操作,較新合法席位/新確認仍依原權限判斷,未改其他命令政策。沒有輪詢、媒體、播放、裝置或權限操作,普通文字不新增 live region;可用 `::part(connection-stats)` 設定外觀。受控 SDK/DOM 驗證不等於原生版面、鍵盤、AT 或實際音訊驗收。 - 舉手佇列位置來自目前 `state.hands` 的本人 ID;快照更新時重算,未找到本人則只顯示「已舉手」,重連期間標示「上次佇列」。保留寬限期的斷線者仍在佇列中;主控可任意核准人選,因此這不是保證的發言順序或等待時間。 - 輸出裝置選擇:已進房時可在「輸出裝置選擇(草稿)」手動重新整理揚聲器/耳機清單,再明確送出選擇;選單變更本身不呼叫 setSinkId。空字串沿用既有 SDK 的系統預設輸出,不支援 outputDeviceSupported 的瀏覽器顯示說明並停用此組控制。名稱以 textContent 顯示,匿名裝置有通用名稱;重新整理後缺失的草稿保留為未列出項,不偷偷改用其他裝置。重新整理與送出共用等待鎖,送出完成只恢復控制,草稿不表示目前實際輸出、已套用或可聽音訊;不會自動 enumerate、play、開麥克風或儲存裝置 ID,也不另呼叫權限申請 API;setSinkId 的裝置存取與權限仍由瀏覽器決定。 輸出設定作用於同一 client 的 audio 元件,因此等待/草稿/清單按 client 生命週期保留,而非按 room/participant 重設。公開 client 斷線、關房、換席或重入相同/不同房間時,沒有有效房間便隱藏停用;重新取得快照仍要等原輸出操作完成才可再次送出。一般重連保留顯示但停用。真正更換 client 或移除元件才退役舊 UI,既有 setSinkId 無取消 API,不能取消已交給瀏覽器的操作;舊 client 的遲到完成不操作新 client 的控制。永不完成的操作持續等待,沒有新增 timeout/強制解除;目前有效連線且可見 owner 的失敗可明確重試,隱藏或重連等待期間的失敗不另冒出錯誤。宿主直接呼叫 element.client.setOutputDevice 不經此 UI 鎖,不能保證與宿主並行選擇的先後結果。可用 ::part(output-device) 設定外觀;驗證限公開 SDK 與受控 DOM/audio/socket,原生裝置、權限、鍵盤、AT 與實際播放未驗收。 -- 內建介面:狀態列(房名.直播狀態.收聽中/已舉手/台上;舉手時顯示目前佇列第幾位)、錯誤列、`啟用音訊`(自動播放被擋時出現,失敗可重試、成功後隱藏)、觀眾的舉手/收回(含冷卻倒數)、台上的麥克風靜音/離開舞台、台下主控的返回舞台,以及所有已進房者的「靜音收聽/恢復收聽」。離開、關房與被踢後隱藏失效操作。除房名、舉手申請與舞台靜音/移下台以外的主控管理功能請改用 `element.client`(底層 `StageClient`,尚未建立實例、明確離房或元件已移除時為 null)。 +- 內建介面:狀態列(房名.直播狀態.收聽中/已舉手/台上;舉手時顯示目前佇列第幾位)、錯誤列、`啟用音訊`(自動播放被擋時出現,失敗可重試、成功後隱藏)、觀眾的舉手/收回(含冷卻倒數)、台上的麥克風靜音/離開舞台、台下主控的返回舞台,以及所有已進房者的「靜音收聽/恢復收聽」。離開、關房與被踢後隱藏失效操作。除房名、舉手申請、舞台靜音/移下台、混音增益、控制權移交與關閉房間以外的主控管理功能請改用 `element.client`(底層 `StageClient`,尚未建立實例、明確離房或元件已移除時為 null)。 +- 本人名稱與房間名稱編輯器只略過等值 hidden/disabled/aria-busy 寫入;每次 sync、owner/attempt/資格與錯誤流程不變,不快取快照,也不跳過接受文字 textContent 或草稿 value 的原有賦值。宿主改壞布林/等待屬性會在後續同步修正,必要寫入失敗可下次重試;原退役清草稿與 accepted-text 子樹重建保持。受控完整 widget+實際 SDK/validator/Hub 的固定 120 次合法操作,寫入 5,800→1,560,340 次必要變更不變;保留每次同步、事件順序與原草稿賦值。等值 setter 呼叫減少,相關 attribute mutation records 也可能減少,且新增 live DOM reads,因此不是任意 monkey-patched getter/setter 相容保證,也不代表原生 CPU、layout、焦點、IME 或 AT 效能。 +- 房間席位摘要以最近收到的權威 state 顯示「主控以外 N / maxAudience 人,其中台上 S / maxSpeakers 人、觀眾 A 人;主控另計 1 人(台上/台下)」。N = A + 非主控台上席位,手部佇列不是額外人口;`maxAudience` 限制全部非主控成員,`maxSpeakers` 限制非主控台位,主控席位另計。暫時斷線保留席位仍包含,mute/媒體準備成功與否不改人口,因此不是在線人數、可聽講者或即時剩餘容量保證。重連只保留同一席位已接受的上次快照,單獨 status 回復不使它變成新資料;角色/舉手/發言/品質通知不自行加減。終止、離房、初次 ACK 前失聯或換席會隱藏並清空,舊回呼不復活顯示。只保留驗證後的數量/上限/主控位置,無需私有 audience/code/resumeToken;無效數值或相互矛盾的本地投影只隱藏摘要,不clamp、不報新全域錯誤、不改SDK狀態。原生文字段落沒有互動控制或live region,不新增API、timer、frame、capture或play;受控驗證不代表原生版面、AT或音訊驗收。 - 所有已收到房間快照的參與者另有「離開房間」,初次 ACK 尚未到達或信令重連中也可使用。先退休目前 client 與舊請求/事件,再沿用 SDK disconnect 清理本機擷取、收聽及重連;顯示「已離開房間。」與手動「重新加入」,cleanup 等待期間按鈕停用並標示 aria-busy。屬性與收聽偏好保留,重新加入採用當時最新值;沒有自動重新加入,也不保證還原先前 participant、台位或主控身分。主控的伺服器寬限政策不變,離房不是關閉房間,不保證底層 socket 立即完成關閉。與元件移除相同,先退休的 client 事件(含 disconnect status)不再轉送,不另外產生合成 stage-status;`element.client` 在這次 teardown 後為 null。直接呼叫公開 `element.client.disconnect()` 仍採原本事件/提示行為,不會建立這個手動重入狀態。關房/被踢亦不新增重入按鈕。 +- 目前主控另有獨立「關閉房間」與全員離場提示,台上/台下皆可用。必須明確確認「確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。」,才呼叫既有 `closeRoom()`;這不是 Leave,不本機設定離房/重新加入狀態。缺少 SDK 方法隱藏入口,缺少確認 API 不提供未確認替代。重連、斷線、失權、換房/席位、屬性重入、卸載或控制項脫離目前元件後停用/退役;確認中觀測到資格消失,即使立即恢復也需新確認。本人台上/台下、靜音與普通改名不撤銷房間層級資格。 +- 關房保留獨立操作 token,先保留再重繪/確認,當前等待不能重複送出;舊 owner 的結果與 finally 不影響新席位。關房與控制權移交只在同步確認/接納期間互斥,標記跨舊 owner 退役保留至同步呼叫返回;不因等待 ACK 鎖住所有命令。原有舊移交確認中換席後的新移交仍可進行。取消、開始與 ACK 成功不清除原錯誤,不自動重試、關閉本機或以 ACK 推論全房終止;權威 `closed` 事件沿用原提示,終止先於 ACK 可使 promise 拒絕但仍顯示房間已關閉。新增 catch 只在同 owner/attempt、仍有效且 error revision 未變時回報,SDK 全域 wire error 仍沿用原政策。受控完整 widget/實際 SDK/validator/Hub 證據不代表原生 dialog、鍵盤、焦點、AT 或硬體音訊驗收。 - 個人舞台按鈕依目前快照切換:台上者「離開舞台」,台下主控「返回舞台」,一般台下觀眾隱藏。主控下台仍保留主控權限;返回沿用伺服器的主控席位與准入規則,可能依既有 SDK 流程再次要求麥克風。強制靜音不妨礙下台。快照早於 ACK 時可先切換文字,但同一按鈕仍保持 pending/aria-busy,直到原請求完成;錯誤保留原本回報,不自動重試。 - 台上麥克風擷取失敗或自然中斷後,提供「重試麥克風」,只在使用者按下時呼叫既有 `retryMicrophone()`。接納後顯示「正在要求麥克風…」與 `aria-busy`,直到 `micready`/`micerror` 或該房間/台位生命週期結束;boolean 回傳不是完成擷取。回 `false` 時恢復按鈕並保留目前錯誤,同步例外仍可回報。重連期間停用但保留同席待處理狀態;下台、換房、離線或移除元件清除。自我/強制靜音不阻止擷取準備,也不會因此解除靜音。只清除屬於該次麥克風失敗的舊提示,較新的其他錯誤保留。房間初次/重試擷取的 `micready` 早於 sender 安裝,且事件與裝置替換及 `element.client.startMicTest()` 的獨立預覽共用;它會結束這個 UI 的擷取等待,但不證明房間麥克風已傳送、協商完成或可聽見,後續安裝錯誤照常顯示。 - 本機收聽靜音:布林屬性 `playback-muted` 與反映它的 `element.playbackMuted` property。加入 `playback-muted` 可初始靜音;移除屬性或設定 property 為 false 恢復收聽。HTML 布林屬性只看是否存在,`playback-muted="false"` 仍表示靜音。變更立即套用於本機 audio,不重新進房、不送控制請求、不改麥克風;更換房間、移除再掛載與 ws 重連保留設定。建議透過此 widget property 控制收聽偏好;直接修改 `element.client` 的 audio 設定不會反映回 widget attribute。按鈕使用原生 button 與 `aria-pressed`,未進房時隱藏。 @@ -236,7 +257,8 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 - 舉手/收回與麥克風靜音按鈕在接納 click 前重查目前席位、重連、冷卻與強制靜音限制。即使主頁的 `stage-state`/`stage-status` listener 在畫面重繪前同步點擊舊的 enabled 按鈕,已失效操作也不進入 pending、不清除目前錯誤、不送請求或占用 SDK 舉手冷卻;隱藏的這兩種控制同時停用。有效操作仍沿用目前快照選擇 raise/withdraw 或 mute/unmute,並保留各自等待 ACK 與錯誤處理。 - 連線結束提示:呼叫 `element.client.disconnect()` 會顯示「未連線」並隱藏房內操作;一般關房、被移出與伺服器維護/重啟各有提示,收聽偏好變更不會清掉提示。換房或重新掛載後清除舊提示,舊 client 的遲到事件不更新或重新派發到新 widget。 - 事件:所有 client 事件以 `stage-` 從元素派發(`bubbles`、`composed`,`detail` 相同),例如 `stage-state`、`stage-hand`、`stage-kicked`。 -- 樣式:`::part(panel)`、`::part(status)`、`::part(error)`、`::part(error-dismiss)`、`::part(actions)`、`::part(playback)`、`::part(name)`、`::part(room-name)`、`::part(quality)`、`::part(speaking)`、`::part(audio-retry)`、`::part(audio-recovery)`,以及 CSS 變數 `--stage-fg`、`--stage-bg`、`--stage-border`、`--stage-radius`、`--stage-button-bg`、`--stage-accent`、`--stage-accent-fg`、`--stage-error`。 +- 本機播放控制僅對收聽 `aria-pressed` 與音量 `aria-valuetext` 做目前屬性值比較,等值時不重寫,缺失/錯誤值仍修復。hidden/disabled 的原始屬性修復、textContent 替換及 input.value 賦值照常;每次完整重繪增加兩次屬性讀取/比較,每次單獨音量同步增加一次。普通受控 SDK/Hub 72 次重繪省去 144 次等值 ARIA 寫入,四次滑桿輸入保留四次必要變更、移除四次等值寫入;安靜 keepalive 不產生此收益。這是 setter 計數,不是瀏覽器時間、CPU 或 AT 改善保證;等值 attribute observer 通知減少,不承諾任意宿主 getter/setter 副作用等價。 +- 樣式:`::part(panel)`、`::part(status)`、`::part(error)`、`::part(error-dismiss)`、`::part(actions)`、`::part(playback)`、`::part(name)`、`::part(room-name)`、`::part(quality)`、`::part(speaking)`、`::part(connection-stats)`、`::part(public-roster)`、`::part(mic-test)`、`::part(audio-retry)`、`::part(audio-recovery)`,以及 CSS 變數 `--stage-fg`、`--stage-bg`、`--stage-border`、`--stage-radius`、`--stage-button-bg`、`--stage-accent`、`--stage-accent-fg`、`--stage-error`。 - 自訂標籤名:`import { defineStageElement } from 'https://host/lib/stage-element.js'` 後呼叫 `defineStageElement('my-stage')`(`stage-client` 已於載入時註冊,多個自訂標籤可並存)。每個自訂標籤使用 `StageElement` 的獨立子類別,因此仍是 `instanceof StageElement`,但 constructor 不與預設/其他標籤相同;重複註冊同名標籤為 no-op,既有其他元件不被覆寫。 `` 的舉手、麥克風、離開/返回舞台與音訊解鎖按鈕在各自請求完成前保持停用並標示 `aria-busy`,不會被冷卻倒數或新 state 的重新繪製提前解鎖。失敗後顯示錯誤並重新套用目前冷卻/強制靜音限制;切換房間或移除元件時,舊請求不會改動新房間的按鈕狀態。 @@ -262,3 +284,31 @@ WebSocket 建構失敗、hello 逾時或尚未進房的連線關閉,也會釋 `npm test -w packages/client` 也會啟動獨立 Node 子程序的 loopback 伺服器,讓實際 StageClient 經真實 WebSocket 完成建立/加入房間、舉手核准、個人/主控靜音、下台、同席位與同媒體 peer 自動恢復、被踢與伺服器關閉。同一流程會分別使用預設 unlimited 與 maxPendingControlRequests=1,並檢查額外控制操作的本機拒絕、名額釋放與信令仍可完成;cap=1 的 child fixture 刻意延後初次 join ACK,確認名額仍占用時 SDP 已完成,再釋放 ACK。測試檢查個人化快照、ACK 錯誤、收聽偏好保留,以及終止後不重連;伺服器使用 MockMediaTransport,瀏覽器 PeerConnection/音軌/AudioContext 為 fake,不代表實際 ICE、音訊品質或瀏覽器權限驗證。需允許本機 loopback listener 與 Node 子程序;不使用外部服務。 `micNoopIntegration.test.ts` 另外以主控、發言者、觀眾三個實際 SDK 經 native WebSocket,檢查五種重複麥克風控制只刷新發起者的個人化 state,順序仍為 state → ACK → 公開 Promise 完成;底層 self/force mute 真正改變時仍廣播。觀察者的零額外快照以各自連線的 ping/pong 排序屏障確認,不以短暫 sleep 當作沒有訊息的證據。測試也涵蓋私人欄位、gain 正規化、既有 peer/mixer 身分、權限拒絕後恢復,以及延後 ACK 的逾時、名額釋放和舊 ACK 隔離;媒體仍為 fake/MockMediaTransport,不是實際音訊或效能測量。 + + +### 最小 embed 的台下麥克風音量測試 + +最小 embed 另提供台下的「測試麥克風音量」:只有已進房且目前離台時,明確按鈕才呼叫既有 `startMicTest({ signal })`,可能要求瀏覽器麥克風權限。它只顯示本機音量,不回放、不送上舞台,也不保證他人能聽見。等待時可取消,取得音軌後可停止;取消不會關閉原生權限視窗,但稍後授權取得的測試音軌會由 SDK 釋放。測試中約每 100 ms 讀取一次音量,停止、自然結束、上台、重連、離房/換席、送出麥克風選擇、頁面隱藏或離開都清理測試與讀取排程,不停止正常舞台麥克風。返回前景或選擇裝置後不自動重啟,必須再按測試。裝置草稿並未套用時,測試仍沿用 SDK 目前輸入設定。失敗只在測試區顯示固定提示,可手動重試;舊 SDK 缺少方法時同樣安全失敗,不改用其他擷取方式。原生按鈕與標籤支援標準操作語意,但受控 DOM/SDK 測試不等同實際權限視窗、鍵盤/焦點、AT 或硬體音訊驗收。 + + +### Web Component 的台下麥克風音量測試 + +可用 `::part(mic-test)` 設定此區外觀。 + +Web Component 也提供台下的「測試麥克風音量」,僅由已進房的使用者明確按下按鈕後開始本機測試,可能要求麥克風權限;它不回放、不發布到舞台,也不保證他人能聽見。等待可取消,開始後可停止;取消保留等待狀態至 SDK 請求結束,晚到授權的測試音軌會釋放,但不會關閉原生權限視窗。測試中約每 100 ms 更新音量,失敗只在測試區提示。上台、重連、離房/換席、元件卸載、送出麥克風選擇、頁面隱藏或離開都停止該元件自己的測試與讀取排程,不停止正常舞台麥克風或其他元件的測試;回到前景、重新掛載與更換輸入後不自動重啟。尚未送出的裝置草稿不改變測試輸入;一般收聽屬性變更維持原 client 與有效測試,變更加入設定則退役舊測試。自然結束後可明確重新測試;舊 SDK 缺少方法時顯示固定失敗,不另行擷取。原生按鈕、meter 標籤與控制順序經靜態檢查;實際權限視窗、鍵盤/焦點、AT 與硬體音訊尚未驗收。 + + +### 公開台上成員快照 + +Web Component 的所有已進房使用者都可展開「台上成員快照」,查看伺服器公開的台上順序、名字、主控/講者角色、靜音/主控鎖定靜音與控制連線保留席位標記。安靜的講者也會列出;離台主控不加入此名單,斷線寬限中的台位仍保留,因此不是在線、正在發言或可聽名單。區塊預設收合,空台會明示;同席位更新保留展開選擇。只在收到目前席位的新房間快照後更新名單,重連保留上次複本並標示歷史,單獨連線狀態恢復不把舊資料當新資料;角色/台位通知先於完整快照時等新快照,不自行推測名單。離房、終止、卸載或換席清空並收合。它只讀公開 speakers 與必要身分/上限欄位,不加入觀眾私有名單、房間代碼或控制按鈕;相同投影與無關品質/說話事件不重建名單,也不新增輪詢或命令。可用 `::part(public-roster)` 設定外觀。受控驗證不代表原生 details、鍵盤/焦點、AT、版面或硬體音訊驗收。 + + +公開台上名單也會記住同一次 state 回呼較早觀測到的席位失效:即使較晚讀取又恢復舊身分,也不重新顯示已清空的舊快照。真正的新房間/新席位仍可由單一新權威快照建立;若這個新席位在同一次處理中再次失效,須等待新的快照。這是顯示生命週期修正,不增加命令或媒體操作。 + +### 最小 embed 的公開台上成員快照 + +最小 embed 也可展開「台上成員快照」,讓觀眾與講者看到安靜的台上成員。名單只呈現公開快照中的順序、名字、主控/講者角色、靜音/主控鎖定靜音與控制連線保留席位;離台主控不加入,斷線寬限席位仍包含。這不是在線、正在發言或可聽名單,不含觀眾私有名單、代碼或控制按鈕。區塊預設收合,空台明示;同席位更新保留展開選擇,相同投影與無關說話/品質事件保留原名單節點。重連保存上次快照並標示歷史,只有目前席位的新權威 state 更新名單;單獨 status 與先於完整快照的角色/台位通知不推測新成員。離房、終止、換席或新加入流程清空並收合。沒有新增命令、輪詢或媒體操作;原生 details、鍵盤/焦點、AT、版面與硬體音訊尚未驗收。 + +### 主控舉手名單搜尋 + +Web Component 的主控「舉手申請」可用「搜尋舉手者名稱」縮小長佇列;輸入會以解碼後名字做去除首尾空白、不分大小寫的包含比對,並顯示符合/總申請數,沒有符合項目時另行提示。「清除搜尋」還原全部申請。同名者仍各自保留,順序不變;篩選只隱藏列,不撤銷舉手、不釋放等待中的處理,也不送出控制請求。只搜尋已收到的主控舉手名單,不搜尋參與者 ID 或擴張觀眾名單。一般同席更新與重連保留搜尋;重連的歷史名單仍不能處理。失去主控、離房、終止、換房/席位或移除元件時清除搜尋與名單。原生搜尋欄、鍵盤/焦點、AT 與長清單版面尚未驗收。 diff --git a/packages/client/src/element.ts b/packages/client/src/element.ts index a344427..d88692d 100644 --- a/packages/client/src/element.ts +++ b/packages/client/src/element.ts @@ -1,4 +1,4 @@ -import { StageClient, decodeName, MAX_GAIN_DB, type ConnectionQuality, type StageEventMap } from './index.ts'; +import { StageClient, decodeName, MAX_GAIN_DB, type ConnectionQuality, type MicTest, type StageEventMap } from './index.ts'; /** Every StageClient event, re-dispatched from the element as `stage-`. */ const EVENTS: (keyof StageEventMap)[] = [ @@ -11,7 +11,10 @@ const STYLE = ` :host { display: block; font: inherit; color: var(--stage-fg, inherit); } [part=panel] { display: grid; gap: .5rem; padding: .75rem; border: 1px solid var(--stage-border, #8884); border-radius: var(--stage-radius, .5rem); background: var(--stage-bg, transparent); } [part=status] { margin: 0; } +.room-occupancy { margin: 0; overflow-wrap: anywhere; } [part=quality] { margin: 0; font-size: .875em; } +[part=connection-stats][hidden] { display: none; } +[part=connection-stats] p { margin: .5rem 0 0; font-size: .875em; overflow-wrap: anywhere; } [part=speaking] { margin: 0; font-size: .875em; } [part=hand-rejection] { margin: 0; } [part=audio-recovery] { margin: 0; font-size: .875em; } @@ -24,6 +27,9 @@ const STYLE = ` [part=hands][hidden] { display: none; } [part=hands] ul { display: grid; gap: .5rem; padding-left: 1.25rem; } [part=hands] li { overflow-wrap: anywhere; } +[part=hands] li[hidden] { display: none; } +[part=hands] label { display: inline-flex; flex-wrap: wrap; align-items: center; gap: .5rem; } +[part=hands] input { font: inherit; max-width: 100%; } [part=hands] button { margin-left: .5rem; } [part=stage-management][hidden] { display: none; } [part=stage-management] ul { display: grid; gap: .5rem; padding-left: 1.25rem; } @@ -37,6 +43,9 @@ const STYLE = ` [part=microphone][hidden] { display: none; } [part=microphone] p { flex-basis: 100%; margin: 0; font-size: .875em; } [part=microphone] select { font: inherit; max-width: 100%; } +[part=mic-test] { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; } +[part=mic-test][hidden] { display: none; } +[part=mic-test] p { flex-basis: 100%; margin: 0; font-size: .875em; } [part=output-device] { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; } [part=output-device][hidden] { display: none; } [part=output-device] p { flex-basis: 100%; margin: 0; font-size: .875em; } @@ -45,6 +54,7 @@ input[type=range] { flex: 1; min-width: 5rem; max-width: 12rem; } button { font: inherit; padding: .4rem .8rem; border-radius: var(--stage-radius, .5rem); border: 1px solid var(--stage-border, #8884); background: var(--stage-button-bg, #fff1); color: inherit; cursor: pointer; } button[hidden] { display: none; } button:disabled { opacity: .5; cursor: default; } +button.danger { color: var(--stage-error, #dc2626); border-color: currentColor; } button.primary { background: var(--stage-accent, #2563eb); border-color: transparent; color: var(--stage-accent-fg, #fff); } `; @@ -66,19 +76,63 @@ button.primary { background: var(--stage-accent, #2563eb); border-color: transpa * Controllers can lock/unlock other speakers' mute, explicitly apply a room-mix gain draft, remove them from the stage, or confirm a connected speaker's control handoff. * The microphone picker is an explicit draft, never an indicator of the actual capture device. * The output picker is also a draft; explicit refresh and submission never imply audible playback. - * Style through `::part(panel|status|quality|speaking|hand-rejection|audio-recovery|audio-retry|actions|name|room-name|hands|stage-management|playback|microphone|output-device)` and the + * Style through `::part(panel|status|quality|connection-stats|public-roster|speaking|hand-rejection|audio-recovery|audio-retry|actions|name|room-name|hands|stage-management|playback|microphone|mic-test|output-device)` and the * `--stage-*` custom properties. */ export class StageElement extends HTMLElement { static readonly observedAttributes = ['url', 'room', 'code', 'name', 'playback-muted', 'playback-volume']; private current: StageClient | null = null; private readonly status: HTMLParagraphElement; + private readonly occupancy: HTMLParagraphElement; + private occupancyOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; + private occupancyReport: { room: number; stage: number; audience: number; maxAudience: number; maxSpeakers: number; controllerOnStage: boolean; last: boolean } | undefined; + // Weak identities fence re-emission of retired public snapshots without retaining their private data. + private readonly occupancySnapshots = new WeakSet(); + private occupancyRevision = 0; + private readonly publicRosterPanel: HTMLDetailsElement; + private readonly publicRosterSummary: HTMLElement; + private readonly publicRosterNotice: HTMLParagraphElement; + private readonly publicRosterList: HTMLUListElement; + private publicRosterOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; + private publicRosterReport: { rows: { id: string; text: string }[]; last: boolean; awaiting: boolean } | undefined; + private readonly publicRosterSnapshots = new WeakSet(); + private publicRosterEventRevision = 0; + private publicRosterPending: { + revision: number; detail: object; anchor: StageElement['publicRosterOwner']; transitioned: boolean; invalid: boolean; + } | undefined; + private publicRosterRevision = 0; + private publicRosterPaintedOwner: StageElement['publicRosterOwner']; + private readonly publicRosterRows = new Map(); + private publicRosterOrder: string[] = []; private readonly quality: HTMLParagraphElement; private qualityOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private qualityReport: Omit | undefined; + private readonly qualitySnapshots = new WeakSet(); + private readonly qualityReceipts = new WeakSet(); + private qualityEventRevision = 0; + private qualityRevision = 0; + // Extra callback reconciliation repaints only changed projection; original paint triggers stay unconditional. + private qualityPaintedRevision = -1; + private qualityClearFirst = true; + private readonly connectionStatsPanel: HTMLDivElement; + private readonly connectionStatsUpdate: HTMLButtonElement; + private readonly connectionStatsText: HTMLParagraphElement; + private connectionStatsOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; + private connectionStatsTerminalClient: StageClient | undefined; + private connectionStatsAttempt: object | undefined; + private connectionStatsReport: { inbound: { loss: number | undefined; rtt: number | undefined; buffer: number | undefined } | null; + outbound: { loss: number | undefined; rtt: number | undefined } | null } | undefined; + private connectionStatsFailed = false; + private connectionStatsLeaving = false; private readonly speaking: HTMLParagraphElement; private speakingOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private speakingReport: { ids: Set; empty: boolean } | undefined; + private readonly speakingSnapshots = new WeakSet(); + private readonly speakingReceipts = new WeakSet(); + private readonly speakingSuppressed = new Set(); + private speakingEventRevision = 0; + private speakingRevision = 0; + private speakingPainted = { hidden: true, text: '' }; private readonly handRejection: HTMLParagraphElement; private handRejectionOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; /** Last failure (request rejected, mic denied…); stays until dismissed, the next action or join. */ @@ -100,6 +154,13 @@ export class StageElement extends HTMLElement { private readonly handQueueSummary: HTMLElement; private readonly handQueueNotice: HTMLParagraphElement; private readonly handQueueList: HTMLUListElement; + private readonly handQueueSearch: HTMLInputElement; + private readonly handQueueClear: HTMLButtonElement; + private readonly handQueueResults: HTMLParagraphElement; + private handQueueQuery = ''; + private handQueueRevision = 0; + private handQueueTerminalClient: StageClient | undefined; + private handQueueAwaitingStateClient: StageClient | undefined; private handQueueOwner: { client: StageClient; roomId: string; participantId: string; generation: number } | undefined; private readonly handQueuePending = new Map(); private readonly handQueueRows = new Map(); @@ -135,6 +196,34 @@ export class StageElement extends HTMLElement { private microphoneTerminalClient: StageClient | undefined; private refreshAttempt: object | undefined; private inputDeviceAttempt: object | undefined; + private readonly micTestPanel: HTMLDivElement; + private readonly micTestStart: HTMLButtonElement; + private readonly micTestCancel: HTMLButtonElement; + private readonly micTestStop: HTMLButtonElement; + private readonly micTestMeter: HTMLMeterElement; + private readonly micTestStatus: HTMLParagraphElement; + // A binding is installed with a new client, never reconstructed from current during teardown. + private micTestBinding: { client: StageClient; generation: number; blocked: boolean; early: Set } | undefined; + private micTestOwner: { binding: NonNullable; roomId: string; participantId: string } | undefined; + private micTestAttempt: { owner: NonNullable; controller: AbortController; + pending: boolean; retired: boolean; handle?: MicTest } | undefined; + private micTestMessage = '尚未開始麥克風音量測試。'; + private micTestValue = 0; + private micTestTimer: ReturnType | undefined; + private micTestCleanup = 0; + private micTestPageHidden = false; + private micTestDocument: Document | undefined; + private micTestWindow: Window | undefined; + private readonly micTestStates = new WeakSet(); + private readonly micTestEarlyStates = new WeakSet(); + private readonly micTestVisibility = () => { + if (this.micTestDocument?.hidden) this.retireMicTest(true); + this.syncMicTest(); + }; + private readonly micTestPageHide = () => { + this.micTestPageHidden = true; this.retireMicTest(true); this.syncMicTest(); + }; + private readonly micTestPageShow = () => { this.micTestPageHidden = false; this.syncMicTest(); }; private readonly outputDevicePanel: HTMLDivElement; private readonly outputDeviceSelect: HTMLSelectElement; private readonly outputDeviceRefresh: HTMLButtonElement; @@ -146,6 +235,18 @@ export class StageElement extends HTMLElement { private errorRevision = 0; private readonly leaveStage: HTMLButtonElement; private readonly roomAction: HTMLButtonElement; + private readonly roomClose: HTMLButtonElement; + private readonly roomCloseNotice: HTMLParagraphElement; + private readonly roomActions: HTMLDivElement; + private readonly widgetRoot: ShadowRoot; + private roomCloseOwner: { client: StageClient; roomId: string; participantId: string; generation: number; revision: number } | undefined; + private roomCloseAuthority: { client: StageClient; roomId: string; participantId: string; generation: number; revision: number; awaitingState: boolean } | undefined; + private roomCloseTerminalClient: StageClient | undefined; + private roomCloseAttempt: { owner: NonNullable; revision: number; invalid: boolean; errorRevision: number } | undefined; + private roomCloseRenderRevision = 0; + // Dialog scopes outlive retired owners, but never the synchronous admission stack. + private roomCloseDialog: object | undefined; + private readonly stageTransferDialogs = new Set(); private leftRoom = false; private roomExit: object | undefined; private readonly listen: HTMLButtonElement; @@ -165,7 +266,7 @@ export class StageElement extends HTMLElement { constructor() { super(); - const root = this.attachShadow({ mode: 'open' }); + const root = this.widgetRoot = this.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = STYLE; const panel = document.createElement('div'); @@ -174,9 +275,29 @@ export class StageElement extends HTMLElement { this.status.part.add('status'); this.status.setAttribute('role', 'status'); this.status.setAttribute('aria-live', 'polite'); + this.occupancy = document.createElement('p'); + this.occupancy.className = 'room-occupancy'; + this.occupancy.hidden = true; + this.publicRosterPanel = document.createElement('details'); + this.publicRosterPanel.part.add('public-roster'); + this.publicRosterPanel.hidden = true; + this.publicRosterSummary = document.createElement('summary'); + this.publicRosterSummary.textContent = '台上成員快照'; + this.publicRosterNotice = document.createElement('p'); + this.publicRosterList = document.createElement('ul'); + this.publicRosterPanel.append(this.publicRosterSummary, this.publicRosterNotice, this.publicRosterList); this.quality = document.createElement('p'); this.quality.part.add('quality'); this.quality.hidden = true; + this.connectionStatsPanel = document.createElement('div'); + this.connectionStatsPanel.part.add('connection-stats'); + this.connectionStatsPanel.hidden = true; + this.connectionStatsUpdate = document.createElement('button'); + this.connectionStatsUpdate.type = 'button'; + this.connectionStatsUpdate.textContent = '更新我的連線統計'; + this.connectionStatsUpdate.onclick = () => this.refreshConnectionStats(); + this.connectionStatsText = document.createElement('p'); + this.connectionStatsPanel.append(this.connectionStatsUpdate, this.connectionStatsText); this.speaking = document.createElement('p'); this.speaking.part.add('speaking'); this.speaking.hidden = true; @@ -225,14 +346,26 @@ export class StageElement extends HTMLElement { this.handQueueSummary = document.createElement('summary'); this.handQueueNotice = document.createElement('p'); this.handQueueList = document.createElement('ul'); - this.handQueuePanel.append(this.handQueueSummary, this.handQueueNotice, this.handQueueList); + const handQueueLabel = document.createElement('label'); + handQueueLabel.textContent = '搜尋舉手者名稱'; + this.handQueueSearch = document.createElement('input'); + this.handQueueSearch.type = 'search'; + this.handQueueSearch.oninput = () => this.filterHandQueue(false); + handQueueLabel.append(this.handQueueSearch); + this.handQueueClear = document.createElement('button'); + this.handQueueClear.type = 'button'; this.handQueueClear.textContent = '清除搜尋'; + this.handQueueClear.onclick = () => this.filterHandQueue(true); + this.handQueueResults = document.createElement('p'); + this.handQueueResults.setAttribute('role', 'status'); + this.handQueuePanel.append(this.handQueueSummary, this.handQueueNotice, handQueueLabel, + this.handQueueClear, this.handQueueResults, this.handQueueList); this.stageManagementPanel = document.createElement('details'); this.stageManagementPanel.part.add('stage-management'); this.stageManagementSummary = document.createElement('summary'); this.stageManagementNotice = document.createElement('p'); this.stageManagementList = document.createElement('ul'); this.stageManagementPanel.append(this.stageManagementSummary, this.stageManagementNotice, this.stageManagementList); - const actions = document.createElement('div'); + const actions = this.roomActions = document.createElement('div'); actions.part.add('actions'); this.unlock = document.createElement('button'); this.unlock.type = 'button'; this.unlock.className = 'primary'; this.unlock.textContent = '啟用音訊'; @@ -272,6 +405,28 @@ export class StageElement extends HTMLElement { microphoneNotice.id = 'microphone-draft-notice'; microphoneNotice.textContent = '草稿不代表目前實際擷取裝置;麥克風失敗後需另按「重試麥克風」。重新整理清單不會開啟麥克風;預設選項沿用 SDK 設定。'; this.microphonePanel.append(microphoneLabel, this.microphoneSelect, this.microphoneRefresh, this.microphoneApply, microphoneNotice); + this.micTestPanel = document.createElement('div'); + this.micTestPanel.id = 'mic-test-panel'; this.micTestPanel.part.add('mic-test'); + this.micTestStart = document.createElement('button'); + this.micTestStart.id = 'mic-test-start'; this.micTestStart.type = 'button'; this.micTestStart.textContent = '測試麥克風音量'; + this.micTestStart.onclick = () => this.startMicTest(); + this.micTestCancel = document.createElement('button'); + this.micTestCancel.id = 'mic-test-cancel'; this.micTestCancel.type = 'button'; this.micTestCancel.textContent = '取消麥克風測試'; + this.micTestCancel.onclick = () => this.stopMicTest(); + this.micTestStop = document.createElement('button'); + this.micTestStop.id = 'mic-test-stop'; this.micTestStop.type = 'button'; this.micTestStop.textContent = '停止麥克風測試'; + this.micTestStop.onclick = () => this.stopMicTest(); + const micTestLabel = document.createElement('label'); + micTestLabel.textContent = '本機麥克風測試音量'; micTestLabel.setAttribute('for', 'mic-test-meter'); + this.micTestMeter = document.createElement('meter'); + this.micTestMeter.id = 'mic-test-meter'; this.micTestMeter.min = 0; this.micTestMeter.max = 1; this.micTestMeter.value = 0; + this.micTestMeter.setAttribute('aria-describedby', 'mic-test-notice'); + this.micTestStatus = document.createElement('p'); this.micTestStatus.id = 'mic-test-status'; + this.micTestStatus.setAttribute('role', 'status'); this.micTestStatus.setAttribute('aria-live', 'polite'); + const micTestNotice = document.createElement('p'); micTestNotice.id = 'mic-test-notice'; + micTestNotice.textContent = '僅量測本機音量,不會播放或傳送到舞台;不保證實際裝置或收音正常。測試使用已送出的選擇,未送出草稿不影響測試。取消無法關閉瀏覽器的權限提示。'; + this.micTestPanel.append(this.micTestStart, this.micTestCancel, this.micTestStop, micTestLabel, + this.micTestMeter, this.micTestStatus, micTestNotice); this.outputDevicePanel = document.createElement('div'); this.outputDevicePanel.part.add('output-device'); const outputDeviceLabel = document.createElement('label'); @@ -309,6 +464,15 @@ export class StageElement extends HTMLElement { this.roomAction = document.createElement('button'); this.roomAction.type = 'button'; this.roomAction.onclick = () => this.changeRoomPresence(); + this.roomClose = document.createElement('button'); + this.roomClose.type = 'button'; this.roomClose.className = 'danger'; this.roomClose.textContent = '關閉房間'; + this.roomClose.hidden = this.roomClose.disabled = true; + this.roomClose.setAttribute('aria-busy', 'false'); + this.roomClose.setAttribute('aria-describedby', 'room-close-notice'); + this.roomClose.onclick = () => this.closeRoom(); + this.roomCloseNotice = document.createElement('p'); + this.roomCloseNotice.id = 'room-close-notice'; this.roomCloseNotice.hidden = true; + this.roomCloseNotice.textContent = '關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。'; this.listen = this.button('靜音收聽', () => { this.playbackMuted = !this.playbackMuted; }); this.listen.setAttribute('aria-label', '靜音收聽'); this.playback = document.createElement('div'); @@ -330,8 +494,8 @@ export class StageElement extends HTMLElement { this.playback.append(volumeLabel, this.volume, this.volumeValue); this.retry = this.button('重試加入', () => { if (this.canRetry()) this.rejoin(); }); this.cancelJoin = this.button('取消加入', () => this.cancelInitial?.()); - actions.append(this.unlock, this.listen, this.hand, this.mic, this.micRetry, this.audioRetry, this.leaveStage, this.roomAction, this.retry, this.cancelJoin); - panel.append(this.status, this.quality, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.audioRecovery, this.microphonePanel, this.outputDevicePanel, this.playback); + actions.append(this.unlock, this.listen, this.hand, this.mic, this.micRetry, this.audioRetry, this.leaveStage, this.roomAction, this.roomClose, this.retry, this.cancelJoin); + panel.append(this.status, this.occupancy, this.publicRosterPanel, this.quality, this.connectionStatsPanel, this.speaking, this.handRejection, this.error, this.errorDismiss, this.namePanel, this.roomNamePanel, this.handQueuePanel, this.stageManagementPanel, actions, this.roomCloseNotice, this.audioRecovery, this.microphonePanel, this.micTestPanel, this.outputDevicePanel, this.playback); root.append(style, panel); this.render(); } @@ -355,7 +519,11 @@ export class StageElement extends HTMLElement { } connectedCallback(): void { if (this.leftRoom) this.render(); else this.rejoin(); } - disconnectedCallback(): void { ++this.generation; void this.leave(); } + disconnectedCallback(): void { + const cleanup = this.retireMicTestBinding(), generation = ++this.generation; + this.detachMicTestListeners(); cleanup(); + if (generation === this.generation) void this.leave(); + } attributeChangedCallback(name: string, previous: string | null, next: string | null): void { if (previous === next) return; if (name === 'playback-volume') { this.current?.setPlaybackVolume(this.playbackVolume); this.renderVolume(); return; } @@ -394,8 +562,10 @@ export class StageElement extends HTMLElement { } private rejoin(): void { - const generation = ++this.generation; + const cleanup = this.retireMicTestBinding(), generation = ++this.generation; this.leftRoom = false; this.roomExit = undefined; + cleanup(); + if (generation !== this.generation) return; void this.leave().then(async () => { const room = this.getAttribute('room')?.trim(), name = this.getAttribute('name')?.trim(); if (generation !== this.generation || !this.isConnected || !room || !name) { this.render(); return; } @@ -403,6 +573,8 @@ export class StageElement extends HTMLElement { client.setPlaybackMuted(this.playbackMuted); client.setPlaybackVolume(this.playbackVolume); this.current = client; + this.micTestBinding = { client, generation, blocked: false, early: new Set() }; + this.attachMicTestListeners(); this.joinPhase = 'connecting'; let receivedState = false; let terminal = false; @@ -411,6 +583,7 @@ export class StageElement extends HTMLElement { if (generation !== this.generation || this.current !== client || !this.isConnected || !this.joinPhase || receivedState || terminal || client.state || client.status === 'reconnecting') return; cancelled = true; + this.detachMicTestListeners(); this.hideCancel(); this.joinPhase = undefined; this.retryAvailable = true; @@ -421,77 +594,110 @@ export class StageElement extends HTMLElement { for (const type of EVENTS) { client.on(type, ({ detail }) => { if (generation !== this.generation || this.current !== client) return; - if (type === 'closed' || type === 'kicked') this.microphoneTerminalClient = client; - else if (type === 'state') this.microphoneTerminalClient = undefined; - if (type === 'closed' || type === 'kicked') this.outputDeviceTerminalClient = client; - else if (type === 'state') this.outputDeviceTerminalClient = undefined; - if (type === 'closed' || type === 'kicked') this.stageManagementTerminalClient = client; - else if (type === 'state') this.stageManagementTerminalClient = undefined; - if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) this.audioRecoveryTerminalClient = client; - else if (type === 'state') this.audioRecoveryTerminalClient = undefined; - this.syncAudioRecovery(); - // Revoke picker admission before any forwarded callback can click an old control. - this.syncMicrophoneOwner(); - this.syncOutputDeviceOwner(); - const micLifecycle = type === 'state' || type === 'status' || type === 'micerror' || type === 'micready' - || type === 'closed' || type === 'kicked'; - if (micLifecycle) this.syncMicRetry(); - if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked' || type === 'audioblocked') this.syncPlaybackOwner(); - if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncNameOwner(); - if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncRoomNameOwner(); - if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncHandQueue(); - this.noteStageTransferEvent(type, detail); - this.syncStageManagement(); - const ownId = client.me?.participantId; - if (type === 'closed' || type === 'kicked' - || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) - || (type === 'hand' && (detail as StageEventMap['hand']).raised && (detail as StageEventMap['hand']).participantId === ownId) - || (type === 'stagejoined' && (detail as StageEventMap['stagejoined']).participantId === ownId) - || (type === 'role' && (detail as StageEventMap['role']).role !== 'audience' && (detail as StageEventMap['role']).participantId === ownId) - || (type === 'transferred' && (detail as StageEventMap['transferred']).toId === ownId)) this.retireHandRejection(); - else this.syncHandRejection(type === 'handrejected' ? detail as StageEventMap['handrejected'] : undefined); - if (type === 'closed' || type === 'kicked' || type === 'micerror' - || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) - || (type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === client.state?.me.participantId)) this.retireQualityOwner(); - else if (type === 'quality') this.syncQualityOwner((detail as StageEventMap['quality']).participants); - else if (type === 'state' || type === 'status') this.syncQualityOwner(); - if (type === 'closed' || type === 'kicked' - || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireSpeakingOwner(); - else if (type === 'speaking') this.syncSpeakingOwner({ participantIds: (detail as StageEventMap['speaking'] | null | undefined)?.participantIds }); - else if (type === 'state' || type === 'status') this.syncSpeakingOwner(); - // SDK status precedes state; a forwarded status listener may disconnect immediately. - if (type === 'state' || client.state) { receivedState = true; this.hideRetry(); this.hideCancel(); } - if (type === 'closed' || type === 'kicked') { terminal = true; this.micFailure = undefined; this.hideRetry(); this.hideCancel(); } - if (type === 'state' || type === 'closed' || type === 'kicked' - || (type === 'status' && detail === 'disconnected')) this.joinPhase = undefined; - if (type === 'audioblocked' && this.playbackOwner) { this.playbackOwner.notice++; this.unlock.hidden = false; this.unlock.disabled = Boolean(this.unlockAttempt); } - if (type === 'status' && detail === 'disconnected') { - this.hideCancel(); - this.status.textContent = cancelled && !receivedState && !terminal ? '已取消加入。' : STATUS_LABELS.disconnected; - } - if (type === 'closed') this.status.textContent = (detail as StageEventMap['closed']).reason === 'shutdown' - ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; - if (type === 'kicked') this.status.textContent = '你已被主控移出房間。'; - if (type === 'error' || type === 'micerror') this.show(detail); - if (type === 'micerror' && client.state && client.me?.onStage) { - this.micFailure = { client, roomId: client.state.roomId, participantId: client.me.participantId, - errorRevision: this.errorRevision, pending: false }; - } - if (type === 'micready' && this.micFailure?.client === client) { - // micready is shared with preview and precedes sender installation. - if (this.micFailure.errorRevision === this.errorRevision) this.hideError(); - this.micFailure = undefined; + const publicRosterEvent = this.rememberPublicRosterEvent(type, detail); + try { + this.markMicTestEvent(client, type, detail); + const speakingEvent = this.rememberSpeakingEvent(type, detail); + const qualityEvent = this.rememberQualityEvent(type, detail); + // Retire the manual read before public authority getters can admit a genuine new entry. + if (type === 'closed' || type === 'kicked') this.connectionStatsTerminalClient = client; + else if (type === 'state') this.connectionStatsTerminalClient = undefined; + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireConnectionStats(); + if (type === 'closed' || type === 'kicked') this.stageManagementTerminalClient = client; + else if (type === 'state') this.stageManagementTerminalClient = undefined; + // Own-role deltas precede their snapshot. Fence both dialogs before any DOM setter can reenter. + this.noteRoomCloseEvent(type, detail); + this.noteStageTransferEvent(type, detail); + this.noteMicTestEvent(client, type, detail); + this.reconcileRoomClose(); + this.noteQualityEvent(client, generation, type, detail, qualityEvent); + this.noteSpeakingEvent(client, generation, type, detail, speakingEvent); + if (type === 'closed' || type === 'kicked') this.microphoneTerminalClient = client; + else if (type === 'state') this.microphoneTerminalClient = undefined; + if (type === 'closed' || type === 'kicked') this.outputDeviceTerminalClient = client; + else if (type === 'state') this.outputDeviceTerminalClient = undefined; + if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) this.audioRecoveryTerminalClient = client; + else if (type === 'state') this.audioRecoveryTerminalClient = undefined; + this.syncOccupancy(type === 'state' ? detail as StageEventMap['state'] : undefined, + type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')); + this.syncAudioRecovery(); + // Revoke picker admission before any forwarded callback can click an old control. + this.syncMicrophoneOwner(); + this.syncOutputDeviceOwner(); + const micLifecycle = type === 'state' || type === 'status' || type === 'micerror' || type === 'micready' + || type === 'closed' || type === 'kicked'; + if (micLifecycle) this.syncMicRetry(); + if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked' || type === 'audioblocked') this.syncPlaybackOwner(); + if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncNameOwner(); + if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked') this.syncRoomNameOwner(); + if (type === 'state' || type === 'status' || type === 'closed' || type === 'kicked' + || type === 'role' || type === 'transferred') this.syncHandQueue(); + this.syncStageManagement(); + this.syncRoomClose(); + const ownId = client.me?.participantId; + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) + || (type === 'hand' && (detail as StageEventMap['hand']).raised && (detail as StageEventMap['hand']).participantId === ownId) + || (type === 'stagejoined' && (detail as StageEventMap['stagejoined']).participantId === ownId) + || (type === 'role' && (detail as StageEventMap['role']).role !== 'audience' && (detail as StageEventMap['role']).participantId === ownId) + || (type === 'transferred' && (detail as StageEventMap['transferred']).toId === ownId)) this.retireHandRejection(); + else this.syncHandRejection(type === 'handrejected' ? detail as StageEventMap['handrejected'] : undefined); + this.syncQualityOwner(type === 'quality' || type === 'state' || type === 'status' || type === 'closed' + || type === 'kicked' || type === 'micerror' + || (type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === ownId)); + this.syncSpeakingOwner(type === 'speaking' || type === 'state' || type === 'status' + || type === 'closed' || type === 'kicked'); + // SDK status precedes state; a forwarded status listener may disconnect immediately. + if (type === 'state' || client.state) { receivedState = true; this.hideRetry(); this.hideCancel(); } + if (type === 'closed' || type === 'kicked') { terminal = true; this.micFailure = undefined; this.hideRetry(); this.hideCancel(); } + if (type === 'state' || type === 'closed' || type === 'kicked' + || (type === 'status' && detail === 'disconnected')) this.joinPhase = undefined; + if (type === 'audioblocked' && this.playbackOwner) { this.playbackOwner.notice++; this.unlock.hidden = false; this.unlock.disabled = Boolean(this.unlockAttempt); } + if (type === 'status' && detail === 'disconnected') { + this.hideCancel(); + this.status.textContent = cancelled && !receivedState && !terminal ? '已取消加入。' : STATUS_LABELS.disconnected; + } + if (type === 'closed') this.status.textContent = (detail as StageEventMap['closed']).reason === 'shutdown' + ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; + if (type === 'kicked') this.status.textContent = '你已被主控移出房間。'; + if (type === 'error' || type === 'micerror') this.show(detail); + if (type === 'micerror' && client.state && client.me?.onStage) { + this.micFailure = { client, roomId: client.state.roomId, participantId: client.me.participantId, + errorRevision: this.errorRevision, pending: false }; + } + if (type === 'micready' && this.micFailure?.client === client) { + // micready is shared with preview and precedes sender installation. + if (this.micFailure.errorRevision === this.errorRevision) this.hideError(); + this.micFailure = undefined; + } + // Revoke stale action admission before application event listeners can reenter. + if (micLifecycle) this.syncMicRetry(); + this.syncOccupancy(); + this.syncQualityOwner(false); + this.syncSpeakingOwner(false); + this.syncConnectionStats(); + this.syncMicTest(); + this.notePublicRosterEvent(client, generation, type, detail, publicRosterEvent); + this.syncPublicRoster(); + this.dispatchEvent(new CustomEvent(`stage-${type}`, { detail, bubbles: true, composed: true })); + this.syncOccupancy(); + this.syncQualityOwner(false); + this.syncSpeakingOwner(false); + this.syncConnectionStats(); + this.syncMicTest(); + // Application callbacks may retire this seat or mutate its exposed snapshot. + this.syncMicrophoneOwner(); + this.syncOutputDeviceOwner(); + this.syncStageManagement(); + this.syncRoomClose(); + this.syncHandRejection(); + this.syncAudioRecovery(); + this.syncPublicRoster(); + if (type === 'state' || type === 'status' || type === 'mic' || type === 'closed' || type === 'kicked') this.render(); + } finally { + if (this.publicRosterPending === publicRosterEvent.pending) this.publicRosterPending = undefined; } - // Revoke stale action admission before application event listeners can reenter. - if (micLifecycle) this.syncMicRetry(); - this.dispatchEvent(new CustomEvent(`stage-${type}`, { detail, bubbles: true, composed: true })); - // Application callbacks may retire this seat or mutate its exposed snapshot. - this.syncMicrophoneOwner(); - this.syncOutputDeviceOwner(); - this.syncStageManagement(); - this.syncHandRejection(); - this.syncAudioRecovery(); - if (type === 'state' || type === 'status' || type === 'mic' || type === 'closed' || type === 'kicked') this.render(); }); } this.hideError(); @@ -529,9 +735,16 @@ export class StageElement extends HTMLElement { return; } if (!this.current?.state || !this.current.me) return; - ++this.generation; + const cleanup = this.retireMicTestBinding(), generation = ++this.generation; const exit = {}; this.leftRoom = true; this.roomExit = exit; + cleanup(); + if (generation !== this.generation || this.roomExit !== exit) return; + this.retireConnectionStats(); + this.retireOccupancy(); + this.retirePublicRoster(); + this.retireQualityOwner(); + this.retireSpeakingOwner(); this.hideError(); // Retire the event lane before disconnect can synchronously invoke application callbacks. void this.leave().catch(error => { @@ -543,6 +756,17 @@ export class StageElement extends HTMLElement { } private async leave(): Promise { + const cleanup = this.retireMicTestBinding(), generation = this.generation; + this.connectionStatsLeaving = true; + this.detachMicTestListeners(); cleanup(); + if (generation !== this.generation) return; + this.retireConnectionStats(); + this.retireOccupancy(); + this.retirePublicRoster(); + this.retireQualityOwner(); + this.retireSpeakingOwner(); + this.retireRoomClose(); + this.roomCloseAuthority = undefined; this.roomCloseTerminalClient = undefined; this.joinPhase = undefined; this.micFailure = undefined; this.microphoneTerminalClient = undefined; @@ -551,13 +775,12 @@ export class StageElement extends HTMLElement { this.retireOutputDeviceOwner(); this.retireNameOwner(); this.retireRoomNameOwner(); + this.handQueueTerminalClient = this.current ?? undefined; this.retireHandQueue(); this.stageManagementTerminalClient = undefined; this.retireStageManagement(); this.audioRecoveryTerminalClient = undefined; this.retireAudioRecovery(); - this.retireQualityOwner(); - this.retireSpeakingOwner(); this.retireHandRejection(); this.hideCancel(); this.hideRetry(); @@ -567,19 +790,570 @@ export class StageElement extends HTMLElement { this.cooldownTimer = undefined; this.cooldownDue = 0; const client = this.current; this.current = null; + this.connectionStatsLeaving = false; + this.connectionStatsTerminalClient = undefined; this.unlock.hidden = true; this.render(); await client?.disconnect(); } + private retirePublicRoster(preserved?: StageElement['publicRosterPending']): void { + if (this.publicRosterPending && this.publicRosterPending !== preserved) this.publicRosterPending.invalid = true; + if (!this.publicRosterOwner && !this.publicRosterReport) return; + this.publicRosterOwner = undefined; this.publicRosterReport = undefined; + ++this.publicRosterRevision; + } + + private rememberPublicRosterEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): { + revision: number; unseen: boolean; pending: StageElement['publicRosterPending']; + } { + const revision = ++this.publicRosterEventRevision; + const object = detail !== null && typeof detail === 'object'; + const unseen = type === 'state' && object && !this.publicRosterSnapshots.has(detail); + // Reserve even rejected receipts without getters or DOM ahead of the existing consent fences. + if (type === 'state' && object) this.publicRosterSnapshots.add(detail); + const pending = this.publicRosterPending = unseen + ? { revision, detail, anchor: this.publicRosterOwner, transitioned: false, invalid: false } : undefined; + if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) this.retirePublicRoster(); + else if (this.publicRosterReport && ((type === 'status' && detail === 'reconnecting') + || type === 'role' || type === 'stageleft' || type === 'stagejoined' || type === 'transferred' || type === 'mic')) { + this.publicRosterReport.last = true; + this.publicRosterReport.awaiting = type !== 'status'; + ++this.publicRosterRevision; + } + return { revision, unseen, pending }; + } + + private reconcilePublicRoster(): void { + for (;;) { + const pending = this.publicRosterPending; + const owner = this.publicRosterOwner ?? (pending && !pending.invalid ? pending.anchor : undefined); + if (!owner) return; + const revision = this.publicRosterRevision, client = owner.client; + const state = client.state, status = client.status; + const roomId = state?.roomId, participantId = state?.me?.participantId; + const current = (): boolean => pending === this.publicRosterPending && revision === this.publicRosterRevision + && owner === (this.publicRosterOwner ?? (pending && !pending.invalid ? pending.anchor : undefined)); + if (!current()) continue; + // Observed identity loss retires this receipt even if a later getter restores old values. + if (!this.isConnected || this.current !== client || this.generation !== owner.generation + || roomId !== owner.roomId || participantId !== owner.participantId + || (status !== 'live' && status !== 'waiting' && status !== 'reconnecting')) { + // Only the exact new receipt may replace its original owner once. Keep its new seat + // anchored while predecessor getters run, even though the old report has been cleared. + const transition = pending && !pending.invalid && !pending.transitioned && pending.anchor === owner + && owner === this.publicRosterOwner && pending.revision === this.publicRosterEventRevision + && this.isConnected && this.current === client && this.generation === owner.generation + && state === pending.detail && (status === 'live' || status === 'waiting') + && typeof roomId === 'string' && Boolean(roomId.trim()) + && typeof participantId === 'string' && Boolean(participantId.trim()) + && (roomId !== owner.roomId || participantId !== owner.participantId) + && state === client.state && status === client.status; + if (!current()) continue; + if (transition && pending) { + pending.anchor = { client, generation: owner.generation, roomId: roomId!, participantId: participantId! }; + pending.transitioned = true; + } + this.retirePublicRoster(transition ? pending : undefined); + this.noteRoomCloseEvent('status', status); + this.noteStageTransferEvent('status', status); + this.reconcileRoomClose(); + continue; + } + if (state !== client.state || status !== client.status) continue; + if (!current()) continue; + if (status === 'reconnecting' && this.publicRosterReport && !this.publicRosterReport.last) { + this.publicRosterReport.last = true; ++this.publicRosterRevision; + } + return; + } + } + + private notePublicRosterEvent(client: StageClient, generation: number, type: keyof StageEventMap, + detail: StageEventMap[keyof StageEventMap], event: ReturnType): void { + this.reconcilePublicRoster(); + if (type !== 'state' || !event.unseen || !event.pending || event.pending.invalid + || event.pending !== this.publicRosterPending || event.revision !== this.publicRosterEventRevision + || this.current !== client || this.generation !== generation) return; + const revision = this.publicRosterRevision; + const state = client.state, status = client.status; + const roomId = state?.roomId, participantId = state?.me?.participantId; + if (!state || state !== detail || !this.isConnected || (status !== 'live' && status !== 'waiting') + || typeof roomId !== 'string' || !roomId.trim() || typeof participantId !== 'string' || !participantId.trim()) return; + const current = (): boolean => { + this.reconcilePublicRoster(); + return !event.pending!.invalid && event.pending === this.publicRosterPending + && revision === this.publicRosterRevision && event.revision === this.publicRosterEventRevision && this.current === client && this.generation === generation + && this.isConnected && state === client.state && status === client.status + && roomId === state.roomId && participantId === state.me?.participantId + && revision === this.publicRosterRevision && event.revision === this.publicRosterEventRevision + && !event.pending!.invalid && event.pending === this.publicRosterPending; + }; + const limit = state.limits?.maxSpeakers, people = state.speakers, controllerId = state.controllerId; + const rows: { id: string; text: string }[] = [], seen = new Set(); + let controllers = 0, speakers = 0; + let valid = typeof limit === 'number' && Number.isSafeInteger(limit) && limit > 0 + && Array.isArray(people) && people.length - 1 <= limit + && typeof controllerId === 'string' && Boolean(controllerId.trim()); + if (valid) { + const length = people.length; + for (let index = 0; index < length; index++) { + const person = people[index]; + if (!person || typeof person !== 'object') { valid = false; break; } + const id = person.participantId, name = person.name, role = person.role, onStage = person.onStage; + const muted = person.muted, forceMuted = person.forceMuted, connected = person.connected; + if (typeof id !== 'string' || !id.trim() || seen.has(id) || typeof name !== 'string' + || onStage !== true || (role !== 'controller' && role !== 'speaker') + || (id === controllerId) !== (role === 'controller') || typeof muted !== 'boolean' + || typeof forceMuted !== 'boolean' || typeof connected !== 'boolean' || (forceMuted && !muted)) { + valid = false; break; + } + seen.add(id); + if (role === 'controller') controllers++; else speakers++; + if (controllers > 1 || speakers > limit) { valid = false; break; } + rows.push({ id, text: `${decodeName(name)} · ${role === 'controller' ? '主控' : '發言者'} · ${forceMuted ? '主控鎖定靜音' : muted ? '已靜音' : '未靜音'} · ${connected ? '控制連線已連接' : '控制連線中斷,保留席位'}` }); + } + } + if (!current()) return; + if (!valid) { this.retirePublicRoster(); return; } + if (!this.publicRosterOwner) this.publicRosterOwner = { client, generation, roomId, participantId }; + this.publicRosterReport = { rows, last: false, awaiting: false }; + ++this.publicRosterRevision; + } + + /** Only admitted state receipts read the public roster; ordinary paints use copied text. */ + private syncPublicRoster(repair = false): void { + this.reconcilePublicRoster(); + const owner = this.publicRosterOwner, report = this.publicRosterReport, revision = this.publicRosterRevision; + const current = (): boolean => { + this.reconcilePublicRoster(); + if (revision !== this.publicRosterRevision || owner !== this.publicRosterOwner) { + // A host setter can finish an old write after the nested winner has already painted. + this.syncPublicRoster(true); return false; + } + return true; + }; + if (!report || owner !== this.publicRosterPaintedOwner) { + if (!this.publicRosterPanel.hidden) this.publicRosterPanel.hidden = true; + if (!current()) return; + if (this.publicRosterPanel.open) this.publicRosterPanel.open = false; + if (!current()) return; + if (this.publicRosterRows.size || this.publicRosterOrder.length || repair) { + this.publicRosterRows.clear(); this.publicRosterOrder = []; + this.publicRosterList.replaceChildren(); + if (!current()) return; + } + this.publicRosterPaintedOwner = owner; + } + const status = owner?.client.status; + if (!current()) return; + const notice = !report ? '' : `${report.last ? status === 'reconnecting' ? '重新連線中,上次快照' : report.awaiting ? '上次快照,等待新快照' : '上次快照' : '最近快照'}:${report.rows.length ? '依台上順序列出成員。' : '沒有台上成員。'}靜音與控制連線旗標僅代表收到的快照,包含斷線保留席位,不代表正在發言或可聽見音訊。`; + if (this.publicRosterNotice.textContent !== notice) this.publicRosterNotice.textContent = notice; + if (!current()) return; + const rows = report?.rows ?? []; + for (const item of rows) { + let row = this.publicRosterRows.get(item.id); + if (!row) { + row = document.createElement('li'); + if (!current()) return; + this.publicRosterRows.set(item.id, row); + } + if (row.textContent !== item.text) row.textContent = item.text; + if (!current()) return; + } + const order = rows.map(item => item.id); + if (repair || order.length !== this.publicRosterOrder.length || order.some((id, index) => id !== this.publicRosterOrder[index])) { + this.publicRosterOrder = order; + const retained = new Set(order); + for (const id of this.publicRosterRows.keys()) if (!retained.has(id)) this.publicRosterRows.delete(id); + this.publicRosterList.replaceChildren(...order.map(id => this.publicRosterRows.get(id)!)); + if (!current()) return; + } + if (report && this.publicRosterPanel.hidden) this.publicRosterPanel.hidden = false; + current(); + } + + private retireOccupancy(): void { + ++this.occupancyRevision; + this.occupancyOwner = undefined; this.occupancyReport = undefined; + } + + /** Counts come only from new authoritative states, never status or membership deltas. */ + private syncOccupancy(snapshot?: StageEventMap['state'], terminal = false): void { + const revision = ++this.occupancyRevision; + const client = this.current, state = client?.state, me = state?.me; + const owner = this.occupancyOwner; + const sameOwner = owner && owner.client === client && owner.generation === this.generation + && owner.roomId === state?.roomId && owner.participantId === me?.participantId; + const active = client?.status === 'waiting' || client?.status === 'live'; + const joined = this.isConnected && client && state && typeof state.roomId === 'string' && state.roomId.trim() + && typeof me?.participantId === 'string' && me.participantId.trim(); + if (terminal || !joined || (!active && client.status !== 'reconnecting') || !sameOwner) { + this.occupancyOwner = undefined; this.occupancyReport = undefined; + } + const unseen = snapshot && !this.occupancySnapshots.has(snapshot); + if (snapshot) this.occupancySnapshots.add(snapshot); + if (!terminal && joined && active && snapshot === state && unseen) { + this.occupancyOwner = undefined; this.occupancyReport = undefined; + const limits = state.limits, audience = state.audienceCount; + const count = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; + if (limits && typeof limits === 'object' && !Array.isArray(limits) && count(limits.maxAudience) && limits.maxAudience > 0 + && count(limits.maxSpeakers) && limits.maxSpeakers > 0 && count(audience) + && typeof state.controllerId === 'string' && state.controllerId.trim() && Array.isArray(state.speakers)) { + const seen = new Set(); + let controllerOnStage = false, valid = true; + for (const person of state.speakers) { + if (!person || typeof person.participantId !== 'string' || !person.participantId.trim() + || seen.has(person.participantId) || person.onStage !== true + || (person.role !== 'controller' && person.role !== 'speaker') + || (person.participantId === state.controllerId) !== (person.role === 'controller')) { valid = false; break; } + seen.add(person.participantId); + if (person.role === 'controller') controllerOnStage = true; + } + const stage = state.speakers.length - Number(controllerOnStage), room = audience + stage; + if (valid && count(stage) && count(room) && stage <= limits.maxSpeakers && room <= limits.maxAudience) { + this.occupancyOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; + this.occupancyReport = { room, stage, audience, maxAudience: limits.maxAudience, + maxSpeakers: limits.maxSpeakers, controllerOnStage, last: false }; + } + } + } + const report = this.occupancyReport; + if (report && client?.status === 'reconnecting') report.last = true; + const status = client?.status; + const current = (): boolean => { + if (revision !== this.occupancyRevision) return false; + const accepted = this.occupancyOwner; + if (report && (!accepted || !this.isConnected || this.current !== accepted.client + || this.generation !== accepted.generation || accepted.client.state?.roomId !== accepted.roomId + || accepted.client.me?.participantId !== accepted.participantId || accepted.client.status !== status)) { + this.syncOccupancy(); return false; + } + return true; + }; + const text = report ? `房間席位(${report.last ? client?.status === 'reconnecting' ? '重新連線中,上次快照' : '上次快照' : '最近快照'}):主控以外 ${report.room} / ${report.maxAudience} 人,其中台上 ${report.stage} / ${report.maxSpeakers} 人、觀眾 ${report.audience} 人;主控另計 1 人(${report.controllerOnStage ? '台上' : '台下'})。含斷線保留席位。` : ''; + if (!report && !this.occupancy.hidden) this.occupancy.hidden = true; + // A host setter can synchronously retire or replace the seat while this paragraph is painted. + if (!current()) return; + if (this.occupancy.textContent !== text) this.occupancy.textContent = text; + if (!current()) return; + if (report && this.occupancy.hidden) this.occupancy.hidden = false; + current(); + } + + private retireRoomClose(): void { + if (this.roomCloseAttempt) this.roomCloseAttempt.invalid = true; + this.roomCloseOwner = undefined; this.roomCloseAttempt = undefined; + } + + private noteRoomCloseEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { + const client = this.current, authority = this.roomCloseAuthority; + if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) { + this.roomCloseTerminalClient = client ?? undefined; this.retireRoomClose(); return; + } + if (type === 'state') { + this.roomCloseTerminalClient = undefined; + if (this.roomCloseAuthority) this.roomCloseAuthority.awaitingState = false; + } + const role = type === 'role' ? detail as StageEventMap['role'] : undefined; + const transfer = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; + // Both dialogs consume known loss using copied IDs before either helper reads public getters. + if (authority && authority.client === client && authority.generation === this.generation + && ((role && role.participantId === authority.participantId && role.role !== 'controller') + || (transfer && transfer.fromId === authority.participantId && transfer.toId !== authority.participantId))) { + this.roomCloseAuthority = { ...authority, revision: authority.revision + 1, awaitingState: true }; + this.retireRoomClose(); + } + if (type === 'status' && detail === 'reconnecting' && this.roomCloseAttempt) this.roomCloseAttempt.invalid = true; + } + + private roomCloseMounted(): boolean { + return this.isConnected && this.roomClose.isConnected && this.widgetRoot.contains(this.roomActions) + && this.roomActions.contains(this.roomClose); + } + + private canCloseRoom(): boolean { + const owner = this.roomCloseOwner, client = owner?.client; + return Boolean(owner && client && this.current === client && this.generation === owner.generation + && client.state?.roomId === owner.roomId && client.me?.participantId === owner.participantId + && client.me?.role === 'controller' && client.me.connected === true + && (client.status === 'waiting' || client.status === 'live') && this.roomCloseTerminalClient !== client + && !this.roomCloseAuthority?.awaitingState && this.roomCloseAuthority?.revision === owner.revision + && typeof client.closeRoom === 'function' && typeof window.confirm === 'function' && this.roomCloseMounted()); + } + + private reconcileRoomClose(): void { + const client = this.current, state = client?.state, me = client?.me; + const active = client?.status === 'waiting' || client?.status === 'live'; + const authority = this.roomCloseAuthority; + if (authority && (authority.client !== client || authority.generation !== this.generation + || authority.roomId !== state?.roomId || authority.participantId !== me?.participantId)) this.roomCloseAuthority = undefined; + if (!this.isConnected || !client || !state || me?.role !== 'controller' || this.roomCloseTerminalClient === client + || (!active && client.status !== 'reconnecting') || typeof client.closeRoom !== 'function' || this.roomCloseAuthority?.awaitingState) { + this.retireRoomClose(); + } else { + const owner = this.roomCloseOwner; + if (!owner || owner.client !== client || owner.generation !== this.generation + || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + this.retireRoomClose(); + // Reconnect can retain a controller tenure, but cannot establish consent for a new one. + if (active && me.connected) { + this.roomCloseAuthority ??= { client, roomId: state.roomId, participantId: me.participantId, + generation: this.generation, revision: 0, awaitingState: false }; + this.roomCloseOwner = { client, roomId: state.roomId, participantId: me.participantId, + generation: this.generation, revision: this.roomCloseAuthority.revision }; + } + } + } + if (this.roomCloseAttempt && !this.canCloseRoom()) this.roomCloseAttempt.invalid = true; + } + + private syncRoomClose(): void { + this.reconcileRoomClose(); + const owner = this.roomCloseOwner, attempt = this.roomCloseAttempt, revision = ++this.roomCloseRenderRevision; + const allowed = this.canCloseRoom(), hidden = !owner, disabled = !allowed || Boolean(attempt), busy = String(Boolean(attempt)); + const current = (): boolean => { + if (revision !== this.roomCloseRenderRevision) return false; + // A host setter can change the exposed snapshot or detach a control without rendering. + this.reconcileRoomClose(); + if (this.roomCloseOwner !== owner || this.roomCloseAttempt !== attempt || this.canCloseRoom() !== allowed) { + this.syncRoomClose(); return false; + } + return true; + }; + if (this.roomClose.hidden !== hidden) this.roomClose.hidden = hidden; + if (!current()) return; + if (this.roomClose.disabled !== disabled) this.roomClose.disabled = disabled; + if (!current()) return; + if (this.roomClose.getAttribute('aria-busy') !== busy) this.roomClose.setAttribute('aria-busy', busy); + if (!current()) return; + if (this.roomCloseNotice.hidden !== hidden) this.roomCloseNotice.hidden = hidden; + current(); + } + + private closeRoom(): void { + if (this.roomCloseDialog || this.stageTransferDialogs.size) return; + const dialog = this.roomCloseDialog = {}; + let owner: StageElement['roomCloseOwner'], attempt: StageElement['roomCloseAttempt']; + const owns = (): boolean => Boolean(owner && attempt && this.roomCloseOwner === owner && this.roomCloseAttempt === attempt); + const eligible = (): boolean => owns() && !attempt!.invalid && attempt!.revision === owner!.revision && this.canCloseRoom(); + const releaseDialog = (): void => { if (this.roomCloseDialog === dialog) this.roomCloseDialog = undefined; }; + void (async () => { + try { + this.reconcileRoomClose(); owner = this.roomCloseOwner; + if (!owner || this.roomCloseAttempt || !this.canCloseRoom()) { this.syncRoomClose(); return; } + attempt = this.roomCloseAttempt = { owner, revision: owner.revision, invalid: false, errorRevision: this.errorRevision }; + this.syncRoomClose(); + if (!eligible()) return; + if (!window.confirm('確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。')) return; + this.syncRoomClose(); + if (!eligible()) return; + const result = owner.client.closeRoom(); + releaseDialog(); + // ACK is not terminal truth: only the SDK's closed/kicked lifecycle changes room presence. + await result; + } catch (error) { + if (owns()) { + try { this.syncRoomClose(); } catch { /* A failed DOM write can be reconciled on the next action. */ } + if (eligible() && this.errorRevision === attempt!.errorRevision) this.show(error); + } + } finally { + releaseDialog(); + if (owns()) { + this.roomCloseAttempt = undefined; + try { this.syncRoomClose(); } catch { /* Release the exact token even if a host setter throws. */ } + } + } + })(); + } + + private retireConnectionStats(): void { + this.connectionStatsOwner = undefined; this.connectionStatsAttempt = undefined; + this.connectionStatsReport = undefined; this.connectionStatsFailed = false; + } + + private connectionStatsMounted(): boolean { + return this.isConnected && this.connectionStatsPanel.isConnected && this.connectionStatsUpdate.isConnected + && this.connectionStatsText.isConnected && this.widgetRoot.contains(this.connectionStatsPanel) + && this.connectionStatsPanel.contains(this.connectionStatsUpdate) && this.connectionStatsPanel.contains(this.connectionStatsText); + } + + private connectionStatsIdentityReader() { + const stats = this.connectionStatsOwner; + const close = this.roomCloseAttempt, closeOwner = close?.owner; + const transfer = this.stageTransferAttempt, transferOwner = this.stageManagementOwner; + const observe = (field: string, value: unknown): void => { + const lost = (owner: NonNullable): boolean => + owner.client !== this.current || owner.generation !== this.generation || field === 'unreadable' + || ((field === 'state' || field === 'me') && !value) + || (field === 'status' && value !== 'waiting' && value !== 'live') + || (field === 'roomId' && value !== owner.roomId) || (field === 'participantId' && value !== owner.participantId); + // Consume each observation before another public getter can restore identity or admit a new dialog. + if (close && (!closeOwner || lost(closeOwner))) close.invalid = true; + if (transfer && (!transferOwner || lost(transferOwner))) transfer.invalid = true; + if (stats && this.connectionStatsOwner === stats && lost(stats)) this.retireConnectionStats(); + }; + return (field: 'state' | 'me' | 'status' | 'roomId' | 'participantId', get: () => T): T => { + let value: T; + try { value = get(); } + catch (error) { observe('unreadable', undefined); throw error; } + observe(field, value); return value; + }; + } + + /** Manual readership needs a current seat, not a publisher, microphone, or new passive report. */ + private reconcileConnectionStats(): void { + for (;;) { + const owner = this.connectionStatsOwner, client = this.current, generation = this.generation; + const read = this.connectionStatsIdentityReader(); + const state = read('state', () => client?.state), me = read('me', () => client?.me), status = read('status', () => client?.status); + const roomId = read('roomId', () => state?.roomId), participantId = read('participantId', () => me?.participantId); + if (owner !== this.connectionStatsOwner || client !== this.current || generation !== this.generation + || state !== read('state', () => client?.state) || status !== read('status', () => client?.status) + || roomId !== read('roomId', () => read('state', () => client?.state)?.roomId) + || participantId !== read('participantId', () => read('me', () => client?.me)?.participantId)) continue; + const eligible = Boolean(client && state && me && typeof roomId === 'string' && typeof participantId === 'string' + && (status === 'waiting' || status === 'live') && this.connectionStatsTerminalClient !== client + && !this.connectionStatsLeaving && !this.leftRoom && !this.roomExit && this.connectionStatsMounted()); + const lost = Boolean(owner && (!eligible || owner.client !== client || owner.generation !== generation + || owner.roomId !== roomId || owner.participantId !== participantId)); + if (lost) this.retireConnectionStats(); + const retained = this.connectionStatsOwner; + this.reconcileRoomClose(); + const transfer = this.stageTransferAttempt; + if (transfer && !this.canTransferStage(transfer.id, transfer.node)) transfer.invalid = true; + if (retained !== this.connectionStatsOwner || client !== this.current || generation !== this.generation + || state !== read('state', () => client?.state) || status !== read('status', () => client?.status) + || roomId !== read('roomId', () => read('state', () => client?.state)?.roomId) + || participantId !== read('participantId', () => read('me', () => client?.me)?.participantId) + || eligible !== Boolean(client && state && me + && typeof roomId === 'string' && typeof participantId === 'string' + && (status === 'waiting' || status === 'live') && this.connectionStatsTerminalClient !== client + && !this.connectionStatsLeaving && !this.leftRoom && !this.roomExit && this.connectionStatsMounted())) continue; + if (eligible && !retained) this.connectionStatsOwner = { client: client!, generation, roomId: roomId!, participantId: participantId! }; + return; + } + } + + private connectionStatsMessage(): string { + const report = this.connectionStatsReport; + if (!report) return this.connectionStatsFailed ? '無法讀取連線統計,請稍後手動再試。' : '尚未手動讀取連線統計。'; + const loss = (value: number | undefined): string => value === undefined ? '—' : value.toFixed(1); + const ms = (value: number | undefined): string => value === undefined ? '—' : String(Math.round(value)); + const inbound = report.inbound, outbound = report.outbound; + const receive = inbound ? `接收掉包 ${loss(inbound.loss)}% · 接收 RTT ${ms(inbound.rtt)} ms · 接收緩衝 ${ms(inbound.buffer)} ms` : '接收:暫無統計'; + const send = outbound ? `傳送掉包 ${loss(outbound.loss)}% · 傳送 RTT ${ms(outbound.rtt)} ms` : '傳送:暫無統計'; + return `上次手動讀取(SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。` + + (this.connectionStatsFailed ? '本次讀取失敗,仍顯示上次手動讀取結果。' : ''); + } + + private syncConnectionStats(): void { + for (;;) { + // Existing leave teardown has earlier host callbacks; defer new DOM until its current=null fence. + if (this.current && (this.connectionStatsLeaving || this.leftRoom || this.roomExit)) { + this.retireConnectionStats(); return; + } + this.reconcileConnectionStats(); + const owner = this.connectionStatsOwner, attempt = this.connectionStatsAttempt; + const report = this.connectionStatsReport, failed = this.connectionStatsFailed; + const hidden = !owner, busy = Boolean(attempt), disabled = hidden || busy; + const text = hidden ? '' : this.connectionStatsMessage(); + // Reproject after every changed setter, including a stale outer setter that overwrites a nested winner. + if (hidden && !this.connectionStatsPanel.hidden) { this.connectionStatsPanel.hidden = true; continue; } + if (this.connectionStatsText.textContent !== text) { this.connectionStatsText.textContent = text; continue; } + if (this.connectionStatsUpdate.disabled !== disabled) { this.connectionStatsUpdate.disabled = disabled; continue; } + if (this.connectionStatsUpdate.getAttribute('aria-busy') !== String(busy)) { + this.connectionStatsUpdate.setAttribute('aria-busy', String(busy)); continue; + } + const label = busy ? '讀取中…' : '更新我的連線統計'; + if (this.connectionStatsUpdate.textContent !== label) { this.connectionStatsUpdate.textContent = label; continue; } + if (this.connectionStatsPanel.hidden !== hidden) { this.connectionStatsPanel.hidden = hidden; continue; } + this.reconcileConnectionStats(); + if (owner === this.connectionStatsOwner && attempt === this.connectionStatsAttempt + && report === this.connectionStatsReport && failed === this.connectionStatsFailed) return; + } + } + + private projectConnectionStats(raw: unknown, current: () => boolean): StageElement['connectionStatsReport'] { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(); + const direction = (key: 'inbound' | 'outbound') => { + const value: unknown = (raw as Record)[key]; + if (!current()) return undefined; + if (value === null || value === undefined) return null; + if (typeof value !== 'object' || Array.isArray(value)) throw new Error(); + return value as Record; + }; + const numeric = (value: unknown, max = Infinity, factor = 1): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > max) return undefined; + const converted = value * factor; + return Number.isFinite(converted) ? converted : undefined; + }; + const inbound = direction('inbound'); + if (!current()) return undefined; + let receive: NonNullable['inbound'] = null; + if (inbound) { + const loss = numeric(inbound.lossPercent, 100); if (!current()) return undefined; + const rtt = numeric(inbound.rtt, Infinity, 1000); if (!current()) return undefined; + const buffer = numeric(inbound.bufferMs); if (!current()) return undefined; + receive = { loss, rtt, buffer }; + } + const outbound = direction('outbound'); + if (!current()) return undefined; + let send: NonNullable['outbound'] = null; + if (outbound) { + const loss = numeric(outbound.lossPercent, 100); if (!current()) return undefined; + const rtt = numeric(outbound.rtt, Infinity, 1000); if (!current()) return undefined; + send = { loss, rtt }; + } + return { inbound: receive, outbound: send }; + } + + private refreshConnectionStats(): void { + if (this.connectionStatsLeaving || this.leftRoom || this.roomExit) return; + if (this.connectionStatsUpdate.disabled || this.connectionStatsUpdate.hidden || this.connectionStatsPanel.hidden + || !this.connectionStatsMounted()) return; + this.syncConnectionStats(); + const owner = this.connectionStatsOwner; + if (!owner || this.connectionStatsAttempt) return; + // Reserve before any getStats lookup or DOM callback can invoke the retained handler again. + const attempt = this.connectionStatsAttempt = {}; + this.connectionStatsFailed = false; + const current = (): boolean => { + this.syncConnectionStats(); + return this.connectionStatsOwner === owner && this.connectionStatsAttempt === attempt; + }; + void (async () => { + try { + if (!current()) return; + const read = owner.client.getStats; + if (!current()) return; + if (typeof read !== 'function') throw new Error(); + const pending = read.call(owner.client); + const active = current(); + // Consume even a retired native promise; UI retirement does not cancel SDK sampling or its deltas. + const raw: unknown = await pending; + if (!current() || !active) return; + const report = this.projectConnectionStats(raw, current); + if (!current() || !report) return; + this.connectionStatsReport = report; + } catch { + if (current()) this.connectionStatsFailed = true; + } finally { + if (this.connectionStatsOwner === owner && this.connectionStatsAttempt === attempt) this.connectionStatsAttempt = undefined; + this.syncConnectionStats(); + } + })(); + } + private retireQualityOwner(): void { - this.qualityOwner = undefined; this.qualityReport = undefined; - this.quality.textContent = ''; this.quality.hidden = true; + ++this.qualityRevision; + this.qualityOwner = undefined; this.qualityReport = undefined; this.qualityClearFirst = true; } private retireSpeakingOwner(): void { + ++this.speakingRevision; this.speakingOwner = undefined; this.speakingReport = undefined; - this.speaking.textContent = ''; this.speaking.hidden = true; + this.speakingSuppressed.clear(); } private retireHandRejection(): void { @@ -605,79 +1379,252 @@ export class StageElement extends HTMLElement { if (this.handRejection.textContent !== text) this.handRejection.textContent = text; } - /** A resumed SDK getter can retain the old report; only a new event establishes this owner's set. */ - private syncSpeakingOwner(report?: { participantIds: unknown }): void { - const client = this.current, state = client?.state, me = state?.me; - if (!this.isConnected || !client || !state || !me - || (client.status !== 'waiting' && client.status !== 'live')) { - this.retireSpeakingOwner(); return; + private rememberSpeakingEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): { revision: number; unseen: boolean } { + const revision = ++this.speakingEventRevision; + const identities = type === 'state' ? this.speakingSnapshots : type === 'speaking' ? this.speakingReceipts : undefined; + const object = detail !== null && (typeof detail === 'object' || typeof detail === 'function'); + const unseen = !object || !identities?.has(detail as object); + // Reserve even rejected envelopes before public getters can reenter. Primitive malformed reports stay unknown. + if (identities && object) identities.add(detail as object); + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) this.retireSpeakingOwner(); + else if (type === 'stageleft' || (type === 'role' && (detail as StageEventMap['role']).role === 'audience')) { + const id = (detail as StageEventMap['stageleft']).participantId; + if (revision === this.speakingEventRevision && typeof id === 'string') { + this.speakingSuppressed.add(id); + this.speakingReport?.ids.delete(id); + ++this.speakingRevision; + } } - const owner = this.speakingOwner; - if (!owner || owner.client !== client || owner.generation !== this.generation - || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + return { revision, unseen }; + } + + /** Room-wide readership survives own-stage loss; only connection and entry identity bind its tenure. */ + private reconcileSpeakingOwner(): void { + for (;;) { + const owner = this.speakingOwner; + if (!owner) return; + const revision = this.speakingRevision, client = owner.client; + const state = client.state, status = client.status; + const roomId = state?.roomId, participantId = state?.me?.participantId; + const currentState = client.state, currentStatus = client.status; + if (owner !== this.speakingOwner || revision !== this.speakingRevision) continue; + if (state !== currentState || status !== currentStatus) continue; + if (this.isConnected && this.current === client && this.generation === owner.generation + && roomId === owner.roomId && participantId === owner.participantId + && (status === 'waiting' || status === 'live')) return; this.retireSpeakingOwner(); - this.speakingOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; - } - if (report) { - const ids = Array.isArray(report.participantIds) ? [...report.participantIds] : undefined; - // Copy before forwarding the mutable event; malformed synthetic reports are not evidence of silence. - this.speakingReport = ids?.every(id => typeof id === 'string') ? { ids: new Set(ids), empty: !ids.length } : undefined; - } - const current = this.speakingReport; - this.speaking.hidden = false; - if (!current) { this.speaking.textContent = '發言偵測:等待伺服器回報。'; return; } - if (current.empty) { this.speaking.textContent = '最近收到的發言回報:未偵測到發言。'; return; } - const seen = new Set(), names: string[] = []; - for (const person of state.speakers) { - if (!person.onStage || (person.role !== 'speaker' && person.role !== 'controller') - || !current.ids.has(person.participantId) || seen.has(person.participantId)) continue; - seen.add(person.participantId); - // Control-socket grace can retain media, so connected is deliberately not an eligibility test. - names.push(decodeName(person.name)); - } - // A later stage entry must not revive an ID from an earlier, unmatched or removed seat. - current.ids = seen; - this.speaking.textContent = names.length ? `最近收到的發言回報:${names.join('、')}(${names.length} 人)。` - : '最近收到的發言回報:沒有可對應的台上成員。'; - } - - /** Reports have no timestamp or media epoch; only a new event may supply this seat's values. */ - private syncQualityOwner(reports?: readonly ConnectionQuality[]): void { - const client = this.current, state = client?.state, me = state?.me; - if (!this.isConnected || !client || !state || !me?.onStage - || client.status === 'reconnecting' || client.status === 'disconnected') { - this.retireQualityOwner(); return; + // A corrective paint must not allow its setters to restore either pending dialog's consent. + this.noteRoomCloseEvent('status', currentStatus); + this.noteStageTransferEvent('status', currentStatus); + this.reconcileRoomClose(); } - const owner = this.qualityOwner; - if (!owner || owner.client !== client || owner.generation !== this.generation - || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + } + + private noteSpeakingEvent(client: StageClient, generation: number, type: keyof StageEventMap, + detail: StageEventMap[keyof StageEventMap], event: { revision: number; unseen: boolean }): void { + this.reconcileSpeakingOwner(); + if (event.revision !== this.speakingEventRevision || this.current !== client || this.generation !== generation) return; + if (type === 'state' && event.unseen) { + const state = client.state, status = client.status; + const roomId = state?.roomId, participantId = state?.me?.participantId; + const eligible = state && state === detail && this.isConnected && participantId !== undefined + && (status === 'waiting' || status === 'live') && state === client.state && status === client.status; + this.reconcileSpeakingOwner(); + if (eligible && event.revision === this.speakingEventRevision && this.current === client && this.generation === generation) { + if (!this.speakingOwner) this.speakingOwner = { client, roomId: roomId!, participantId, generation }; + // Releasing suppression never repopulates the already-pruned historical report. + this.speakingSuppressed.clear(); + ++this.speakingRevision; + } + } + this.reconcileSpeakingOwner(); + const owner = this.speakingOwner; + if (type !== 'speaking' || !event.unseen || !owner || event.revision !== this.speakingEventRevision) return; + const raw = (detail as StageEventMap['speaking'] | null | undefined)?.participantIds; + const ids: unknown[] | undefined = Array.isArray(raw) ? [...raw] : undefined; + const report = ids?.every((id): id is string => typeof id === 'string') + ? { ids: new Set(ids.filter(id => !this.speakingSuppressed.has(id))), empty: ids.length === 0 } : undefined; + this.reconcileSpeakingOwner(); + if (owner !== this.speakingOwner || event.revision !== this.speakingEventRevision) return; + this.speakingReport = report; ++this.speakingRevision; + } + + private speakingProjection(): { hidden: boolean; text: string; revision: number } { + for (;;) { + this.reconcileSpeakingOwner(); + const owner = this.speakingOwner, report = this.speakingReport, revision = this.speakingRevision; + const client = this.current, generation = this.generation, state = client?.state, status = client?.status; + // SDK status can precede the first receipt for a new identity. This placeholder admits no report. + const placeholder = !owner && state?.me && !this.speakingSnapshots.has(state) && this.isConnected + && (status === 'waiting' || status === 'live'); + const hidden = !owner && !placeholder; + let text = hidden ? '' : '發言偵測:等待伺服器回報。'; + let seen: Set | undefined; + if (owner && report) { + if (report.empty) text = '最近收到的發言回報:未偵測到發言。'; + else { + seen = new Set(); const names: string[] = []; + for (const person of state?.speakers ?? []) { + const id = person.participantId, onStage = person.onStage, role = person.role; + if (!onStage || (role !== 'speaker' && role !== 'controller') || !report.ids.has(id) + || this.speakingSuppressed.has(id) || seen.has(id)) continue; + seen.add(id); + // Read the live public roster on every original render, including disconnected grace seats. + names.push(decodeName(person.name)); + } + text = names.length ? `最近收到的發言回報:${names.join('、')}(${names.length} 人)。` + : '最近收到的發言回報:沒有可對應的台上成員。'; + } + } + const sameSnapshot = state === client?.state && status === client?.status; + // Always reconcile the live winner, even after a getter completed a newer nested report. + this.reconcileSpeakingOwner(); + if (revision !== this.speakingRevision || owner !== this.speakingOwner || client !== this.current + || generation !== this.generation || !sameSnapshot) continue; + if (seen && report) report.ids = seen; + return { hidden, text, revision }; + } + } + + /** New events supply activity; rendering only maps and permanently prunes that copied report. */ + private syncSpeakingOwner(force = true): void { + const projection = this.speakingProjection(); + if (!force && projection.hidden === this.speakingPainted.hidden && projection.text === this.speakingPainted.text) return; + const current = (): boolean => { + const next = this.speakingProjection(); + if (next.revision !== projection.revision || next.hidden !== projection.hidden || next.text !== projection.text) { + // A host setter may finish its old write after a nested painter; its cached paint is no longer proof. + this.syncSpeakingOwner(); return false; + } + return true; + }; + // Keep ordinary unconditional replacement and its hidden-then-text order, including host descendant repair. + if (projection.hidden) { + this.speaking.textContent = projection.text; + if (!current()) return; + this.speaking.hidden = true; + } else { + this.speaking.hidden = false; + if (!current()) return; + this.speaking.textContent = projection.text; + } + if (current()) this.speakingPainted = projection; + } + + /** Retirement is pure: a corrective DOM write must never precede the dialog authority fences. */ + private reconcileQualityOwner(): void { + for (;;) { + const owner = this.qualityOwner; + if (!owner) return; + const revision = this.qualityRevision, client = owner.client; + const status = client.status, state = client.state; + // Public getters can finish nested work before returning. Recheck the live owner, not just this painter. + const currentState = client.state, currentStatus = client.status; + if (owner !== this.qualityOwner || revision !== this.qualityRevision) continue; + if (state !== currentState || status !== currentStatus) continue; + if (this.isConnected && this.current === client && this.generation === owner.generation + && state?.roomId === owner.roomId && state.me.participantId === owner.participantId + && state.me.onStage && (status === 'waiting' || status === 'live')) return; this.retireQualityOwner(); - this.qualityOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; - } - if (reports !== undefined) { - let report: ConnectionQuality | undefined; - for (const candidate of reports) if (candidate.participantId === me.participantId) report = candidate; - // The event remains public and mutable; retain only primitive values from its full replacement set. - this.qualityReport = report ? { - uplinkLossPercent: typeof report.uplinkLossPercent === 'number' ? report.uplinkLossPercent : undefined, - downlinkLossPercent: typeof report.downlinkLossPercent === 'number' ? report.downlinkLossPercent : undefined, - rttMs: typeof report.rttMs === 'number' ? report.rttMs : undefined, - } : undefined; - } - const report = this.qualityReport; - this.quality.hidden = !report; - if (!report) { this.quality.textContent = ''; return; } + this.noteRoomCloseEvent('status', currentStatus); + this.noteStageTransferEvent('status', currentStatus); + this.reconcileRoomClose(); + // Either authority getter may have admitted a newer entry; reconcile that entry too. + } + } + + private rememberQualityEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): { revision: number; unseen: boolean } { + const revision = ++this.qualityEventRevision; + const identities = type === 'state' ? this.qualitySnapshots : type === 'quality' ? this.qualityReceipts : undefined; + const unseen = Boolean(identities && detail && !identities.has(detail as object)); + // Consume before any public getter: even rejected or superseded receipts cannot gain authority on replay. + if (identities && detail) identities.add(detail as object); + const ownId = this.qualityOwner?.participantId; + // Known loss must precede even authority getters: a nested report alone cannot outrun retirement. + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected')) + || (ownId !== undefined && type === 'stageleft' && (detail as StageEventMap['stageleft']).participantId === ownId) + || (ownId !== undefined && type === 'role' && (detail as StageEventMap['role']).participantId === ownId + && (detail as StageEventMap['role']).role === 'audience')) this.retireQualityOwner(); + else if (type === 'micerror') { + ++this.qualityRevision; this.qualityReport = undefined; this.qualityClearFirst = true; + } + return { revision, unseen }; + } + + private noteQualityEvent(client: StageClient, generation: number, type: keyof StageEventMap, + detail: StageEventMap[keyof StageEventMap], event: { revision: number; unseen: boolean }): void { + if (event.revision !== this.qualityEventRevision || this.current !== client || this.generation !== generation) return; + const snapshot = type === 'state' ? detail as StageEventMap['state'] : undefined; + const receipt = type === 'quality' ? detail as StageEventMap['quality'] : undefined; + this.reconcileQualityOwner(); + const state = client.state; + if (event.revision !== this.qualityEventRevision || this.current !== client || this.generation !== generation) return; + if (snapshot && event.unseen && snapshot === state && this.isConnected && state.me.onStage + && (client.status === 'waiting' || client.status === 'live') + && snapshot === client.state && this.current === client && this.generation === generation + && event.revision === this.qualityEventRevision) { + if (!this.qualityOwner) { + this.qualityOwner = { client, roomId: state.roomId, participantId: state.me.participantId, generation }; + ++this.qualityRevision; this.qualityClearFirst = false; + } + } + this.reconcileQualityOwner(); + const owner = this.qualityOwner; + if (!receipt || !event.unseen || !owner || event.revision !== this.qualityEventRevision) return; + let report: ConnectionQuality | undefined; + for (const candidate of receipt.participants) if (candidate.participantId === owner.participantId) report = candidate; + // Preserve the last whole own item, and copy only its primitives before forwarding the raw detail. + const up = report?.uplinkLossPercent, down = report?.downlinkLossPercent, rtt = report?.rttMs; + const copied = report ? { + uplinkLossPercent: typeof up === 'number' ? up : undefined, + downlinkLossPercent: typeof down === 'number' ? down : undefined, + rttMs: typeof rtt === 'number' ? rtt : undefined, + } : undefined; + this.reconcileQualityOwner(); + if (owner !== this.qualityOwner || event.revision !== this.qualityEventRevision) return; + ++this.qualityRevision; this.qualityReport = copied; this.qualityClearFirst = false; + } + + /** Reports have no timestamp or media epoch; only a new admitted receipt supplies values. */ + private syncQualityOwner(force = true): void { + this.reconcileQualityOwner(); + if (!force && this.qualityPaintedRevision === this.qualityRevision) return; + const revision = this.qualityRevision, report = this.qualityReport; + const current = (): boolean => { + const before = this.qualityRevision; + // Even a superseded painter must observe silent ownership loss after nested newer work. + this.reconcileQualityOwner(); + if (before !== this.qualityRevision) { this.syncQualityOwner(); return false; } + return revision === this.qualityRevision; + }; const percent = (value: number | undefined) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 100 ? `${value.toFixed(1)}%` : '—'; const rtt = (value: number | undefined) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? `${Math.round(value)} ms` : '—'; - this.quality.textContent = `你的連線品質(最近收到):上行掉包 ${percent(report.uplinkLossPercent)} · 下行掉包 ${percent(report.downlinkLossPercent)} · RTT ${rtt(report.rttMs)}(非端到端音訊延遲)`; + const text = report ? `你的連線品質(最近收到):上行掉包 ${percent(report.uplinkLossPercent)} · 下行掉包 ${percent(report.downlinkLossPercent)} · RTT ${rtt(report.rttMs)}(非端到端音訊延遲)` : ''; + // Keep unconditional text replacement, including repair of same-text host-added descendants. + if (this.qualityClearFirst) { + this.quality.textContent = text; + if (!current()) return; + this.quality.hidden = !report; + } else { + this.quality.hidden = !report; + if (!current()) return; + this.quality.textContent = text; + } + if (current()) { + this.qualityPaintedRevision = revision; + if (this.qualityOwner) this.qualityClearFirst = false; + } } private retireNameOwner(): void { this.nameOwner = undefined; this.nameAttempt = undefined; this.nameInput.value = ''; this.acceptedName.textContent = ''; - this.namePanel.hidden = true; - this.nameInput.disabled = this.nameUpdate.disabled = true; - this.nameUpdate.removeAttribute('aria-busy'); + if (!this.namePanel.hidden) this.namePanel.hidden = true; + if (!this.nameUpdate.disabled) this.nameUpdate.disabled = true; + if (!this.nameInput.disabled) this.nameInput.disabled = true; + if (this.nameUpdate.hasAttribute('aria-busy')) this.nameUpdate.removeAttribute('aria-busy'); } private syncNameOwner(): void { @@ -694,9 +1641,12 @@ export class StageElement extends HTMLElement { } // A same-seat snapshot (including resume) must not replace an unsent or rejected draft. this.acceptedName.textContent = `目前名稱:${decodeName(me.name)}`; - this.namePanel.hidden = false; this.nameInput.disabled = false; - this.nameUpdate.disabled = client.status === 'reconnecting' || Boolean(this.nameAttempt); - this.nameUpdate.setAttribute('aria-busy', String(Boolean(this.nameAttempt))); + if (this.namePanel.hidden) this.namePanel.hidden = false; + if (this.nameInput.disabled) this.nameInput.disabled = false; + const disabled = client.status === 'reconnecting' || Boolean(this.nameAttempt); + if (this.nameUpdate.disabled !== disabled) this.nameUpdate.disabled = disabled; + const busy = String(Boolean(this.nameAttempt)); + if (this.nameUpdate.getAttribute('aria-busy') !== busy) this.nameUpdate.setAttribute('aria-busy', busy); } private updateName(): void { @@ -725,38 +1675,130 @@ export class StageElement extends HTMLElement { })(); } - private retireHandQueue(): void { - this.handQueueOwner = undefined; this.handQueuePending.clear(); - for (const row of this.handQueueRows.values()) { - for (const control of [row.approve, row.reject]) { control.disabled = true; control.setAttribute('aria-busy', 'false'); } + private invalidateHandQueue(): void { + this.handQueueOwner = undefined; this.handQueuePending.clear(); this.handQueueQuery = ''; + this.handQueueRevision++; + } + + private noteHandQueueEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { + const client = this.current, owner = this.handQueueOwner; + if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) { + this.handQueueTerminalClient = client ?? undefined; this.invalidateHandQueue(); return; + } + if (type === 'state') { + this.handQueueTerminalClient = undefined; this.handQueueAwaitingStateClient = undefined; + } + const role = type === 'role' ? detail as StageEventMap['role'] : undefined; + const transfer = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; + // A control-loss delta precedes its snapshot; copied owner IDs avoid reopening old private data. + const lost = owner && ((role && role.participantId === owner.participantId && role.role !== 'controller') + || (transfer && transfer.fromId === owner.participantId && transfer.toId !== owner.participantId)); + if (lost && this.handQueueOwner === owner && this.current === client && owner.client === client && owner.generation === this.generation) { + this.handQueueAwaitingStateClient = client ?? undefined; this.invalidateHandQueue(); } - if (this.handQueueRows.size) this.handQueueList.replaceChildren(); + } + + private retireHandQueue(): void { + this.invalidateHandQueue(); + const revision = this.handQueueRevision, rows = [...this.handQueueRows.values()]; this.handQueueRows.clear(); this.handQueueOrder = []; - this.handQueuePanel.hidden = true; this.handQueuePanel.open = false; - this.handQueueSummary.textContent = ''; this.handQueueNotice.textContent = ''; + const current = () => { + if (revision === this.handQueueRevision && !this.handQueueOwner) return true; + this.syncHandQueue(); return false; + }; + for (const row of rows) { + for (const control of [row.approve, row.reject]) { + control.disabled = true; if (!current()) return; + control.setAttribute('aria-busy', 'false'); if (!current()) return; + } + } + if (rows.length) { this.handQueueList.replaceChildren(); if (!current()) return; } + this.handQueuePanel.hidden = true; if (!current()) return; + this.handQueuePanel.open = false; if (!current()) return; + this.handQueueSummary.textContent = ''; if (!current()) return; + this.handQueueNotice.textContent = ''; if (!current()) return; + this.handQueueSearch.disabled = true; if (!current()) return; + if (this.handQueueSearch.value !== '') { + if (!current()) return; + this.handQueueSearch.value = ''; if (!current()) return; + } + if (!current()) return; + this.handQueueClear.disabled = true; if (!current()) return; + this.handQueueResults.textContent = ''; current(); + } + + private handQueueInputCurrent(owner: NonNullable, revision: number): boolean { + if (this.handQueueOwner !== owner || this.handQueueRevision !== revision || this.current !== owner.client + || this.generation !== owner.generation || !this.isConnected) return false; + const state = owner.client.state, me = owner.client.me, status = owner.client.status; + const roomId = state?.roomId, participantId = me?.participantId, role = me?.role, connected = this.isConnected; + return this.handQueueOwner === owner && this.handQueueRevision === revision && this.current === owner.client + && this.generation === owner.generation && connected && roomId === owner.roomId + && participantId === owner.participantId && role === 'controller' && status !== 'disconnected' + && this.handQueueTerminalClient !== owner.client && this.handQueueAwaitingStateClient !== owner.client; + } + + private filterHandQueue(clear: boolean): void { + const owner = this.handQueueOwner, revision = this.handQueueRevision; + if (!owner || !this.handQueueInputCurrent(owner, revision)) { this.syncHandQueue(); return; } + const unavailable = this.handQueuePanel.hidden || this.handQueueSearch.disabled; + if (!this.handQueueInputCurrent(owner, revision)) { this.syncHandQueue(); return; } + if (unavailable) return; + const query = clear ? '' : this.handQueueSearch.value; + // A host getter can replace this owner or run a newer input handler synchronously. + if (!this.handQueueInputCurrent(owner, revision)) { this.syncHandQueue(); return; } + this.handQueueQuery = query; this.handQueueRevision++; + this.syncHandQueue(); } private syncHandQueue(): void { - const client = this.current, state = client?.state, me = client?.me; - if (!this.isConnected || !client || !state || me?.role !== 'controller' || client.status === 'disconnected') { + let revision = ++this.handQueueRevision; + const client = this.current, generation = this.generation; + const state = client?.state, me = client?.me, status = client?.status; + const roomId = state?.roomId, participantId = me?.participantId, role = me?.role, connected = this.isConnected; + const unchanged = () => revision === this.handQueueRevision && this.current === client && this.generation === generation; + if (!unchanged()) { this.syncHandQueue(); return; } + if (!connected || !client || !state || role !== 'controller' || status === 'disconnected' + || this.handQueueTerminalClient === client || this.handQueueAwaitingStateClient === client) { this.retireHandQueue(); return; } - const owner = this.handQueueOwner; - if (!owner || owner.client !== client || owner.generation !== this.generation - || owner.roomId !== state.roomId || owner.participantId !== me.participantId) { + let owner = this.handQueueOwner; + if (!owner || owner.client !== client || owner.generation !== generation + || owner.roomId !== roomId || owner.participantId !== participantId) { this.retireHandQueue(); - this.handQueueOwner = { client, roomId: state.roomId, participantId: me.participantId, generation: this.generation }; + const retiredRevision = this.handQueueRevision; + const latestState = client.state, latestMe = client.me, latestStatus = client.status; + const latestRoomId = latestState?.roomId, latestId = latestMe?.participantId, latestRole = latestMe?.role, connected = this.isConnected; + if (retiredRevision !== this.handQueueRevision || this.handQueueOwner || this.current !== client || this.generation !== generation + || latestState !== state || latestRoomId !== roomId || latestId !== participantId || latestRole !== 'controller' + || latestStatus !== status || !connected || this.handQueueTerminalClient === client + || this.handQueueAwaitingStateClient === client) { this.syncHandQueue(); return; } + this.handQueueOwner = owner = { client, roomId: roomId!, participantId: participantId!, generation }; + revision = ++this.handQueueRevision; } + const current = () => { + const latestState = client.state, latestMe = client.me, latestStatus = client.status; + const latestRoomId = latestState?.roomId, latestId = latestMe?.participantId, latestRole = latestMe?.role, connected = this.isConnected; + if (unchanged() && this.handQueueOwner === owner && connected && latestState === state + && latestRoomId === owner.roomId && latestId === participantId && latestRole === 'controller' && latestStatus === status + && this.handQueueTerminalClient !== client && this.handQueueAwaitingStateClient !== client) return true; + this.syncHandQueue(); return false; + }; + if (!current()) return; const seen = new Set(); const people = state.hands.filter(person => { if (seen.has(person.participantId) || person.role !== 'audience' || !person.handRaised || person.onStage) return false; seen.add(person.participantId); return true; }); - const reconnecting = client.status === 'reconnecting'; - this.handQueuePanel.hidden = false; + const query = this.handQueueQuery.trim().toLowerCase(), reconnecting = status === 'reconnecting'; + if (!current()) return; + this.handQueuePanel.hidden = false; if (!current()) return; this.handQueueSummary.textContent = `舉手申請(${people.length})${reconnecting ? ' · 上次收到' : ''}`; + if (!current()) return; this.handQueueNotice.textContent = reconnecting ? '重新連線中,暫時不能處理申請。' : people.length ? '請選擇要核准上台或婉拒的申請;是否可上台仍由伺服器決定。' : '目前沒有舉手申請。'; + if (!current()) return; + let matching = 0; for (const person of people) { const id = person.participantId; let row = this.handQueueRows.get(id); @@ -768,39 +1810,88 @@ export class StageElement extends HTMLElement { const currentRow = row; approve.onclick = () => this.moderateHand(id, currentRow.node, true); reject.onclick = () => this.moderateHand(id, currentRow.node, false); - node.append(name, approve, reject); this.handQueueRows.set(id, row); + node.append(name, approve, reject); + if (!current()) return; + this.handQueueRows.set(id, row); } - const name = decodeName(person.name); + const name = decodeName(person.name), hidden = !name.toLowerCase().includes(query); + if (!hidden) matching++; + if (!current()) return; const label = `${name}${person.connected ? '' : '(暫時離線)'}`; - if (row.name.textContent !== label) row.name.textContent = label; + if (row.name.textContent !== label) { if (!current()) return; row.name.textContent = label; } + if (!current()) return; const approveLabel = `核准 ${name} 上台`, rejectLabel = `婉拒 ${name} 的舉手申請`; - if (row.approve.getAttribute('aria-label') !== approveLabel) row.approve.setAttribute('aria-label', approveLabel); - if (row.reject.getAttribute('aria-label') !== rejectLabel) row.reject.setAttribute('aria-label', rejectLabel); + if (row.approve.getAttribute('aria-label') !== approveLabel) { + if (!current()) return; row.approve.setAttribute('aria-label', approveLabel); + } + if (!current()) return; + if (row.reject.getAttribute('aria-label') !== rejectLabel) { + if (!current()) return; row.reject.setAttribute('aria-label', rejectLabel); + } + if (!current()) return; + if (row.node.hidden !== hidden) { if (!current()) return; row.node.hidden = hidden; } + if (!current()) return; const pending = this.handQueuePending.has(id); for (const control of [row.approve, row.reject]) { - const disabled = reconnecting || pending, busy = String(pending); - if (control.disabled !== disabled) control.disabled = disabled; - if (control.getAttribute('aria-busy') !== busy) control.setAttribute('aria-busy', busy); + const disabled = reconnecting || pending || hidden, busy = String(pending); + if (control.disabled !== disabled) { if (!current()) return; control.disabled = disabled; } + if (!current()) return; + if (control.getAttribute('aria-busy') !== busy) { if (!current()) return; control.setAttribute('aria-busy', busy); } + if (!current()) return; } } for (const [id, row] of this.handQueueRows) if (!seen.has(id)) { - for (const control of [row.approve, row.reject]) { control.disabled = true; control.setAttribute('aria-busy', 'false'); } this.handQueueRows.delete(id); + for (const control of [row.approve, row.reject]) { + control.disabled = true; if (!current()) return; + control.setAttribute('aria-busy', 'false'); if (!current()) return; + } } const order = people.map(person => person.participantId); if (order.length !== this.handQueueOrder.length || order.some((id, index) => id !== this.handQueueOrder[index])) { this.handQueueList.replaceChildren(...order.map(id => this.handQueueRows.get(id)!.node)); + if (!current()) return; this.handQueueOrder = order; } + if (this.handQueueSearch.disabled) { if (!current()) return; this.handQueueSearch.disabled = false; } + if (!current()) return; + if (this.handQueueSearch.value !== this.handQueueQuery) { + if (!current()) return; this.handQueueSearch.value = this.handQueueQuery; + } + if (!current()) return; + const clearDisabled = !this.handQueueQuery; + if (this.handQueueClear.disabled !== clearDisabled) { if (!current()) return; this.handQueueClear.disabled = clearDisabled; } + if (!current()) return; + const results = `符合 ${matching} / ${people.length} 筆舉手申請。${people.length && !matching ? '沒有符合搜尋的舉手者。' : ''}`; + if (this.handQueueResults.textContent !== results) { if (!current()) return; this.handQueueResults.textContent = results; } + current(); } private moderateHand(id: string, node: HTMLLIElement, approve: boolean): void { this.syncHandQueue(); - const owner = this.handQueueOwner, row = this.handQueueRows.get(id); - if (!owner || !row || row.node !== node || this.handQueuePanel.hidden - || owner.client.status === 'reconnecting' || this.handQueuePending.has(id)) return; + const owner = this.handQueueOwner, row = this.handQueueRows.get(id), revision = this.handQueueRevision; + if (!owner || !row || row.node !== node || this.handQueuePending.has(id)) return; + const hidden = row.node.hidden || this.handQueuePanel.hidden || owner.client.status === 'reconnecting'; + if (!this.handQueueInputCurrent(owner, revision) || hidden || this.handQueueRows.get(id) !== row) { + this.syncHandQueue(); return; + } const attempt = {}; - this.handQueuePending.set(id, attempt); this.syncHandQueue(); this.hideError(); + this.handQueuePending.set(id, attempt); this.syncHandQueue(); + const admitted = (): boolean => { + const revision = this.handQueueRevision; + if (this.handQueueOwner !== owner || this.handQueuePending.get(id) !== attempt || this.handQueueRows.get(id) !== row) return false; + const hidden = row.node.hidden || this.handQueuePanel.hidden || owner.client.status === 'reconnecting'; + return this.handQueueInputCurrent(owner, revision) && !hidden && this.handQueuePending.get(id) === attempt + && this.handQueueRows.get(id) === row; + }; + const cancel = () => { + if (this.handQueuePending.get(id) === attempt) this.handQueuePending.delete(id); + this.syncHandQueue(); + }; + // The new filter's getters/setters may revoke this row while its pending state is painted. + if (!admitted()) { cancel(); return; } + this.hideError(); + if (!admitted()) { cancel(); return; } const current = (): boolean => { if (this.handQueueOwner !== owner || this.handQueuePending.get(id) !== attempt) return false; this.syncHandQueue(); @@ -996,8 +2087,9 @@ export class StageElement extends HTMLElement { private noteStageTransferEvent(type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { const owner = this.stageManagementOwner; - if (!owner) return; - if (type === 'state') { owner.transferAwaitingState = false; return; } + if (!owner) { this.noteHandQueueEvent(type, detail); return; } + if ((type === 'closed' || type === 'kicked' || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) + && this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; const role = type === 'role' ? detail as StageEventMap['role'] : undefined; const left = type === 'stageleft' ? detail as StageEventMap['stageleft'] : undefined; const transferred = type === 'transferred' ? detail as StageEventMap['transferred'] : undefined; @@ -1009,6 +2101,19 @@ export class StageElement extends HTMLElement { owner.transferRevision++; owner.transferAwaitingState = true; if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; } + // Keep both dialog fences ahead of new filter reads, including getters on event details. + this.noteHandQueueEvent(type, detail); + const client = this.current, state = client?.state, me = client?.me; + if (owner !== this.stageManagementOwner) return; + const sameController = owner.client === client && owner.generation === this.generation + && state?.roomId === owner.roomId && me?.participantId === owner.participantId + && me.role === 'controller' && me.connected === true; + // A replacement snapshot can emit status before state; observe its own-seat loss before DOM sync. + if (!sameController) { + owner.transferRevision++; owner.transferAwaitingState = true; + if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; + } + if (type === 'state') { owner.transferAwaitingState = !sameController; return; } } private canTransferStage(id: string, node: HTMLLIElement): boolean { @@ -1022,38 +2127,48 @@ export class StageElement extends HTMLElement { } private transferStageControl(id: string, node: HTMLLIElement): void { - this.syncStageManagement(); - const owner = this.stageManagementOwner; - if (!owner || this.stageTransferAttempt || !this.canTransferStage(id, node)) return; - const attempt = { id, node, revision: owner.transferRevision, invalid: false }; - this.stageTransferAttempt = attempt; this.syncStageManagement(); - const current = (): boolean => { - if (this.stageManagementOwner !== owner || this.stageTransferAttempt !== attempt) return false; + const dialog = {}; + this.stageTransferDialogs.add(dialog); + const releaseDialog = (): void => { this.stageTransferDialogs.delete(dialog); }; + try { + // Reconcile even when a close dialog excludes this admission. this.syncStageManagement(); - return this.stageManagementOwner === owner && this.stageTransferAttempt === attempt; - }; - const eligible = (): boolean => current() && !attempt.invalid && owner.transferRevision === attempt.revision && this.canTransferStage(id, node); - void (async () => { - try { - if (!eligible()) return; - const target = owner.client.state!.speakers.find(person => person.participantId === id && person.role === 'speaker' && person.onStage)!; - if (!window.confirm(`確定將控制權移交給 ${decodeName(target.name)}?你將失去主控權限,但保留目前台位並留在房間。`) || !eligible()) return; - this.hideError(); - await owner.client.transferControl(id); - } catch (error) { - if (current() && !attempt.invalid && this.stageManagementRows.get(id)?.node === node) this.show(error); - } finally { - if (current()) { this.stageTransferAttempt = undefined; this.syncStageManagement(); } - } - })(); + const owner = this.stageManagementOwner; + if (this.roomCloseDialog || !owner || this.stageTransferAttempt || !this.canTransferStage(id, node)) { releaseDialog(); return; } + const attempt = { id, node, revision: owner.transferRevision, invalid: false }; + this.stageTransferAttempt = attempt; this.syncStageManagement(); + const current = (): boolean => { + if (this.stageManagementOwner !== owner || this.stageTransferAttempt !== attempt) return false; + this.syncStageManagement(); + return this.stageManagementOwner === owner && this.stageTransferAttempt === attempt; + }; + const eligible = (): boolean => current() && !attempt.invalid && owner.transferRevision === attempt.revision && this.canTransferStage(id, node); + void (async () => { + try { + if (!eligible()) return; + const target = owner.client.state!.speakers.find(person => person.participantId === id && person.role === 'speaker' && person.onStage)!; + if (!window.confirm(`確定將控制權移交給 ${decodeName(target.name)}?你將失去主控權限,但保留目前台位並留在房間。`) || !eligible()) return; + this.hideError(); + const result = owner.client.transferControl(id); + releaseDialog(); + await result; + } catch (error) { + if (current() && !attempt.invalid && this.stageManagementRows.get(id)?.node === node) this.show(error); + } finally { + releaseDialog(); + if (current()) { this.stageTransferAttempt = undefined; this.syncStageManagement(); } + } + })(); + } catch (error) { releaseDialog(); throw error; } } private retireRoomNameOwner(): void { this.roomNameOwner = undefined; this.roomNameAttempt = undefined; this.roomNameInput.value = ''; this.acceptedRoomName.textContent = ''; - this.roomNamePanel.hidden = true; - this.roomNameInput.disabled = this.roomNameUpdate.disabled = true; - this.roomNameUpdate.removeAttribute('aria-busy'); + if (!this.roomNamePanel.hidden) this.roomNamePanel.hidden = true; + if (!this.roomNameUpdate.disabled) this.roomNameUpdate.disabled = true; + if (!this.roomNameInput.disabled) this.roomNameInput.disabled = true; + if (this.roomNameUpdate.hasAttribute('aria-busy')) this.roomNameUpdate.removeAttribute('aria-busy'); } private syncRoomNameOwner(): void { @@ -1070,9 +2185,12 @@ export class StageElement extends HTMLElement { } // Same-controller snapshots and reconnects update the accepted value, never the draft. this.acceptedRoomName.textContent = `目前房間名稱:${decodeName(state.name)}`; - this.roomNamePanel.hidden = false; this.roomNameInput.disabled = false; - this.roomNameUpdate.disabled = client.status === 'reconnecting' || Boolean(this.roomNameAttempt); - this.roomNameUpdate.setAttribute('aria-busy', String(Boolean(this.roomNameAttempt))); + if (this.roomNamePanel.hidden) this.roomNamePanel.hidden = false; + if (this.roomNameInput.disabled) this.roomNameInput.disabled = false; + const disabled = client.status === 'reconnecting' || Boolean(this.roomNameAttempt); + if (this.roomNameUpdate.disabled !== disabled) this.roomNameUpdate.disabled = disabled; + const busy = String(Boolean(this.roomNameAttempt)); + if (this.roomNameUpdate.getAttribute('aria-busy') !== busy) this.roomNameUpdate.setAttribute('aria-busy', busy); } private updateRoomName(): void { @@ -1215,6 +2333,228 @@ export class StageElement extends HTMLElement { && !this.current?.state && this.current?.status !== 'reconnecting'; } + private attachMicTestListeners(): void { + if (this.micTestDocument) return; + this.micTestPageHidden = false; + this.micTestDocument = document; this.micTestWindow = window; + document.addEventListener?.('visibilitychange', this.micTestVisibility); + window.addEventListener('pagehide', this.micTestPageHide); + window.addEventListener('pageshow', this.micTestPageShow); + } + + private detachMicTestListeners(): void { + const doc = this.micTestDocument, win = this.micTestWindow; + this.micTestDocument = undefined; this.micTestWindow = undefined; + doc?.removeEventListener?.('visibilitychange', this.micTestVisibility); + win?.removeEventListener('pagehide', this.micTestPageHide); + win?.removeEventListener('pageshow', this.micTestPageShow); + } + + private retireMicTestBinding(): () => void { + this.micTestBinding = undefined; + // Departing-client cleanup must not recreate controller consent under the new generation. + this.roomCloseTerminalClient = this.current ?? undefined; + this.stageManagementTerminalClient = this.current ?? undefined; + this.retireRoomClose(); + if (this.stageTransferAttempt) this.stageTransferAttempt.invalid = true; + return this.revokeMicTest(true); + } + + private disposeMicTest(handle: MicTest): void { + ++this.micTestCleanup; + try { handle.stop(); } catch { /* A retired handle cannot report into a newer test. */ } + finally { --this.micTestCleanup; } + } + + private retireMicTest(dropOwner: boolean, message = '麥克風音量測試已停止。'): void { + this.revokeMicTest(dropOwner, message)(); + } + + private revokeMicTest(dropOwner: boolean, message = '麥克風音量測試已停止。'): () => void { + const attempt = this.micTestAttempt, handle = attempt?.handle; + // Retire all authority before abort, stop, or a host DOM callback can reenter. + if (dropOwner) this.micTestOwner = undefined; + this.micTestValue = 0; this.micTestMessage = message; + const timer = this.micTestTimer; this.micTestTimer = undefined; + if (attempt) { + attempt.retired = true; attempt.handle = undefined; + if (!attempt.pending) this.micTestAttempt = undefined; + } + return () => { + ++this.micTestCleanup; + try { + clearTimeout(timer); + try { attempt?.controller.abort(); } catch { /* Stop the owned handle even if an abort listener fails. */ } + if (handle) this.disposeMicTest(handle); + } finally { --this.micTestCleanup; } + }; + } + + private micTestMounted(): boolean { + return this.isConnected && Boolean(this.micTestDocument) && this.micTestPanel.isConnected + && this.widgetRoot.contains(this.micTestPanel) + && [this.micTestStart, this.micTestCancel, this.micTestStop, this.micTestMeter, this.micTestStatus] + .every(node => node.isConnected && this.micTestPanel.contains(node)); + } + + private readMicTestOwner(): StageElement['micTestOwner'] { + const binding = this.micTestBinding; + const valid = () => Boolean(binding && this.micTestBinding === binding && this.current === binding.client + && this.generation === binding.generation && !binding.blocked + && !this.micTestCleanup && !this.leftRoom && !this.roomExit && !this.connectionStatsLeaving + && !this.micTestPageHidden && !this.micTestDocument?.hidden && this.micTestMounted()); + if (!binding || !valid()) return; + // Preserve existing dialog invalidation when a public getter exposes a transient identity loss. + const read = this.connectionStatsIdentityReader(), client = binding.client; + const state = read('state', () => client.state); if (!valid() || !state) return; + const me = read('me', () => client.me); if (!valid() || !me) return; + const status = read('status', () => client.status); if (!valid() || (status !== 'waiting' && status !== 'live')) return; + const roomId = read('roomId', () => state.roomId); if (!valid() || typeof roomId !== 'string' || !roomId.trim()) return; + const participantId = read('participantId', () => me.participantId); + if (!valid() || typeof participantId !== 'string' || !participantId.trim() || binding.early.has(participantId)) return; + const connected = me.connected; if (!valid() || connected !== true) return; + const onStage = me.onStage; if (!valid() || onStage !== false) return; + if (read('state', () => client.state) !== state || !valid() + || read('me', () => client.me) !== me || !valid() + || read('status', () => client.status) !== status || !valid() + || read('roomId', () => state.roomId) !== roomId || !valid() + || read('participantId', () => me.participantId) !== participantId || !valid() + || me.connected !== true || !valid() || me.onStage !== false || !valid() || binding.early.has(participantId)) return; + return { binding, roomId, participantId }; + } + + private markMicTestEvent(client: StageClient, type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { + const binding = this.micTestBinding; + if (!binding || binding.client !== client || binding.generation !== this.generation) return; + // Notification fences precede all authority getters; cleanup follows the existing consent fences. + if (type === 'closed' || type === 'kicked' + || (type === 'status' && (detail === 'reconnecting' || detail === 'disconnected'))) binding.blocked = true; + if (type === 'invite' || type === 'stagejoined') binding.early.add((detail as StageEventMap['stagejoined']).participantId); + } + + private noteMicTestEvent(client: StageClient, type: keyof StageEventMap, detail: StageEventMap[keyof StageEventMap]): void { + const binding = this.micTestBinding; + if (!binding || binding.client !== client || binding.generation !== this.generation) return; + if (type === 'closed' || type === 'kicked' || (type === 'status' && detail === 'disconnected')) this.detachMicTestListeners(); + if (type === 'invite' || type === 'stagejoined') { + // A status callback can deliver this notice before its already-installed snapshot emits state. + const state = this.connectionStatsIdentityReader()('state', () => client.state); + if (state && this.micTestBinding === binding) this.micTestEarlyStates.add(state); + } + if (binding.blocked || (this.micTestOwner && binding.early.has(this.micTestOwner.participantId))) { + this.retireMicTest(true); + } + if (type === 'state' && detail && typeof detail === 'object' && !this.micTestStates.has(detail)) { + const state = this.connectionStatsIdentityReader()('state', () => client.state); + if (this.micTestBinding === binding && this.current === client && this.generation === binding.generation && state === detail) { + this.attachMicTestListeners(); + if (this.micTestBinding !== binding || this.current !== client || this.generation !== binding.generation) return; + this.micTestStates.add(detail); binding.blocked = false; + if (!this.micTestEarlyStates.has(detail)) binding.early.clear(); + } + } + this.syncMicTest(); + } + + private syncMicTest(): void { + for (;;) { + const before = this.micTestOwner; + let observed: StageElement['micTestOwner']; + try { observed = this.readMicTestOwner(); } catch { observed = undefined; } + if (before !== this.micTestOwner) continue; + if (!observed || (before && (before.binding !== observed.binding + || before.roomId !== observed.roomId || before.participantId !== observed.participantId))) { + if (before) { this.retireMicTest(true); continue; } + } else if (!before) { + this.micTestOwner = observed; this.micTestMessage = '尚未開始麥克風音量測試。'; this.micTestValue = 0; + } + const owner = this.micTestOwner, attempt = this.micTestAttempt; + const pending = Boolean(attempt?.pending && !attempt.retired && attempt.owner === owner); + const active = Boolean(attempt?.handle && !attempt.retired && attempt.owner === owner); + const hidden = !owner, busy = Boolean(attempt?.pending); + const disabled = hidden || Boolean(attempt) || Boolean(this.inputDeviceAttempt) || Boolean(this.micTestCleanup); + const message = hidden ? '' : pending ? '等待麥克風權限;可取消本次測試。' + : active ? '正在量測本機麥克風音量。' : busy ? '正在取消麥克風測試,請稍候。' : this.micTestMessage; + // Recompute after every changed setter so a stale outer paint cannot overwrite a nested entry. + if (hidden && !this.micTestPanel.hidden) { this.micTestPanel.hidden = true; continue; } + if (this.micTestStart.disabled !== disabled) { this.micTestStart.disabled = disabled; continue; } + if (this.micTestStart.getAttribute('aria-busy') !== String(busy)) { this.micTestStart.setAttribute('aria-busy', String(busy)); continue; } + if (this.micTestCancel.hidden !== !pending) { this.micTestCancel.hidden = !pending; continue; } + if (this.micTestCancel.disabled !== !pending) { this.micTestCancel.disabled = !pending; continue; } + if (this.micTestStop.hidden !== !active) { this.micTestStop.hidden = !active; continue; } + if (this.micTestStop.disabled !== !active) { this.micTestStop.disabled = !active; continue; } + if (Number(this.micTestMeter.value) !== this.micTestValue) { this.micTestMeter.value = this.micTestValue; continue; } + if (this.micTestStatus.textContent !== message) { this.micTestStatus.textContent = message; continue; } + if (this.micTestPanel.hidden !== hidden) { this.micTestPanel.hidden = hidden; continue; } + return; + } + } + + private currentMicTest(attempt: NonNullable): boolean { + this.syncMicTest(); + return this.micTestAttempt === attempt && !attempt.retired && this.micTestOwner === attempt.owner; + } + + private startMicTest(): void { + if (this.micTestCleanup || this.inputDeviceAttempt || this.micTestAttempt) return; + this.syncMicTest(); + const owner = this.micTestOwner; + if (!owner || this.micTestStart.disabled || this.micTestPanel.hidden || this.micTestAttempt || this.inputDeviceAttempt) return; + const attempt = this.micTestAttempt = { owner, controller: new AbortController(), pending: true, retired: false } as NonNullable; + void (async () => { + try { + if (!this.currentMicTest(attempt) || this.inputDeviceAttempt) return; + const start = owner.binding.client.startMicTest; + if (!this.currentMicTest(attempt) || this.inputDeviceAttempt) return; + if (typeof start !== 'function') throw new Error(); + const handle = await start.call(owner.binding.client, { signal: attempt.controller.signal }); + if (!this.currentMicTest(attempt)) { this.disposeMicTest(handle); return; } + attempt.pending = false; attempt.handle = handle; + this.sampleMicTest(attempt); + } catch { + if (this.currentMicTest(attempt)) this.retireMicTest(false, '無法完成麥克風音量測試,請手動再試。'); + } finally { + attempt.pending = false; + if (this.micTestAttempt === attempt && !attempt.handle) this.micTestAttempt = undefined; + this.syncMicTest(); + } + })(); + } + + private stopMicTest(): void { + const attempt = this.micTestAttempt; + if (!attempt || !this.currentMicTest(attempt)) return; + this.retireMicTest(false); this.syncMicTest(); + } + + private sampleMicTest(attempt: NonNullable): void { + try { + if (!this.currentMicTest(attempt) || !attempt.handle) return; + const handle = attempt.handle, stopped = handle.stopped; + if (!this.currentMicTest(attempt)) return; + if (stopped) { this.retireMicTest(false, '麥克風音量測試已結束。'); this.syncMicTest(); return; } + const level = handle.level; + if (!this.currentMicTest(attempt)) return; + const value = level.call(handle); + if (!this.currentMicTest(attempt)) return; + const ended = handle.stopped; + if (!this.currentMicTest(attempt)) return; + if (ended) { this.retireMicTest(false, '麥克風音量測試已結束。'); this.syncMicTest(); return; } + this.micTestValue = typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0; + this.syncMicTest(); + if (!this.currentMicTest(attempt)) return; + const timer = setTimeout(() => { + if (this.micTestTimer !== timer) return; + this.micTestTimer = undefined; this.sampleMicTest(attempt); + }, 100); + this.micTestTimer = timer; + } catch { + if (this.currentMicTest(attempt)) { + this.retireMicTest(false, '無法完成麥克風音量測試,請手動再試。'); this.syncMicTest(); + } + } + } + private replaceMicrophoneOptions(inputs: readonly MediaDeviceInfo[], draft: string): void { const initial = document.createElement('option'); initial.value = ''; initial.textContent = '預設(依 SDK 設定)'; @@ -1301,19 +2641,25 @@ export class StageElement extends HTMLElement { || this.refreshAttempt || this.inputDeviceAttempt) return; const draft = this.microphoneSelect.value; const attempt = this.inputDeviceAttempt = {}; - this.syncMicrophoneOwner(); this.hideError(); + this.retireMicTest(false); + this.syncMicrophoneOwner(); this.syncMicTest(); this.hideError(); const current = () => { if (this.microphoneOwner !== owner || this.inputDeviceAttempt !== attempt) return false; this.syncMicrophoneOwner(); return this.microphoneOwner === owner && this.inputDeviceAttempt === attempt; }; void (async () => { - try { await owner.client.setInputDevice(draft); } + try { + if (!current()) return; + const apply = owner.client.setInputDevice; + if (!current()) return; + await apply.call(owner.client, draft); + } catch (error) { if (current()) this.show(error); } finally { if (current()) { // Fulfillment can be a stage-transition no-op. Neither it nor shared micready proves capture. - this.inputDeviceAttempt = undefined; this.syncMicrophoneOwner(); + this.inputDeviceAttempt = undefined; this.syncMicrophoneOwner(); this.syncMicTest(); } } })(); @@ -1461,7 +2807,8 @@ export class StageElement extends HTMLElement { private renderVolume(): void { const percent = Math.round(this.playbackVolume * 100); this.volume.value = String(percent); - this.volume.setAttribute('aria-valuetext', `${percent}%`); + const valueText = `${percent}%`; + if (this.volume.getAttribute('aria-valuetext') !== valueText) this.volume.setAttribute('aria-valuetext', valueText); this.volumeValue.textContent = `${percent}%`; } @@ -1527,17 +2874,21 @@ export class StageElement extends HTMLElement { } private render(): void { + this.syncOccupancy(); this.syncAudioRecovery(); this.syncPlaybackOwner(); this.syncMicRetry(); this.syncMicrophoneOwner(); + this.syncMicTest(); this.syncOutputDeviceOwner(); this.syncNameOwner(); this.syncRoomNameOwner(); this.syncHandQueue(); this.syncStageManagement(); + this.syncRoomClose(); this.syncQualityOwner(); this.syncSpeakingOwner(); + this.syncConnectionStats(); this.syncHandRejection(); const client = this.current; const me = client?.me ?? null; @@ -1573,7 +2924,8 @@ export class StageElement extends HTMLElement { this.listen.hidden = !me; this.listen.disabled = !client || this.pendingActions.has(this.listen); this.listen.textContent = this.playbackMuted ? '恢復收聽' : '靜音收聽'; - this.listen.setAttribute('aria-pressed', String(this.playbackMuted)); + const pressed = String(this.playbackMuted); + if (this.listen.getAttribute('aria-pressed') !== pressed) this.listen.setAttribute('aria-pressed', pressed); this.playback.hidden = !me; this.volume.disabled = !client || !me; this.renderVolume(); @@ -1587,6 +2939,7 @@ export class StageElement extends HTMLElement { this.mic.setAttribute('aria-pressed', String(Boolean(me?.muted))); this.leaveStage.textContent = me?.onStage ? '離開舞台' : '返回舞台'; this.leaveStage.disabled = !this.canUseStage() || this.pendingActions.has(this.leaveStage); + this.syncPublicRoster(); } } diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 93ab5b5..07907ea 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -119,6 +119,7 @@ function createMicMeter(stream: MediaStream): MicTest { throw error; } } +export interface StageClientEventOptions { once?: boolean; signal?: AbortSignal } export interface StageEventMap { state: RoomStatePayload; status: ClientStatus; @@ -463,9 +464,10 @@ export class StageClient extends EventTarget { private audioConstraints(deviceId: string): MediaTrackConstraints { return { channelCount: 1, sampleRate: 48000, echoCancellation: true, noiseSuppression: true, autoGainControl: true, ...this.options.micConstraints, ...(deviceId ? { deviceId: { exact: deviceId } } : {}) }; } - on(type: K, listener: (event: CustomEvent) => void): () => void { + on(type: K, listener: (event: CustomEvent) => void, options?: StageClientEventOptions): () => void { const handler = listener as EventListener; - this.addEventListener(type, handler); + // Keep native listener identity and false capture even with structurally wider options. + this.addEventListener(type, handler, { once: options?.once, signal: options?.signal }); return () => this.removeEventListener(type, handler); } private emit(type: K, detail: StageEventMap[K]): void { diff --git a/packages/client/test/element.test.ts b/packages/client/test/element.test.ts index 32d3266..d607806 100644 --- a/packages/client/test/element.test.ts +++ b/packages/client/test/element.test.ts @@ -7,6 +7,7 @@ import type { TestContext } from 'node:test'; import { StageClient } from '../src/index.ts'; import type { ClientMessage, RoomStatePayload, ServerMessage } from '../../../shared/protocol.ts'; import { Room } from '../../../src/model/room.ts'; +import type { Session } from '../../../src/ws/hub.ts'; import { parseClientMessage, ValidationError } from '../../../src/ws/validate.ts'; /** Minimal DOM surface for widget state tests; this does not simulate a browser layout or input system. */ @@ -11076,3 +11077,3247 @@ describe('widget manual audio negotiation recovery', () => { assert.equal(client.playbackMuted, true); assert.equal(client.playbackVolume, .37); assert.equal(peer.connectionState, 'new'); }); }); + + +describe('widget confirmed room closure', () => { + type CloseWidget = InstanceType; + const closePrompt = '確定關閉此房間並讓所有人離場?關閉後無法再加入此房間。'; + function controllerState(onStage = false): RoomStatePayload { + const state = roomState(onStage); state.controllerId = state.me.participantId; state.me.role = 'controller'; + state.speakers = ['target', 'second'].map(participantId => ({ ...roomState(true).me, participantId, name: `Speaker ${participantId}` })); + return state; + } + const closeRequests = (socket: { sent: ClientMessage[] }) => socket.sent.filter(message => message.type === 'room:close'); + const closeBusy = (element: CloseWidget, busy: boolean) => assert.equal(element['roomClose'].getAttribute('aria-busy'), String(busy)); + const transferButton = (element: CloseWidget, id = 'target') => { + const row = element['stageManagementRows'].get(id); assert.ok(row); return row.transfer; + }; + function feedback(element: CloseWidget, message = 'Keep existing feedback'): void { element['show'](new Error(message)); } + function controlSetter(t: TestContext, control: Node, property: 'hidden' | 'disabled' | 'textContent', callback: () => void): void { + let value = control[property]; + Object.defineProperty(control, property, { configurable: true, get: () => value, set(next: string | boolean) { value = next; callback(); } }); + t.after(() => Object.defineProperty(control, property, { configurable: true, writable: true, value })); + } + + for (const onStage of [false, true]) for (const muted of [false, true]) it(`offers independent destructive closure for controller onStage=${onStage} muted=${muted}`, async t => { + const state = controllerState(onStage); state.me.muted = muted; state.me.forceMuted = muted; + const { element, client } = await socketJoinedWidget(t, state), pending = deferred(); + const close = t.mock.method(client, 'closeRoom', () => pending.promise), dialog = t.mock.method(window, 'confirm', () => true); + const action = element['roomClose']; + assert.equal(action.type, 'button'); assert.equal(action.className, 'danger'); assert.equal(action.hidden, false); assert.equal(action.disabled, false); + assert.equal(action.textContent, '關閉房間'); assert.equal(action.getAttribute('aria-describedby'), element['roomCloseNotice'].id); + assert.equal(element['roomCloseNotice'].hidden, false); assert.equal(element['roomCloseNotice'].textContent, + '關閉房間會讓所有人離場,且無法再加入此房間。離開房間只會讓你離場,房間可能保留並由其他人接任主控。'); + assert.equal(element['roomAction'].textContent, '離開房間'); feedback(element); + click(action); closeBusy(element, true); assert.equal(action.disabled, true); click(action); + assert.equal(dialog.mock.callCount(), 1); assert.equal(dialog.mock.calls[0]!.arguments[0], closePrompt); assert.equal(close.mock.callCount(), 1); + pending.resolve(); await settle(); closeBusy(element, false); assert.equal(action.disabled, false); + assert.equal(element['error'].textContent, 'Keep existing feedback'); assert.equal(element['error'].hidden, false); + assert.equal(element['leftRoom'], false); assert.equal(element.client, client); assert.notEqual(element['status'].textContent, '房間已關閉。'); + }); + + for (const condition of ['initial', 'unjoined', 'audience', 'speaker', 'disconnected', 'unsupported', 'unmounted'] as const) { + it(`rejects retained closure clicks for ${condition} without a dialog or SDK call`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => true); + feedback(element); + if (condition === 'initial') element['current'] = null; + else if (condition === 'unjoined') client['snapshot'] = null; + else if (condition === 'audience' || condition === 'speaker') client.me!.role = condition; + else if (condition === 'disconnected') client['setStatus']('disconnected'); + else if (condition === 'unsupported') Reflect.set(client, 'closeRoom', undefined); + else Object.assign(element, { isConnected: false }); + element['render'](); assert.equal(action.hidden, true); assert.equal(action.disabled, true); action.disabled = false; click(action); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(element['error'].textContent, 'Keep existing feedback'); + assert.equal(element['error'].hidden, false); closeBusy(element, false); + }); + } + + for (const decision of ['cancel', 'throw', 'missing'] as const) it(`handles ${decision} confirmation without dispatch or fallback and permits a fresh action`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), leave = t.mock.method(client, 'disconnect', async () => {}); + const transfer = t.mock.method(client, 'transferControl', async () => {}); feedback(element); + const dialog = t.mock.method(window, 'confirm', () => { if (decision === 'throw') throw new Error('Confirmation failed'); return false; }); + if (decision === 'missing') Reflect.set(window, 'confirm', undefined); + element['render'](); assert.equal(action.hidden, false); assert.equal(action.disabled, decision === 'missing'); click(action); await settle(); + assert.equal(close.mock.callCount(), 0); assert.equal(leave.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); closeBusy(element, false); + assert.equal(element['error'].textContent, decision === 'throw' ? 'Confirmation failed' : 'Keep existing feedback'); assert.equal(element['error'].hidden, false); + dialog.mock.restore(); t.mock.method(window, 'confirm', () => true); element['render'](); click(action); await settle(); assert.equal(close.mock.callCount(), 1); + }); + + for (const entry of ['reservation setter', 'confirmation', 'SDK invocation'] as const) it(`reserves exact close consent before ${entry} reentry`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + let entered = false; + const reenter = () => { if (entered) return; entered = true; click(element['roomClose']); click(transferButton(element)); }; + const close = t.mock.method(client, 'closeRoom', () => { if (entry === 'SDK invocation') { socket.deliver({ type: 'room:state', ...controllerState() }); reenter(); } return pending.promise; }); + const transfer = t.mock.method(client, 'transferControl', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => { if (entry === 'confirmation') reenter(); return true; }); + if (entry === 'reservation setter') controlSetter(t, element['roomClose'] as unknown as Node, 'disabled', reenter); + click(element['roomClose']); assert.equal(entered, true); assert.equal(close.mock.callCount(), 1); assert.equal(dialog.mock.callCount(), 1); + assert.equal(transfer.mock.callCount(), 0); closeBusy(element, true); pending.resolve(); await settle(); closeBusy(element, false); + }); + + it('reserves the original owner before the first admission render can replace its controller tenure', async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), action = element['roomClose'], owner = element['roomCloseOwner']; + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => true); + action.hidden = true; let replaced = false; + controlSetter(t, action as unknown as Node, 'hidden', () => { + if (replaced || action.hidden) return; replaced = true; + socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); + }); + click(action); await settle(); assert.equal(replaced, true); assert.notEqual(element['roomCloseOwner'], owner); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); closeBusy(element, false); + click(action); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + }); + + for (const boundary of ['reconnect', 'local disconnected', 'role', 'method', 'confirmation', 'button disconnected', 'button moved', 'actions removed', 'snapshot', 'room', 'seat', 'client'] as const) { + it(`permanently invalidates consent after observed ${boundary} even when restored inside confirmation`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}); let once = false; + const dialog = t.mock.method(window, 'confirm', () => { + if (once) return true; once = true; + if (boundary === 'reconnect') { client['setStatus']('reconnecting'); socket.deliver({ type: 'room:state', ...controllerState() }); } + else if (boundary === 'local disconnected') { client.me!.connected = false; element['render'](); client.me!.connected = true; } + else if (boundary === 'role') { socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); } + else if (boundary === 'method') { const method = client.closeRoom; Reflect.set(client, 'closeRoom', undefined); element['render'](); Reflect.set(client, 'closeRoom', method); } + else if (boundary === 'confirmation') { const confirm = window.confirm; Reflect.set(window, 'confirm', undefined); element['render'](); Reflect.set(window, 'confirm', confirm); } + else if (boundary === 'button disconnected') { Object.assign(action, { isConnected: false }); element['render'](); Object.assign(action, { isConnected: true }); } + else if (boundary === 'button moved') { + const actions = element['roomActions'] as unknown as Node, original = [...actions.children], root = element['widgetRoot'] as unknown as Node; + actions.replaceChildren(...original.filter(node => node !== action as unknown as Node)); root.append(action as unknown as Node); + assert.equal(action.isConnected, true); element['render'](); root.children.splice(root.children.indexOf(action as unknown as Node), 1); actions.replaceChildren(...original); + } else if (boundary === 'actions removed') { + const root = element['widgetRoot'] as unknown as Node, original = [...root.children]; root.replaceChildren(); element['render'](); root.replaceChildren(...original); + } else if (boundary === 'snapshot') { const original = client['snapshot']; client['snapshot'] = null; element['render'](); client['snapshot'] = original; } + else if (boundary === 'room') { client.state!.roomId = 'other'; element['render'](); client.state!.roomId = 'one'; } + else if (boundary === 'seat') { client.me!.participantId = 'other'; element['render'](); client.me!.participantId = 'me'; } + else { element['current'] = null; element['render'](); element['current'] = client; } + element['render'](); click(action); return true; + }); + feedback(element); click(action); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); + assert.equal(element['error'].textContent, 'Keep existing feedback'); closeBusy(element, false); + click(action); await settle(); assert.equal(dialog.mock.callCount(), 2); assert.equal(close.mock.callCount(), 1); + }); + } + + for (const attribute of ['room', 'name', 'code', 'url'] as const) it(`invalidates consent for ${attribute} identity replacement and never replays it`, async t => { + const { element, client, tick } = await socketJoinedWidget(t, controllerState()); + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => { + element.setAttribute(attribute, attribute === 'url' ? 'wss://synthetic.invalid/replacement' : 'replacement'); click(element['roomClose']); return true; + }); + click(element['roomClose']); await tick(100); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); + assert.notEqual(element.client, client); assert.equal(element['leftRoom'], false); closeBusy(element, false); + }); + + it('invalidates an open dialog through unmount/remount while retaining its synchronous exclusion', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()); + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => { + Object.assign(element, { isConnected: false }); element.disconnectedCallback(); + Object.assign(element, { isConnected: true }); element.connectedCallback(); click(element['roomClose']); return true; + }); + click(element['roomClose']); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); closeBusy(element, false); + }); + + for (const update of ['snapshot', 'names', 'stage and mute', 'target removal', 'other role delta', 'other stage delta', 'playback volume', 'playback mute'] as const) { + it(`keeps room-level consent through unrelated ${update}`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), owner = element['roomCloseOwner']; + const close = t.mock.method(client, 'closeRoom', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => { + const state = controllerState(); + if (update === 'names') { state.name = 'Renamed room'; state.me.name = 'Renamed controller'; } + if (update === 'stage and mute') { state.me.onStage = true; state.me.muted = true; state.me.forceMuted = true; } + if (update === 'target removal') state.speakers = []; + if (update === 'other role delta') socket.deliver({ type: 'role:update', participantId: 'target', role: 'audience' }); + else if (update === 'other stage delta') socket.deliver({ type: 'stage:left', participantId: 'target', role: 'audience' }); + else if (update === 'playback volume') element.playbackVolume = .41; + else if (update === 'playback mute') element.playbackMuted = true; + else socket.deliver({ type: 'room:state', ...state }); + assert.equal(element['roomCloseOwner'], owner); return true; + }); + click(element['roomClose']); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + }); + } + + for (const boundary of ['closed', 'shutdown', 'kicked', 'disconnect', 'role tenure', 'unmount', 'identity'] as const) for (const result of ['resolve', 'reject'] as const) { + it(`ignores delayed ${result} after ${boundary} and preserves newer feedback`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), old = deferred(), active = deferred(); let calls = 0; + t.mock.method(client, 'closeRoom', () => (++calls === 1 ? old : active).promise); t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); closeBusy(element, true); + if (boundary === 'closed' || boundary === 'shutdown') socket.deliver({ type: 'room:closed', roomId: 'one', ...(boundary === 'shutdown' ? { reason: 'shutdown' as const } : {}) }); + else if (boundary === 'kicked') socket.deliver({ type: 'kicked', roomId: 'one' }); + else if (boundary === 'disconnect') await client.disconnect(); + else if (boundary === 'role tenure') { socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); click(element['roomClose']); closeBusy(element, true); } + else if (boundary === 'unmount') { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); } + else element.setAttribute('name', 'Replacement'); + await settle(); feedback(element, 'New feedback'); if (result === 'resolve') old.resolve(); else old.reject(new Error('Obsolete close failure')); await settle(); + assert.equal(element['error'].textContent, 'New feedback'); assert.equal(element['error'].hidden, false); closeBusy(element, boundary === 'role tenure'); + if (boundary === 'role tenure') { assert.equal(calls, 2); active.resolve(); await settle(); closeBusy(element, false); } + if (boundary === 'closed' || boundary === 'shutdown' || boundary === 'kicked') { assert.equal(element['roomAction'].hidden, true); assert.equal(element['roomClose'].hidden, true); assert.equal(element['leftRoom'], false); } + }); + } + + for (const newerFeedback of [false, true]) it(`reports current close rejection only with unchanged feedback revision: newer=${newerFeedback}`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + t.mock.method(client, 'closeRoom', () => pending.promise); t.mock.method(window, 'confirm', () => true); + feedback(element); click(element['roomClose']); if (newerFeedback) feedback(element, 'Newer feedback'); + pending.reject(new Error('Current close failed')); await settle(); + assert.equal(element['error'].textContent, newerFeedback ? 'Newer feedback' : 'Current close failed'); closeBusy(element, false); + }); + + it('releases an actual SDK ACK without synthesizing terminal state, clearing feedback, or replaying closure', async t => { + const { element, client, socket, tick } = await socketJoinedWidget(t, controllerState()); t.mock.method(window, 'confirm', () => true); feedback(element); + click(element['roomClose']); await tick(100); const request = closeRequests(socket)[0]!; assert.deepEqual(Object.keys(request).sort(), ['requestId', 'type']); + socket.deliver({ type: 'ok', requestId: request.requestId }); await settle(); closeBusy(element, false); + assert.equal(client.me!.role, 'controller'); assert.equal(element['leftRoom'], false); assert.equal(element['roomAction'].textContent, '離開房間'); + assert.notEqual(element['status'].textContent, '房間已關閉。'); assert.equal(element['error'].textContent, 'Keep existing feedback'); assert.equal(element['error'].hidden, false); + await tick(500); assert.equal(closeRequests(socket).length, 1); + }); + + it('retains the same pending owner across reconnect, releases only its request rejection and permits a fresh resumed action', async t => { + const { element, client, socket, sockets, tick, hello } = await socketJoinedWidget(t, controllerState()); t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); await tick(100); const owner = element['roomCloseOwner'], stale = socket.onmessage, old = closeRequests(socket)[0]!; + socket.close(); assert.equal(element['roomCloseOwner'], owner); assert.equal(element['roomClose'].disabled, true); await settle(); closeBusy(element, false); + feedback(element, 'Reconnect feedback'); await tick(500); const next = sockets[1]!, join = await hello(next); + next.deliver({ type: 'room:state', ...controllerState() }); next.deliver({ type: 'ok', requestId: join.requestId }); await settle(); + assert.equal(element['roomCloseOwner'], owner); assert.equal(closeRequests(next).length, 0); click(element['roomClose']); await tick(100); closeBusy(element, true); + feedback(element, 'Fresh feedback'); stale?.({ data: JSON.stringify({ type: 'error', requestId: old.requestId, code: 'forbidden', message: 'Stale wire error' }) }); await settle(); + assert.equal(element['error'].textContent, 'Fresh feedback'); closeBusy(element, true); + next.deliver({ type: 'ok', requestId: closeRequests(next)[0]!.requestId }); await settle(); closeBusy(element, false); assert.equal(client.me!.role, 'controller'); + }); + + for (const property of ['hidden', 'disabled', 'aria-busy', 'notice'] as const) it(`stops old close render writes after ${property} setter retires the owner without another render`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; let retired = false; + const retire = () => { if (retired) return; retired = true; client.me!.role = 'audience'; }; + if (property === 'hidden') { action.hidden = true; controlSetter(t, action as unknown as Node, 'hidden', retire); } + else if (property === 'disabled') { action.disabled = true; controlSetter(t, action as unknown as Node, 'disabled', retire); } + else if (property === 'notice') { element['roomCloseNotice'].hidden = true; controlSetter(t, element['roomCloseNotice'] as unknown as Node, 'hidden', retire); } + else { action.setAttribute('aria-busy', 'tampered'); const original = action.setAttribute; t.mock.method(action, 'setAttribute', function (this: HTMLButtonElement, key: string, value: string) { original.call(this, key, value); retire(); }); } + element['syncRoomClose'](); assert.equal(retired, true); assert.equal(action.hidden, true); assert.equal(action.disabled, true); closeBusy(element, false); + assert.equal(element['roomCloseNotice'].hidden, true); assert.equal(element['roomCloseOwner'], undefined); + }); + + for (const direction of ['close to transfer', 'transfer to close'] as const) for (const decision of ['cancel', 'throw', 'replacement', 'remount'] as const) { + it(`excludes ${direction} during ${decision} while allowing a fresh action after synchronous scope exit`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + const close = t.mock.method(client, 'closeRoom', () => pending.promise), transfer = t.mock.method(client, 'transferControl', () => pending.promise); + const outer = direction === 'close to transfer' ? element['roomClose'] : transferButton(element); + let dialogs = 0; + t.mock.method(window, 'confirm', () => { + dialogs++; + if (decision === 'replacement') { socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); } + if (decision === 'remount') { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); Object.assign(element, { isConnected: true }); element.connectedCallback(); } + const inner = direction === 'close to transfer' ? (element['stageManagementRows'].get('target')?.transfer ?? outer) : element['roomClose']; click(inner); + if (decision === 'throw') throw new Error('Dialog failed'); return false; + }); + click(outer); await settle(); assert.equal(dialogs, 1); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + assert.equal(element['roomCloseDialog'], undefined); assert.equal(element['stageTransferDialogs'].size, 0); + if (decision !== 'remount') { t.mock.method(window, 'confirm', () => true); click(direction === 'close to transfer' ? transferButton(element) : element['roomClose']); + assert.equal(direction === 'close to transfer' ? transfer.mock.callCount() : close.mock.callCount(), 1); pending.resolve(); await settle(); } + }); + } + + it('retains an outer transfer scope after a nested replacement transfer exits and releases it before waiting for ACK', async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); let transfers = 0, closes = 0; + const transfer = t.mock.method(client, 'transferControl', () => pending.promise), close = t.mock.method(client, 'closeRoom', async () => {}); + t.mock.method(window, 'confirm', (message: string) => { + if (message === closePrompt) { closes++; return true; } + if (++transfers === 2) return true; + socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); + click(transferButton(element, 'second')); assert.equal(transfer.mock.callCount(), 1); + click(element['roomClose']); assert.equal(closes, 0); return false; + }); + click(transferButton(element)); await settle(); assert.equal(transfers, 2); assert.equal(transfer.mock.callCount(), 1); assert.equal(closes, 0); + assert.equal(element['stageTransferDialogs'].size, 0); click(element['roomClose']); await settle(); assert.equal(closes, 1); assert.equal(close.mock.callCount(), 1); + pending.resolve(); await settle(); assert.equal(element['stageTransferAttempt'], undefined); + }); + + it('preserves transfer pre-admission reconciliation even when a close dialog excludes it', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = transferButton(element); + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + t.mock.method(window, 'confirm', () => { client.me!.role = 'audience'; click(action); assert.equal(element['stageManagementOwner'], undefined); return true; }); + click(element['roomClose']); await settle(); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + + it('marks transfer admission before pre-admission setters can attempt closure', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()); let entered = false; + const close = t.mock.method(client, 'closeRoom', async () => {}), transfer = t.mock.method(client, 'transferControl', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => true); + controlSetter(t, element['stageManagementSummary'] as unknown as Node, 'textContent', () => { if (entered) return; entered = true; click(element['roomClose']); }); + click(transferButton(element)); await settle(); assert.equal(entered, true); assert.equal(dialog.mock.callCount(), 1); + assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 1); + }); + + for (const event of ['role:update', 'control:transferred'] as const) for (const lane of ['audio', 'output', 'transfer', 'close'] as const) { + it(`fences both controller actions before ${lane} DOM setters on real Hub ${event} with its snapshot held`, async t => { + const f = await hubCloseWidget(t), transferNode = [...f.element['stageManagementRows'].values()][0]?.transfer; + assert.ok(transferNode); f.holdSnapshots(); f.holdDeltas(); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'real-held-deltas', targetId: f.host.id }); await f.drain(); + const delta = f.heldDeltas.find(message => message.type === event && (message.type !== 'role:update' || message.participantId === f.pid)); assert.ok(delta); + assert.equal(f.client.me!.role, 'controller'); + const close = t.mock.method(f.client, 'closeRoom', async () => {}), transfer = t.mock.method(f.client, 'transferControl', async () => {}); + const dialog = t.mock.method(window, 'confirm', () => true); let entered = false; + const node = lane === 'audio' ? f.element['audioRetry'] : lane === 'output' ? f.element['outputDeviceApply'] : lane === 'transfer' ? transferNode : f.element['roomClose']; + controlSetter(t, node as unknown as Node, lane === 'close' ? 'hidden' : 'disabled', () => { + if (entered) return; entered = true; assert.equal(f.client.me!.role, 'controller'); + click(f.element['roomClose']); click(transferNode); + }); + f.socket.deliver(delta); await settle(); assert.equal(entered, true); assert.equal(dialog.mock.callCount(), 0); + assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); assert.equal(f.element['roomCloseOwner'], undefined); + assert.equal(f.element['roomCloseAuthority']?.awaitingState, true); assert.equal(f.element['stageManagementOwner']?.transferAwaitingState, true); + }); + } + + for (const event of ['role:update', 'control:transferred'] as const) it(`cannot restore consent inside confirmation after a real ${event} loses control before its snapshot`, async t => { + const f = await hubCloseWidget(t), state = structuredClone(f.client.state!); f.holdSnapshots(); f.holdDeltas(); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'real-confirm-delta', targetId: f.host.id }); await f.drain(); + const delta = f.heldDeltas.find(message => message.type === event && (message.type !== 'role:update' || message.participantId === f.pid)); assert.ok(delta); + const close = t.mock.method(f.client, 'closeRoom', async () => {}), owner = f.element['roomCloseOwner']; + const dialog = t.mock.method(window, 'confirm', () => { + f.socket.deliver(delta); assert.equal(f.client.me!.role, 'controller'); click(f.element['roomClose']); + f.socket.deliver({ type: 'room:state', ...state }); assert.notEqual(f.element['roomCloseOwner'], owner); click(f.element['roomClose']); return true; + }); + click(f.element['roomClose']); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); closeBusy(f.element, false); + }); + + it('retains the own-role fence when unsupported close capability retires the UI owner before the real delta', async t => { + const f = await hubCloseWidget(t); f.holdSnapshots(); f.holdDeltas(); + const method = f.client.closeRoom; Reflect.set(f.client, 'closeRoom', undefined); f.element['render'](); assert.equal(f.element['roomCloseOwner'], undefined); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'missing-method-fence', targetId: f.host.id }); await f.drain(); + const delta = f.heldDeltas.find(message => message.type === 'role:update' && message.participantId === f.pid); assert.ok(delta); + const dialog = t.mock.method(window, 'confirm', () => true); let restored = false; + controlSetter(t, f.element['audioRetry'] as unknown as Node, 'disabled', () => { + if (restored) return; restored = true; Reflect.set(f.client, 'closeRoom', method); click(f.element['roomClose']); + }); + f.socket.deliver(delta); await f.drain(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 0); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); assert.equal(f.element['roomCloseAuthority']?.awaitingState, true); + for (const state of f.heldSnapshots) f.socket.deliver(state); assert.equal(f.element['roomClose'].hidden, true); + }); + + it('never establishes a fresh close owner from a reconnecting snapshot', async t => { + const { element, client, socket } = await socketJoinedWidget(t, roomState()); t.mock.method(window, 'confirm', () => true); + client['setStatus']('reconnecting'); client.me!.role = 'controller'; element['render'](); assert.equal(element['roomCloseOwner'], undefined); + assert.equal(element['roomClose'].hidden, true); click(element['roomClose']); assert.equal(closeRequests(socket).length, 0); + client['setStatus']('live'); assert.ok(element['roomCloseOwner']); assert.equal(element['roomClose'].disabled, false); + }); + + for (const result of ['resolve', 'reject'] as const) it(`isolates old ${result} after same-client same-ID actual disconnect and rejoin`, async t => { + const { element, client, socket, sockets, tick, joinRequest } = await socketJoinedWidget(t, controllerState()); + const old = deferred(), active = deferred(); let calls = 0; + t.mock.method(client, 'closeRoom', () => (++calls === 1 ? old : active).promise); t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); const owner = element['roomCloseOwner']; await client.disconnect(); assert.equal(element['roomCloseOwner'], undefined); + const connecting = client.connect(), next = sockets[1]!; next.hello(); await connecting; + const joining = client.join({ roomId: 'one', name: 'Same-ID controller' }); await settle(); await tick(100); const join = joinRequest(next); + next.deliver({ type: 'room:state', ...controllerState() }); next.deliver({ type: 'ok', requestId: join.requestId }); await joining; + assert.notEqual(element['roomCloseOwner'], owner); click(element['roomClose']); closeBusy(element, true); feedback(element, 'New tenure feedback'); + if (result === 'resolve') old.resolve(); else old.reject(new Error('Retired close failure')); await settle(); + closeBusy(element, true); assert.equal(element['error'].textContent, 'New tenure feedback'); assert.equal(calls, 2); assert.equal(closeRequests(socket).length, 0); + active.resolve(); await settle(); closeBusy(element, false); + }); + + for (const result of ['resolve', 'reject'] as const) it(`keeps a pending same-tenure attempt across temporary reconnect until exact ${result}`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); + const close = t.mock.method(client, 'closeRoom', () => pending.promise), dialog = t.mock.method(window, 'confirm', () => true); + click(element['roomClose']); const owner = element['roomCloseOwner'], attempt = element['roomCloseAttempt']; + client['setStatus']('reconnecting'); assert.equal(element['roomCloseOwner'], owner); closeBusy(element, true); + socket.deliver({ type: 'room:state', ...controllerState() }); click(element['roomClose']); + assert.equal(element['roomCloseAttempt'], attempt); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + feedback(element, 'Reconnect feedback'); if (result === 'resolve') pending.resolve(); else pending.reject(new Error('Obsolete request failure')); + await settle(); closeBusy(element, false); assert.equal(element['error'].textContent, 'Reconnect feedback'); assert.equal(element['roomClose'].disabled, false); + }); + + it('does not let an old render overwrite a nested replacement controller pending close', async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), pending = deferred(); let replaced = false; + const close = t.mock.method(client, 'closeRoom', () => pending.promise); t.mock.method(window, 'confirm', () => true); + element['roomClose'].hidden = true; + controlSetter(t, element['roomClose'] as unknown as Node, 'hidden', () => { + if (replaced || element['roomClose'].hidden) return; replaced = true; + socket.deliver({ type: 'room:state', ...roomState() }); socket.deliver({ type: 'room:state', ...controllerState() }); click(element['roomClose']); + }); + element['syncRoomClose'](); assert.equal(replaced, true); assert.equal(close.mock.callCount(), 1); + closeBusy(element, true); assert.equal(element['roomClose'].disabled, true); assert.equal(element['roomCloseNotice'].hidden, false); + pending.resolve(); await settle(); closeBusy(element, false); + }); + + for (const terminal of ['room:closed', 'kicked'] as const) it(`stops a close renderer when a setter delivers ${terminal}`, async t => { + const { element, socket } = await socketJoinedWidget(t, controllerState()); let terminalSent = false; + element['roomClose'].disabled = true; + controlSetter(t, element['roomClose'] as unknown as Node, 'disabled', () => { + if (terminalSent) return; terminalSent = true; socket.deliver({ type: terminal, roomId: 'one' }); + }); + element['syncRoomClose'](); assert.equal(element['roomCloseOwner'], undefined); assert.equal(element['roomClose'].hidden, true); + assert.equal(element['roomClose'].disabled, true); assert.equal(element['roomCloseNotice'].hidden, true); closeBusy(element, false); + }); + + it('releases only its exact attempt even when reservation and finalizer DOM setters throw', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), dialog = t.mock.method(window, 'confirm', () => true); let fail = true; + controlSetter(t, action as unknown as Node, 'disabled', () => { if (fail) throw new Error('Controlled DOM failure'); }); + click(action); await settle(); assert.equal(element['roomCloseAttempt'], undefined); assert.equal(element['roomCloseDialog'], undefined); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); + fail = false; element['render'](); click(action); await settle(); assert.equal(close.mock.callCount(), 1); closeBusy(element, false); + }); + + it('keeps synchronous dialog exclusion local to each widget instance', async t => { + const first = await subscribedWidget(t), second = await subscribedWidget(t); + await first.client['receive']({ type: 'room:state', ...controllerState() }); await second.client['receive']({ type: 'room:state', ...controllerState() }); + const firstClose = t.mock.method(first.client, 'closeRoom', async () => {}), secondClose = t.mock.method(second.client, 'closeRoom', async () => {}); let dialogs = 0; + t.mock.method(window, 'confirm', () => { if (++dialogs === 1) click(second.element['roomClose']); return true; }); + click(first.element['roomClose']); await settle(); assert.equal(dialogs, 2); assert.equal(firstClose.mock.callCount(), 1); assert.equal(secondClose.mock.callCount(), 1); + }); + + it('reconciles a retained direct handler after capability removal without transferring that click to a restored owner', async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = element['roomClose']; + const close = t.mock.method(client, 'closeRoom', async () => {}), method = client.closeRoom, dialog = t.mock.method(window, 'confirm', () => true); + Reflect.set(client, 'closeRoom', undefined); click(action); await settle(); + assert.equal(dialog.mock.callCount(), 0); assert.equal(close.mock.callCount(), 0); assert.equal(action.hidden, true); assert.equal(action.disabled, true); + Reflect.set(client, 'closeRoom', method); click(action); await settle(); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 1); + }); + + for (const boundary of ['role', 'connectivity', 'room', 'seat'] as const) it(`observes ${boundary} replacement state before an early audio setter can restore it inside confirmation`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), close = t.mock.method(client, 'closeRoom', async () => {}); + let restoring = false, restored = false; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; + client.me!.role = 'controller'; client.me!.connected = true; client.me!.participantId = 'me'; client.state!.roomId = 'one'; + click(element['roomClose']); + }); + const dialog = t.mock.method(window, 'confirm', () => { + restoring = true; const state = controllerState(); + if (boundary === 'role') state.me.role = 'audience'; + else if (boundary === 'connectivity') state.me.connected = false; + else if (boundary === 'room') state.roomId = 'other'; + else state.me.participantId = 'other'; + socket.deliver({ type: 'room:state', ...state }); return true; + }); + click(element['roomClose']); await settle(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); closeBusy(element, false); + }); + + for (const boundary of ['role', 'connectivity', 'room', 'seat'] as const) it(`fences a transfer's observed ${boundary} replacement state before an early setter restores the old controller`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), action = transferButton(element); + const transfer = t.mock.method(client, 'transferControl', async () => {}); let restoring = false, restored = false, dialogs = 0; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; + client.me!.role = 'controller'; client.me!.connected = true; client.me!.participantId = 'me'; client.state!.roomId = 'one'; + click(transferButton(element)); click(element['roomClose']); + }); + t.mock.method(window, 'confirm', () => { + if (++dialogs > 1) return true; + restoring = true; const state = controllerState(); + if (boundary === 'role') state.me.role = 'audience'; + else if (boundary === 'connectivity') state.me.connected = false; + else if (boundary === 'room') state.roomId = 'other'; + else state.me.participantId = 'other'; + socket.deliver({ type: 'room:state', ...state }); return true; + }); + click(action); await settle(); assert.equal(restored, true); assert.equal(dialogs, 1); assert.equal(transfer.mock.callCount(), 0); + assert.equal(element['stageManagementOwner']?.transferAwaitingState, true); click(transferButton(element)); await settle(); assert.equal(dialogs, 1); + socket.deliver({ type: 'room:state', ...controllerState() }); click(transferButton(element)); await settle(); assert.equal(dialogs, 2); assert.equal(transfer.mock.callCount(), 1); + }); + + it('permits waiting offstage controller closure with an empty stage and preserves independent Leave', async t => { + const state = controllerState(); state.status = 'waiting'; state.speakers = []; + const { element, client } = await socketJoinedWidget(t, state), close = t.mock.method(client, 'closeRoom', async () => {}); + t.mock.method(window, 'confirm', () => true); assert.equal(element['roomClose'].disabled, false); + assert.equal(element['roomAction'].disabled, false); assert.equal(element['stageManagementRows'].size, 0); + click(element['roomClose']); await settle(); assert.equal(close.mock.callCount(), 1); assert.equal(element['leftRoom'], false); + }); + + for (const status of ['reconnecting', 'disconnected'] as const) it(`invalidates active transfer consent on observed ${status} before an early setter restores live status`, async t => { + const { element, client } = await socketJoinedWidget(t, controllerState()), action = transferButton(element); + const transfer = t.mock.method(client, 'transferControl', async () => {}); let restoring = false, restored = false; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; client['setStatus']('live'); + }); + const dialog = t.mock.method(window, 'confirm', () => { restoring = true; client['setStatus'](status); return true; }); + click(action); await settle(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(transfer.mock.callCount(), 0); + }); + + for (const boundary of ['role', 'connectivity', 'room', 'seat'] as const) it(`fences a transfer's ${boundary} loss in a status-changing snapshot before the following state event`, async t => { + const { element, client, socket } = await socketJoinedWidget(t, controllerState()), transfer = t.mock.method(client, 'transferControl', async () => {}); + let restoring = false, restored = false; + controlSetter(t, element['audioRetry'] as unknown as Node, 'disabled', () => { + if (!restoring || restored) return; restored = true; + client.me!.role = 'controller'; client.me!.connected = true; client.me!.participantId = 'me'; client.state!.roomId = 'one'; + }); + const dialog = t.mock.method(window, 'confirm', () => { + restoring = true; const state = controllerState(); state.status = 'waiting'; + if (boundary === 'role') state.me.role = 'audience'; + else if (boundary === 'connectivity') state.me.connected = false; + else if (boundary === 'room') state.roomId = 'other'; + else state.me.participantId = 'other'; + socket.deliver({ type: 'room:state', ...state }); return true; + }); + click(transferButton(element)); await settle(); assert.equal(restored, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(transfer.mock.callCount(), 0); + }); + + // Controlled browser-media surfaces for the complete widget/SDK/validator/Hub path. + // These do not establish native dialog, focus, layout, accessibility or hardware-audio behavior. + class StagePeer { + static instances: StagePeer[] = []; + connectionState = 'connected'; signalingState = 'stable'; + localDescription: { type: 'offer'; sdp: string } | null = null; + readonly transceiver = { direction: 'recvonly', sender: { track: null as unknown, + replaceTrack: async (track: unknown) => { this.transceiver.sender.track = track; } } }; + constructor() { StagePeer.instances.push(this); } + addTransceiver() { return this.transceiver; } + async createOffer() { return { type: 'offer' as const, sdp: `v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\na=${this.transceiver.direction}\r\n` }; } + async setLocalDescription(description: { type: 'offer'; sdp: string }): Promise { this.localDescription = description; this.signalingState = 'have-local-offer'; } + async setRemoteDescription(): Promise { this.signalingState = 'stable'; } + async addIceCandidate(): Promise {} + close(): void { this.connectionState = 'closed'; } + } + class StageContext { + static instances: StageContext[] = []; + closes = 0; + constructor() { StageContext.instances.push(this); } + createMediaStreamSource() { return { connect() {}, disconnect() {} }; } + createAnalyser() { return { fftSize: 2048, getFloatTimeDomainData() {}, disconnect() {} }; } + async resume(): Promise {} + async close(): Promise { this.closes++; } + } + + async function hubCloseWidget(t: TestContext, onStage = false, asController = true) { + const tracks: { enabled: boolean; stopped: boolean; readyState: string; stop(): void }[] = []; + const mediaGlobals = new Map(['navigator', 'RTCPeerConnection', 'AudioContext'].map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + Object.defineProperty(globalThis, 'RTCPeerConnection', { configurable: true, value: StagePeer }); + Object.defineProperty(globalThis, 'AudioContext', { configurable: true, value: StageContext }); + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { mediaDevices: { getUserMedia: async () => { + const track = Object.assign(new EventTarget(), { enabled: true, stopped: false, readyState: 'live', stop() { this.stopped = true; this.readyState = 'ended'; } }); + tracks.push(track); return { getTracks: () => [track], getAudioTracks: () => [track] }; + } } } }); + t.after(() => { for (const [key, descriptor] of mediaGlobals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } }); + const config = testConfig(c => { c.rooms.qualityIntervalMs = 0; c.rooms.controllerGraceMs = 60_000; }); + const server = harness(config), host = await server.create('Synthetic original controller'); + const sockets: Socket[] = [], elements: InstanceType[] = []; + const validated: ClientMessage[] = [], incoming: ServerMessage[] = [], held: ServerMessage[] = []; + const sessions = new Map(); + const closes: { socket: Socket; code: number | undefined }[] = []; + const peersStart = StagePeer.instances.length, contextsStart = StageContext.instances.length, tracksStart = tracks.length; + let serverWork = Promise.resolve(), sequence = 0, holdCloseAck = false, holdSnapshots = false, holdDeltas = false; + const heldDeltas: ServerMessage[] = []; + let beforeClose: (() => Promise) | undefined; + const heldSnapshots: ServerMessage[] = []; + class Socket { + static OPEN = 1; + readyState = 1; + onmessage: ((event: { data: string }) => void) | null = null; + onclose: (() => void) | null = null; + constructor() { + sockets.push(this); + const session: Session = { id: `widget-close-${++sequence}`, + send: message => { + incoming.push(message); + if (holdDeltas && (message.type === 'role:update' || message.type === 'control:transferred')) { heldDeltas.push(message); return; } + if (holdCloseAck && message.type === 'ok' && validated.some(request => request.type === 'room:close' && request.requestId === message.requestId)) { held.push(message); return; } + if (holdSnapshots && message.type === 'room:state') { heldSnapshots.push(message); return; } + if (this.readyState === Socket.OPEN) this.deliver(message); + }, + close: code => this.close(code), + }; + sessions.set(this, session); + queueMicrotask(() => server.hub.attach(session)); + } + send(data: string) { + assert.equal(this.readyState, Socket.OPEN); + const message = parseClientMessage(data, config.limits), session = sessions.get(this)!; + validated.push(message); + serverWork = serverWork.then(async () => { if (message.type === 'room:close') await beforeClose?.(); await server.hub.handle(session, message); }); + } + deliver(message: ServerMessage) { this.onmessage?.({ data: JSON.stringify(message) }); } + close(code?: number) { + if (this.readyState === 3) return; + closes.push({ socket: this, code }); this.readyState = 3; this.onclose?.(); + const session = sessions.get(this)!; + serverWork = serverWork.then(() => server.hub.detach(session, code === 1000)); + } + } + const oldSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + Object.defineProperty(globalThis, 'WebSocket', { configurable: true, value: Socket }); + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + let now = 1000; t.mock.method(performance, 'now', () => now); + const tick = async (ms = 200) => { now += ms; t.mock.timers.tick(ms); await settle(); await serverWork; await settle(); }; + const drain = async () => { for (let n = 0; n < 8; n++) await tick(); }; + const flushStates = async () => { + for (const timer of server.timers) if (!timer.cleared && (timer.ms === 0 || timer.ms === config.rooms.presenceBroadcastMs)) { timer.cleared = true; timer.fn(); } + await nextTurn(); await settle(); + }; + const mount = async (name: string) => { + const element = new StageElement(); elements.push(element); Object.assign(element, { isConnected: false }); + element.setAttribute('url', 'wss://synthetic.invalid/ws'); element.setAttribute('room', host.roomId); element.setAttribute('code', host.code); element.setAttribute('name', name); + Object.assign(element, { isConnected: true }); element.connectedCallback(); await settle(); await drain(); + assert.ok(element.client?.me, 'actual widget joined through actual SDK and Hub'); return element; + }; + t.after(async () => { + for (const element of elements) { Object.assign(element, { isConnected: false }); element.disconnectedCallback(); } + await settle(); await serverWork; await server.hub.shutdown(); await server.transport.close(); await settle(); + if (oldSocket) Object.defineProperty(globalThis, 'WebSocket', oldSocket); else Reflect.deleteProperty(globalThis, 'WebSocket'); + }); + const element = await mount('Synthetic widget controller'), client = element.client!, socket = sockets[0]!, session = sessions.get(socket)!, pid = client.me!.participantId; + if (asController) { assert.equal(await server.req(host.s, { type: 'control:transfer', targetId: pid }), 'ok'); await drain(); assert.equal(client.me!.role, 'controller'); } + if (onStage) { + const work = asController ? client.returnToStage() : client.raiseHand(); await drain(); await work; + if (!asController) { assert.equal(await server.req(host.s, { type: 'stage:approve', targetId: pid }), 'ok'); await drain(); } + assert.equal(client.me!.onStage, true); + } + return { config, server, host, sockets, sessions, closes, validated, incoming, held, heldSnapshots, heldDeltas, peersStart, contextsStart, tracksStart, tracks, + element, client, socket, session, pid, tick, drain, flushStates, mount, + holdAck: () => { holdCloseAck = true; }, beforeClose: (fn: () => Promise) => { beforeClose = fn; }, + holdSnapshots: () => { holdSnapshots = true; }, holdDeltas: () => { holdDeltas = true; }, + }; + } + + for (const onStage of [false, true]) it(`widget Leave onStage=${onStage} keeps controller grace, live guests and later succession without room:close`, async t => { + const f = await hubCloseWidget(t, onStage), guest = await f.mount('Synthetic guest'), guestSession = f.sessions.get(f.sockets.at(-1)!)!; + click(f.element['roomAction']); await f.drain(); await f.flushStates(); + assert.equal(f.closes.find(close => close.socket === f.socket)?.code, 1000); + assert.equal(f.element.client, null); assert.equal(f.client.status, 'disconnected'); + assert.equal(f.element['status'].textContent, '已離開房間。'); assert.equal(f.element['roomAction'].textContent, '重新加入'); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); + assert.equal(f.server.hub.roomCount, 1); assert.equal(f.server.transport.rooms.has(f.host.roomId), true); + assert.equal(f.server.hub.isJoined(guestSession), true); assert.equal(guest.client!.state!.controllerId, f.pid); + assert.notEqual(guest.client!.status, 'disconnected'); assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + const late = await f.server.join(f.host.roomId, f.host.code, 'Synthetic late join'); assert.ok(late.id); + const grace = f.server.timers.find(timer => !timer.cleared && timer.ms === f.config.rooms.controllerGraceMs); assert.ok(grace); + grace.cleared = true; grace.fn(); await nextTurn(); await f.flushStates(); + assert.equal(f.server.hub.roomCount, 1); assert.notEqual(guest.client!.state!.controllerId, f.pid); + assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + }); + + it('ordinary audience Leave immediately removes only its own seat and leaves the room joinable', async t => { + const f = await hubCloseWidget(t, false, false); click(f.element['roomAction']); await f.drain(); await f.flushStates(); + assert.equal(f.server.hub.isJoined(f.session), false); assert.equal(f.server.hub.isJoined(f.host.s), true); + assert.equal(f.host.s.last('room:state')!.audience!.some(person => person.participantId === f.pid), false); + assert.equal(f.server.hub.roomCount, 1); assert.equal(f.validated.some(message => message.type === 'room:close'), false); + }); + + for (const onStage of [false, true]) it(`confirmed widget button onStage=${onStage} closes all seats/media, unlike Leave`, async t => { + const f = await hubCloseWidget(t, onStage), guest = await f.mount('Synthetic guest'), guestSession = f.sessions.get(f.sockets.at(-1)!)!; + const closed: unknown[] = []; f.element.addEventListener('stage-closed', event => closed.push((event as CustomEvent).detail)); + t.mock.method(window, 'confirm', () => true); click(f.element['roomClose']); await f.drain(); + assert.deepEqual(f.validated.filter(message => message.type === 'room:close').map(message => Object.keys(message).sort()), [['requestId', 'type']]); + assert.equal(f.server.hub.roomCount, 0); assert.equal(f.server.transport.rooms.has(f.host.roomId), false); + for (const session of [f.session, guestSession, f.host.s]) assert.equal(f.server.hub.isJoined(session), false); + assert.equal(f.client.me, null); assert.equal(guest.client!.me, null); assert.equal(f.client.status, 'disconnected'); + assert.equal(f.element['status'].textContent, '房間已關閉。'); assert.equal(guest['status'].textContent, '房間已關閉。'); + assert.equal(f.element['roomAction'].hidden, true); assert.deepEqual(closed, [{ roomId: f.host.roomId }]); + assert.ok(f.server.timers.every(timer => timer.cleared)); + assert.ok(StagePeer.instances.length > f.peersStart); + if (onStage) { assert.ok(f.tracks.length > f.tracksStart); assert.ok(StageContext.instances.length > f.contextsStart); } + assert.ok(StagePeer.instances.slice(f.peersStart).every(peer => peer.connectionState === 'closed')); + assert.ok(StageContext.instances.slice(f.contextsStart).every(context => context.closes > 0)); + assert.ok(f.tracks.slice(f.tracksStart).every(track => track.stopped)); + const late = new FakeSession(); f.server.hub.attach(late); + assert.equal(await f.server.req(late, { type: 'join', roomId: f.host.roomId, code: f.host.code, name: 'Cannot rejoin closed room' }), 'unauthorized'); + }); + + for (const onStage of [false, true]) it(`Hub rejects actual widget ${onStage ? 'speaker' : 'audience'} SDK closure without affecting room`, async t => { + const f = await hubCloseWidget(t, onStage, false), dialog = t.mock.method(window, 'confirm', () => true); + assert.equal(f.element['roomClose'].hidden, true); assert.equal(f.element['roomClose'].disabled, true); + click(f.element['roomClose']); await f.drain(); assert.equal(dialog.mock.callCount(), 0); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); + const result = f.client.closeRoom().then(() => 'ok', error => error.code); + await f.drain(); assert.equal(await result, 'forbidden'); assert.equal(f.server.hub.roomCount, 1); + assert.equal(f.server.hub.isJoined(f.session), true); assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + assert.equal(f.element['error'].hidden, false); + }); + + it('terminal before withheld ACK rejects local close promise but complete widget shows terminal result and never retries', async t => { + const f = await hubCloseWidget(t); f.holdAck(); + t.mock.method(window, 'confirm', () => true); click(f.element['roomClose']); await f.drain(); + assert.equal(f.held.length, 1); assert.equal(f.client['pending'].size, 0); + assert.equal(f.element['status'].textContent, '房間已關閉。'); assert.equal(f.element['error'].hidden, true); + for (const ack of f.held) f.socket.deliver(ack); await f.tick(30_000); await f.drain(); + assert.equal(f.sockets.length, 1); assert.equal(f.validated.filter(message => message.type === 'room:close').length, 1); + assert.equal(f.element['status'].textContent, '房間已關閉。'); + }); + + it('server authority wins if control is transferred after close is queued but before Hub executes it', async t => { + const f = await hubCloseWidget(t); + // Inject the competing authoritative request before the queued close executes. + f.beforeClose(async () => { await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'competing-transfer', targetId: f.host.id }); }); + t.mock.method(window, 'confirm', () => true); click(f.element['roomClose']); await f.drain(); + assert.ok(f.incoming.some(message => message.type === 'error' && message.code === 'forbidden')); assert.notEqual(f.client.me!.role, 'controller'); assert.equal(f.server.hub.roomCount, 1); + assert.equal(f.incoming.some(message => message.type === 'room:closed'), false); + }); + + it('actual transfer notifications precede authoritative snapshot and expose stale controller getter to host callbacks', async t => { + const f = await hubCloseWidget(t), observed: { type: string; role: string | undefined }[] = []; + for (const type of ['stage-role', 'stage-transferred']) f.element.addEventListener(type, () => observed.push({ type, role: f.client.me?.role })); + f.holdSnapshots(); await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'notify-before-state', targetId: f.host.id }); await f.drain(); + assert.ok(observed.some(event => event.type === 'stage-transferred' && event.role === 'controller')); + assert.ok(f.heldSnapshots.length); assert.equal(f.client.me!.role, 'controller'); + const result = f.client.closeRoom().then(() => 'ok', error => error.code); await f.drain(); assert.equal(await result, 'forbidden'); + for (const state of f.heldSnapshots) f.socket.deliver(state); await settle(); assert.notEqual(f.client.me!.role, 'controller'); + }); + + it('validator cannot use extra roomId, targetId or role fields to confer closure authority', async t => { + const f = await hubCloseWidget(t, false, false); + const request = parseClientMessage(JSON.stringify({ type: 'room:close', requestId: 'spoof-close', roomId: 'another-room', targetId: f.host.id, role: 'controller' }), f.config.limits); + assert.deepEqual(request, { type: 'room:close', requestId: 'spoof-close' }); + await f.server.hub.handle(f.session, request); await settle(); + assert.ok(f.incoming.some(message => message.type === 'error' && message.requestId === 'spoof-close' && message.code === 'forbidden')); + assert.equal(f.server.hub.roomCount, 1); + }); + + it('Leave opt-out survives widget attribute changes and unmount/remount until explicit rejoin', async t => { + const f = await hubCloseWidget(t); click(f.element['roomAction']); await f.drain(); + f.element.setAttribute('name', 'New synthetic name'); f.element.setAttribute('code', f.host.code); + Object.assign(f.element, { isConnected: false }); f.element.disconnectedCallback(); + Object.assign(f.element, { isConnected: true }); f.element.connectedCallback(); await f.drain(); + assert.equal(f.sockets.length, 1); assert.equal(f.element.client, null); assert.equal(f.element['status'].textContent, '已離開房間。'); + click(f.element['roomAction']); await f.drain(); assert.equal(f.sockets.length, 2); assert.ok(f.element.client!.me); + assert.equal(f.validated.some(message => message.type === 'room:close'), false); + }); + + + /** Counts controlled DOM calls, not browser layout, focus, IME, or accessibility behavior. */ + function instrumentEditors(element: InstanceType) { + const counts = new Map(); + const restores: (() => void)[] = [], writes: string[] = []; let recording = true; + const bump = (key: string, kind: 'calls' | 'changes' | 'reads') => { + if (!recording) return; + if (kind === 'calls') writes.push(key); + const value = counts.get(key) ?? { calls: 0, changes: 0, reads: 0 }; value[kind]++; counts.set(key, value); + }; + for (const [key, property] of [ + ['namePanel', 'hidden'], ['nameInput', 'value'], ['nameInput', 'disabled'], ['acceptedName', 'textContent'], ['nameUpdate', 'disabled'], + ['roomNamePanel', 'hidden'], ['roomNameInput', 'value'], ['roomNameInput', 'disabled'], ['acceptedRoomName', 'textContent'], ['roomNameUpdate', 'disabled'], + ] as const) { + const node = element[key], original = Object.getOwnPropertyDescriptor(node, property); + let stored = Reflect.get(node, property); + Object.defineProperty(node, property, { configurable: true, get() { bump(`${key}.${property}`, 'reads'); return stored; }, set(value) { + bump(`${key}.${property}`, 'calls'); if (stored !== value) bump(`${key}.${property}`, 'changes'); stored = value; + } }); + restores.push(() => { if (original) Object.defineProperty(node, property, { ...original, value: stored }); else { Reflect.deleteProperty(node, property); Reflect.set(node, property, stored); } }); + } + for (const key of ['nameUpdate', 'roomNameUpdate'] as const) { + const node = element[key], set = node.setAttribute, remove = node.removeAttribute, get = node.getAttribute, has = node.hasAttribute; + node.setAttribute = function(name, value) { + if (name === 'aria-busy') { bump(`${key}.aria-busy`, 'calls'); if (get.call(this, name) !== value) bump(`${key}.aria-busy`, 'changes'); } + set.call(this, name, value); + }; + node.removeAttribute = function(name) { + if (name === 'aria-busy') { bump(`${key}.aria-busy`, 'calls'); if (has.call(this, name)) bump(`${key}.aria-busy`, 'changes'); } + remove.call(this, name); + }; + node.getAttribute = function(name) { if (name === 'aria-busy') bump(`${key}.aria-busy`, 'reads'); return get.call(this, name); }; + node.hasAttribute = function(name) { if (name === 'aria-busy') bump(`${key}.aria-busy`, 'reads'); return has.call(this, name); }; + restores.push(() => { node.setAttribute = set; node.removeAttribute = remove; node.getAttribute = get; node.hasAttribute = has; }); + } + const snapshot = () => { + const properties = Object.fromEntries([...counts].sort(([a], [b]) => a.localeCompare(b))); + return { calls: [...counts.values()].reduce((n, v) => n + v.calls, 0), changes: [...counts.values()].reduce((n, v) => n + v.changes, 0), properties, writes: [...writes] }; + }; + return { snapshot, reset: () => { counts.clear(); writes.length = 0; }, hostWrite(fn: () => void) { recording = false; try { fn(); } finally { recording = true; } }, restore: () => restores.forEach(fn => fn()) }; + } + + it('editor DOM guards counts full widget legal SDK validator Hub workloads with retained drafts and callback order', async t => { + const f = await hubCloseWidget(t), guest = await f.mount('Synthetic audience'); + await f.flushStates(); await f.drain(); + const controller = f.element, viewers = [controller, guest], meters = viewers.map(instrumentEditors); + t.after(() => meters.forEach(meter => meter.restore())); + const identities = viewers.map(element => ['namePanel', 'acceptedName', 'nameInput', 'nameUpdate', 'roomNamePanel', 'acceptedRoomName', 'roomNameInput', 'roomNameUpdate'].map(key => Reflect.get(element, key))); + const handlers = viewers.map(element => [element['nameUpdate'].onclick, element['roomNameUpdate'].onclick]); + controller['nameInput'].value = ' untouched controller draft '; controller['roomNameInput'].value = ' untouched room draft '; guest['nameInput'].value = ' untouched audience draft '; + for (const input of [controller['nameInput'], controller['roomNameInput'], guest['nameInput']]) Object.assign(input, { selectionStart: 2, selectionEnd: 7, selectionDirection: 'backward' }); + const methods = ['syncNameOwner', 'syncRoomNameOwner', 'noteRoomCloseEvent', 'noteStageTransferEvent'] as const; + const syncs = viewers.map(element => methods.map(method => t.mock.method(element as unknown as Record<(typeof methods)[number], (...args: unknown[]) => void>, method))); + const eventTrace: string[] = []; + for (const [index, element] of viewers.entries()) for (const type of ['state', 'status', 'role', 'transferred', 'error']) element.addEventListener(`stage-${type}`, () => eventTrace.push(`${index}:${type}`)); + type EditorCounts = ReturnType['snapshot']>; + type Workload = { + label: string; controller: EditorCounts; audience: EditorCounts; + callbacks: string[]; synchronizations: number[][]; commands: string[]; + }; + const results: Workload[] = []; + async function measure(label: string, work: () => Promise) { + meters.forEach(meter => meter.reset()); + const before = syncs.map(items => items.map(item => item.mock.callCount())), trace = eventTrace.length, request = f.validated.length; + await work(); await f.drain(); + results.push({ label, controller: meters[0]!.snapshot(), audience: meters[1]!.snapshot(), callbacks: eventTrace.slice(trace), + synchronizations: syncs.map((items, i) => items.map((item, j) => item.mock.callCount() - before[i]![j]!)), + commands: f.validated.slice(request).filter(message => message.type !== 'ping').map(message => message.type) }); + } + async function run(action: () => Promise) { const result = action(); await f.drain(); await result; } + await measure('20 audience participant rename changes', async () => { for (let i = 0; i < 20; i++) await run(() => guest.client!.setName(`Audience ${i}`)); }); + await measure('20 controller room rename changes', async () => { for (let i = 0; i < 20; i++) await run(() => f.client.setRoomName(`Room ${i}`)); }); + await measure('20 controller no-op room submissions', async () => { for (let i = 0; i < 20; i++) await run(() => f.client.setRoomName('Room 19')); }); + assert.equal(controller['nameInput'].value, ' untouched controller draft '); assert.equal(controller['roomNameInput'].value, ' untouched room draft '); assert.equal(guest['nameInput'].value, ' untouched audience draft '); + for (const input of [controller['nameInput'], controller['roomNameInput'], guest['nameInput']]) assert.deepEqual([input.selectionStart, input.selectionEnd, input.selectionDirection], [2, 7, 'backward']); + for (const [index, element] of viewers.entries()) { + for (const [position, key] of ['namePanel', 'acceptedName', 'nameInput', 'nameUpdate', 'roomNamePanel', 'acceptedRoomName', 'roomNameInput', 'roomNameUpdate'].entries()) assert.equal(Reflect.get(element, key), identities[index]![position]); + assert.equal(element['nameUpdate'].onclick, handlers[index]![0]); assert.equal(element['roomNameUpdate'].onclick, handlers[index]![1]); + } + await measure('20 controller participant editor button submissions', async () => { + for (let i = 0; i < 20; i++) { meters[0]!.hostWrite(() => { controller['nameInput'].value = `UI Controller ${i}`; }); click(controller['nameUpdate']); await f.drain(); assert.equal(controller['nameAttempt'], undefined); } + }); + await measure('20 controller room editor button submissions', async () => { + for (let i = 0; i < 20; i++) { meters[0]!.hostWrite(() => { controller['roomNameInput'].value = `UI Room ${i}`; }); click(controller['roomNameUpdate']); await f.drain(); assert.equal(controller['roomNameAttempt'], undefined); } + }); + await measure('20 audience participant editor button submissions', async () => { + for (let i = 0; i < 20; i++) { meters[1]!.hostWrite(() => { guest['nameInput'].value = `UI Audience ${i}`; }); click(guest['nameUpdate']); await f.drain(); assert.equal(guest['nameAttempt'], undefined); } + }); + // Per-property expectations retain every accepted-text and retired-draft write, including equal values. + const expected = [ + { calls: [80, 120], changes: [0, 20], command: 'participant:rename', events: ['1:state', '0:state'], syncs: [[40, 40, 20, 20], [40, 40, 20, 20]], changed: [null, 'name'], pending: [null, null] }, + { calls: [80, 120], changes: [20, 0], command: 'room:rename', events: ['0:state', '1:state'], syncs: [[40, 40, 20, 20], [40, 40, 20, 20]], changed: ['roomName', null], pending: [null, null] }, + { calls: [80, 0], changes: [0, 0], command: 'room:rename', events: ['0:state'], syncs: [[40, 40, 20, 20], [0, 0, 0, 0]], changed: [null, null], pending: [null, null] }, + { calls: [240, 120], changes: [100, 0], command: 'participant:rename', events: ['0:state', '1:state'], syncs: [[120, 40, 20, 20], [40, 40, 20, 20]], changed: ['name', null], pending: ['name', null] }, + { calls: [240, 120], changes: [100, 0], command: 'room:rename', events: ['0:state', '1:state'], syncs: [[40, 120, 20, 20], [40, 40, 20, 20]], changed: ['roomName', null], pending: ['roomName', null] }, + { calls: [80, 280], changes: [0, 100], command: 'participant:rename', events: ['1:state', '0:state'], syncs: [[40, 40, 20, 20], [120, 40, 20, 20]], changed: [null, 'name'], pending: [null, 'name'] }, + ] as const; + assert.equal(results.length, expected.length); + for (const [index, result] of results.entries()) { + const want = expected[index]!; + assert.deepEqual(result.commands, Array(20).fill(want.command), result.label); + assert.deepEqual(result.callbacks, Array.from({ length: 20 }, () => [...want.events]).flat(), result.label); + assert.deepEqual(result.synchronizations, want.syncs, result.label); + for (const [viewer, measured] of [result.controller, result.audience].entries()) { + assert.equal(measured.calls, want.calls[viewer], `${result.label}: viewer ${viewer} writes`); + assert.equal(measured.changes, want.changes[viewer], `${result.label}: viewer ${viewer} changed writes`); + for (const [editorIndex, editor] of (['name', 'roomName'] as const).entries()) { + const sync = want.syncs[viewer]![editorIndex]!, pending = want.pending[viewer] === editor; + const text = editor === 'name' ? 'acceptedName' : 'acceptedRoomName'; + const propertyCounts: Record = { + [`${text}.textContent`]: [sync, want.changed[viewer] === editor ? 20 : 0], + [`${editor}Input.value`]: [viewer === 1 && editor === 'roomName' ? sync : 0, 0], + [`${editor}Panel.hidden`]: [0, 0], [`${editor}Input.disabled`]: [0, 0], + [`${editor}Update.disabled`]: [pending ? 40 : 0, pending ? 40 : 0], + [`${editor}Update.aria-busy`]: [pending ? 40 : 0, pending ? 40 : 0], + }; + for (const [key, counts] of Object.entries(propertyCounts)) { + const actual = measured.properties[key]; + assert.deepEqual([actual?.calls ?? 0, actual?.changes ?? 0], counts, `${result.label}: viewer ${viewer} ${key}`); + } + } + } + } + assert.equal(results.reduce((sum, result) => sum + result.controller.calls + result.audience.calls, 0), 1560); + assert.equal(results.reduce((sum, result) => sum + result.controller.changes + result.audience.changes, 0), 340); + assert.equal(eventTrace.length, 220); + + }); + + for (const editor of ['name', 'roomName'] as const) it(`editor DOM guards ${editor} full Hub pending ACK, draft edits, validation failure and retry`, async t => { + const f = await hubCloseWidget(t), element = f.element, input = editor === 'name' ? element['nameInput'] : element['roomNameInput']; + const button = editor === 'name' ? element['nameUpdate'] : element['roomNameUpdate']; + const accepted = editor === 'name' ? element['acceptedName'] : element['acceptedRoomName']; + const command = editor === 'name' ? 'participant:rename' : 'room:rename'; + const attempt = editor === 'name' ? 'nameAttempt' : 'roomNameAttempt'; + let hold = true; const held: ServerMessage[] = [], deliver = f.socket.deliver.bind(f.socket); + t.mock.method(f.socket, 'deliver', (message: ServerMessage) => { + if (hold && message.type === 'ok' && f.validated.some(request => request.type === command && request.requestId === message.requestId)) { held.push(message); return; } + deliver(message); + }); + input.value = ' e\u0301 & '; click(button); const token = element[attempt]; assert.ok(token); + await f.drain(); assert.equal(held.length, 1); assert.equal(element[attempt], token); assert.equal(button.disabled, true); assert.equal(input.disabled, false); assert.match(accepted.textContent!, /é & /); + input.value = 'Next raw draft'; Object.assign(input, { selectionStart: 1, selectionEnd: 5 }); click(button); await f.drain(); + assert.equal(f.validated.filter(message => message.type === command).length, 1); assert.equal(input.value, 'Next raw draft'); + hold = false; for (const message of held.splice(0)) deliver(message); await f.drain(); + assert.equal(button.disabled, false); assert.equal(button.getAttribute('aria-busy'), 'false'); assert.equal(element[attempt], undefined); + assert.deepEqual([input.selectionStart, input.selectionEnd], [1, 5]); + input.value = ' '; click(button); await f.drain(); assert.equal(element['error'].hidden, false); assert.equal(input.value, ' '); assert.equal(button.disabled, false); + input.value = 'Successful retry'; click(button); await f.drain(); assert.equal(element['error'].hidden, true); assert.match(accepted.textContent!, /Successful retry/); assert.equal(f.validated.filter(message => message.type === command).length, 2); + }); + + for (const asController of [true, false]) it(`editor DOM guards repairs corrupted live DOM via legal Hub update controller=${asController}`, async t => { + const f = await hubCloseWidget(t, false, asController), element = f.element; + const nameDraft = element['nameInput'].value = 'Preserve personal raw', roomAccepted = element['acceptedRoomName'].textContent; + if (asController) element['roomNameInput'].value = 'Preserve room raw'; + element['namePanel'].hidden = true; element['nameInput'].disabled = true; element['nameUpdate'].disabled = true; element['nameUpdate'].setAttribute('aria-busy', 'corrupt'); element['acceptedName'].textContent = 'Corrupt accepted'; + element['roomNamePanel'].hidden = !asController; element['roomNameInput'].disabled = asController; element['roomNameUpdate'].disabled = asController; element['roomNameUpdate'].setAttribute('aria-busy', 'corrupt'); element['acceptedRoomName'].textContent = 'Corrupt room accepted'; + if (!asController) { element['roomNamePanel'].hidden = false; element['roomNameInput'].disabled = false; element['roomNameUpdate'].disabled = false; element['roomNameInput'].value = 'Stale retired draft'; } + const work = f.client.setName('Repair via authoritative state'); await f.drain(); await work; + assert.equal(element['namePanel'].hidden, false); assert.equal(element['nameInput'].disabled, false); assert.equal(element['nameUpdate'].disabled, false); assert.equal(element['nameUpdate'].getAttribute('aria-busy'), 'false'); assert.equal(element['nameInput'].value, nameDraft); assert.match(element['acceptedName'].textContent!, /Repair via authoritative state/); + assert.equal(element['roomNamePanel'].hidden, !asController); assert.equal(element['roomNameInput'].disabled, !asController); assert.equal(element['roomNameUpdate'].disabled, !asController); assert.equal(element['roomNameUpdate'].getAttribute('aria-busy'), asController ? 'false' : null); assert.equal(element['roomNameInput'].value, asController ? 'Preserve room raw' : ''); + assert.equal(element['acceptedRoomName'].textContent, roomAccepted); + }); + + for (const editor of ['name', 'roomName'] as const) for (const retired of [false, true]) for (const property of ['hidden', 'input-disabled', 'update-disabled', 'aria-busy'] as const) it(`editor DOM guards retries failed ${editor} ${property} setter retired=${retired}`, async t => { + const f = await hubCloseWidget(t), element = f.element; + if (retired) await f.client.disconnect(); + const panel = editor === 'name' ? element['namePanel'] : element['roomNamePanel']; + const input = editor === 'name' ? element['nameInput'] : element['roomNameInput']; + const button = editor === 'name' ? element['nameUpdate'] : element['roomNameUpdate']; + let failed = false, writes = 0; + if (property === 'aria-busy') { + if (retired) button.setAttribute('aria-busy', 'corrupt'); else button.removeAttribute('aria-busy'); + const method = retired ? 'removeAttribute' : 'setAttribute', original = button[method]; + Object.defineProperty(button, method, { configurable: true, value: function(...args: unknown[]) { + if (args[0] === 'aria-busy') { + writes++; + if (!failed) { failed = true; throw new Error('Synthetic editor write failure'); } + } + return Reflect.apply(original, this, args); + } }); + t.after(() => { Object.defineProperty(button, method, { configurable: true, value: original }); }); + } else { + const node = property === 'hidden' ? panel : property === 'input-disabled' ? input : button; + const key = property === 'hidden' ? 'hidden' : 'disabled'; let value = !retired; + Object.defineProperty(node, key, { configurable: true, get: () => value, set(next: boolean) { + writes++; + if (!failed) { failed = true; throw new Error('Synthetic editor write failure'); } value = next; + } }); + } + assert.throws(() => element['render'](), /Synthetic editor write failure/); assert.equal(failed, true); assert.equal(writes, 1); + element['render'](); assert.equal(writes, 2); + assert.equal(panel.hidden, retired); assert.equal(input.disabled, retired); assert.equal(button.disabled, retired); assert.equal(button.getAttribute('aria-busy'), retired ? null : 'false'); + element['render'](); assert.equal(writes, 2, 'the successful retry alone makes subsequent equal writes unnecessary'); + }); + + for (const action of ['close', 'transfer'] as const) it(`editor DOM guards preserves early ${action} authority notes across editor setter reentry and skipped equal writes`, async t => { + const f = await hubCloseWidget(t), element = f.element; + const originalState = structuredClone(f.client.state!), closeOwner = element['roomCloseOwner'], transferOwner = element['stageManagementOwner']; + const close = t.mock.method(f.client, 'closeRoom', async () => {}), transfer = t.mock.method(f.client, 'transferControl', async () => {}); + const transferAction = transferButton(element, f.host.id), closeAction = element['roomClose']; + f.holdSnapshots(); f.holdDeltas(); + await f.server.hub.handle(f.session, { type: 'control:transfer', requestId: 'editor-authority-loss', targetId: f.host.id }); await f.drain(); + const loss = f.heldSnapshots.find(message => message.type === 'room:state' && message.me.role !== 'controller'); assert.ok(loss); + let armed = false, reentered = false; + controlSetter(t, element['acceptedName'] as unknown as Node, 'textContent', () => { + if (!armed || reentered) return; reentered = true; + assert.notEqual(element['roomCloseOwner'], closeOwner, 'old close owner retired before editor text setter'); assert.equal(transferOwner?.transferAwaitingState, true, 'old transfer owner fenced before editor text setter'); + f.client.me!.role = 'controller'; f.client.me!.connected = true; + click(closeAction); click(transferAction); + }); + const dialog = t.mock.method(window, 'confirm', () => { + armed = true; f.socket.deliver(loss); f.socket.deliver({ type: 'room:state', ...originalState }); return true; + }); + click(action === 'close' ? closeAction : transferAction); await settle(); + assert.equal(reentered, true); assert.equal(dialog.mock.callCount(), 1); assert.equal(close.mock.callCount(), 0); assert.equal(transfer.mock.callCount(), 0); + }); + + it('editor DOM guards retain retirement write order and unconditional draft/text resets', async t => { + const f = await hubCloseWidget(t), element = f.element; + await f.client.disconnect(); + for (const editor of ['name', 'roomName'] as const) { + element[`${editor}Panel`].hidden = false; + element[`${editor}Input`].disabled = false; + element[`${editor}Update`].disabled = false; + element[`${editor}Update`].setAttribute('aria-busy', 'corrupt'); + } + const meter = instrumentEditors(element); t.after(() => meter.restore()); + element['render'](); + assert.deepEqual(meter.snapshot().writes, ['name', 'roomName'].flatMap(editor => [ + `${editor}Input.value`, `${editor === 'name' ? 'acceptedName' : 'acceptedRoomName'}.textContent`, + `${editor}Panel.hidden`, `${editor}Update.disabled`, `${editor}Input.disabled`, `${editor}Update.aria-busy`, + ])); + meter.reset(); element['render'](); + assert.deepEqual(meter.snapshot().writes, ['nameInput.value', 'acceptedName.textContent', 'roomNameInput.value', 'acceptedRoomName.textContent']); + }); + + for (const editor of ['name', 'roomName'] as const) it(`editor DOM guards evaluate ${editor} disabled and busy at their original setter boundaries`, async t => { + const f = await hubCloseWidget(t), element = f.element; + const panel = element[`${editor}Panel`], input = element[`${editor}Input`], button = element[`${editor}Update`]; + const attempt = editor === 'name' ? 'nameAttempt' : 'roomNameAttempt', calls: string[] = []; + let inspecting = true; + panel.hidden = true; input.disabled = true; button.disabled = false; button.setAttribute('aria-busy', 'corrupt'); + controlSetter(t, input as unknown as Node, 'disabled', () => { + if (!inspecting) return; + assert.equal(panel.hidden, false); calls.push('input'); element[attempt] = {}; + }); + controlSetter(t, button as unknown as Node, 'disabled', () => { + if (!inspecting) return; + assert.equal(input.disabled, false); calls.push('update'); element[attempt] = undefined; + }); + try { + element['render'](); + assert.deepEqual(calls, ['input', 'update']); + assert.equal(button.disabled, true, 'disabled observes the attempt created by the preceding input setter'); + assert.equal(button.getAttribute('aria-busy'), 'false', 'busy observes the attempt retired by the preceding update setter'); + assert.equal(element[attempt], undefined); + element['render'](); assert.equal(button.disabled, false); assert.equal(button.getAttribute('aria-busy'), 'false'); + } finally { inspecting = false; } + }); + +}); + +describe('widget room occupancy summary', () => { + type Widget = InstanceType; + const text = (room: number, stage: number, audience: number, controllerOnStage: boolean, maxAudience = 300, maxSpeakers = 8, freshness = '最近快照') => + `房間席位(${freshness}):主控以外 ${room} / ${maxAudience} 人,其中台上 ${stage} / ${maxSpeakers} 人、觀眾 ${audience} 人;主控另計 1 人(${controllerOnStage ? '台上' : '台下'})。含斷線保留席位。`; + const hidden = (element: Widget) => { assert.equal(element['occupancy'].hidden, true); assert.equal(element['occupancy'].textContent, ''); }; + const shown = (element: Widget, expected: string) => { assert.equal(element['occupancy'].hidden, false); assert.equal(element['occupancy'].textContent, expected); }; + function state(audience = 2, stage = 1, controllerOnStage = true, viewer: 'audience' | 'speaker' | 'controller' = 'audience'): RoomStatePayload { + const value = roomState(); value.audienceCount = audience; + value.speakers = Array.from({ length: stage }, (_, i) => ({ ...value.me, participantId: `speaker-${i}`, name: 'Private stage name', role: 'speaker', onStage: true, connected: i !== 0 })); + const host = { ...value.me, participantId: value.controllerId, name: 'Private controller', role: 'controller' as const, onStage: controllerOnStage }; + if (controllerOnStage) value.speakers.push(host); + if (viewer === 'controller') { value.me = host; value.audience = [{ ...roomState().me, name: 'SECRET audience '); + await writeFile(join(fixture.repositoryDirectory, 'web/dist/embed.html'), '

synthetic embed

'); + await writeFile(join(fixture.repositoryDirectory, 'web/dist/assets/app.js'), 'export const app = true;'); + await writeFile(join(fixture.repositoryDirectory, 'web/dist/assets/app.css'), 'body { color: red; }'); + for (const name of ['stage-client.js', 'stage-element.js']) await writeFile(join(fixture.repositoryDirectory, 'packages/client/dist', name), `export const name = ${JSON.stringify(name)};`); + try { await run(fixture); } + finally { + assert.deepEqual(await readdir(fixture.temporaryRoot), [], 'Delivery and alternate CWD must always be removed'); + try { + const report = JSON.parse(await readFile(fixture.reportPath, 'utf8')) as { pid: number; root: string; cwd: string; configPath: string }; + assert.throws(() => process.kill(report.pid, 0), { code: 'ESRCH' }, 'Owned server must be reaped on every outcome'); + await assert.rejects(readFile(report.configPath), { code: 'ENOENT' }); + } catch (error) { if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) throw error; } + assert.equal(await readFile(join(fixture.repositoryDirectory, 'config.yaml'), 'utf8'), 'This user config must never be parsed.'); + } + } finally { await rm(root, { recursive: true, force: true }); } +} +const linux = { skip: process.platform !== 'linux' }; + +it('rejects unsupported platforms explicitly before touching files or child processes', () => { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + const before = ['SIGINT', 'SIGTERM'].map(name => process.listenerCount(name)); + const { checkServerPackage } = await import(${JSON.stringify(pathToFileURL(checker).href)}); + assert.deepEqual(['SIGINT', 'SIGTERM'].map(name => process.listenerCount(name)), before); + for (const platform of ['darwin', 'win32']) { + Object.defineProperty(process, 'platform', { value: platform }); + await assert.rejects(checkServerPackage({ repositoryDirectory: '/must-not-read', temporaryRoot: '/must-not-write' }), /requires Linux.*Windows and macOS are not verified/); + } + `], { encoding: 'utf8', timeout: 5_000 }); + assert.equal(result.status, 0, result.stderr); +}); + +it('checks real HTTP/WS behavior of copied synthetic JavaScript without repository dist or user config', linux, async () => { + await withFixture(async fixture => { + const before = ['SIGINT', 'SIGTERM'].map(name => process.listenerCount(name)); + const saved = { NODE_OPTIONS: process.env.NODE_OPTIONS, NODE_PATH: process.env.NODE_PATH, STAGE_CONFIG: process.env.STAGE_CONFIG }; + try { + process.env.NODE_OPTIONS = '--import=/must-not-import.mjs'; + process.env.NODE_PATH = '/must-not-resolve'; + process.env.STAGE_CONFIG = '/must-not-read.yaml'; + await checkServerPackage(fixture); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; else process.env[key] = value; + } + } + assert.deepEqual(['SIGINT', 'SIGTERM'].map(name => process.listenerCount(name)), before); + }); +}); + +it('requires built files and refuses linked artifacts, with normal build guidance', linux, async t => { + for (const file of ['dist/src/index.js', 'dist/src/transport/codecWorker.js', 'dist/src/transport/mediaWorker.js', 'web/dist/index.html', 'packages/client/dist/stage-element.js']) { + await t.test(`missing ${file}`, () => withFixture(async fixture => { + await rm(join(fixture.repositoryDirectory, file)); + await assert.rejects(checkServerPackage(fixture), /artifacts are missing or invalid\. Run npm run build/); + })); + } + for (const file of ['package.json', 'dist/src/index.js', 'web/dist/assets/app.js', 'packages/client/dist']) { + await t.test(`linked ${file}`, () => withFixture(async fixture => { + const target = join(fixture.repositoryDirectory, file); + await rm(target, { recursive: true }); + await symlink(join(fixture.repositoryDirectory, 'src'), target); + await assert.rejects(checkServerPackage(fixture), /artifacts are missing or invalid\. Run npm run build/); + })); + } +}); + +it('blocks checkout imports and TypeScript fallbacks using actual Node resolution', linux, async t => { + for (const kind of ['absolute-esm', 'absolute-require', 'copied-typescript', 'workspace-link']) await t.test(kind, () => withFixture(async fixture => { + const entry = join(fixture.repositoryDirectory, 'dist/src/index.js'); + let prefix = ''; + if (kind === 'absolute-esm') prefix = `import ${JSON.stringify(pathToFileURL(join(fixture.repositoryDirectory, 'src/source.js')).href)};`; + if (kind === 'absolute-require') prefix = `import { createRequire } from 'node:module'; createRequire(import.meta.url)(${JSON.stringify(join(fixture.repositoryDirectory, 'src/source.js'))});`; + if (kind === 'copied-typescript') { + await writeFile(join(fixture.repositoryDirectory, 'dist/src/fallback.ts'), 'export {};'); + prefix = 'import "./fallback.ts";'; + } + if (kind === 'workspace-link') { + const modules = join(fixture.root, 'installed node_modules'); + await mkdir(modules); + await symlink(join(fixture.repositoryDirectory, 'src'), join(modules, 'source-link')); + await writeFile(join(fixture.repositoryDirectory, 'src/package.json'), '{"type":"module","main":"source.js"}'); + fixture.dependenciesDirectory = modules; + prefix = 'import "source-link";'; + // Avoid unrelated dependency resolution before the blocked workspace import. + await writeFile(entry, prefix); + } else await writeFile(entry, prefix + '\n' + await readFile(entry, 'utf8')); + await assert.rejects(checkServerPackage(fixture), /module escaped delivered JavaScript and installed dependencies/); + })); +}); + +it('bounds malformed startup, unexpected exit and output while reaping the child', linux, async t => { + const cases: [string, RegExp][] = [ + ['early-exit', /Server exited before checks completed.*exit 7/], + ['ready-then-exit', /Server exited before checks completed|ECONNRESET|ECONNREFUSED|socket hang up/], + ['never-ready', /Server startup timed out/], + ['malformed-log', /Malformed server JSON log/], + ['malformed-address', /Malformed server listening addresses/], + ['foreign-address', /expected loopback ephemeral port/], + ['startup-error', /Server emitted an error log/], + ['output-limit', /Server output exceeded/], + ['late-output-limit', /Server output exceeded/], + ]; + for (const [scenario, expected] of cases) await t.test(scenario, () => withFixture(async fixture => { + if (scenario === 'never-ready') fixture.startupTimeoutMs = 250; + await assert.rejects(checkServerPackage(fixture), error => { + assert.ok(error instanceof Error); assert.match(error.message, expected); + assert.ok(error.message.length < 8_200, 'Failure diagnostics are bounded'); + return true; + }); + }, scenario)); +}); + +it('bounds HTTP failures including trickling bodies and verifies exact asset bytes and MIME', linux, async t => { + const cases: [string, RegExp][] = [ + ['http-timeout', /HTTP \/healthz timed out/], ['http-trickle', /HTTP \/healthz timed out/], + ['http-reset', /socket hang up|ECONNRESET/], ['http-overflow', /response size limit/], + ['health-body', /healthz body/], ['not-ready', /readyz status/], ['wrong-mime', /MIME/], + ['wrong-bytes', /differs from delivered artifact bytes/], ['wrong-asset', /assets\/app.js differs from delivered artifact bytes/], + ]; + for (const [scenario, expected] of cases) await t.test(scenario, () => withFixture(async fixture => { + await assert.rejects(checkServerPackage(fixture), expected); + }, scenario)); +}); + +it('bounds WebSocket failures and checks the hello and real SIGTERM contracts', linux, async t => { + const cases: [string, RegExp][] = [ + ['ws-reject', /Unexpected server response: 404/], ['ws-handshake-timeout', /timed out|Opening handshake has timed out/], + ['ws-timeout', /WebSocket hello timed out/], ['ws-early-close', /WebSocket closed before hello/], + ['ws-malformed', /Malformed WebSocket hello/], ['ws-overflow', /Max payload size exceeded/], + ['wrong-hello', /WebSocket hello contract/], ['wrong-close', /WebSocket shutdown contract/], + ['nonzero-shutdown', /Server SIGTERM must exit 0/], ['ignored-shutdown', /Server SIGTERM shutdown timed out/], + ]; + for (const [scenario, expected] of cases) await t.test(scenario, () => withFixture(async fixture => { + await assert.rejects(checkServerPackage(fixture), expected); + }, scenario)); +}); + +async function waitForPendingHttp(fixture: Fixture): Promise { + const end = Date.now() + 3_000; + while (Date.now() < end) { + try { await readFile(fixture.reportPath + '.http'); return; } + catch (error) { if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')) throw error; } + await delay(20); + } + throw new Error('Synthetic server did not receive the pending HTTP request'); +} + +it('abort cancels pending HTTP and reaps the owned process without installing global handlers', linux, async () => { + await withFixture(async fixture => { + const controller = new AbortController(); + const pending = checkServerPackage({ ...fixture, signal: controller.signal, requestTimeoutMs: 5_000 }); + void pending.catch(() => {}); + try { + await waitForPendingHttp(fixture); + controller.abort(); + await assert.rejects(pending, /check aborted/); + } finally { controller.abort(); await pending.catch(() => {}); } + }, 'http-timeout'); +}); + +it('CLI interruption reaps its server and removes temporary delivery files', linux, async () => { + await withFixture(async fixture => { + const scripts = join(fixture.repositoryDirectory, 'scripts'); + await mkdir(join(scripts, 'fixtures/server-package'), { recursive: true }); + await copyFile(checker, join(scripts, 'check-server-package.ts')); + await copyFile(new URL('../scripts/fixtures/server-package/resolution-guard.mjs', import.meta.url), join(scripts, 'fixtures/server-package/resolution-guard.mjs')); + await symlink(dependenciesDirectory, join(fixture.repositoryDirectory, 'node_modules'), 'dir'); + const child = spawn(process.execPath, [join(scripts, 'check-server-package.ts')], { cwd: fixture.root, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, TMPDIR: fixture.temporaryRoot } }); + let output = ''; + child.stdout.on('data', chunk => { output += String(chunk); }); + child.stderr.on('data', chunk => { output += String(chunk); }); + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolveExit, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolveExit({ code, signal })); + }); + void exited.catch(() => {}); + const timer = setTimeout(() => { child.kill('SIGKILL'); }, 8_000); + try { + await waitForPendingHttp(fixture); + assert.ok(child.kill('SIGTERM')); + assert.deepEqual(await exited, { code: 1, signal: null }); + assert.match(output, /check aborted/); + } finally { + clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await exited; + } + }, 'http-timeout'); +}); diff --git a/test/stageRestart.test.ts b/test/stageRestart.test.ts index 46457f9..a83f6b8 100644 --- a/test/stageRestart.test.ts +++ b/test/stageRestart.test.ts @@ -28,6 +28,7 @@ const PROCESS_LINES = new Set([ 'kill -TERM "$pid"', 'while kill -0 "$pid" 2>/dev/null; do', 'kill -KILL "$pid" 2>/dev/null || true', + 'wait "$pid" 2>/dev/null || true', 'node "$CHECKER"', 'install 安裝依賴(npm ci)', 'run) check_node; ensure_config; exec node "$ENTRY" ;;', @@ -41,7 +42,7 @@ const PROCESS_LINES = new Set([ function assertMockableScript(script: string): void { for (const line of script.split('\n')) { const code = line.trim(); - if (!code.startsWith('#') && /\b(?:kill|sleep|node|nohup|npm|npx|exec|eval|env|setsid|bash|dash|sh|python|python3|perl|ruby|curl|wget)\b/.test(code)) { + if (!code.startsWith('#') && /\b(?:kill|sleep|wait|node|nohup|npm|npx|exec|eval|env|setsid|bash|dash|sh|python|python3|perl|ruby|curl|wget)\b/.test(code)) { assert.ok(PROCESS_LINES.has(code), `Unreviewed process operation: ${code}`); } } @@ -260,6 +261,7 @@ it('rejects unreviewed process wrappers, direct startup and checker arguments be assertMockableScript(SCRIPT); for (const replacement of [ 'command kill -TERM "$pid"', '/bin/kill -TERM "$pid"', 'builtin kill -TERM "$pid"', + 'command wait "$pid"', '/bin/wait "$pid"', 'builtin wait "$pid"', 'exec node "$ENTRY"', 'node "$ROOT/src/check-config.ts"', 'node "$CHECKER" "$cfg"', ]) { const mutated = SCRIPT.replace(' node "$CHECKER"', ` ${replacement}`); diff --git a/test/stageScript.test.ts b/test/stageScript.test.ts index d783cd6..0209f44 100644 --- a/test/stageScript.test.ts +++ b/test/stageScript.test.ts @@ -15,6 +15,7 @@ const MOCKED_CALLS = new Set([ 'kill -TERM "$pid"', 'while kill -0 "$pid" 2>/dev/null; do', 'kill -KILL "$pid" 2>/dev/null || true', + 'wait "$pid" 2>/dev/null || true', 'command -v node >/dev/null 2>&1 || die "找不到 node(需要 Node.js 24 以上)"', 'major=$(node -p \'process.versions.node.split(".")[0]\')', '[ "$major" -ge 24 ] || die "Node.js 版本為 $(node --version),需要 24 以上"', @@ -29,7 +30,7 @@ const MOCKED_CALLS = new Set([ function assertMockableScript(): void { for (const line of SCRIPT.split('\n')) { const code = line.trim(); - if (!code.startsWith('#') && /\b(?:kill|sleep|node|nohup)\b/.test(code)) { + if (!code.startsWith('#') && /\b(?:kill|sleep|wait|node|nohup)\b/.test(code)) { assert.ok(MOCKED_CALLS.has(code), `Unreviewed process operation: ${code}`); } } @@ -54,6 +55,7 @@ sleep() { printf 'sleep %s\n' "$*" >> "$TRACE" [ "$#" -eq 1 ] && [ "$1" = 1 ] || exit 93 } +wait() { printf 'wait %s\n' "$*" >> "$TRACE"; return 97; } # Simulate read errors independently of the runner's filesystem permissions. od() { if [ "$PID_READ_FAIL" = 1 ]; then diff --git a/test/stageStartFailure.test.ts b/test/stageStartFailure.test.ts new file mode 100644 index 0000000..b26919c --- /dev/null +++ b/test/stageStartFailure.test.ts @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const SCRIPT = readFileSync(new URL('../stage.sh', import.meta.url), 'utf8'); +const PROCESS_LINES = new Set([ + '用法:./stage.sh [args]', + 'command -v node >/dev/null 2>&1 || die "找不到 node(需要 Node.js 24 以上)"', + 'major=$(node -p \'process.versions.node.split(".")[0]\')', + '[ "$major" -ge 24 ] || die "Node.js 版本為 $(node --version),需要 24 以上"', + 'if kill -0 "$pid" 2>/dev/null; then', + 'cmd_install() { check_node; npm ci; }', + 'cmd_build() { check_node; npm run build; }', + 'nohup node "$ENTRY" >>"$LOG_FILE" 2>&1 &', + 'sleep 1', + 'kill -TERM "$pid"', + 'while kill -0 "$pid" 2>/dev/null; do', + 'kill -KILL "$pid" 2>/dev/null || true', + 'wait "$pid" 2>/dev/null || true', + 'node "$CHECKER"', + 'install 安裝依賴(npm ci)', + 'run) check_node; ensure_config; exec node "$ENTRY" ;;', + 'dev) check_node; ensure_config; exec npm run dev ;;', + 'test) check_node; npm run typecheck; npm test ;;', +]); + +// Only reviewed shell functions may launch, signal or join the synthetic child. +function assertMockableScript(script: string): void { + for (const line of script.split('\n')) { + const code = line.trim(); + if (!code.startsWith('#') && /\b(?:kill|sleep|wait|node|nohup|npm|npx|exec|eval|env|setsid|bash|dash|sh|python|python3|perl|ruby|curl|wget)\b/.test(code)) { + assert.ok(PROCESS_LINES.has(code), `Unreviewed process operation: ${code}`); + } + } +} + +// No sockets, rooms, peers, dependencies or service source are used here. +const ENTRY = ` +import { writeFileSync } from 'node:fs'; +process.on('SIGTERM', () => writeFileSync(process.env.TERM_SEEN, 'ignored\\n')); +writeFileSync(process.env.READY, String(process.pid)); +writeFileSync(process.env.CHILD_CWD, process.cwd()); +if (process.env.CHILD_MODE === 'exited') process.exit(23); +setTimeout(() => { + writeFileSync(process.env.WATCHDOG, 'expired\\n'); + process.exit(82); +}, 4_000); +`; + +const HARNESS = String.raw` +owned_pid= +known_exited=0 +reaped=0 +cleanup() { + result=$? + trap - EXIT + set +e + alive=0 + if [ -n "$owned_pid" ]; then + if [ "$known_exited" = 0 ] && command kill -0 "$owned_pid" 2>/dev/null; then alive=1; fi + printf '%s %s %s\n' "$result" "$alive" "$reaped" > "$OUTCOME" + # The trap owns this exact child and removes leaks even on a failed assertion. + if [ "$known_exited" = 0 ] && [ "$reaped" = 0 ]; then + command kill -KILL "$owned_pid" 2>/dev/null || true + command wait "$owned_pid" 2>/dev/null || true + fi + fi + exit "$result" +} +trap cleanup EXIT +node() { + [ "$#" -eq 2 ] && [ "$1" = -p ] && [ "$2" = 'process.versions.node.split(".")[0]' ] || exit 90 + printf '24\n' +} +nohup() { + [ "$#" -eq 2 ] && [ "$1" = node ] && [ "$2" = "$ROOT/dist/src/index.js" ] || exit 91 + exec "$REAL_NODE" "$2" +} +await_file() { + attempts=0 + while [ ! -f "$1" ]; do + attempts=$((attempts + 1)) + [ "$attempts" -le 200 ] || exit 92 + command sleep 0.01 + done +} +fixture_ready() { + [ "$#" -eq 1 ] && [ "$1" = "$!" ] && [ "$1" != "$UNRELATED_PID" ] || exit 93 + owned_pid=$1 + printf '%s\n' "$owned_pid" > "$OWNED_PID" + await_file "$READY" + [ "$(cat "$READY")" = "$owned_pid" ] || exit 94 + if [ "$CHILD_MODE" = exited ]; then + if command wait "$owned_pid"; then exit 95; else child_status=$?; fi + [ "$child_status" = 23 ] || exit 96 + known_exited=1 + else + # Prove TERM cannot end this child before testing the failed-start cleanup. + command kill -TERM "$owned_pid" + await_file "$TERM_SEEN" + fi +} +kill() { + [ "$#" -eq 2 ] && [ "$2" = "$owned_pid" ] && [ "$2" != "$UNRELATED_PID" ] || exit 97 + case "$1" in -0|-KILL) ;; *) exit 98 ;; esac + printf 'kill %s\n' "$*" >> "$TRACE" + # A known-reaped child must never become a real stale-PID signal in this fixture. + [ "$known_exited" = 0 ] || return 1 + command kill "$@" +} +wait() { + [ "$#" -eq 1 ] && [ "$1" = "$owned_pid" ] && [ "$1" != "$UNRELATED_PID" ] || exit 99 + printf 'wait %s\n' "$*" >> "$TRACE" + if command wait "$1"; then wait_status=0; else wait_status=$?; fi + reaped=1 + printf 'wait-result %s\n' "$wait_status" >> "$TRACE" + return "$wait_status" +} +. "$0" +`; + +type Failure = 'directory' | 'permissions' | 'full' | 'none'; +type Options = { script?: string; failure?: Failure; exited?: boolean }; + +function runStart(shell: string, { script = SCRIPT, failure = 'directory', exited = false }: Options = {}) { + assertMockableScript(script); + // Synchronize the owned child, leaving the production printf/cleanup untouched. + const capture = ' pid=$!\n'; + assert.equal(script.split(capture).length, 2, 'the readiness hook must have exactly one insertion point'); + const instrumented = script.replace(capture, `${capture} fixture_ready "$pid"\n`); + const root = mkdtempSync(join(tmpdir(), 'stage start failure ')); + const runDir = join(root, '.run'); + try { + const scriptPath = join(root, 'stage.sh'); + const pidFile = join(runDir, 'stage.pid'); + const trace = join(root, 'trace'); + const outcome = join(root, 'outcome'); + const ownedPid = join(root, 'owned.pid'); + const termSeen = join(root, 'term-seen'); + const watchdog = join(root, 'watchdog'); + mkdirSync(runDir); + mkdirSync(join(root, 'dist/src'), { recursive: true }); + mkdirSync(join(root, 'caller elsewhere')); + writeFileSync(scriptPath, instrumented); + writeFileSync(join(root, 'package.json'), '{"type":"module"}\n'); + writeFileSync(join(root, 'dist/src/index.js'), ENTRY); + writeFileSync(join(root, 'config.yaml'), 'synthetic: true\n'); + writeFileSync(join(runDir, 'stage.log'), ''); + writeFileSync(trace, ''); + if (failure === 'directory') { + mkdirSync(pidFile); + writeFileSync(join(pidFile, 'preserve-me'), 'operator data\n'); + } else if (failure === 'permissions') { + chmodSync(runDir, 0o555); + } else if (failure === 'full') { + symlinkSync('/dev/full', pidFile); + } + const result = spawnSync(shell, ['-c', HARNESS, scriptPath, 'start'], { + cwd: join(root, 'caller elsewhere'), encoding: 'utf8', timeout: 6_000, maxBuffer: 64 * 1024, + env: { + PATH: '/usr/bin:/bin', REAL_NODE: process.execPath, TRACE: trace, OUTCOME: outcome, + OWNED_PID: ownedPid, READY: join(root, 'ready'), TERM_SEEN: termSeen, + CHILD_CWD: join(root, 'child-cwd'), WATCHDOG: watchdog, CHILD_MODE: exited ? 'exited' : 'live', + UNRELATED_PID: String(process.pid), + }, + }); + assert.ifError(result.error); + assert.equal(result.signal, null, 'the fixture shell must finish within its bound'); + assert.ok(existsSync(ownedPid), result.stderr); + const pid = readFileSync(ownedPid, 'utf8').trim(); + assert.match(pid, /^[1-9][0-9]*$/); + assert.notEqual(pid, String(process.pid)); + assert.ok(existsSync(outcome), result.stderr); + assert.equal(readFileSync(join(root, 'child-cwd'), 'utf8'), root, 'start must resolve its own root despite spaces and alternate CWD'); + assert.equal(existsSync(watchdog), false, 'cleanup must not depend on the child lifetime limit'); + if (!exited) assert.equal(readFileSync(termSeen, 'utf8'), 'ignored\n'); + if (failure === 'directory') { + assert.ok(lstatSync(pidFile).isDirectory()); + assert.equal(readFileSync(join(pidFile, 'preserve-me'), 'utf8'), 'operator data\n'); + } else if (failure === 'permissions') { + assert.equal(existsSync(pidFile), false); + assert.equal(lstatSync(runDir).mode & 0o777, 0o555); + } else if (failure === 'full') { + assert.ok(lstatSync(pidFile).isSymbolicLink()); + assert.equal(readlinkSync(pidFile), '/dev/full'); + } else { + assert.equal(readFileSync(pidFile, 'utf8'), `${pid}\n`); + } + return { + ...result, pid, pidFile, + trace: readFileSync(trace, 'utf8').trim().split('\n').filter(Boolean), + outcome: readFileSync(outcome, 'utf8').trim(), + }; + } finally { + if (existsSync(runDir)) chmodSync(runDir, 0o755); + rmSync(root, { recursive: true, force: true }); + } +} + +it('rejects unreviewed startup process operations before creating the fixture', () => { + assertMockableScript(SCRIPT); + for (const operation of [ + 'command kill -KILL "$pid"', '/bin/kill -KILL "$pid"', 'kill -KILL "$UNRELATED_PID"', + 'command wait "$pid"', '/bin/wait "$pid"', 'builtin wait "$pid"', 'wait "$UNRELATED_PID"', + 'exec node "$ENTRY"', + ]) { + const changed = SCRIPT.replace(' pid=$!\n', ` pid=$!\n ${operation}\n`); + assert.notEqual(changed, SCRIPT); + assert.throws(() => assertMockableScript(changed), /Unreviewed process operation/); + } +}); + +for (const shell of ['/bin/dash', '/bin/bash']) { + const skip = process.platform === 'win32' ? 'stage.sh is POSIX-only' : !existsSync(shell) ? `${shell} is unavailable` : false; + describe(`stage.sh failed PID persistence (${shell})`, { skip }, () => { + it('proves the pre-fix write leaks a live child until the fixture cleans it up', () => { + const baseline = SCRIPT.replace(/ if ! printf '%s\\n' "\$pid" >"\$PID_FILE"; then\n[\s\S]*?^ fi\n/m, ' printf \'%s\\n\' "$pid" >"$PID_FILE"\n'); + assert.notEqual(baseline, SCRIPT, 'baseline mutation must remove exactly the failed-write cleanup'); + const result = runStart(shell, { script: baseline }); + assert.notEqual(result.status, 0); + assert.equal(result.outcome, `${result.status} 1 0`, 'the original child is alive and unjoined after failed start'); + assert.deepEqual(result.trace, []); + assert.doesNotMatch(result.stdout, /已啟動/); + }); + + for (const failure of ['directory', 'permissions', 'full'] as const) { + const unavailable = failure === 'permissions' && process.getuid?.() === 0 ? 'root bypasses fixture directory permissions' + : failure === 'full' && !existsSync('/dev/full') ? '/dev/full is unavailable' : false; + it(`kills and reaps only its TERM-ignoring child after ${failure} write failure`, { skip: unavailable }, () => { + const result = runStart(shell, { failure }); + assert.equal(result.status, 1); + assert.equal(result.outcome, '1 0 1', 'failure must be reported only after the child is gone and joined'); + assert.deepEqual(result.trace.slice(0, 2), [`kill -KILL ${result.pid}`, `wait ${result.pid}`]); + assert.equal(result.trace.length, 3); + assert.match(result.trace[2] ?? '', /^wait-result (?:137|265)$/); + assert.ok(result.stderr.includes(`stage: 無法寫入 PID 檔:${result.pidFile}\n`)); + assert.doesNotMatch(result.stdout, /已啟動/); + }); + } + + it('still reports PID-write failure when the owned child already exited', () => { + const result = runStart(shell, { exited: true }); + assert.equal(result.status, 1); + assert.equal(result.outcome, '1 0 1'); + assert.deepEqual(result.trace, [`kill -KILL ${result.pid}`, `wait ${result.pid}`, 'wait-result 23']); + assert.ok(result.stderr.includes(`stage: 無法寫入 PID 檔:${result.pidFile}\n`)); + assert.doesNotMatch(result.stdout, /已啟動/); + }); + + it('keeps successful start alive with its PID file and no failed-start signals', () => { + const result = runStart(shell, { failure: 'none' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.outcome, '0 1 0'); + assert.deepEqual(result.trace, [`kill -0 ${result.pid}`]); + assert.match(result.stdout, /已啟動/); + assert.equal(result.stderr, ''); + }); + }); +} diff --git a/test/webActions.test.ts b/test/webActions.test.ts index 2e9a5ca..9c7d299 100644 --- a/test/webActions.test.ts +++ b/test/webActions.test.ts @@ -48,6 +48,9 @@ function setup(): Harness { let renderedRoomSignature, renderedAudienceHeading, renderedSpeakingSignature, renderedQualitySignature; const renderedGainInputs = []; let audienceFilterIdentity = ''; const audienceFilterRows = []; const audienceFilter = {}, audienceSearch = { value: '' }, audienceFilterClear = {}, audienceFilterCount = {}; + let handFilterIdentity = '', handFilterDraft = '', handFilterGeneration = 0, handFilterRevision = 0; + let handFilterPainting = false, handFilterDirty = false, handFilterRows = []; + const handFilter = {}, handSearch = { value: '' }, handFilterClear = {}, handFilterCount = {}; let nextTimer = 0, clock = 0, callbacks = 0; const performance = { now: () => clock }; const timers = new Map(); @@ -60,7 +63,7 @@ function setup(): Harness { const visit = node => { if (node.dataset.roomAction) nodes.push(node); for (const child of node.children) visit(child); }; children.forEach(visit); } }; - const brand = {}, status = {}, connectionLine = { dataset: {}, children: [] }; + const brand = {}, status = {}, connectionLine = { dataset: {}, children: [], classList: { toggle() {} } }; const audioRetryNotice = { hidden: true, textContent: '', dataset: {}, children: [] }; const ownHandPosition = { hidden: true, textContent: '', dataset: {}, children: [] }; const statusLabels = {}, roleLabels = {}, createdRoom = null; @@ -77,7 +80,7 @@ function setup(): Harness { function resetMicSettings() {} function stopRoomTimers() {} const errorBox = { hidden: true }; - function element(tag, className, text) { return { textContent: text ?? '', classList: { toggle() {} }, isConnected: true, disabled: false, dataset: {}, attrs: {}, children: [], append(...children) { this.children.push(...children); }, setAttribute(k, v) { this.attrs[k] = v; } }; } + function element(tag, className, text) { return { textContent: text ?? '', classList: { toggle() {} }, isConnected: true, parentNode: null, disabled: false, dataset: {}, attrs: {}, children: [], append(...children) { for (const child of children) { child.parentNode = this; this.children.push(child); } }, setAttribute(k, v) { this.attrs[k] = v; } }; } function showError(error) { errors.push(error); } function syncMeter() {} ${actions} diff --git a/test/webAudienceFilter.test.ts b/test/webAudienceFilter.test.ts index 92d5c5a..e3719fe 100644 --- a/test/webAudienceFilter.test.ts +++ b/test/webAudienceFilter.test.ts @@ -74,7 +74,7 @@ document.createElement = ((tag: string) => { const reset = () => { counts.created = counts.replacements = 0; }; reset(); return { ...h, main, counts, storage, calls, effects, reset }; } -function search(h: App): SampleNode { return h.find(node => node.tagName === 'input' && node.type === 'search'); } +function search(h: App): SampleNode { return h.find(node => node.tagName === 'input' && node.type === 'search' && node.id === 'audience-search'); } function toolbar(h: App): SampleNode { let node = search(h); while (node.parentNode && node.parentNode !== h.app) node = node.parentNode; @@ -232,7 +232,7 @@ it('whole sample distinguishes empty audience from no matches without changing s const h = await setup(t), state = controllerState(); state.audienceCount = 9; h.sdk.snapshot(state); const stage = rowsIn(panel(h, '舞台 ·')), hands = rowsIn(panel(h, '舉手佇列 ·')); type(h, 'no result'); assertCount(h, 0, 5); assert.deepEqual(visibleIds(h), []); - assert.match(panel(h, '觀眾 ·').textContent, /觀眾 · 9\/300/); + assert.match(panel(h, '觀眾 ·').textContent, /觀眾 · 9 人/); assert.ok(h.app.descendants().some(node => !node.hidden && /沒有符合|找不到符合/.test(node.textContent) && node.children.length === 0), 'visible no-match message'); assert.equal([...stage, ...hands].every(row => !row.hidden), true); diff --git a/test/webBuildMounts.test.ts b/test/webBuildMounts.test.ts new file mode 100644 index 0000000..7eac344 --- /dev/null +++ b/test/webBuildMounts.test.ts @@ -0,0 +1,195 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { createServer, request, type IncomingHttpHeaders } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { extname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; +import { createStaticHandler } from '../src/http/static.ts'; + +function get(url: URL): Promise<{ status: number; headers: IncomingHttpHeaders; body: Buffer }> { + return new Promise((resolve, reject) => { + const req = request(url, { agent: false, headers: { 'accept-encoding': 'identity' } }, res => { + const chunks: Buffer[] = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('error', reject); + res.on('end', () => resolve({ status: res.statusCode!, headers: res.headers, body: Buffer.concat(chunks) })); + }); + const deadline = setTimeout(() => req.destroy(new Error(`Built web request timed out: ${url.href}`)), 5000); + req.on('close', () => clearTimeout(deadline)); + req.on('error', reject); + req.end(); + }); +} + +function attributes(tag: string): Record { + return Object.fromEntries([...tag.matchAll(/\b([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)] + .map(match => [match[1]!.toLowerCase(), match[2] ?? match[3]!])); +} + +function moduleSpecifiers(node: unknown, found = new Set()): Set { + if (node === null || typeof node !== 'object') return found; + if (Array.isArray(node)) { + for (const child of node) moduleSpecifiers(child, found); + return found; + } + const record = node as Record; + if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration', 'ImportExpression'].includes(String(record.type))) { + const source = record.source as { value?: unknown } | null | undefined; + // The embed page's user-selected SDK host is a runtime expression, not a built chunk. + if (typeof source?.value === 'string') found.add(source.value); + } + for (const child of Object.values(record)) moduleSpecifiers(child, found); + return found; +} + +describe('production web build at configured static mounts', () => { + const webRoot = fileURLToPath(new URL('../web/', import.meta.url)); + const files = new Map(); + const imports = new Map>(); + let outputDir: string | undefined; + + before(async () => { + outputDir = await mkdtemp(join(tmpdir(), 'stage-web-build-mounts-')); + // Keep the real production config, especially its base, while isolating all build output. + await build({ + root: webRoot, + configFile: join(webRoot, 'vite.config.ts'), + configLoader: 'native', + logLevel: 'silent', + build: { outDir: outputDir, emptyOutDir: true }, + plugins: [{ + name: 'inspect-built-module-references', + enforce: 'post', + generateBundle(_options, bundle) { + for (const output of Object.values(bundle)) { + if (output.type === 'chunk') imports.set(output.fileName, moduleSpecifiers(this.parse(output.code))); + } + }, + }], + }); + for (const name of await readdir(outputDir, { recursive: true })) { + if (['.html', '.js', '.mjs', '.css'].includes(extname(name))) { + files.set(name.split(sep).join('/'), await readFile(join(outputDir, name))); + } + } + assert.ok(files.has('index.html')); + assert.ok(files.has('embed.html')); + assert.ok(imports.size > 0, 'the real build must emit JavaScript chunks'); + }); + + after(async () => { + if (outputDir) await rm(outputDir, { recursive: true, force: true }); + }); + + for (const mount of ['/', '/demo/', '/nested/demo/']) { + describe(`mounted only at ${mount}`, () => { + let server: ReturnType | undefined; + let origin: string; + + before(async () => { + assert.ok(outputDir); + // A root fallback would hide broken /assets URLs on either prefixed deployment. + const serve = createStaticHandler([{ mount, dir: outputDir, cors: false }], outputDir); + server = createServer((req, res) => { + void serve(req, res).then(found => { if (!found) res.writeHead(404).end(); }) + .catch(() => res.writeHead(500).end()); + }); + const listener = server; + await new Promise((resolve, reject) => { + listener.once('error', reject); + listener.listen(0, '127.0.0.1', resolve); + }); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + after(async () => { + if (server) { + const closed = new Promise(resolve => server!.close(() => resolve())); + server.closeAllConnections(); + await closed; + } + }); + + async function verifyDocument(pageUrl: URL, fileName: string): Promise { + const page = await get(pageUrl); + assert.equal(page.status, 200, pageUrl.href); + assert.equal(page.headers['content-type'], 'text/html; charset=utf-8'); + assert.deepEqual(page.body, files.get(fileName), pageUrl.href); + const html = page.body.toString(); + const baseTag = html.match(/]*>/i)?.[0]; + const baseHref = baseTag ? attributes(baseTag).href : undefined; + const documentBase = baseHref === undefined ? pageUrl : new URL(baseHref, pageUrl); + const references: Array<{ url: URL; type: 'script' | 'modulepreload' | 'stylesheet' }> = []; + for (const match of html.matchAll(/<(script|link)\b[^>]*>/gi)) { + const attr = attributes(match[0]); + if (match[1]!.toLowerCase() === 'script' && attr.type === 'module') { + assert.ok(attr.src, `${pageUrl.href}: emitted module entry must have a source`); + references.push({ url: new URL(attr.src, documentBase), type: 'script' }); + } else if (attr.rel === 'modulepreload' || attr.rel === 'stylesheet') { + assert.ok(attr.href, `${pageUrl.href}: emitted ${attr.rel} must have a URL`); + references.push({ url: new URL(attr.href, documentBase), type: attr.rel }); + } + } + assert.ok(references.some(ref => ref.type === 'script'), `${pageUrl.href}: module entry missing`); + assert.ok(references.some(ref => ref.type === 'stylesheet'), `${pageUrl.href}: stylesheet missing`); + const visited = new Set(); + + async function verifyAsset(url: URL, expectedType: 'script' | 'modulepreload' | 'stylesheet'): Promise { + assert.equal(url.origin, origin, 'built assets must stay on their serving origin'); + if (visited.has(url.href)) return; + visited.add(url.href); + const response = await get(url); + assert.equal(response.status, 200, `${pageUrl.pathname} references unreachable ${url.pathname}`); + assert.ok(url.pathname.startsWith(mount), `${url.pathname} escaped ${mount}`); + const name = decodeURIComponent(url.pathname.slice(mount.length)); + const bytes = files.get(name); + assert.ok(bytes, `${url.pathname} must identify a real emitted file`); + assert.equal(response.headers['content-type'], expectedType === 'stylesheet' + ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8', url.pathname); + assert.equal(response.headers['x-content-type-options'], 'nosniff'); + assert.deepEqual(response.body, bytes, url.pathname); + if (expectedType !== 'stylesheet') { + assert.ok(imports.has(name), `${name} must be a parsed emitted chunk`); + for (const specifier of imports.get(name)!) { + assert.match(specifier, /^(?:\.{1,2}\/|\/)/, `${name}: unexpected bare or external module ${specifier}`); + await verifyAsset(new URL(specifier, url), 'script'); + } + } + } + + for (const reference of references) await verifyAsset(reference.url, reference.type); + if (mount !== '/') { + for (const reference of references) { + const rootAsset = new URL(reference.url.pathname.slice(mount.length), `${origin}/`); + assert.equal((await get(rootAsset)).status, 404, 'root assets must not mask a broken prefixed build'); + } + } + } + + for (const page of ['', 'index.html', 'embed.html']) { + it(`serves ${page || 'the main directory entry'} and its actual emitted asset graph`, async () => { + await verifyDocument(new URL(`${mount}${page}?deployment=mount-test`, origin), page || 'index.html'); + }); + } + + if (mount !== '/') { + it('preserves the full query through the canonical 308 and loads assets at the followed URL', async () => { + const query = '?next=%2Froom%3Fx%3D1&plus=a+b&empty=&again=1&again=2'; + const initialUrl = new URL(`${mount.slice(0, -1)}${query}`, origin); + const redirect = await get(initialUrl); + assert.equal(redirect.status, 308); + assert.equal(redirect.body.length, 0); + assert.ok(redirect.headers.location); + const canonicalUrl = new URL(redirect.headers.location, initialUrl); + assert.equal(canonicalUrl.origin, origin); + assert.equal(canonicalUrl.pathname, mount); + assert.equal(canonicalUrl.search, query); + await verifyDocument(canonicalUrl, 'index.html'); + }); + } + }); + } +}); diff --git a/test/webEmbedConnectionStats.test.ts b/test/webEmbedConnectionStats.test.ts new file mode 100644 index 0000000..ca4c1e4 --- /dev/null +++ b/test/webEmbedConnectionStats.test.ts @@ -0,0 +1,566 @@ +/// +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { StageClient } from '../packages/client/src/index.ts'; +import type { ParticipantView, RoomStatePayload } from '../shared/protocol.ts'; +import { EmbedNode, embedApp, flush } from './fixtures/embedApp.ts'; +import { actualApp, actualState, StagePeer, StageSocket, StageContext } from './fixtures/embedSdkApp.ts'; + +// Execute the complete production entry with the real SDK; replace only browser/external-host boundaries. +type App = Awaited>; +type Entry = Record; +const INITIAL = '尚未手動讀取連線統計。'; +const FAILED = '無法讀取連線統計,請稍後手動再試。'; +const RETAINED = '本次讀取失敗,仍顯示上次手動讀取結果。'; +const report = (...entries: Entry[]) => new Map(entries.map(entry => [String(entry.id), entry])) as unknown as RTCStatsReport; +const inbound = (timestamp = 1000, received = 100, lost = 0, delay = 2, emitted = 100): Entry => + ({ id: 'in', type: 'inbound-rtp', kind: 'audio', timestamp, bytesReceived: timestamp, + packetsReceived: received, packetsLost: lost, jitterBufferDelay: delay, jitterBufferEmittedCount: emitted }); +const pair = { id: 'pair', type: 'candidate-pair', nominated: true, state: 'succeeded', currentRoundTripTime: .043 }; +const outbound = { id: 'out', type: 'outbound-rtp', kind: 'audio', timestamp: 1000, bytesSent: 300, remoteId: 'remote' }; +const remote = { id: 'remote', type: 'remote-inbound-rtp', fractionLost: .125, roundTripTime: .1 }; +const stats = (rtt = .04) => ({ inbound: { lossPercent: 4, rtt, bufferMs: 20 }, outbound: { lossPercent: 12.5, rtt: .09 } }); +const text = (h: App) => h.node('connection-stats-text').textContent; +const refresh = (h: App) => h.node('connection-stats-update').invoke(); +function state(role: ParticipantView['role'] = 'audience', onStage = false): RoomStatePayload { + const s = actualState({ role, onStage }); s.controllerId = role === 'controller' ? s.me.participantId : 'host'; + const controller = { ...s.me, participantId: s.controllerId, role: 'controller' as const, onStage: true }; + s.speakers = role === 'controller' ? (onStage ? [{ ...s.me }] : []) : [controller, ...(onStage ? [{ ...s.me }] : [])]; + s.audienceCount = role === 'audience' && !onStage ? 1 : 0; return s; +} +function hello(socket: StageSocket) { + socket.deliver({ type: 'hello', protocol: 1, serverVersion: 'test', limits: { controlPerSecond: 2000, icePerSecond: 2000, handRaiseIntervalMs: 10000 } }); +} +async function join(h: App, s = state()) { + h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'join request'); + const request = socket.sent.find(m => m.type === 'join')!; + socket.deliver({ type: 'room:state', ...s }); socket.deliver({ type: 'ok', requestId: request.requestId }); + await h.until(() => StagePeer.instances.at(-1)?.localDescription !== null && StagePeer.instances.at(-1)?.signalingState === 'stable', 'negotiation'); + await h.tick(20); return { socket, peer: StagePeer.instances.at(-1)!, client: h.clients.at(-1)! as StageClient }; +} +async function setup(t: TestContext, s = state(), options: Parameters[1] = {}) { + const h = await actualApp(t, options); return { h, ...await join(h, s) }; +} +function samples(peer: StagePeer, get: () => Promise) { Object.defineProperty(peer, 'getStats', { configurable: true, value: get }); } +function fake(client: StageClient, get: () => unknown) { Object.defineProperty(client, 'getStats', { configurable: true, value: get }); } +function busy(h: App, value: boolean): void { + assert.equal(h.node('connection-stats-update').disabled, value); + assert.equal(h.node('connection-stats-update').getAttribute('aria-busy'), String(value)); +} +function hidden(h: App): void { + assert.equal(h.node('connection-stats-panel').hidden, true); assert.equal(text(h), ''); + assert.equal(h.node('connection-stats-update').disabled, true); + assert.equal(h.node('connection-stats-update').getAttribute('aria-busy'), 'false'); +} +function media(h: App) { + return { captures: h.captures.length, plays: h.audio.plays, pauses: h.audio.pauses, peers: StagePeer.instances.length, + contexts: StageContext.instances.length, enumerations: h.enumerations.length, sinks: h.sinks.length, + stops: h.tracks.map(track => track.stops), offers: StagePeer.instances.map(peer => peer.offers.length), + replacements: StagePeer.instances.map(peer => peer.replacements.length) }; +} +function unaffected(h: App) { + return ['status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'last-error', 'error-notice', + 'close-room', 'stage-management-list', 'mic-retry', 'audio-retry-status'].map(id => { + const n = h.node(id); return [id, n.textContent, n.hidden, n.disabled, { ...n.attributes }]; + }); +} +function setter(node: EmbedNode, property: 'hidden' | 'disabled' | 'textContent', action: () => void, late = false) { + let value: unknown = node[property], armed = true; + Object.defineProperty(node, property, { configurable: true, get: () => value, set(next: unknown) { + if (!late) value = next; + if (armed) { armed = false; action(); } + if (late) value = next; + } }); +} +function transfer(h: App): EmbedNode { + const button = h.node('stage-management-list').children[0]?.children[1]?.children[2]; assert.ok(button); return button; +} +async function controller(t: TestContext, options: Parameters[1] = {}) { + const s = state('controller'); s.speakers.push({ ...s.me, participantId: 'target', role: 'speaker', onStage: true }); + return setup(t, s, options); +} + +it('embed authors one ordinary independent panel and explicit native button, without an automatic read', async t => { + const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); + for (const id of ['connection-stats-panel', 'connection-stats-update', 'connection-stats-text']) assert.equal(html.match(new RegExp(`id="${id}"`, 'g'))?.length, 1); + const authored = html.match(/
/)![0]; + assert.match(authored, /]*type="button"[^>]*disabled>更新我的連線統計<\/button>/); + assert.match(authored, /aria-describedby="connection-stats-text"/); assert.doesNotMatch(authored, /role=|aria-live=|innerHTML/); + const { h, client, peer } = await setup(t); let reads = 0; samples(peer, async () => { reads++; return report(inbound()); }); + assert.equal(text(h), INITIAL); busy(h, false); + for (let i = 0; i < 4; i++) { + client.dispatchEvent(new CustomEvent('state', { detail: client.state })); + client.dispatchEvent(new CustomEvent('quality', { detail: { participants: [] } })); + client.dispatchEvent(new CustomEvent('speaking', { detail: { participantIds: [] } })); + client['setStatus']('waiting'); client['setStatus']('live'); await h.tick(200); + } + assert.equal(reads, 0); assert.equal(text(h), INITIAL); refresh(h); await flush(); assert.equal(reads, 1); +}); +for (const role of ['audience', 'speaker', 'controller'] as const) for (const onStage of [false, true]) + for (const active of ['waiting', 'live'] as const) it(`actual SDK manual ${role}/${onStage}/${active} read adds no commands, timers, media or old UI changes`, async t => { + const s = state(role, onStage); s.me.connected = false; s.me.muted = s.me.forceMuted = true; + const { h, client, peer, socket } = await setup(t, s); client['setStatus'](active); let calls = 0; + samples(peer, async () => { calls++; return report(inbound(), pair, ...(onStage ? [outbound, remote] : [])); }); + const oldUi = unaffected(h), oldMedia = media(h), frames = [...socket.sent]; + const timeout = t.mock.method(globalThis, 'setTimeout'), interval = t.mock.method(globalThis, 'setInterval'); + refresh(h); await flush(); assert.equal(calls, 1); assert.match(text(h), /接收掉包 —% · 接收 RTT 43 ms · 接收緩衝 — ms/); + assert.match(text(h), onStage ? /傳送掉包 12.5% · 傳送 RTT 100 ms/ : /傳送:暫無統計/); + assert.match(text(h), /上次手動讀取(SDK 取樣區間)/); assert.match(text(h), /RTT 不是端到端音訊延遲/); + assert.deepEqual(unaffected(h), oldUi); assert.deepEqual(media(h), oldMedia); assert.deepEqual(socket.sent, frames); + assert.equal(timeout.mock.callCount(), 0); assert.equal(interval.mock.callCount(), 0); + }); +it('actual SDK delta units use shared sampling baseline and last matching audio RTP, never aggregate streams', async t => { + const { h, peer, client } = await setup(t); let entries = report(inbound(), pair); samples(peer, async () => entries); + refresh(h); await flush(); + entries = report(inbound(2000, 119, 1, 2.4, 120), pair); refresh(h); await flush(); + assert.match(text(h), /接收掉包 5.0% · 接收 RTT 43 ms · 接收緩衝 20 ms/); + entries = report(inbound(3000, 219, 1, 4.4, 220), pair); await client.getStats(); + entries = report(inbound(4000, 318, 2, 4.9, 320), pair); refresh(h); await flush(); + assert.match(text(h), /接收掉包 1.0% · 接收 RTT 43 ms · 接收緩衝 5 ms/); + entries = report(inbound(5000), { ...inbound(5000), id: 'last-in', remoteId: 'last-remote' }, pair, + { id: 'last-remote', type: 'remote-outbound-rtp', roundTripTime: .222 }, outbound, remote, + { ...outbound, id: 'last-out', remoteId: 'last-out-remote' }, { ...remote, id: 'last-out-remote', fractionLost: .5 }, + { id: 'video', type: 'inbound-rtp', kind: 'video' }); + refresh(h); await flush(); assert.match(text(h), /接收 RTT 222 ms/); assert.match(text(h), /傳送掉包 50.0%/); +}); +it('actual SDK missing audio, partial and repeated/reset counters never invent zero loss', async t => { + const { h, peer } = await setup(t); let entries = report(); samples(peer, async () => entries); + refresh(h); await flush(); assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); + entries = report({ id: 'video', type: 'inbound-rtp', kind: 'video' }); refresh(h); await flush(); assert.match(text(h), /接收:暫無統計/); + entries = report({ id: 'in', type: 'inbound-rtp', kind: 'audio' }); refresh(h); await flush(); assert.match(text(h), /接收掉包 —% · 接收 RTT — ms · 接收緩衝 — ms/); + for (const stamp of [1000, 1000, 500]) { entries = report(inbound(stamp)); refresh(h); await flush(); assert.match(text(h), /接收掉包 —%/); } + entries = report(inbound(2000, 1, 0, 0, 1)); refresh(h); await flush(); assert.match(text(h), /接收掉包 —%/); +}); +it('actual state before ACK and peer creation permits an explicitly empty manual result', async t => { + const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'join'); + const client = h.clients[0] as StageClient; + client.on('state', () => { assert.equal(StagePeer.instances.length, 0); refresh(h); }); + socket.deliver({ type: 'room:state', ...state() }); + await flush(); assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); + socket.deliver({ type: 'ok', requestId: socket.sent.find(m => m.type === 'join')!.requestId }); await h.tick(20); +}); +for (const invalid of [undefined, null, '1', -1, NaN, Infinity, -Infinity, {}, new Number(1), 101]) + it(`projects only numeric primitives without coercion: ${String(invalid)}`, async t => { + const { h, client } = await setup(t); + fake(client, async () => ({ inbound: { lossPercent: invalid, rtt: invalid, bufferMs: invalid }, outbound: { lossPercent: invalid, rtt: invalid } })); + refresh(h); await flush(); assert.match(text(h), /接收掉包 —%/); assert.match(text(h), /傳送掉包 —%/); + if (invalid !== 101) assert.match(text(h), /接收 RTT — ms · 接收緩衝 — ms;傳送掉包 —% · 傳送 RTT — ms/); + assert.doesNotMatch(text(h), /NaN|Infinity|\[object|失敗/); + }); +it('preserves zero, rounds bounded percentages and milliseconds, and rejects conversion overflow', async t => { + const { h, client } = await setup(t); let raw: unknown = { inbound: { lossPercent: 0, rtt: 0, bufferMs: 0 }, outbound: { lossPercent: 100, rtt: 0 } }; + fake(client, async () => raw); refresh(h); await flush(); assert.match(text(h), /接收掉包 0.0% · 接收 RTT 0 ms · 接收緩衝 0 ms;傳送掉包 100.0% · 傳送 RTT 0 ms/); + raw = { inbound: { lossPercent: 1.26, rtt: .0126, bufferMs: 20.6 }, outbound: { lossPercent: 2.34, rtt: Number.MAX_VALUE } }; + refresh(h); await flush(); assert.match(text(h), /接收掉包 1.3% · 接收 RTT 13 ms · 接收緩衝 21 ms;傳送掉包 2.3% · 傳送 RTT — ms/); +}); +for (const malformed of [null, undefined, false, 42, 'private', [], { inbound: [] }, { outbound: 1 }, { inbound: () => 1 }]) + it(`malformed envelope/direction retains historical result: ${JSON.stringify(malformed)}`, async t => { + const { h, client } = await setup(t); let raw: unknown = stats(); fake(client, async () => raw); refresh(h); await flush(); + const historical = text(h); raw = malformed; refresh(h); await flush(); assert.equal(text(h), historical + RETAINED); busy(h, false); + }); +for (const [direction, field] of [['root', 'inbound'], ['root', 'outbound'], ['inbound', 'lossPercent'], ['inbound', 'rtt'], ['inbound', 'bufferMs'], ['outbound', 'lossPercent'], ['outbound', 'rtt']] as const) + it(`throwing used getter ${direction}.${field} produces only fixed local feedback`, async t => { + const { h, client } = await setup(t), raw = stats(), secret = { toString() { assert.fail('exception must not be coerced'); } }; + Object.defineProperty(direction === 'root' ? raw : raw[direction], field, { get() { throw secret; } }); + const old = unaffected(h); fake(client, async () => raw); refresh(h); await flush(); assert.equal(text(h), FAILED); assert.deepEqual(unaffected(h), old); busy(h, false); + }); +it('copies only five metrics, never reads RTC raw/private fields, and ignores later result mutation', async t => { + const { h, client } = await setup(t), raw = stats(); + for (const object of [raw, raw.inbound, raw.outbound]) for (const key of ['address', 'mimeType', 'jitter', 'bitrateKbps', 'packetsLost', 'timestamp', 'ssrc', 'token', 'toString']) + Object.defineProperty(object, key, { get() { assert.fail(`unused ${key}`); } }); + fake(client, async () => raw); refresh(h); await flush(); const historical = text(h); + raw.inbound.rtt = 999; raw.outbound.lossPercent = 88; client['setStatus']('waiting'); assert.equal(text(h), historical); + assert.equal((historical.match(/掉包| RTT \d|緩衝/g) ?? []).length, 5); +}); +it('busy and failure retain history; successful empty read replaces it without changing old error/quality/speaking', async t => { + const { h, client, socket } = await setup(t, state('speaker', true)); const work = Promise.withResolvers(); let calls = 0; + fake(client, () => ++calls === 1 ? Promise.resolve(stats()) : work.promise); + socket.deliver({ type: 'error', code: 'conflict', message: '既有操作失敗。' }); + socket.deliver({ type: 'quality', participants: [{ participantId: 'guest', rttMs: 77 }] }); socket.deliver({ type: 'speaking', participantIds: [] }); + const old = unaffected(h); refresh(h); await flush(); const historical = text(h); refresh(h); busy(h, true); assert.equal(text(h), historical); + refresh(h); h.node('connection-stats-update').disabled = false; refresh(h); assert.equal(calls, 2); busy(h, true); + work.reject({ message: 'PRIVATE_ADDRESS_TOKEN' }); await flush(); assert.equal(text(h), historical + RETAINED); assert.deepEqual(unaffected(h), old); + fake(client, async () => ({})); refresh(h); await flush(); assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); assert.doesNotMatch(text(h), /失敗|40 ms/); +}); +for (const mode of ['missing', 'noncallable', 'sync', 'reject', 'getter']) + it(`missing or failing public capability (${mode}) has fixed local feedback and permits explicit retry`, async t => { + const { h, client } = await setup(t); + Object.defineProperty(client, 'getStats', { configurable: true, ...(mode === 'getter' ? { get() { throw 'PRIVATE'; } } + : { value: mode === 'missing' ? undefined : mode === 'noncallable' ? 1 : () => { if (mode === 'sync') throw 'PRIVATE'; return Promise.reject('PRIVATE'); } }) }); + refresh(h); await flush(); assert.equal(text(h), FAILED); busy(h, false); + fake(client, async () => stats()); refresh(h); await flush(); assert.match(text(h), /40 ms/); + }); +it('reserves before method getter, method call, and thenable assimilation can synthetically reclick', async t => { + const { h, client } = await setup(t); let getters = 0, calls = 0, thenReads = 0; + const again = () => { h.node('connection-stats-update').disabled = false; refresh(h); }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + getters++; again(); return () => { calls++; again(); return { get then() { thenReads++; again(); return (resolve: (value: unknown) => void) => resolve(stats()); } }; }; + } }); + refresh(h); await flush(); assert.deepEqual([getters, calls, thenReads], [1, 1, 1]); busy(h, false); assert.match(text(h), /40 ms/); +}); +for (const boundary of ['getter', 'call', 'inbound', 'outbound', 'inbound.lossPercent', 'inbound.rtt', 'inbound.bufferMs', 'outbound.lossPercent', 'outbound.rtt']) + it(`${boundary} reentry cannot publish stale projection or release a newer attempt`, async t => { + const { h, client } = await setup(t), newer = Promise.withResolvers(); let entered = false, calls = 0; + const replace = () => { entered = true; client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(h); }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + if (boundary === 'getter' && !entered) { replace(); return () => assert.fail('retired getter cannot be invoked'); } + return () => { + calls++; if (entered) return newer.promise; + if (boundary === 'call') replace(); + const raw = stats(); + if (boundary !== 'call' && boundary !== 'getter') { + const [direction, field] = boundary.split('.'); const object = field ? Reflect.get(raw, direction!) : raw; + const key = field ?? direction!, value = Reflect.get(object, key); + Object.defineProperty(object, key, { get() { replace(); return value; } }); + } + return Promise.resolve(raw); + }; + } }); + refresh(h); await flush(); assert.ok(entered); assert.equal(calls, boundary === 'getter' ? 1 : 2); busy(h, true); assert.equal(text(h), INITIAL); + newer.resolve(stats(.123)); await flush(); busy(h, false); assert.match(text(h), /123 ms/); assert.doesNotMatch(text(h), /40 ms/); + }); +for (const cause of ['reconnecting', 'disconnected', 'closed', 'kicked', 'room', 'participant', 'leave', 'new-entry']) + for (const outcome of ['resolve', 'reject']) it(`${cause} retires history and ignores late ${outcome}`, async t => { + const { h, client } = await setup(t), old = Promise.withResolvers(); let calls = 0; + fake(client, () => ++calls === 1 ? Promise.resolve(stats()) : old.promise); refresh(h); await flush(); refresh(h); busy(h, true); + if (cause === 'reconnecting' || cause === 'disconnected') client['setStatus'](cause); + else if (cause === 'closed' || cause === 'kicked') client.dispatchEvent(new CustomEvent(cause, { detail: { reason: 'closed' } })); + else if (cause === 'room' || cause === 'participant') { + if (cause === 'room') client.state!.roomId = 'another-room'; else client.state!.me.participantId = 'another-seat'; + client['setStatus']('waiting'); + } else if (cause === 'leave') h.leave.click(); else h.form.submit(); + await flush(); if (outcome === 'resolve') old.resolve(stats(.999)); else old.reject('PRIVATE'); await flush(); + assert.doesNotMatch(text(h), /40 ms|999|失敗/); + if (cause === 'room' || cause === 'participant') { assert.equal(text(h), INITIAL); busy(h, false); } + else hidden(h); + }); +it('reconnect retains its captured binding and permits fresh manual work without a distinct state; old finally cannot unlock it', async t => { + const { h, client } = await setup(t), old = Promise.withResolvers(), newer = Promise.withResolvers(); let calls = 0; + fake(client, () => ++calls === 1 ? old.promise : newer.promise); const snapshot = client.state; + refresh(h); client['setStatus']('reconnecting'); hidden(h); client['setStatus']('live'); + assert.equal(client.state, snapshot); assert.equal(calls, 1); assert.equal(text(h), INITIAL); refresh(h); busy(h, true); + old.reject('PRIVATE'); await flush(); busy(h, true); assert.equal(text(h), INITIAL); + newer.resolve(stats(.073)); await flush(); busy(h, false); assert.match(text(h), /73 ms/); assert.equal(calls, 2); +}); +it('actual socket reconnect retains peer; stale native completion advances shared SDK baseline but cannot publish retired history', async t => { + const original = state(), { h, socket, peer, client } = await setup(t, original); let entries = report(inbound()); + samples(peer, async () => entries); refresh(h); await flush(); + const old = Promise.withResolvers(); samples(peer, () => old.promise); refresh(h); + socket.close(); hidden(h); assert.equal(peer.connectionState, 'connected'); await h.tick(500); + const resumed = StageSocket.instances.at(-1)!; hello(resumed); await h.until(() => resumed.sent.some(m => m.type === 'join'), 'resume'); + resumed.deliver({ type: 'room:state', ...original }); resumed.deliver({ type: 'ok', requestId: resumed.sent.find(m => m.type === 'join')!.requestId }); await h.tick(20); + assert.equal(client.status, 'live'); assert.equal(StagePeer.instances.length, 1); assert.equal(text(h), INITIAL); + old.resolve(report(inbound(2000, 190, 10, 4, 200))); await flush(); assert.equal(text(h), INITIAL); + entries = report(inbound(3000, 290, 10, 6, 300)); samples(peer, async () => entries); refresh(h); await flush(); + assert.match(text(h), /接收掉包 0.0%/); +}); +it('actual newer external completion suppresses older UI native result without failure or aggregate deltas', async t => { + const { h, peer, client } = await setup(t), old = Promise.withResolvers(); let count = 0; + samples(peer, () => ++count === 1 ? old.promise : Promise.resolve(report(inbound(2000)))); + refresh(h); await client.getStats(); old.resolve(report(inbound(1000))); await flush(); + assert.match(text(h), /接收:暫無統計;傳送:暫無統計/); assert.doesNotMatch(text(h), /失敗/); busy(h, false); +}); +for (const outcome of ['resolve', 'reject']) it(`new legitimate client reads despite unresolved old peer and shared-audio sink; late ${outcome} cannot change new stats`, async t => { + const { h, client, peer } = await setup(t, state(), { output: true }), old = Promise.withResolvers(); samples(peer, () => old.promise); + refresh(h); h.node('output-apply').click(); assert.equal(h.sinks.length, 1); + h.leave.click(); await flush(); const newer = await join(h); assert.notEqual(newer.client, client); assert.notEqual(newer.peer, peer); + assert.equal(client['audio'], newer.client['audio']); assert.equal(h.node('output-apply').disabled, true); + let entries = report(inbound(2000, 190, 10, 4, 200), pair); samples(newer.peer, async () => entries); refresh(h); await flush(); + assert.match(text(h), /接收掉包 —% · 接收 RTT 43 ms · 接收緩衝 — ms/); const historical = text(h); + if (outcome === 'resolve') old.resolve(report(inbound(9000, 9000, 90))); else old.reject('PRIVATE'); await flush(); + assert.equal(text(h), historical); busy(h, false); entries = report(inbound(3000, 290, 10, 6, 300)); refresh(h); await flush(); assert.match(text(h), /接收掉包 0.0%/); + h.sinks[0]!.work.resolve(); await flush(); +}); +for (const terminal of ['disconnected', 'closed', 'kicked', 'leave']) it(`${terminal} permanently retires embed binding even when actual same client directly rejoins`, async t => { + const { h, client, peer } = await setup(t); samples(peer, async () => report(inbound(), pair)); refresh(h); await flush(); + if (terminal === 'disconnected') await client.disconnect(); + else if (terminal === 'leave') h.leave.click(); + else { client.dispatchEvent(new CustomEvent(terminal, { detail: {} })); await client.disconnect(); } + await flush(); hidden(h); + const returning = client.join({ roomId: 'same-room', name: 'Returning' }); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'same-client join'); + socket.deliver({ type: 'room:state', ...state() }); socket.deliver({ type: 'ok', requestId: socket.sent.find(m => m.type === 'join')!.requestId }); await returning; await h.tick(20); + assert.equal(client.status, 'live'); hidden(h); let calls = 0; samples(StagePeer.instances.at(-1)!, async () => { calls++; return report(inbound(), pair); }); + h.node('connection-stats-update').disabled = false; h.node('connection-stats-panel').hidden = false; refresh(h); await flush(); assert.equal(calls, 0); hidden(h); + assert.equal(h.leave.disabled, true); assert.equal((await client.getStats()).inbound?.rtt, .043); +}); +it('actual initial state without ACK followed by disconnect cannot recreate controls from retained state/status', async t => { + const h = await actualApp(t); h.form.submit(); await flush(); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'join'); socket.deliver({ type: 'room:state', ...state() }); + const client = h.clients[0] as StageClient; socket.close(); await flush(); assert.equal(client.status, 'disconnected'); assert.ok(client.state); hidden(h); + client['currentStatus'] = 'live'; client.dispatchEvent(new CustomEvent('state', { detail: client.state })); + client.dispatchEvent(new CustomEvent('status', { detail: 'live' })); hidden(h); + let reads = 0; fake(client, async () => { reads++; return stats(); }); h.node('connection-stats-update').disabled = false; + h.node('connection-stats-panel').hidden = false; refresh(h); await flush(); assert.equal(reads, 0); hidden(h); +}); +it('same-seat role/stage/connected/microphone changes preserve pending and historical own metrics', async t => { + const { h, client, socket } = await setup(t, state('speaker', true)), work = Promise.withResolvers(); let calls = 0; + fake(client, () => { calls++; return work.promise; }); refresh(h); + for (const role of ['audience', 'controller', 'speaker'] as const) { + const s = state(role, role === 'speaker'); s.me.connected = false; s.me.muted = s.me.forceMuted = true; + socket.deliver({ type: 'room:state', ...s }); await h.tick(20); busy(h, true); + } + client.dispatchEvent(new CustomEvent('micerror', { detail: { message: '麥克風不可用', name: 'NotAllowedError' } })); + const old = unaffected(h), oldMedia = media(h); work.resolve(stats()); await flush(); assert.match(text(h), /40 ms/); + assert.deepEqual(unaffected(h), old); assert.deepEqual(media(h), oldMedia); const historical = text(h); + client.dispatchEvent(new CustomEvent('state', { detail: client.state })); assert.equal(text(h), historical); assert.equal(calls, 1); +}); +for (const kind of ['disabled', 'button-hidden', 'panel-hidden', 'button-detached', 'text-detached', 'reparented']) + it(`rejects retained synthetic ${kind} click without stats lookup`, async t => { + const { h, client } = await setup(t); let calls = 0; fake(client, async () => { calls++; return stats(); }); + if (kind === 'disabled') h.node('connection-stats-update').disabled = true; + if (kind === 'button-hidden') h.node('connection-stats-update').hidden = true; + if (kind === 'panel-hidden') h.node('connection-stats-panel').hidden = true; + if (kind === 'button-detached') h.node('connection-stats-update').remove(); + if (kind === 'text-detached') h.node('connection-stats-text').remove(); + if (kind === 'reparented') new EmbedNode().append(h.node('connection-stats-update')); + refresh(h); await flush(); assert.equal(calls, 0); + }); +for (const id of ['connection-stats-panel', 'connection-stats-update', 'connection-stats-text']) + it(`exact document identity rejects replaced ${id} even with original panel containment`, async t => { + // Instrument only querySelector's result inside the actual production module seam. + const source = readFileSync(new URL('../web/src/embed.ts', import.meta.url), 'utf8') + + `\nconst originalQuery = document.querySelector.bind(document);\n(document as any).querySelector = (selector: string) => selector === '#${id}' ? document.createElement('div') : originalQuery(selector);\n`; + const h = embedApp({ source }); t.after(() => h.cleanup()); const client = await h.join(); let calls = 0; + Object.defineProperty(client, 'getStats', { value: async () => { calls++; return stats(); } }); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; h.node('connection-stats-update').invoke(); await flush(); assert.equal(calls, 0); + }); +for (const property of ['disabled', 'button-text', 'result-text', 'aria-busy', 'hidden']) for (const late of [false, true]) + it(`${property} setter reconciles nested newer winner, including late outer overwrite=${late}`, async t => { + const { h, client } = await setup(t), newer = Promise.withResolvers(); let calls = 0; + fake(client, () => { calls++; return property === 'result-text' && calls === 1 ? Promise.resolve(stats()) : newer.promise; }); + const replace = () => { client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(h); }; + if (property === 'aria-busy') { + const node = h.node('connection-stats-update'), set = node.setAttribute.bind(node); let armed = true; + t.mock.method(node, 'setAttribute', (key: string, value: string) => { + if (!late) set(key, value); if (key === 'aria-busy' && armed) { armed = false; replace(); } if (late) set(key, value); + }); + } else if (property === 'hidden') { client['setStatus']('reconnecting'); setter(h.node('connection-stats-panel'), 'hidden', replace, late); client['setStatus']('live'); } + else setter(h.node(property === 'result-text' ? 'connection-stats-text' : 'connection-stats-update'), property === 'disabled' ? 'disabled' : 'textContent', replace, late); + refresh(h); await flush(); assert.equal(calls, property === 'result-text' ? 2 : 1); busy(h, true); assert.equal(text(h), INITIAL); + newer.resolve(stats(.081)); await flush(); busy(h, false); assert.match(text(h), /81 ms/); assert.doesNotMatch(text(h), /40 ms/); + }); +for (const boundary of ['result getter', 'result setter']) it(`${boundary} reconciles nested winner followed by silent disconnect and never restores old binding`, async t => { + const { h, client } = await setup(t), newer = Promise.withResolvers(); let calls = 0; + const replaceThenLose = () => { client['setStatus']('reconnecting'); client['setStatus']('live'); refresh(h); client['currentStatus'] = 'disconnected'; }; + fake(client, () => { + if (++calls > 1) return newer.promise; const raw = stats(); + if (boundary === 'result getter') Object.defineProperty(raw, 'inbound', { get() { replaceThenLose(); return stats().inbound; } }); + return Promise.resolve(raw); + }); + if (boundary === 'result setter') setter(h.node('connection-stats-text'), 'textContent', replaceThenLose, true); + refresh(h); await flush(); assert.equal(calls, 2); hidden(h); newer.resolve(stats(.999)); await flush(); hidden(h); + client['setStatus']('live'); hidden(h); assert.equal(calls, 2); +}); +for (const action of ['close', 'transfer']) for (const loss of ['none', 'reconnecting', 'disconnected', 'room', 'participant', 'role', 'connected', 'target']) + it(`${action} consent ${loss === 'none' ? 'survives ordinary diagnostics' : `retires on observed ${loss} before corrective stats setters restore it`}`, async t => { + let onConfirm = () => {}; + const { h, client } = await controller(t, { confirm: () => { onConfirm(); return true; } }); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + const snapshot = client.state!, target = snapshot.speakers.find(p => p.participantId === 'target')!; + const restore = () => { + client['currentStatus'] = 'live'; snapshot.roomId = 'same-room'; snapshot.me.participantId = 'guest'; + snapshot.me.role = 'controller'; snapshot.me.connected = true; target.connected = true; + }; + Object.defineProperty(client, 'getStats', { configurable: true, get() { + if (loss === 'reconnecting' || loss === 'disconnected') client['currentStatus'] = loss; + if (loss === 'room') snapshot.roomId = 'other'; + if (loss === 'participant') snapshot.me.participantId = 'other'; + if (loss === 'role') snapshot.me.role = 'audience'; + if (loss === 'connected') snapshot.me.connected = false; + if (loss === 'target') target.connected = false; + return async () => stats(); + } }); + // Status/identity loss hides or clears; role/target loss preserves readership but changes busy text. + if (loss === 'reconnecting' || loss === 'disconnected') setter(h.node('connection-stats-panel'), 'hidden', restore, true); + else setter(h.node('connection-stats-update'), 'textContent', () => {}, false); + onConfirm = () => { + refresh(h); + // Synchronous getStats guard has already observed any loss before confirmation returns. + restore(); + }; + (action === 'close' ? h.node('close-room') : transfer(h)).click(); await flush(); + const preserved = loss === 'none' || (action === 'close' && loss === 'target'); + assert.equal(close.mock.callCount(), preserved && action === 'close' ? 1 : 0); + assert.equal(handover.mock.callCount(), preserved && action === 'transfer' ? 1 : 0); + }); +for (const cause of ['leave', 'new-entry', 'closed', 'kicked', 'disconnected']) for (const action of ['close', 'transfer']) + it(`${cause} purely fences diagnostics before early teardown callbacks and invalidates old ${action} before new DOM`, async t => { + const { h, client } = await controller(t, { confirm: () => true }); let reads = 0, inEarly = false, earlyPaints = 0; + fake(client, async () => { reads++; return stats(); }); const oldButton = action === 'close' ? h.node('close-room') : transfer(h); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + setter(h.node('connection-stats-panel'), 'hidden', () => { + if (inEarly) earlyPaints++; + oldButton.disabled = false; oldButton.invoke(); + }); + const early = () => { inEarly = true; refresh(h); inEarly = false; }; + if (cause === 'disconnected') { + const descriptor = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { if (armed) { armed = false; early(); } return descriptor.get!.call(client); } }); + } else setter(h.node(cause === 'closed' || cause === 'kicked' ? 'cancel-join' : 'error-notice'), 'hidden', early); + if (cause === 'leave') h.leave.click(); else if (cause === 'new-entry') h.form.submit(); + else if (cause === 'disconnected') client['setStatus']('disconnected'); + else client.dispatchEvent(new CustomEvent(cause, { detail: {} })); + await flush(); assert.equal(reads, 0); assert.equal(earlyPaints, 0); assert.equal(close.mock.callCount(), 0); assert.equal(handover.mock.callCount(), 0); + Reflect.deleteProperty(client, 'state'); + }); +for (const cause of ['leave', 'new-entry', 'closed', 'kicked', 'disconnected']) for (const action of ['close', 'transfer']) + it(`${cause} inside pending ${action} confirmation cannot restore its consent via diagnostics`, async t => { + let onConfirm = () => {}; + const { h, client } = await controller(t, { confirm: () => { onConfirm(); return true; } }); + let reads = 0; fake(client, async () => { reads++; return stats(); }); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + onConfirm = () => { + if (cause === 'leave') h.leave.click(); else if (cause === 'new-entry') h.form.submit(); + else if (cause === 'disconnected') client['setStatus']('disconnected'); else client.dispatchEvent(new CustomEvent(cause, { detail: {} })); + client['currentStatus'] = 'live'; h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); + }; + (action === 'close' ? h.node('close-room') : transfer(h)).click(); await flush(); + assert.equal(reads, 0); assert.equal(close.mock.callCount(), 0); assert.equal(handover.mock.callCount(), 0); + }); +it('installs actual new-client captured generation binding before its first syncUnlock callback', async t => { + const h = await actualApp(t); let reads = 0, entered = false; + const node = h.node('unlock'); let disabled = node.disabled; + Object.defineProperty(node, 'disabled', { configurable: true, get: () => disabled, set(value: boolean) { + disabled = value; + if (!entered && h.clients.length === 1) { + entered = true; const client = h.clients[0] as StageClient; client['snapshot'] = state(); client['currentStatus'] = 'live'; + fake(client, async () => { reads++; return stats(.064); }); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); + } + } }); + await join(h); await flush(); assert.ok(entered); assert.equal(reads, 1); assert.match(text(h), /64 ms/); +}); +for (const getter of ['state', 'status']) for (const cause of ['new-entry', 'leave']) + it(`public ${getter} getter starting ${cause} cannot manufacture a binding from the new global generation`, async t => { + const { h, client } = await setup(t); let reads = 0, armed = true; fake(client, async () => { reads++; return stats(); }); + const descriptor = Object.getOwnPropertyDescriptor(StageClient.prototype, getter)!; + Object.defineProperty(client, getter, { configurable: true, get() { + const value: unknown = descriptor.get!.call(client); + if (armed) { armed = false; if (cause === 'new-entry') h.form.submit(); else h.leave.click(); } + return value; + } }); + refresh(h); await flush(); assert.equal(reads, 0); Reflect.deleteProperty(client, getter); hidden(h); + }); +for (const getter of ['roomId', 'participantId']) + it(`public ${getter} identity getter reentry retires attempt before SDK method lookup`, async t => { + const { h, client } = await setup(t); let reads = 0, armed = true; fake(client, async () => { reads++; return stats(); }); + const target = getter === 'roomId' ? client.state! : client.state!.me, value = Reflect.get(target, getter); + Object.defineProperty(target, getter, { configurable: true, get() { + if (armed) { armed = false; h.leave.click(); } return value; + } }); + refresh(h); await flush(); assert.equal(reads, 0); hidden(h); + }); +it('preserves unrelated UI and avoids equal writes on repeated same-seat state and status receipts', async t => { + const { h, client } = await setup(t); fake(client, async () => stats()); refresh(h); await flush(); const historical = text(h); + const nodes = ['connection-stats-panel', 'connection-stats-update', 'connection-stats-text'].map(id => h.node(id)); + for (const node of nodes) node.resetWrites(); + for (let i = 0; i < 3; i++) client.dispatchEvent(new CustomEvent('state', { detail: client.state })); + assert.equal(text(h), historical); for (const node of nodes) assert.deepEqual(node.writes, { disabled: 0, textContent: 0, attributes: 0, unchanged: 0 }); +}); +it('genuine hidden initial DOM rejects early syncUnlock retained handler without damaging normal join', async t => { + const h = await actualApp(t); let early = 0, reads = 0; + const node = h.node('unlock'); let disabled = node.disabled; + Object.defineProperty(node, 'disabled', { configurable: true, get: () => disabled, set(value: boolean) { + disabled = value; + if (h.clients.length === 1 && early === 0) { + early++; const client = h.clients[0] as StageClient; assert.equal(client.status, 'disconnected'); + assert.equal(h.node('connection-stats-panel').hidden, true); assert.equal(h.node('connection-stats-update').disabled, true); + fake(client, async () => { reads++; return stats(); }); refresh(h); + } + } }); + await join(h); assert.equal(early, 1); assert.equal(reads, 0); assert.equal(text(h), INITIAL); + refresh(h); await flush(); assert.equal(reads, 1); assert.match(text(h), /40 ms/); +}); +it('host-forced initial disconnected visibility fails closed for that binding; a genuine new entry recovers', async t => { + const h = await actualApp(t); let early = 0, reads = 0; + const node = h.node('unlock'); let disabled = node.disabled; + Object.defineProperty(node, 'disabled', { configurable: true, get: () => disabled, set(value: boolean) { + disabled = value; + if (h.clients.length === 1 && early === 0) { + early++; const client = h.clients[0] as StageClient; assert.equal(client.status, 'disconnected'); + fake(client, async () => { reads++; return stats(); }); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); + } + } }); + const first = await join(h); assert.equal(early, 1); assert.equal(reads, 0); hidden(h); + first.client['setStatus']('waiting'); first.client['setStatus']('live'); hidden(h); assert.equal(reads, 0); + h.leave.click(); await flush(); const next = await join(h); fake(next.client, async () => { reads++; return stats(.088); }); + assert.equal(text(h), INITIAL); refresh(h); await flush(); assert.equal(reads, 1); assert.match(text(h), /88 ms/); +}); +it('cancelling initial entry retires captured binding before late old state and admits only a genuine new entry', async t => { + const h = await actualApp(t); h.form.submit(); await flush(); const client = h.clients[0] as StageClient; let reads = 0; + fake(client, async () => { reads++; return stats(); }); hidden(h); refresh(h); h.node('cancel-join').invoke(); await flush(); + assert.equal(client.status, 'disconnected'); client['snapshot'] = state(); client['currentStatus'] = 'live'; + client.dispatchEvent(new CustomEvent('state', { detail: client.state })); client.dispatchEvent(new CustomEvent('status', { detail: 'live' })); + h.node('connection-stats-panel').hidden = false; h.node('connection-stats-update').disabled = false; refresh(h); await flush(); assert.equal(reads, 0); hidden(h); + const newer = await join(h); fake(newer.client, async () => { reads++; return stats(.067); }); refresh(h); await flush(); assert.equal(reads, 1); assert.match(text(h), /67 ms/); +}); +for (const member of ['connection-stats-update', 'connection-stats-text']) it(`pending result cannot survive detached ${member} or seed reattached history`, async t => { + const { h, client } = await setup(t), old = Promise.withResolvers(); fake(client, () => old.promise); refresh(h); + h.node(member).remove(); old.resolve(stats(.999)); await flush(); hidden(h); + h.node('connection-stats-panel').append(h.node(member)); client['setStatus']('waiting'); assert.equal(text(h), INITIAL); + fake(client, async () => stats(.033)); refresh(h); await flush(); assert.match(text(h), /33 ms/); assert.doesNotMatch(text(h), /999/); +}); +for (const action of ['close', 'transfer']) for (const loss of ['reconnecting', 'disconnected', 'room', 'participant']) + for (const restoreAt of [2, 3]) it(`observed ${loss} on read1, restored on read${restoreAt}, permanently retires old ${action} consent`, async t => { + let inspect = () => {}; + const { h, client } = await controller(t, { confirm: () => { inspect(); return true; } }); + const close = t.mock.method(client, 'closeRoom', async () => {}), handover = t.mock.method(client, 'transferControl', async () => {}); + const target = loss === 'room' ? client.state! : loss === 'participant' ? client.state!.me : client; + const property = loss === 'room' ? 'roomId' : loss === 'participant' ? 'participantId' : 'status'; + const original = Reflect.get(target, property); let value = original, armed = false, reads = 0; + Object.defineProperty(target, property, { configurable: true, get() { if (armed && ++reads === restoreAt) value = original; return value; } }); + Object.defineProperty(client, 'getStats', { configurable: true, get() { + value = loss === 'room' || loss === 'participant' ? 'different-identity' : loss; reads = 0; armed = true; + return async () => stats(); + } }); + inspect = () => refresh(h); (action === 'close' ? h.node('close-room') : transfer(h)).click(); await flush(); + assert.ok(reads >= restoreAt); assert.equal(close.mock.callCount(), 0); assert.equal(handover.mock.callCount(), 0); + if (property === 'status') Reflect.deleteProperty(target, property); else Object.defineProperty(target, property, { configurable: true, writable: true, value: original }); + }); +for (const action of ['close', 'transfer']) it(`observed loss with no stats owner still retires old ${action} consent`, async t => { + let inspect = () => {}; + const source = readFileSync(new URL('../web/src/embed.ts', import.meta.url), 'utf8') + + '\nconnectionStatsUpdate.onclick = () => { retireConnectionStats(); refreshConnectionStats(); };\n'; + const h = embedApp({ source, confirm: () => { inspect(); return true; } }); t.after(() => h.cleanup()); const sdk = await h.join(); + const s = state('controller'); s.speakers.push({ ...s.me, participantId: 'target', role: 'speaker', onStage: true }); + sdk.state = s; sdk.me = s.me; sdk.emit('state', s); let value = sdk.status, armed = false, reads = 0; + Object.defineProperty(sdk, 'status', { configurable: true, get() { if (armed && ++reads === 2) value = 'live'; return value; }, set(next) { value = next; } }); + inspect = () => { value = 'reconnecting'; armed = true; h.node('connection-stats-update').invoke(); }; + const handover = h.node('stage-management-list').children[0]!.children[1]!.children[2]!; + (action === 'close' ? h.node('close-room') : handover).click(); await flush(); assert.equal(sdk.closes.length, 0); assert.equal(sdk.transfers.length, 0); +}); +for (const action of ['close', 'transfer']) it(`older status value cannot invalidate a fresh actual SDK ${action} token created in that getter`, async t => { + const { h, client } = await controller(t, { confirm: () => true }), work = Promise.withResolvers(); fake(client, async () => stats()); + const command = t.mock.method(client, action === 'close' ? 'closeRoom' : 'transferControl', () => work.promise); + const original = Object.getOwnPropertyDescriptor(StageClient.prototype, 'status')!; let armed = true; + Object.defineProperty(client, 'status', { configurable: true, get() { + if (!armed) return original.get!.call(client); + armed = false; (action === 'close' ? h.node('close-room') : transfer(h)).click(); return 'reconnecting'; + } }); + refresh(h); await flush(); assert.equal(command.mock.callCount(), 1); assert.equal(h.confirmations.length, 1); + work.reject(new Error('fresh authorized action failed')); await flush(); assert.match(h.node('last-error').textContent, /fresh authorized action failed/); + Reflect.deleteProperty(client, 'status'); +}); +for (const action of ['close', 'transfer']) it(`old identity getter starting a genuine new entry cannot invalidate its later fresh ${action} consent`, async t => { + const { h, client } = await controller(t, { confirm: () => true }); fake(client, async () => stats()); + const original = Object.getOwnPropertyDescriptor(StageClient.prototype, 'state')!; let armed = true; + Object.defineProperty(client, 'state', { configurable: true, get() { + const value: unknown = original.get!.call(client); + if (armed) { armed = false; h.form.submit(); } + return value; + } }); + refresh(h); await flush(); Reflect.deleteProperty(client, 'state'); + const next = h.clients.at(-1) as StageClient; assert.notEqual(next, client); const socket = StageSocket.instances.at(-1)!; hello(socket); + await h.until(() => socket.sent.some(m => m.type === 'join'), 'new entry join'); + const s = state('controller'); s.speakers.push({ ...s.me, participantId: 'target', role: 'speaker', onStage: true }); + socket.deliver({ type: 'room:state', ...s }); socket.deliver({ type: 'ok', requestId: socket.sent.find(m => m.type === 'join')!.requestId }); await h.tick(20); + const work = Promise.withResolvers(); const command = t.mock.method(next, action === 'close' ? 'closeRoom' : 'transferControl', () => work.promise); + fake(next, async () => stats(.062)); refresh(h); await flush(); assert.match(text(h), /62 ms/); + (action === 'close' ? h.node('close-room') : transfer(h)).click(); assert.equal(command.mock.callCount(), 1); + work.reject(new Error('new entry authorized action failed')); await flush(); assert.match(h.node('last-error').textContent, /new entry authorized action failed/); +}); diff --git a/test/webEmbedEntry.test.ts b/test/webEmbedEntry.test.ts index 16f5d77..16bb22e 100644 --- a/test/webEmbedEntry.test.ts +++ b/test/webEmbedEntry.test.ts @@ -60,9 +60,13 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); let now = 1000; t.mock.method(performance, 'now', () => now); - const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); + const nodes = new Map(['host', 'ws-path', 'room', 'code', 'name', 'status', 'room-occupancy', 'own-connection-quality', 'speaking-summary', 'public-roster', 'public-roster-summary', 'public-roster-notice', 'public-roster-list', 'connection-stats-panel', 'connection-stats-update', 'connection-stats-text', 'hand-rejected', 'error-notice', 'last-error', 'error-dismiss', 'join', 'hand', 'mic', 'rename', 'room-name-panel', 'room-name-current', 'room-name-draft', 'room-name-update', 'hand-queue', 'hand-queue-summary', 'hand-queue-notice', 'hand-queue-search', 'hand-queue-clear', 'hand-queue-results', 'hand-queue-list', 'stage-management', 'stage-management-summary', 'stage-management-notice', 'stage-management-list', 'stage', 'mic-retry', 'audio-retry', 'audio-retry-status', 'microphone-panel', 'microphone-device', 'microphone-refresh', 'microphone-apply', 'microphone-draft-notice', 'mic-test-panel', 'mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice', 'output-panel', 'output-device', 'output-refresh', 'output-apply', 'output-draft-notice', 'output-support', 'cancel-join', 'leave', 'close-room', 'audio', 'unlock'].map(id => [id, new EmbedNode()])); const node = (id: string): EmbedNode => { const found = nodes.get(id); assert.ok(found); return found; }; - node('error-notice').hidden = true; node('hand-rejected').hidden = true; + node('public-roster').append(node('public-roster-summary'), node('public-roster-notice'), node('public-roster-list')); + node('public-roster').hidden = true; + node('connection-stats-panel').append(node('connection-stats-update'), node('connection-stats-text')); + node('mic-test-panel').append(...['mic-test-start', 'mic-test-cancel', 'mic-test-stop', 'mic-test-meter', 'mic-test-status', 'mic-test-notice'].map(node)); + node('error-notice').hidden = true; node('hand-rejected').hidden = true; node('room-occupancy').hidden = true; node('own-connection-quality').hidden = true; node('speaking-summary').hidden = true; const cancel = node('cancel-join'); cancel.hidden = cancel.disabled = true; cancel.textContent = '取消連線'; const form = node('join'), submit = new EmbedNode(), status = node('status'), leave = node('leave'), hand = node('hand'); form.submitButton = submit; node('name').value = ' 測試聽眾 '; @@ -78,8 +82,10 @@ async function fixture(t: TestContext, options: { deferLibrary?: boolean } = {}) assert.equal(source.split("import './styles.css';").length, 2); const executable = stripTypeScriptTypes(source.replace("import './styles.css';", '').replace(dynamicImport, 'loadLibrary(moduleUrl)')); assert.doesNotMatch(executable, /\bimport\s*(?:\(|['"])/); + const pageDocument = Object.assign(new EventTarget(), { hidden: false, createElement(tag: string) { assert.equal(tag, 'option'); return new EmbedNode(); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }); + const pageWindow = Reflect.get(globalThis, 'window') as EventTarget; runInNewContext(executable, { - document: { createElement(tag: string) { assert.equal(tag, 'option'); return new EmbedNode(); }, querySelector(selector: string) { assert.match(selector, /^#[a-z-]+$/); return node(selector.slice(1)); } }, + document: pageDocument, window: pageWindow, AbortController, location: { search: '?room=same-room&code=TEST42', origin: 'https://stage.example.test' }, URL, URLSearchParams, Error, Promise, performance, setTimeout, clearTimeout, setInterval, clearInterval, loadLibrary: async (url: string) => { diff --git a/test/webEmbedHandQueueSearch.test.ts b/test/webEmbedHandQueueSearch.test.ts new file mode 100644 index 0000000..53fd96e --- /dev/null +++ b/test/webEmbedHandQueueSearch.test.ts @@ -0,0 +1,366 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { it, type TestContext } from 'node:test'; +import { StageClient, decodeName } from '../packages/client/src/index.ts'; +import { Room } from '../src/model/room.ts'; +import { parseClientMessage } from '../src/ws/validate.ts'; +import type { ParticipantView } from '../shared/protocol.ts'; +import { testConfig } from './helpers.ts'; +import { embedApp, flush, type EmbedNode, type EmbedSdk } from './fixtures/embedApp.ts'; +import { actualApp, actualState, StageSocket, StagePeer } from './fixtures/embedSdkApp.ts'; + +// Complete embed module + controlled DOM, and real SDK/Room below. No native keyboard, +// focus, details layout, accessibility tree, real network, microphone or audible-media claim. +type View = { node(id: string): EmbedNode }; +const search = (h: View) => h.node('hand-queue-search'); +const clear = (h: View) => h.node('hand-queue-clear'); +const panel = (h: View) => h.node('hand-queue'); +const rows = (h: View) => h.node('hand-queue-list').children; +const results = (h: View) => h.node('hand-queue-results').textContent; +const visible = (h: View) => rows(h).filter(row => !row.hidden); +const label = (row: EmbedNode) => row.children[0]!.textContent; +const button = (row: EmbedNode, kind: 'approve' | 'reject' = 'approve') => row.children[1]!.children[kind === 'approve' ? 0 : 1]!; +function query(h: View, value: string): void { search(h).value = value; assert.ok(search(h).oninput); search(h).oninput!(); } +function force(row: EmbedNode, kind: 'approve' | 'reject' = 'approve'): void { button(row, kind).disabled = false; button(row, kind).invoke(); } +function locked(row: EmbedNode, pending: boolean, disabled = pending): void { + for (const kind of ['approve', 'reject'] as const) { + assert.equal(button(row, kind).disabled, disabled); assert.equal(button(row, kind).getAttribute('aria-busy'), String(pending)); + } +} +function retired(h: View): void { + assert.equal(panel(h).hidden, true); assert.equal(panel(h).open, false); assert.equal(rows(h).length, 0); + assert.equal(search(h).value, ''); assert.equal(search(h).disabled, true); assert.equal(clear(h).disabled, true); assert.equal(results(h), ''); +} +const person = (id: string, name = id, patch: Partial = {}): ParticipantView => ({ + participantId: id, name, role: 'audience', onStage: false, handRaised: true, connected: true, + muted: false, forceMuted: false, gainDb: 0, joinedAt: 0, ...patch, +}); +function snapshot(sdk: EmbedSdk, hands: ParticipantView[] = [person('a', 'Alice'), person('b', 'Bob')], patch: { roomId?: string; me?: Partial } = {}, emit = true): void { + assert.ok(sdk.state); assert.ok(sdk.me); + sdk.state = { ...sdk.state, ...patch, hands, me: { ...sdk.me, ...patch.me } }; sdk.me = sdk.state.me; + if (emit) sdk.emit('state', sdk.state); +} +async function setup(t: TestContext) { + const h = embedApp(); t.after(() => h.cleanup()); const sdk = await h.join({ role: 'controller' }); snapshot(sdk); return { h, sdk }; +} +function onceProperty(object: T, key: keyof T, edge: 'get' | 'set', callback: () => void): void { + let value = object[key], fired = false; + Object.defineProperty(object, key, { configurable: true, + get() { if (edge === 'get' && !fired) { fired = true; callback(); } return value; }, + set(next: T[keyof T]) { if (edge === 'set' && !fired) { fired = true; callback(); } value = next; }, + }); +} + +it('authored search is explicitly labelled native search, clear is a non-submit button, and status is associated outside join form', () => { + const html = readFileSync(new URL('../web/embed.html', import.meta.url), 'utf8'); + const source = readFileSync(new URL('../web/src/embed.ts', import.meta.url), 'utf8'); + const details = html.match(/]*id="hand-queue"[^>]*>[\s\S]*?<\/details>/)?.[0]; assert.ok(details); + assert.match(details, /]*for="hand-queue-search">搜尋舉手者名稱/); + assert.match(details, /`))?.[0]; assert.ok(button); + assert.match(button, /type="button"/); assert.match(button, /\bdisabled\b/); + assert.doesNotMatch(button, /tabindex=|role=|aria-pressed=|aria-label=/); + if (suffix !== 'start') assert.match(button, /\bhidden\b/); + } + assert.match(html, /
diff --git a/web/src/embed.ts b/web/src/embed.ts index 544640f..474dcf9 100644 --- a/web/src/embed.ts +++ b/web/src/embed.ts @@ -1,10 +1,20 @@ -import type { StageClient as Client, StageEventMap } from '../../packages/client/src/index.ts'; +import type { StageClient as Client, StageEventMap, MicTest } from '../../packages/client/src/index.ts'; import type * as StageLibrary from '../../packages/client/src/index.ts'; import './styles.css'; function input(id: string): HTMLInputElement { return document.querySelector(`#${id}`)!; } const status = document.querySelector('#status')!; +const roomOccupancy = document.querySelector('#room-occupancy')!; +const ownConnectionQuality = document.querySelector('#own-connection-quality')!; +const speakingSummary = document.querySelector('#speaking-summary')!; +const publicRosterPanel = document.querySelector('#public-roster')!; +const publicRosterSummary = document.querySelector('#public-roster-summary')!; +const publicRosterNotice = document.querySelector('#public-roster-notice')!; +const publicRosterList = document.querySelector('#public-roster-list')!; +const connectionStatsPanel = document.querySelector('#connection-stats-panel')!; +const connectionStatsUpdate = document.querySelector('#connection-stats-update')!; +const connectionStatsText = document.querySelector('#connection-stats-text')!; const errorNotice = document.querySelector('#error-notice')!; const lastError = document.querySelector('#last-error')!; const handRejectionNotice = document.querySelector('#hand-rejected')!; @@ -22,6 +32,9 @@ const handQueuePanel = document.querySelector('#hand-queue') const handQueueSummary = document.querySelector('#hand-queue-summary')!; const handQueueNotice = document.querySelector('#hand-queue-notice')!; const handQueueList = document.querySelector('#hand-queue-list')!; +const handQueueSearch = input('hand-queue-search'); +const handQueueClear = document.querySelector('#hand-queue-clear')!; +const handQueueResults = document.querySelector('#hand-queue-results')!; const stageManagementPanel = document.querySelector('#stage-management')!; const stageManagementSummary = document.querySelector('#stage-management-summary')!; const stageManagementNotice = document.querySelector('#stage-management-notice')!; @@ -38,6 +51,12 @@ const microphonePanel = document.querySelector('#microphone-pane const microphoneSelect = document.querySelector('#microphone-device')!; const microphoneRefresh = document.querySelector('#microphone-refresh')!; const microphoneApply = document.querySelector('#microphone-apply')!; +const micTestPanel = document.querySelector('#mic-test-panel')!; +const micTestStart = document.querySelector('#mic-test-start')!; +const micTestCancel = document.querySelector('#mic-test-cancel')!; +const micTestStop = document.querySelector('#mic-test-stop')!; +const micTestMeter = document.querySelector('#mic-test-meter')!; +const micTestStatus = document.querySelector('#mic-test-status')!; const outputPanel = document.querySelector('#output-panel')!; const outputSelect = document.querySelector('#output-device')!; const outputRefresh = document.querySelector('#output-refresh')!; @@ -65,6 +84,8 @@ let handQueueOwner: { client: Client; roomId: string; participantId: string; gen const handQueueRows = new Map(); const handQueuePending = new Map(); let handQueueOrder: string[] = []; +let handQueueQuery = ''; +let handQueueRevision = 0; interface StageManagementRow { node: HTMLLIElement; name: HTMLSpanElement; mute: HTMLButtonElement; remove: HTMLButtonElement; transfer: HTMLButtonElement; gainCurrent: HTMLSpanElement; gainLabel: HTMLSpanElement; gainDraft: HTMLInputElement; gainApply: HTMLButtonElement; gainNotice: HTMLSpanElement; @@ -102,6 +123,643 @@ let outputOwner: { client: Client; roomId: string; participantId: string; genera let playbackGeneration = 0; let pendingUnlock: { client: Client | null; generation: number } | undefined; let entryGeneration = 0; +interface OccupancyReport { + client: Client; generation: number; roomId: string; participantId: string; + room: number; stage: number; audience: number; maxAudience: number; maxSpeakers: number; + controllerOnStage: boolean; stale: boolean; +} +let occupancyReport: OccupancyReport | undefined; +let occupancyRevision = 0; +let occupancyRetiredGeneration = -1; +// Retirement is pure: existing action authority must be reconciled before the new DOM surface runs. +function retireOccupancy(): void { occupancyReport = undefined; occupancyRevision++; } +function projectOccupancy(current: Client, generation: number, state: StageEventMap['state']): OccupancyReport | undefined { + const roomId = state.roomId, participantId = state.me?.participantId, controllerId = state.controllerId; + const audience = state.audienceCount, maxAudience = state.limits?.maxAudience, maxSpeakers = state.limits?.maxSpeakers; + if (typeof roomId !== 'string' || !roomId.trim() || typeof participantId !== 'string' || !participantId.trim() + || typeof controllerId !== 'string' || !controllerId.trim() || !Array.isArray(state.speakers) + || !state.limits || typeof state.limits !== 'object' || Array.isArray(state.limits) + || !Number.isSafeInteger(audience) || audience < 0 || !Number.isSafeInteger(maxAudience) || maxAudience <= 0 + || !Number.isSafeInteger(maxSpeakers) || maxSpeakers <= 0) return; + const ids = new Set(); + let controllerOnStage = false; + for (const person of state.speakers) { + if (!person || typeof person.participantId !== 'string' || !person.participantId.trim() || ids.has(person.participantId) + || person.onStage !== true || (person.role !== 'speaker' && person.role !== 'controller') + || (person.role === 'controller') !== (person.participantId === controllerId)) return; + ids.add(person.participantId); + if (person.participantId === controllerId) controllerOnStage = true; + } + const stage = ids.size - Number(controllerOnStage), room = audience + stage; + if (!Number.isSafeInteger(room) || room > maxAudience || stage > maxSpeakers) return; + return { client: current, generation, roomId, participantId, room, stage, audience, maxAudience, maxSpeakers, + controllerOnStage, stale: false }; +} +function reconcileOccupancy(): void { + const report = occupancyReport, current = client; + if (current?.status === 'disconnected') occupancyRetiredGeneration = entryGeneration; + if (!report) return; + if (current !== report.client || current !== controlsClient || report.generation !== entryGeneration + || current.state?.roomId !== report.roomId || current.state.me?.participantId !== report.participantId + || !['waiting', 'live', 'reconnecting'].includes(current.status)) { retireOccupancy(); return; } + if (current.status === 'reconnecting' && !report.stale) { report.stale = true; occupancyRevision++; } +} +function renderOccupancy(): void { + const revision = occupancyRevision, report = occupancyReport, current = client, controls = controlsClient; + const generation = entryGeneration, connectionStatus = current?.status; + const roomId = current?.state?.roomId, participantId = current?.state?.me?.participantId; + const valid = (): boolean => { + if (revision !== occupancyRevision || report !== occupancyReport || current !== client + || controls !== controlsClient || generation !== entryGeneration) return false; + if (connectionStatus === current?.status && roomId === current?.state?.roomId + && participantId === current?.state?.me?.participantId) return true; + // A setter can change public SDK identity/status without dispatching an event. + // Retire both pending consents before any corrective DOM can restore their eligibility. + reconcileOccupancy(); + if (pendingRoomClose && !eligibleRoomClose(roomCloseOwner)) pendingRoomClose.invalidated = true; + invalidateTransfer(); syncRoomClose(); syncStageManagement(); + renderOccupancy(); return false; + }; + if (!report) { + if (!roomOccupancy.hidden) { roomOccupancy.hidden = true; if (!valid()) return; } + if (roomOccupancy.textContent !== '') { roomOccupancy.textContent = ''; if (!valid()) return; } + return; + } + if (report.client !== current || current !== controls || report.generation !== generation + || report.roomId !== roomId || report.participantId !== participantId + || !['waiting', 'live', 'reconnecting'].includes(connectionStatus!)) return; + const freshness = connectionStatus === 'reconnecting' ? '重新連線中,上次快照' : report.stale ? '上次快照' : '最近快照'; + const text = `房間席位(${freshness}):主控以外 ${report.room} / ${report.maxAudience} 人,其中台上 ${report.stage} / ${report.maxSpeakers} 人、觀眾 ${report.audience} 人;主控另計 1 人(${report.controllerOnStage ? '台上' : '台下'})。含斷線保留席位。`; + if (roomOccupancy.textContent !== text) { roomOccupancy.textContent = text; if (!valid()) return; } + if (roomOccupancy.hidden) { roomOccupancy.hidden = false; if (!valid()) return; } +} +interface QualitySeat { + client: Client; generation: number; roomId: string; participantId: string; +} +interface QualityReport { + uplink: number | undefined; downlink: number | undefined; rtt: number | undefined; +} +let qualitySeat: QualitySeat | undefined; +let qualityReport: QualityReport | undefined; +let qualityRevision = 0; +// A receipt window is local presentation ownership, not a measurement timestamp or SDK stage epoch. +function retireQuality(): void { qualitySeat = undefined; qualityReport = undefined; qualityRevision++; } +function eligibleQualitySeat(current: Client): boolean { + const state = current.state, me = state?.me; + return current === client && current === controlsClient && occupancyRetiredGeneration !== entryGeneration + && (current.status === 'waiting' || current.status === 'live') + && typeof state?.roomId === 'string' && Boolean(state.roomId.trim()) + && typeof me?.participantId === 'string' && Boolean(me.participantId.trim()) + && me.onStage === true && (me.role === 'controller' || me.role === 'speaker'); +} +function reconcileQuality(): void { + const seat = qualitySeat; + if (client === controlsClient && client?.status === 'disconnected') occupancyRetiredGeneration = entryGeneration; + if (seat && (seat.generation !== entryGeneration || !eligibleQualitySeat(seat.client) + || seat.client.state?.roomId !== seat.roomId || seat.client.state.me.participantId !== seat.participantId)) retireQuality(); +} +function projectQuality(detail: unknown, participantId: string): QualityReport | undefined { + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return; + const participants: unknown = Reflect.get(detail, 'participants'); + if (!Array.isArray(participants)) return; + let own: object | undefined; + for (const item of participants) { + if (!item || typeof item !== 'object' || Array.isArray(item) || item.participantId !== participantId) continue; + if (own) return; // Multiple own entries are an ambiguous full replacement. + own = item; + } + if (!own) return; + const loss = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) + && value >= 0 && value <= 100 ? value : undefined; + const rtt: unknown = Reflect.get(own, 'rttMs'); + return { uplink: loss(Reflect.get(own, 'uplinkLossPercent')), downlink: loss(Reflect.get(own, 'downlinkLossPercent')), + rtt: typeof rtt === 'number' && Number.isFinite(rtt) && rtt >= 0 ? rtt : undefined }; +} +function renderQuality(): void { + reconcileQuality(); + const revision = qualityRevision, seat = qualitySeat, report = qualityReport; + const current = client, controls = controlsClient, generation = entryGeneration; + const owns = (): boolean => revision === qualityRevision && seat === qualitySeat && report === qualityReport + && current === client && controls === controlsClient && generation === entryGeneration; + const valid = (): boolean => { + // A nested receipt may win before the same setter silently changes its SDK eligibility. + if (!owns()) { renderQuality(); return false; } + reconcileQuality(); + // Both consents are invalidated before any corrective DOM can restore public SDK eligibility. + const closeInvalid = Boolean(pendingRoomClose && !pendingRoomClose.invalidated + && (!roomCloseOwner || pendingRoomClose.revision !== roomCloseOwner.revision || !eligibleRoomClose(roomCloseOwner))); + const transferInvalid = Boolean(pendingTransfer && !pendingTransfer.invalidated + && (!stageManagementOwner || pendingTransfer.revision !== stageManagementOwner.revision + || !eligibleTransfer(stageManagementOwner, pendingTransfer.id, pendingTransfer.row))); + if (closeInvalid) pendingRoomClose!.invalidated = true; + if (transferInvalid) pendingTransfer!.invalidated = true; + if (!owns() || closeInvalid || transferInvalid) { + syncRoomClose(); syncStageManagement(); + renderQuality(); return false; + } + return true; + }; + if (!valid()) return; + if (!seat || !report) { + if (!ownConnectionQuality.hidden) { ownConnectionQuality.hidden = true; if (!valid()) return; } + if (ownConnectionQuality.textContent !== '') { ownConnectionQuality.textContent = ''; if (!valid()) return; } + return; + } + const percent = (value: number | undefined): string => value === undefined ? '—' : `${value.toFixed(1)}%`; + const text = `你的連線品質(最近收到):上行掉包 ${percent(report.uplink)} · 下行掉包 ${percent(report.downlink)} · RTT ${report.rtt === undefined ? '—' : `${Math.round(report.rtt)} ms`}(非端到端音訊延遲)`; + if (ownConnectionQuality.textContent !== text) { ownConnectionQuality.textContent = text; if (!valid()) return; } + if (ownConnectionQuality.hidden) { ownConnectionQuality.hidden = false; if (!valid()) return; } +} +interface SpeakingSeat { + client: Client; generation: number; roomId: string; participantId: string; +} +interface SpeakingReport { ids: Set; rawEmpty: boolean } +let speakingSeat: SpeakingSeat | undefined; +let speakingRoster = new Map(); +let speakingSuppressed = new Set(); +let speakingReport: SpeakingReport | undefined; +let speakingRevision = 0; +function retireSpeaking(): void { + speakingSeat = undefined; speakingRoster = new Map(); speakingSuppressed = new Set(); + speakingReport = undefined; speakingRevision++; +} +function speakingIdentity(current: Client, generation: number): SpeakingSeat | undefined { + const state = current.state, roomId = state?.roomId, participantId = state?.me?.participantId; + if (current !== client || current !== controlsClient || generation !== entryGeneration + || occupancyRetiredGeneration === generation || (current.status !== 'waiting' && current.status !== 'live') + || typeof roomId !== 'string' || !roomId.trim() || typeof participantId !== 'string' || !participantId.trim()) return; + return { client: current, generation, roomId, participantId }; +} +function reconcileSpeaking(): void { + if (client === controlsClient && client?.status === 'disconnected') occupancyRetiredGeneration = entryGeneration; + const seat = speakingSeat; + if (!seat) return; + let identity: SpeakingSeat | undefined; + try { identity = speakingIdentity(seat.client, seat.generation); } catch { /* Invalid public identity retires its reader. */ } + if (seat === speakingSeat && (!identity || identity.roomId !== seat.roomId || identity.participantId !== seat.participantId)) retireSpeaking(); +} +// Invalidate both consents before a summary setter can restore an observed loss of authority. +// Ordinary reports need no control rendering, and unrelated target loss must leave Close intact. +function reconcileSpeakingConsents(): void { + const close = pendingRoomClose, closeOwner = roomCloseOwner; + if (close && !close.invalidated) { + try { + if (!closeOwner || close.revision !== closeOwner.revision || !eligibleRoomClose(closeOwner)) close.invalidated = true; + } catch { close.invalidated = true; } + } + const transfer = pendingTransfer, transferOwner = stageManagementOwner; + if (transfer && !transfer.invalidated) { + try { + if (!transferOwner || transfer.revision !== transferOwner.revision + || !eligibleTransfer(transferOwner, transfer.id, transfer.row)) transfer.invalidated = true; + } catch { transfer.invalidated = true; } + } +} +function projectSpeakingRoster(state: StageEventMap['state']): Map | undefined { + const speakers: unknown = state.speakers; + if (!Array.isArray(speakers) || !decodeRoomName) return; + const roster = new Map(); + for (const person of speakers) { + if (!person || typeof person !== 'object' || Array.isArray(person)) continue; + const id: unknown = person.participantId; + if (typeof id !== 'string' || !id.trim() || roster.has(id) || person.onStage !== true) continue; + const role: unknown = person.role; + if (role !== 'speaker' && role !== 'controller') continue; + const name: unknown = person.name; + if (typeof name === 'string') roster.set(id, decodeRoomName(name)); + } + return roster; +} +function receiveSpeakingState(current: Client, generation: number, detail: StageEventMap['state'], unseen: boolean): boolean { + reconcileSpeaking(); + if (!unseen || detail !== current.state) return true; + const revision = speakingRevision, seat = speakingSeat; + let identity: SpeakingSeat | undefined, roster: Map | undefined; + try { + identity = speakingIdentity(current, generation); + if (identity) roster = projectSpeakingRoster(detail); + } catch { /* Copy the complete projection before replacing history or releasing delta suppression. */ } + let live: SpeakingSeat | undefined, matches = false; + try { + live = speakingIdentity(current, generation); + matches = Boolean(identity && live && detail === current.state + && live.roomId === identity.roomId && live.participantId === identity.participantId); + } catch { /* An unreadable identity cannot admit a reader. */ } + reconcileSpeaking(); reconcileSpeakingConsents(); + if (revision !== speakingRevision || seat !== speakingSeat || current !== client || generation !== entryGeneration + || !matches || !identity) return false; + speakingSeat = seat ?? identity; + speakingRoster = roster ?? new Map(); + if (roster) { + speakingSuppressed = new Set(); + if (speakingReport) speakingReport = { rawEmpty: speakingReport.rawEmpty, + ids: new Set([...speakingReport.ids].filter(id => roster.has(id))) }; + } else speakingReport = undefined; + speakingRevision++; + return roster !== undefined; +} +function projectSpeakingReport(detail: unknown): SpeakingReport | undefined { + if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return; + const participantIds: unknown = Reflect.get(detail, 'participantIds'); + if (!Array.isArray(participantIds)) return; + const ids = new Set(), rawEmpty = participantIds.length === 0; + for (const id of participantIds) { + if (typeof id !== 'string') return; + if (speakingRoster.has(id) && !speakingSuppressed.has(id)) ids.add(id); + } + return { ids, rawEmpty }; +} +function pruneSpeaking(current: Client, generation: number, id: string): boolean { + reconcileSpeaking(); + if (!speakingSeat || speakingSeat.client !== current || speakingSeat.generation !== generation + || typeof id !== 'string' || !id.trim() || !speakingRoster.has(id)) return false; + speakingSuppressed.add(id); + speakingReport?.ids.delete(id); + speakingRevision++; + return true; +} +function renderSpeaking(): void { + reconcileSpeaking(); reconcileSpeakingConsents(); + const revision = speakingRevision, seat = speakingSeat, report = speakingReport; + const current = client, controls = controlsClient, generation = entryGeneration; + const valid = (): boolean => { + // Reconcile the winner even if a nested receipt already superseded this render. + reconcileSpeaking(); reconcileSpeakingConsents(); + if (revision !== speakingRevision || seat !== speakingSeat || report !== speakingReport + || current !== client || controls !== controlsClient || generation !== entryGeneration) { + renderSpeaking(); return false; + } + return true; + }; + if (!valid()) return; + if (!seat) { + if (!speakingSummary.hidden) { speakingSummary.hidden = true; if (!valid()) return; } + if (speakingSummary.textContent !== '') { speakingSummary.textContent = ''; if (!valid()) return; } + return; + } + const names = report ? [...speakingRoster].filter(([id]) => report.ids.has(id)).map(([, name]) => name) : []; + const text = !report ? '發言偵測:等待伺服器回報。' + : report.rawEmpty ? '最近收到的發言回報:未偵測到發言。' + : names.length ? `最近收到的發言回報:${names.join('、')}(${names.length} 人)。` + : '最近收到的發言回報:沒有可對應的台上成員。'; + if (speakingSummary.textContent !== text) { speakingSummary.textContent = text; if (!valid()) return; } + if (speakingSummary.hidden) { speakingSummary.hidden = false; if (!valid()) return; } +} +interface PublicRosterPerson { + id: string; name: string; role: 'controller' | 'speaker'; muted: boolean; forceMuted: boolean; connected: boolean; +} +interface PublicRosterOwner { + client: Client; generation: number; roomId: string; participantId: string; + people: PublicRosterPerson[]; stale: boolean; awaitingState: boolean; reconnecting: boolean; +} +let publicRosterOwner: PublicRosterOwner | undefined; +let publicRosterEventRevision = 0, publicRosterRetirementRevision = 0; +let publicRosterCollapse = true; +const publicRosterRows = new Map(); +// Retirement is pure; the existing Close/Transfer fences run before this surface touches DOM. +function retirePublicRoster(): void { + publicRosterOwner = undefined; publicRosterCollapse = true; + publicRosterRetirementRevision++; +} +function publicRosterMounted(): boolean { + return document.querySelector('#public-roster') === publicRosterPanel + && document.querySelector('#public-roster-summary') === publicRosterSummary + && document.querySelector('#public-roster-notice') === publicRosterNotice + && document.querySelector('#public-roster-list') === publicRosterList + && publicRosterPanel.contains(publicRosterSummary) && publicRosterPanel.contains(publicRosterNotice) + && publicRosterPanel.contains(publicRosterList); +} +function observePublicRoster(current: Client, generation: number, expected?: PublicRosterOwner) { + const event = publicRosterEventRevision, retirement = publicRosterRetirementRevision; + const close = pendingRoomClose, closeOwner = roomCloseOwner, transfer = pendingTransfer, transferOwner = stageManagementOwner; + const stable = (): boolean => event === publicRosterEventRevision && retirement === publicRosterRetirementRevision + && current === client && current === controlsClient && generation === entryGeneration; + const invalidate = (lost: (owner: { client: Client; generation: number; roomId: string; participantId: string }) => boolean): void => { + if (close && close === pendingRoomClose && closeOwner === roomCloseOwner && (!closeOwner || lost(closeOwner))) close.invalidated = true; + if (transfer && transfer === pendingTransfer && transferOwner === stageManagementOwner && (!transferOwner || lost(transferOwner))) transfer.invalidated = true; + }; + const lose = (): undefined => { if (stable()) retirePublicRoster(); return undefined; }; + try { + // Check both original consents before any new public projection or mount getter. + reconcileSpeakingConsents(); + if (!stable()) return; + const state = current.state, status = current.status; + invalidate(owner => owner.client !== current || owner.generation !== generation || (status !== 'waiting' && status !== 'live')); + if (!stable()) return; + if (occupancyRetiredGeneration === generation || !['waiting', 'live', 'reconnecting'].includes(status)) return lose(); + const roomId: unknown = state?.roomId; + invalidate(owner => owner.roomId !== roomId); + if (!stable()) return; + if (typeof roomId !== 'string' || !roomId.trim() || (expected && roomId !== expected.roomId)) return lose(); + const participantId: unknown = state?.me?.participantId; + invalidate(owner => owner.participantId !== participantId); + if (!stable()) return; + if (typeof participantId !== 'string' || !participantId.trim() || (expected && participantId !== expected.participantId)) return lose(); + const mounted = publicRosterMounted(); + if (!stable()) return; + if (!mounted) return lose(); + reconcileSpeakingConsents(); + if (!stable()) return; + return { state, status, roomId, participantId }; + } catch { invalidate(() => true); return lose(); } +} +function reconcilePublicRoster(): void { + for (;;) { + const owner = publicRosterOwner; + if (!owner) return; + if (owner.client !== client || owner.client !== controlsClient || owner.generation !== entryGeneration) { retirePublicRoster(); return; } + const event = publicRosterEventRevision, retirement = publicRosterRetirementRevision; + const identity = observePublicRoster(owner.client, owner.generation, owner); + if (event !== publicRosterEventRevision || retirement !== publicRosterRetirementRevision || owner !== publicRosterOwner) continue; + if (!identity) return; + owner.reconnecting = identity.status === 'reconnecting'; + if (owner.reconnecting) owner.stale = true; + return; + } +} +function projectPublicRoster(state: StageEventMap['state']): PublicRosterPerson[] | undefined { + const speakers: unknown = state.speakers, controllerId: unknown = state.controllerId, limits = state.limits; + const limit: unknown = limits?.maxSpeakers; + if (!Array.isArray(speakers) || !limits || typeof limits !== 'object' || Array.isArray(limits) + || typeof controllerId !== 'string' || !controllerId.trim() || !decodeRoomName + || typeof limit !== 'number' || !Number.isSafeInteger(limit) || limit <= 0) return; + const ids = new Set(), people: PublicRosterPerson[] = []; + let controllers = 0, others = 0; + for (const person of speakers) { + if (!person || typeof person !== 'object' || Array.isArray(person)) return; + const id: unknown = person.participantId, name: unknown = person.name, role: unknown = person.role; + const onStage: unknown = person.onStage, muted: unknown = person.muted, forceMuted: unknown = person.forceMuted, connected: unknown = person.connected; + if (typeof id !== 'string' || !id.trim() || ids.has(id) || typeof name !== 'string' || onStage !== true + || (role !== 'controller' && role !== 'speaker') || (role === 'controller') !== (id === controllerId) + || typeof muted !== 'boolean' || typeof forceMuted !== 'boolean' || typeof connected !== 'boolean' || (forceMuted && !muted)) return; + ids.add(id); + if (role === 'controller') controllers++; else others++; + if (controllers > 1 || others > limit) return; + people.push({ id, name: decodeRoomName(name), role, muted, forceMuted, connected }); + } + return people; +} +function receivePublicRoster(current: Client, generation: number, detail: StageEventMap['state'], unseen: boolean, + event: number, entryRetirement: number): void { + let retirement = entryRetirement; + const stable = (): boolean => event === publicRosterEventRevision && retirement === publicRosterRetirementRevision + && current === client && current === controlsClient && generation === entryGeneration; + if (!stable() || !unseen) { renderPublicRoster(); return; } + // Keep the callback-entry retirement token across earlier surfaces and all projection getters. + const identity = observePublicRoster(current, generation); + if (!stable() || !identity || identity.state !== detail || identity.status === 'reconnecting') { renderPublicRoster(); return; } + const previous = publicRosterOwner; + if (previous && (previous.roomId !== identity.roomId || previous.participantId !== identity.participantId)) { + // Only this explicit, validated new-seat reset may advance the admission retirement token. + retirePublicRoster(); retirement++; + renderPublicRoster(); + if (!stable()) return; + } + const owner: PublicRosterOwner = { client: current, generation, roomId: identity.roomId, participantId: identity.participantId, + people: [], stale: false, awaitingState: false, reconnecting: false }; + let people: PublicRosterPerson[] | undefined; + try { people = projectPublicRoster(detail); } catch { /* A partial projection never replaces an accepted snapshot. */ } + if (!stable()) { renderPublicRoster(); return; } + const checked = observePublicRoster(current, generation, owner); + if (!stable()) { renderPublicRoster(); return; } + if (!checked || checked.state !== detail || checked.status === 'reconnecting') { retirePublicRoster(); renderPublicRoster(); return; } + if (!people) retirePublicRoster(); + else { owner.people = people; publicRosterOwner = owner; } + renderPublicRoster(); +} +function renderPublicRoster(): void { + for (;;) { + reconcileSpeakingConsents(); reconcilePublicRoster(); + const owner = publicRosterOwner, event = publicRosterEventRevision, retirement = publicRosterRetirementRevision; + const stable = (): boolean => owner === publicRosterOwner && event === publicRosterEventRevision && retirement === publicRosterRetirementRevision; + const hidden = !owner; + if (hidden && !publicRosterPanel.hidden) { publicRosterPanel.hidden = true; continue; } + if (publicRosterCollapse && publicRosterPanel.open) { publicRosterPanel.open = false; continue; } + if (!stable()) continue; + publicRosterCollapse = false; + const summary = owner ? `台上成員快照(${owner.people.length})` : '台上成員快照'; + const freshness = owner?.reconnecting ? '重新連線中,上次快照。' : (owner?.stale || owner?.awaitingState) ? '上次快照。' : '最近收到的快照。'; + const notice = owner ? `${freshness}${owner.awaitingState ? '成員狀態變更,等待伺服器快照。' : ''}${owner.people.length ? '' : '目前沒有台上成員。'}含斷線保留席位;不代表目前發言中或可聽見聲音。` : ''; + if (publicRosterSummary.textContent !== summary) { publicRosterSummary.textContent = summary; continue; } + if (publicRosterNotice.textContent !== notice) { publicRosterNotice.textContent = notice; continue; } + if (!stable()) continue; + const ids = new Set(owner?.people.map(person => person.id)); + for (const id of publicRosterRows.keys()) if (!ids.has(id)) publicRosterRows.delete(id); + let changed = false; + for (const person of owner?.people ?? []) { + let row = publicRosterRows.get(person.id); + if (!row) { + row = document.createElement('li'); + if (!stable()) { changed = true; break; } + publicRosterRows.set(person.id, row); + } + const text = `${person.name} · ${person.role === 'controller' ? '主控' : '發言者'} · ${person.muted ? '已靜音' : '未靜音'} · ${person.forceMuted ? '靜音已鎖定' : '靜音未鎖定'} · ${person.connected ? '控制連線已連接' : '控制連線中斷(保留席位)'}`; + if (row.textContent !== text) { row.textContent = text; changed = true; break; } + if (!stable()) { changed = true; break; } + } + if (changed || !stable()) continue; + const rows = owner?.people.map(person => publicRosterRows.get(person.id)!) ?? []; + const children = Array.from(publicRosterList.children); + if (!stable()) continue; + if (children.length !== rows.length || children.some((node, index) => node !== rows[index])) { publicRosterList.replaceChildren(...rows); continue; } + if (publicRosterPanel.hidden !== hidden) { publicRosterPanel.hidden = hidden; continue; } + if (stable()) return; + } +} +interface ConnectionStatsBinding { client: Client; generation: number } +interface ConnectionStatsOwner extends ConnectionStatsBinding { roomId: string; participantId: string } +interface ConnectionStatsReport { + inbound: { loss: number | undefined; rtt: number | undefined; buffer: number | undefined } | null; + outbound: { loss: number | undefined; rtt: number | undefined } | null; +} +let connectionStatsBinding: ConnectionStatsBinding | undefined; +let connectionStatsOwner: ConnectionStatsOwner | undefined; +let connectionStatsAttempt: object | undefined; +let connectionStatsReport: ConnectionStatsReport | undefined; +let connectionStatsFailed = false; +let connectionStatsTeardown = false; +let connectionStatsRevision = 0; +function retireConnectionStats(): void { + connectionStatsOwner = undefined; connectionStatsAttempt = undefined; + connectionStatsReport = undefined; connectionStatsFailed = false; connectionStatsRevision++; +} +// Pure departure fence: legacy Close/Transfer teardown must run before any new surface setter. +function retireConnectionStatsBinding(): void { + connectionStatsBinding = undefined; connectionStatsTeardown = true; retireConnectionStats(); +} +function connectionStatsMounted(): boolean { + return document.querySelector('#connection-stats-panel') === connectionStatsPanel + && document.querySelector('#connection-stats-update') === connectionStatsUpdate + && document.querySelector('#connection-stats-text') === connectionStatsText + && connectionStatsPanel.contains(connectionStatsUpdate) && connectionStatsPanel.contains(connectionStatsText); +} +function reconcileConnectionStats(): void { + for (;;) { + const binding = connectionStatsBinding, owner = connectionStatsOwner, revision = connectionStatsRevision; + const current = client, controls = controlsClient, generation = entryGeneration; + const closeAttempt = pendingRoomClose, closeAuthority = roomCloseOwner; + const transferAttempt = pendingTransfer, transferAuthority = stageManagementOwner; + const stable = (): boolean => binding === connectionStatsBinding && owner === connectionStatsOwner + && revision === connectionStatsRevision && current === client && controls === controlsClient && generation === entryGeneration; + // Observed authority loss is sticky before any next public getter can restore it. + // Exact captured tokens cannot invalidate a genuinely newer nested consent. + const invalidateObserved = (lost: (authority: ConnectionStatsOwner) => boolean): void => { + if (closeAttempt && pendingRoomClose === closeAttempt && roomCloseOwner === closeAuthority + && (!closeAuthority || lost(closeAuthority))) closeAttempt.invalidated = true; + if (transferAttempt && pendingTransfer === transferAttempt && stageManagementOwner === transferAuthority + && (!transferAuthority || lost(transferAuthority))) transferAttempt.invalidated = true; + }; + const observe = () => { + const state = current?.state ?? null, status = current?.status ?? 'disconnected'; + invalidateObserved(authority => authority.client !== current || current !== controls || authority.generation !== generation + || (status !== 'waiting' && status !== 'live')); + if (stable() && current && status === 'disconnected' && binding?.client === current) { + connectionStatsBinding = undefined; retireConnectionStats(); + } else if (stable() && owner && status !== 'waiting' && status !== 'live') retireConnectionStats(); + const room: unknown = state?.roomId; + invalidateObserved(authority => authority.roomId !== room); + if (stable() && owner && owner.roomId !== room) retireConnectionStats(); + const participant: unknown = state?.me?.participantId; + invalidateObserved(authority => authority.participantId !== participant); + if (stable() && owner && owner.participantId !== participant) retireConnectionStats(); + return { state, status, room, participant }; + }; + try { + const identity = observe(); + if (!stable()) continue; + const { room, participant, status } = identity; + const eligible = Boolean(binding && binding.client === current && binding.generation === generation && current === controls + && typeof room === 'string' && room.trim() && typeof participant === 'string' && participant.trim() + && (status === 'waiting' || status === 'live') && connectionStatsMounted()); + if (!stable()) continue; + if (owner && (!eligible || owner.client !== current || owner.generation !== generation)) { retireConnectionStats(); continue; } + // Role/connected/target eligibility belongs to the existing pure consent check. + reconcileSpeakingConsents(); + if (!stable()) continue; + const checked = observe(); + if (!stable() || identity.state !== checked.state || status !== checked.status + || room !== checked.room || participant !== checked.participant) continue; + if (eligible && !connectionStatsMounted()) continue; + if (!stable()) continue; + if (eligible && !owner) { + connectionStatsOwner = { client: current!, generation, roomId: room as string, participantId: participant as string }; + connectionStatsRevision++; + } + return; + } catch { + invalidateObserved(() => true); + if (!stable()) continue; + if (owner) { retireConnectionStats(); continue; } + return; + } + } +} +function connectionStatsMessage(): string { + const report = connectionStatsReport; + if (!report) return connectionStatsFailed ? '無法讀取連線統計,請稍後手動再試。' : '尚未手動讀取連線統計。'; + const loss = (value: number | undefined): string => value === undefined ? '—' : value.toFixed(1); + const ms = (value: number | undefined): string => value === undefined ? '—' : String(Math.round(value)); + const inbound = report.inbound, outbound = report.outbound; + const receive = inbound ? `接收掉包 ${loss(inbound.loss)}% · 接收 RTT ${ms(inbound.rtt)} ms · 接收緩衝 ${ms(inbound.buffer)} ms` : '接收:暫無統計'; + const send = outbound ? `傳送掉包 ${loss(outbound.loss)}% · 傳送 RTT ${ms(outbound.rtt)} ms` : '傳送:暫無統計'; + return `上次手動讀取(SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。` + + (connectionStatsFailed ? '本次讀取失敗,仍顯示上次手動讀取結果。' : ''); +} +function syncConnectionStats(): void { + for (;;) { + if (connectionStatsTeardown) return; + reconcileConnectionStats(); + if (connectionStatsTeardown) return; + const revision = connectionStatsRevision, owner = connectionStatsOwner, attempt = connectionStatsAttempt; + const hidden = !owner, busy = Boolean(attempt), disabled = hidden || busy; + const text = hidden ? '' : connectionStatsMessage(); + // Reconcile after every changed setter, even when an old setter completes after a nested render. + if (hidden && !connectionStatsPanel.hidden) { connectionStatsPanel.hidden = true; continue; } + if (connectionStatsText.textContent !== text) { connectionStatsText.textContent = text; continue; } + if (connectionStatsUpdate.disabled !== disabled) { connectionStatsUpdate.disabled = disabled; continue; } + if (connectionStatsUpdate.getAttribute('aria-busy') !== String(busy)) { + connectionStatsUpdate.setAttribute('aria-busy', String(busy)); continue; + } + const label = busy ? '讀取中…' : '更新我的連線統計'; + if (connectionStatsUpdate.textContent !== label) { connectionStatsUpdate.textContent = label; continue; } + if (connectionStatsPanel.hidden !== hidden) { connectionStatsPanel.hidden = hidden; continue; } + reconcileConnectionStats(); + if (revision === connectionStatsRevision && owner === connectionStatsOwner && attempt === connectionStatsAttempt) return; + } +} +function finishConnectionStatsTeardown(generation: number): void { + if (generation !== entryGeneration) return; + connectionStatsTeardown = false; syncConnectionStats(); +} +function projectConnectionStats(raw: unknown, current: () => boolean): ConnectionStatsReport | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(); + const direction = (key: 'inbound' | 'outbound') => { + const value: unknown = (raw as Record)[key]; + if (!current()) return undefined; + if (value === null || value === undefined) return null; + if (typeof value !== 'object' || Array.isArray(value)) throw new Error(); + return value as Record; + }; + const numeric = (value: unknown, max = Infinity, factor = 1): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > max) return undefined; + const converted = value * factor; + return Number.isFinite(converted) ? converted : undefined; + }; + const inbound = direction('inbound'); + if (!current()) return; + let receive: ConnectionStatsReport['inbound'] = null; + if (inbound) { + const loss = numeric(inbound.lossPercent, 100); if (!current()) return; + const rtt = numeric(inbound.rtt, Infinity, 1000); if (!current()) return; + const buffer = numeric(inbound.bufferMs); if (!current()) return; + receive = { loss, rtt, buffer }; + } + const outbound = direction('outbound'); + if (!current()) return; + let send: ConnectionStatsReport['outbound'] = null; + if (outbound) { + const loss = numeric(outbound.lossPercent, 100); if (!current()) return; + const rtt = numeric(outbound.rtt, Infinity, 1000); if (!current()) return; + send = { loss, rtt }; + } + return { inbound: receive, outbound: send }; +} +function refreshConnectionStats(): void { + if (connectionStatsTeardown || connectionStatsUpdate.disabled || connectionStatsUpdate.hidden || connectionStatsPanel.hidden + || !connectionStatsMounted()) return; + syncConnectionStats(); + const owner = connectionStatsOwner; + if (!owner || connectionStatsAttempt || connectionStatsTeardown) return; + // Reserve before the public method getter, invocation, or busy setters can reenter. + const attempt = connectionStatsAttempt = {}; + connectionStatsFailed = false; connectionStatsRevision++; + const current = (): boolean => { + syncConnectionStats(); + return connectionStatsOwner === owner && connectionStatsAttempt === attempt && !connectionStatsTeardown; + }; + void (async () => { + try { + if (!current()) return; + const read = owner.client.getStats; + if (!current()) return; + if (typeof read !== 'function') throw new Error(); + const pending = read.call(owner.client); + const active = current(); + // Consume retired native work: a retained peer can still advance the SDK's shared delta baseline. + const raw: unknown = await pending; + if (!current() || !active) return; + const report = projectConnectionStats(raw, current); + if (!current() || !report) return; + connectionStatsReport = report; connectionStatsRevision++; + } catch { if (current()) { connectionStatsFailed = true; connectionStatsRevision++; } } + finally { + if (connectionStatsOwner === owner && connectionStatsAttempt === attempt) { + connectionStatsAttempt = undefined; connectionStatsRevision++; + } + syncConnectionStats(); + } + })(); +} let feedbackRevision = 0; let handRejectionOwner: { client: Client; roomId: string; participantId: string; generation: number } | undefined; function retireHandRejection(): void { @@ -130,16 +788,21 @@ function hideCancel(): void { cancelEntry = undefined; cancelJoin.hidden = cancelJoin.disabled = true; } function retireControls(): void { + retirePublicRoster(); + retireMicTestBinding(); + retireConnectionStatsBinding(); controlsClient = undefined; roomCloseFence = undefined; retireRoomClose(); retireHandRejection(); retireHandQueue(); retireStageManagement(); pendingHand = undefined; pendingMic = undefined; pendingStage = undefined; micRetryParticipant = undefined; pendingMicRetry = undefined; - pendingRename = undefined; syncRename(); retireRoomName(); retireMicrophone(); retireAudioRetry(); retireOutput(); + pendingRename = undefined; syncRename(); retireRoomName(); retireMicrophone(); retireAudioRetry(); retireOutput(); syncMicTest(); } /** Retire initial-join feedback before user or terminal callbacks can start another entry. */ function retireEntry(): number { + retireConnectionStatsBinding(); + const generation = ++entryGeneration; retireMicTestBinding(); retireOccupancy(); retireQuality(); retireSpeaking(); retirePublicRoster(); hideCancel(); submit.disabled = false; submit.setAttribute('aria-busy', 'false'); - return ++entryGeneration; + return generation; } let handTimer: ReturnType | undefined; let handTimerDue = 0; @@ -194,41 +857,104 @@ function syncRoomName(): void { roomNameUpdate.disabled = current.status === 'reconnecting' || Boolean(roomNameAttempt); roomNameUpdate.setAttribute('aria-busy', String(Boolean(roomNameAttempt))); } +function invalidateHandQueue(): void { + handQueueOwner = undefined; handQueuePending.clear(); handQueueQuery = ''; handQueueRevision++; +} function retireHandQueue(): void { - handQueueOwner = undefined; handQueuePending.clear(); - for (const row of handQueueRows.values()) { - for (const button of [row.approve, row.reject]) { button.disabled = true; button.setAttribute('aria-busy', 'false'); } - } - if (handQueueRows.size) handQueueList.replaceChildren(); + reconcileSpeakingConsents(); + invalidateHandQueue(); + const revision = handQueueRevision, generation = entryGeneration, current = client, rows = [...handQueueRows.values()]; handQueueRows.clear(); handQueueOrder = []; - handQueuePanel.hidden = true; handQueuePanel.open = false; - handQueueSummary.textContent = ''; handQueueNotice.textContent = ''; + const valid = (): boolean => { + if (revision === handQueueRevision && !handQueueOwner && generation === entryGeneration && current === client) return true; + syncHandQueue(); return false; + }; + for (const row of rows) for (const button of [row.approve, row.reject]) { + button.disabled = true; if (!valid()) return; + button.setAttribute('aria-busy', 'false'); if (!valid()) return; + } + if (rows.length) { handQueueList.replaceChildren(); if (!valid()) return; } + handQueuePanel.hidden = true; if (!valid()) return; + handQueuePanel.open = false; if (!valid()) return; + handQueueSummary.textContent = ''; if (!valid()) return; + handQueueNotice.textContent = ''; if (!valid()) return; + handQueueSearch.disabled = true; if (!valid()) return; + if (handQueueSearch.value !== '') { if (!valid()) return; handQueueSearch.value = ''; } + if (!valid()) return; + handQueueClear.disabled = true; if (!valid()) return; + handQueueResults.textContent = ''; valid(); } +function handQueueInputCurrent(owner: NonNullable, revision: number): boolean { + if (handQueueOwner !== owner || handQueueRevision !== revision || client !== owner.client + || controlsClient !== owner.client || entryGeneration !== owner.generation) return false; + const state = owner.client.state, roomId = state?.roomId, participantId = state?.me?.participantId; + const role = state?.me?.role, status = owner.client.status, latestState = owner.client.state; + return handQueueOwner === owner && handQueueRevision === revision && client === owner.client + && controlsClient === owner.client && entryGeneration === owner.generation && latestState === state + && roomId === owner.roomId && participantId === owner.participantId && role === 'controller' + && (status === 'waiting' || status === 'live' || status === 'reconnecting'); +} +function filterHandQueue(clear: boolean): void { + const owner = handQueueOwner, revision = handQueueRevision; + if (!owner || !handQueueInputCurrent(owner, revision)) { syncHandQueue(); return; } + const unavailable = handQueuePanel.hidden || handQueueSearch.disabled; + if (!handQueueInputCurrent(owner, revision)) { syncHandQueue(); return; } + if (unavailable) return; + const query = clear ? '' : handQueueSearch.value; + // An input getter may run a newer handler or replace the controller seat. + if (!handQueueInputCurrent(owner, revision)) { syncHandQueue(); return; } + handQueueQuery = query; handQueueRevision++; syncHandQueue(); +} +handQueueSearch.oninput = () => filterHandQueue(false); +handQueueClear.onclick = () => filterHandQueue(true); function syncHandQueue(): void { - const current = client, state = current?.state; - const active = current?.status === 'waiting' || current?.status === 'live'; - if (!current || current !== controlsClient || !state || state.me.role !== 'controller' || !decodeRoomName - || (!active && current.status !== 'reconnecting')) { retireHandQueue(); return; } - const owner = handQueueOwner; - if (!owner || owner.client !== current || owner.generation !== entryGeneration - || owner.roomId !== state.roomId || owner.participantId !== state.me.participantId) { + let revision = ++handQueueRevision; + const current = client, generation = entryGeneration, state = current?.state; + const roomId = state?.roomId, participantId = state?.me?.participantId, role = state?.me?.role, status = current?.status; + const unchanged = (): boolean => revision === handQueueRevision && current === client && generation === entryGeneration; + if (!unchanged()) { syncHandQueue(); return; } + const active = status === 'waiting' || status === 'live'; + if (!current || current !== controlsClient || !state || role !== 'controller' || !decodeRoomName + || (!active && status !== 'reconnecting')) { + retireHandQueue(); return; + } + let owner = handQueueOwner; + if (!owner || owner.client !== current || owner.generation !== generation + || owner.roomId !== roomId || owner.participantId !== participantId) { retireHandQueue(); + revision = handQueueRevision; + const latestState = current.state, latestStatus = current.status; + const latestRoomId = latestState?.roomId, latestId = latestState?.me?.participantId, latestRole = latestState?.me?.role; + if (!unchanged() || handQueueOwner || current !== controlsClient || latestState !== state || latestStatus !== status + || latestRoomId !== roomId || latestId !== participantId || latestRole !== 'controller') { syncHandQueue(); return; } // Reconnect may retain an owned controller seat, but cannot establish a new tenure. if (!active) return; - handQueueOwner = { client: current, roomId: state.roomId, participantId: state.me.participantId, generation: entryGeneration }; + handQueueOwner = owner = { client: current, roomId: roomId!, participantId: participantId!, generation }; + revision = ++handQueueRevision; } + const valid = (): boolean => { + if (handQueueInputCurrent(owner, revision) && current.state === state && current.status === status && unchanged()) return true; + // Preserve observed consent loss before corrective search DOM can reenter another control. + reconcileSpeakingConsents(); syncHandQueue(); return false; + }; + if (!valid()) return; const seen = new Set(); const people = state.hands.filter(person => { - if (!person.participantId || person.participantId === state.me.participantId || seen.has(person.participantId) + if (!person.participantId || person.participantId === participantId || seen.has(person.participantId) || person.role !== 'audience' || !person.handRaised || person.onStage) return false; seen.add(person.participantId); return true; }); + const query = handQueueQuery.trim().toLowerCase(); const supported = typeof current.approve === 'function' && typeof current.reject === 'function'; - handQueuePanel.hidden = false; + if (!valid()) return; + handQueuePanel.hidden = false; if (!valid()) return; handQueueSummary.textContent = `舉手申請(${people.length})${active ? '' : ' · 上次收到'}`; + if (!valid()) return; handQueueNotice.textContent = !active ? '重新連線中,暫時不能處理申請。' : !supported ? '此 SDK 的部分申請處理功能無法使用。' : people.length ? '請選擇要核准上台或婉拒的申請;是否可上台仍由伺服器決定。' : '目前沒有舉手申請。'; + if (!valid()) return; + let matching = 0; for (const person of people) { const id = person.participantId; let row = handQueueRows.get(id); @@ -241,39 +967,79 @@ function syncHandQueue(): void { const original = row; approve.onclick = () => moderateHand(id, original, 'approve'); reject.onclick = () => moderateHand(id, original, 'reject'); - actions.append(approve, reject); node.append(name, actions); handQueueRows.set(id, row); + actions.append(approve, reject); node.append(name, actions); + if (!valid()) return; + handQueueRows.set(id, row); } - const name = decodeRoomName(person.name), label = `${name}${person.connected ? '' : '(暫時離線)'}`; - if (row.name.textContent !== label) row.name.textContent = label; + const name = decodeRoomName(person.name), hidden = !name.toLowerCase().includes(query); + const label = `${name}${person.connected ? '' : '(暫時離線)'}`; + if (!hidden) matching++; + if (!valid()) return; + if (row.name.textContent !== label) { if (!valid()) return; row.name.textContent = label; } + if (!valid()) return; + if (row.node.hidden !== hidden) { if (!valid()) return; row.node.hidden = hidden; } + if (!valid()) return; const labels = { approve: `核准 ${name} 上台`, reject: `婉拒 ${name} 的舉手申請` }; for (const kind of ['approve', 'reject'] as const) { const button = row[kind], pending = handQueuePending.has(id); - if (button.getAttribute('aria-label') !== labels[kind]) button.setAttribute('aria-label', labels[kind]); - const disabled = !active || pending || typeof current[kind] !== 'function', busy = String(pending); - if (button.disabled !== disabled) button.disabled = disabled; - if (button.getAttribute('aria-busy') !== busy) button.setAttribute('aria-busy', busy); + if (button.getAttribute('aria-label') !== labels[kind]) { if (!valid()) return; button.setAttribute('aria-label', labels[kind]); } + if (!valid()) return; + const disabled = !active || pending || hidden || typeof current[kind] !== 'function', busy = String(pending); + if (!valid()) return; + if (button.disabled !== disabled) { if (!valid()) return; button.disabled = disabled; } + if (!valid()) return; + if (button.getAttribute('aria-busy') !== busy) { if (!valid()) return; button.setAttribute('aria-busy', busy); } + if (!valid()) return; } } for (const [id, row] of handQueueRows) if (!seen.has(id)) { - for (const button of [row.approve, row.reject]) { button.disabled = true; button.setAttribute('aria-busy', 'false'); } handQueueRows.delete(id); + for (const button of [row.approve, row.reject]) { + button.disabled = true; if (!valid()) return; + button.setAttribute('aria-busy', 'false'); if (!valid()) return; + } } const order = people.map(person => person.participantId); + if (!valid()) return; if (order.length !== handQueueOrder.length || order.some((id, index) => id !== handQueueOrder[index])) { - handQueueList.replaceChildren(...order.map(id => handQueueRows.get(id)!.node)); handQueueOrder = order; + handQueueList.replaceChildren(...order.map(id => handQueueRows.get(id)!.node)); + if (!valid()) return; + handQueueOrder = order; } + if (handQueueSearch.disabled) { if (!valid()) return; handQueueSearch.disabled = false; } + if (!valid()) return; + if (handQueueSearch.value !== handQueueQuery) { if (!valid()) return; handQueueSearch.value = handQueueQuery; } + if (!valid()) return; + const clearDisabled = !handQueueQuery; + if (handQueueClear.disabled !== clearDisabled) { if (!valid()) return; handQueueClear.disabled = clearDisabled; } + if (!valid()) return; + const results = `符合 ${matching} / ${people.length} 筆舉手申請。${people.length && !matching ? '沒有符合搜尋的舉手者。' : ''}`; + if (handQueueResults.textContent !== results) { if (!valid()) return; handQueueResults.textContent = results; } + valid(); } -function currentHandRow(id: string, row: HandQueueRow, kind: 'approve' | 'reject'): boolean { - return handQueueRows.get(id) === row && row.node.parentElement === handQueueList - && row.node.contains(row[kind]) && !handQueuePanel.hidden; +function currentHandRow(owner: NonNullable, id: string, row: HandQueueRow, kind: 'approve' | 'reject'): boolean { + const revision = handQueueRevision, query = handQueueQuery; + const attached = handQueueRows.get(id) === row && row.node.parentElement === handQueueList + && row.node.contains(row[kind]) && !handQueuePanel.hidden && !row.node.hidden; + const owned = handQueueInputCurrent(owner, revision); + const state = owner.client.state, status = owner.client.status; + const person = state?.hands.find(person => person.participantId === id && id !== owner.participantId + && person.role === 'audience' && person.handRaised && !person.onStage); + // Read the authoritative name after DOM getters: hidden/disabled flags cannot grant eligibility. + const matches = person && decodeRoomName!(person.name).toLowerCase().includes(query.trim().toLowerCase()); + const latestState = owner.client.state, latestStatus = owner.client.status; + return Boolean(owned && matches && attached && (status === 'waiting' || status === 'live') + && latestState === state && latestStatus === status && handQueueOwner === owner && client === owner.client + && controlsClient === owner.client && entryGeneration === owner.generation && handQueueQuery === query + && handQueueRows.get(id) === row && handQueueRevision === revision); } function moderateHand(id: string, row: HandQueueRow, kind: 'approve' | 'reject'): void { syncHandQueue(); const owner = handQueueOwner; - if (!owner || !currentHandRow(id, row, kind) || handQueuePending.has(id) - || (owner.client.status !== 'waiting' && owner.client.status !== 'live') - || typeof owner.client[kind] !== 'function') return; - // A target may disappear in state before its ACK, so pending belongs to the tenure, not its row. + if (!owner || handQueuePending.has(id) || !currentHandRow(owner, id, row, kind)) return; + const method = owner.client[kind]; + if (typeof method !== 'function' || !currentHandRow(owner, id, row, kind) || handQueuePending.has(id)) return; + // A target may disappear or be filtered before its ACK, so pending belongs to the tenure, not visibility. const attempt = {}; handQueuePending.set(id, attempt); syncHandQueue(); const current = (): boolean => { @@ -281,14 +1047,15 @@ function moderateHand(id: string, row: HandQueueRow, kind: 'approve' | 'reject') syncHandQueue(); return handQueueOwner === owner && handQueuePending.get(id) === attempt; }; - const eligible = (): boolean => current() && currentHandRow(id, row, kind) - && (owner.client.status === 'waiting' || owner.client.status === 'live'); + const eligible = (): boolean => current() && currentHandRow(owner, id, row, kind); void (async () => { try { - if (!eligible() || typeof owner.client[kind] !== 'function') return; - await owner.client[kind](id); + if (!eligible()) return; + const method = owner.client[kind]; + if (typeof method !== 'function' || !eligible()) return; + await method.call(owner.client, id); } catch (error) { - // Promise-specific feedback requires the original row; global SDK errors retain their existing policy. + // Promise-specific feedback requires the original matching row; global SDK errors keep their existing policy. if (eligible()) report(error); } finally { if (current()) { handQueuePending.delete(id); syncHandQueue(); } @@ -725,6 +1492,180 @@ function reportMicrophoneError(owner: NonNullable, attem if (ownsMicrophoneAttempt(owner, attempt) && !microphonePanel.hidden && (owner.client.status === 'waiting' || owner.client.status === 'live')) report(error); } +interface MicTestOwner { client: Client; generation: number; roomId: string; participantId: string; message: string } +interface MicTestAttempt { + owner: MicTestOwner; controller: AbortController; test?: MicTest; timer?: ReturnType; level: number; +} +let micTestBinding: { client: Client; generation: number } | undefined; +let micTestOwner: MicTestOwner | undefined; +let micTestAttempt: MicTestAttempt | undefined; +// Abort settles the SDK request before native permission resolves; reserve until that settlement. +let micTestPending: MicTestAttempt | undefined; +let micTestFence: { client: Client; generation: number; roomId: string; participantId: string } | undefined; +let micTestPageHidden = false; +let micTestRevision = 0; +function stopMicTestAttempt(): void { + const attempt = micTestAttempt; + micTestAttempt = undefined; micTestRevision++; + if (!attempt) return; + if (attempt.timer !== undefined) { clearTimeout(attempt.timer); attempt.timer = undefined; } + // Retire result authority before SDK cleanup can synchronously invoke application callbacks. + try { attempt.controller.abort(); } catch { /* Cleanup still owns the returned handle below. */ } + try { attempt.test?.stop(); } catch { /* A faulty host implementation must not restore ownership. */ } +} +function retireMicTest(): void { + micTestOwner = undefined; micTestRevision++; stopMicTestAttempt(); +} +function retireMicTestBinding(): void { + micTestBinding = undefined; retireMicTest(); +} +function micTestMounted(): boolean { + return document.querySelector('#mic-test-panel') === micTestPanel + && [micTestStart, micTestCancel, micTestStop, micTestMeter, micTestStatus].every(node => micTestPanel.contains(node)) + && document.querySelector('#mic-test-start') === micTestStart && document.querySelector('#mic-test-cancel') === micTestCancel + && document.querySelector('#mic-test-stop') === micTestStop && document.querySelector('#mic-test-meter') === micTestMeter + && document.querySelector('#mic-test-status') === micTestStatus; +} +function reconcileMicTest(): void { + for (;;) { + const current = client, controls = controlsClient, generation = entryGeneration; + const binding = micTestBinding, owner = micTestOwner, attempt = micTestAttempt, revision = micTestRevision; + const close = pendingRoomClose, closeOwner = roomCloseOwner, transfer = pendingTransfer, transferOwner = stageManagementOwner; + const stable = (): boolean => current === client && controls === controlsClient && generation === entryGeneration + && binding === micTestBinding && owner === micTestOwner && attempt === micTestAttempt && revision === micTestRevision; + const invalidate = (lost: (seat: ConnectionStatsOwner) => boolean): void => { + if (close && pendingRoomClose === close && roomCloseOwner === closeOwner && (!closeOwner || lost(closeOwner))) close.invalidated = true; + if (transfer && pendingTransfer === transfer && stageManagementOwner === transferOwner && (!transferOwner || lost(transferOwner))) transfer.invalidated = true; + }; + try { + const state = current?.state, status = current?.status; + invalidate(seat => seat.client !== current || current !== controls || seat.generation !== generation + || (status !== 'waiting' && status !== 'live')); + if (stable() && binding?.client === current && status === 'disconnected') { retireMicTestBinding(); continue; } + if (stable() && owner && status !== 'waiting' && status !== 'live') { retireMicTest(); continue; } + const room: unknown = state?.roomId; + invalidate(seat => seat.roomId !== room); + if (stable() && owner && owner.roomId !== room) { retireMicTest(); continue; } + const participant: unknown = state?.me?.participantId; + invalidate(seat => seat.participantId !== participant); + if (stable() && owner && owner.participantId !== participant) { retireMicTest(); continue; } + const offstage = state?.me?.onStage === false; + if (!stable()) continue; + const fenced = micTestFence?.client === current && micTestFence.generation === generation + && micTestFence.roomId === room && micTestFence.participantId === participant; + const eligible = current && binding?.client === current && binding.generation === generation + && current === controls && occupancyRetiredGeneration !== generation + && (status === 'waiting' || status === 'live') && offstage && !fenced + && typeof room === 'string' && room.trim() && typeof participant === 'string' && participant.trim() + && !document.hidden && !micTestPageHidden && micTestMounted(); + if (!stable()) continue; + if (owner && (!eligible || owner.client !== current || owner.generation !== generation)) { retireMicTest(); continue; } + reconcileSpeakingConsents(); + if (!stable()) continue; + if (current?.state !== state || current?.status !== status || state?.roomId !== room + || state?.me?.participantId !== participant || (state?.me?.onStage === false) !== offstage) continue; + if (!stable()) continue; + if (eligible && !owner) { + micTestOwner = { client: current, generation, roomId: room as string, participantId: participant as string, + message: '尚未測試麥克風。' }; micTestRevision++; + } + return; + } catch { + invalidate(() => true); + if (!stable()) continue; + if (owner) { retireMicTest(); continue; } + return; + } + } +} +function syncMicTest(): void { + for (;;) { + reconcileMicTest(); + const binding = micTestBinding, owner = micTestOwner, attempt = micTestAttempt, revision = micTestRevision; + const hidden = !owner, active = Boolean(attempt?.test), pending = Boolean(attempt && !attempt.test); + const busy = Boolean(micTestPending), disabled = hidden || Boolean(attempt) || busy || pendingMicrophone?.kind === 'apply'; + const text = hidden ? '' : pending ? '正在要求麥克風權限…' : active ? '正在測試音量;這不是回放,也不代表已上台。' : owner.message; + const level = !hidden && active ? attempt!.level : 0; + if (hidden && !micTestPanel.hidden) { micTestPanel.hidden = true; continue; } + if (micTestStatus.textContent !== text) { micTestStatus.textContent = text; continue; } + if (micTestMeter.value !== level) { micTestMeter.value = level; continue; } + if (micTestStart.disabled !== disabled) { micTestStart.disabled = disabled; continue; } + if (micTestStart.getAttribute('aria-busy') !== String(busy)) { micTestStart.setAttribute('aria-busy', String(busy)); continue; } + if (micTestCancel.hidden !== !pending) { micTestCancel.hidden = !pending; continue; } + if (micTestCancel.disabled !== !pending) { micTestCancel.disabled = !pending; continue; } + if (micTestStop.hidden !== !active) { micTestStop.hidden = !active; continue; } + if (micTestStop.disabled !== !active) { micTestStop.disabled = !active; continue; } + if (micTestPanel.hidden !== hidden) { micTestPanel.hidden = hidden; continue; } + reconcileMicTest(); + if (binding === micTestBinding && owner === micTestOwner && attempt === micTestAttempt && revision === micTestRevision) return; + } +} +function ownsMicTest(attempt: MicTestAttempt): boolean { + reconcileMicTest(); + return micTestOwner === attempt.owner && micTestAttempt === attempt; +} +function sampleMicTest(attempt: MicTestAttempt): void { + if (!ownsMicTest(attempt) || !attempt.test) { syncMicTest(); return; } + const test = attempt.test; + try { + const stopped = test.stopped; + if (!ownsMicTest(attempt)) { syncMicTest(); return; } + if (stopped) { attempt.owner.message = '麥克風測試已結束。'; stopMicTestAttempt(); syncMicTest(); return; } + const level = test.level(); + if (!ownsMicTest(attempt)) { syncMicTest(); return; } + const ended = test.stopped; + if (!ownsMicTest(attempt)) { syncMicTest(); return; } + if (ended) { attempt.owner.message = '麥克風測試已結束。'; stopMicTestAttempt(); syncMicTest(); return; } + attempt.level = typeof level === 'number' && Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : 0; + micTestRevision++; syncMicTest(); + if (ownsMicTest(attempt)) attempt.timer = setTimeout(() => { + attempt.timer = undefined; sampleMicTest(attempt); + }, 100); + else syncMicTest(); + } catch { + if (ownsMicTest(attempt)) { attempt.owner.message = '無法讀取麥克風音量,請手動再試。'; stopMicTestAttempt(); } + syncMicTest(); + } +} +function startMicTest(): void { + if (micTestPanel.hidden || micTestStart.hidden || micTestStart.disabled || !micTestMounted()) return; + syncMicTest(); + const owner = micTestOwner; + if (!owner || micTestAttempt || micTestPending || pendingMicrophone?.kind === 'apply') return; + const attempt: MicTestAttempt = { owner, controller: new AbortController(), level: 0 }; + micTestAttempt = micTestPending = attempt; micTestRevision++; + void (async () => { + try { + syncMicTest(); + if (!ownsMicTest(attempt)) return; + const start = owner.client.startMicTest; + if (!ownsMicTest(attempt)) return; + const test = await start.call(owner.client, { signal: attempt.controller.signal }); + if (!ownsMicTest(attempt)) { test.stop(); return; } + attempt.test = test; micTestRevision++; sampleMicTest(attempt); + } catch { + if (ownsMicTest(attempt)) { owner.message = '無法開始麥克風測試,請檢查權限或裝置後手動再試。'; stopMicTestAttempt(); } + } finally { + if (micTestPending === attempt) { micTestPending = undefined; micTestRevision++; } + syncMicTest(); + } + })(); +} +function cancelMicTest(active: boolean): void { + const button = active ? micTestStop : micTestCancel; + if (micTestPanel.hidden || button.hidden || button.disabled || !micTestMounted()) return; + reconcileMicTest(); + const attempt = micTestAttempt; + if (!attempt || Boolean(attempt.test) !== active) { syncMicTest(); return; } + attempt.owner.message = active ? '已停止麥克風測試。' : '已取消麥克風測試;若權限視窗仍顯示,稍後取得的音軌會立即釋放。'; + stopMicTestAttempt(); syncMicTest(); +} +function fenceMicTest(current: Client, generation: number, participantId: string): void { + const state = current.state; + if (current !== client || current !== controlsClient || generation !== entryGeneration || state?.me?.participantId !== participantId) return; + micTestFence = { client: current, generation, roomId: state.roomId, participantId }; + retireMicTest(); syncMicTest(); +} function replaceOutputOptions(outputs: readonly MediaDeviceInfo[]): void { const initial = document.createElement('option'); initial.value = ''; initial.textContent = '系統預設'; @@ -860,13 +1801,20 @@ function signalingUrl(host: URL, value: string): URL { return url; } join.onsubmit = (event) => { + retireConnectionStatsBinding(); event.preventDefault(); + const previous = client, generation = ++entryGeneration; + retireMicTestBinding(); + retireOccupancy(); retireQuality(); retireSpeaking(); retirePublicRoster(); clearError(); const hostValue = input('host').value, pathValue = input('ws-path').value; const joinOptions = { roomId: input('room').value.trim(), code: input('code').value.trim() || undefined, name: input('name').value.trim() }; retirePlayback(); scheduleHandCooldown(0); - const generation = ++entryGeneration, feedback = feedbackRevision; + const feedback = feedbackRevision; let progress = true, receivedState = false, cancelledEntry = false; + const occupancyReceipts = new WeakSet(); + const qualityReceipts = new WeakSet(); + const speakingReceipts = new WeakSet(); let entryClient: Client | undefined; const showProgress = (message: string): void => { if (generation === entryGeneration && feedback === feedbackRevision && progress) status.textContent = message; @@ -880,6 +1828,12 @@ join.onsubmit = (event) => { retirePlayback(); scheduleHandCooldown(0); retireControls(); hand.disabled = leave.disabled = true; hand.setAttribute('aria-busy', 'false'); syncMic(); syncStage(); syncMicRetry(); + renderOccupancy(); + if (cancelled === entryGeneration) renderQuality(); + if (cancelled === entryGeneration) renderSpeaking(); + if (cancelled === entryGeneration) renderPublicRoster(); + finishConnectionStatsTeardown(cancelled); + if (cancelled !== entryGeneration) return; try { const stopping = entryClient?.disconnect(); if (cancelled === entryGeneration) status.textContent = '已取消連線。'; @@ -894,7 +1848,13 @@ join.onsubmit = (event) => { stage.hidden = stage.disabled = true; stage.setAttribute('aria-busy', 'false'); syncMicRetry(); hand.setAttribute('aria-busy', 'false'); hand.disabled = leave.disabled = true; - await client?.disconnect(); + renderOccupancy(); + if (generation === entryGeneration) renderQuality(); + if (generation === entryGeneration) renderSpeaking(); + if (generation === entryGeneration) renderPublicRoster(); + finishConnectionStatsTeardown(generation); + if (generation !== entryGeneration) return; + await previous?.disconnect(); if (generation !== entryGeneration) return; const host = new URL(hostValue); if (!['https:', 'http:'].includes(host.protocol)) throw new Error('請輸入 HTTP 或 HTTPS 主機網址。'); @@ -904,14 +1864,37 @@ join.onsubmit = (event) => { // The host is selected by this page's visitor, not known at build time. const library: typeof StageLibrary = await import(/* @vite-ignore */ moduleUrl); if (generation !== entryGeneration) return; - // This compact UI has no room-level meter; explicit preview APIs remain available. + // Stage capture stays meter-free; the separate explicit preview owns its own meter. client = new library.StageClient({ url: ws.href, audioElement: audio, enableMicMeter: false }); const current = client; controlsClient = current; entryClient = current; decodeRoomName = library.decodeName; + connectionStatsBinding = { client: current, generation }; connectionStatsTeardown = false; + micTestBinding = { client: current, generation }; maxGainDb = typeof library.MAX_GAIN_DB === 'number' && Number.isFinite(library.MAX_GAIN_DB) && library.MAX_GAIN_DB > 0 ? library.MAX_GAIN_DB : undefined; syncUnlock(); client.on('state', ({ detail }: CustomEvent) => { - if (client !== current || cancelledEntry) return; + // Record rejected receipts too: their mutable objects cannot later become a new admission. + const unseen = detail && typeof detail === 'object' && !occupancyReceipts.has(detail); + if (detail && typeof detail === 'object') occupancyReceipts.add(detail); + if (client !== current || cancelledEntry || generation !== entryGeneration || occupancyRetiredGeneration === generation || controlsClient !== current) return; + const rosterEvent = ++publicRosterEventRevision, rosterRetirement = publicRosterRetirementRevision; + if (detail === current.state && unseen) micTestFence = undefined; + reconcileMicTest(); + // SDK installs state before status; only its actual, previously unseen state receipt is fresh. + const speakingStateValid = receiveSpeakingState(current, generation, detail, Boolean(unseen)); + if (!speakingStateValid) renderSpeaking(); + // Public projection getters may have replaced this entry before legacy reconciliation. + if (client !== current || controlsClient !== current || generation !== entryGeneration || cancelledEntry) return; + reconcileQuality(); + if (detail === current.state && unseen && eligibleQualitySeat(current) && !qualitySeat) { + qualitySeat = { client: current, generation, roomId: detail.roomId, participantId: detail.me.participantId }; + qualityRevision++; + } + reconcileOccupancy(); + if (detail === current.state && unseen + && (current.status === 'waiting' || current.status === 'live')) { + occupancyReport = projectOccupancy(current, generation, detail); occupancyRevision++; + } if (generation === entryGeneration && controlsClient === current && stageManagementOwner?.client === current) stageManagementOwner.transferFenced = false; if (generation === entryGeneration && controlsClient === current) { @@ -925,27 +1908,70 @@ join.onsubmit = (event) => { if (generation === entryGeneration) hideCancel(); progress = false; status.textContent = `${library.decodeName(detail.name)} · ${library.decodeName(detail.me.name)} · ${detail.me.onStage ? '已上台' : '收聽中'}`; - syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); - if (renderingEntry === entryGeneration && client === current && controlsClient === current) leave.disabled = false; + syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); syncMicTest(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) { + leave.disabled = false; renderOccupancy(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderQuality(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) renderSpeaking(); + if (renderingEntry === entryGeneration && client === current && controlsClient === current) syncConnectionStats(); + receivePublicRoster(current, generation, detail, Boolean(unseen), rosterEvent, rosterRetirement); + } + }); + client.on('quality', ({ detail }) => { + const unseen = !detail || typeof detail !== 'object' || !qualityReceipts.has(detail); + if (detail && typeof detail === 'object') qualityReceipts.add(detail); + if (client !== current || cancelledEntry || generation !== entryGeneration || controlsClient !== current) return; + reconcileQuality(); + const seat = qualitySeat, revision = qualityRevision; + if (unseen && seat) { + let projection: QualityReport | undefined; + try { projection = projectQuality(detail, seat.participantId); } catch { /* Malformed public events fail closed. */ } + if (revision !== qualityRevision || seat !== qualitySeat || client !== current || generation !== entryGeneration) { renderQuality(); return; } + reconcileQuality(); + if (seat === qualitySeat) { qualityReport = projection; qualityRevision++; } + } + renderQuality(); renderPublicRoster(); + }); + client.on('speaking', ({ detail }) => { + const unseen = !detail || typeof detail !== 'object' || !speakingReceipts.has(detail); + if (detail && typeof detail === 'object') speakingReceipts.add(detail); + if (client !== current || cancelledEntry || generation !== entryGeneration || controlsClient !== current) return; + reconcileSpeaking(); + const seat = speakingSeat, revision = speakingRevision; + if (unseen && seat) { + let projection: SpeakingReport | undefined; + try { projection = projectSpeakingReport(detail); } catch { /* Malformed reports mean unknown activity, not silence. */ } + reconcileSpeaking(); reconcileSpeakingConsents(); + if (revision !== speakingRevision || seat !== speakingSeat || current !== client || generation !== entryGeneration) { renderSpeaking(); return; } + speakingReport = projection; speakingRevision++; + } + renderSpeaking(); renderPublicRoster(); }); const retireOwnHandRejection = (participantId: string): void => { if (client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration && participantId === current.me?.participantId) retireHandRejection(); }; client.on('hand', ({ detail }) => { if (detail.raised) retireOwnHandRejection(detail.participantId); }); - client.on('stagejoined', ({ detail }) => retireOwnHandRejection(detail.participantId)); + client.on('invite', ({ detail }) => fenceMicTest(current, generation, detail.participantId)); + client.on('stagejoined', ({ detail }) => { fenceMicTest(current, generation, detail.participantId); retireOwnHandRejection(detail.participantId); }); client.on('role', ({ detail }) => { if (detail.role !== 'audience') retireOwnHandRejection(detail.participantId); }); client.on('transferred', ({ detail }) => retireOwnHandRejection(detail.toId)); const ownsTransferEvents = (): boolean => client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration && stageManagementOwner?.client === current; client.on('role', ({ detail }) => { + const speakingPruned = detail.role === 'audience' && pruneSpeaking(current, generation, detail.participantId); const closeFenced = detail.role !== 'controller' && fenceRoomClose(current, generation, detail.participantId); const transferFenced = ownsTransferEvents() && ((detail.participantId === stageManagementOwner!.participantId && detail.role !== 'controller') || (stageManagementRows.has(detail.participantId) && detail.role !== 'speaker')); // Both confirmations lose authority before either control can synchronously reenter from render. if (transferFenced) fenceTransfer(false); + const ownDeparture = client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration + && detail.participantId === current.me?.participantId && detail.role === 'audience'; + if (ownDeparture) retireQuality(); if (closeFenced) syncRoomClose(); if (transferFenced) syncStageManagement(); + if (ownDeparture && client === current && generation === entryGeneration) renderQuality(); + if (speakingPruned && client === current && generation === entryGeneration) renderSpeaking(); }); client.on('transferred', ({ detail }) => { const closeFenced = fenceRoomClose(current, generation, detail.fromId); @@ -955,7 +1981,15 @@ join.onsubmit = (event) => { if (transferFenced) syncStageManagement(); }); client.on('stageleft', ({ detail }) => { - if (ownsTransferEvents() && stageManagementRows.has(detail.participantId)) fenceTransfer(); + const speakingPruned = pruneSpeaking(current, generation, detail.participantId); + const ownDeparture = client === current && controlsClient === current && !cancelledEntry && generation === entryGeneration + && detail.participantId === current.me?.participantId; + if (ownDeparture) retireQuality(); + const transferFenced = ownsTransferEvents() && stageManagementRows.has(detail.participantId); + if (transferFenced) fenceTransfer(false); + if (transferFenced) syncStageManagement(); + if (ownDeparture && client === current && generation === entryGeneration) renderQuality(); + if (speakingPruned && client === current && generation === entryGeneration) renderSpeaking(); }); client.on('handrejected', ({ detail }) => { // No per-raise identity is provided; feedback belongs only to the current eligible seat. @@ -980,19 +2014,65 @@ join.onsubmit = (event) => { if (retrying) { progress = false; status.textContent = '已取得麥克風。'; } }); client.on('status', ({ detail }) => { - if (client !== current || cancelledEntry) return; + if (client !== current || cancelledEntry || generation !== entryGeneration) return; + publicRosterEventRevision++; + if (detail === 'reconnecting' && publicRosterOwner) { publicRosterOwner.stale = true; publicRosterOwner.reconnecting = true; } + if (detail === 'disconnected') retireMicTestBinding(); + else if (detail === 'reconnecting') retireMicTest(); + if (detail === 'disconnected') retireConnectionStatsBinding(); + else if (detail === 'reconnecting') retireConnectionStats(); + if (detail === 'disconnected') { occupancyRetiredGeneration = generation; retireOccupancy(); retireQuality(); retireSpeaking(); retirePublicRoster(); } + else { + reconcileOccupancy(); + if (detail === 'reconnecting') { retireQuality(); retireSpeaking(); } + else { reconcileQuality(); reconcileSpeaking(); } + } const renderingEntry = entryGeneration; syncRoomClose(); if (renderingEntry !== entryGeneration || client !== current) return; if (current.state) { receivedState = true; if (generation === entryGeneration) hideCancel(); } if (detail === 'reconnecting') { retireHandRejection(); progress = false; status.textContent = '重新連線中'; hand.disabled = true; } else if (detail === 'disconnected') { progress = false; retirePlayback(); retireControls(); status.textContent = '未連線'; hand.disabled = leave.disabled = true; } - syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); + syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); syncMicTest(); + if (renderingEntry === entryGeneration && client === current) renderOccupancy(); + if (renderingEntry === entryGeneration && client === current) renderQuality(); + if (renderingEntry === entryGeneration && client === current) renderSpeaking(); + if (renderingEntry === entryGeneration && client === current) renderPublicRoster(); + if (renderingEntry === entryGeneration && client === current) finishConnectionStatsTeardown(renderingEntry); + }); + // Original role/stage Close/Transfer fences above run before this passive notification reader. + for (const type of ['role', 'stagejoined', 'stageleft', 'transferred', 'mic'] as const) current.on(type, () => { + if (client !== current || controlsClient !== current || cancelledEntry || generation !== entryGeneration) return; + publicRosterEventRevision++; + if (publicRosterOwner) publicRosterOwner.awaitingState = true; + renderPublicRoster(); }); client.on('error', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; progress = false; report(detail); }); client.on('audioblocked', () => { if (cancelledEntry) return; progress = false; status.textContent = '請點擊啟用音訊。'; }); - client.on('closed', ({ detail }) => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; retireEntry(); clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; }); - client.on('kicked', () => { if (client !== current || cancelledEntry || generation !== entryGeneration) return; retireEntry(); clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; }); + client.on('closed', ({ detail }) => { + if (client !== current || cancelledEntry || generation !== entryGeneration) return; + const retired = retireEntry(); + clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); + renderOccupancy(); + if (retired === entryGeneration && client === current) renderQuality(); + if (retired === entryGeneration && client === current) renderSpeaking(); + if (retired === entryGeneration && client === current) renderPublicRoster(); + if (retired === entryGeneration && client === current) finishConnectionStatsTeardown(retired); + if (retired !== entryGeneration || client !== current) return; + status.textContent = detail.reason === 'shutdown' ? '伺服器維護或重新啟動,房間已關閉。' : '房間已關閉。'; hand.disabled = leave.disabled = true; + }); + client.on('kicked', () => { + if (client !== current || cancelledEntry || generation !== entryGeneration) return; + const retired = retireEntry(); + clearError(); retirePlayback(); retireControls(); syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); + renderOccupancy(); + if (retired === entryGeneration && client === current) renderQuality(); + if (retired === entryGeneration && client === current) renderSpeaking(); + if (retired === entryGeneration && client === current) renderPublicRoster(); + if (retired === entryGeneration && client === current) finishConnectionStatsTeardown(retired); + if (retired !== entryGeneration || client !== current) return; + status.textContent = '你已被主控移出房間。'; hand.disabled = leave.disabled = true; + }); showProgress('連線中…'); await current.connect(); if (generation !== entryGeneration) return; @@ -1117,7 +2197,7 @@ microphoneRefresh.onclick = () => { const devices = await owner.client.listAudioDevices(); if (ownsMicrophoneAttempt(owner, attempt)) owner.choices = replaceMicrophoneOptions(devices.inputs, draft); } catch (error) { reportMicrophoneError(owner, attempt, error); } - finally { if (ownsMicrophoneAttempt(owner, attempt)) { pendingMicrophone = undefined; syncMicrophone(); } } + finally { if (ownsMicrophoneAttempt(owner, attempt)) { pendingMicrophone = undefined; syncMicrophone(); syncMicTest(); } } })(); }; microphoneApply.onclick = () => { @@ -1125,13 +2205,13 @@ microphoneApply.onclick = () => { const owner = microphoneOwner; if (!owner || microphonePanel.hidden || microphoneApply.hidden || microphoneApply.disabled || pendingMicrophone) return; const draft = owner.draft, attempt = pendingMicrophone = { kind: 'apply' }; - syncMicrophone(); + stopMicTestAttempt(); syncMicrophone(); syncMicTest(); void (async () => { - try { await owner.client.setInputDevice(draft); } + try { if (ownsMicrophoneAttempt(owner, attempt)) await owner.client.setInputDevice(draft); } catch (error) { reportMicrophoneError(owner, attempt, error); } finally { // Fulfillment can be a cancelled stage-transition no-op; micready is not this operation's completion. - if (ownsMicrophoneAttempt(owner, attempt)) { pendingMicrophone = undefined; syncMicrophone(); } + if (ownsMicrophoneAttempt(owner, attempt)) { pendingMicrophone = undefined; syncMicrophone(); syncMicTest(); } } })(); }; @@ -1198,14 +2278,34 @@ micRetry.onclick = () => { } }; leave.onclick = () => { - const generation = retireEntry(), current = client; + const current = client, generation = retireEntry(); clearError(); retirePlayback(); scheduleHandCooldown(0); - retireControls(); hand.setAttribute('aria-busy', 'false'); syncMic(); syncStage(); syncMicRetry(); + retireControls(); hand.setAttribute('aria-busy', 'false'); syncHand(); syncMic(); syncStage(); syncMicRetry(); + renderOccupancy(); + if (generation === entryGeneration && client === current) renderQuality(); + if (generation === entryGeneration && client === current) renderSpeaking(); + if (generation === entryGeneration && client === current) renderPublicRoster(); + if (generation === entryGeneration && client === current) finishConnectionStatsTeardown(generation); + if (generation !== entryGeneration || client !== current) return; void current?.disconnect().then(() => { if (generation !== entryGeneration || client !== current) return; status.textContent = '已離開。'; hand.disabled = leave.disabled = true; }); }; replaceOutputOptions([]); -syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); syncUnlock(); +syncHand(); syncMic(); syncStage(); syncMicRetry(); syncRename(); syncRoomName(); syncHandQueue(); syncStageManagement(); syncRoomClose(); syncMicrophone(); syncAudioRetry(); syncOutput(); syncMicTest(); syncUnlock(); + +connectionStatsUpdate.onclick = refreshConnectionStats; +syncConnectionStats(); + +micTestStart.onclick = startMicTest; +micTestCancel.onclick = () => cancelMicTest(false); +micTestStop.onclick = () => cancelMicTest(true); +document.addEventListener('visibilitychange', () => { + if (document.hidden) retireMicTest(); + syncMicTest(); +}); +window.addEventListener('pagehide', () => { micTestPageHidden = true; retireMicTest(); syncMicTest(); }); +window.addEventListener('pageshow', () => { micTestPageHidden = false; syncMicTest(); }); +syncMicTest(); diff --git a/web/src/main.ts b/web/src/main.ts index 4694d9e..56e9abc 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -154,8 +154,34 @@ audienceFilterClear.onclick = () => { if (!canFilterAudience()) return; audienceSearch.value = ''; applyAudienceFilter(); }; +const handFilter = element('section', 'panel hand-filter'); +handFilter.hidden = true; +const handSearch = element('input'); +handSearch.type = 'search'; handSearch.id = 'hand-queue-search'; handSearch.autocomplete = 'off'; +handSearch.disabled = true; handSearch.setAttribute('aria-describedby', 'hand-queue-results'); +const handSearchLabel = element('label', '', '搜尋舉手者名稱'); +handSearchLabel.setAttribute('for', handSearch.id); +const handFilterCount = element('p', 'muted'); +handFilterCount.id = 'hand-queue-results'; handFilterCount.setAttribute('role', 'status'); +const handFilterClear = element('button', '', '清除舉手搜尋'); +handFilterClear.id = 'hand-queue-clear'; handFilterClear.type = 'button'; handFilterClear.disabled = true; +handFilter.append(handSearchLabel, handSearch, handFilterClear, handFilterCount); +interface HandFilterRow { row: HTMLLIElement; id: string; sourceName: string; name: string } +let handFilterIdentity = '', handFilterDraft = ''; +let handFilterGeneration = 0, handFilterRevision = 0; +let handFilterPainting = false, handFilterDirty = false; +let handFilterRows: HandFilterRow[] = []; +handSearch.oninput = () => { + const revision = ++handFilterRevision, value = handSearch.value; + if (revision !== handFilterRevision || !canFilterHands()) return; + handFilterDraft = value; applyHandFilter(); +}; +handFilterClear.onclick = () => { + if (!canFilterHands()) return; + handFilterDraft = ''; applyHandFilter(); +}; const content = element('main'); -app.append(header, notices, errorBox, audienceFilter, content); +app.append(header, notices, errorBox, audienceFilter, handFilter, content); const stageQuality = element('details', 'panel stage-quality'); stageQuality.id = 'stage-quality'; stageQuality.hidden = true; const stageQualitySummary = element('summary', '', '舞台連線品質'); @@ -164,29 +190,55 @@ const stageQualityList = element('ul', 'stage-quality-list'); stageQuality.append(stageQualitySummary, stageQualityStatus, stageQualityList); app.append(stageQuality); let stageQualityIdentity = ''; +let stageQualitySuppressedState: RoomStatePayload | undefined; +let stageQualityRevision = 0; let stageQualityReconnecting = false; let stageQualityReports = new Map(); const stageQualityRows = new Map(); let stageQualityOrder: string[] = []; +function qualityDetailsUpdate(): () => boolean { + const revision = ++stageQualityRevision, state = client.state, generation = roomActionGeneration; + return () => revision === stageQualityRevision && state === client.state && generation === roomActionGeneration; +} function clearQualityDetailsReports(): void { stageQualityReports.clear(); stageQualityRows.clear(); stageQualityOrder = []; stageQualityList.replaceChildren(); } -function resetQualityDetails(): void { +function resetQualityDetails(current = qualityDetailsUpdate()): boolean { stageQualityIdentity = ''; stageQualityReconnecting = false; - stageQuality.hidden = true; stageQuality.removeAttribute('open'); - stageQualityStatus.textContent = ''; clearQualityDetailsReports(); + stageQualityReports.clear(); stageQualityRows.clear(); stageQualityOrder = []; + stageQuality.hidden = true; + if (!current()) return false; + stageQuality.removeAttribute('open'); + if (!current()) return false; + stageQualityStatus.textContent = ''; + if (!current()) return false; + stageQualityList.replaceChildren(); + return current(); +} +function suppressQualityDetails(): void { + const state = client.state; + if (!state || stageQualitySuppressedState === state) return; + // Role/stage deltas precede their snapshot; reserve loss before any observable reset. + stageQualitySuppressedState = state; + resetQualityDetails(); } /** Reports have no timestamp or room epoch; accept them only in the current eligible view. */ function syncQualityDetails(reports?: readonly ConnectionQuality[]): void { - const state = client.state, me = state?.me; - if (!state || !me || (me.role !== 'controller' && !me.onStage)) { resetQualityDetails(); return; } + const state = client.state, me = state?.me, current = qualityDetailsUpdate(); + if (stageQualitySuppressedState || !state || !me || (me.role !== 'controller' && !me.onStage)) { + resetQualityDetails(current); return; + } const identity = JSON.stringify([state.roomId, me.participantId]); - if (identity !== stageQualityIdentity) { resetQualityDetails(); stageQualityIdentity = identity; } + if (identity !== stageQualityIdentity) { + if (!resetQualityDetails(current)) return; + stageQualityIdentity = identity; + } stageQuality.hidden = false; - const reconnecting = client.status === 'reconnecting'; - if (reconnecting && !stageQualityReconnecting) clearQualityDetailsReports(); + if (!current()) return; + const reconnecting = client.status === 'reconnecting', clearReports = reconnecting && !stageQualityReconnecting; stageQualityReconnecting = reconnecting; + if (clearReports) { clearQualityDetailsReports(); if (!current()) return; } const speakers = state.speakers.filter(person => person.onStage); const ids = new Set(speakers.map(person => person.participantId)); if (reports !== undefined && !reconnecting) { @@ -208,11 +260,13 @@ function syncQualityDetails(reports?: readonly ConnectionQuality[]): void { if (!row) { row = element('li'); row.dataset.participantId = person.participantId; stageQualityRows.set(person.participantId, row); } const text = `${decodeName(person.name)}:上行掉包 ${percent(report.uplinkLossPercent)} · 下行掉包 ${percent(report.downlinkLossPercent)} · RTT ${rtt(report.rttMs)}`; if (row.textContent !== text) row.textContent = text; + if (!current()) return; } for (const id of stageQualityRows.keys()) if (!order.includes(id)) stageQualityRows.delete(id); if (order.length !== stageQualityOrder.length || order.some((id, index) => id !== stageQualityOrder[index])) { - stageQualityList.replaceChildren(...order.map(id => stageQualityRows.get(id)!)); stageQualityOrder = order; + stageQualityList.replaceChildren(...order.map(id => stageQualityRows.get(id)!)); + if (!current()) return; } stageQualityStatus.textContent = order.length ? '最近收到的伺服器回報;RTT 不是端到端音訊延遲。' @@ -368,6 +422,11 @@ client.on('micready', () => { }); navigator.mediaDevices?.addEventListener('devicechange', () => { void refreshDevices().catch(() => {}); }); void refreshDevices().catch(() => {}); +// Reserve public snapshot ownership before existing UI callbacks can reenter the SDK. +client.on('state', prepareOccupancyState); +client.on('status', prepareOccupancyStatus); +client.on('closed', retireOccupancy); +client.on('kicked', retireOccupancy); /** Muted room microphones have a known zero level; only previews or unmuted stage mics need polling. */ let meterTimer: ReturnType | undefined; /** Only preview capture is abortable here, never setInputDevice or the room microphone. */ @@ -447,13 +506,18 @@ async function run(action: () => Promise, target?: HTMLButtonElement, g finally { if (target?.isConnected) target.disabled = false; updateCooldown(); syncMeter(); } } /** Room controls retain their pending state when snapshots replace their DOM nodes. */ -function resetRoomActions(identity: string): void { - if (roomActionIdentity === identity) return; - roomActionIdentity = identity; roomActionGeneration++; pendingRoomActions.clear(); +function resetRoomActions(identity: string): number { + if (roomActionIdentity === identity) return roomActionGeneration; + roomActionIdentity = identity; const generation = ++roomActionGeneration; pendingRoomActions.clear(); audioRetryNotice.hidden = true; audioRetryNotice.textContent = ''; handRejectionRoomActive = identity !== ''; retireHandRejection(); pendingAudioUnlock = undefined; micRetryAvailable = micRetryPending = false; micNotice = ''; + // Retire the previous seat's history only after its actions and polls have lost ownership. + if (generation !== roomActionGeneration) return generation; + connectionLine.textContent = ''; + if (generation === roomActionGeneration) connectionLine.classList.toggle('error', false); + return generation; } function roomButton(key: string, label: string, action: () => Promise, className = '', canAct?: () => boolean): HTMLButtonElement { const node = element('button', className, label); @@ -517,7 +581,8 @@ function runLanding(operation: 'create' | 'join', action: (isCancelled: () => bo if (signaling.error) { showError(signaling.error); return; } const revision = landingRevision; const entry = { operation, revision, cancelled: false, entered: false }; - landingEntry = entry; busy = true; syncLandingActions(); + retireOccupancy(); + landingEntry = entry; busy = true; syncLandingActions(); renderOccupancy(); void run(() => action(() => entry.cancelled), undefined, undefined, () => !entry.cancelled, error => { const message = error instanceof Error ? error.message : '操作失敗,請稍後再試。'; if (landingEntry !== entry || entry.entered || client.state || revision !== landingRevision @@ -551,18 +616,21 @@ function field(form: HTMLFormElement, label: string, name: string, value = '', r return input; } function landing(): void { + retireOccupancy(); + // Drop ownership before any reset can synchronously deliver another speaking callback. + resetSpeaking(); handRejectionRoomActive = false; retireHandRejection(); stopRoomTimers(); - resetQualityDetails(); + stageQualitySuppressedState = undefined; resetQualityDetails(); stagePermissionOwner = undefined; landingRevision += 1; ownHandPosition.hidden = true; ownHandPosition.textContent = ''; resetAudienceFilter(); renderedRoomSignature = renderedSpeakingSignature = renderedQualitySignature = undefined; renderedGainInputs.length = 0; renderedAudienceHeading = undefined; - personRows.clear(); speakingIds.clear(); resetMicSettings(); resetRoomActions(''); + resetHandFilter(); cooldownButton = null; renderedReturnStage = null; renderedRoomActions = []; scheduleCooldown(0); brand.textContent = 'Stage.js 音訊舞台'; const intro = element('section', 'intro'); @@ -611,15 +679,16 @@ function landing(): void { cancel.id = 'landing-cancel'; cancel.type = 'button'; cancel.dataset.landingCancel = 'true'; cancel.onclick = () => { if (!cancel.isConnected || !canCancelLanding()) return; + retireOccupancy(); landingEntry!.cancelled = true; - syncLandingActions(); + syncLandingActions(); renderOccupancy(); void run(() => client.disconnect()); }; const progress = element('p', 'muted'); progress.id = 'landing-progress'; progress.dataset.landingProgress = 'true'; progress.setAttribute('role', 'status'); progress.setAttribute('aria-live', 'polite'); content.replaceChildren(intro, grid, progress, cancel); - syncLandingActions(); renderNotices(); + syncLandingActions(); renderNotices(); renderOccupancy(); } function syncMicRetry(): void { if (!micRetryButton) return; @@ -722,29 +791,262 @@ function syncAudienceFilter(state: RoomStatePayload): void { audienceFilter.hidden = false; audienceSearch.disabled = false; applyAudienceFilter(); } +function resetHandFilter(): void { + handFilterIdentity = ''; handFilterDraft = ''; handFilterRows = []; + handFilterGeneration = roomActionGeneration; + applyHandFilter(); +} +function canFilterHands(): boolean { + const revision = handFilterRevision, state = client.state; + const eligible = Boolean(state?.me.role === 'controller' && handFilterIdentity + && handFilterIdentity === JSON.stringify([state.roomId, state.me.participantId])); + return eligible && revision === handFilterRevision && handFilterGeneration === roomActionGeneration; +} +function applyHandFilter(): void { + ++handFilterRevision; handFilterDirty = true; + if (handFilterPainting) return; + handFilterPainting = true; + try { + // A finite nested input/state callback may overwrite a DOM setter after its newer paint. + while (handFilterDirty) { + handFilterDirty = false; + const revision = handFilterRevision, rows = handFilterRows, draft = handFilterDraft; + const active = canFilterHands(), query = draft.trim().normalize('NFC').toLowerCase(); + const current = () => revision === handFilterRevision && rows === handFilterRows; + const write = (action: () => void): boolean => { + if (!current()) { handFilterDirty = true; return false; } + action(); + if (!current()) { handFilterDirty = true; return false; } + return true; + }; + if (!write(() => { if (handSearch.value !== draft) handSearch.value = draft; })) continue; + if (!write(() => { handFilter.hidden = !active; handSearch.disabled = !active; })) continue; + let visible = 0; + for (const { row, name } of rows) { + const matches = name.includes(query); + if (!write(() => { if (row.hidden !== !matches) row.hidden = !matches; })) break; + if (matches) visible++; + } + if (!current()) { handFilterDirty = true; continue; } + if (!write(() => { handFilterClear.disabled = !active || !draft; })) continue; + const text = !active ? '' : `符合 ${visible} / ${rows.length} 筆舉手申請。${query && !visible && rows.length ? '沒有符合搜尋的舉手者。' : ''}`; + write(() => { if (handFilterCount.textContent !== text) handFilterCount.textContent = text; }); + } + } finally { handFilterPainting = false; } +} +function syncHandFilter(state: RoomStatePayload): void { + if (state !== client.state) return; + if (state.me.role !== 'controller') { resetHandFilter(); return; } + const identity = JSON.stringify([state.roomId, state.me.participantId]); + if (handFilterIdentity !== identity || handFilterGeneration !== roomActionGeneration) handFilterDraft = ''; + handFilterIdentity = identity; handFilterGeneration = roomActionGeneration; + applyHandFilter(); +} +function handActionButton(entry: HandFilterRow, key: string, label: string, + method: 'approve' | 'reject' | 'transferControl' | 'kick', className = '', confirmation?: string): HTMLButtonElement { + const generation = roomActionGeneration, identity = roomActionIdentity; + const canAct = (): boolean => { + const revision = handFilterRevision, state = client.state, query = handSearch.value; + const eligible = Boolean(state?.me.role === 'controller' && generation === roomActionGeneration + && identity === roomActionIdentity && identity === handFilterIdentity + && identity === JSON.stringify([state.roomId, state.me.participantId]) + && client.status !== 'reconnecting' && client.status !== 'disconnected' + && entry.row.isConnected && node.isConnected && node.parentNode?.parentNode === entry.row + && handFilterRows.includes(entry) && !entry.row.hidden && query === handFilterDraft + && entry.name.includes(query.trim().normalize('NFC').toLowerCase()) + && state.hands.some(person => person.participantId === entry.id && person.name === entry.sourceName)); + const currentState = client.state; + return eligible && state === currentState && revision === handFilterRevision && generation === roomActionGeneration; + }; + const node = roomButton(key, label, async () => { + // Room action admission paints pending/error state; that work can retire this filtered row. + if (!canAct() || (confirmation && (!window.confirm(confirmation) || !canAct()))) return; + const command = client[method]; + if (canAct()) await command.call(client, entry.id); + }, className, canAct); + return node; +} +const roomOccupancy = element('p', 'muted room-occupancy'); +roomOccupancy.id = 'room-occupancy'; roomOccupancy.hidden = true; +interface OccupancyReport { + roomId: string; participantId: string; + room: number; stage: number; audience: number; maxAudience: number; maxSpeakers: number; + controllerOnStage: boolean; stale: boolean; +} +let occupancyReport: OccupancyReport | undefined; +let occupancyRevision = 0; +let occupancyPainting = false; +const occupancyReceipts = new WeakSet(); +// Logical retirement is separate from painting so existing action ownership changes run first. +function retireOccupancy(): void { occupancyRevision++; occupancyReport = undefined; } +function projectOccupancy(state: RoomStatePayload): OccupancyReport | undefined { + const roomId = state.roomId, participantId = state.me?.participantId, controllerId = state.controllerId; + const limits = state.limits, audience = state.audienceCount; + const maxAudience = limits?.maxAudience, maxSpeakers = limits?.maxSpeakers, speakers = state.speakers; + if (typeof roomId !== 'string' || !roomId.trim() || typeof participantId !== 'string' || !participantId.trim() + || typeof controllerId !== 'string' || !controllerId.trim() || !Array.isArray(speakers) + || !limits || typeof limits !== 'object' || Array.isArray(limits) + || !Number.isSafeInteger(audience) || audience < 0 || !Number.isSafeInteger(maxAudience) || maxAudience <= 0 + || !Number.isSafeInteger(maxSpeakers) || maxSpeakers <= 0) return; + const ids = new Set(); + let controllerOnStage = false; + for (const person of speakers) { + if (!person || typeof person.participantId !== 'string' || !person.participantId.trim() || ids.has(person.participantId) + || person.onStage !== true || (person.role !== 'speaker' && person.role !== 'controller') + || (person.role === 'controller') !== (person.participantId === controllerId)) return; + ids.add(person.participantId); + if (person.participantId === controllerId) controllerOnStage = true; + } + const stage = ids.size - Number(controllerOnStage), room = audience + stage; + if (!Number.isSafeInteger(room) || room > maxAudience || stage > maxSpeakers) return; + return { roomId, participantId, room, stage, audience, maxAudience, maxSpeakers, controllerOnStage, stale: false }; +} +function reconcileOccupancy(): void { + const report = occupancyReport; + if (!report) return; + if (report.roomId !== client.state?.roomId || report.participantId !== client.state?.me?.participantId + || !['waiting', 'live', 'reconnecting'].includes(client.status)) { retireOccupancy(); return; } + if (client.status === 'reconnecting' && !report.stale) { report.stale = true; occupancyRevision++; } +} +function prepareOccupancyState({ detail }: { detail: RoomStatePayload }): void { + const revision = ++occupancyRevision; + const object = detail && typeof detail === 'object', unseen = object && !occupancyReceipts.has(detail); + if (object) occupancyReceipts.add(detail); + if (!unseen || detail !== client.state || (client.status !== 'waiting' && client.status !== 'live')) { + reconcileOccupancy(); return; + } + const report = projectOccupancy(detail); + // Public getters can synchronously replace this receipt or retire the room. + if (revision !== occupancyRevision || detail !== client.state + || (client.status !== 'waiting' && client.status !== 'live')) return; + occupancyReport = report; +} +function prepareOccupancyStatus(): void { occupancyRevision++; reconcileOccupancy(); } +function renderOccupancy(): void { + if (occupancyPainting) return; + occupancyPainting = true; + try { + // Nested callbacks reserve their output first; drain finite changes without trusting a paint cache. + for (;;) { + reconcileOccupancy(); + // A predecessor UI setter may emit status before the new state reaches renderRoom. + // Retire old-room commands before this independent paragraph exposes the new seat. + const identity = client.state ? JSON.stringify([client.state.roomId, client.state.me.participantId]) : ''; + if (identity !== roomActionIdentity) { resetRoomActions(identity); continue; } + const revision = occupancyRevision, report = occupancyReport, connectionStatus = client.status; + const roomId = client.state?.roomId, participantId = client.state?.me?.participantId; + const current = (): boolean => { + if (connectionStatus !== client.status || roomId !== client.state?.roomId + || participantId !== client.state?.me?.participantId) { + // A host setter can alter public ownership without an SDK event. Fence old actions first. + reconcileOccupancy(); + resetRoomActions(client.state ? JSON.stringify([client.state.roomId, client.state.me.participantId]) : ''); + syncRoomActions(); + return false; + } + return revision === occupancyRevision && report === occupancyReport; + }; + const freshness = connectionStatus === 'reconnecting' ? '重新連線中,上次快照' : report?.stale ? '上次快照' : '最近快照'; + const text = report ? `房間席位(${freshness}):主控以外 ${report.room} / ${report.maxAudience} 人,其中台上 ${report.stage} / ${report.maxSpeakers} 人、觀眾 ${report.audience} 人;主控另計 1 人(${report.controllerOnStage ? '台上' : '台下'})。含斷線保留席位。` : ''; + if (!report && !roomOccupancy.hidden) { roomOccupancy.hidden = true; if (!current()) continue; } + if (roomOccupancy.textContent !== text) { roomOccupancy.textContent = text; if (!current()) continue; } + if (report && roomOccupancy.hidden) { roomOccupancy.hidden = false; if (!current()) continue; } + if (current()) return; + } + } finally { occupancyPainting = false; } +} +let speakingDirtyIds = new Set(); +let speakingRowsRevision = 0; +let speakingPaintRevision: number | undefined; +let speakingRevision = 0; +let speakingIdentity = ''; +let speakingState: RoomStatePayload | undefined; +let speakingRetired = false; +let speakingReconnectState: RoomStatePayload | null = null; +function resetSpeaking(): void { + speakingRevision++; speakingIdentity = ''; speakingState = undefined; + speakingRetired = false; speakingReconnectState = null; + speakingRowsRevision++; personRows.clear(); speakingIds = new Set(); speakingDirtyIds = new Set(); +} +/** The SDK retains its last report across socket loss; that is not a current activity indication. */ +function retireSpeaking(): void { + speakingRevision++; + speakingRetired = true; speakingReconnectState = client.state; speakingState = undefined; + updateSpeaking(new Set()); +} +function acceptSpeakingState(state: RoomStatePayload): void { + if (state !== client.state) return; + const identity = JSON.stringify([state.roomId, state.me.participantId]); + if (client.status === 'reconnecting' || client.status === 'disconnected') { + speakingIdentity = identity; speakingRetired = true; speakingState = undefined; return; + } + if (identity !== speakingIdentity) { + speakingIdentity = identity; speakingRetired = false; speakingReconnectState = null; + } + // Status is emitted before state on resume. Neither status alone nor a pre-loss replay admits reports. + speakingState = state !== speakingReconnectState ? state : undefined; +} +function updateSpeaking(ids: Set): void { + const previous = speakingIds, revision = speakingRowsRevision, dirty = speakingDirtyIds; + speakingIds = ids; renderedSpeakingSignature = JSON.stringify([...ids]); + for (const id of previous) if (!ids.has(id)) dirty.add(id); + for (const id of ids) if (!previous.has(id)) dirty.add(id); + if (speakingPaintRevision === revision) return; + speakingPaintRevision = revision; + try { + // Nested receipts reserve their desired set first. Drain only dirty IDs until finite callbacks settle. + while (dirty.size && speakingRowsRevision === revision) { + const id = dirty.values().next().value!; dirty.delete(id); + for (const row of personRows.get(id) ?? []) { + if (speakingRowsRevision !== revision) return; + const active = speakingIds.has(id); + if (row.classList.contains('speaking') !== active) row.classList.toggle('speaking', active); + if (speakingRowsRevision !== revision) return; + // A before-write callback can return into an older toggle after reserving a newer report. + if (row.classList.contains('speaking') !== speakingIds.has(id)) dirty.add(id); + } + } + } finally { + if (speakingPaintRevision === revision) speakingPaintRevision = undefined; + } +} +function effectiveRoomCode(state: RoomStatePayload): string | undefined { + return state.code ?? (createdRoom?.roomId === state.roomId ? createdRoom.code : undefined); +} +function invitationUrl(state: RoomStatePayload, roomCode = effectiveRoomCode(state)): URL { + const link = new URL(location.origin); + link.pathname = location.pathname; + link.searchParams.set('room', state.roomId); + if (signaling.path !== '/ws') link.searchParams.set('wsPath', signaling.path); + if (state.codeRequired && roomCode) link.searchParams.set('code', roomCode); + return link; +} function renderRoom(state: RoomStatePayload): void { + const generation = resetRoomActions(JSON.stringify([state.roomId, state.me.participantId])); + if (generation !== roomActionGeneration) return; + const qualityState = client.state; syncQualityDetails(); - resetRoomActions(JSON.stringify([state.roomId, state.me.participantId])); + if (generation !== roomActionGeneration || qualityState !== client.state) return; if (!state.me.onStage) { if (micRetryPending) micNotice = ''; micRetryAvailable = micRetryPending = false; } - const roomCode = state.code ?? (createdRoom?.roomId === state.roomId ? createdRoom.code : undefined); + const roomCode = effectiveRoomCode(state); // Separate already-applied overlays from structural state; dirty sliders still reset to server state. const { audienceCount, ...roomStructure } = state; - const audienceTitle = `觀眾 · ${audienceCount}/${state.limits.maxAudience}`; + const audienceTitle = `觀眾 · ${audienceCount} 人`; const signature = JSON.stringify([roomStructure, roomCode, client.status]); - const speakingSignature = JSON.stringify([...client.speaking]); + const currentSpeaking = new Set(speakingRetired ? speakingIds : client.speaking); + const speakingSignature = JSON.stringify([...currentSpeaking]); const qualitySignature = JSON.stringify([...quality]); if (signature === renderedRoomSignature && speakingSignature === renderedSpeakingSignature && qualitySignature === renderedQualitySignature && !renderedGainInputs.some(({ input, value }) => input.value !== value)) { status.textContent = statusLabels[client.status]; if (renderedAudienceHeading && renderedAudienceHeading.textContent !== audienceTitle) renderedAudienceHeading.textContent = audienceTitle; - syncAudienceFilter(state); updateCooldown(); renderNotices(true); return; + syncAudienceFilter(state); syncHandFilter(state); updateCooldown(); renderNotices(true); return; } - renderedGainInputs.length = 0; audienceFilterRows.length = 0; - personRows.clear(); speakingIds = new Set(client.speaking); - const generation = roomActionGeneration; + renderedGainInputs.length = 0; audienceFilterRows.length = 0; handFilterRows = []; handFilterRevision++; + speakingRevision++; speakingRowsRevision++; personRows.clear(); speakingDirtyIds = new Set(); speakingIds = currentSpeaking; brand.textContent = decodeName(state.name); status.textContent = statusLabels[client.status]; cooldownButton = null; @@ -763,15 +1065,26 @@ function renderRoom(state: RoomStatePayload): void { await client.setRoomName(name); }, '', () => renameRoom.isConnected && canRenameRoom()); summary.append(renameRoom); - const link = new URL(location.origin); - link.pathname = location.pathname; - link.searchParams.set('room', state.roomId); - if (signaling.path !== '/ws') link.searchParams.set('wsPath', signaling.path); - if (state.codeRequired && roomCode) link.searchParams.set('code', roomCode); + const link = invitationUrl(state, roomCode); const inviteLink = element('input', 'invite-link'); inviteLink.value = link.href; inviteLink.readOnly = true; inviteLink.setAttribute('aria-label', '邀請連結'); - const copy = button('複製邀請連結', async () => { + const copy = element('button', '', '複製邀請連結'); + copy.type = 'button'; + const canCopy = (requireIdle = false): boolean => { + try { + if (requireIdle && copy.disabled) return false; + const current = client.state; + if (!current || current.roomId !== titleRoomId || current.me.participantId !== titleParticipantId + || current.me.role !== 'controller' || invitationUrl(current).href !== link.href) return false; + return copy.isConnected && inviteLink.isConnected && copy.parentNode === inviteLink.parentNode + && content.querySelector('.invite-link') === inviteLink && inviteLink.value === link.href + && current === client.state && generation === roomActionGeneration && (!requireIdle || !copy.disabled); + } catch { return false; } + }; + const copyInvitation = async () => { + // A host callback during run's DOM writes may have retired the admitted resource. + if (!canCopy()) return; const sequence = ++inviteCopySequence; let copied = false; try { await navigator.clipboard.writeText(link.href); copied = true; } @@ -783,7 +1096,12 @@ function renderRoom(state: RoomStatePayload): void { if (copied) invitation = '已複製邀請連結,請只分享給受邀者。'; else { currentLink.focus(); currentLink.select(); invitation = '請長按或使用複製快捷鍵複製已選取的連結。'; } renderNotices(); - }); + }; + copy.onclick = () => { + // SDK snapshots are installed before the sample's state listener replaces the old nodes. + if (!canCopy(true)) return; + void run(copyInvitation, copy); + }; summary.append(inviteLink, copy); if (state.codeRequired) summary.append(roomButton('rotate-code', '更換房間代碼', async () => { if (!window.confirm('更換後舊代碼與舊邀請連結立即失效,已在房內的人不受影響。確定更換?')) return; @@ -836,12 +1154,14 @@ function renderRoom(state: RoomStatePayload): void { actions.append(cooldownButton); } actions.append(button('離開房間', async () => { + retireOccupancy(); handRejectionRoomActive = false; retireHandRejection(); // Leaving after a pre-ACK snapshot also retires that entry's local feedback. if (landingEntry) landingEntry.cancelled = true; micTest?.stop(); micTest = null; testButton.textContent = '測試麥克風'; testButton.setAttribute('aria-pressed', 'false'); - await client.disconnect(); createdRoom = null; micNotice = invitation = ''; landing(); + const disconnect = client.disconnect(); renderOccupancy(); + await disconnect; createdRoom = null; micNotice = invitation = ''; landing(); })); if (controller) actions.append(roomButton('close', '關閉房間', async () => { if (window.confirm('確定關閉房間並讓所有人離場?')) await client.closeRoom(); @@ -869,7 +1189,7 @@ function renderRoom(state: RoomStatePayload): void { renderedReturnStage = content.querySelector('[data-return-stage]'); renderedAudienceHeading = audienceHeading; renderedRoomSignature = signature; renderedSpeakingSignature = speakingSignature; renderedQualitySignature = qualitySignature; - syncAudienceFilter(state); updateCooldown(); renderNotices(true); + syncAudienceFilter(state); syncHandFilter(state); updateCooldown(); renderNotices(true); } function panelList(title: string, people: ParticipantView[], controller: boolean, kind: 'speaker' | 'hand'): HTMLElement { const panel = element('section', 'panel'); @@ -886,7 +1206,7 @@ function personRow(person: ParticipantView, controller: boolean, kind: 'speaker' if (kind === 'audience') audienceFilterRows.push({ row, name: decodeName(person.name).normalize('NFC').toLowerCase() }); const rows = personRows.get(person.participantId); if (rows) rows.push(row); else personRows.set(person.participantId, [row]); - row.classList.toggle('speaking', client.speaking.has(person.participantId)); + row.classList.toggle('speaking', speakingIds.has(person.participantId)); const identity = element('div', 'identity'); identity.append(element('strong', '', decodeName(person.name)), element('span', 'badge', roleLabels[person.role])); if (!person.connected) identity.append(element('span', 'badge', '連線中斷')); @@ -902,7 +1222,15 @@ function personRow(person: ParticipantView, controller: boolean, kind: 'speaker' && client.status !== 'reconnecting' && client.me?.role === 'controller' && client.state?.audience?.some((p) => p.participantId === id)); const actions = element('div', 'actions'); - if (kind === 'hand') actions.append(roomButton(`stage:${id}`, '核准上台', () => client.approve(id), 'primary'), roomButton(`stage:${id}`, '婉拒', () => client.reject(id))); + if (kind === 'hand') { + const entry = { row, id, sourceName: person.name, name: name.normalize('NFC').toLowerCase() }; + handFilterRows.push(entry); + actions.append(handActionButton(entry, `stage:${id}`, '核准上台', 'approve', 'primary'), + handActionButton(entry, `stage:${id}`, '婉拒', 'reject'), + handActionButton(entry, 'transfer', '移交控制權', 'transferControl', '', `確定將控制權移交給 ${name}?`), + handActionButton(entry, `kick:${id}`, '踢出房間', 'kick', 'danger', `確定將 ${name} 踢出房間?若要防止對方再進來,請接著更換房間代碼。`)); + row.append(actions); return row; + } if (kind === 'speaker') actions.append(gainSlider(person), roomButton(`force-mute:${id}`, forceMuted ? '解除強制靜音' : '強制靜音', () => forceMuted ? client.forceUnmute(id) : client.forceMute(id)), roomButton(`stage:${id}`, '移出舞台', () => client.removeFromStage(id))); actions.append(roomButton('transfer', '移交控制權', async () => { if (window.confirm(`確定將控制權移交給 ${name}?`) && canManageAudience()) await client.transferControl(id); @@ -984,8 +1312,16 @@ function scheduleCooldown(remaining: number): void { cooldownTimer = timer; cooldownDue = due; } } -client.on('state', ({ detail }) => { if (landingEntry) landingEntry.entered = true; renderRoom(detail); }); +client.on('state', ({ detail }) => { + acceptSpeakingState(detail); + // Only a new current snapshot resolves early loss; status/report sync still sees the old seat. + if (detail === client.state && detail !== stageQualitySuppressedState) stageQualitySuppressedState = undefined; + if (landingEntry) landingEntry.entered = true; + try { renderRoom(detail); } finally { renderOccupancy(); } +}); client.on('status', ({ detail }) => { + if (detail === 'disconnected' && !client.state) resetSpeaking(); + else if (detail === 'reconnecting' || detail === 'disconnected') retireSpeaking(); if (detail === 'reconnecting' || detail === 'disconnected') { retireHandRejection(); audioRetryNotice.hidden = true; audioRetryNotice.textContent = ''; } @@ -1002,8 +1338,16 @@ client.on('status', ({ detail }) => { }); client.on('hand', ({ detail }) => { if (detail.raised && detail.participantId === client.me?.participantId) retireHandRejection(); }); client.on('stagejoined', ({ detail }) => { if (detail.participantId === client.me?.participantId) retireHandRejection(); }); -client.on('role', ({ detail }) => { if (detail.role !== 'audience' && detail.participantId === client.me?.participantId) retireHandRejection(); }); -client.on('transferred', ({ detail }) => { if (detail.toId === client.me?.participantId) retireHandRejection(); }); +client.on('role', ({ detail }) => { + if (detail.participantId !== client.me?.participantId) return; + if (detail.role === 'audience') suppressQualityDetails(); + else retireHandRejection(); +}); +client.on('transferred', ({ detail }) => { + const me = client.me; + if (detail.fromId === me?.participantId && !me.onStage) suppressQualityDetails(); + if (detail.toId === client.me?.participantId) retireHandRejection(); +}); client.on('handrejected', ({ detail }) => { // Events carry no raise identifier; copy the current-seat ownership before observable work. const generation = roomActionGeneration; @@ -1026,6 +1370,7 @@ client.on('invite', ({ detail }) => { } }); client.on('stageleft', ({ detail }) => { + if (detail.participantId === client.me?.participantId && detail.role !== 'controller') suppressQualityDetails(); if (detail.participantId === client.me?.participantId) { stagePermissionOwner = undefined; invitation = detail.reason === 'removed' ? '主控已將你移出舞台,你仍可收聽。' : ''; renderNotices(); } }); client.on('micerror', ({ detail }) => { micRetryPending = false; micRetryAvailable = Boolean(client.me?.onStage); micNotice = `${detail.message}(${detail.name})`; renderNotices(); }); @@ -1033,15 +1378,12 @@ client.on('audioblocked', () => { audioBlocked = true; renderNotices(); }); client.on('error', ({ detail }) => showError(detail.message)); // Toggle in place: a full re-render every few hundred ms would reset focus and scroll. client.on('speaking', ({ detail }) => { + const revision = ++speakingRevision; const ids = new Set(detail.participantIds); - for (const id of speakingIds) { - if (!ids.has(id)) for (const row of personRows.get(id) ?? []) row.classList.toggle('speaking', false); - } - for (const id of ids) { - if (!speakingIds.has(id)) for (const row of personRows.get(id) ?? []) row.classList.toggle('speaking', true); - } - speakingIds = ids; - renderedSpeakingSignature = JSON.stringify([...ids]); + if (revision !== speakingRevision || !speakingIdentity || speakingState !== client.state + || client.status === 'reconnecting' || client.status === 'disconnected') return; + speakingRetired = false; speakingReconnectState = null; + updateSpeaking(ids); }); client.on('quality', ({ detail }) => { syncQualityDetails(detail.participants); @@ -1060,13 +1402,20 @@ function refreshConnectionLine(): void { // A newer pending poll must not starve completed samples; older completions cannot replace newer results. if (!client.state || document.hidden || generation !== roomActionGeneration || sequence <= connectionResultWatermark) return; connectionResultWatermark = sequence; - const parts: string[] = []; - if (inbound?.lossPercent !== undefined) parts.push(`收聽掉包 ${inbound.lossPercent.toFixed(1)}%`); - if (outbound?.lossPercent !== undefined) parts.push(`發言掉包 ${outbound.lossPercent.toFixed(1)}%`); - const rtt = inbound?.rtt ?? outbound?.rtt; - if (rtt !== undefined) parts.push(`延遲 ${Math.round(rtt * 1000)} ms`); - if (inbound?.bufferMs !== undefined) parts.push(`緩衝 ${Math.round(inbound.bufferMs)} ms`); - connectionLine.textContent = parts.length ? `你的連線:${parts.join(' · ')}` : ''; + const milliseconds = (value: number | undefined, scale = 1): string => { + if (value === undefined) return '—'; + const ms = value * scale; + return Number.isFinite(ms) ? String(Math.round(ms)) : '—'; + }; + const receive = inbound + ? `接收掉包 ${inbound.lossPercent?.toFixed(1) ?? '—'}% · 接收 RTT ${milliseconds(inbound.rtt, 1000)} ms · 接收緩衝 ${milliseconds(inbound.bufferMs)} ms` + : '接收:暫無統計'; + const send = outbound + ? `傳送掉包 ${outbound.lossPercent?.toFixed(1) ?? '—'}% · 傳送 RTT ${milliseconds(outbound.rtt, 1000)} ms` + : '傳送:暫無統計'; + connectionLine.textContent = inbound || outbound + ? `你的連線(上次完成讀取;SDK 取樣區間):${receive};${send}。RTT 不是端到端音訊延遲;不保證資料即時或目前可聽。` + : ''; connectionLine.classList.toggle('error', Math.max(inbound?.lossPercent ?? 0, outbound?.lossPercent ?? 0) >= POOR_LOSS_PERCENT); }, () => {}); } @@ -1089,6 +1438,9 @@ function stopRoomTimers(): void { } client.on('state', syncRoomTimers); client.on('status', syncRoomTimers); +client.on('status', renderOccupancy); +client.on('closed', renderOccupancy); +client.on('kicked', renderOccupancy); // Page-lifetime listener, like the client/device listeners; landing previews also use it. document.addEventListener('visibilitychange', () => { connectionResultWatermark = ++connectionPollSequence; // Retire pending UI results without resetting SDK delta baselines. @@ -1096,5 +1448,7 @@ document.addEventListener('visibilitychange', () => { // A resumed SDK sample can span the hidden interval since the last completed stats sample. if (!document.hidden) refreshConnectionLine(); }); +// Keep this passive paragraph outside the retained room tree and existing live notices. +app.append(roomOccupancy, content, stageQuality, settings); landing(); if (signaling.error) showError(signaling.error); diff --git a/web/vite.config.ts b/web/vite.config.ts index 8c623bc..42011cb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ + // The same built pages can be served at the root or a configured static mount. + base: './', server: { proxy: { '/ws': { target: 'http://127.0.0.1:9728', ws: true } } }, build: { outDir: 'dist', rollupOptions: { input: { main: 'index.html', embed: 'embed.html' } } }, });