@lampdotdev/lamp 0.0.0-stage → 0.36.2-nightly.16

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 (470) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +397 -2
  3. package/Resources/Devices/roku.json +410 -0
  4. package/Resources/Devices/xbox-one.json +367 -0
  5. package/Resources/GameKit/NOTICE.md +63 -0
  6. package/Resources/GameKit/apple/LampPage.swift +1813 -0
  7. package/Resources/GameKit/apple/LampPlayer.swift +46 -0
  8. package/Resources/GameKit/apple/LampWebApp.swift +63 -0
  9. package/Resources/GameKit/apple/LampWebGame.swift +87 -0
  10. package/Resources/GameKit/apple/Native.swift +12 -0
  11. package/Resources/GameKit/build.sh +55 -0
  12. package/Resources/GameKit/lamp_game.h +116 -0
  13. package/Resources/GameKit/lamp_host.h +19 -0
  14. package/Resources/GameKit/lamp_kit.cpp +61 -0
  15. package/Resources/GameKit/lamp_sound.cpp +455 -0
  16. package/Resources/GameKit/lamp_xbox.cpp +374 -0
  17. package/Resources/GameKit/libraries/HandmadeMath.h +3940 -0
  18. package/Resources/GameKit/libraries/cgltf.h +7240 -0
  19. package/Resources/GameKit/libraries/dr_mp3.h +5430 -0
  20. package/Resources/GameKit/libraries/dr_wav.h +9209 -0
  21. package/Resources/GameKit/libraries/sokol_debugtext.h +4754 -0
  22. package/Resources/GameKit/libraries/sokol_gfx.h +29558 -0
  23. package/Resources/GameKit/libraries/sokol_gl.h +4538 -0
  24. package/Resources/GameKit/libraries/sokol_log.h +335 -0
  25. package/Resources/GameKit/libraries/sokol_shape.h +1531 -0
  26. package/Resources/GameKit/libraries/stb_image.h +7988 -0
  27. package/Resources/GameKit/libraries/stb_vorbis.c +5584 -0
  28. package/Resources/GameKit/page/lamp-live.js +672 -0
  29. package/Resources/GameKit/scaffold/Game/game.cpp +142 -0
  30. package/Resources/GameKit/scaffold/Game/scene.glsl +49 -0
  31. package/Resources/GameKit/scaffold-web/Web/game.js +31 -0
  32. package/Resources/GameKit/scaffold-web/Web/index.html +13 -0
  33. package/Resources/GameKit/scaffold-web/Web/making.js +33 -0
  34. package/Resources/GameKit/scaffold-web/Web/world.js +33 -0
  35. package/Resources/GameKit/web/lamp-game.js +3042 -0
  36. package/Resources/GameKit/web/lamp-phaser.js +128 -0
  37. package/Resources/WebHost/App.exe +0 -0
  38. package/Resources/WebHost/Microsoft.Web.WebView2.Core.dll +0 -0
  39. package/Resources/WebHost/WebView2-LICENSE.txt +27 -0
  40. package/Resources/WebHost/lamp_controllers.h +1506 -0
  41. package/Resources/WebHost/lamp_web_host.cpp +1173 -0
  42. package/Resources/WebLibraries/earlier.txt +318 -0
  43. package/Resources/WebLibraries/phaser/LICENSE.md +20 -0
  44. package/Resources/WebLibraries/phaser/NOTICE.md +2 -0
  45. package/Resources/WebLibraries/phaser/phaser.esm.min.js +1 -0
  46. package/Resources/WebLibraries/qrcode/LICENSE +21 -0
  47. package/Resources/WebLibraries/qrcode/NOTICE.md +4 -0
  48. package/Resources/WebLibraries/qrcode/qrcode.js +2297 -0
  49. package/Resources/WebLibraries/rapier/LICENSE +201 -0
  50. package/Resources/WebLibraries/rapier/NOTICE.md +3 -0
  51. package/Resources/WebLibraries/rapier/rapier.js +2 -0
  52. package/Resources/WebLibraries/three/LICENSE +21 -0
  53. package/Resources/WebLibraries/three/NOTICE.md +4 -0
  54. package/Resources/WebLibraries/three/addons/animation/AnimationClipCreator.js +168 -0
  55. package/Resources/WebLibraries/three/addons/animation/CCDIKSolver.js +607 -0
  56. package/Resources/WebLibraries/three/addons/controls/ArcballControls.js +3543 -0
  57. package/Resources/WebLibraries/three/addons/controls/DragControls.js +452 -0
  58. package/Resources/WebLibraries/three/addons/controls/FirstPersonControls.js +512 -0
  59. package/Resources/WebLibraries/three/addons/controls/FlyControls.js +384 -0
  60. package/Resources/WebLibraries/three/addons/controls/MapControls.js +116 -0
  61. package/Resources/WebLibraries/three/addons/controls/OrbitControls.js +1985 -0
  62. package/Resources/WebLibraries/three/addons/controls/PointerLockControls.js +264 -0
  63. package/Resources/WebLibraries/three/addons/controls/TrackballControls.js +1106 -0
  64. package/Resources/WebLibraries/three/addons/controls/TransformControls.js +2003 -0
  65. package/Resources/WebLibraries/three/addons/curves/CurveExtras.js +694 -0
  66. package/Resources/WebLibraries/three/addons/curves/NURBSCurve.js +169 -0
  67. package/Resources/WebLibraries/three/addons/curves/NURBSSurface.js +98 -0
  68. package/Resources/WebLibraries/three/addons/curves/NURBSUtils.js +532 -0
  69. package/Resources/WebLibraries/three/addons/curves/NURBSVolume.js +82 -0
  70. package/Resources/WebLibraries/three/addons/effects/AnaglyphEffect.js +274 -0
  71. package/Resources/WebLibraries/three/addons/effects/AsciiEffect.js +323 -0
  72. package/Resources/WebLibraries/three/addons/effects/OutlineEffect.js +489 -0
  73. package/Resources/WebLibraries/three/addons/effects/ParallaxBarrierEffect.js +155 -0
  74. package/Resources/WebLibraries/three/addons/effects/StereoEffect.js +91 -0
  75. package/Resources/WebLibraries/three/addons/environments/ColorEnvironment.js +59 -0
  76. package/Resources/WebLibraries/three/addons/environments/DebugEnvironment.js +102 -0
  77. package/Resources/WebLibraries/three/addons/environments/RoomEnvironment.js +185 -0
  78. package/Resources/WebLibraries/three/addons/geometries/BoxLineGeometry.js +92 -0
  79. package/Resources/WebLibraries/three/addons/geometries/ConvexGeometry.js +72 -0
  80. package/Resources/WebLibraries/three/addons/geometries/DecalGeometry.js +420 -0
  81. package/Resources/WebLibraries/three/addons/geometries/LoftGeometry.js +355 -0
  82. package/Resources/WebLibraries/three/addons/geometries/ParametricFunctions.js +100 -0
  83. package/Resources/WebLibraries/three/addons/geometries/ParametricGeometry.js +172 -0
  84. package/Resources/WebLibraries/three/addons/geometries/RoundedBoxGeometry.js +216 -0
  85. package/Resources/WebLibraries/three/addons/geometries/TeapotGeometry.js +689 -0
  86. package/Resources/WebLibraries/three/addons/geometries/TextGeometry.js +102 -0
  87. package/Resources/WebLibraries/three/addons/helpers/AnimationPathHelper.js +302 -0
  88. package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelper.js +166 -0
  89. package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelperWebGL.js +221 -0
  90. package/Resources/WebLibraries/three/addons/helpers/LightProbeHelper.js +166 -0
  91. package/Resources/WebLibraries/three/addons/helpers/LightProbeHelperGPU.js +102 -0
  92. package/Resources/WebLibraries/three/addons/helpers/OctreeHelper.js +109 -0
  93. package/Resources/WebLibraries/three/addons/helpers/PositionalAudioHelper.js +169 -0
  94. package/Resources/WebLibraries/three/addons/helpers/RapierHelper.js +59 -0
  95. package/Resources/WebLibraries/three/addons/helpers/RectAreaLightHelper.js +118 -0
  96. package/Resources/WebLibraries/three/addons/helpers/TextureHelper.js +265 -0
  97. package/Resources/WebLibraries/three/addons/helpers/TextureHelperGPU.js +214 -0
  98. package/Resources/WebLibraries/three/addons/helpers/VertexNormalsHelper.js +155 -0
  99. package/Resources/WebLibraries/three/addons/helpers/VertexTangentsHelper.js +133 -0
  100. package/Resources/WebLibraries/three/addons/helpers/ViewHelper.js +529 -0
  101. package/Resources/WebLibraries/three/addons/interactive/HTMLMesh.js +617 -0
  102. package/Resources/WebLibraries/three/addons/interactive/InteractiveGroup.js +224 -0
  103. package/Resources/WebLibraries/three/addons/interactive/SelectionBox.js +324 -0
  104. package/Resources/WebLibraries/three/addons/interactive/SelectionHelper.js +150 -0
  105. package/Resources/WebLibraries/three/addons/libs/fflate.module.js +2699 -0
  106. package/Resources/WebLibraries/three/addons/libs/lil-gui.module.min.js +8 -0
  107. package/Resources/WebLibraries/three/addons/libs/meshopt_decoder.module.js +196 -0
  108. package/Resources/WebLibraries/three/addons/libs/potpack.module.js +125 -0
  109. package/Resources/WebLibraries/three/addons/libs/stats.module.js +167 -0
  110. package/Resources/WebLibraries/three/addons/libs/tween.module.js +876 -0
  111. package/Resources/WebLibraries/three/addons/lighting/ClusteredLighting.js +153 -0
  112. package/Resources/WebLibraries/three/addons/lighting/DynamicLighting.js +82 -0
  113. package/Resources/WebLibraries/three/addons/lighting/LightMapper.js +295 -0
  114. package/Resources/WebLibraries/three/addons/lighting/LightProbeGrid.js +923 -0
  115. package/Resources/WebLibraries/three/addons/lighting/LightProbeGridUtils.js +107 -0
  116. package/Resources/WebLibraries/three/addons/lighting/LightProbeGridWebGL.js +846 -0
  117. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIConeTracer.js +243 -0
  118. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIIrradiance.js +43 -0
  119. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGINode.js +617 -0
  120. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGISceneCollector.js +793 -0
  121. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIVolume.js +1440 -0
  122. package/Resources/WebLibraries/three/addons/lights/LightProbeGenerator.js +337 -0
  123. package/Resources/WebLibraries/three/addons/lights/RectAreaLightTexturesLib.js +127 -0
  124. package/Resources/WebLibraries/three/addons/lights/RectAreaLightUniformsLib.js +40 -0
  125. package/Resources/WebLibraries/three/addons/lights/SunLight.js +100 -0
  126. package/Resources/WebLibraries/three/addons/lights/SunLightNode.js +56 -0
  127. package/Resources/WebLibraries/three/addons/lights/SunLightShadow.js +319 -0
  128. package/Resources/WebLibraries/three/addons/lights/SunShadowNode.js +312 -0
  129. package/Resources/WebLibraries/three/addons/lines/Line2.js +56 -0
  130. package/Resources/WebLibraries/three/addons/lines/LineGeometry.js +157 -0
  131. package/Resources/WebLibraries/three/addons/lines/LineMaterial.js +742 -0
  132. package/Resources/WebLibraries/three/addons/lines/LineSegments2.js +434 -0
  133. package/Resources/WebLibraries/three/addons/lines/LineSegmentsGeometry.js +298 -0
  134. package/Resources/WebLibraries/three/addons/lines/Wireframe.js +108 -0
  135. package/Resources/WebLibraries/three/addons/lines/WireframeGeometry2.js +49 -0
  136. package/Resources/WebLibraries/three/addons/lines/webgpu/Line2.js +46 -0
  137. package/Resources/WebLibraries/three/addons/lines/webgpu/LineSegments2.js +419 -0
  138. package/Resources/WebLibraries/three/addons/lines/webgpu/Wireframe.js +86 -0
  139. package/Resources/WebLibraries/three/addons/loaders/EXRLoader.js +3411 -0
  140. package/Resources/WebLibraries/three/addons/loaders/FBXLoader.js +4660 -0
  141. package/Resources/WebLibraries/three/addons/loaders/FontLoader.js +262 -0
  142. package/Resources/WebLibraries/three/addons/loaders/GLTFLoader.js +5006 -0
  143. package/Resources/WebLibraries/three/addons/loaders/HDRLoader.js +463 -0
  144. package/Resources/WebLibraries/three/addons/loaders/MTLLoader.js +601 -0
  145. package/Resources/WebLibraries/three/addons/loaders/OBJLoader.js +961 -0
  146. package/Resources/WebLibraries/three/addons/loaders/PLYLoader.js +992 -0
  147. package/Resources/WebLibraries/three/addons/loaders/RGBELoader.js +18 -0
  148. package/Resources/WebLibraries/three/addons/loaders/STLLoader.js +423 -0
  149. package/Resources/WebLibraries/three/addons/loaders/SVGLoader.js +3297 -0
  150. package/Resources/WebLibraries/three/addons/loaders/TGALoader.js +539 -0
  151. package/Resources/WebLibraries/three/addons/loaders/UltraHDRLoader.js +757 -0
  152. package/Resources/WebLibraries/three/addons/math/Capsule.js +159 -0
  153. package/Resources/WebLibraries/three/addons/math/ColorConverter.js +62 -0
  154. package/Resources/WebLibraries/three/addons/math/ColorSpaces.js +147 -0
  155. package/Resources/WebLibraries/three/addons/math/ConvexHull.js +1695 -0
  156. package/Resources/WebLibraries/three/addons/math/ImprovedNoise.js +88 -0
  157. package/Resources/WebLibraries/three/addons/math/Lut.js +317 -0
  158. package/Resources/WebLibraries/three/addons/math/MeshSurfaceSampler.js +315 -0
  159. package/Resources/WebLibraries/three/addons/math/OBB.js +535 -0
  160. package/Resources/WebLibraries/three/addons/math/Octree.js +825 -0
  161. package/Resources/WebLibraries/three/addons/math/SimplexNoise.js +470 -0
  162. package/Resources/WebLibraries/three/addons/misc/ConvexObjectBreaker.js +539 -0
  163. package/Resources/WebLibraries/three/addons/misc/GPUComputationRenderer.js +508 -0
  164. package/Resources/WebLibraries/three/addons/misc/Gyroscope.js +78 -0
  165. package/Resources/WebLibraries/three/addons/misc/MorphAnimMesh.js +119 -0
  166. package/Resources/WebLibraries/three/addons/misc/MorphBlendMesh.js +425 -0
  167. package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMap.js +370 -0
  168. package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMapGPU.js +322 -0
  169. package/Resources/WebLibraries/three/addons/misc/RollerCoaster.js +661 -0
  170. package/Resources/WebLibraries/three/addons/misc/Sculptor.js +1679 -0
  171. package/Resources/WebLibraries/three/addons/misc/SculptorMesh.js +1504 -0
  172. package/Resources/WebLibraries/three/addons/misc/SculptorTools.js +1180 -0
  173. package/Resources/WebLibraries/three/addons/misc/SculptorUtils.js +345 -0
  174. package/Resources/WebLibraries/three/addons/misc/TileCreasedNormalsPlugin.js +270 -0
  175. package/Resources/WebLibraries/three/addons/misc/TubePainter.js +611 -0
  176. package/Resources/WebLibraries/three/addons/misc/Volume.js +520 -0
  177. package/Resources/WebLibraries/three/addons/misc/VolumeSlice.js +275 -0
  178. package/Resources/WebLibraries/three/addons/modifiers/CurveModifier.js +374 -0
  179. package/Resources/WebLibraries/three/addons/modifiers/CurveModifierGPU.js +256 -0
  180. package/Resources/WebLibraries/three/addons/modifiers/EdgeSplitModifier.js +299 -0
  181. package/Resources/WebLibraries/three/addons/modifiers/TessellateModifier.js +340 -0
  182. package/Resources/WebLibraries/three/addons/objects/GroundedSkybox.js +69 -0
  183. package/Resources/WebLibraries/three/addons/objects/Lensflare.js +489 -0
  184. package/Resources/WebLibraries/three/addons/objects/LensflareMesh.js +376 -0
  185. package/Resources/WebLibraries/three/addons/objects/MarchingCubes.js +1261 -0
  186. package/Resources/WebLibraries/three/addons/objects/Reflector.js +390 -0
  187. package/Resources/WebLibraries/three/addons/objects/ReflectorForSSRPass.js +393 -0
  188. package/Resources/WebLibraries/three/addons/objects/Refractor.js +389 -0
  189. package/Resources/WebLibraries/three/addons/objects/ShadowMesh.js +130 -0
  190. package/Resources/WebLibraries/three/addons/objects/Sky.js +342 -0
  191. package/Resources/WebLibraries/three/addons/objects/SkyMesh.js +403 -0
  192. package/Resources/WebLibraries/three/addons/objects/Water.js +374 -0
  193. package/Resources/WebLibraries/three/addons/objects/Water2.js +403 -0
  194. package/Resources/WebLibraries/three/addons/objects/Water2Mesh.js +199 -0
  195. package/Resources/WebLibraries/three/addons/objects/WaterMesh.js +194 -0
  196. package/Resources/WebLibraries/three/addons/postprocessing/AfterimagePass.js +187 -0
  197. package/Resources/WebLibraries/three/addons/postprocessing/BloomPass.js +274 -0
  198. package/Resources/WebLibraries/three/addons/postprocessing/BokehPass.js +218 -0
  199. package/Resources/WebLibraries/three/addons/postprocessing/ClearPass.js +97 -0
  200. package/Resources/WebLibraries/three/addons/postprocessing/CubeTexturePass.js +146 -0
  201. package/Resources/WebLibraries/three/addons/postprocessing/DotScreenPass.js +114 -0
  202. package/Resources/WebLibraries/three/addons/postprocessing/EffectComposer.js +365 -0
  203. package/Resources/WebLibraries/three/addons/postprocessing/FXAAPass.js +40 -0
  204. package/Resources/WebLibraries/three/addons/postprocessing/FilmPass.js +113 -0
  205. package/Resources/WebLibraries/three/addons/postprocessing/GTAOPass.js +735 -0
  206. package/Resources/WebLibraries/three/addons/postprocessing/GlitchPass.js +177 -0
  207. package/Resources/WebLibraries/three/addons/postprocessing/HalftonePass.js +134 -0
  208. package/Resources/WebLibraries/three/addons/postprocessing/LUTPass.js +138 -0
  209. package/Resources/WebLibraries/three/addons/postprocessing/MaskPass.js +195 -0
  210. package/Resources/WebLibraries/three/addons/postprocessing/OutlinePass.js +776 -0
  211. package/Resources/WebLibraries/three/addons/postprocessing/OutputPass.js +148 -0
  212. package/Resources/WebLibraries/three/addons/postprocessing/Pass.js +191 -0
  213. package/Resources/WebLibraries/three/addons/postprocessing/RenderPass.js +193 -0
  214. package/Resources/WebLibraries/three/addons/postprocessing/RenderPixelatedPass.js +314 -0
  215. package/Resources/WebLibraries/three/addons/postprocessing/RenderTransitionPass.js +267 -0
  216. package/Resources/WebLibraries/three/addons/postprocessing/SAOPass.js +407 -0
  217. package/Resources/WebLibraries/three/addons/postprocessing/SMAAPass.js +230 -0
  218. package/Resources/WebLibraries/three/addons/postprocessing/SSAARenderPass.js +313 -0
  219. package/Resources/WebLibraries/three/addons/postprocessing/SSAOPass.js +527 -0
  220. package/Resources/WebLibraries/three/addons/postprocessing/SSRPass.js +858 -0
  221. package/Resources/WebLibraries/three/addons/postprocessing/SavePass.js +132 -0
  222. package/Resources/WebLibraries/three/addons/postprocessing/ShaderPass.js +135 -0
  223. package/Resources/WebLibraries/three/addons/postprocessing/TAARenderPass.js +243 -0
  224. package/Resources/WebLibraries/three/addons/postprocessing/TexturePass.js +131 -0
  225. package/Resources/WebLibraries/three/addons/postprocessing/UnrealBloomPass.js +541 -0
  226. package/Resources/WebLibraries/three/addons/shaders/ACESFilmicToneMappingShader.js +96 -0
  227. package/Resources/WebLibraries/three/addons/shaders/AfterimageShader.js +63 -0
  228. package/Resources/WebLibraries/three/addons/shaders/BasicShader.js +36 -0
  229. package/Resources/WebLibraries/three/addons/shaders/BleachBypassShader.js +68 -0
  230. package/Resources/WebLibraries/three/addons/shaders/BlendShader.js +56 -0
  231. package/Resources/WebLibraries/three/addons/shaders/BokehShader.js +151 -0
  232. package/Resources/WebLibraries/three/addons/shaders/BokehShader2.js +404 -0
  233. package/Resources/WebLibraries/three/addons/shaders/BrightnessContrastShader.js +62 -0
  234. package/Resources/WebLibraries/three/addons/shaders/ColorCorrectionShader.js +59 -0
  235. package/Resources/WebLibraries/three/addons/shaders/ColorifyShader.js +57 -0
  236. package/Resources/WebLibraries/three/addons/shaders/ConvolutionShader.js +74 -0
  237. package/Resources/WebLibraries/three/addons/shaders/CopyShader.js +52 -0
  238. package/Resources/WebLibraries/three/addons/shaders/DOFMipMapShader.js +63 -0
  239. package/Resources/WebLibraries/three/addons/shaders/DepthLimitedBlurShader.js +183 -0
  240. package/Resources/WebLibraries/three/addons/shaders/DigitalGlitch.js +104 -0
  241. package/Resources/WebLibraries/three/addons/shaders/DotScreenShader.js +75 -0
  242. package/Resources/WebLibraries/three/addons/shaders/ExposureShader.js +51 -0
  243. package/Resources/WebLibraries/three/addons/shaders/FXAAShader.js +298 -0
  244. package/Resources/WebLibraries/three/addons/shaders/FilmShader.js +73 -0
  245. package/Resources/WebLibraries/three/addons/shaders/FocusShader.js +94 -0
  246. package/Resources/WebLibraries/three/addons/shaders/FreiChenShader.js +103 -0
  247. package/Resources/WebLibraries/three/addons/shaders/GTAOShader.js +437 -0
  248. package/Resources/WebLibraries/three/addons/shaders/GammaCorrectionShader.js +52 -0
  249. package/Resources/WebLibraries/three/addons/shaders/HalftoneShader.js +332 -0
  250. package/Resources/WebLibraries/three/addons/shaders/HorizontalBlurShader.js +68 -0
  251. package/Resources/WebLibraries/three/addons/shaders/HorizontalTiltShiftShader.js +70 -0
  252. package/Resources/WebLibraries/three/addons/shaders/HueSaturationShader.js +74 -0
  253. package/Resources/WebLibraries/three/addons/shaders/KaleidoShader.js +65 -0
  254. package/Resources/WebLibraries/three/addons/shaders/LuminosityHighPassShader.js +68 -0
  255. package/Resources/WebLibraries/three/addons/shaders/LuminosityShader.js +54 -0
  256. package/Resources/WebLibraries/three/addons/shaders/MirrorShader.js +62 -0
  257. package/Resources/WebLibraries/three/addons/shaders/NormalMapShader.js +60 -0
  258. package/Resources/WebLibraries/three/addons/shaders/OutputShader.js +103 -0
  259. package/Resources/WebLibraries/three/addons/shaders/PoissonDenoiseShader.js +230 -0
  260. package/Resources/WebLibraries/three/addons/shaders/RGBShiftShader.js +61 -0
  261. package/Resources/WebLibraries/three/addons/shaders/SAOShader.js +201 -0
  262. package/Resources/WebLibraries/three/addons/shaders/SMAAShader.js +489 -0
  263. package/Resources/WebLibraries/three/addons/shaders/SSAOShader.js +331 -0
  264. package/Resources/WebLibraries/three/addons/shaders/SSRShader.js +396 -0
  265. package/Resources/WebLibraries/three/addons/shaders/SepiaShader.js +57 -0
  266. package/Resources/WebLibraries/three/addons/shaders/SobelOperatorShader.js +98 -0
  267. package/Resources/WebLibraries/three/addons/shaders/SubsurfaceScatteringShader.js +95 -0
  268. package/Resources/WebLibraries/three/addons/shaders/TechnicolorShader.js +51 -0
  269. package/Resources/WebLibraries/three/addons/shaders/ToonShader.js +349 -0
  270. package/Resources/WebLibraries/three/addons/shaders/TriangleBlurShader.js +79 -0
  271. package/Resources/WebLibraries/three/addons/shaders/UnpackDepthRGBAShader.js +60 -0
  272. package/Resources/WebLibraries/three/addons/shaders/VelocityShader.js +137 -0
  273. package/Resources/WebLibraries/three/addons/shaders/VerticalBlurShader.js +66 -0
  274. package/Resources/WebLibraries/three/addons/shaders/VerticalTiltShiftShader.js +70 -0
  275. package/Resources/WebLibraries/three/addons/shaders/VignetteShader.js +56 -0
  276. package/Resources/WebLibraries/three/addons/shaders/VolumeShader.js +285 -0
  277. package/Resources/WebLibraries/three/addons/shaders/WaterRefractionShader.js +106 -0
  278. package/Resources/WebLibraries/three/addons/textures/FlakesTexture.js +54 -0
  279. package/Resources/WebLibraries/three/addons/tsl/WebGLNodesHandler.js +607 -0
  280. package/Resources/WebLibraries/three/addons/tsl/display/AfterImageNode.js +256 -0
  281. package/Resources/WebLibraries/three/addons/tsl/display/AnaglyphPassNode.js +550 -0
  282. package/Resources/WebLibraries/three/addons/tsl/display/BilateralBlurNode.js +377 -0
  283. package/Resources/WebLibraries/three/addons/tsl/display/BleachBypass.js +33 -0
  284. package/Resources/WebLibraries/three/addons/tsl/display/BloomNode.js +599 -0
  285. package/Resources/WebLibraries/three/addons/tsl/display/CRT.js +150 -0
  286. package/Resources/WebLibraries/three/addons/tsl/display/ChromaticAberrationNode.js +163 -0
  287. package/Resources/WebLibraries/three/addons/tsl/display/DenoiseNode.js +354 -0
  288. package/Resources/WebLibraries/three/addons/tsl/display/DepthOfFieldNode.js +572 -0
  289. package/Resources/WebLibraries/three/addons/tsl/display/DotScreenNode.js +104 -0
  290. package/Resources/WebLibraries/three/addons/tsl/display/FSR1Node.js +481 -0
  291. package/Resources/WebLibraries/three/addons/tsl/display/FXAANode.js +357 -0
  292. package/Resources/WebLibraries/three/addons/tsl/display/FilmNode.js +101 -0
  293. package/Resources/WebLibraries/three/addons/tsl/display/GTAONode.js +727 -0
  294. package/Resources/WebLibraries/three/addons/tsl/display/GaussianBlurNode.js +412 -0
  295. package/Resources/WebLibraries/three/addons/tsl/display/GodraysNode.js +604 -0
  296. package/Resources/WebLibraries/three/addons/tsl/display/ImportanceSampledEnvironment.js +564 -0
  297. package/Resources/WebLibraries/three/addons/tsl/display/LensflareNode.js +282 -0
  298. package/Resources/WebLibraries/three/addons/tsl/display/Lut3DNode.js +109 -0
  299. package/Resources/WebLibraries/three/addons/tsl/display/MotionBlur.js +33 -0
  300. package/Resources/WebLibraries/three/addons/tsl/display/OITPassNode.js +341 -0
  301. package/Resources/WebLibraries/three/addons/tsl/display/OutlineNode.js +916 -0
  302. package/Resources/WebLibraries/three/addons/tsl/display/ParallaxBarrierPassNode.js +90 -0
  303. package/Resources/WebLibraries/three/addons/tsl/display/PixelationPassNode.js +227 -0
  304. package/Resources/WebLibraries/three/addons/tsl/display/RGBShiftNode.js +96 -0
  305. package/Resources/WebLibraries/three/addons/tsl/display/RecurrentDenoiseNode.js +812 -0
  306. package/Resources/WebLibraries/three/addons/tsl/display/RetroPassNode.js +263 -0
  307. package/Resources/WebLibraries/three/addons/tsl/display/SMAANode.js +729 -0
  308. package/Resources/WebLibraries/three/addons/tsl/display/SSAAPassNode.js +344 -0
  309. package/Resources/WebLibraries/three/addons/tsl/display/SSAONode.js +422 -0
  310. package/Resources/WebLibraries/three/addons/tsl/display/SSGINode.js +698 -0
  311. package/Resources/WebLibraries/three/addons/tsl/display/SSRNode.js +1430 -0
  312. package/Resources/WebLibraries/three/addons/tsl/display/SSSNode.js +479 -0
  313. package/Resources/WebLibraries/three/addons/tsl/display/Sepia.js +24 -0
  314. package/Resources/WebLibraries/three/addons/tsl/display/Shape.js +29 -0
  315. package/Resources/WebLibraries/three/addons/tsl/display/SharpenNode.js +284 -0
  316. package/Resources/WebLibraries/three/addons/tsl/display/SobelOperatorNode.js +168 -0
  317. package/Resources/WebLibraries/three/addons/tsl/display/StereoCompositePassNode.js +190 -0
  318. package/Resources/WebLibraries/three/addons/tsl/display/StereoPassNode.js +117 -0
  319. package/Resources/WebLibraries/three/addons/tsl/display/TAAUNode.js +700 -0
  320. package/Resources/WebLibraries/three/addons/tsl/display/TRAANode.js +609 -0
  321. package/Resources/WebLibraries/three/addons/tsl/display/TemporalReprojectNode.js +1024 -0
  322. package/Resources/WebLibraries/three/addons/tsl/display/TransitionNode.js +141 -0
  323. package/Resources/WebLibraries/three/addons/tsl/display/boxBlur.js +65 -0
  324. package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlend.js +80 -0
  325. package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlur.js +63 -0
  326. package/Resources/WebLibraries/three/addons/tsl/display/hashBlur.js +55 -0
  327. package/Resources/WebLibraries/three/addons/tsl/display/lensDirt.js +30 -0
  328. package/Resources/WebLibraries/three/addons/tsl/display/radialBlur.js +68 -0
  329. package/Resources/WebLibraries/three/addons/tsl/lighting/ClusteredLightsNode.js +994 -0
  330. package/Resources/WebLibraries/three/addons/tsl/lighting/DynamicLightsNode.js +201 -0
  331. package/Resources/WebLibraries/three/addons/tsl/lighting/LightProbeGridNode.js +183 -0
  332. package/Resources/WebLibraries/three/addons/tsl/lighting/data/AmbientLightDataNode.js +67 -0
  333. package/Resources/WebLibraries/three/addons/tsl/lighting/data/DirectionalLightDataNode.js +117 -0
  334. package/Resources/WebLibraries/three/addons/tsl/lighting/data/HemisphereLightDataNode.js +105 -0
  335. package/Resources/WebLibraries/three/addons/tsl/lighting/data/PointLightDataNode.js +140 -0
  336. package/Resources/WebLibraries/three/addons/tsl/lighting/data/SpotLightDataNode.js +167 -0
  337. package/Resources/WebLibraries/three/addons/tsl/math/Bayer.js +84 -0
  338. package/Resources/WebLibraries/three/addons/tsl/math/curlNoise.js +107 -0
  339. package/Resources/WebLibraries/three/addons/tsl/math/voronoiNoise.js +112 -0
  340. package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNode.js +542 -0
  341. package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNodeHelper.js +212 -0
  342. package/Resources/WebLibraries/three/addons/tsl/utils/GroundedSkybox.js +62 -0
  343. package/Resources/WebLibraries/three/addons/tsl/utils/RNoise.js +51 -0
  344. package/Resources/WebLibraries/three/addons/tsl/utils/Raymarching.js +70 -0
  345. package/Resources/WebLibraries/three/addons/tsl/utils/SoftParticles.js +53 -0
  346. package/Resources/WebLibraries/three/addons/tsl/utils/SpecularHelpers.js +302 -0
  347. package/Resources/WebLibraries/three/addons/tsl/utils/TAAUtils.js +184 -0
  348. package/Resources/WebLibraries/three/addons/utils/BufferGeometryUtils.js +1523 -0
  349. package/Resources/WebLibraries/three/addons/utils/CameraUtils.js +82 -0
  350. package/Resources/WebLibraries/three/addons/utils/ColorUtils.js +76 -0
  351. package/Resources/WebLibraries/three/addons/utils/GaussianSplatUtils.js +229 -0
  352. package/Resources/WebLibraries/three/addons/utils/GeometryCompressionUtils.js +547 -0
  353. package/Resources/WebLibraries/three/addons/utils/GeometryUtils.js +226 -0
  354. package/Resources/WebLibraries/three/addons/utils/LDrawUtils.js +211 -0
  355. package/Resources/WebLibraries/three/addons/utils/SceneOptimizer.js +459 -0
  356. package/Resources/WebLibraries/three/addons/utils/SceneUtils.js +363 -0
  357. package/Resources/WebLibraries/three/addons/utils/ShadowMapViewer.js +244 -0
  358. package/Resources/WebLibraries/three/addons/utils/ShadowMapViewerGPU.js +233 -0
  359. package/Resources/WebLibraries/three/addons/utils/SkeletonUtils.js +496 -0
  360. package/Resources/WebLibraries/three/addons/utils/SortUtils.js +175 -0
  361. package/Resources/WebLibraries/three/addons/utils/UVsDebug.js +173 -0
  362. package/Resources/WebLibraries/three/addons/utils/WebGLTextureUtils.js +115 -0
  363. package/Resources/WebLibraries/three/addons/utils/WebGPUTextureUtils.js +81 -0
  364. package/Resources/WebLibraries/three/addons/utils/WorkerPool.js +167 -0
  365. package/Resources/WebLibraries/three/addons/utils/unwrapUVs.js +865 -0
  366. package/Resources/WebLibraries/three/three.core.js +61162 -0
  367. package/Resources/WebLibraries/three/three.module.js +19537 -0
  368. package/Resources/WebLibraries/three/three.tsl.js +41903 -0
  369. package/Resources/WebLibraries/three/three.webgpu.js +53714 -0
  370. package/Resources/share-rules.json +64 -0
  371. package/Resources/skills/2d-game/SKILL.md +305 -0
  372. package/Resources/skills/3d-game/SKILL.md +274 -0
  373. package/Resources/skills/accounts/SKILL.md +41 -0
  374. package/Resources/skills/android-design/SKILL.md +159 -0
  375. package/Resources/skills/app-ai/SKILL.md +154 -0
  376. package/Resources/skills/app-intents/SKILL.md +31 -0
  377. package/Resources/skills/app-kinds/SKILL.md +122 -0
  378. package/Resources/skills/apple-watch/SKILL.md +43 -0
  379. package/Resources/skills/assets/SKILL.md +183 -0
  380. package/Resources/skills/building-blocks/SKILL.md +97 -0
  381. package/Resources/skills/device-control/SKILL.md +56 -0
  382. package/Resources/skills/device-integrations/SKILL.md +278 -0
  383. package/Resources/skills/finder-files/SKILL.md +19 -0
  384. package/Resources/skills/game/SKILL.md +416 -0
  385. package/Resources/skills/game-3d/SKILL.md +12 -0
  386. package/Resources/skills/game-art/SKILL.md +11 -0
  387. package/Resources/skills/game-feel/SKILL.md +13 -0
  388. package/Resources/skills/game-levels/SKILL.md +12 -0
  389. package/Resources/skills/game-live/SKILL.md +12 -0
  390. package/Resources/skills/game-physics/SKILL.md +13 -0
  391. package/Resources/skills/game-playtest/SKILL.md +26 -0
  392. package/Resources/skills/game-ui/SKILL.md +278 -0
  393. package/Resources/skills/github-projects/SKILL.md +62 -0
  394. package/Resources/skills/icloud-sync/SKILL.md +30 -0
  395. package/Resources/skills/iphone-hardware/SKILL.md +57 -0
  396. package/Resources/skills/iphone-widgets/SKILL.md +53 -0
  397. package/Resources/skills/lg-developer-mode/SKILL.md +152 -0
  398. package/Resources/skills/mac-widgets/SKILL.md +60 -0
  399. package/Resources/skills/maps/SKILL.md +8 -0
  400. package/Resources/skills/menu-bar/SKILL.md +36 -0
  401. package/Resources/skills/more-apps/SKILL.md +53 -0
  402. package/Resources/skills/phone-controllers/SKILL.md +79 -0
  403. package/Resources/skills/phone-pages/SKILL.md +173 -0
  404. package/Resources/skills/roku-developer-mode/SKILL.md +106 -0
  405. package/Resources/skills/scene-3d/SKILL.md +27 -0
  406. package/Resources/skills/scripting/SKILL.md +17 -0
  407. package/Resources/skills/share-extension/SKILL.md +22 -0
  408. package/Resources/skills/tv-design/SKILL.md +72 -0
  409. package/Resources/skills/web-app/SKILL.md +170 -0
  410. package/Resources/skills/web-game/SKILL.md +95 -0
  411. package/Resources/skills/web-or-native/SKILL.md +108 -0
  412. package/Resources/skills/wish-links/SKILL.md +156 -0
  413. package/THIRD_PARTY_NOTICES.md +170 -0
  414. package/cli/ai.mjs +237 -0
  415. package/cli/android.mjs +941 -0
  416. package/cli/app-ai.mjs +473 -0
  417. package/cli/browser.mjs +284 -0
  418. package/cli/calls.mjs +594 -0
  419. package/cli/dev.mjs +820 -0
  420. package/cli/devices.mjs +984 -0
  421. package/cli/lamp.mjs +1326 -0
  422. package/cli/lampapp.mjs +332 -0
  423. package/cli/lamps/android-host/LampPageGateway.kt +345 -0
  424. package/cli/lamps/android-host/app/build.gradle.kts +40 -0
  425. package/cli/lamps/android-host/build.gradle.kts +4 -0
  426. package/cli/lamps/android-host/game/AndroidManifest.xml +33 -0
  427. package/cli/lamps/android-host/game/MainActivity.kt +173 -0
  428. package/cli/lamps/android-host/gradle/libs.versions.toml +39 -0
  429. package/cli/lamps/android-host/gradle.properties +8 -0
  430. package/cli/lamps/android-host/page/AndroidManifest.xml +35 -0
  431. package/cli/lamps/android-host/page/MainActivity.kt +193 -0
  432. package/cli/lamps/android-host/settings.gradle.kts +30 -0
  433. package/cli/lamps/android-host/tools.json +43 -0
  434. package/cli/lamps/lamp-controllers.js +2 -0
  435. package/cli/lamps/lamp-for-games.js +6922 -0
  436. package/cli/lamps/lamp.js +7082 -0
  437. package/cli/lamps/mac-starter/.lamp/meta.json +1 -0
  438. package/cli/lamps/mac-starter/APP.md +12 -0
  439. package/cli/lamps/mac-starter/Package.swift +8 -0
  440. package/cli/lamps/mac-starter/Sources/App.swift +11 -0
  441. package/cli/lamps/mac-starter/Sources/ContentView.swift +17 -0
  442. package/cli/lamps/mac-starter/Sources/LampSupport.swift +18215 -0
  443. package/cli/lamps/mac-starter/build.sh +336 -0
  444. package/cli/lamps/mac-starter/gitignore +3 -0
  445. package/cli/lamps/mac-starter/lamp-starter.json +6 -0
  446. package/cli/lamps/web-starter/APP.md +8 -0
  447. package/cli/lamps/web-starter/app.js +1 -0
  448. package/cli/lamps/web-starter/index.html +17 -0
  449. package/cli/lamps/web-starter/lamp-starter.json +5 -0
  450. package/cli/lamps/web-starter/lamp.js +7082 -0
  451. package/cli/lamps/web-starter/style.css +25 -0
  452. package/cli/link.mjs +202 -0
  453. package/cli/local-ai.mjs +193 -0
  454. package/cli/mac.mjs +621 -0
  455. package/cli/mdns.mjs +205 -0
  456. package/cli/msix.mjs +413 -0
  457. package/cli/network.mjs +209 -0
  458. package/cli/openrouter.mjs +133 -0
  459. package/cli/outside.mjs +203 -0
  460. package/cli/page.js +317 -0
  461. package/cli/place.mjs +298 -0
  462. package/cli/png.mjs +184 -0
  463. package/cli/share.mjs +270 -0
  464. package/cli/skill/SKILL.md +280 -0
  465. package/cli/skills.mjs +71 -0
  466. package/cli/state.mjs +176 -0
  467. package/cli/update.mjs +162 -0
  468. package/package.json +20 -4
  469. package/site/lib/quota.js +59 -0
  470. package/site/lib/shared-wishes.js +1727 -0
@@ -0,0 +1,984 @@
1
+ // The person's devices, for lamp's command line: finding them on the home network, setting one up, and running what
2
+ // its integration says an app goes on it by. An integration is data (Resources/Devices/*.json, the same files Lamp's
3
+ // app reads): how the device is found, its setup steps, and the requests that install, open, picture and remove an app.
4
+ // Nothing in one is code that runs: lamp makes each request itself, held to the guards here, as `DeviceHTTP`,
5
+ // `DeviceTLS` and `IntegrationRunner` hold them in Lamp's app.
6
+ //
7
+ // The guards: a request goes only to the address the device was found at, which must be on the home network and
8
+ // never this computer; no redirect is followed; over HTTPS the device's own certificate is kept the first time and a
9
+ // different one refused from then on, as SSH keeps a host's key; and what the person typed (a password) goes only to
10
+ // a device whose certificate is kept, and only in answer to the device's own request for it.
11
+
12
+ import { createHash, randomUUID, X509Certificate } from "node:crypto";
13
+ import dgram from "node:dgram";
14
+ import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
+ import net from "node:net";
16
+ import { homedir, networkInterfaces } from "node:os";
17
+ import { basename, dirname, join } from "node:path";
18
+ import tls from "node:tls";
19
+ import { fileURLToPath } from "node:url";
20
+ import { bonjour } from "./mdns.mjs";
21
+ import { Problem, RESOURCES } from "./state.mjs";
22
+
23
+ export { bonjour, servicesIn } from "./mdns.mjs";
24
+
25
+ const HERE = dirname(fileURLToPath(import.meta.url));
26
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
27
+
28
+ /** Where lamp keeps what it knows of the person's devices: outside every app's folder, read by the person alone. */
29
+ export const devicesHome = () => process.env.LAMP_DEVICES_HOME || join(homedir(), ".lamp", "devices");
30
+
31
+ // MARK: Integrations
32
+
33
+ /** The integrations Lamp ships, as its app reads them. */
34
+ export function integrations() {
35
+ const folder = join(RESOURCES, "Devices");
36
+ return readdirSync(folder).filter((name) => name.endsWith(".json")).sort().map((name) => JSON.parse(readFileSync(join(folder, name), "utf8")));
37
+ }
38
+
39
+ export const integrationOf = (id) => integrations().find((integration) => integration.id === id);
40
+
41
+ /** Whether lamp puts a page on this integration's devices: where it packages pages for the device's host, the Xbox's so far. */
42
+ export const takesPages = (integration) => integration?.app?.declared?.wrap === "msix" && integration.app.declared.host === "webview2";
43
+
44
+ // MARK: Addresses
45
+
46
+ /** This computer's own addresses, read each time: they change as it moves between networks. */
47
+ export const ownAddresses = () => new Set(Object.values(networkInterfaces()).flat().map((entry) => entry.address.split("%")[0].toLowerCase()));
48
+
49
+ /** A private or link-local address: where a device in the person's home is. */
50
+ export function onNetwork(address) {
51
+ if (net.isIPv4(address)) {
52
+ const [a, b] = address.split(".").map(Number);
53
+ return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
54
+ }
55
+ if (net.isIPv6(address)) {
56
+ const first = parseInt(address.split(":")[0] || "0", 16);
57
+ return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80;
58
+ }
59
+ return false;
60
+ }
61
+
62
+ /** On the home network and not this computer itself, by loopback or by any of its own addresses; never a name, which could be made to lead anywhere. */
63
+ export const isPrivate = (address) => onNetwork(address) && !ownAddresses().has(address.toLowerCase());
64
+
65
+ /** This computer's address on the device's network: the one that shares the most of its leading numbers. */
66
+ function nearest(address) {
67
+ const mine = Object.values(networkInterfaces()).flat().filter((entry) => entry.family === "IPv4" && !entry.internal).map((entry) => entry.address);
68
+ const shared = (other) => { const a = other.split("."), b = String(address ?? "").split("."); let n = 0; while (n < 4 && a[n] === b[n]) n++; return n; };
69
+ return mine.sort((a, b) => shared(b) - shared(a))[0];
70
+ }
71
+
72
+ // MARK: Looking
73
+
74
+ /** One SSDP answer's headers, as the device at `address` sent them. */
75
+ export function answerOf(text, address) {
76
+ const headers = {};
77
+ for (const line of text.split(/\r?\n/)) {
78
+ const colon = line.indexOf(":");
79
+ if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
80
+ }
81
+ return { address, server: headers.server ?? "", location: headers.location, target: headers.st ?? "", usn: headers.usn ?? "" };
82
+ }
83
+
84
+ /** One SSDP search of the home network, for a few seconds: who answered, and what each said of itself. */
85
+ export function ssdp(target = "ssdp:all", seconds = 3) {
86
+ return new Promise((done) => {
87
+ const socket = dgram.createSocket({ type: "udp4", reuseAddr: true }), answers = new Map();
88
+ const message = Buffer.from(`M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\nMX: 1\r\nST: ${target.replace(/[\r\n]/g, "")}\r\n\r\n`);
89
+ const ask = () => socket.send(message, 1900, "239.255.255.250", () => {});
90
+ socket.on("error", () => { try { socket.close(); } catch {} done([...answers.values()]); });
91
+ socket.on("message", (data, from) => {
92
+ const answer = answerOf(data.toString("utf8"), from.address);
93
+ answers.set(`${answer.address} ${answer.target} ${answer.usn}`, answer);
94
+ });
95
+ socket.bind(0, () => {
96
+ try { socket.setMulticastTTL(2); } catch {}
97
+ ask(); ask();
98
+ // Asked again partway through: a device that was asleep or busy misses the first search more often than not.
99
+ setTimeout(ask, (seconds * 1000) / 2);
100
+ setTimeout(() => { try { socket.close(); } catch {} done([...answers.values()]); }, seconds * 1000);
101
+ });
102
+ });
103
+ }
104
+
105
+ const sameWords = (a, b) => String(a).toLowerCase() === String(b).toLowerCase();
106
+ const holds = (text, part) => String(text ?? "").toLowerCase().includes(String(part).toLowerCase());
107
+
108
+ /**
109
+ * The addresses an integration's device answers at, by what its `find` says: the search's answers for its search
110
+ * target (with the server's words holding `contains`), a Bonjour service, or a page on the device holding words.
111
+ */
112
+ export async function addressesOf(integration, answers, { reach = isPrivate, timeout = 4000 } = {}) {
113
+ const addresses = new Set();
114
+ for (const find of integration.find ?? []) {
115
+ if (find.ssdp) {
116
+ for (const answer of answers) {
117
+ if ((find.ssdp === "ssdp:all" || sameWords(answer.target, find.ssdp)) && (!find.contains || holds(answer.server, find.contains))) addresses.add(answer.address);
118
+ }
119
+ }
120
+ if (find.bonjour) for (const found of await bonjour(find.bonjour)) if (!find.contains || holds(found.name, find.contains)) addresses.add(found.address);
121
+ if (find.http) {
122
+ const asked = [...new Set(answers.map((answer) => answer.address))].filter(reach);
123
+ const answered = await Promise.all(asked.map((address) => send({ port: find.http.port, path: find.http.path }, address, {}, { timeout, reach })
124
+ .then((answer) => answer.ok && answer.text.includes(find.http.contains), () => false)));
125
+ asked.forEach((address, index) => { if (answered[index]) addresses.add(address); });
126
+ }
127
+ }
128
+ return [...addresses].filter(reach).sort();
129
+ }
130
+
131
+ /**
132
+ * What the device at an address says of itself, read as its integration's `identity` says: its serial and model, and
133
+ * its model's number, name and system's version where it says them. Null when it doesn't answer as one.
134
+ */
135
+ export async function identify(integration, address, answers, { reach = isPrivate, timeout = 4000 } = {}) {
136
+ const got = new Map();
137
+ const read = async (how) => {
138
+ if (!how) return null;
139
+ let request = how.http;
140
+ if (how.description) {
141
+ // Where the device says its UPnP description is, in its answer to the integration's own search: only a
142
+ // description on the device itself, over plain HTTP.
143
+ request = null;
144
+ for (const find of integration.find ?? []) {
145
+ for (const answer of answers) {
146
+ if (request || answer.address !== address || !find.ssdp || !(find.ssdp === "ssdp:all" || sameWords(answer.target, find.ssdp)) || !answer.location) continue;
147
+ let url;
148
+ try { url = new URL(answer.location); } catch { continue; }
149
+ if (url.protocol === "http:" && url.hostname === address) request = { port: Number(url.port) || 80, path: (url.pathname || "/") + url.search };
150
+ }
151
+ }
152
+ }
153
+ if (!request) return null;
154
+ const key = JSON.stringify(request);
155
+ if (!got.has(key)) {
156
+ const answer = await send(request, address, {}, { timeout, reach }).catch(() => null);
157
+ if (answer?.ok) got.set(key, answer);
158
+ }
159
+ const answer = got.get(key), value = answer && (how.json ? jsonAt(answer.body, how.json) : how.xml ? xmlAt(answer.body, how.xml) : null)?.trim();
160
+ if (how.contains && !holds(value, how.contains)) return null;
161
+ return value || null;
162
+ };
163
+ const identity = integration.identity ?? {};
164
+ const serial = await read(identity.id), model = serial && (await read(identity.model));
165
+ if (!serial || !model) return null;
166
+ return { integration: integration.id, address, serial, model, modelID: await read(identity.modelID), name: await read(identity.name), osVersion: await read(identity.osVersion) };
167
+ }
168
+
169
+ // The kinds of device that answer every search and that nobody makes apps for: a router and its parts.
170
+ const PLUMBING = ["InternetGatewayDevice", "WANDevice", "WFADevice"];
171
+
172
+ /**
173
+ * A device that answered the search, read from the UPnP descriptions its answers point at, for one that no
174
+ * integration knows. A device often answers for several services, each with a description of its own, and the first
175
+ * to answer may say nothing of the device (a TV's streaming service answers a few bytes of "status=ok"), so each is
176
+ * tried until one names it. Null when none does, and for a router. `get` is how a description is read; tests answer
177
+ * with recorded ones.
178
+ */
179
+ export async function describe(address, answers, { reach = isPrivate, timeout = 4000, get = (request) => send(request, address, {}, { timeout, reach }) } = {}) {
180
+ const locations = [];
181
+ for (const answer of answers) {
182
+ let url;
183
+ try { url = new URL(answer.location); } catch { continue; }
184
+ if (url.hostname === address && !locations.includes(answer.location)) locations.push(answer.location);
185
+ }
186
+ for (const location of locations) {
187
+ const url = new URL(location);
188
+ if (url.protocol !== "http:") continue;
189
+ const answer = await get({ port: Number(url.port) || 80, path: (url.pathname || "/") + url.search }).catch(() => null);
190
+ if (!answer?.ok) continue;
191
+ const said = (path) => xmlAt(answer.body, path) || null;
192
+ const name = said("friendlyName"), maker = said("manufacturer") ?? "", model = said("modelName") ?? said("modelDescription") ?? "";
193
+ if (PLUMBING.some((kind) => (said("deviceType") ?? "").includes(kind))) return null;
194
+ if (!name || (!maker && !model)) continue;
195
+ return { address, name, maker, model: model || maker, modelID: said("modelNumber") ?? "", discovery: answers.map((each) => `${each.target} (${each.server})`).join("; ") };
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * One look at the home network for the integrations given: a search for everything (or the `answers` of one just
202
+ * made), then each integration's devices found and read, all at once (where two find one address, the first given
203
+ * has it), and with `unknown` the devices no integration knows, each read from its own description.
204
+ */
205
+ export async function searchNetwork({ integrations: list = integrations(), seconds = 2.5, unknown = false, reach = isPrivate, answers } = {}) {
206
+ answers ??= await ssdp("ssdp:all", seconds);
207
+ const each = await Promise.all(list.map(async (integration) => {
208
+ const read = [];
209
+ for (const address of await addressesOf(integration, answers, { reach })) {
210
+ const device = await identify(integration, address, answers, { reach });
211
+ if (device) read.push(device);
212
+ }
213
+ return read;
214
+ }));
215
+ const found = [];
216
+ for (const read of each) for (const device of read) if (!found.some((other) => other.address === device.address)) found.push(device);
217
+ const others = new Map();
218
+ if (unknown) {
219
+ const taken = new Set(found.map((device) => device.address));
220
+ for (const answer of answers) if (!taken.has(answer.address) && reach(answer.address)) others.set(answer.address, [...(others.get(answer.address) ?? []), answer]);
221
+ }
222
+ const described = (await Promise.all([...others].map(([address, said]) => describe(address, said, { reach })))).filter(Boolean);
223
+ return { answers, found, unknown: described.sort((a, b) => a.name.localeCompare(b.name)) };
224
+ }
225
+
226
+ /**
227
+ * The devices the integrations find on the network now, each read from its own answers as its integration says, with
228
+ * the ones lamp keeps that didn't answer after them (`found: false`, at the address they were last at).
229
+ */
230
+ export async function look({ seconds = 3, reach = isPrivate } = {}) {
231
+ const shipped = integrations(), { found } = await searchNetwork({ integrations: shipped, seconds, reach });
232
+ const devices = found.map((read) => {
233
+ const integration = shipped.find((each) => each.id === read.integration);
234
+ return { id: `${integration.id}:${read.serial}`, integration: integration.id, what: integration.kind, maker: integration.maker, model: read.model,
235
+ name: read.name ?? `${integration.maker} ${read.model}`, address: read.address, found: true };
236
+ });
237
+ for (const device of devices) {
238
+ const mine = kept(device.id);
239
+ device.kept = !!mine;
240
+ if (mine) keep({ ...mine, name: device.name, address: device.address });
241
+ if (mine?.model) device.model = mine.model;
242
+ }
243
+ for (const mine of allKept()) if (!devices.some((device) => device.id === mine.id)) devices.push({ ...mine, kept: true, found: false });
244
+ return devices;
245
+ }
246
+
247
+ // MARK: What's kept of a device
248
+
249
+ const folderOf = (id) => join(devicesHome(), createHash("sha256").update(id).digest("hex").slice(0, 16));
250
+ function readJSON(file) { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } }
251
+ function writeKept(id, name, value) {
252
+ mkdirSync(folderOf(id), { recursive: true, mode: 0o700 });
253
+ const file = join(folderOf(id), name);
254
+ writeFileSync(file, JSON.stringify(value, null, 2), { mode: 0o600 });
255
+ chmodSync(file, 0o600);
256
+ }
257
+
258
+ /** A device lamp set up: its record, kept once a setup step has passed on it. */
259
+ export const kept = (id) => readJSON(join(folderOf(id), "device.json"));
260
+ export const keep = (device) => writeKept(device.id, "device.json", { id: device.id, integration: device.integration, name: device.name, model: device.model, address: device.address, passed: device.passed ?? [], reads: device.reads ?? {} });
261
+ export function allKept() {
262
+ let names = [];
263
+ try { names = readdirSync(devicesHome()); } catch {}
264
+ return names.map((name) => readJSON(join(devicesHome(), name, "device.json"))).filter(Boolean).sort((a, b) => a.name.localeCompare(b.name));
265
+ }
266
+
267
+ /** Lets a device go: its record, its certificates and what was typed for it. */
268
+ export const forget = (id) => rmSync(folderOf(id), { recursive: true, force: true });
269
+
270
+ /** What the person typed for a device's setup (a password), kept beside its record and read by them alone. */
271
+ export const fields = {
272
+ read: (id) => readJSON(join(folderOf(id), "fields.json")) ?? {},
273
+ save(id, key, value) { writeKept(id, "fields.json", { ...fields.read(id), [key]: value }); },
274
+ remove(id, key) { const all = fields.read(id); delete all[key]; writeKept(id, "fields.json", all); },
275
+ };
276
+
277
+ // MARK: HTTPS
278
+
279
+ const fingerprintOf = (certificate) => createHash("sha256").update(certificate).digest("hex");
280
+
281
+ const CHANGED = "this isn't the device lamp set up at that address: its certificate has changed. If you reset it or turned its developer mode on again, forget it (lamp devices forget) and add it again.";
282
+ const UNSAVED = "lamp couldn't keep this device's certificate on this computer, so it sent nothing to it";
283
+
284
+ /** Whether `root` signed `leaf`: the leaf checked against that root alone. */
285
+ function signs(root, leaf) {
286
+ try {
287
+ const signer = new X509Certificate(root), signed = new X509Certificate(leaf);
288
+ return signed.checkIssued(signer) && signed.verify(signer.publicKey);
289
+ } catch { return false; }
290
+ }
291
+
292
+ /** A certificate as a device serves it, PEM or DER, as DER; null when it isn't one. */
293
+ function certificateFrom(bytes) {
294
+ const text = bytes.toString("latin1");
295
+ let certificate = bytes;
296
+ if (text.includes("-----BEGIN CERTIFICATE-----")) {
297
+ const body = text.split("-----BEGIN CERTIFICATE-----")[1]?.split("-----END CERTIFICATE-----")[0] ?? "";
298
+ certificate = Buffer.from(body.replace(/\s+/g, ""), "base64");
299
+ }
300
+ try { new X509Certificate(certificate); return certificate; } catch { return null; }
301
+ }
302
+
303
+ /**
304
+ * One device's kept certificates: the one it showed first, by port, and, for a device that makes itself a new one
305
+ * from a root of its own (an Xbox's Device Portal does, whenever it restarts), that root, under "root:<port>". A
306
+ * certificate other than the kept one is taken only when the kept root signed it, and is kept in its place.
307
+ */
308
+ export const pinsOf = (id) => pinsAt(join(folderOf(id), "certificates.json"));
309
+
310
+ /** The same, kept in a file the caller names: Lamp's app keeps each device's in its own folder for the device. */
311
+ export function pinsAt(file) {
312
+ // A store that's there but isn't one (not JSON, not names with words for values) is never read as empty.
313
+ const read = () => {
314
+ if (!existsSync(file)) return {};
315
+ const pins = readJSON(file);
316
+ return pins && typeof pins === "object" && !Array.isArray(pins) && Object.values(pins).every((value) => typeof value === "string") ? pins : null;
317
+ };
318
+ // Written whole or not at all, for the person alone to read.
319
+ const write = (pins) => {
320
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
321
+ const beside = `${file}.writing`;
322
+ writeFileSync(beside, JSON.stringify(pins, null, 2), { mode: 0o600 });
323
+ renameSync(beside, file);
324
+ };
325
+ return {
326
+ /** Why a certificate isn't taken, or null when it is: it's the one kept, or the first, kept now. It fails closed. */
327
+ admit(fingerprint, port, certificate) {
328
+ const pins = read();
329
+ if (!pins) return UNSAVED;
330
+ if (pins[port] === fingerprint) return null;
331
+ if (pins[port]) {
332
+ const root = pins[`root:${port}`];
333
+ if (!certificate || !root || !signs(Buffer.from(root, "base64"), certificate)) return CHANGED;
334
+ }
335
+ try { write({ ...pins, [port]: fingerprint }); } catch { return UNSAVED; }
336
+ return null;
337
+ },
338
+ root: (port) => { const root = read()?.[`root:${port}`]; return root ? Buffer.from(root, "base64") : null; },
339
+ /** Keeps the device's root for a port, only when it signed the certificate kept there, so the root is the device's own. */
340
+ keepRoot(root, leaf, port) {
341
+ const pins = read();
342
+ if (!pins || pins[port] !== fingerprintOf(leaf) || pins[`root:${port}`] || !signs(root, leaf)) return false;
343
+ write({ ...pins, [`root:${port}`]: root.toString("base64") });
344
+ return true;
345
+ },
346
+ };
347
+ }
348
+
349
+ // MARK: Placeholders
350
+
351
+ // The values lamp puts into an integration's requests: `{name}`, `{field:<key>}`, `{cookie:<name>}`, `{base64:<name>}`.
352
+ // A word in braces and nothing else, so a brace that opens JSON is the template's own text.
353
+ const PLACEHOLDER = /\{([A-Za-z][A-Za-z0-9]*(?::[A-Za-z0-9-]+)?)\}/g;
354
+ const SAFE = /^[A-Za-z0-9._\-:/@+=,]*$/;
355
+
356
+ /** What's secret among the values: what the person typed for the device, and what a maker's sign-in gave for it. */
357
+ const isSecret = (name) => name.startsWith("field:") || name === "signIn";
358
+
359
+ /**
360
+ * The template with each placeholder filled, as `Placeholders` holds an integration to in Lamp's app. A value outside
361
+ * the safe alphabet is refused, except a secret, which only reaches places that are encoded or apart from the address
362
+ * (a sign-in, a form, a header, a body) and is never put in base64; Lamp's own public key, which has spaces; and text
363
+ * being typed on the device, which may be anything and so goes only in a form, a body, or a path, there encoded down
364
+ * to letters and digits. In a path every value is percent-encoded so it can't change the path.
365
+ */
366
+ export function fill(template, values, place) {
367
+ return template.replace(PLACEHOLDER, (whole, name) => {
368
+ const inner = name.startsWith("base64:") ? name.slice(7) : null;
369
+ if (inner !== null && isSecret(inner)) throw new Problem(`{${name}} must be base64 of a value that isn't secret`);
370
+ const value = inner !== null ? (values[inner] === undefined ? undefined : Buffer.from(values[inner]).toString("base64")) : values[name];
371
+ if (value === undefined) throw new Problem(`lamp has nothing to put in {${name}}`);
372
+ const secret = isSecret(name), text = name === "text";
373
+ if (secret && !["auth", "form", "header", "body"].includes(place)) throw new Problem(`{${name}} can't go in a ${place}: ${name === "signIn" ? "a sign-in" : "what the person types"} goes only in a sign-in, a form, a header or a body`);
374
+ if (name.startsWith("cookie:") && place !== "header") throw new Problem(`{${name}} goes only in a header`);
375
+ if (text && !["path", "form", "body"].includes(place)) throw new Problem("{text} goes only in a path, a form or a body");
376
+ if (!secret && !text && name !== "sshKey" && (!SAFE.test(value) || value.length > 512)) throw new Problem(`the value for {${name}} has characters lamp doesn't send to a device`);
377
+ if (text && place === "path") return [...Buffer.from(value)].map((byte) => (/[A-Za-z0-9]/.test(String.fromCharCode(byte)) ? String.fromCharCode(byte) : `%${byte.toString(16).toUpperCase().padStart(2, "0")}`)).join("");
378
+ return place === "path" ? encodeURIComponent(value).replace(/%2F/g, "/").replace(/%3A/g, ":").replace(/%40/g, "@").replace(/%2C/g, ",") : value;
379
+ });
380
+ }
381
+
382
+ const namesIn = (template) => [...String(template ?? "").matchAll(PLACEHOLDER)].map((match) => match[1]);
383
+
384
+ /** Whether a request carries a secret or a sign-in, which goes only where lamp knows the device. */
385
+ function carriesSecret(request) {
386
+ if (request.auth) return true;
387
+ return [request.path, request.body, ...Object.values(request.headers ?? {}), ...Object.values(request.form ?? {})].flatMap(namesIn).some(isSecret);
388
+ }
389
+
390
+ function pathProblem(path) {
391
+ if (!path.startsWith("/") || path.startsWith("//")) return "must start with one /";
392
+ if (path.includes("..")) return "can't hold ..";
393
+ if (path.includes("://") || path.includes("@") || path.includes("#") || path.includes("\\")) return "must be a path on the device, not an address";
394
+ if (/[\s\u0000-\u001f\u007f]/.test(path)) return "can't hold spaces";
395
+ return null;
396
+ }
397
+
398
+ // MARK: One request
399
+
400
+ // Answers are cut off here: a device's answers are small, and a picture is a few megabytes.
401
+ const ANSWER_LIMIT = 16 << 20;
402
+
403
+ /**
404
+ * Makes one request of an integration to a device, with lamp's values filled in. `session` remembers, for one
405
+ * sequence of requests, that the device has asked for the sign-in, so the requests after it carry it and a package
406
+ * isn't sent twice.
407
+ */
408
+ export async function send(request, address, values, { files = {}, pins, timeout = 30_000, session = {}, reach = isPrivate, stopped } = {}) {
409
+ const https = request.https === true;
410
+ if (https && !pins && carriesSecret(request)) throw new Problem("lamp sends a password over HTTPS only to a device whose certificate it keeps");
411
+ if (!reach(address)) throw new OffNetwork(`lamp only talks to devices on your own network, and ${address} isn't one`);
412
+ if (!Number.isInteger(request.port) || request.port < 1 || request.port > 65535) throw new Problem(`${request.port} isn't a port`);
413
+ const path = fill(request.path, values, "path"), problem = pathProblem(path.split("?")[0]);
414
+ if (problem) throw new Problem(`the path ${problem}`);
415
+ // lamp writes the request itself, so nothing in it may end a line or start another: the path whole, each header.
416
+ if (!/^[\x21-\x7e]+$/.test(path)) throw new Problem("the path can't hold spaces");
417
+ const method = (request.method ?? "GET").toUpperCase(), headers = {};
418
+ if (!/^[A-Z]{1,16}$/.test(method)) throw new Problem(`${method} isn't a way to ask a device`);
419
+ for (const [name, value] of Object.entries(request.headers ?? {})) {
420
+ if (name.toLowerCase() === "host") continue;
421
+ const filled = fill(value, values, "header");
422
+ if (!/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/.test(name) || /[\r\n\0]/.test(filled)) throw new Problem(`the header ${name} can't be sent as it is`);
423
+ headers[name] = filled;
424
+ }
425
+ let body = null;
426
+ if (request.body != null) body = Buffer.from(fill(request.body, values, "body"));
427
+ if (request.form) {
428
+ const boundary = "lamp-" + randomUUID(), parts = [];
429
+ for (const [name, value] of Object.entries(request.form).sort(([a], [b]) => (a < b ? -1 : 1))) {
430
+ parts.push(Buffer.from(`--${boundary}\r\n`));
431
+ if (value === "@{package}" || value === "@{certificate}") {
432
+ const file = files[value.slice(2, -1)];
433
+ if (!file || !existsSync(file)) throw new Problem(`there's no ${value.slice(2, -1)} to send yet`);
434
+ // A field named like a file ("app.msix") is sent under that name: some devices go by the file's name.
435
+ parts.push(Buffer.from(`Content-Disposition: form-data; name="${name}"; filename="${name.includes(".") ? name : basename(file)}"\r\nContent-Type: application/octet-stream\r\n\r\n`), readFileSync(file));
436
+ } else {
437
+ parts.push(Buffer.from(`Content-Disposition: form-data; name="${name}"\r\n\r\n`), Buffer.from(fill(value, values, "form")));
438
+ }
439
+ parts.push(Buffer.from("\r\n"));
440
+ }
441
+ parts.push(Buffer.from(`--${boundary}--\r\n`));
442
+ body = Buffer.concat(parts);
443
+ headers["Content-Type"] = `multipart/form-data; boundary=${boundary}`;
444
+ }
445
+ // Some devices refuse a POST that says nothing of its length (Device Portal answers 411), so an empty one says 0.
446
+ if (!body && ["POST", "PUT"].includes(method)) body = Buffer.alloc(0);
447
+ if (body) headers["Content-Length"] = String(body.length);
448
+
449
+ const once = async (authorization) => {
450
+ if (stopped?.aborted) throw new Problem("stopped");
451
+ const { socket, certificate } = https ? await connect(address, request.port, pins, timeout) : await plain(address, request.port, timeout);
452
+ // Stopped by whoever asked (`stopped`, an AbortSignal): the connection is let go, whatever was under way on it.
453
+ const stop = () => socket.destroy(new Problem("stopped"));
454
+ stopped?.addEventListener("abort", stop, { once: true });
455
+ const host = `${address.includes(":") ? `[${address}]` : address}:${request.port}`;
456
+ let answer;
457
+ try { answer = await exchange(socket, { method, path, host, headers: authorization ? { ...headers, Authorization: authorization } : headers, body, timeout, address }); }
458
+ finally { stopped?.removeEventListener("abort", stop); }
459
+ const cookies = {};
460
+ for (const cookie of answer.headers["set-cookie"] ?? []) {
461
+ const pair = cookie.split(";")[0], equals = pair.indexOf("=");
462
+ if (equals > 0) cookies[pair.slice(0, equals).trim()] = pair.slice(equals + 1).trim();
463
+ }
464
+ return { status: answer.status, ok: answer.status >= 200 && answer.status < 300, body: answer.body, text: answer.body.toString("utf8"), cookies, certificate,
465
+ asks: (answer.headers["www-authenticate"] ?? []).join(", ") };
466
+ };
467
+
468
+ // The sign-in goes only in answer to the device's own request for it, once, with the scheme the integration
469
+ // declared: a password to a device that asks for it plainly, and to one that asks for a digest never the password
470
+ // itself, only what the device's own challenge makes of it.
471
+ if (!request.auth) return once(null);
472
+ const user = request.auth.user, password = fill(request.auth.password, values, "auth"), door = `${address}:${request.port}`;
473
+ // A session that came back from Lamp's app as JSON holds its doors as a list.
474
+ if (!(session.asked instanceof Set)) session.asked = new Set(Array.isArray(session.asked) ? session.asked : []);
475
+ if (request.auth.digest) {
476
+ const first = await once(null), challenge = digestChallenge(first.asks);
477
+ return first.status === 401 && challenge ? once(digestAnswer(challenge, { user, password, method, path })) : first;
478
+ }
479
+ const basic = "Basic " + Buffer.from(`${user}:${password}`).toString("base64");
480
+ if (session.asked.has(door)) return once(basic);
481
+ const first = await once(null);
482
+ if (first.status !== 401 || !/(^|,)\s*basic\b/i.test(first.asks)) return first;
483
+ session.asked.add(door);
484
+ return once(basic);
485
+ }
486
+
487
+ // MARK: The request itself
488
+
489
+ /** A connection to the device with nothing over it. */
490
+ function plain(address, port, timeout) {
491
+ return new Promise((done, failed) => {
492
+ const socket = net.connect({ host: address, port, timeout });
493
+ socket.once("connect", () => done({ socket, certificate: null }));
494
+ socket.once("timeout", () => socket.destroy(timedOut()));
495
+ socket.once("error", (error) => failed(error instanceof Problem ? error : new Away(address, error)));
496
+ });
497
+ }
498
+
499
+ /** An answer's head: its status and its headers, each name in small letters with every value it was given. */
500
+ function headOf(text) {
501
+ const [first, ...lines] = text.split("\r\n"), status = Number(/^HTTP\/1\.[01] (\d{3})/.exec(first)?.[1]);
502
+ if (!status) return null;
503
+ const headers = {};
504
+ for (const line of lines) {
505
+ const colon = line.indexOf(":");
506
+ if (colon > 0) (headers[line.slice(0, colon).trim().toLowerCase()] ??= []).push(line.slice(colon + 1).trim());
507
+ }
508
+ return { status, headers };
509
+ }
510
+
511
+ /** A body sent in chunks, put back together; null while its last chunk hasn't come. */
512
+ function unchunked(bytes) {
513
+ const parts = [];
514
+ for (let at = 0; ;) {
515
+ const line = bytes.indexOf("\r\n", at);
516
+ if (line < 0) return null;
517
+ const size = parseInt(bytes.toString("latin1", at, line).split(";")[0].trim(), 16);
518
+ if (!Number.isInteger(size) || size < 0) throw new Problem("the device's answer couldn't be read");
519
+ if (size === 0) return Buffer.concat(parts);
520
+ if (bytes.length < line + 2 + size + 2) return null;
521
+ parts.push(bytes.subarray(line + 2, line + 2 + size));
522
+ at = line + 2 + size + 2;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * One request on a connection lamp opened, and its answer read to its end: HTTP/1.1 written out by hand, so the
528
+ * request goes down the very connection whose certificate was just checked, the same way on Node and on Bun (whose
529
+ * own client can't be handed a connection). No redirect is followed: an answer that names another place is the answer.
530
+ */
531
+ function exchange(socket, { method, path, host, headers, body, timeout, address }) {
532
+ return new Promise((done, failed) => {
533
+ let settled = false, head = null, kept = [], size = 0, pending = Buffer.alloc(0);
534
+ const finish = (error, answer) => {
535
+ if (settled) return;
536
+ settled = true;
537
+ socket.destroy();
538
+ if (error) failed(error instanceof Problem ? error : new Away(address, error)); else done(answer);
539
+ };
540
+ const tooBig = () => finish(new Problem("the device's answer was bigger than lamp takes"));
541
+ /** The answer, once all of it is here; `ended` says the device has closed its side. */
542
+ const read = (ended) => {
543
+ while (!head) {
544
+ const end = pending.indexOf("\r\n\r\n");
545
+ if (end < 0) { if (pending.length > 65536) tooBig(); else if (ended) finish(Object.assign(new Error("cut short"), { code: "ECONNRESET" })); return; }
546
+ const parsed = headOf(pending.toString("latin1", 0, end));
547
+ if (!parsed) return finish(new Problem("the device's answer couldn't be read"));
548
+ pending = pending.subarray(end + 4);
549
+ // A word to go on (100 Continue) isn't the answer: the answer follows it.
550
+ if (parsed.status >= 100 && parsed.status < 200 && parsed.status !== 101) continue;
551
+ head = parsed;
552
+ kept = [pending];
553
+ size = pending.length;
554
+ }
555
+ const none = method === "HEAD" || head.status === 204 || head.status === 304;
556
+ const chunked = (head.headers["transfer-encoding"] ?? []).join(",").toLowerCase().includes("chunked");
557
+ const length = Number(head.headers["content-length"]?.[0]);
558
+ if (size > ANSWER_LIMIT + 65536) return tooBig();
559
+ let bytes = null;
560
+ if (none) bytes = Buffer.alloc(0);
561
+ else if (chunked) { try { bytes = unchunked(Buffer.concat(kept)); } catch (error) { return finish(error); } }
562
+ else if (Number.isInteger(length) && length >= 0) { if (size >= length) bytes = Buffer.concat(kept).subarray(0, length); }
563
+ else if (ended) bytes = Buffer.concat(kept);
564
+ if (bytes && bytes.length > ANSWER_LIMIT) return tooBig();
565
+ if (bytes) return finish(null, { status: head.status, headers: head.headers, body: bytes });
566
+ if (ended) finish(Object.assign(new Error("cut short"), { code: "ECONNRESET" }));
567
+ };
568
+ socket.setTimeout(timeout, () => finish(timedOut()));
569
+ socket.on("data", (chunk) => {
570
+ if (head) { kept.push(chunk); size += chunk.length; } else pending = Buffer.concat([pending, chunk]);
571
+ read(false);
572
+ });
573
+ socket.on("end", () => read(true));
574
+ socket.on("close", () => read(true));
575
+ socket.on("error", (error) => finish(error));
576
+ const lines = [`${method} ${path} HTTP/1.1`, `Host: ${host}`, ...Object.entries(headers).map(([name, value]) => `${name}: ${value}`), "Connection: close", "", ""];
577
+ socket.write(Buffer.from(lines.join("\r\n"), "latin1"));
578
+ if (body?.length) socket.write(body);
579
+ });
580
+ }
581
+
582
+ // MARK: A digest sign-in
583
+
584
+ const quoted = (value) => String(value).replaceAll("\\", "\\\\").replaceAll('"', '\\"');
585
+
586
+ /** What a device asks of a digest sign-in, from its challenge; null when the challenge isn't one lamp answers. */
587
+ function digestChallenge(asks) {
588
+ const at = /(^|,)\s*digest\s/i.exec(asks);
589
+ if (!at) return null;
590
+ const challenge = {};
591
+ for (const [, name, inQuotes, bare] of asks.slice(at.index + at[0].length).matchAll(/([A-Za-z]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))/g)) {
592
+ challenge[name.toLowerCase()] ??= inQuotes !== undefined ? inQuotes.replace(/\\(.)/g, "$1") : bare;
593
+ }
594
+ const algorithm = (challenge.algorithm ?? "MD5").toUpperCase();
595
+ if (!challenge.realm || !challenge.nonce || !["MD5", "SHA-256"].includes(algorithm)) return null;
596
+ const ways = (challenge.qop ?? "").split(",").map((way) => way.trim().toLowerCase()).filter(Boolean);
597
+ if (ways.length && !ways.includes("auth")) return null;
598
+ return { ...challenge, algorithm, qop: ways.length ? "auth" : null };
599
+ }
600
+
601
+ /** The Authorization header that answers a digest challenge (RFC 7616): the password itself is never in it. */
602
+ function digestAnswer(challenge, { user, password, method, path }) {
603
+ const hash = (text) => createHash(challenge.algorithm === "MD5" ? "md5" : "sha256").update(text).digest("hex");
604
+ const cnonce = randomUUID().replaceAll("-", ""), count = "00000001";
605
+ const one = hash(`${user}:${challenge.realm}:${password}`), two = hash(`${method}:${path}`);
606
+ const response = challenge.qop ? hash(`${one}:${challenge.nonce}:${count}:${cnonce}:auth:${two}`) : hash(`${one}:${challenge.nonce}:${two}`);
607
+ const parts = [`username="${quoted(user)}"`, `realm="${quoted(challenge.realm)}"`, `nonce="${quoted(challenge.nonce)}"`, `uri="${path}"`, `response="${response}"`, `algorithm=${challenge.algorithm}`];
608
+ if (challenge.qop) parts.push("qop=auth", `nc=${count}`, `cnonce="${cnonce}"`);
609
+ if (challenge.opaque !== undefined) parts.push(`opaque="${quoted(challenge.opaque)}"`);
610
+ return "Digest " + parts.join(", ");
611
+ }
612
+
613
+ /** The device's certificate isn't one lamp takes: waiting won't mend it. */
614
+ export class Refused extends Problem {
615
+ constructor(why) {
616
+ super(why);
617
+ this.kind = why === CHANGED ? "changed" : "unsaved";
618
+ }
619
+ }
620
+
621
+ /** The address isn't a device on the person's own network: nothing is sent there. */
622
+ export class OffNetwork extends Problem {}
623
+
624
+ /** The device didn't answer, or let the connection go: it's off, asleep, somewhere else on the network, or busy. */
625
+ export class Away extends Problem {
626
+ constructor(address, cause) {
627
+ super(`the device at ${address} ${cause?.timedOut ? "didn't answer in time" : "isn't answering"}: make sure it's on, then try again`);
628
+ this.code = cause?.code;
629
+ this.timedOut = !!cause?.timedOut;
630
+ }
631
+ }
632
+ const timedOut = () => Object.assign(new Error("timed out"), { timedOut: true });
633
+
634
+ /** A connection to the device over TLS, taken only once its certificate is the one kept (or the first, kept now). */
635
+ function connect(address, port, pins, timeout) {
636
+ return new Promise((done, failed) => {
637
+ const socket = tls.connect({ host: address, port, rejectUnauthorized: false, timeout });
638
+ socket.once("secureConnect", () => {
639
+ const certificate = socket.getPeerX509Certificate?.()?.raw ?? socket.getPeerCertificate(true)?.raw;
640
+ if (!certificate) { socket.destroy(); return failed(new Problem(`${address} showed no certificate`)); }
641
+ // Kept and compared before anything is sent; without pins (a read while finding) nothing secret is in the request.
642
+ const refused = pins?.admit(fingerprintOf(certificate), String(port), certificate);
643
+ if (refused) { socket.destroy(); return failed(new Refused(refused)); }
644
+ done({ socket, certificate });
645
+ });
646
+ socket.once("timeout", () => socket.destroy(timedOut()));
647
+ socket.once("error", (error) => failed(error instanceof Problem ? error : new Away(address, error)));
648
+ });
649
+ }
650
+
651
+ /** Whether the device accepts a connection on a port, within a few seconds. Connects and closes; sends nothing. */
652
+ export function answersOn(address, port, { timeout = 3000, reach = isPrivate } = {}) {
653
+ if (!reach(address)) return Promise.resolve(false);
654
+ return new Promise((done) => {
655
+ const socket = net.connect({ host: address, port, timeout });
656
+ const end = (open) => { socket.destroy(); done(open); };
657
+ socket.once("connect", () => end(true)).once("timeout", () => end(false)).once("error", () => end(false));
658
+ });
659
+ }
660
+
661
+ // MARK: Reading a value out of an answer
662
+
663
+ const textOf = (value) => (value === null || value === undefined ? undefined : typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value));
664
+
665
+ /** Every value at a dotted JSON path ("Platform", "items.0.id"), where `*` stands for each element of a list. */
666
+ export function jsonValues(body, path) {
667
+ let values;
668
+ try { values = [JSON.parse(body.toString("utf8"))]; } catch { return []; }
669
+ for (const part of path.split(".")) {
670
+ values = values.flatMap((value) => {
671
+ if (part === "*") return Array.isArray(value) ? value : [];
672
+ if (Array.isArray(value)) return /^\d+$/.test(part) && Number(part) < value.length ? [value[Number(part)]] : [];
673
+ return value && typeof value === "object" && part in value ? [value[part]] : [];
674
+ });
675
+ }
676
+ return values.map(textOf).filter((text) => text !== undefined);
677
+ }
678
+ export const jsonAt = (body, path) => jsonValues(body, path)[0];
679
+
680
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
681
+ const plainText = (text) => text.replace(/&(#x?[0-9a-fA-F]+|[a-z]+);/g, (whole, name) =>
682
+ (name[0] === "#" ? String.fromCodePoint(name[1] === "x" ? parseInt(name.slice(2), 16) : Number(name.slice(1))) : ENTITIES[name] ?? whole));
683
+
684
+ /** The text of the first element at an XML path ("model-name", or "device/modelName" for one inside another). */
685
+ export function xmlAt(body, path) {
686
+ const wanted = path.split("/"), stack = [];
687
+ const matches = () => stack.length >= wanted.length && wanted.every((name, index) => stack[stack.length - wanted.length + index] === name);
688
+ const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!(?:[^>]*)>|<\/([^\s>]+)\s*>|<([^\s>/]+)(?:\s[^>]*?)?(\/?)>|([^<]+)/g;
689
+ let text = "";
690
+ for (const [, cdata, closed, opened, empty, plain] of body.toString("utf8").matchAll(token)) {
691
+ if (opened) {
692
+ stack.push(opened);
693
+ if (matches()) text = "";
694
+ if (empty) { if (matches()) return ""; stack.pop(); }
695
+ } else if (closed) {
696
+ if (matches()) return text.trim();
697
+ stack.pop();
698
+ } else if (matches()) text += cdata ?? plainText(plain ?? "");
699
+ }
700
+ return undefined;
701
+ }
702
+
703
+ /** A device's answer as plain words: an HTML page's text without its tags and scripts, cut to a few lines. */
704
+ export function plainAnswer(answer) {
705
+ const text = answer.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/g, " ").replace(/<[^>]+>/g, "\n");
706
+ return text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).join("\n").slice(0, 1500);
707
+ }
708
+
709
+ // MARK: Running an integration
710
+
711
+ /** The device turned the sign-in away: waiting won't mend it, the person has to type it again. */
712
+ export class Rejected extends Problem {
713
+ constructor() { super("the device rejected the sign-in. Check the user name and password, then try again"); }
714
+ }
715
+
716
+ /** The device was still at what it was sent when lamp stopped waiting: a moment later it may be done. */
717
+ export class StillWorking extends Problem {}
718
+
719
+ /**
720
+ * Runs an integration's setup checks and its sequences (install, launch, look…) on one device. `pause` and `reach`
721
+ * are how it waits and what it takes for an address on the home network, which a test gives its own.
722
+ */
723
+ export class Runner {
724
+ /**
725
+ * `values` and `pins`, when given, are another keeper's (Lamp's app keeps what the person typed for a device in its
726
+ * own store, and names the file its certificates are kept in); `keyFile` is where a key the device gives is kept,
727
+ * and `stopped` ends the run between requests once whoever asked no longer wants it.
728
+ */
729
+ constructor({ integration, device, onWaiting = () => {}, pause = sleep, reach = isPrivate, values, pins, keyFile, stopped }) {
730
+ Object.assign(this, { integration, device, onWaiting, pause, reach, given: values, pins: pins ?? pinsOf(device.id), keyFile, stopped });
731
+ }
732
+
733
+ #stillWanted() { if (this.stopped?.aborted) throw new Problem("stopped"); }
734
+
735
+ get address() {
736
+ if (!this.device.address || !this.reach(this.device.address)) throw new Problem(`lamp doesn't know where ${this.device.name} is on your network: make sure it's on, then run lamp devices`);
737
+ return this.device.address;
738
+ }
739
+
740
+ /** lamp's values for this device, and the caller's (an app's names, whether it's a game), or those given whole. */
741
+ values(extra = {}) {
742
+ if (this.given) return { ...this.given, ...extra };
743
+ const values = { ...extra, address: this.device.address, deviceID: this.device.id.split(":").slice(1).join(":") };
744
+ const mine = nearest(this.device.address);
745
+ if (mine) values.mac = mine;
746
+ for (const [key, value] of Object.entries(fields.read(this.device.id))) values[`field:${key}`] = value;
747
+ for (const [step, value] of Object.entries(kept(this.device.id)?.reads ?? {})) values[`read:${step}`] = value;
748
+ return values;
749
+ }
750
+
751
+ #send(request, values, more = {}) {
752
+ return send(request, this.address, values, { pins: this.pins, reach: this.reach, stopped: this.stopped, ...more });
753
+ }
754
+
755
+ /**
756
+ * The first time the device's certificate is kept for a port, the root the integration says signs it is read from
757
+ * the device over that same connection and kept with it, so a certificate the device makes later is still known as
758
+ * its own.
759
+ */
760
+ async #learnRoot(request, answer) {
761
+ const path = this.integration.certificateRoot, pins = this.pins;
762
+ if (!request.https || !path || !answer.certificate || pins.root(String(request.port))) return;
763
+ const got = await this.#send({ port: request.port, path, https: true }, {}).catch(() => null);
764
+ const root = got?.ok && certificateFrom(got.body);
765
+ if (root) pins.keepRoot(root, answer.certificate, String(request.port));
766
+ }
767
+
768
+ /**
769
+ * Runs a sequence in order, stopping at the first failure. A setting it changes only for itself (`restore`) is put
770
+ * back as it ends; or, given `changed`, once the caller is done (`putBack`): an Xbox settles an installed package's
771
+ * type only as it's opened, after the install's own sequence.
772
+ */
773
+ async run(sequence, { extra = {}, files = {}, changed, cookies = {} } = {}) {
774
+ const settings = changed ?? [];
775
+ try { return await this.#run(sequence, extra, files, settings, cookies); } finally { if (!changed) await this.putBack(settings); }
776
+ }
777
+
778
+ /**
779
+ * Each setting as it was before a sequence changed it, last changed first, sent with the cookies its change went
780
+ * with (a CSRF token). A device that can't be reached by then keeps what the sequence set: there's nothing more to
781
+ * do from here. Answers the settings it couldn't put back.
782
+ */
783
+ async putBack(changed) {
784
+ const missed = [];
785
+ for (const restore of changed.splice(0).reverse()) {
786
+ const back = { ...restore.request, body: JSON.stringify({ [restore.request.restore]: restore.was }) };
787
+ const values = { ...this.values(), ...Object.fromEntries(Object.entries(restore.cookies ?? {}).map(([name, value]) => [`cookie:${name}`, value])) };
788
+ // Put back even once the run was stopped: a setting left changed is the device's for good. Each is given a while, not forever.
789
+ const answer = await this.#send(back, values, { session: { ...(restore.session ?? {}) }, stopped: undefined, timeout: 15_000 }).catch(() => null);
790
+ if (!answer?.ok) missed.push(`lamp couldn't set ${restore.request.path} on ${this.device.name} back to ${restore.was}`);
791
+ }
792
+ return missed;
793
+ }
794
+
795
+ async #run(sequence, extra, files, changed, given) {
796
+ const outcome = { answers: [] }, cookies = { ...given }, session = {};
797
+ for (const action of sequence) {
798
+ this.#stillWanted();
799
+ const values = { ...this.values(extra), ...Object.fromEntries(Object.entries(cookies).map(([name, value]) => [`cookie:${name}`, value])) };
800
+ const request = action.http;
801
+ if (action.wait) { await this.pause(Math.min(Math.max(action.wait, 1), 30) * 1000); continue; }
802
+ if (action.probe) {
803
+ if (!(await present(this.address, action.probe, { reach: this.reach }))) throw new Problem(`${this.device.name} didn't answer (${action.probe})`);
804
+ continue;
805
+ }
806
+ if (!request) throw new Problem(`${this.device.name}'s integration asks for something lamp's command line doesn't do (${Object.keys(action).join(", ")})`);
807
+ // A setting changed only for this sequence: what it is now, to put back after. Read as it's written, with the
808
+ // same sign-in and headers, but for a cookie the device sets only now.
809
+ if (request.restore) {
810
+ const headers = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name, value]) => name.toLowerCase() !== "content-type" && !value.includes("{cookie:")));
811
+ const now = await this.#send({ port: request.port, path: request.path, headers, auth: request.auth, https: request.https }, values, { session });
812
+ const was = now.ok ? jsonAt(now.body, request.restore) : undefined;
813
+ if (was === undefined || !SAFE.test(was)) throw new Problem(`${this.device.name} didn't say what ${request.path} is set to, so lamp left it as it was`);
814
+ // Put back only once, as it was first: a sequence tried again finds its own change there.
815
+ const setting = `${request.port}${request.path}`;
816
+ let wanted;
817
+ try { wanted = jsonAt(Buffer.from(fill(request.body ?? "", values, "body")), request.restore); } catch {}
818
+ if (!changed.some((restore) => `${restore.request.port}${restore.request.path}` === setting) && was !== wanted) changed.push({ request, was, cookies: { ...cookies }, session: { asked: [...(session.asked ?? [])] } });
819
+ }
820
+ const transfer = () => this.#send(request, values, { files, session });
821
+ let asked = Date.now(), answer = await transfer();
822
+ // A device still working on what it was sent says so with 204 until it's done.
823
+ if (request.poll) {
824
+ const until = Date.now() + Math.min(Math.max(request.poll, 2), 600) * 1000;
825
+ while (answer.status === 204 && Date.now() < until) {
826
+ this.#stillWanted();
827
+ this.onWaiting();
828
+ await this.pause(2000);
829
+ asked = Date.now();
830
+ answer = await transfer();
831
+ }
832
+ if (answer.status === 204) throw new StillWorking(`${this.device.name} was still working on ${request.path} after ${request.poll} seconds`);
833
+ }
834
+ Object.assign(cookies, answer.cookies);
835
+ if (request.auth && answer.status === 401) throw new Rejected();
836
+ if (answer.ok) await this.#learnRoot(request, answer);
837
+ if (!answer.ok) {
838
+ const said = plainAnswer(answer.text);
839
+ throw new Problem(`${this.device.name} answered ${answer.status} to ${request.path}${said ? ": " + said : ""}`);
840
+ }
841
+ if (request.expect) {
842
+ // Asked again while the answer doesn't say it yet, for up to `until` seconds, counting the time each request
843
+ // took as well as each pause.
844
+ let spent = (Date.now() - asked) / 1000;
845
+ while (!meets(request.expect, answer, values)) {
846
+ if (!request.until) throw new Problem(`${this.device.name} said: ${plainAnswer(answer.text)}`);
847
+ if (spent >= request.until) throw new StillWorking(`${this.device.name} still wasn't ready at ${request.path} after ${request.until} seconds`);
848
+ this.#stillWanted();
849
+ this.onWaiting();
850
+ await this.pause(2000);
851
+ asked = Date.now();
852
+ answer = await transfer();
853
+ spent += 2 + (Date.now() - asked) / 1000;
854
+ Object.assign(cookies, answer.cookies);
855
+ if (!answer.ok) throw new Problem(`${this.device.name} answered ${answer.status} to ${request.path}`);
856
+ }
857
+ }
858
+ if (request.keep === "picture") outcome.picture = answer.body;
859
+ else if (request.keep === "screen") outcome.screen = answer.body.subarray(0, 512 << 10).toString("utf8");
860
+ else if (request.keep === "log") outcome.log = answer.body.subarray(Math.max(0, answer.body.length - (256 << 10))).toString("utf8");
861
+ else if (request.keep === "key") {
862
+ // A key the device gives this computer to sign in to it with, kept where the caller says, for its person alone.
863
+ if (!this.keyFile) throw new Problem(`${this.device.name} gave a key, and lamp has nowhere to keep it`);
864
+ mkdirSync(dirname(this.keyFile), { recursive: true, mode: 0o700 });
865
+ writeFileSync(this.keyFile, answer.body, { mode: 0o600 });
866
+ chmodSync(this.keyFile, 0o600);
867
+ } else outcome.answers.push(answer.text.slice(0, 64 << 10));
868
+ }
869
+ outcome.cookies = cookies;
870
+ return outcome;
871
+ }
872
+
873
+ /**
874
+ * Opens an app just put on the device: `launch`, and then, where the bridge says how (`running`), a check that it's
875
+ * running. A device only just done installing a package can refuse to open it for a while, or take the launch and
876
+ * not open the app yet; so a refused launch is asked again after a pause that grows, and one that didn't open is
877
+ * launched again, until `tries` launches have gone. Answers whether it opened, and what the device last refused with.
878
+ */
879
+ async open(launch, running, { extra = {}, changed, tries = 8 } = {}) {
880
+ let launched = false, refused = null, wait = 2000;
881
+ for (let attempt = 1; attempt <= tries; attempt++) {
882
+ try {
883
+ await this.run(launch, { extra, changed });
884
+ launched = true;
885
+ if (!running?.length) return { opened: true };
886
+ try { await this.run(running, { extra, changed }); return { opened: true }; } catch (error) { if (!(error instanceof Problem) || error instanceof Away) throw error; }
887
+ } catch (error) {
888
+ if (!(error instanceof Problem) || error instanceof Rejected || error instanceof Away) throw error;
889
+ refused = error.message;
890
+ }
891
+ if (attempt === tries) break;
892
+ this.#stillWanted();
893
+ this.onWaiting();
894
+ await this.pause(wait);
895
+ wait = Math.min(wait * 2, 8000);
896
+ }
897
+ return { opened: false, launched, refused };
898
+ }
899
+
900
+ /**
901
+ * What a check of these conditions finds, told apart the way a setup needs it told: `holds` (with the first value
902
+ * read), `waiting` (the device answered otherwise, or the person hasn't typed what's needed yet), `away` (the device
903
+ * didn't answer), or `rejected` (it answered, but its certificate isn't the one kept, or it turned the sign-in away).
904
+ * `needs` names the field a condition waits for.
905
+ */
906
+ async evaluate(conditions) {
907
+ const values = this.values(), waiting = `Waiting for ${this.device.name}…`;
908
+ let read;
909
+ for (const condition of conditions ?? []) {
910
+ if (condition.field) {
911
+ if (!values[`field:${condition.field}`]) return { verdict: "waiting", why: "Waiting for what you type.", needs: condition.field };
912
+ } else if (condition.signedIn) {
913
+ if (!values.signIn) return { verdict: "waiting", why: "Waiting for you to sign in." };
914
+ } else if (condition.tcp) {
915
+ if (!this.device.address || !(await answersOn(this.device.address, condition.tcp, { reach: this.reach }))) return { verdict: "away", why: waiting };
916
+ } else if (condition.http) {
917
+ let answer;
918
+ try {
919
+ answer = await this.#send(condition.http, values);
920
+ if (answer.ok) await this.#learnRoot(condition.http, answer);
921
+ } catch (error) {
922
+ if (error instanceof Refused) return { verdict: "rejected", why: sentence(error.message), refused: error.kind };
923
+ return { verdict: "away", why: waiting };
924
+ }
925
+ // A device that turns the password away keeps doing so however long one waits: the person has to type it again.
926
+ if (condition.http.auth && answer.status === 401) return { verdict: "rejected", why: sentence(new Rejected().message), rejected: true };
927
+ if (!answer.ok) return { verdict: "waiting", why: waiting };
928
+ if (condition.json || condition.xml) {
929
+ const value = condition.json ? jsonAt(answer.body, condition.json) : xmlAt(answer.body, condition.xml);
930
+ if (value === undefined) return { verdict: "waiting", why: `${this.device.name} doesn't say yet.` };
931
+ if (condition.equals && !sameWords(value, condition.equals)) return { verdict: "waiting", why: waiting };
932
+ if (condition.contains && !holds(value, condition.contains)) return { verdict: "waiting", why: waiting };
933
+ read ??= value;
934
+ }
935
+ }
936
+ }
937
+ return { verdict: "holds", read: read ?? null };
938
+ }
939
+
940
+ /**
941
+ * Whether a setup step's check passes now: `passed`, what it read from the device, and when it didn't pass, why,
942
+ * with `failed` for a reason waiting won't mend (the certificate changed, or the device rejected the sign-in).
943
+ */
944
+ async check(step) {
945
+ const found = await this.evaluate(step.check);
946
+ if (found.verdict === "holds") return { passed: true, read: found.read ?? undefined };
947
+ return { passed: false, why: found.why, needs: found.needs, failed: found.verdict === "rejected", rejected: !!found.rejected };
948
+ }
949
+
950
+ /** A step passed on this device: it's kept, with the step and what the step read. */
951
+ passed(step, read) {
952
+ const mine = kept(this.device.id) ?? { ...this.device, passed: [], reads: {} };
953
+ if (!mine.passed.includes(step.id)) mine.passed.push(step.id);
954
+ if (read) mine.reads[step.id] = read;
955
+ // What the model step read is the device's model from now on, finer than what it said of itself when found.
956
+ if (step.model && read?.trim()) mine.model = read.trim();
957
+ keep(mine);
958
+ Object.assign(this.device, { model: mine.model, kept: true });
959
+ }
960
+ }
961
+
962
+ /** A problem's words as a sentence. */
963
+ export const sentence = (words) => `${words[0].toUpperCase()}${words.slice(1)}${/[.!?…]$/.test(words) ? "" : "."}`;
964
+
965
+ /** Whether the device at `address` is there now, as a probe says: a port it takes ("tcp:8060"), a search it answers ("ssdp:roku:ecp") or a Bonjour service it offers. */
966
+ export async function present(address, probe, { reach = isPrivate } = {}) {
967
+ const [kind, ...rest] = String(probe).split(":"), value = rest.join(":");
968
+ if (!value || !reach(address)) return false;
969
+ if (kind === "tcp") return answersOn(address, Number(value), { reach });
970
+ if (kind === "ssdp") return (await ssdp(value, 2)).some((answer) => answer.address === address);
971
+ if (kind === "bonjour") return (await bonjour(value, 3)).some((found) => found.address === address);
972
+ return false;
973
+ }
974
+
975
+ /** Whether an answer says what `expect` asks, of any element where its path has `*`. */
976
+ function meets(expect, answer, values) {
977
+ const found = expect.json ? jsonValues(answer.body, expect.json) : expect.xml ? [xmlAt(answer.body, expect.xml) ?? ""] : [];
978
+ if (expect.equals != null) { const wanted = fill(expect.equals, values, "shown"); return found.some((value) => sameWords(value, wanted)); }
979
+ if (expect.contains != null) { const wanted = fill(expect.contains, values, "shown"); return found.some((value) => holds(value, wanted)); }
980
+ return true;
981
+ }
982
+
983
+ /** Whether every setup step has passed on a kept device. */
984
+ export const isReady = (device, integration) => !!device && (integration?.steps ?? []).every((step) => (kept(device.id)?.passed ?? []).includes(step.id));