@xenon-device-management/xenon 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/lib/package.json +3 -3
  2. package/lib/public/assets/{AnnotationOverlay-C-Pw_hvw.js → AnnotationOverlay-AxcBYoKp.js} +1 -1
  3. package/lib/public/assets/{ApiKeyGate-DmYQ0oSk.js → ApiKeyGate-CIU02EIx.js} +1 -1
  4. package/lib/public/assets/{BugReportButton-n8T1dFUp.js → BugReportButton-_akEEjXm.js} +1 -1
  5. package/lib/public/assets/DeviceMosaicView-Bhk1udmn.js +2 -0
  6. package/lib/public/assets/{EmptyState-DExETevp.js → EmptyState-DRKRm19_.js} +1 -1
  7. package/lib/public/assets/{FieldGroup-CCdFlZm6.js → FieldGroup-zQQHPh7x.js} +1 -1
  8. package/lib/public/assets/{FilterMenu-DiUSOSL-.js → FilterMenu-C9_StnDi.js} +1 -1
  9. package/lib/public/assets/{Menu-ZOfpH6gh.js → Menu-0J7io6pF.js} +1 -1
  10. package/lib/public/assets/{Modal-CZNtoB-1.js → Modal-C677ljNX.js} +1 -1
  11. package/lib/public/assets/{RecordingPage-BKp6h8Jl.js → RecordingPage-BDJFck-L.js} +1 -1
  12. package/lib/public/assets/{RecordingsPage-Bf881FX4.js → RecordingsPage-DQkimbKK.js} +1 -1
  13. package/lib/public/assets/{SegmentedControl-EUoyizvW.js → SegmentedControl-DvGMZBIE.js} +1 -1
  14. package/lib/public/assets/{SettingCard-ClT4hf4_.js → SettingCard-ogyouYBq.js} +1 -1
  15. package/lib/public/assets/{Table-ucR4bnmc.js → Table-BLFCOLlO.js} +1 -1
  16. package/lib/public/assets/{activity-K-hdylwq.js → activity-BU55TGRG.js} +1 -1
  17. package/lib/public/assets/{ai-settings-BLhdwI3c.js → ai-settings-GpDAiHav.js} +1 -1
  18. package/lib/public/assets/{api-keys-BbiN7rBI.js → api-keys-dOzP0k8_.js} +1 -1
  19. package/lib/public/assets/{apps-Dez1petz.js → apps-mAwAQmkn.js} +1 -1
  20. package/lib/public/assets/{arrow-left-UjmFHLAJ.js → arrow-left-Dg9mxe9T.js} +1 -1
  21. package/lib/public/assets/{arrow-right-BVyEigbu.js → arrow-right-BVcmUyq3.js} +1 -1
  22. package/lib/public/assets/{arrow-up-right-B-ZlBof3.js → arrow-up-right-ByR5pZrH.js} +1 -1
  23. package/lib/public/assets/{auth-shell-DAQjAgUv.js → auth-shell-Jpgk8CgY.js} +2 -2
  24. package/lib/public/assets/{builds-page-CmIPa5JK.js → builds-page-VnRTrcdl.js} +1 -1
  25. package/lib/public/assets/{button-Dd-IhMC1.js → button-D-X4wjO8.js} +1 -1
  26. package/lib/public/assets/{calendar-CAIeJwa5.js → calendar-ur_p5ETA.js} +1 -1
  27. package/lib/public/assets/{check-C4Jrjg8f.js → check-K33iMeyq.js} +1 -1
  28. package/lib/public/assets/{chevron-left-BGzlf-4i.js → chevron-left-DMVWShd-.js} +1 -1
  29. package/lib/public/assets/{chevron-right-_wYqDjDC.js → chevron-right-DphLJ2_X.js} +1 -1
  30. package/lib/public/assets/{circle-check-Ckn00yMB.js → circle-check-CNFhu8oE.js} +1 -1
  31. package/lib/public/assets/{circle-x-pdVkomLa.js → circle-x-BH_cUbqZ.js} +1 -1
  32. package/lib/public/assets/{clock-B8vmwkHj.js → clock-CVMVdVvp.js} +1 -1
  33. package/lib/public/assets/{copy-BAqnFI-F.js → copy-CZ2XHafL.js} +1 -1
  34. package/lib/public/assets/{device-explorer-CYq_4F_N.js → device-explorer-B94BhaqF.js} +52 -52
  35. package/lib/public/assets/{download-B6UEAp_1.js → download-UUiyw7kO.js} +1 -1
  36. package/lib/public/assets/{forgot-password-CLqNvmrN.js → forgot-password-CrS14F79.js} +1 -1
  37. package/lib/public/assets/{funnel-Da90BBbT.js → funnel-BMOE5qJY.js} +1 -1
  38. package/lib/public/assets/{index-BuIIcmGp.js → index-2XPPbPwy.js} +43 -43
  39. package/lib/public/assets/{index-MX7IzqyW.js → index-D0R9Yic3.js} +1 -1
  40. package/lib/public/assets/{input-Bq9aY-We.js → input-C-1b8J7i.js} +1 -1
  41. package/lib/public/assets/{keycodes-CIhXIJyP.js → keycodes-Cly7dQc9.js} +4 -4
  42. package/lib/public/assets/{layers-bXjpswQI.js → layers-DFZfqGdX.js} +1 -1
  43. package/lib/public/assets/{lock-pJ72y5Fy.js → lock-ylHMJpfP.js} +1 -1
  44. package/lib/public/assets/{login-UOcHALV2.js → login-DWsWKqDx.js} +1 -1
  45. package/lib/public/assets/{maintenance-settings-XnJgp5yb.js → maintenance-settings-9B6_MTqW.js} +1 -1
  46. package/lib/public/assets/{monitor-QkAhI1Jx.js → monitor-CzX0hBH1.js} +1 -1
  47. package/lib/public/assets/{mouse-pointer-2-B42Da0m4.js → mouse-pointer-2-CZHD7N3z.js} +1 -1
  48. package/lib/public/assets/{network-CKVqcyp6.js → network-Bikc4MyT.js} +1 -1
  49. package/lib/public/assets/{overview-BORGkN3N.js → overview-DzaA0aMe.js} +1 -1
  50. package/lib/public/assets/{page-header-CKWFOjOI.js → page-header-DqGlDufy.js} +1 -1
  51. package/lib/public/assets/{plus-BpFKVdC8.js → plus-Ct6aQTGM.js} +1 -1
  52. package/lib/public/assets/{profile-page-CcbKBicY.js → profile-page-CCnUQ1Qk.js} +1 -1
  53. package/lib/public/assets/{reset-password-BA0W9bxl.js → reset-password-AmkGGXo5.js} +1 -1
  54. package/lib/public/assets/{runbook-page-Cp7ZBq3s.js → runbook-page-DQQn9VyE.js} +1 -1
  55. package/lib/public/assets/{select-DIZWIoVR.js → select-k5ogtEWW.js} +1 -1
  56. package/lib/public/assets/{selector-detail-page-C_4YuvAp.js → selector-detail-page-BCcwC5ku.js} +1 -1
  57. package/lib/public/assets/{selector-health-CXjD_Xth.js → selector-health-BVyX6kIh.js} +1 -1
  58. package/lib/public/assets/{selector-health-page-CTqIHeoG.js → selector-health-page-CElBB6XT.js} +1 -1
  59. package/lib/public/assets/{session-detail-page-B0KYkk8z.js → session-detail-page-6bqo_-ND.js} +1 -1
  60. package/lib/public/assets/{settings-BcRBKUXd.js → settings-DKBj9KyN.js} +1 -1
  61. package/lib/public/assets/{status-pill-outline-Dh2PXSf_.js → status-pill-outline-CZNpvqej.js} +1 -1
  62. package/lib/public/assets/{tablet-_sXqZztt.js → tablet-B6HI5OxV.js} +1 -1
  63. package/lib/public/assets/{teams-DGHT8MKm.js → teams-owoDHbOU.js} +1 -1
  64. package/lib/public/assets/{trash-2-BV88cV3Z.js → trash-2-D61PoUrZ.js} +1 -1
  65. package/lib/public/assets/{upload-CIDH9wkj.js → upload-1ZYEVvgD.js} +1 -1
  66. package/lib/public/assets/{use-password-reset-mode-DYGuSNwl.js → use-password-reset-mode-Dx0ghiiQ.js} +1 -1
  67. package/lib/public/assets/{users-7yviqflc.js → users-C5CS5Qez.js} +1 -1
  68. package/lib/public/assets/{users-BUE7MoTq.js → users-PbGMd5sD.js} +1 -1
  69. package/lib/public/assets/{video-off-BTRxtlGo.js → video-off-_XusI0HK.js} +1 -1
  70. package/lib/public/assets/{webhook-settings-DMKD9Pgm.js → webhook-settings-Bi_HHQlQ.js} +1 -1
  71. package/lib/public/assets/{zap-CWE94qcK.js → zap-uzHWFAKI.js} +1 -1
  72. package/lib/public/index.html +1 -1
  73. package/lib/src/app/routers/control.js +119 -203
  74. package/lib/src/app/routers/nodePhoneControl.js +520 -0
  75. package/lib/src/app/ws/h264StreamWs.js +30 -4
  76. package/lib/src/app/ws/logcatWs.js +36 -7
  77. package/lib/src/app/ws/nodeSocketRelay.js +167 -0
  78. package/lib/src/data-service/device-store.js +17 -2
  79. package/lib/src/data-service/deviceClaims.js +32 -0
  80. package/lib/src/data-service/deviceFieldOwners.js +3 -1
  81. package/lib/src/data-service/prisma-store.js +9 -1
  82. package/lib/src/device-managers/AndroidDeviceManager.js +38 -28
  83. package/lib/src/device-managers/android/AndroidH264StreamService.js +10 -2
  84. package/lib/src/device-managers/android/AndroidStreamService.js +3 -1
  85. package/lib/src/device-managers/ios/IOSStreamService.js +4 -2
  86. package/lib/src/gateway/forwardToNode.js +10 -3
  87. package/lib/src/generated/client/edge.js +5 -4
  88. package/lib/src/generated/client/index-browser.js +2 -1
  89. package/lib/src/generated/client/index.d.ts +36 -1
  90. package/lib/src/generated/client/index.js +5 -4
  91. package/lib/src/generated/client/package.json +1 -1
  92. package/lib/src/generated/client/schema.prisma +4 -0
  93. package/lib/src/generated/client/wasm.js +2 -1
  94. package/lib/src/middleware/deviceAccessGuard.js +4 -1
  95. package/lib/src/services/ServerManager.js +13 -0
  96. package/lib/src/services/device-access/ticketActorAccess.js +3 -1
  97. package/lib/src/services/lease/LeaseOrphanSweeper.js +4 -2
  98. package/lib/test/helpers/own-node-id.js +29 -0
  99. package/lib/test/setup/scratch-default-database.js +32 -0
  100. package/lib/test/unit/AndroidDeviceManager.spec.js +60 -0
  101. package/lib/test/unit/android-h264-service.spec.js +18 -0
  102. package/lib/test/unit/clipboard-route.spec.js +4 -0
  103. package/lib/test/unit/control-forward-decision.spec.js +17 -17
  104. package/lib/test/unit/device-access-guard.spec.js +38 -0
  105. package/lib/test/unit/hub-node-control-forward.spec.js +374 -1
  106. package/lib/test/unit/hub-node-race.spec.js +51 -0
  107. package/lib/test/unit/install-repository-app-team.spec.js +4 -0
  108. package/lib/test/unit/lease/lease-sweeper-expiry.spec.js +15 -0
  109. package/lib/test/unit/logcat-ws.spec.js +8 -0
  110. package/lib/test/unit/mark-node-busy-loki.spec.js +66 -0
  111. package/lib/test/unit/node-phone-control.spec.js +225 -0
  112. package/lib/test/unit/node-socket-relay.spec.js +357 -0
  113. package/lib/test/unit/screenshot-route.spec.js +104 -0
  114. package/lib/test/unit/stream-leave-route.spec.js +4 -0
  115. package/lib/test/unit/stream-stop-ownership.spec.js +4 -0
  116. package/lib/test/unit/stream-stop-recording.spec.js +4 -0
  117. package/lib/test/unit/stream-ticket-identity.spec.js +11 -1
  118. package/lib/test/unit/stream-watchdog-timers.spec.js +46 -0
  119. package/lib/test/unit/upload-install-route.spec.js +205 -0
  120. package/lib/tsconfig.tsbuildinfo +1 -1
  121. package/package.json +3 -3
  122. package/prisma/migrations/20261001120000_device_node_hold/migration.sql +2 -0
  123. package/prisma/schema.prisma +4 -0
  124. package/lib/public/assets/DeviceMosaicView-D-rGOBbJ.js +0 -2
@@ -1,4 +1,4 @@
1
- import{c as a}from"./index-BuIIcmGp.js";/**
1
+ import{c as a}from"./index-2XPPbPwy.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1 +1 @@
1
- import{r as t,h as u}from"./index-BuIIcmGp.js";function n(){const[s,r]=t.useState(null);return t.useEffect(()=>{let e=!0;return u().then(o=>e&&r(o.passwordReset)),()=>{e=!1}},[]),s}export{n as u};
1
+ import{r as t,h as u}from"./index-2XPPbPwy.js";function n(){const[s,r]=t.useState(null);return t.useEffect(()=>{let e=!0;return u().then(o=>e&&r(o.passwordReset)),()=>{e=!1}},[]),s}export{n as u};
@@ -1,4 +1,4 @@
1
- import{c as w,b as k,r as a,j as e,$ as C}from"./index-BuIIcmGp.js";import{l as M,d as R,c as I,a as A,u as D}from"./users-BUE7MoTq.js";import{f as U}from"./time-B8FIgwPE.js";import{P}from"./page-header-CKWFOjOI.js";import{B as y}from"./button-Dd-IhMC1.js";import{I as N}from"./input-Bq9aY-We.js";import{S as g}from"./select-DIZWIoVR.js";import{P as B}from"./plus-BpFKVdC8.js";import{T as L}from"./trash-2-BV88cV3Z.js";import"./utils-CDN07tui.js";/**
1
+ import{c as w,b as k,r as a,j as e,$ as C}from"./index-2XPPbPwy.js";import{l as M,d as R,c as I,a as A,u as D}from"./users-PbGMd5sD.js";import{f as U}from"./time-B8FIgwPE.js";import{P}from"./page-header-DqGlDufy.js";import{B as y}from"./button-D-X4wjO8.js";import{I as N}from"./input-C-1b8J7i.js";import{S as g}from"./select-k5ogtEWW.js";import{P as B}from"./plus-Ct6aQTGM.js";import{T as L}from"./trash-2-D61PoUrZ.js";import"./utils-CDN07tui.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1 +1 @@
1
- import{o as s}from"./index-BuIIcmGp.js";const o="/xenon/api/users";async function c(){const t=await fetch(`${o}/`,{credentials:"include"});if(!t.ok)throw new Error(`listUsers failed (${t.status})`);return t.json()}async function i(t){const e=await fetch(`${o}/`,{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",...s},body:JSON.stringify(t)});if(!e.ok){const n=await e.json().catch(()=>({}));throw new Error(n.error||`createUser failed (${e.status})`)}return e.json()}async function d(t,e){const n=await fetch(`${o}/${encodeURIComponent(t)}`,{method:"PATCH",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`updateUser failed (${n.status})`)}return n.json()}async function l(t){const e=await fetch(`${o}/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"include"});if(!e.ok&&e.status!==204){const n=await e.json().catch(()=>({}));throw new Error(n.error||`deleteUser failed (${e.status})`)}}async function u(t){const e=await fetch(`${o}/${encodeURIComponent(t)}/reset-link`,{method:"POST",credentials:"include"}),n=await e.json().catch(()=>({}));if(!e.ok)throw new Error(n.error||`Reset link failed (${e.status})`);return n}export{i as a,u as c,l as d,c as l,d as u};
1
+ import{o as s}from"./index-2XPPbPwy.js";const o="/xenon/api/users";async function c(){const t=await fetch(`${o}/`,{credentials:"include"});if(!t.ok)throw new Error(`listUsers failed (${t.status})`);return t.json()}async function i(t){const e=await fetch(`${o}/`,{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",...s},body:JSON.stringify(t)});if(!e.ok){const n=await e.json().catch(()=>({}));throw new Error(n.error||`createUser failed (${e.status})`)}return e.json()}async function d(t,e){const n=await fetch(`${o}/${encodeURIComponent(t)}`,{method:"PATCH",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(!n.ok){const r=await n.json().catch(()=>({}));throw new Error(r.error||`updateUser failed (${n.status})`)}return n.json()}async function l(t){const e=await fetch(`${o}/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"include"});if(!e.ok&&e.status!==204){const n=await e.json().catch(()=>({}));throw new Error(n.error||`deleteUser failed (${e.status})`)}}async function u(t){const e=await fetch(`${o}/${encodeURIComponent(t)}/reset-link`,{method:"POST",credentials:"include"}),n=await e.json().catch(()=>({}));if(!e.ok)throw new Error(n.error||`Reset link failed (${e.status})`);return n}export{i as a,u as c,l as d,c as l,d as u};
@@ -1,4 +1,4 @@
1
- import{c as o}from"./index-BuIIcmGp.js";/**
1
+ import{c as o}from"./index-2XPPbPwy.js";/**
2
2
  * @license lucide-react v0.555.0 - ISC
3
3
  *
4
4
  * This source code is licensed under the ISC license.
@@ -1 +1 @@
1
- import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-BuIIcmGp.js";/* empty css */import{F as S}from"./FieldGroup-CCdFlZm6.js";import{P as O}from"./page-header-CKWFOjOI.js";import{T as V}from"./trash-2-BV88cV3Z.js";import{P as x}from"./plus-BpFKVdC8.js";import{C as B}from"./circle-x-pdVkomLa.js";import{C as H}from"./circle-check-Ckn00yMB.js";import{Z as I}from"./zap-CWE94qcK.js";import{A as J}from"./activity-K-hdylwq.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),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(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{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:g.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:s.payloadTemplate?"CUSTOM":"SLACK"}),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:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{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 i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{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:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.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:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{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:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(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:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
1
+ import{t as P,r,X as c,j as e,J as u,A as b,C as U,K as D}from"./index-2XPPbPwy.js";/* empty css */import{F as S}from"./FieldGroup-zQQHPh7x.js";import{P as O}from"./page-header-DqGlDufy.js";import{T as V}from"./trash-2-D61PoUrZ.js";import{P as x}from"./plus-Ct6aQTGM.js";import{C as B}from"./circle-x-BH_cUbqZ.js";import{C as H}from"./circle-check-CNFhu8oE.js";import{Z as I}from"./zap-uzHWFAKI.js";import{A as J}from"./activity-BU55TGRG.js";const C=[{id:"device_offline",label:"Device offline",icon:U,tone:"red"},{id:"device_new",label:"New device",icon:x,tone:"green"},{id:"session_failed",label:"Session failed",icon:B,tone:"amber"},{id:"selector_health_digest",label:"Selector health digest",icon:D,tone:"green"}],X=["udid","host","name","sessionId","failureReason","eventType","platform"],ae=()=>{const{toast:o}=P(),[g,T]=r.useState([]),[A,w]=r.useState(!0),[l,j]=r.useState(""),[d,k]=r.useState(["device_offline","session_failed"]),[h,f]=r.useState(!1),[v,N]=r.useState(!1),[n,_]=r.useState(!1),[y,m]=r.useState("");r.useEffect(()=>{p()},[]);const p=async()=>{w(!0);try{const s=await c.getWebhookConfigs();T(s||[])}catch(s){console.error("Failed to load webhook configs",s),o("Failed to load webhooks.","error")}finally{w(!1)}},z=async()=>{if(!l.trim()){o("Webhook URL is required.","error");return}if(d.length===0){o("Select at least one trigger event.","error");return}f(!0);try{await c.addWebhookConfig(l.trim(),d,"slack",y||void 0),j(""),m(""),_(!1),k(["device_offline","session_failed"]),await p(),o("Webhook saved.","success")}catch(s){console.error("Failed to add webhook",s),o(`Failed to add webhook: ${s.message||"unknown error"}`,"error")}finally{f(!1)}},E=async(s,t)=>{if(window.confirm(`Remove webhook for "${t}"?`))try{await c.deleteWebhookConfig(s),await p(),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(l.trim()){N(!0);try{await c.testWebhook(l.trim(),"slack"),o("Test payload delivered.","success")}catch(s){o(`Test failed: ${s.message||"check the URL"}`,"error")}finally{N(!1)}}},$=s=>{k(t=>t.includes(s)?t.filter(a=>a!==s):[...t,s])},R=s=>{m(t=>t+`{{${s}}} `)};return e.jsxs("div",{className:"settings-container",children:[e.jsx(O,{icon:u,title:"Notifications",subtitle:"Configure Slack or generic webhooks to receive alerts for critical infrastructure events."}),e.jsxs("div",{className:"settings-content",children:[A?e.jsxs("div",{className:"settings-loading",style:{height:200},children:[e.jsx(b,{className:"animate-spin",size:28}),e.jsx("span",{children:"Loading webhooks…"})]}):g.length===0?e.jsxs("div",{className:"empty-state",children:[e.jsx("div",{className:"empty-state__icon",children:e.jsx(u,{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:g.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:s.payloadTemplate?"CUSTOM":"SLACK"}),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:()=>E(s.id,s.url),"aria-label":`Remove webhook for ${s.url}`,children:[e.jsx(V,{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 i=C.find(F=>F.id===a),W=(i==null?void 0:i.icon)??u;return e.jsxs("span",{className:`event-pill event-pill--${(i==null?void 0:i.tone)||"gray"}`,children:[e.jsx(W,{size:11}),(i==null?void 0:i.label)||a]},a)})})]},s.id)})}),e.jsxs("div",{className:"add-webhook-card",children:[e.jsxs("div",{className:"add-webhook-card__header",children:[e.jsx(x,{size:14}),e.jsx("span",{children:"Add a new webhook"})]}),e.jsxs("div",{className:"add-webhook-card__body",children:[e.jsx(S,{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:l,onChange:s=>j(s.target.value)})})}),e.jsx(S,{label:"Trigger events",children:e.jsx("div",{className:"event-toggle-grid",children:C.map(s=>{const t=d.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:()=>$(s.id),children:[e.jsx(a,{size:14,className:"event-toggle__lead"}),e.jsx("span",{children:s.label}),t&&e.jsx(H,{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:()=>_(!n),"aria-expanded":n,children:[e.jsx(I,{size:14,className:n?"tone-amber":"tone-dim"}),e.jsx("span",{children:"Use custom payload (optional)"}),e.jsx("span",{className:"template-section__indicator",children:n?"−":"+"})]}),n&&e.jsxs("div",{className:"template-editor",children:[e.jsx("p",{className:"template-editor__hint",children:"Define a JSON or text template. Click a variable below to insert it."}),e.jsx("div",{className:"template-editor__chips",children:X.map(s=>e.jsx("button",{type:"button",className:"template-editor__chip",onClick:()=>R(s),children:`{{${s}}}`},s))}),e.jsx("textarea",{className:"template-editor__textarea",placeholder:'Example: { "text": "Alert: Device {{udid}} is offline!" }',value:y,onChange:s=>m(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:!l.trim()||v,children:[v?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(J,{size:14}),e.jsx("span",{children:"Test payload"})]}),e.jsxs("button",{type:"button",className:"page-header-action",onClick:z,disabled:h||!l.trim(),children:[h?e.jsx(b,{size:14,className:"animate-spin"}):e.jsx(x,{size:14}),e.jsx("span",{children:h?"Saving…":"Save webhook"})]})]})]})]})]})};export{ae as WebhookSettings};
@@ -1,4 +1,4 @@
1
- import{c as a}from"./index-BuIIcmGp.js";/**
1
+ import{c as a}from"./index-2XPPbPwy.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-BuIIcmGp.js"></script>
39
+ <script type="module" crossorigin src="/xenon/assets/index-2XPPbPwy.js"></script>
40
40
  <link rel="stylesheet" crossorigin href="/xenon/assets/index-hymrhlEZ.css">
41
41
  </head>
42
42
 
@@ -45,7 +45,8 @@ 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.previewLeaves = exports.previewLeaveDeps = void 0;
48
+ exports.previewLeaves = exports.previewLeaveDeps = exports.UPLOAD_INSTALL_MAX_BYTES = void 0;
49
+ exports.uploadName = uploadName;
49
50
  const plugin_1 = require("../../plugin");
50
51
  const express_1 = require("express");
51
52
  const device_store_1 = require("../../data-service/device-store");
@@ -61,6 +62,7 @@ const IOSStreamService_1 = __importDefault(require("../../device-managers/ios/IO
61
62
  const AndroidStreamService_1 = __importDefault(require("../../device-managers/android/AndroidStreamService"));
62
63
  const AndroidH264StreamService_1 = __importDefault(require("../../device-managers/android/AndroidH264StreamService"));
63
64
  const path_1 = __importDefault(require("path"));
65
+ const express_fileupload_1 = __importDefault(require("express-fileupload"));
64
66
  const os_1 = __importDefault(require("os"));
65
67
  const fs_extra_1 = __importDefault(require("fs-extra"));
66
68
  const OmniVisionService_1 = require("../../services/omni-vision/OmniVisionService");
@@ -68,7 +70,6 @@ const InspectorService_1 = require("../../services/InspectorService");
68
70
  const StreamTicketService_1 = require("../../services/token/StreamTicketService");
69
71
  const appVisibility_1 = require("../../services/device-access/appVisibility");
70
72
  const PluginContext_1 = require("../../PluginContext");
71
- const localDeviceHosts_1 = require("../../device-managers/localDeviceHosts");
72
73
  const streamType_1 = require("./streamType");
73
74
  const iosStreamPort_1 = require("./iosStreamPort");
74
75
  const androidH264Config_1 = require("./androidH264Config");
@@ -81,10 +82,10 @@ const deviceTeamGuard_1 = require("../../middleware/deviceTeamGuard");
81
82
  const controlDevice_1 = require("../../middleware/controlDevice");
82
83
  const manualLock_1 = require("../../services/recording/manualLock");
83
84
  const streamStartConflict_1 = require("./streamStartConflict");
85
+ const nodePhoneControl_1 = require("./nodePhoneControl");
84
86
  const streamLeave_1 = require("./streamLeave");
85
87
  const SessionOwnerResolver_1 = require("../../services/device-access/SessionOwnerResolver");
86
88
  const actor_1 = require("../../services/device-access/actor");
87
- const hubSessionToken_1 = require("../../gateway/hubSessionToken");
88
89
  const deviceAccessPolicy_1 = require("../../services/device-access/deviceAccessPolicy");
89
90
  const router = (0, express_1.Router)();
90
91
  /** True when an in-memory iOS/Android (MJPEG or H.264) stream session exists. */
@@ -112,76 +113,10 @@ router.use((0, deviceTeamGuard_1.deviceTeamGuard)());
112
113
  // another user's Appium session. Mounted here so every current and future
113
114
  // mutation is covered without per-handler opt-in.
114
115
  router.use((0, deviceAccessGuard_1.deviceAccessGuard)());
115
- // Cloud metadata endpoints — never proxy to these regardless of caller.
116
- const FORBIDDEN_PROXY_HOSTS = new Set([
117
- '169.254.169.254', // AWS/Azure/GCP IMDS
118
- 'metadata.google.internal',
119
- 'metadata.goog',
120
- '100.100.100.200', // Alibaba ECS metadata
121
- 'fd00:ec2::254', // AWS IMDSv6
122
- ]);
123
- /**
124
- * Build a safe proxy URL from a device's reported host. Strips any path,
125
- * query, or fragment the host string carried, blocks cloud-metadata targets,
126
- * and refuses non-http(s) schemes. Returns null if the host is unsafe.
127
- */
128
- function buildProxyUrl(deviceHost, req) {
129
- let parsed;
130
- try {
131
- parsed = new URL(deviceHost);
132
- }
133
- catch (_a) {
134
- return null;
135
- }
136
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
137
- return null;
138
- if (FORBIDDEN_PROXY_HOSTS.has(parsed.hostname))
139
- return null;
140
- // Only keep scheme + host + port; discard any attacker-baked path/query/fragment.
141
- const origin = `${parsed.protocol}//${parsed.host}`;
142
- // Treat req.originalUrl as a path — reparse to strip any control characters.
143
- const forwardPath = req.originalUrl.startsWith('/') ? req.originalUrl : `/${req.originalUrl}`;
144
- return `${origin}${forwardPath}`;
145
- }
146
- /**
147
- * Forward a control action on another server's phone to that server, signed
148
- * for the caller: the hub's control token (hubSessionToken.ts) names the
149
- * user, whether they are an admin, the phone and its node. This hub already
150
- * checked them (the guards above); a node with auth enabled accepts the token
151
- * for /control on that phone and runs its own ownership guard with that user.
152
- * Sent with no credential at all, it was refused by every such node. A cloud
153
- * provider never gets a hub token.
154
- */
155
- function forwardControl(device, req, target) {
156
- return __awaiter(this, void 0, void 0, function* () {
157
- const actor = (0, actor_1.resolveActor)(req);
158
- const token = !device.cloud && actor.userId
159
- ? yield typedi_1.Container.get(hubSessionToken_1.HubSessionTokenIssuer).controlTokenFor({
160
- userId: actor.userId,
161
- isAdmin: actor.isAdmin,
162
- udid: device.udid,
163
- host: device.host,
164
- })
165
- : null;
166
- // Sent once: a retry after a slow node's timeout or 5xx could land the
167
- // same tap twice.
168
- const config = Object.assign(Object.assign({}, (token ? { headers: { [hubSessionToken_1.HUB_TOKEN_HEADER]: token } } : {})), { retry: false });
169
- yield InternalHttpClient_1.InternalHttpClient.post(target, req.body, config);
170
- });
171
- }
172
- /**
173
- * A phone another server drives, whose /control actions are forwarded there.
174
- * Decided by the phone's own row (its nodeId, else its exact host against this
175
- * server's own hosts), never by the Host header the caller used: a substring
176
- * match on that header forwarded a lab's own phone back to itself, with no
177
- * login, whenever the lab was reached as `localhost`.
178
- */
179
- function isOtherServersPhone(device) {
180
- if (!device.host)
181
- return false;
182
- const context = typedi_1.Container.get(PluginContext_1.PluginContext);
183
- return !(0, localDeviceHosts_1.isOwnDevice)((0, localDeviceHosts_1.localDeviceHosts)(context.pluginArgs, context.port), context.nodeId, device);
184
- }
116
+ // Another server's phone (a node's, or a cloud provider's): its actions are
117
+ // sent on to its node, answered here, or refused, before any handler below
118
+ // runs it with this server's own tools. See nodePhoneControl.ts.
119
+ router.use((0, nodePhoneControl_1.nodePhoneControl)());
185
120
  function getDeviceInfo(udid) {
186
121
  return __awaiter(this, void 0, void 0, function* () {
187
122
  const device = yield device_store_1.DeviceStoreFactory.getStore().findDevice({ udid });
@@ -213,26 +148,12 @@ function getDeviceManagerForPlatform(platform) {
213
148
  });
214
149
  }
215
150
  router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
216
- var _a, _b;
217
151
  const { udid } = req.params;
218
152
  const { x, y } = req.body;
219
153
  const device = yield getDeviceInfo(udid);
220
154
  if (!device)
221
155
  return res.status(404).send('Device not found');
222
156
  const manager = yield getDeviceManagerForPlatform(device.platform);
223
- if (isOtherServersPhone(device)) {
224
- const target = buildProxyUrl(device.host, req);
225
- if (!target)
226
- return res.status(400).send({ error: 'Unsafe device host' });
227
- logger_1.default.info(`Proxying tap for ${udid} to ${target}`);
228
- try {
229
- yield forwardControl(device, req, target);
230
- return res.status(200).send({ success: true });
231
- }
232
- catch (err) {
233
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
234
- }
235
- }
236
157
  if (manager && manager.tap) {
237
158
  try {
238
159
  yield manager.tap(udid, x, y);
@@ -257,26 +178,12 @@ router.post('/:udid/tap', (req, res) => __awaiter(void 0, void 0, void 0, functi
257
178
  res.status(400).send('Manager not found or tap not supported');
258
179
  }));
259
180
  router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
260
- var _a, _b;
261
181
  const { udid } = req.params;
262
182
  const { x, y, endX, endY, duration = 1000 } = req.body;
263
183
  const device = yield getDeviceInfo(udid);
264
184
  if (!device)
265
185
  return res.status(404).send('Device not found');
266
186
  const manager = yield getDeviceManagerForPlatform(device.platform);
267
- if (isOtherServersPhone(device)) {
268
- const target = buildProxyUrl(device.host, req);
269
- if (!target)
270
- return res.status(400).send({ error: 'Unsafe device host' });
271
- logger_1.default.info(`Proxying swipe for ${udid} to ${target}`);
272
- try {
273
- yield forwardControl(device, req, target);
274
- return res.status(200).send({ success: true });
275
- }
276
- catch (err) {
277
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
278
- }
279
- }
280
187
  if (manager && manager.swipe) {
281
188
  try {
282
189
  yield manager.swipe(udid, x, y, endX, endY, duration);
@@ -290,26 +197,12 @@ router.post('/:udid/swipe', (req, res) => __awaiter(void 0, void 0, void 0, func
290
197
  res.status(400).send('Manager not found or swipe not supported');
291
198
  }));
292
199
  router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
293
- var _a, _b;
294
200
  const { udid } = req.params;
295
201
  const { text } = req.body;
296
202
  const device = yield getDeviceInfo(udid);
297
203
  if (!device)
298
204
  return res.status(404).send('Device not found');
299
205
  const manager = yield getDeviceManagerForPlatform(device.platform);
300
- if (isOtherServersPhone(device)) {
301
- const target = buildProxyUrl(device.host, req);
302
- if (!target)
303
- return res.status(400).send({ error: 'Unsafe device host' });
304
- logger_1.default.info(`Proxying typeText for ${udid} to ${target}`);
305
- try {
306
- yield forwardControl(device, req, target);
307
- return res.status(200).send({ success: true });
308
- }
309
- catch (err) {
310
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
311
- }
312
- }
313
206
  if (manager && manager.typeText) {
314
207
  try {
315
208
  yield manager.typeText(udid, text);
@@ -323,26 +216,12 @@ router.post('/:udid/text', (req, res) => __awaiter(void 0, void 0, void 0, funct
323
216
  res.status(400).send('Manager not found or typeText not supported');
324
217
  }));
325
218
  router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
326
- var _a, _b;
327
219
  const { udid } = req.params;
328
220
  const { keyCode } = req.body;
329
221
  const device = yield getDeviceInfo(udid);
330
222
  if (!device)
331
223
  return res.status(404).send('Device not found');
332
224
  const manager = yield getDeviceManagerForPlatform(device.platform);
333
- if (isOtherServersPhone(device)) {
334
- const target = buildProxyUrl(device.host, req);
335
- if (!target)
336
- return res.status(400).send({ error: 'Unsafe device host' });
337
- logger_1.default.info(`Proxying keyevent for ${udid} to ${target}`);
338
- try {
339
- yield forwardControl(device, req, target);
340
- return res.status(200).send({ success: true });
341
- }
342
- catch (err) {
343
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
344
- }
345
- }
346
225
  if (manager && manager.pressKey) {
347
226
  try {
348
227
  yield manager.pressKey(udid, keyCode);
@@ -356,6 +235,7 @@ router.post('/:udid/keyevent', (req, res) => __awaiter(void 0, void 0, void 0, f
356
235
  res.status(400).send('Manager not found or pressKey not supported');
357
236
  }));
358
237
  router.get('/:udid/screenshot', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
238
+ var _a, _b;
359
239
  const { udid } = req.params;
360
240
  const device = yield getDeviceInfo(udid);
361
241
  if (!device)
@@ -371,7 +251,16 @@ router.get('/:udid/screenshot', (req, res) => __awaiter(void 0, void 0, void 0,
371
251
  }
372
252
  const manager = yield getDeviceManagerForPlatform(device.platform);
373
253
  if (manager && manager.getScreenshot) {
374
- const base64 = yield manager.getScreenshot(udid);
254
+ let base64;
255
+ try {
256
+ base64 = yield manager.getScreenshot(udid);
257
+ }
258
+ catch (err) {
259
+ // Answered: Express 4 doesn't catch a rejected async handler, and an
260
+ // iPhone with no live preview (no WDA, no tunnel) left this unanswered.
261
+ logger_1.default.error(`Screenshot capture failed for ${udid}: ${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`);
262
+ return res.status(502).send({ error: `Screenshot capture failed: ${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}` });
263
+ }
375
264
  // CRITICAL: Validate screenshot is not empty before returning success
376
265
  if (base64 && base64.length > 100) {
377
266
  return res.status(200).send({ screenshot: base64 });
@@ -426,26 +315,12 @@ router.post('/:udid/clipboard', (req, res) => __awaiter(void 0, void 0, void 0,
426
315
  res.status(400).send('Manager not found or setClipboard not supported');
427
316
  }));
428
317
  router.post('/:udid/touchAndHold', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
429
- var _a, _b;
430
318
  const { udid } = req.params;
431
319
  const { x, y, duration = 1000 } = req.body;
432
320
  const device = yield getDeviceInfo(udid);
433
321
  if (!device)
434
322
  return res.status(404).send('Device not found');
435
323
  const manager = yield getDeviceManagerForPlatform(device.platform);
436
- if (isOtherServersPhone(device)) {
437
- const target = buildProxyUrl(device.host, req);
438
- if (!target)
439
- return res.status(400).send({ error: 'Unsafe device host' });
440
- logger_1.default.info(`Proxying touchAndHold for ${udid} to ${target}`);
441
- try {
442
- yield forwardControl(device, req, target);
443
- return res.status(200).send({ success: true });
444
- }
445
- catch (err) {
446
- return res.status(((_a = err.response) === null || _a === void 0 ? void 0 : _a.status) || 500).send(((_b = err.response) === null || _b === void 0 ? void 0 : _b.data) || err.message);
447
- }
448
- }
449
324
  if (manager && manager.touchAndHold) {
450
325
  try {
451
326
  yield manager.touchAndHold(udid, x, y, duration);
@@ -552,11 +427,16 @@ router.post('/:udid/install-repository-app', (req, res) => __awaiter(void 0, voi
552
427
  if (!(0, appVisibility_1.canSeeApp)(app, (_a = req.auth) === null || _a === void 0 ? void 0 : _a.teamIds)) {
553
428
  return res.status(404).send('App not found in repository');
554
429
  }
430
+ // A node's phone: the app is in this server's library, not the node's,
431
+ // so the file goes to the node's own upload-install.
432
+ if ((0, nodePhoneControl_1.isOtherServersPhone)(device)) {
433
+ return yield (0, nodePhoneControl_1.installFileOnNode)(req, res, udid, device, {
434
+ path: app.filepath,
435
+ name: app.filename || path_1.default.basename(app.filepath),
436
+ });
437
+ }
555
438
  const manager = yield getDeviceManagerForPlatform(device.platform);
556
439
  if (manager && manager.installApp) {
557
- // If it's a local file, pass the path.
558
- // In a distributed setup, the node would ideally download it.
559
- // For now, we assume hub-node shared storage or hub-local execution.
560
440
  yield manager.installApp(udid, app.filepath);
561
441
  return res.status(200).send({ success: true, message: `Installed ${app.name}` });
562
442
  }
@@ -567,38 +447,71 @@ router.post('/:udid/install-repository-app', (req, res) => __awaiter(void 0, voi
567
447
  res.status(500).send({ error: err.message });
568
448
  }
569
449
  }));
570
- router.post('/:udid/upload-install', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
450
+ /** Where upload-install writes an uploaded app while it is installed. */
451
+ const UPLOAD_DIR = path_1.default.join(os_1.default.tmpdir(), 'xenon-uploads');
452
+ /** The largest app upload-install takes: room for a large iOS app. */
453
+ exports.UPLOAD_INSTALL_MAX_BYTES = 4 * 1024 ** 3;
454
+ /**
455
+ * upload-install's own multipart parser. /control had none (only /apps
456
+ * mounts one), so every upload was answered "No files were uploaded." The
457
+ * file goes to a temporary file rather than memory, and one past the limit
458
+ * is answered 413.
459
+ */
460
+ const uploadParser = (0, express_fileupload_1.default)({
461
+ useTempFiles: true,
462
+ tempFileDir: UPLOAD_DIR,
463
+ limits: { fileSize: exports.UPLOAD_INSTALL_MAX_BYTES },
464
+ abortOnLimit: true,
465
+ });
466
+ /**
467
+ * The name an upload is saved under: the client's name, cut to its last
468
+ * path segment and to safe characters, keeping its extension, which the
469
+ * installers read. busboy already cuts the name to its last segment (its
470
+ * `preservePath` is off); this holds if a parser ever keeps paths.
471
+ */
472
+ function uploadName(clientName) {
473
+ const base = path_1.default
474
+ .basename(String(clientName !== null && clientName !== void 0 ? clientName : ''))
475
+ .replace(/[^\w.-]+/g, '_')
476
+ .replace(/^\.+/, '');
477
+ return `${Date.now()}-${base || 'app'}`;
478
+ }
479
+ router.post('/:udid/upload-install', uploadParser, (req, res) => __awaiter(void 0, void 0, void 0, function* () {
480
+ var _a;
571
481
  const { udid } = req.params;
572
- const device = yield getDeviceInfo(udid);
573
- if (!device)
574
- return res.status(404).send('Device not found');
575
- if (!req.files || Object.keys(req.files).length === 0) {
576
- return res.status(400).send('No files were uploaded.');
577
- }
578
- const appFile = req.files.app;
579
- if (!appFile)
580
- return res.status(400).send('File "app" is required');
581
- const tmpDir = path_1.default.join(os_1.default.tmpdir(), 'xenon-uploads');
582
- if (!fs_extra_1.default.existsSync(tmpDir))
583
- fs_extra_1.default.mkdirSync(tmpDir, { recursive: true });
584
- const appPath = path_1.default.join(tmpDir, `${Date.now()}-${appFile.name}`);
585
- try {
482
+ const appFile = (_a = req.files) === null || _a === void 0 ? void 0 : _a.app;
483
+ const appPath = appFile ? path_1.default.join(UPLOAD_DIR, uploadName(appFile.name)) : undefined;
484
+ const answer = yield (() => __awaiter(void 0, void 0, void 0, function* () {
485
+ const device = yield getDeviceInfo(udid);
486
+ if (!device)
487
+ return { status: 404, body: 'Device not found' };
488
+ if (!req.files || Object.keys(req.files).length === 0) {
489
+ return { status: 400, body: 'No files were uploaded.' };
490
+ }
491
+ if (!appFile || !appPath)
492
+ return { status: 400, body: 'File "app" is required' };
586
493
  yield appFile.mv(appPath);
587
494
  const manager = yield getDeviceManagerForPlatform(device.platform);
588
- if (manager && manager.installApp) {
589
- yield manager.installApp(udid, appPath);
590
- // Clean up after installation
591
- setTimeout(() => fs_extra_1.default.remove(appPath).catch(() => { }), 10000);
592
- return res
593
- .status(200)
594
- .send({ success: true, message: `App ${appFile.name} installed successfully` });
495
+ if (!(manager === null || manager === void 0 ? void 0 : manager.installApp)) {
496
+ return { status: 400, body: 'Manager not found or installApp not supported' };
595
497
  }
596
- res.status(400).send('Manager not found or installApp not supported');
597
- }
598
- catch (err) {
498
+ yield manager.installApp(udid, appPath);
499
+ return {
500
+ status: 200,
501
+ body: { success: true, message: `App ${appFile.name} installed successfully` },
502
+ };
503
+ }))().catch((err) => {
599
504
  logger_1.default.error(`Installation failed for ${udid}: ${err.message}`);
600
- res.status(500).send({ error: err.message });
505
+ return { status: 500, body: { error: err.message } };
506
+ });
507
+ // The install has finished with the file, whether it worked or not (both
508
+ // installers wait for their process). Removed before answering, so nothing
509
+ // is left behind once the caller has the answer.
510
+ for (const file of [appPath, appFile === null || appFile === void 0 ? void 0 : appFile.tempFilePath]) {
511
+ if (file)
512
+ yield fs_extra_1.default.remove(file).catch(() => undefined);
601
513
  }
514
+ res.status(answer.status).send(answer.body);
602
515
  }));
603
516
  router.post('/:udid/uninstall', (req, res) => __awaiter(void 0, void 0, void 0, function* () {
604
517
  const { udid } = req.params;
@@ -1169,6 +1082,33 @@ router.post('/:udid/shell', (req, res) => __awaiter(void 0, void 0, void 0, func
1169
1082
  }
1170
1083
  res.status(400).send('Manager not found or executeShell not supported');
1171
1084
  }));
1085
+ /**
1086
+ * Where Omni-Vision reads the screen from, for the stand-in driver it is
1087
+ * given: this server's device manager, or, for a node's phone, the node
1088
+ * (screenshotFromNode). Omni itself runs here either way, with this
1089
+ * server's AI settings. Null when this server has no manager for the phone.
1090
+ */
1091
+ function omniScreen(req, udid, device) {
1092
+ return __awaiter(this, void 0, void 0, function* () {
1093
+ if ((0, nodePhoneControl_1.isOtherServersPhone)(device)) {
1094
+ return {
1095
+ getScreenshot: () => (0, nodePhoneControl_1.screenshotFromNode)(req, udid, device),
1096
+ getPageSource: () => __awaiter(this, void 0, void 0, function* () { return ''; }),
1097
+ };
1098
+ }
1099
+ const manager = yield getDeviceManagerForPlatform(device.platform);
1100
+ if (!manager)
1101
+ return null;
1102
+ return {
1103
+ getScreenshot: () => __awaiter(this, void 0, void 0, function* () {
1104
+ if (manager.getScreenshot)
1105
+ return yield manager.getScreenshot(udid);
1106
+ throw new Error('Screenshot not supported for this device');
1107
+ }),
1108
+ getPageSource: () => __awaiter(this, void 0, void 0, function* () { return (manager.getPageSource ? yield manager.getPageSource(udid) : ''); }),
1109
+ };
1110
+ });
1111
+ }
1172
1112
  /**
1173
1113
  * Omni-Scan for manual control (No Appium Session)
1174
1114
  */
@@ -1177,28 +1117,12 @@ router.get('/:udid/omni-scan', (req, res) => __awaiter(void 0, void 0, void 0, f
1177
1117
  const device = yield getDeviceInfo(udid);
1178
1118
  if (!device)
1179
1119
  return res.status(404).send('Device not found');
1180
- const manager = yield getDeviceManagerForPlatform(device.platform);
1181
- if (!manager)
1120
+ const screen = yield omniScreen(req, udid, device);
1121
+ if (!screen)
1182
1122
  return res.status(400).send('Manager not found');
1183
1123
  try {
1184
1124
  const omniService = typedi_1.Container.get(OmniVisionService_1.OmniVisionService);
1185
- // Create a Mock Driver that OmniVisionService can use
1186
- const mockDriver = {
1187
- sessionId: `manual_${udid}`,
1188
- getScreenshot: () => __awaiter(void 0, void 0, void 0, function* () {
1189
- if (manager.getScreenshot) {
1190
- return yield manager.getScreenshot(udid);
1191
- }
1192
- throw new Error('Screenshot not supported for this device');
1193
- }),
1194
- // OmniVision might need page source for some analysis later
1195
- getPageSource: () => __awaiter(void 0, void 0, void 0, function* () {
1196
- if (manager.getPageSource) {
1197
- return yield manager.getPageSource(udid);
1198
- }
1199
- return '';
1200
- }),
1201
- };
1125
+ const mockDriver = Object.assign({ sessionId: `manual_${udid}` }, screen);
1202
1126
  const result = yield omniService.analyzeScreen(mockDriver);
1203
1127
  return res.status(200).send({ status: 'success', value: result });
1204
1128
  }
@@ -1277,20 +1201,12 @@ router.post('/:udid/test-locator', (req, res) => __awaiter(void 0, void 0, void
1277
1201
  const device = yield getDeviceInfo(udid);
1278
1202
  if (!device)
1279
1203
  return res.status(404).send('Device not found');
1280
- const manager = yield getDeviceManagerForPlatform(device.platform);
1281
- if (!manager)
1204
+ const screen = yield omniScreen(req, udid, device);
1205
+ if (!screen)
1282
1206
  return res.status(400).send('Manager not found');
1283
1207
  try {
1284
1208
  const omniService = typedi_1.Container.get(OmniVisionService_1.OmniVisionService);
1285
- const mockDriver = {
1286
- sessionId: `manual_${udid}`,
1287
- getScreenshot: () => __awaiter(void 0, void 0, void 0, function* () {
1288
- if (manager.getScreenshot) {
1289
- return yield manager.getScreenshot(udid);
1290
- }
1291
- throw new Error('Screenshot not supported for this device');
1292
- }),
1293
- };
1209
+ const mockDriver = { sessionId: `manual_${udid}`, getScreenshot: screen.getScreenshot };
1294
1210
  let value = [];
1295
1211
  if (strategy === '-custom:ai-text') {
1296
1212
  value = yield omniService.findByText(mockDriver, selector);