@xenon-device-management/xenon 2.17.0 → 2.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/lib/package.json +2 -3
  2. package/lib/public/assets/{AnnotationOverlay-CjmMCS0S.js → AnnotationOverlay-CB8njQvS.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-B4Ob0v3C.js → ApiKeyGate-B-804QBS.js} +1 -1
  4. package/lib/public/assets/DeviceMosaicView-BsDvfjR1.js +2 -0
  5. package/lib/public/assets/{EmptyState-DcEG-oAi.js → EmptyState-WzLw1cEV.js} +1 -1
  6. package/lib/public/assets/{FieldGroup-CtnCDshE.js → FieldGroup-CaQNXgcE.js} +1 -1
  7. package/lib/public/assets/{FilterMenu-BK0dE9cc.js → FilterMenu-C9gAiypL.js} +1 -1
  8. package/lib/public/assets/{Menu-eKsmLpKS.js → Menu-CfTti3rw.js} +1 -1
  9. package/lib/public/assets/{Modal-DPwop3Lz.js → Modal-BcZUFhDm.js} +1 -1
  10. package/lib/public/assets/{RecordingPage-CfgSkZkB.js → RecordingPage-B_pbl3UE.js} +1 -1
  11. package/lib/public/assets/{RecordingsPage-Bzl9pTUY.js → RecordingsPage-BkVhplE3.js} +1 -1
  12. package/lib/public/assets/{SegmentedControl-B_TZTrPm.js → SegmentedControl-CDLMLNl5.js} +1 -1
  13. package/lib/public/assets/{SettingCard-DlmvZJPM.js → SettingCard-Lyw5CB1F.js} +1 -1
  14. package/lib/public/assets/{Table-BigPWKXi.js → Table-CK414bOU.js} +1 -1
  15. package/lib/public/assets/{activity-CvuZdQYu.js → activity-pEyAdE-M.js} +1 -1
  16. package/lib/public/assets/{ai-settings-lI2UJ2n7.js → ai-settings-CIhS7MMq.js} +1 -1
  17. package/lib/public/assets/{api-keys-CPgf9XJM.js → api-keys-D2J44Ebv.js} +1 -1
  18. package/lib/public/assets/{apps-DIzOvtUp.js → apps-CCV3RZY8.js} +1 -1
  19. package/lib/public/assets/{arrow-left-DiP5ywBT.js → arrow-left-DaEUmERk.js} +1 -1
  20. package/lib/public/assets/{arrow-right-DtW_8hm5.js → arrow-right-CnRXHXit.js} +1 -1
  21. package/lib/public/assets/{arrow-up-right-C89FbNU0.js → arrow-up-right-BGCS7zhi.js} +1 -1
  22. package/lib/public/assets/{auth-shell-B7sUDb--.js → auth-shell-CpW1zsxD.js} +2 -2
  23. package/lib/public/assets/{builds-page-BBhvrMfA.js → builds-page-B-xdzjMx.js} +1 -1
  24. package/lib/public/assets/{button-DugsdUw1.js → button-B5QkOVa4.js} +1 -1
  25. package/lib/public/assets/{calendar-grKd08ZH.js → calendar-gX_1EKue.js} +1 -1
  26. package/lib/public/assets/{check-ORjyNfnX.js → check-MIM4THvH.js} +1 -1
  27. package/lib/public/assets/{chevron-right-Ei2FxUXq.js → chevron-right-BZIp3-9t.js} +1 -1
  28. package/lib/public/assets/{circle-check-Day0HjW4.js → circle-check-Crt-G_uy.js} +1 -1
  29. package/lib/public/assets/{circle-x-CHxSRLHp.js → circle-x-C8GvvXVt.js} +1 -1
  30. package/lib/public/assets/{clock-BDHewWTx.js → clock-MFKV4qGa.js} +1 -1
  31. package/lib/public/assets/{copy-CTwT4I3D.js → copy-BU-Z7mBK.js} +1 -1
  32. package/lib/public/assets/device-explorer-D-78Twa0.js +282 -0
  33. package/lib/public/assets/{download-D3bjz_vr.js → download-DIoLCCYv.js} +1 -1
  34. package/lib/public/assets/{forgot-password-BNBLi023.js → forgot-password-vNYlPnia.js} +1 -1
  35. package/lib/public/assets/{h264Stream-C51Au2i_.js → h264Stream-BLcSplMU.js} +1 -1
  36. package/lib/public/assets/{index-Dk5J6HB1.js → index-BpqyclNY.js} +2 -2
  37. package/lib/public/assets/{index-DncSz5LG.js → index-Hl1OL-k_.js} +1 -1
  38. package/lib/public/assets/{index-Z7_BY3pJ.js → index-QbukVVrl.js} +1 -1
  39. package/lib/public/assets/{input-bTXM4l09.js → input-DHH3fHer.js} +1 -1
  40. package/lib/public/assets/{line-chart-B3YmVYyr.js → line-chart-PdViEphw.js} +1 -1
  41. package/lib/public/assets/{list-checks-BClKEj1-.js → list-checks-DI_heIg9.js} +1 -1
  42. package/lib/public/assets/{lock-CZsenX3D.js → lock-DVP_dL6J.js} +1 -1
  43. package/lib/public/assets/{login-B6A6tOp9.js → login-B8vtTDcr.js} +1 -1
  44. package/lib/public/assets/{maintenance-settings-CmCJGy7O.js → maintenance-settings-BMa5EfgI.js} +1 -1
  45. package/lib/public/assets/{monitor-BdTNEglU.js → monitor-CtzX_RkD.js} +1 -1
  46. package/lib/public/assets/{mouse-pointer-2-BM8CaLtt.js → mouse-pointer-2-BA09sdS9.js} +1 -1
  47. package/lib/public/assets/{network-BS6vUc1x.js → network-B_F9Nox-.js} +1 -1
  48. package/lib/public/assets/{overview-Yfk_Y7g3.js → overview-kWCYMHMV.js} +1 -1
  49. package/lib/public/assets/{page-header-DhXCwpml.js → page-header-CAbH1O1a.js} +1 -1
  50. package/lib/public/assets/{play-D08gP1td.js → play-sg95hLqA.js} +1 -1
  51. package/lib/public/assets/{plus-BKboorfa.js → plus-BT9mk2gj.js} +1 -1
  52. package/lib/public/assets/{profile-page-Cd9NodrA.js → profile-page-DZOz5Ypc.js} +1 -1
  53. package/lib/public/assets/{reset-password-acLDyyDW.js → reset-password-CqeImlEO.js} +1 -1
  54. package/lib/public/assets/{runbook-page-CWLkC1Fd.js → runbook-page-U_8TSysj.js} +1 -1
  55. package/lib/public/assets/{select-DZoRKNti.js → select-Cx4JDzsR.js} +1 -1
  56. package/lib/public/assets/{selector-detail-redirect-DS4qrt84.js → selector-detail-redirect-DiMRe7DW.js} +1 -1
  57. package/lib/public/assets/{selector-health-page-TpmN61G3.js → selector-health-page-CKR5URll.js} +1 -1
  58. package/lib/public/assets/{session-detail-page-C0wYaXqy.js → session-detail-page-DuIVzTW_.js} +1 -1
  59. package/lib/public/assets/{settings-CxPGEY2j.js → settings-B5fuvgTP.js} +1 -1
  60. package/lib/public/assets/{stat-tile-DUpyYgl-.js → stat-tile-CtHX1dkh.js} +1 -1
  61. package/lib/public/assets/{tablet-SA02BJNQ.js → tablet-DCGD0GQt.js} +1 -1
  62. package/lib/public/assets/{teams-jamD0Kxr.js → teams-MzlELlw9.js} +1 -1
  63. package/lib/public/assets/{trash-2-DO8fv6w4.js → trash-2-BIRm2KVs.js} +1 -1
  64. package/lib/public/assets/{upload-CaiGCStb.js → upload-C-lgyswH.js} +1 -1
  65. package/lib/public/assets/{use-builds-data-jS-fcvVf.js → use-builds-data-IOydXA8d.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-BzoenegM.js → use-password-reset-mode-CIVXG7Me.js} +1 -1
  67. package/lib/public/assets/{users-Bnw7Vs_B.js → users-hwK5XGIy.js} +1 -1
  68. package/lib/public/assets/{users-DHvDrriG.js → users-ivNCjeus.js} +1 -1
  69. package/lib/public/assets/{video-off-FEvy0w5U.js → video-off-DvcIEsdZ.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-CBVB5lxi.js → webhook-settings-DukgCe1u.js} +1 -1
  71. package/lib/public/assets/{zap-BMrTJO6M.js → zap-BE9gacFj.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/openapi/control.yaml +69 -4
  74. package/lib/src/app/routers/control.js +24 -5
  75. package/lib/src/device-managers/ios/IOSStreamService.js +147 -18
  76. package/lib/src/device-utils.js +50 -22
  77. package/lib/src/gateway/internalCall.js +33 -5
  78. package/lib/src/gateway/sessionGateway.js +9 -4
  79. package/lib/src/helpers/index.js +9 -6
  80. package/lib/src/interceptors/CommandInterceptor.js +10 -0
  81. package/lib/src/services/ServerManager.js +25 -4
  82. package/lib/src/services/UserService.js +8 -3
  83. package/lib/src/sessions/appiumUmbrella.js +11 -0
  84. package/lib/src/sessions/sessionMemory.js +11 -2
  85. package/lib/test/unit/UserService.test.js +8 -0
  86. package/lib/test/unit/boot-device-sync.spec.js +195 -0
  87. package/lib/test/unit/hub-node-gateway-appium-server.spec.js +21 -0
  88. package/lib/test/unit/install-scripts.spec.js +98 -0
  89. package/lib/test/unit/internal-call.spec.js +99 -0
  90. package/lib/test/unit/internal-calls-session-record.spec.js +312 -0
  91. package/lib/test/unit/ios-simulator-preview.spec.js +315 -0
  92. package/lib/test/unit/is-xenon-running.spec.js +10 -0
  93. package/lib/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +2 -3
  95. package/lib/public/assets/DeviceMosaicView-DTVOB_NO.js +0 -2
  96. package/lib/public/assets/device-explorer-DlhrKJ-g.js +0 -282
@@ -1 +1 @@
1
- import{t as P,r as l,X as p,j as e,P as w,A as x,C as V,Q as I}from"./index-Dk5J6HB1.js";/* empty css */import{F as k}from"./FieldGroup-CtnCDshE.js";import{S as U}from"./select-DZoRKNti.js";import{P as B}from"./page-header-DhXCwpml.js";import{T as D}from"./trash-2-DO8fv6w4.js";import{P as _}from"./plus-BKboorfa.js";import{C as J}from"./circle-x-CHxSRLHp.js";import{C as K}from"./circle-check-Day0HjW4.js";import{Z as M}from"./zap-BMrTJO6M.js";import{A as X}from"./activity-CvuZdQYu.js";import"./utils-CDN07tui.js";const Z=["udid","name","host","platform"],q=["udid","name","host","platform"],G=["sessionId","sessionName","failureReason","udid","deviceName","platform","osVersion","startTime","endTime"],Q=["windowDays","totalHeals","distinctSelectors"],Y={device_offline:Z,device_new:q,session_failed:G,selector_health_digest:Q},ee=Y,se={eventType:"The event: device_offline, session_failed, ...",udid:"The device's id",name:"The device's name",host:"The machine it is plugged into",platform:"android or ios",sessionId:"The session's id",sessionName:"The session's name, if the test gave it one",failureReason:"Why it failed",deviceName:"The device's name",osVersion:"The device's OS version",startTime:"When it started (ISO 8601)",endTime:"When it ended (ISO 8601)",windowDays:"How many days the digest covers",totalHeals:"Heals in that time",distinctSelectors:"Selectors that were healed"};function te(o){const c=["eventType"];for(const u of o)for(const h of ee[u]??[])c.includes(h)||c.push(h);return c}const ae=[{id:"slack",label:"Slack message"},{id:"webhook",label:"JSON (event and payload)"}];function oe(o){return o.payloadTemplate?"CUSTOM":o.type==="slack"?"SLACK":"JSON"}const j=[{id:"device_offline",label:"Device offline",icon:V,tone:"red"},{id:"device_new",label:"New device",icon:_,tone:"green"},{id:"session_failed",label:"Session failed",icon:J,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:I,tone:"green"}],be=()=>{const{toast:o}=P(),[c,u]=l.useState([]),[h,N]=l.useState(!0),[r,y]=l.useState(""),[i,S]=l.useState(["device_offline","session_failed"]),[f,T]=l.useState(!1),[C,A]=l.useState(!1),[d,E]=l.useState(!1),[m,g]=l.useState(""),[b,O]=l.useState("slack");l.useEffect(()=>{v()},[]);const v=async()=>{N(!0);try{const s=await p.getWebhookConfigs();u(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{N(!1)}},W=async()=>{if(!r.trim()){o("Webhook URL is required.","error");return}if(i.length===0){o("Select at least one trigger event.","error");return}T(!0);try{await p.addWebhookConfig(r.trim(),i,b,m||void 0),y(""),g(""),E(!1),O("slack"),S(["device_offline","session_failed"]),await v(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{T(!1)}},$=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await p.deleteWebhookConfig(s),await v(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(!r.trim()||i.length===0)return;A(!0);const s=t=>{var a;return((a=j.find(n=>n.id===t))==null?void 0:a.label)??t};try{for(const t of i)try{await p.testWebhook(r.trim(),b,m||void 0,t)}catch(a){o(`Test failed for ${s(t)}: ${a.message||"check the URL"}`,"error");return}o(i.length===1?`Test message delivered: ${s(i[0])}.`:`Test messages delivered: ${i.map(s).join(", ")}.`,"success")}finally{A(!1)}},R=s=>{S(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},z=s=>{g(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(B,{icon:w,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[h?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(x,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):c.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(w,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:c.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:oe(s)}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>$(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(D,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const n=j.find(H=>H.id===a),F=(n==null?void 0:n.icon)??w;return e.jsxs("span",{className:`event-pill event-pill--${(n==null?void 0:n.tone)||"gray"}`,children:[e.jsx(F,{size:11}),(n==null?void 0:n.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(_,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(k,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:r,onChange:s=>y(s.target.value)})})}),e.jsx(k,{label:"Message format",description:m.trim()?"Your custom payload is sent as written, whatever the format.":"Slack message for a Slack incoming webhook; JSON for anything else.",htmlFor:"webhook-format",children:e.jsx(U,{id:"webhook-format",value:b,onChange:s=>O(s.target.value),children:ae.map(s=>e.jsx("option",{value:s.id,children:s.label},s.id))})}),e.jsx(k,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:j.map(s=>{const t=i.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>R(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(K,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>E(!d),"aria-expanded":d,children:[e.jsx(M,{size:14,className:d?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:d?"−":"+"})]}),d&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a name below to insert it; the names are the ones your selected events carry."}),e.jsx("div",{className:"template-editor__chips",children:te(i).map(s=>e.jsx("button",{type:"button",className:"template-editor__chip","aria-label":`{{${s}}}`,title:se[s],onClick:()=>z(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:m,onChange:s=>g(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!r.trim()||i.length===0||C,"aria-label":"Send test",title:i.length===0?"Select a trigger event to send a sample of":"Sends a sample message for each selected event",children:[C?e.jsx(x,{size:14,className:"animate-spin"}):e.jsx(X,{size:14}),e.jsx("span",{children:"Send test"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:W,disabled:f||!r.trim(),children:[f?e.jsx(x,{size:14,className:"animate-spin"}):e.jsx(_,{size:14}),e.jsx("span",{children:f?"Saving…":"Save webhook"})]})]})]})]})]})};export{be as WebhookSettings};
1
+ import{t as P,r as l,X as p,j as e,P as w,A as x,C as V,Q as I}from"./index-BpqyclNY.js";/* empty css */import{F as k}from"./FieldGroup-CaQNXgcE.js";import{S as U}from"./select-Cx4JDzsR.js";import{P as B}from"./page-header-CAbH1O1a.js";import{T as D}from"./trash-2-BIRm2KVs.js";import{P as _}from"./plus-BT9mk2gj.js";import{C as J}from"./circle-x-C8GvvXVt.js";import{C as K}from"./circle-check-Crt-G_uy.js";import{Z as M}from"./zap-BE9gacFj.js";import{A as X}from"./activity-pEyAdE-M.js";import"./utils-CDN07tui.js";const Z=["udid","name","host","platform"],q=["udid","name","host","platform"],G=["sessionId","sessionName","failureReason","udid","deviceName","platform","osVersion","startTime","endTime"],Q=["windowDays","totalHeals","distinctSelectors"],Y={device_offline:Z,device_new:q,session_failed:G,selector_health_digest:Q},ee=Y,se={eventType:"The event: device_offline, session_failed, ...",udid:"The device's id",name:"The device's name",host:"The machine it is plugged into",platform:"android or ios",sessionId:"The session's id",sessionName:"The session's name, if the test gave it one",failureReason:"Why it failed",deviceName:"The device's name",osVersion:"The device's OS version",startTime:"When it started (ISO 8601)",endTime:"When it ended (ISO 8601)",windowDays:"How many days the digest covers",totalHeals:"Heals in that time",distinctSelectors:"Selectors that were healed"};function te(o){const c=["eventType"];for(const u of o)for(const h of ee[u]??[])c.includes(h)||c.push(h);return c}const ae=[{id:"slack",label:"Slack message"},{id:"webhook",label:"JSON (event and payload)"}];function oe(o){return o.payloadTemplate?"CUSTOM":o.type==="slack"?"SLACK":"JSON"}const j=[{id:"device_offline",label:"Device offline",icon:V,tone:"red"},{id:"device_new",label:"New device",icon:_,tone:"green"},{id:"session_failed",label:"Session failed",icon:J,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:I,tone:"green"}],be=()=>{const{toast:o}=P(),[c,u]=l.useState([]),[h,N]=l.useState(!0),[r,y]=l.useState(""),[i,S]=l.useState(["device_offline","session_failed"]),[f,T]=l.useState(!1),[C,A]=l.useState(!1),[d,E]=l.useState(!1),[m,g]=l.useState(""),[b,O]=l.useState("slack");l.useEffect(()=>{v()},[]);const v=async()=>{N(!0);try{const s=await p.getWebhookConfigs();u(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{N(!1)}},W=async()=>{if(!r.trim()){o("Webhook URL is required.","error");return}if(i.length===0){o("Select at least one trigger event.","error");return}T(!0);try{await p.addWebhookConfig(r.trim(),i,b,m||void 0),y(""),g(""),E(!1),O("slack"),S(["device_offline","session_failed"]),await v(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{T(!1)}},$=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await p.deleteWebhookConfig(s),await v(),o("Webhook removed.","success")}catch(a){console.error("Failed to delete webhook",a),o(`Failed to delete: ${a.message||"unknown error"}`,"error")}},L=async()=>{if(!r.trim()||i.length===0)return;A(!0);const s=t=>{var a;return((a=j.find(n=>n.id===t))==null?void 0:a.label)??t};try{for(const t of i)try{await p.testWebhook(r.trim(),b,m||void 0,t)}catch(a){o(`Test failed for ${s(t)}: ${a.message||"check the URL"}`,"error");return}o(i.length===1?`Test message delivered: ${s(i[0])}.`:`Test messages delivered: ${i.map(s).join(", ")}.`,"success")}finally{A(!1)}},R=s=>{S(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},z=s=>{g(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(B,{icon:w,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[h?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(x,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):c.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(w,{size:28})}),e.jsx("h3",{className:"empty-state__title",children:"No webhooks configured"}),e.jsx("p",{className:"empty-state__copy",children:"Wire up a Slack incoming webhook or any HTTPS endpoint to receive alerts when devices go offline, new devices register, or sessions fail. Configure your first webhook below."})]}):e.jsx("div",{className:"webhook-list-grid",children:c.map(s=>{const t=(()=>{try{return JSON.parse(s.events)}catch{return[]}})();return e.jsxs("div",{className:"webhook-row-card",children:[e.jsxs("div",{className:"webhook-row-card__header",children:[e.jsx("span",{className:`pill-chip ${s.payloadTemplate?"pill-chip--admin":"pill-chip--scope"}`,children:oe(s)}),e.jsx("span",{className:"webhook-row-card__url",title:s.url,children:s.url}),e.jsxs("button",{type:"button",className:"row-action row-action--danger",onClick:()=>$(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(D,{size:13}),e.jsx("span",{children:"Remove"})]})]}),e.jsx("div",{className:"webhook-row-card__events",children:t.length===0?e.jsx("span",{className:"webhook-row-card__no-events",children:"No triggers"}):t.map(a=>{const n=j.find(H=>H.id===a),F=(n==null?void 0:n.icon)??w;return e.jsxs("span",{className:`event-pill event-pill--${(n==null?void 0:n.tone)||"gray"}`,children:[e.jsx(F,{size:11}),(n==null?void 0:n.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(_,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(k,{label:"Webhook URL",description:"The endpoint we POST webhook events to.",htmlFor:"webhook-url",children:e.jsx("div",{className:"setting-input-wrapper",children:e.jsx("input",{id:"webhook-url",type:"url",placeholder:"https://hooks.slack.com/services/...",value:r,onChange:s=>y(s.target.value)})})}),e.jsx(k,{label:"Message format",description:m.trim()?"Your custom payload is sent as written, whatever the format.":"Slack message for a Slack incoming webhook; JSON for anything else.",htmlFor:"webhook-format",children:e.jsx(U,{id:"webhook-format",value:b,onChange:s=>O(s.target.value),children:ae.map(s=>e.jsx("option",{value:s.id,children:s.label},s.id))})}),e.jsx(k,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:j.map(s=>{const t=i.includes(s.id),a=s.icon;return e.jsxs("button",{type:"button",role:"checkbox","aria-checked":t,className:`event-toggle event-toggle--${s.tone} ${t?"is-selected":""}`,onClick:()=>R(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(K,{size:14,className:"event-toggle__check"})]},s.id)})})}),e.jsxs("div",{className:"template-section",children:[e.jsxs("button",{type:"button",className:"template-section__toggle",onClick:()=>E(!d),"aria-expanded":d,children:[e.jsx(M,{size:14,className:d?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:d?"−":"+"})]}),d&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a name below to insert it; the names are the ones your selected events carry."}),e.jsx("div",{className:"template-editor__chips",children:te(i).map(s=>e.jsx("button",{type:"button",className:"template-editor__chip","aria-label":`{{${s}}}`,title:se[s],onClick:()=>z(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:m,onChange:s=>g(s.target.value),rows:4})]})]})]}),e.jsxs("div",{className:"add-webhook-card__footer",children:[e.jsxs("button",{type:"button",className:"page-header-action page-header-action--ghost",onClick:L,disabled:!r.trim()||i.length===0||C,"aria-label":"Send test",title:i.length===0?"Select a trigger event to send a sample of":"Sends a sample message for each selected event",children:[C?e.jsx(x,{size:14,className:"animate-spin"}):e.jsx(X,{size:14}),e.jsx("span",{children:"Send test"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:W,disabled:f||!r.trim(),children:[f?e.jsx(x,{size:14,className:"animate-spin"}):e.jsx(_,{size:14}),e.jsx("span",{children:f?"Saving…":"Save webhook"})]})]})]})]})]})};export{be as WebhookSettings};
@@ -1,4 +1,4 @@
1
- import{c as a}from"./index-Dk5J6HB1.js";/**
1
+ import{c as a}from"./index-BpqyclNY.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -36,7 +36,7 @@
36
36
  Learn how to configure a non-root public URL by running `npm run build`.
37
37
  -->
38
38
  <title>Xenon</title>
39
- <script type="module" crossorigin src="/xenon/assets/index-Dk5J6HB1.js"></script>
39
+ <script type="module" crossorigin src="/xenon/assets/index-BpqyclNY.js"></script>
40
40
  <link rel="stylesheet" crossorigin href="/xenon/assets/index-Bxoxt9sV.css">
41
41
  </head>
42
42
 
@@ -1941,8 +1941,12 @@ paths:
1941
1941
  summary: Start the live preview and hold the device
1942
1942
  description: >-
1943
1943
  Starts the device's live preview and holds the device for you (`session_id` becomes
1944
- `manual_<yourUserId>_<udid>`), so automation is not given it while you watch. iOS starts
1945
- WebDriverAgent's MJPEG stream (and the go-ios tunnel on iOS 17+). Android starts an MJPEG
1944
+ `manual_<yourUserId>_<udid>`), so automation is not given it while you watch. An iPhone
1945
+ starts WebDriverAgent's MJPEG stream (and the go-ios tunnel on iOS 17+). An iOS simulator
1946
+ starts nothing and is not held (its test holds it): it shows the picture of the test
1947
+ running on it, from the WebDriverAgent the XCUITest driver runs for that test, and with no
1948
+ test running it answers `409` with `error: simulator_needs_test` and a `message` saying
1949
+ so. Android starts an MJPEG
1946
1950
  screen capture, or, when the server's `streaming.androidH264` setting is on and the device
1947
1951
  is not being recorded, an H.264 stream for the WebSocket in `h264Path`. Missing screen
1948
1952
  dimensions are filled in and saved.
@@ -2035,7 +2039,34 @@ paths:
2035
2039
  '404':
2036
2040
  $ref: '#/components/responses/ControlDeviceNotFound'
2037
2041
  '409':
2038
- $ref: '#/components/responses/ControlHeld'
2042
+ description: >-
2043
+ Another user holds the device, or it runs another user's Appium session (admins are
2044
+ never refused), as for every device action. Or the device is an iOS simulator no test
2045
+ runs on (`simulator_needs_test`): its screen shows only while a test runs on it.
2046
+ content:
2047
+ application/json:
2048
+ schema:
2049
+ oneOf:
2050
+ - $ref: '#/components/schemas/ControlDenied'
2051
+ - $ref: '#/components/schemas/ControlSimulatorNeedsTest'
2052
+ examples:
2053
+ held:
2054
+ summary: Another user's preview or recording
2055
+ value:
2056
+ success: false
2057
+ error: device_held_by_another_user
2058
+ message: >-
2059
+ Device is being controlled by Priya Raman. Ask them to release it, or use an
2060
+ admin key to force-release.
2061
+ holder:
2062
+ userId: c7a1e5d2-3f4b-4a9e-8c21-5b6d7e8f9a01
2063
+ name: Priya Raman
2064
+ simulatorNeedsTest:
2065
+ summary: An iOS simulator no test runs on
2066
+ value:
2067
+ success: false
2068
+ error: simulator_needs_test
2069
+ message: A simulator's screen shows here only while a test runs on it.
2039
2070
  '429':
2040
2071
  $ref: '#/components/responses/RateLimited'
2041
2072
  '500':
@@ -2286,7 +2317,9 @@ paths:
2286
2317
  Says whether the device's preview is running and which transport a player should use
2287
2318
  (`type`: `h264` on Android when the server's `streaming.androidH264` setting is on and the
2288
2319
  device isn't being recorded, else `mjpeg`). A device with no preview answers `200` with
2289
- `status: stopped`. iOS adds WebDriverAgent's port, the start time and the last error.
2320
+ `status: stopped`. iOS adds WebDriverAgent's port, the start time and the last error. A
2321
+ simulator no test runs on answers `status: stopped` with `lastError` saying why it shows
2322
+ nothing, and `reason: simulator_needs_test`.
2290
2323
 
2291
2324
 
2292
2325
  Open to watchers. Needs the `MEMBER` role; any scope will do.
@@ -2326,6 +2359,15 @@ paths:
2326
2359
  udid: R5CT32ABCDE
2327
2360
  status: stopped
2328
2361
  type: mjpeg
2362
+ simulatorWithoutTest:
2363
+ summary: An iOS simulator no test runs on
2364
+ value:
2365
+ udid: 35429994-B029-4AF4-9634-880B89DAC782
2366
+ status: stopped
2367
+ type: mjpeg
2368
+ mjpegPort: 9101
2369
+ lastError: A simulator's screen shows here only while a test runs on it.
2370
+ reason: simulator_needs_test
2329
2371
  '400':
2330
2372
  description: The udid is malformed.
2331
2373
  content:
@@ -3147,6 +3189,13 @@ components:
3147
3189
  lastError:
3148
3190
  type: string
3149
3191
  description: iOS only.
3192
+ reason:
3193
+ type: string
3194
+ enum:
3195
+ - simulator_needs_test
3196
+ description: >-
3197
+ Set when the preview is refused rather than failed: `simulator_needs_test`, an iOS
3198
+ simulator no test runs on.
3150
3199
  ControlStreamLeaveResult:
3151
3200
  type: object
3152
3201
  properties:
@@ -3156,6 +3205,22 @@ components:
3156
3205
  pending:
3157
3206
  type: boolean
3158
3207
  example: true
3208
+ ControlSimulatorNeedsTest:
3209
+ type: object
3210
+ required:
3211
+ - error
3212
+ - message
3213
+ properties:
3214
+ success:
3215
+ type: boolean
3216
+ example: false
3217
+ error:
3218
+ type: string
3219
+ enum:
3220
+ - simulator_needs_test
3221
+ message:
3222
+ type: string
3223
+ description: Why the simulator shows nothing, for testers.
3159
3224
  ControlDenied:
3160
3225
  type: object
3161
3226
  required:
@@ -58,7 +58,7 @@ const InternalHttpClient_1 = require("../../InternalHttpClient");
58
58
  const device_service_1 = require("../../data-service/device-service");
59
59
  const UniversalMjpegProxy_1 = require("../../helpers/UniversalMjpegProxy");
60
60
  const DisplayStateService_1 = require("../../services/DisplayStateService");
61
- const IOSStreamService_1 = __importDefault(require("../../device-managers/ios/IOSStreamService"));
61
+ const IOSStreamService_1 = __importStar(require("../../device-managers/ios/IOSStreamService"));
62
62
  const AndroidStreamService_1 = __importDefault(require("../../device-managers/android/AndroidStreamService"));
63
63
  const AndroidH264StreamService_1 = __importDefault(require("../../device-managers/android/AndroidH264StreamService"));
64
64
  const path_1 = __importDefault(require("path"));
@@ -748,9 +748,14 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
748
748
  // Mark device as "Busy" so automation sessions don't pick it up.
749
749
  // Lock is keyed on the user, not the credential — see
750
750
  // src/services/device-access/deviceAccessPolicy.ts.
751
- const manualSid = (0, manualLock_1.formatManualLock)(actorUserId, udid);
752
- yield (0, device_service_1.blockDevice)(udid, device.host, manualSid);
753
- logger_1.default.info(`Manual Control: Device ${udid} locked for active UI session (${manualSid}).`);
751
+ // A simulator is previewed only while a test holds it, so a preview hold
752
+ // adds nothing, and written after the test ended it would hold a free
753
+ // simulator for nobody's test.
754
+ if (!(0, IOSStreamService_1.isIosSimulator)(device)) {
755
+ const manualSid = (0, manualLock_1.formatManualLock)(actorUserId, udid);
756
+ yield (0, device_service_1.blockDevice)(udid, device.host, manualSid);
757
+ logger_1.default.info(`Manual Control: Device ${udid} locked for active UI session (${manualSid}).`);
758
+ }
754
759
  if (streamType === 'h264') {
755
760
  logger_1.default.info(`H.264 stream started for ${udid}`);
756
761
  return res.status(200).send({
@@ -774,6 +779,13 @@ router.post('/:udid/stream/start', (req, res) => __awaiter(void 0, void 0, void
774
779
  });
775
780
  }
776
781
  catch (err) {
782
+ // Not a failure: a simulator shows its running test's picture, and none
783
+ // runs. Said in plain words; the dashboard toasts it.
784
+ if (err instanceof IOSStreamService_1.SimulatorPreviewNeedsTest) {
785
+ return res
786
+ .status(409)
787
+ .send({ success: false, error: 'simulator_needs_test', message: err.message });
788
+ }
777
789
  logger_1.default.error(`Failed to start stream for ${udid}: ${err.message}`);
778
790
  return res.status(500).send({
779
791
  success: false,
@@ -1037,6 +1049,9 @@ router.get('/:udid/stream/status', (req, res) => __awaiter(void 0, void 0, void
1037
1049
  type,
1038
1050
  h264Path,
1039
1051
  mjpegPort: device.mjpegServerPort,
1052
+ // Why a simulator shows nothing: a Live devices tile says it once it stops retrying.
1053
+ lastError: (0, IOSStreamService_1.simulatorPreviewRefusal)(device),
1054
+ reason: (0, IOSStreamService_1.simulatorPreviewRefusal)(device) ? 'simulator_needs_test' : undefined,
1040
1055
  });
1041
1056
  }));
1042
1057
  /**
@@ -1083,7 +1098,11 @@ router.get('/:udid/stream', (req, res) => __awaiter(void 0, void 0, void 0, func
1083
1098
  });
1084
1099
  }
1085
1100
  catch (err) {
1086
- logger_1.default.error(`Failed to start stream for ${udid}: ${err.message}`);
1101
+ // A simulator no test runs on: expected, and asked again by each retry.
1102
+ if (err instanceof IOSStreamService_1.SimulatorPreviewNeedsTest)
1103
+ logger_1.default.debug(`${udid}: ${err.message}`);
1104
+ else
1105
+ logger_1.default.error(`Failed to start stream for ${udid}: ${err.message}`);
1087
1106
  return res.status(503).send({
1088
1107
  error: 'Stream not available',
1089
1108
  message: err.message,
@@ -60,9 +60,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
60
60
  return (mod && mod.__esModule) ? mod : { "default": mod };
61
61
  };
62
62
  Object.defineProperty(exports, "__esModule", { value: true });
63
- exports.StreamRestartRefused = exports.IOS_IDLE_STOP_MS = void 0;
63
+ exports.SimulatorPreviewNeedsTest = exports.SIMULATOR_PREVIEW_NEEDS_TEST = exports.StreamRestartRefused = exports.IOS_IDLE_STOP_MS = void 0;
64
64
  exports.shouldStopIdleIosStream = shouldStopIdleIosStream;
65
65
  exports.heldByAppiumSession = heldByAppiumSession;
66
+ exports.isIosSimulator = isIosSimulator;
67
+ exports.simulatorPreviewRefusal = simulatorPreviewRefusal;
66
68
  const typedi_1 = require("typedi");
67
69
  const ProcessRegistry_1 = require("../../services/ProcessRegistry");
68
70
  const child_process_1 = require("child_process");
@@ -82,6 +84,7 @@ const iosStreamDiagnostics_1 = require("./iosStreamDiagnostics");
82
84
  const tunnelProcess_1 = require("./tunnelProcess");
83
85
  const IOSTunnels_1 = require("./IOSTunnels");
84
86
  const goIosBinary_1 = require("./goIosBinary");
87
+ const appiumUmbrella_1 = require("../../sessions/appiumUmbrella");
85
88
  const device_service_1 = require("../../data-service/device-service");
86
89
  const manualLock_1 = require("../../services/recording/manualLock");
87
90
  const recording_store_1 = require("../../services/recording/recording-store");
@@ -113,9 +116,39 @@ function heldByAppiumSession(device) {
113
116
  class StreamRestartRefused extends Error {
114
117
  }
115
118
  exports.StreamRestartRefused = StreamRestartRefused;
119
+ /** Why a simulator shows no live preview: for testers, on the device page and its tile. */
120
+ exports.SIMULATOR_PREVIEW_NEEDS_TEST = "A simulator's screen shows here only while a test runs on it.";
121
+ /** A simulator's preview was asked for while no test runs on it. */
122
+ class SimulatorPreviewNeedsTest extends Error {
123
+ constructor() {
124
+ super(exports.SIMULATOR_PREVIEW_NEEDS_TEST);
125
+ }
126
+ }
127
+ exports.SimulatorPreviewNeedsTest = SimulatorPreviewNeedsTest;
128
+ /**
129
+ * An iOS or tvOS simulator's row. `realDevice` is false only there among
130
+ * Apple rows (discovery writes it, the store reads a missing one as true); an
131
+ * Android emulator's is false too.
132
+ */
133
+ function isIosSimulator(device) {
134
+ return (device === null || device === void 0 ? void 0 : device.realDevice) === false && (device.platform === 'ios' || device.platform === 'tvos');
135
+ }
136
+ /**
137
+ * Why this phone can't be previewed now, or undefined when it can: a
138
+ * simulator no Appium session holds.
139
+ */
140
+ function simulatorPreviewRefusal(device) {
141
+ if (!isIosSimulator(device))
142
+ return undefined;
143
+ return heldByAppiumSession(device) ? undefined : exports.SIMULATOR_PREVIEW_NEEDS_TEST;
144
+ }
145
+ /** How many ended sessions a simulator preview remembers, to refuse a start mid-teardown. */
146
+ const ENDED_SESSIONS_KEPT = 200;
116
147
  let IOSStreamService = class IOSStreamService {
117
148
  constructor() {
118
149
  this.sessions = new Map();
150
+ /** Sessions whose simulator previews have ended (endSimulatorPreviews), newest last. */
151
+ this.endedSimulatorSessions = new Set();
119
152
  this.startFlight = new singleFlight_1.SingleFlight();
120
153
  this.recoveryCooldowns = new Map(); // Track last recovery attempt time
121
154
  this.RECOVERY_COOLDOWN_MS = 30000; // 30s cooldown between recovery attempts
@@ -411,6 +444,10 @@ let IOSStreamService = class IOSStreamService {
411
444
  const session = this.sessions.get(udid);
412
445
  if (!session || session.status !== 'running')
413
446
  return false;
447
+ // A simulator's preview has no process of its own to check or heal: the
448
+ // picture is its session's, and the preview ends with it.
449
+ if (session.simulatorSession)
450
+ return true;
414
451
  // 1. Process Check: verify child processes haven't exited
415
452
  const isWdaAlive = session.wdaProcess && session.wdaProcess.exitCode === null;
416
453
  const isWdaIproxyAlive = session.forwardWDAProcess && session.forwardWDAProcess.exitCode === null;
@@ -453,6 +490,9 @@ let IOSStreamService = class IOSStreamService {
453
490
  }
454
491
  startStream(udid) {
455
492
  return __awaiter(this, void 0, void 0, function* () {
493
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
494
+ if (isIosSimulator(device))
495
+ return this.startSimulatorStream(udid, device);
456
496
  // Check if stream is already running - avoid unnecessary restarts
457
497
  const existingSession = this.sessions.get(udid);
458
498
  if (existingSession && existingSession.status === 'running') {
@@ -811,6 +851,82 @@ let IOSStreamService = class IOSStreamService {
811
851
  return this.startFlight.run(udid, performStartup);
812
852
  });
813
853
  }
854
+ /**
855
+ * A simulator's preview is its running test's picture. The XCUITest
856
+ * driver's WDA serves it on this Mac at the session's mjpegServerPort, so
857
+ * this starts nothing (go-ios, iproxy and runwda reach only an iPhone) and
858
+ * leases no port (the session's are its own). With no test running there
859
+ * is nothing to show: refused in plain words, with no cool-down, since a
860
+ * test may start any moment.
861
+ *
862
+ * Through 2.15 a simulator went through the iPhone's start. With no test it
863
+ * failed, and left its iproxy forwards on the ports it had leased to the
864
+ * simulator: a test started meanwhile was given those ports and its WDA
865
+ * couldn't open them. With a test it read the picture through a forward
866
+ * that carries nothing, so a Live devices tile stayed empty.
867
+ */
868
+ startSimulatorStream(udid, device) {
869
+ return __awaiter(this, void 0, void 0, function* () {
870
+ var _a;
871
+ let sessionId = simulatorPreviewRefusal(device) ? undefined : ((_a = device.session_id) !== null && _a !== void 0 ? _a : undefined);
872
+ // An ended test's row can still name it while its phone is being released.
873
+ if (sessionId && this.endedSimulatorSessions.has(sessionId))
874
+ sessionId = undefined;
875
+ // Only the ports of a session Appium has now: the row's would outlive it.
876
+ const driver = sessionId ? typedi_2.Container.get(appiumUmbrella_1.AppiumUmbrella).driverOptions(sessionId) : undefined;
877
+ const wdaPort = Number(driver === null || driver === void 0 ? void 0 : driver.wdaLocalPort);
878
+ const mjpegPort = Number(driver === null || driver === void 0 ? void 0 : driver.mjpegServerPort);
879
+ const existing = this.sessions.get(udid);
880
+ if (!sessionId || !wdaPort || !mjpegPort) {
881
+ if (existing)
882
+ yield this.stopStream(udid);
883
+ throw new SimulatorPreviewNeedsTest();
884
+ }
885
+ if ((existing === null || existing === void 0 ? void 0 : existing.simulatorSession) === sessionId && existing.status === 'running') {
886
+ return { wdaPort: existing.wdaPort, mjpegPort: existing.mjpegPort };
887
+ }
888
+ // An earlier test's preview, or anything else held for this simulator.
889
+ if (existing)
890
+ yield this.stopStream(udid);
891
+ this.sessions.set(udid, {
892
+ udid,
893
+ wdaProcess: null,
894
+ forwardWDAProcess: null,
895
+ forwardMJPEGProcess: null,
896
+ tunnelPort: null,
897
+ wdaPort,
898
+ mjpegPort,
899
+ status: 'running',
900
+ startedAt: new Date(),
901
+ lastViewerAt: Date.now(),
902
+ viewerCount: 0,
903
+ simulatorSession: sessionId,
904
+ });
905
+ logger_1.default.info(`[${udid}] Simulator preview: the picture of session ${sessionId} (port ${mjpegPort})`);
906
+ return { wdaPort, mjpegPort };
907
+ });
908
+ }
909
+ /**
910
+ * End the simulator previews that show this session's picture. Called
911
+ * however the session ends (forgetSessionMemory): the driver's WDA may
912
+ * outlive it, but no test runs any more.
913
+ */
914
+ endSimulatorPreviews(sessionId) {
915
+ return __awaiter(this, void 0, void 0, function* () {
916
+ this.endedSimulatorSessions.add(sessionId);
917
+ if (this.endedSimulatorSessions.size > ENDED_SESSIONS_KEPT) {
918
+ const oldest = this.endedSimulatorSessions.values().next().value;
919
+ if (oldest !== undefined)
920
+ this.endedSimulatorSessions.delete(oldest);
921
+ }
922
+ for (const [udid, session] of this.sessions.entries()) {
923
+ if (session.simulatorSession !== sessionId)
924
+ continue;
925
+ logger_1.default.info(`[${udid}] Simulator preview ends with session ${sessionId}`);
926
+ yield this.stopStream(udid);
927
+ }
928
+ });
929
+ }
814
930
  killStaleProcesses(udid, wdaPort, mjpegPort) {
815
931
  return __awaiter(this, void 0, void 0, function* () {
816
932
  logger_1.default.info(`Cleaning up stale processes for ${udid} (Ports: ${wdaPort}, ${mjpegPort})`);
@@ -879,10 +995,16 @@ let IOSStreamService = class IOSStreamService {
879
995
  */
880
996
  stopStream(udid_1) {
881
997
  return __awaiter(this, arguments, void 0, function* (udid, opts = {}) {
882
- var _a;
883
998
  const session = this.sessions.get(udid);
884
999
  if (!session)
885
1000
  return;
1001
+ // A simulator's preview started nothing and leased no port: the ports and
1002
+ // WDA are its session's. Only the entry goes (and a manual hold, below).
1003
+ if (session.simulatorSession) {
1004
+ this.sessions.delete(udid);
1005
+ yield this.releaseManualHold(udid);
1006
+ return;
1007
+ }
886
1008
  if (opts.forViewer &&
887
1009
  session.wdaProcess &&
888
1010
  (yield this.appiumSessionMayUse(udid, "the stream's WDA and go-ios tunnel"))) {
@@ -902,22 +1024,7 @@ let IOSStreamService = class IOSStreamService {
902
1024
  // A stream attached to an Appium session's WDA started none.
903
1025
  if (session.tunnelPort != null)
904
1026
  yield this.tunnels().stop(udid);
905
- // Principal Fix: Only release the device lock if THIS STREAM SERVICE owns it.
906
- // The lock could belong to an Appium automation session (session_id is a real UUID).
907
- // We should only unblock if it's a manual control lock (session_id starts with 'manual_').
908
- try {
909
- const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
910
- if (device && ((_a = device.session_id) === null || _a === void 0 ? void 0 : _a.startsWith('manual_'))) {
911
- logger_1.default.info(`Stream Stop: Releasing manual control lock for ${udid}`);
912
- yield (0, device_service_1.unblockDevice)(udid, device.host);
913
- }
914
- else if (device && device.busy) {
915
- logger_1.default.info(`Stream Stop: Device ${udid} is busy with session ${device.session_id}. NOT releasing lock.`);
916
- }
917
- }
918
- catch (e) {
919
- logger_1.default.error(`Failed to check/release lock during stream stop for ${udid}: ${e}`);
920
- }
1027
+ yield this.releaseManualHold(udid);
921
1028
  // Also clean up any orphan processes (belt and suspenders)
922
1029
  yield this.cleanupOrphanTunnels(udid);
923
1030
  // Release the port leases so the allocator can reuse these ports for other
@@ -933,6 +1040,28 @@ let IOSStreamService = class IOSStreamService {
933
1040
  this.sessions.delete(udid);
934
1041
  });
935
1042
  }
1043
+ /**
1044
+ * Release the phone's manual hold (a preview's, a recording's), never an
1045
+ * Appium session's claim: that session_id is a real id, not `manual_...`.
1046
+ */
1047
+ releaseManualHold(udid) {
1048
+ return __awaiter(this, void 0, void 0, function* () {
1049
+ var _a;
1050
+ try {
1051
+ const device = yield (0, ownDeviceRow_1.findOwnDevice)(udid);
1052
+ if (device && ((_a = device.session_id) === null || _a === void 0 ? void 0 : _a.startsWith('manual_'))) {
1053
+ logger_1.default.info(`Stream Stop: Releasing manual control lock for ${udid}`);
1054
+ yield (0, device_service_1.unblockDevice)(udid, device.host);
1055
+ }
1056
+ else if (device && device.busy) {
1057
+ logger_1.default.info(`Stream Stop: Device ${udid} is busy with session ${device.session_id}. NOT releasing lock.`);
1058
+ }
1059
+ }
1060
+ catch (e) {
1061
+ logger_1.default.error(`Failed to check/release lock during stream stop for ${udid}: ${e}`);
1062
+ }
1063
+ });
1064
+ }
936
1065
  updateWDASettings(wdaPort) {
937
1066
  return __awaiter(this, void 0, void 0, function* () {
938
1067
  const urls = [`http://127.0.0.1:${wdaPort}/appium/settings`];
@@ -45,7 +45,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
45
45
  return (mod && mod.__esModule) ? mod : { "default": mod };
46
46
  };
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
- exports.getDeviceTypeFromApp = void 0;
48
+ exports.BOOT_PROBE = exports.getDeviceTypeFromApp = void 0;
49
49
  exports.isAndroid = isAndroid;
50
50
  exports.deviceType = deviceType;
51
51
  exports.isIOS = isIOS;
@@ -59,6 +59,8 @@ exports.setUtilizationTime = setUtilizationTime;
59
59
  exports.getDeviceFiltersFromCapability = getDeviceFiltersFromCapability;
60
60
  exports.getRunningSessionsCount = getRunningSessionsCount;
61
61
  exports.updateDeviceList = updateDeviceList;
62
+ exports.syncLocalDevices = syncLocalDevices;
63
+ exports.reportDevicesToHub = reportDevicesToHub;
62
64
  exports.refreshSimulatorState = refreshSimulatorState;
63
65
  exports.setupCronCheckStaleDevices = setupCronCheckStaleDevices;
64
66
  exports.removeStaleDevices = removeStaleDevices;
@@ -530,6 +532,19 @@ function getRunningSessionsCount() {
530
532
  });
531
533
  }
532
534
  function updateDeviceList(local, hubArgument, tlsRejectUnauthorized) {
535
+ return __awaiter(this, void 0, void 0, function* () {
536
+ const { devices, gone } = yield syncLocalDevices(local);
537
+ if (hubArgument) {
538
+ yield reportDevicesToHub(hubArgument, devices, gone, tlsRejectUnauthorized);
539
+ }
540
+ return devices;
541
+ });
542
+ }
543
+ /**
544
+ * This server's own phones: what discovery reports now is written to the
545
+ * table, and its own rows discovery no longer reports are removed (`gone`).
546
+ */
547
+ function syncLocalDevices(local) {
533
548
  return __awaiter(this, void 0, void 0, function* () {
534
549
  const allExistingDevices = yield (0, device_service_1.getAllDevices)();
535
550
  const devices = yield getDeviceManager().getDevices(allExistingDevices);
@@ -547,28 +562,31 @@ function updateDeviceList(local, hubArgument, tlsRejectUnauthorized) {
547
562
  logger_1.default.info(`Removing ${staleLocalDevices.length} stale devices/simulators no longer discovered on this host.`);
548
563
  yield (0, device_service_1.removeDevice)(staleLocalDevices.map((d) => ({ udid: d.udid, host: d.host })));
549
564
  }
550
- if (hubArgument) {
551
- if (yield (0, helpers_1.isXenonRunning)(hubArgument, tlsRejectUnauthorized)) {
552
- const nodeDevices = new NodeDevices_1.default(hubArgument, {
553
- tlsRejectUnauthorized,
554
- hubAccessKey: config_1.config.hubAccessKey,
555
- hubToken: config_1.config.hubToken,
556
- });
557
- try {
558
- yield nodeDevices.postDevicesToHub(devices, 'add');
559
- if (staleLocalDevices.length > 0) {
560
- yield nodeDevices.postDevicesToHub(staleLocalDevices, 'remove');
561
- }
562
- }
563
- catch (error) {
564
- logger_1.default.error(`Cannot send device list update. Reason: ${error}`);
565
+ return { devices, gone: staleLocalDevices };
566
+ });
567
+ }
568
+ /** A node tells its hub the phones it has, and the ones it no longer has. */
569
+ function reportDevicesToHub(hubArgument, devices, gone, tlsRejectUnauthorized) {
570
+ return __awaiter(this, void 0, void 0, function* () {
571
+ if (yield (0, helpers_1.isXenonRunning)(hubArgument, tlsRejectUnauthorized)) {
572
+ const nodeDevices = new NodeDevices_1.default(hubArgument, {
573
+ tlsRejectUnauthorized,
574
+ hubAccessKey: config_1.config.hubAccessKey,
575
+ hubToken: config_1.config.hubToken,
576
+ });
577
+ try {
578
+ yield nodeDevices.postDevicesToHub(devices, 'add');
579
+ if (gone.length > 0) {
580
+ yield nodeDevices.postDevicesToHub(gone, 'remove');
565
581
  }
566
582
  }
567
- else {
568
- logger_1.default.warn(`Not sending device update since hub ${hubArgument} is not running`);
583
+ catch (error) {
584
+ logger_1.default.error(`Cannot send device list update. Reason: ${error}`);
569
585
  }
570
586
  }
571
- return devices;
587
+ else {
588
+ logger_1.default.warn(`Not sending device update since hub ${hubArgument} is not running`);
589
+ }
572
590
  });
573
591
  }
574
592
  function refreshSimulatorState(pluginArgs, hostPort) {
@@ -590,11 +608,21 @@ function setupCronCheckStaleDevices(intervalMs, local, tlsRejectUnauthorized) {
590
608
  }), intervalMs);
591
609
  });
592
610
  }
611
+ /**
612
+ * How a start asks another server whether it is alive: once, for at most 5 s.
613
+ * Appium opens its port only once every plugin's updateServer has finished,
614
+ * and the client's own way (four 30 s tries) held it closed for two minutes
615
+ * when a row named a host that drops packets. A live server answers in
616
+ * milliseconds; one that answers slower has its phones dropped and reports
617
+ * them again at its next report.
618
+ */
619
+ exports.BOOT_PROBE = { timeoutMs: 5000, retry: false };
593
620
  /**
594
621
  * Remove devices where the host is not alive nor defined.
595
622
  * @param local the hosts this server files its own phones under
623
+ * @param probe how each host is asked (the client's own way when not given)
596
624
  */
597
- function removeStaleDevices(local, tlsRejectUnauthorized) {
625
+ function removeStaleDevices(local, tlsRejectUnauthorized, probe) {
598
626
  return __awaiter(this, void 0, void 0, function* () {
599
627
  const allDevices = yield (0, device_service_1.getAllDevices)();
600
628
  const nodeId = typedi_1.Container.get(PluginContext_1.PluginContext).nodeId;
@@ -611,13 +639,13 @@ function removeStaleDevices(local, tlsRejectUnauthorized) {
611
639
  const aliveHosts = (yield Promise.allSettled(nodeHosts.map((host) => __awaiter(this, void 0, void 0, function* () {
612
640
  return {
613
641
  host: host,
614
- alive: yield (0, helpers_1.isXenonRunning)(host, tlsRejectUnauthorized),
642
+ alive: yield (0, helpers_1.isXenonRunning)(host, tlsRejectUnauthorized, probe),
615
643
  };
616
644
  }))));
617
645
  const aliveCloudHosts = (yield Promise.allSettled(cloudHosts.map((host) => __awaiter(this, void 0, void 0, function* () {
618
646
  return {
619
647
  host: host,
620
- alive: yield (0, helpers_1.isAppiumRunningAt)(host, tlsRejectUnauthorized),
648
+ alive: yield (0, helpers_1.isAppiumRunningAt)(host, tlsRejectUnauthorized, probe),
621
649
  };
622
650
  }))));
623
651
  // summarize alive hosts