@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,1813 @@
1
+ // Lamp's common host for a page, on the Mac, the iPhone and the iPad: whatever the page is (a game drawn with three.js,
2
+ // an app), the app around it serves it, shows it and answers it here. Lamp writes this file before every build.
3
+ //
4
+ // The page is Web/ in a web view of the app's own, served from the app's bundle at an address of its own
5
+ // (<scheme>://app/, `LampPageFiles`), never as a file, so it reads its own files with fetch and loads its modules; the
6
+ // address is the app's for good, since what the page saves (localStorage, IndexedDB) is kept under it. What it asks of Lamp (LampAI in Web/lamp.js) goes through this
7
+ // app with the app's own token, never the page's; what it asks of this device (`LampNative.call` in lamp.js: the
8
+ // person's mail, calendar, reminders, photos…) is answered by Swift in Sources/Native.swift, the app's own code, with
9
+ // Lamp's building blocks in LampSupport.swift. Only the app's own page, in its main frame, is answered. A page shared
10
+ // with this person reaches only its own files and the sites its review named (`LampPageGuard`).
11
+ import AVFoundation
12
+ import Combine
13
+ import CryptoKit
14
+ import Foundation
15
+ import SwiftUI
16
+ import UniformTypeIdentifiers
17
+ import WebKit
18
+ #if os(iOS)
19
+ import UIKit
20
+ #endif
21
+
22
+ /// Calls the page can make of this device, by name. Register each in `Native.register()` (Sources/Native.swift):
23
+ ///
24
+ /// LampNative.on("unanswered") { args in
25
+ /// let limit = args["limit"] as? Int ?? 20
26
+ /// return try await LampMail.inbox(limit: limit).filter { !$0.isRead }.map { ["subject": $0.subject, "from": $0.sender] }
27
+ /// }
28
+ ///
29
+ /// The page calls it with `LampNative.call("unanswered", { limit: 30 })`, a Promise of what the handler returned as JSON
30
+ /// (dates as ISO 8601 strings). A thrown error rejects the Promise with its message, which the page shows in its own
31
+ /// words. Return only what the page needs: everything returned is the page's.
32
+ @MainActor enum LampNative {
33
+ typealias Handler = @MainActor ([String: Any]) async throws -> Data
34
+
35
+ private(set) static var handlers: [String: Handler] = [:]
36
+
37
+ /// A call the page may make, answering with anything Encodable (a struct, an array, a dictionary of strings…).
38
+ static func on<Answer: Encodable>(_ name: String, _ handler: @escaping @MainActor ([String: Any]) async throws -> Answer) {
39
+ handlers[name] = { args in
40
+ let encoder = JSONEncoder()
41
+ encoder.dateEncodingStrategy = .iso8601
42
+ return try encoder.encode(try await handler(args))
43
+ }
44
+ }
45
+
46
+ struct Failure: LocalizedError {
47
+ let message: String
48
+ var errorDescription: String? { message }
49
+ init(_ message: String) { self.message = message }
50
+ }
51
+ }
52
+
53
+ @MainActor enum LampPage {
54
+ private static var registered = false
55
+ /// Whether this launch's fresh start (`-LampFreshStart`) has gone to a page already.
56
+ static var freshStartGiven = false
57
+
58
+ /// Gives the page in `configuration` its ways to Lamp and to this device, for pages served at `scheme`. A page that
59
+ /// logs its own errors (a game's runtime does) leaves `catchErrors` off, or each would be logged twice.
60
+ static func attach(to configuration: WKWebViewConfiguration, scheme: String, catchErrors: Bool = true) {
61
+ if !registered { registered = true; Native.register() }
62
+ let gateway = LampPageGateway(scheme: scheme)
63
+ configuration.userContentController.addScriptMessageHandler(gateway, contentWorld: .page, name: "lampGateway")
64
+ configuration.userContentController.addScriptMessageHandler(LampPageNative(scheme: scheme), contentWorld: .page, name: "lampNative")
65
+ configuration.userContentController.add(LampPageWish(scheme: scheme), contentWorld: .page, name: "lampWish") // the project's other apps
66
+ configuration.userContentController.addScriptMessageHandler(LampPageVoice(scheme: scheme), contentWorld: .page, name: "lampVoice")
67
+ configuration.userContentController.add(LampPageLog(), name: "lampLog")
68
+ #if os(macOS)
69
+ configuration.userContentController.add(LampPagePlaying(), contentWorld: .page, name: "lampPlaying")
70
+ configuration.userContentController.add(LampPageDriveTaken(scheme: scheme), contentWorld: .page, name: "lampDriveTakenOver")
71
+ // A fresh start Lamp asked for when it opened the app (`-LampFreshStart YES`): a game starts from its beginning
72
+ // (lamp-game.js's claim()), once, at this launch's first page; a page opened again later in the same launch
73
+ // (Reload, a change) starts where the player was, as ever.
74
+ if UserDefaults.standard.bool(forKey: "LampFreshStart"), !LampPage.freshStartGiven {
75
+ // Once a launch, whatever opens later in it (another window of the game) starting where the player was.
76
+ LampPage.freshStartGiven = true
77
+ configuration.userContentController.addUserScript(WKUserScript(source: """
78
+ try { if (!sessionStorage.getItem('lamp.freshStart')) { sessionStorage.setItem('lamp.freshStart', '1'); window.lampFreshStart = true; } } catch (e) {}
79
+ """, injectionTime: .atDocumentStart, forMainFrameOnly: true))
80
+ }
81
+ #endif
82
+ // The page's console, into the app's log.
83
+ configuration.userContentController.addUserScript(WKUserScript(source: """
84
+ (function () {
85
+ var send = function (kind) { return function () { try { window.webkit.messageHandlers.lampLog.postMessage(kind + Array.prototype.join.call(arguments, ' ')); } catch (e) {} }; };
86
+ console.log = send(''); console.warn = send('Warning: '); console.error = send('Error: ');
87
+ if (\(catchErrors)) {
88
+ window.addEventListener('error', function (e) { send('Error: ')(e.message + ' (' + (e.filename || '').split('/').pop() + ':' + e.lineno + ')'); });
89
+ window.addEventListener('unhandledrejection', function (e) { send('Error: ')(String(e.reason && e.reason.message || e.reason)); });
90
+ }
91
+ })();
92
+ """, injectionTime: .atDocumentStart, forMainFrameOnly: true))
93
+ }
94
+
95
+ static func isOwnPage(_ message: WKScriptMessage, scheme: String) -> Bool {
96
+ message.frameInfo.isMainFrame && message.frameInfo.securityOrigin.protocol == scheme
97
+ }
98
+ }
99
+
100
+ /// The page's way to Lamp: only the routes LampAI uses, and where phones join a game (`Lamp.controllers`), with the
101
+ /// app's own token.
102
+ final class LampPageGateway: NSObject, WKScriptMessageHandlerWithReply {
103
+ private static let routes: Set<String> = ["/ai", "/ai/status", "/ai/image", "/ai/decide", "/ai/act", "/ai/hear", "/app/controller"]
104
+ private let scheme: String
105
+ init(scheme: String) { self.scheme = scheme }
106
+
107
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
108
+ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
109
+ guard LampPage.isOwnPage(message, scheme: scheme),
110
+ let call = message.body as? [String: Any], let path = call["path"] as? String, Self.routes.contains(path),
111
+ let url = URL(string: lampGateway + path) else {
112
+ return replyHandler(["status": 404, "body": #"{"error":"Not found"}"#], nil)
113
+ }
114
+ // A game's phones on the Mac, the iPhone and the iPad are hosted here: it serves their page and runs their relay
115
+ // itself (`LampControllers`), so they go on playing, and new ones join, whether Lamp is open or not. A game that
116
+ // chose Lamp's host (`start({ host: 'lamp' })`) is told Lamp doesn't host them here, and one that can't open its
117
+ // own says why: neither is handed the other host in its place.
118
+ if path == "/app/controller" {
119
+ let body = (call["body"] as? String).flatMap { try? JSONSerialization.jsonObject(with: Data($0.utf8)) } as? [String: Any] ?? [:]
120
+ let reply: @Sendable (Int, [String: Any]) -> Void = { status, answer in
121
+ let text = (try? JSONSerialization.data(withJSONObject: answer)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
122
+ DispatchQueue.main.async { replyHandler(["status": status, "body": text], nil) }
123
+ }
124
+ let stop = body["stop"] as? Bool == true
125
+ // A page that chose Lamp as its phones' host hears that this copy is the game itself, never served as if it
126
+ // were Lamp: the page chose, and only it may choose again.
127
+ if !stop, body["host"] as? String == "lamp" {
128
+ return reply(409, ["error": "Lamp doesn't host this game's phones here: the game hosts them itself (start() or start({ host: 'game' }))."])
129
+ }
130
+ return LampControllers.answer(stop: stop, reply)
131
+ }
132
+ // The page's own wait for an answer (a status check's few seconds, an action step's ten minutes at most).
133
+ let timeout = min(max((call["timeout"] as? NSNumber)?.doubleValue ?? 240_000, 1_000), 600_000) / 1000
134
+ Self.ask(url, body: call["body"] as? String, timeout: timeout) { status, body in replyHandler(["status": status, "body": body], nil) }
135
+ }
136
+
137
+ private static func ask(_ url: URL, body: String?, timeout: TimeInterval, done: @escaping @MainActor @Sendable (Int, String) -> Void) {
138
+ var request = URLRequest(url: url, timeoutInterval: timeout)
139
+ request.httpMethod = "POST"
140
+ request.setValue("Bearer " + LampInstall.token, forHTTPHeaderField: "Authorization")
141
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
142
+ request.httpBody = Data((body ?? "{}").utf8)
143
+ URLSession.shared.dataTask(with: request) { data, response, _ in
144
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 502
145
+ let body = data.map { String(decoding: $0, as: UTF8.self) } ?? #"{"error":"Couldn't reach Lamp on your Mac. Open Lamp and try again."}"#
146
+ DispatchQueue.main.async { done(status, body) }
147
+ }.resume()
148
+ }
149
+ }
150
+
151
+ /// The game's phones as controllers (`ControllerRuntime`, in LampControllerKit.swift): its phone page from the copy Lamp
152
+ /// put in the bundle (Phone/, in Resources on the Mac and at the bundle's top on an iPhone or iPad) and the relay its
153
+ /// phones join, in this process, opened as the page's `Lamp.controllers` starts and closed as it stops or the game quits.
154
+ ///
155
+ /// On an iPhone or iPad only while the game is in front: a game the person leaves is suspended by the system, which
156
+ /// would leave phones holding buttons on a game that can't hear them, so leaving ends the session (every phone let go,
157
+ /// both listeners closed) and coming back opens a new one at the page's next ask, which phones join by scanning its
158
+ /// code again. A moment away that isn't leaving (a permission's alert, the app switcher pulled half up) ends nothing.
159
+ ///
160
+ /// A page held to its sites keeps the port it reaches the relay at through all of that, on this device alone, since its
161
+ /// rules name that port for as long as it's open (`ControllerRuntime`'s `holdingPagePort`).
162
+ enum LampControllers {
163
+ private static let runtime: ControllerRuntime = {
164
+ let runtime = ControllerRuntime(app: LampInstall.slug.isEmpty ? (Bundle.main.bundleIdentifier ?? "game") : LampInstall.slug,
165
+ page: Bundle.main.resourceURL?.appendingPathComponent("Phone") ?? URL(fileURLWithPath: "/nonexistent"),
166
+ holdingPagePort: LampPageGuard.current != nil)
167
+ runtime.onPagePortMoved = { DispatchQueue.main.async { MainActor.assumeIsolated { LampPageGuard.retirePages() } } }
168
+ #if os(iOS)
169
+ NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in
170
+ runtime.stop {}
171
+ }
172
+ #endif
173
+ return runtime
174
+ }()
175
+
176
+ /// What a held page's rules let it connect to for its phones.
177
+ static var pageSites: [String] { runtime.pageSites() }
178
+
179
+ /// Serves this phone page from now on, under the session that's open (`LampPageLive`). Nil: the bundle's, by its own
180
+ /// version.
181
+ static func use(page: URL, version: String?) {
182
+ runtime.use(page: page, version: version ?? (try? ControllerSnapshot.read(page))?.version ?? "")
183
+ }
184
+
185
+ static func answer(stop: Bool, _ done: @escaping @Sendable (Int, [String: Any]) -> Void) {
186
+ // Both on the runtime's own queue, in the order the page asked: a stop then a start for the next round opens a
187
+ // fresh session, never one the stop then closes.
188
+ if stop { return runtime.stop { done(200, [:]) } }
189
+ guard inFront else { return done(409, ["error": "The game is in the background: phones join again when it's back in front."]) }
190
+ runtime.start(done)
191
+ }
192
+
193
+ /// Whether the game may host phones now: always on the Mac; on an iPhone or iPad unless it's in the background.
194
+ private static var inFront: Bool {
195
+ #if os(iOS)
196
+ // Asked from the page's bridge, on the main thread, where the app's state is read.
197
+ let state = Thread.isMainThread ? MainActor.assumeIsolated { UIApplication.shared.applicationState }
198
+ : DispatchQueue.main.sync { UIApplication.shared.applicationState }
199
+ return state != .background
200
+ #else
201
+ return true
202
+ #endif
203
+ }
204
+ }
205
+
206
+ /// The page's `LampVoice` (Web/lamp.js): what the person says, recorded by this device's microphone as 16 kHz mono
207
+ /// 16-bit PCM in a WAV, for the page to have transcribed (`LampAI.hear`); and what the page says back, read out. Asking
208
+ /// what's possible (`status`) never prompts: only `listen` asks for the microphone. One recording at a time, each tagged
209
+ /// with the page's session; a stop keeps it, a cancel throws it away, and the page reloading or going away (or, on
210
+ /// the iPhone and iPad, the app leaving the screen) cancels it whether the page says so or not.
211
+ @MainActor final class LampPageVoice: NSObject, WKScriptMessageHandlerWithReply, AVSpeechSynthesizerDelegate {
212
+ static let rate = 16_000.0
213
+
214
+ private let scheme: String
215
+ private weak var page: WKWebView?
216
+ private var loading: NSKeyValueObservation?
217
+ private var engine: AVAudioEngine?
218
+ private var session = ""
219
+ private var pcm = Data()
220
+ private var reply: (@MainActor @Sendable (Any?, String?) -> Void)?
221
+ private var end = LampVoiceEnd(), started = 0.0
222
+ /// Asks `end` on the clock, so a recording ends on time even when audio stops coming.
223
+ private var tick: Timer?
224
+ private let voice = AVSpeechSynthesizer()
225
+ /// Each utterance (kept, so its identity isn't reused) with its reply, so a late callback for one never answers another.
226
+ private var spoken: [ObjectIdentifier: (AVSpeechUtterance, @MainActor @Sendable (Any?, String?) -> Void)] = [:]
227
+ #if os(iOS)
228
+ private var leaving: NSObjectProtocol?
229
+ #endif
230
+
231
+ init(scheme: String) {
232
+ self.scheme = scheme
233
+ super.init()
234
+ voice.delegate = self
235
+ #if os(iOS)
236
+ leaving = NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in
237
+ MainActor.assumeIsolated { self?.finish("cancelled") }
238
+ }
239
+ #endif
240
+ }
241
+
242
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
243
+ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
244
+ guard LampPage.isOwnPage(message, scheme: scheme), let call = message.body as? [String: Any], let command = call["cmd"] as? String else {
245
+ return replyHandler(["error": "Not for this page."], nil)
246
+ }
247
+ if page !== message.webView {
248
+ page = message.webView
249
+ // A reload or another page: whatever was being heard is let go.
250
+ loading = message.webView?.observe(\.isLoading, options: [.new]) { [weak self] _, change in
251
+ guard change.newValue == true else { return }
252
+ MainActor.assumeIsolated { self?.finish("cancelled") }
253
+ }
254
+ }
255
+ let tag = call["session"] as? String ?? ""
256
+ switch command {
257
+ case "status": replyHandler(status(), nil)
258
+ case "listen": Task { await listen(session: tag, replyHandler) }
259
+ case "stop": if tag == session { finish("stopped") }; replyHandler(true, nil)
260
+ case "cancel": if tag == session { finish("cancelled") }; replyHandler(true, nil)
261
+ case "speak": speak(call["text"] as? String ?? "", replyHandler)
262
+ case "hush": if voice.isSpeaking { voice.stopSpeaking(at: .immediate) }; replyHandler(true, nil)
263
+ default: replyHandler(["error": "Unknown."], nil)
264
+ }
265
+ }
266
+
267
+ /// What this device can do, without asking anything of the person.
268
+ func status() -> [String: Any] {
269
+ #if os(iOS)
270
+ let input = AVAudioSession.sharedInstance().isInputAvailable
271
+ let permission: String = switch AVAudioApplication.shared.recordPermission {
272
+ case .granted: "granted"; case .denied: "denied"; default: "ask"
273
+ }
274
+ #else
275
+ let input = AVCaptureDevice.default(for: .audio) != nil
276
+ let permission: String = switch AVCaptureDevice.authorizationStatus(for: .audio) {
277
+ case .authorized: "granted"; case .denied, .restricted: "denied"; default: "ask"
278
+ }
279
+ #endif
280
+ return ["listen": input, "speak": true, "permission": permission, "why": input ? "" : "This device has no microphone.",
281
+ "maxSeconds": LampVoiceEnd.maxSeconds]
282
+ }
283
+
284
+ private func listen(session tag: String, _ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) async {
285
+ finish("cancelled")
286
+ session = tag
287
+ #if os(iOS)
288
+ let allowed = await AVAudioApplication.requestRecordPermission()
289
+ #else
290
+ let allowed = await AVCaptureDevice.requestAccess(for: .audio)
291
+ #endif
292
+ // Closed, or another recording asked for, while the system was asking: nothing starts.
293
+ guard session == tag, page != nil else { return replyHandler(["ended": "cancelled"], nil) }
294
+ guard allowed else { session = ""; return replyHandler(["ended": "denied"], nil) }
295
+ if voice.isSpeaking { voice.stopSpeaking(at: .immediate) }
296
+ #if os(iOS)
297
+ try? AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
298
+ try? AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
299
+ #endif
300
+ let engine = AVAudioEngine()
301
+ let input = engine.inputNode
302
+ let from = input.outputFormat(forBus: 0)
303
+ guard from.sampleRate > 0, let to = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: Self.rate, channels: 1, interleaved: true),
304
+ let converter = AVAudioConverter(from: from, to: to) else {
305
+ session = ""
306
+ return replyHandler(["ended": "error", "error": "The microphone isn't available."], nil)
307
+ }
308
+ pcm = Data(); end = LampVoiceEnd(); started = ProcessInfo.processInfo.systemUptime
309
+ reply = replyHandler
310
+ input.installTap(onBus: 0, bufferSize: 2048, format: from) { [weak self] buffer, _ in
311
+ // On the audio thread: converted to 16 kHz mono 16-bit here, the rest on the main thread.
312
+ guard let chunk = Self.convert(buffer, with: converter, to: to) else { return }
313
+ Task { @MainActor in self?.heard(chunk, session: tag) }
314
+ }
315
+ do { try engine.start() } catch {
316
+ input.removeTap(onBus: 0)
317
+ reply = nil; session = ""
318
+ return replyHandler(["ended": "error", "error": "The microphone didn't start: \(error.localizedDescription)"], nil)
319
+ }
320
+ self.engine = engine
321
+ tick = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
322
+ MainActor.assumeIsolated { self?.check(session: tag) }
323
+ }
324
+ }
325
+
326
+ private var elapsed: Double { ProcessInfo.processInfo.systemUptime - started }
327
+
328
+ private func check(session tag: String) {
329
+ guard tag == session, engine != nil, let ended = end.ended(at: elapsed) else { return }
330
+ finish(ended)
331
+ }
332
+
333
+ nonisolated private static func convert(_ buffer: AVAudioPCMBuffer, with converter: AVAudioConverter, to format: AVAudioFormat) -> Data? {
334
+ let capacity = AVAudioFrameCount(Double(buffer.frameLength) * format.sampleRate / buffer.format.sampleRate) + 32
335
+ guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return nil }
336
+ var given = false
337
+ var error: NSError?
338
+ converter.convert(to: out, error: &error) { _, status in
339
+ if given { status.pointee = .noDataNow; return nil }
340
+ given = true; status.pointee = .haveData; return buffer
341
+ }
342
+ guard error == nil, out.frameLength > 0, let samples = out.int16ChannelData else { return nil }
343
+ return Data(bytes: samples[0], count: Int(out.frameLength) * 2)
344
+ }
345
+
346
+ /// Each converted chunk: kept, its level told to the page, and whether that ends it asked of `end`.
347
+ private func heard(_ chunk: Data, session tag: String) {
348
+ guard tag == session, engine != nil else { return }
349
+ pcm.append(chunk)
350
+ var sum = 0.0, top = 0.0
351
+ chunk.withUnsafeBytes { raw in
352
+ for s in raw.bindMemory(to: Int16.self) { let v = Double(s) / 32768; sum += v * v; top = max(top, abs(v)) }
353
+ }
354
+ let level = (sum / Double(max(1, chunk.count / 2))).squareRoot()
355
+ end.heard(level: level, top: top, at: elapsed)
356
+ tell(["event": "level", "session": tag, "level": min(1, level * 4), "speech": end.speechAt != nil])
357
+ check(session: tag)
358
+ }
359
+
360
+ /// Ends the recording: kept as a WAV unless it was cancelled, or nothing was said.
361
+ private func finish(_ ended: String) {
362
+ guard let engine else { if ended == "cancelled" { session = "" }; return }
363
+ engine.inputNode.removeTap(onBus: 0)
364
+ engine.stop()
365
+ self.engine = nil
366
+ tick?.invalidate(); tick = nil
367
+ #if os(iOS)
368
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
369
+ #endif
370
+ let seconds = Double(pcm.count / 2) / Self.rate
371
+ var answer: [String: Any] = ["ended": ended, "seconds": seconds, "peak": end.peak, "speech": end.speechAt != nil]
372
+ // The microphone stopped sending mid-way: what it heard may be cut off, so it's an error, not something to act on.
373
+ if ended == "lost" { answer["ended"] = "error"; answer["error"] = "The microphone stopped sending sound. Try again." }
374
+ if ended == "stopped" || ended == "quiet" || ended == "cap" { answer["audio"] = Self.wav(pcm).base64EncodedString() }
375
+ pcm = Data(); session = ""
376
+ reply?(answer, nil); reply = nil
377
+ }
378
+
379
+ static func wav(_ pcm: Data) -> Data {
380
+ var d = Data()
381
+ func u32(_ v: Int) { var x = UInt32(v).littleEndian; d.append(Data(bytes: &x, count: 4)) }
382
+ func u16(_ v: Int) { var x = UInt16(v).littleEndian; d.append(Data(bytes: &x, count: 2)) }
383
+ d.append(Data("RIFF".utf8)); u32(36 + pcm.count); d.append(Data("WAVEfmt ".utf8)); u32(16); u16(1); u16(1)
384
+ u32(Int(rate)); u32(Int(rate) * 2); u16(2); u16(16); d.append(Data("data".utf8)); u32(pcm.count); d.append(pcm)
385
+ return d
386
+ }
387
+
388
+ private func tell(_ message: [String: Any]) {
389
+ guard let page, let data = try? JSONSerialization.data(withJSONObject: message) else { return }
390
+ page.evaluateJavaScript("window.__lampVoice && window.__lampVoice(\(String(decoding: data, as: UTF8.self)))", in: nil, in: .page) { _ in }
391
+ }
392
+
393
+ private func speak(_ text: String, _ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
394
+ guard !text.isEmpty, engine == nil else { return replyHandler(false, nil) }
395
+ if voice.isSpeaking { voice.stopSpeaking(at: .immediate) }
396
+ #if os(iOS)
397
+ try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.duckOthers])
398
+ try? AVAudioSession.sharedInstance().setActive(true)
399
+ #endif
400
+ let utterance = AVSpeechUtterance(string: text)
401
+ utterance.voice = AVSpeechSynthesisVoice(language: Locale.current.identifier.replacingOccurrences(of: "_", with: "-"))
402
+ ?? AVSpeechSynthesisVoice(language: "en-US")
403
+ spoken[ObjectIdentifier(utterance)] = (utterance, replyHandler)
404
+ voice.speak(utterance)
405
+ }
406
+
407
+ nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
408
+ let key = ObjectIdentifier(utterance)
409
+ Task { @MainActor in self.spoken.removeValue(forKey: key)?.1(true, nil) }
410
+ }
411
+ nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
412
+ let key = ObjectIdentifier(utterance)
413
+ Task { @MainActor in self.spoken.removeValue(forKey: key)?.1(false, nil) }
414
+ }
415
+ }
416
+
417
+ /// When one recording ends, from its levels and the clock alone (the same rule as LampVoiceEnd in Android's
418
+ /// LampPageGateway.kt). Speech is a level three times the room's, and above a floor. The room is the quietest it has
419
+ /// been lately: it follows a dip at once and, until speech is heard, rises back over a couple of seconds, so speech
420
+ /// from the very first moment counts once its first pause shows the room; once speech is heard the room stays put, so
421
+ /// talking on without a pause is never taken for the room. A recording ends as finished (`quiet`) only after 1.2 s of
422
+ /// quiet that was received: audio under the speech level after speech, or under the floor (past 6 s) when there was
423
+ /// only sound. Missing audio is never quiet: a stream that stops coming for a second ends as `lost`, which nothing acts
424
+ /// on. With no sound at all by 6 s it's `nothing`. Anything still going at 15 s is `cap`, which is never acted on.
425
+ /// Asked on a timer as well as for each chunk, so these hold when audio stops coming. A rule of thumb on levels, not a
426
+ /// way of telling speech from a television.
427
+ struct LampVoiceEnd {
428
+ static let maxSeconds = 15.0, waitSeconds = 6.0, quietSeconds = 1.2, floor = 0.02, rise = 2.0, stallSeconds = 1.0
429
+ private(set) var speechAt: Double?
430
+ private(set) var peak = 0.0
431
+ private var room: Double?, at = 0.0, lastHeard: Double?, sound = false
432
+ /// When the quiet that's going on now began, by the audio received: under the speech level, and under the floor.
433
+ private var quietFrom: Double?, silentFrom: Double?
434
+
435
+ mutating func heard(level: Double, top: Double, at t: Double) {
436
+ peak = max(peak, top)
437
+ if let r = room, level >= r { if speechAt == nil { room = r + (level - r) * min(1, (t - at) / Self.rise) } } else { room = level }
438
+ at = t
439
+ lastHeard = t
440
+ if level > Self.floor { sound = true; silentFrom = nil } else if silentFrom == nil { silentFrom = t }
441
+ if level > max((room ?? 0) * 3, Self.floor) { if speechAt == nil { speechAt = t }; quietFrom = nil }
442
+ else if quietFrom == nil { quietFrom = t }
443
+ }
444
+
445
+ func ended(at t: Double) -> String? {
446
+ if t >= Self.maxSeconds { return "cap" }
447
+ guard let last = lastHeard else { return t >= Self.waitSeconds ? "nothing" : nil }
448
+ if speechAt != nil, let q = quietFrom, last - q >= Self.quietSeconds { return "quiet" }
449
+ if speechAt == nil, sound, t >= Self.waitSeconds, let q = silentFrom, last - q >= Self.quietSeconds { return "quiet" }
450
+ if speechAt == nil, !sound, t >= Self.waitSeconds { return "nothing" }
451
+ // Audio that has stopped coming isn't quiet: what was heard may have been cut off.
452
+ return t - last >= Self.stallSeconds ? "lost" : nil
453
+ }
454
+ }
455
+
456
+ /// The page's calls of this device, answered by the handlers `Native.register()` gave.
457
+ final class LampPageNative: NSObject, WKScriptMessageHandlerWithReply {
458
+ private let scheme: String
459
+ init(scheme: String) { self.scheme = scheme }
460
+
461
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
462
+ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
463
+ guard LampPage.isOwnPage(message, scheme: scheme), let call = message.body as? [String: Any],
464
+ let name = call["name"] as? String else { return replyHandler(nil, "Not found") }
465
+ let args = (call["args"] as? String).flatMap { try? JSONSerialization.jsonObject(with: Data($0.utf8)) } as? [String: Any] ?? [:]
466
+ Task { @MainActor in
467
+ guard let handler = LampNative.handlers[name] else {
468
+ return replyHandler(nil, "This app has no call named \(name).")
469
+ }
470
+ do {
471
+ replyHandler(String(decoding: try await handler(args), as: UTF8.self), nil)
472
+ } catch {
473
+ FileHandle.standardError.write(Data("LampNative \(name) failed: \(error.localizedDescription)\n".utf8))
474
+ replyHandler(nil, error.localizedDescription)
475
+ }
476
+ }
477
+ }
478
+ }
479
+
480
+ // MARK: - The project's other apps
481
+
482
+ /// The page's `Lamp.wish` (Web/lamp.js), answered by this app's own `LampWish` (LampSupport.swift): the app is the project's
483
+ /// peer, as every Swift app of a project is (it listens, is found on the network and keeps the values on this device, so
484
+ /// it goes on with Lamp closed), and the page mirrors it. What the app hears goes to the page's `window.__lampWish`.
485
+ /// Only an app whose project has other apps has this, as only it has `LampWish`.
486
+ @MainActor final class LampPageWish: NSObject, WKScriptMessageHandler {
487
+ private let scheme: String
488
+ private weak var page: WKWebView?
489
+ private var listening = false
490
+ private var filesHeard = false
491
+ private var watching: AnyCancellable?
492
+ private var transfers: [Int: LampTransfer] = [:]
493
+ private var watchingTransfers: [Int: AnyCancellable] = [:]
494
+ /// The shared collections the page uses (`LampStore.shared` in lamp.js), which this app keeps: told whole once, then
495
+ /// as they change.
496
+ private var collections: Set<String> = []
497
+ init(scheme: String) { self.scheme = scheme }
498
+
499
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
500
+ guard LampPage.isOwnPage(message, scheme: scheme), let call = message.body as? [String: Any], let op = call["op"] as? String else { return }
501
+ let wish = LampWish.shared
502
+ switch op {
503
+ case "start":
504
+ page = message.webView
505
+ listen(to: wish)
506
+ wish.start()
507
+ tellState()
508
+ tellValues([])
509
+ case "stop":
510
+ wish.stop()
511
+ case "toLamp":
512
+ guard let data = Self.json(call["data"]) else { return }
513
+ wish.toLamp(data)
514
+ case "send":
515
+ guard let data = Self.json(call["data"]) else { return }
516
+ wish.send(data, to: call["to"] as? String)
517
+ case "set":
518
+ guard let key = call["key"] as? String else { return }
519
+ let value = Self.json(call["value"])
520
+ wish.set(key, value is NSNull ? nil : value)
521
+ case "files":
522
+ guard !filesHeard else { return }
523
+ filesHeard = true
524
+ wish.onFile { [weak self] from, file in
525
+ self?.tell(["t": "file", "from": from, "copy": file.copy, "name": file.name, "type": file.type, "data": file.data.base64EncodedString()])
526
+ }
527
+ case "sendFile":
528
+ guard let id = call["id"] as? Int, let to = call["to"] as? String,
529
+ let data = (call["data"] as? String).flatMap({ Data(base64Encoded: $0) }) else { return }
530
+ let transfer = wish.sendFile(data, name: call["name"] as? String ?? "file", type: call["type"] as? String ?? "application/octet-stream", to: to)
531
+ transfers[id] = transfer
532
+ watchingTransfers[id] = transfer.objectWillChange.sink { [weak self] _ in
533
+ DispatchQueue.main.async { MainActor.assumeIsolated { self?.tellTransfer(id) } }
534
+ }
535
+ tellTransfer(id)
536
+ case "cancel":
537
+ guard let id = call["id"] as? Int else { return }
538
+ transfers[id]?.cancel()
539
+ case "shared":
540
+ guard let name = call["s"] as? String, LampWish.isCollection(name) else { return }
541
+ page = message.webView
542
+ // Heard once; told whole each time the page asks, a page loaded again included.
543
+ if collections.insert(name).inserted {
544
+ wish.onItems(in: name) { [weak self] changed, from in self?.tellItems(name, changed.map { ($0.0, $0.1) }, whole: false, from: from) }
545
+ }
546
+ tellItems(name, wish.items(in: name).map { ($0.id, $0.value as Any?) }, whole: true, from: wish.me)
547
+ case "put", "publish":
548
+ guard let name = call["s"] as? String, let list = Self.json(call["items"]) as? [[Any]] else { return }
549
+ let given = list.compactMap { pair -> (String, Any?)? in
550
+ guard pair.count == 2, let id = pair[0] as? String else { return nil }
551
+ return (id, pair[1] is NSNull ? nil : pair[1])
552
+ }
553
+ let kept = op == "put" ? wish.putItems(in: name, given) : wish.publishItems(in: name, given.compactMap { id, value in value.map { (id, $0) } })
554
+ // Turned down (past a limit): the page, which showed it at once, is told the collection as it really is.
555
+ if !kept { tellItems(name, wish.items(in: name).map { ($0.id, $0.value as Any?) }, whole: true, from: wish.me) }
556
+ case "putFile":
557
+ guard let name = call["s"] as? String, let id = call["id"] as? String,
558
+ let data = (call["data"] as? String).flatMap({ Data(base64Encoded: $0) }) else { return }
559
+ let kept = wish.putFile(in: name, id: id, data: data, type: call["type"] as? String ?? "")
560
+ if let ask = call["ask"] as? Int { tell(["t": "fileDone", "ask": ask, "ok": kept]) }
561
+ case "fileBytes":
562
+ guard let name = call["s"] as? String, let id = call["id"] as? String, let ask = call["ask"] as? Int else { return }
563
+ let data = wish.fileURL(in: name, id: id).flatMap { try? Data(contentsOf: $0) }
564
+ tell(["t": "fileBytes", "ask": ask, "data": data.map { $0.base64EncodedString() } ?? NSNull()])
565
+ default:
566
+ break
567
+ }
568
+ }
569
+
570
+ private func listen(to wish: LampWish) {
571
+ guard !listening else { return }
572
+ listening = true
573
+ // Who is open and how this app's link stands, once each change has been made.
574
+ watching = wish.objectWillChange.sink { [weak self] _ in
575
+ DispatchQueue.main.async { MainActor.assumeIsolated { self?.tellState() } }
576
+ }
577
+ wish.onMessage { [weak self] from, data, copy in self?.tell(["t": "message", "from": from, "copy": copy, "data": data]) }
578
+ // Who made each change; the page says to its onChange every value that differs from its copy.
579
+ wish.onChange { [weak self] key, value, from in
580
+ let said: Any = value ?? NSNull()
581
+ self?.tellValues([["key": key, "value": said, "from": from]])
582
+ }
583
+ }
584
+
585
+ private func tellState() {
586
+ let wish = LampWish.shared
587
+ let apps: [[String: Any]] = wish.apps.map { peer in
588
+ let lastSeen: Any = peer.status.lastSeen.map { ($0.timeIntervalSince1970 * 1000).rounded() } ?? NSNull()
589
+ let copies: [[String: Any]] = peer.copies.map { ["id": $0.id, "device": $0.device, "since": ($0.since.timeIntervalSince1970 * 1000).rounded()] }
590
+ return ["peer": peer.id, "kind": peer.kind, "name": peer.name, "device": peer.device, "open": peer.isOpen, "copies": copies,
591
+ "status": ["state": peer.status.state.rawValue, "reason": peer.status.reason, "lastSeen": lastSeen]]
592
+ }
593
+ tell(["t": "state", "me": wish.me, "copy": wish.copy, "apps": apps, "status": ["state": wish.status.state.rawValue, "reason": wish.status.reason]])
594
+ }
595
+
596
+ private func tellItems(_ name: String, _ items: [(String, Any?)], whole: Bool, from: String) {
597
+ let wish = LampWish.shared
598
+ var said: [String: Any] = [:], here: [String: Bool] = [:]
599
+ for (id, value) in items {
600
+ said[id] = value ?? NSNull()
601
+ if name.hasPrefix("files:") { here[id] = wish.fileURL(in: name, id: id) != nil }
602
+ }
603
+ tell(["t": "items", "s": name, "items": said, "whole": whole, "from": from, "here": here])
604
+ }
605
+
606
+ private func tellValues(_ changed: [[String: Any]]) {
607
+ tell(["t": "values", "values": LampWish.shared.values, "changed": changed])
608
+ }
609
+
610
+ private func tellTransfer(_ id: Int) {
611
+ guard let transfer = transfers[id] else { return }
612
+ var said: [String: Any] = ["t": "transfer", "id": id, "sent": transfer.sent]
613
+ switch transfer.state {
614
+ case .sending: said["state"] = "sending"
615
+ case .done: said["state"] = "done"
616
+ case .failed(let why): said["state"] = "failed"; said["error"] = why
617
+ }
618
+ if transfer.state != .sending { transfers[id] = nil; watchingTransfers[id] = nil }
619
+ tell(said)
620
+ }
621
+
622
+ private func tell(_ message: [String: Any]) {
623
+ guard let page, JSONSerialization.isValidJSONObject(message),
624
+ let data = try? JSONSerialization.data(withJSONObject: message, options: [.fragmentsAllowed]) else { return }
625
+ page.evaluateJavaScript("window.__lampWish && window.__lampWish(\(String(decoding: data, as: UTF8.self)))", in: nil, in: .page) { _ in }
626
+ }
627
+
628
+ private static func json(_ text: Any?) -> Any? {
629
+ guard let text = text as? String else { return nil }
630
+ return try? JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed])
631
+ }
632
+ }
633
+
634
+ // MARK: - The page's log
635
+
636
+ /// What the page logs, into the app's log.
637
+ final class LampPageLog: NSObject, WKScriptMessageHandler {
638
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
639
+ guard let line = message.body as? String else { return }
640
+ FileHandle.standardError.write(Data((line.prefix(2000) + "\n").utf8))
641
+ }
642
+ }
643
+
644
+ #if os(macOS)
645
+ /// A game's page saying the person played while Lamp held its controller (lamp-game.js, "Lamp's hands on the
646
+ /// controller"): it has let go already, and Lamp's batch ends there, as it does for their click or key in the app.
647
+ final class LampPageDriveTaken: NSObject, WKScriptMessageHandler {
648
+ private let scheme: String
649
+ init(scheme: String) { self.scheme = scheme }
650
+
651
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
652
+ guard LampPage.isOwnPage(message, scheme: scheme) else { return }
653
+ MainActor.assumeIsolated { LampDriver.Handover.current?.stop() }
654
+ }
655
+ }
656
+
657
+ /// A game's page saying it's loading, and then that it's playing (lamp-game.js): the picture of the app Lamp's desk shows
658
+ /// waits for play, and is taken a moment after it starts, once the loading screen has faded (`LampRuntime.snapshot`).
659
+ final class LampPagePlaying: NSObject, WKScriptMessageHandler {
660
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
661
+ let said = message.body as? String
662
+ MainActor.assumeIsolated {
663
+ switch said {
664
+ case "loading":
665
+ guard LampPageItems.playing == nil else { return }
666
+ LampPageItems.playing = false
667
+ LampRuntime.gameLoading()
668
+ case "playing":
669
+ LampPageLive.current?.sayPlaying()
670
+ guard LampPageItems.playing != true else { return }
671
+ LampPageItems.playing = true
672
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { MainActor.assumeIsolated { LampRuntime.gamePlaying() } }
673
+ default: break
674
+ }
675
+ }
676
+ }
677
+ }
678
+ #endif
679
+
680
+ // MARK: - Serving and showing the page
681
+
682
+ /// The page's files, from the app's bundle (Web/), each a plain file inside it, at <scheme>://app/; and those Lamp has
683
+ /// changed since the bundle was built, while the app is open (`LampPageLive`), from the copies it was handed.
684
+ final class LampPageFiles: NSObject, WKURLSchemeHandler {
685
+ let scheme: String
686
+ private let folder = Bundle.main.resourceURL?.appendingPathComponent("Web")
687
+ /// The files Lamp changed since this bundle was built, by their path in Web/, and those it took away.
688
+ private let lock = NSLock()
689
+ private var changed: [String: URL] = [:]
690
+ private var gone: Set<String> = []
691
+ private var askedFor: Set<String> = []
692
+
693
+ init(scheme: String) { self.scheme = scheme }
694
+
695
+ /// Every file of the page the page asked for since the app opened, there or not: a change says them, so a game's page
696
+ /// that doesn't run on the runtime tells a file it uses from one it doesn't (`LampPageLive.gameScript`), which the
697
+ /// page can't see itself.
698
+ var asked: [String] {
699
+ lock.lock(); defer { lock.unlock() }
700
+ return askedFor.sorted()
701
+ }
702
+
703
+ func take(changed: [String: URL], gone: Set<String>) {
704
+ lock.lock(); defer { lock.unlock() }
705
+ self.changed = changed
706
+ self.gone = gone
707
+ }
708
+
709
+ func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
710
+ guard let url = task.request.url else { return task.didFailWithError(URLError(.badURL)) }
711
+ guard let data = file(at: url) else {
712
+ task.didReceive(HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: LampPageGuard.headers(scheme: scheme))!)
713
+ task.didFinish()
714
+ return
715
+ }
716
+ let ext = url.pathExtension.lowercased()
717
+ let type = ["js": "text/javascript", "mjs": "text/javascript", "html": "text/html", "css": "text/css", "json": "application/json",
718
+ "wasm": "application/wasm"][ext] ?? UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream"
719
+ var headers = ["Content-Type": type, "Content-Length": String(data.count), "Cache-Control": "no-store"]
720
+ // Every document and worker of a shared page carries its rules, a frame's and a worker's included: they're served
721
+ // from here, so none is without them.
722
+ headers.merge(LampPageGuard.headers(scheme: scheme)) { $1 }
723
+ task.didReceive(HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers)!)
724
+ task.didReceive(data)
725
+ task.didFinish()
726
+ }
727
+
728
+ func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {}
729
+
730
+ private func file(at url: URL) -> Data? {
731
+ guard let folder, url.host == "app" else { return nil }
732
+ let root = folder.standardizedFileURL.path + "/"
733
+ let path = url.path.isEmpty || url.path.hasSuffix("/") ? url.path + "index.html" : url.path
734
+ let relative = String(path.drop(while: { $0 == "/" }))
735
+ lock.lock()
736
+ askedFor.insert(relative)
737
+ let newer = changed[relative], removed = gone.contains(relative)
738
+ lock.unlock()
739
+ if let newer { return try? Data(contentsOf: newer) }
740
+ if removed { return nil }
741
+ let file = folder.appendingPathComponent(path).standardizedFileURL
742
+ guard file.path.hasPrefix(root), let values = try? file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
743
+ values.isRegularFile == true, values.isSymbolicLink != true else { return nil }
744
+ return try? Data(contentsOf: file)
745
+ }
746
+ }
747
+
748
+ // MARK: - What a shared page may reach
749
+
750
+ /// A page shared with this person, held to its own files and the exact sites its review named (`NetworkPolicy`, which
751
+ /// Lamp writes into this app's Info.plist from its own record, so neither the page, a rebuild nor a change while it's
752
+ /// open can widen it; an app the person made has none, and none of this applies). Its documents and workers are served
753
+ /// with a Content-Security-Policy that names only those (`LampPageFiles`); it never leaves its own address, in its
754
+ /// window or a frame, and a link opens in the browser only when the person clicks it and agrees to where it goes, a
755
+ /// redirect included since each step is decided here; what no policy covers (WebRTC, a link's preconnect) is off
756
+ /// (`NetworkPolicy.holdBack`). A page whose review said
757
+ /// it may connect elsewhere shows what was blocked, and the person can allow that site or, for good, every site.
758
+ /// Swift of the sender's in this app (Native.swift or any other) runs in this process with none of this, and can turn it
759
+ /// off for the page too (the person's choices are this app's defaults): such an app isn't held, and its review says so.
760
+ enum LampPageGuard {
761
+ static let policy = NetworkPolicy(plist: Bundle.main.object(forInfoDictionaryKey: NetworkPolicy.plistKey))
762
+ private static let allowedKey = "LampNetworkAllowed", everyKey = "LampNetworkUnrestricted"
763
+
764
+ /// Sites the person allowed on this device, kept where the page can't write.
765
+ static var allowed: [String] { UserDefaults.standard.stringArray(forKey: allowedKey) ?? [] }
766
+
767
+ /// The policy the page is held to now, or nil when it's held to nothing: the person's own app, or one they let
768
+ /// reach every site.
769
+ static var current: NetworkPolicy? {
770
+ guard var policy, !UserDefaults.standard.bool(forKey: everyKey) else { return nil }
771
+ policy.origins += allowed
772
+ return policy.restricts ? policy : nil
773
+ }
774
+
775
+ static func own(_ scheme: String) -> String { "\(scheme)://app" }
776
+
777
+ /// The headers every file of the page is served with.
778
+ static func headers(scheme: String) -> [String: String] {
779
+ // The game's own relay is its host's to name, for this launch only: it's never one of the page's sites, kept with
780
+ // the ones the person allowed, or anything the page says.
781
+ guard let held = current, let csp = held.contentSecurityPolicy(own: own(scheme), connect: LampControllers.pageSites) else { return [:] }
782
+ // A name looked up ahead is a request too: a page could spell what it has in one.
783
+ return ["Content-Security-Policy": csp, "X-DNS-Prefetch-Control": "off"]
784
+ }
785
+
786
+ /// What the page is held to as it's set up: WebRTC off, and what it's refused heard of in every frame, from a world
787
+ /// of Lamp's own the page can't reach (it hears only refusals WebKit made, never ones the page made up).
788
+ @MainActor static func attach(to configuration: WKWebViewConfiguration, scheme: String) {
789
+ guard policy != nil else { return }
790
+ forgetOldRelays()
791
+ let world = WKContentWorld.world(name: "LampGuard")
792
+ configuration.userContentController.add(LampPageRefused(scheme: scheme), contentWorld: world, name: "lampRefused")
793
+ configuration.userContentController.addUserScript(WKUserScript(source: """
794
+ document.addEventListener('securitypolicyviolation', function (e) {
795
+ if (!e.isTrusted) return;
796
+ try { window.webkit.messageHandlers.lampRefused.postMessage({ url: String(e.blockedURI || ''), what: String(e.effectiveDirective || '') }); } catch (_) {}
797
+ }, true);
798
+ """, injectionTime: .atDocumentStart, forMainFrameOnly: false, in: world))
799
+ guard current != nil else { return }
800
+ NetworkPolicy.holdBack(configuration.preferences)
801
+ }
802
+
803
+ /// A game's relay was once refused here, and the port the person allowed for it was that launch's alone: what's left
804
+ /// of those is a port on this device nothing should be able to reach, so it goes, once, for a game that takes phones.
805
+ private static func forgetOldRelays() {
806
+ let done = "LampRelayAllowancesForgotten"
807
+ guard !UserDefaults.standard.bool(forKey: done),
808
+ let phone = Bundle.main.resourceURL?.appendingPathComponent("Phone/index.html"), FileManager.default.fileExists(atPath: phone.path) else { return }
809
+ UserDefaults.standard.set(allowed.filter { !$0.hasPrefix("ws://127.0.0.1:") }, forKey: allowedKey)
810
+ UserDefaults.standard.set(true, forKey: done)
811
+ }
812
+
813
+ /// The pages held to a policy, which open again when its rules change (`retirePages`).
814
+ private static let watchers = NSMapTable<WKWebView, LampPageWatcher>.weakToStrongObjects()
815
+ /// Every page's watcher, kept as long as its page.
816
+ private static let kept = NSMapTable<WKWebView, LampPageWatcher>.weakToStrongObjects()
817
+
818
+ /// Where the page may go, decided for every navigation of its window and frames, and the files it opens and saves:
819
+ /// every page has this, since a page with no policy still picks files and saves them (`LampPageWatcher`).
820
+ @MainActor static func watch(_ view: WKWebView, scheme: String) {
821
+ let watcher = LampPageWatcher(scheme: scheme)
822
+ kept.setObject(watcher, forKey: view)
823
+ if policy != nil { watchers.setObject(watcher, forKey: view) }
824
+ view.navigationDelegate = watcher
825
+ view.uiDelegate = watcher
826
+ }
827
+
828
+ /// Every page opened again, for rules that have changed under them: what it runs goes with it, its workers too. Each
829
+ /// opens again as Lamp's Reload opens it (`LampPageView.reopen`), so a game in any window keeps where the player was.
830
+ @MainActor static func retirePages() {
831
+ for case let view as WKWebView in watchers.keyEnumerator().allObjects { LampPageView.reopen(view) }
832
+ }
833
+
834
+ /// Lets the page reach `site` from now on, on this device, and opens every page of the app again with it: a page's
835
+ /// rules came with its files, so one in another window would go on refusing the site until it opens again.
836
+ @MainActor static func allow(_ site: String) {
837
+ UserDefaults.standard.set(Array(Set(allowed + [site])).sorted(), forKey: allowedKey)
838
+ retirePages()
839
+ }
840
+
841
+ /// Lets the page reach every site, for good, and opens every page of the app again under that.
842
+ @MainActor static func allowEverySite() {
843
+ UserDefaults.standard.set(true, forKey: everyKey)
844
+ retirePages()
845
+ }
846
+
847
+ /// Asks the person, over the page, and calls `then` with their answer.
848
+ @MainActor static func ask(_ title: String, _ message: String, button: String, over view: WKWebView, then: @escaping @MainActor (Bool) -> Void) {
849
+ #if os(macOS)
850
+ let alert = NSAlert()
851
+ alert.messageText = title
852
+ alert.informativeText = message
853
+ alert.addButton(withTitle: button)
854
+ alert.addButton(withTitle: "Cancel")
855
+ guard let window = view.window else { return then(alert.runModal() == .alertFirstButtonReturn) }
856
+ alert.beginSheetModal(for: window) { answer in then(answer == .alertFirstButtonReturn) }
857
+ #else
858
+ var top = view.window?.rootViewController
859
+ while let shown = top?.presentedViewController { top = shown }
860
+ guard let top else { return then(false) }
861
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
862
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in then(false) })
863
+ alert.addAction(UIAlertAction(title: button, style: .default) { _ in then(true) })
864
+ top.present(alert, animated: true)
865
+ #endif
866
+ }
867
+ }
868
+
869
+ /// The page's window and frames: a shared page goes only to its own address, and a link the person clicks opens in the
870
+ /// browser once they've seen where it goes. Any page opens the person's files with a file field and saves what it
871
+ /// downloads (a link with `download`, a Blob it made, a file the web view can't show) where the person says, as a
872
+ /// browser does, so a plain site needs no Swift for either. A download is a request, held to what the page may fetch,
873
+ /// never a navigation: it doesn't take a shared page anywhere.
874
+ @MainActor final class LampPageWatcher: NSObject, WKNavigationDelegate, WKUIDelegate {
875
+ private let scheme: String
876
+ private var asking = false
877
+ /// A shared page's request to one of its sites, let go to see whether it answers with a file, and whether the person
878
+ /// clicked it in the page's window, so a page that comes back instead is offered to the browser as before.
879
+ private var trying: (url: URL, clicked: Bool)?
880
+ init(scheme: String) { self.scheme = scheme }
881
+
882
+ func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
883
+ decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
884
+ guard let url = action.request.url else { return decisionHandler(.allow) }
885
+ let policy = LampPageGuard.current, own = LampPageGuard.own(scheme)
886
+ #if os(macOS)
887
+ if action.shouldPerformDownload, policy?.allows(url, own: own) ?? true { return decisionHandler(.download) }
888
+ #endif
889
+ guard let policy, !policy.allowsFrame(url, own: own) else { return decisionHandler(.allow) }
890
+ let clicked = action.targetFrame?.isMainFrame != false && Self.clicked(action)
891
+ #if os(macOS)
892
+ // A link to a file on one of the page's sites (`download` is ignored across origins): the request is one the page
893
+ // may make anyway, and its answer decides, saved if it's a file, never shown (`decidePolicyFor response`). A
894
+ // redirect elsewhere comes back here and is refused.
895
+ if ["http", "https"].contains(url.scheme?.lowercased() ?? ""), policy.allows(url, own: own) {
896
+ trying = (url, clicked)
897
+ return decisionHandler(.allow)
898
+ }
899
+ #endif
900
+ decisionHandler(.cancel)
901
+ refused(url, clicked: clicked, over: webView)
902
+ }
903
+
904
+ private func refused(_ url: URL, clicked: Bool, over view: WKWebView) {
905
+ FileHandle.standardError.write(Data("Lamp kept this shared app from opening \(url.absoluteString.prefix(300))\n".utf8))
906
+ if clicked { offer(url, over: view) }
907
+ }
908
+
909
+ #if os(macOS)
910
+ /// What the web view can't show (a zip, a file sent as an attachment) is saved rather than shown as nothing. A shared
911
+ /// page's site answering with a page is never shown: that's refused as it would have been before the request.
912
+ func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
913
+ decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) {
914
+ let attachment = ((response.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") ?? "")
915
+ .lowercased().hasPrefix("attachment")
916
+ let file = attachment || !response.canShowMIMEType
917
+ if let url = response.response.url, let policy = LampPageGuard.current,
918
+ !policy.allowsFrame(url, own: LampPageGuard.own(scheme)) {
919
+ let clicked = trying?.url == url && trying?.clicked == true
920
+ trying = nil
921
+ if file { return decisionHandler(.download) }
922
+ decisionHandler(.cancel)
923
+ return refused(url, clicked: clicked, over: webView)
924
+ }
925
+ decisionHandler(file ? .download : .allow)
926
+ }
927
+
928
+ func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
929
+ download.delegate = LampPageDownloads.shared
930
+ }
931
+
932
+ func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
933
+ download.delegate = LampPageDownloads.shared
934
+ }
935
+
936
+ /// A file field: the person's choice of files, or folders where the field takes one, as a browser offers them.
937
+ func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo,
938
+ completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void) {
939
+ let panel = NSOpenPanel()
940
+ panel.canChooseFiles = true
941
+ panel.canChooseDirectories = parameters.allowsDirectories
942
+ panel.allowsMultipleSelection = parameters.allowsMultipleSelection
943
+ panel.resolvesAliases = true
944
+ let chosen: (NSApplication.ModalResponse) -> Void = { answer in completionHandler(answer == .OK ? panel.urls : nil) }
945
+ if let window = webView.window { panel.beginSheetModal(for: window, completionHandler: chosen) } else { chosen(panel.runModal()) }
946
+ }
947
+ #endif
948
+
949
+ /// A link that opens a window of its own (target="_blank"): never a window here. Only a shared page offers it to the
950
+ /// browser; the person's own page does with it what it always has, nothing.
951
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
952
+ for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
953
+ if LampPageGuard.policy != nil, let url = action.request.url, Self.clicked(action) { offer(url, over: webView) }
954
+ return nil
955
+ }
956
+
957
+ /// A website the person clicked a link to, opened in the browser once they've said yes to where it goes. Anything
958
+ /// the page does by itself (setting its address, opening a window, a redirect) goes nowhere.
959
+ private func offer(_ url: URL, over view: WKWebView) {
960
+ guard !asking,
961
+ let scheme = url.scheme?.lowercased(), ["http", "https", "mailto"].contains(scheme) else { return }
962
+ asking = true
963
+ LampPageGuard.ask("Open \(url.host ?? "this website")?", url.absoluteString, button: "Open", over: view) { [weak self] open in
964
+ self?.asking = false
965
+ guard open else { return }
966
+ #if os(macOS)
967
+ NSWorkspace.shared.open(url)
968
+ #else
969
+ UIApplication.shared.open(url)
970
+ #endif
971
+ }
972
+ }
973
+
974
+ /// A link the person clicked.
975
+ private static func clicked(_ action: WKNavigationAction) -> Bool { action.navigationType == .linkActivated && byPerson(action) }
976
+
977
+ /// Whether the person's own click or tap started it. WebKit says so only by a name it doesn't publish: where it isn't
978
+ /// there, a link's being clicked is all there is to go on.
979
+ private static func byPerson(_ action: WKNavigationAction) -> Bool {
980
+ guard action.responds(to: NSSelectorFromString("_isUserInitiated")) else { return true }
981
+ return (action.value(forKey: "_isUserInitiated") as? Bool) ?? false
982
+ }
983
+ }
984
+
985
+ #if os(macOS)
986
+ /// Where a page's download goes: where the person says, in the window it came from, starting in Downloads with the name
987
+ /// the page gave it. Nothing is saved unless they say where.
988
+ @MainActor final class LampPageDownloads: NSObject, WKDownloadDelegate {
989
+ static let shared = LampPageDownloads()
990
+ /// Each download is written beside where it goes, under a hidden name, and takes that place only once it's whole: a
991
+ /// file it replaces is kept if it fails.
992
+ private var places: [ObjectIdentifier: (writing: URL, final: URL)] = [:]
993
+ /// How each download set off by Lamp's builder goes, which `use_app` tells it (`LampSaves` in LampSupport.swift).
994
+ private var saves: [ObjectIdentifier: LampSaves.Save] = [:]
995
+
996
+ func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String,
997
+ completionHandler: @escaping @MainActor @Sendable (URL?) -> Void) {
998
+ let panel = NSSavePanel()
999
+ panel.nameFieldStringValue = suggestedFilename
1000
+ panel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
1001
+ panel.canCreateDirectories = true
1002
+ let chosen: (NSApplication.ModalResponse) -> Void = { answer in
1003
+ // Kept from when its panel is answered, whenever the download began (`LampSaves.began`).
1004
+ let save = LampSaves.began(suggestedFilename)
1005
+ guard answer == .OK, let url = panel.url else {
1006
+ LampSaves.ended(save, .canceled)
1007
+ return completionHandler(nil)
1008
+ }
1009
+ LampSaves.chose(save, url)
1010
+ if let save { self.saves[ObjectIdentifier(download)] = save }
1011
+ let writing = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).\(UUID().uuidString).download")
1012
+ self.places[ObjectIdentifier(download)] = (writing, url)
1013
+ completionHandler(writing)
1014
+ }
1015
+ if let window = download.webView?.window { panel.beginSheetModal(for: window, completionHandler: chosen) } else { chosen(panel.runModal()) }
1016
+ }
1017
+
1018
+ func downloadDidFinish(_ download: WKDownload) {
1019
+ let save = saves.removeValue(forKey: ObjectIdentifier(download))
1020
+ guard let place = places.removeValue(forKey: ObjectIdentifier(download)) else { return }
1021
+ let files = FileManager.default
1022
+ do {
1023
+ // The save panel asked before replacing a file of that name.
1024
+ if files.fileExists(atPath: place.final.path) { _ = try files.replaceItemAt(place.final, withItemAt: place.writing) }
1025
+ else { try files.moveItem(at: place.writing, to: place.final) }
1026
+ LampSaves.ended(save, .saved)
1027
+ } catch {
1028
+ try? files.removeItem(at: place.writing)
1029
+ LampSaves.ended(save, .notMoved(error.localizedDescription))
1030
+ FileHandle.standardError.write(Data("A download couldn't be saved: \(error.localizedDescription)\n".utf8))
1031
+ }
1032
+ }
1033
+
1034
+ func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
1035
+ if let place = places.removeValue(forKey: ObjectIdentifier(download)) { try? FileManager.default.removeItem(at: place.writing) }
1036
+ LampSaves.ended(saves.removeValue(forKey: ObjectIdentifier(download)), .failed(error.localizedDescription))
1037
+ FileHandle.standardError.write(Data("A download failed: \(error.localizedDescription)\n".utf8))
1038
+ }
1039
+ }
1040
+ #endif
1041
+
1042
+ /// What the page was refused, from Lamp's own world in it: into the app's log, and, for a page whose review said it may
1043
+ /// connect elsewhere, shown above it with a way to allow the site.
1044
+ @MainActor final class LampPageRefused: NSObject, WKScriptMessageHandler {
1045
+ private let scheme: String
1046
+ private var bars = LampPageRefusals()
1047
+ /// The page whose bar this is.
1048
+ private weak var page: WKWebView?
1049
+ /// Takes this page's bar away without a word, when what the person did in another window settled it.
1050
+ private var takeAway: (@MainActor () -> Void)?
1051
+ /// Every page's refusals, so what the person does in one window (closes a site's bar, allows it, allows every site)
1052
+ /// reaches the bars other windows show at that moment.
1053
+ private static let all = NSHashTable<LampPageRefused>.weakObjects()
1054
+
1055
+ init(scheme: String) {
1056
+ self.scheme = scheme
1057
+ super.init()
1058
+ Self.all.add(self)
1059
+ }
1060
+
1061
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
1062
+ guard let said = message.body as? [String: Any], let address = said["url"] as? String,
1063
+ let view = message.webView, let policy = LampPageGuard.current else { return }
1064
+ let site = NetworkPolicy.origin(address)
1065
+ FileHandle.standardError.write(Data("Lamp blocked this shared app's request to \(site ?? String(address.prefix(200))) (\(said["what"] as? String ?? ""))\n".utf8))
1066
+ // The game's own relay is never offered: an allowance is kept, and that port is this launch's.
1067
+ guard policy.mayConnect, let site, !LampControllers.pageSites.contains(site) else { return }
1068
+ page = view
1069
+ show(bars.heard(site, blocked: Self.blocked), over: view)
1070
+ }
1071
+
1072
+ private func show(_ site: String?, over view: WKWebView) {
1073
+ guard let site else { return }
1074
+ takeAway = LampPageBlocked.show(site, over: view) { [weak self, weak view] closed in
1075
+ guard let self else { return }
1076
+ self.takeAway = nil
1077
+ let next = self.bars.gone(closed: closed, blocked: Self.blocked)
1078
+ if let view { self.show(next, over: view) }
1079
+ for other in Self.all.allObjects where other !== self { other.settle() }
1080
+ }
1081
+ }
1082
+
1083
+ /// Another window's bar went: this page's goes too if its site is now closed, or allowed (the allowing opened every page
1084
+ /// again under the new rules, this one too, so the site is reached here as well), and the next waiting has its turn.
1085
+ private func settle() {
1086
+ let (dropped, next) = bars.settle(blocked: Self.blocked)
1087
+ guard dropped else { return }
1088
+ takeAway?()
1089
+ takeAway = nil
1090
+ if let page { show(next, over: page) }
1091
+ }
1092
+
1093
+ /// Whether the page is still kept from `site`: the person may have allowed it, or every site, meanwhile.
1094
+ private static func blocked(_ site: String) -> Bool { LampPageGuard.current.map { !$0.origins.contains(site) } ?? false }
1095
+ }
1096
+
1097
+ /// Which site's bar a page shows and which wait their turn: one bar at a time, each site once. A site whose bar the
1098
+ /// person closed stays quiet for the rest of this launch, in every page and window, though it's still blocked: a page
1099
+ /// that asks for it again and again (a map's tiles, a feed polled each second) would otherwise bring the bar straight
1100
+ /// back. Quitting the app forgets it. A waiting site the person has allowed meanwhile isn't shown.
1101
+ @MainActor struct LampPageRefusals {
1102
+ private static var closed: Set<String> = []
1103
+ private(set) var showing: String?
1104
+ private var waiting: [String] = []
1105
+
1106
+ /// The page was refused `site`: the site whose bar to show now, if any.
1107
+ mutating func heard(_ site: String, blocked: (String) -> Bool) -> String? {
1108
+ guard !Self.closed.contains(site), showing != site, !waiting.contains(site) else { return nil }
1109
+ waiting.append(site)
1110
+ return next(blocked)
1111
+ }
1112
+
1113
+ /// The bar shown went, `closed` by the person or its site allowed: the next site whose bar to show, if any.
1114
+ mutating func gone(closed: Bool, blocked: (String) -> Bool) -> String? {
1115
+ if closed, let showing { Self.closed.insert(showing) }
1116
+ showing = nil
1117
+ return next(blocked)
1118
+ }
1119
+
1120
+ /// What the person did elsewhere (closed a site's bar in another window, allowed a site or every site) may settle
1121
+ /// the bar shown here: whether it goes, and the next site whose bar to show then, if any.
1122
+ mutating func settle(blocked: (String) -> Bool) -> (dropped: Bool, next: String?) {
1123
+ guard let site = showing, Self.closed.contains(site) || !blocked(site) else { return (false, nil) }
1124
+ showing = nil
1125
+ return (true, next(blocked))
1126
+ }
1127
+
1128
+ private mutating func next(_ blocked: (String) -> Bool) -> String? {
1129
+ guard showing == nil else { return nil }
1130
+ while !waiting.isEmpty {
1131
+ let site = waiting.removeFirst()
1132
+ guard !Self.closed.contains(site), blocked(site) else { continue }
1133
+ showing = site
1134
+ return site
1135
+ }
1136
+ return nil
1137
+ }
1138
+ }
1139
+
1140
+ /// The bar above a page that says what was blocked: allow that site, every site, or let it be.
1141
+ struct LampPageBlocked: View {
1142
+ let site: String
1143
+ let allow: () -> Void
1144
+ let allowAll: () -> Void
1145
+ let close: () -> Void
1146
+
1147
+ var body: some View {
1148
+ HStack(spacing: 10) {
1149
+ Image(systemName: "network.slash")
1150
+ Text("Blocked \(site.components(separatedBy: "://").last ?? site)").lineLimit(1).truncationMode(.middle)
1151
+ Spacer(minLength: 8)
1152
+ Button("Allow", action: allow)
1153
+ Button("Allow All…", action: allowAll)
1154
+ Button(action: close) { Image(systemName: "xmark") }.buttonStyle(.plain).accessibilityLabel("Close")
1155
+ }
1156
+ .font(.callout)
1157
+ .padding(.horizontal, 12).padding(.vertical, 8)
1158
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
1159
+ .padding(10)
1160
+ }
1161
+
1162
+ /// Shows the bar for `site` above the page, in room of its own (`LampPageHolder`); `gone` once it's closed by the
1163
+ /// person (true) or its site allowed (false). A question the person cancels leaves the bar as it was.
1164
+ /// What it gives back takes the bar away without calling `gone`, for a bar another window's choice settled.
1165
+ @discardableResult
1166
+ @MainActor static func show(_ site: String, over view: WKWebView, gone: @escaping @MainActor (_ closed: Bool) -> Void) -> (@MainActor () -> Void)? {
1167
+ guard let holder = LampPageHolder.of(view) else { return nil }
1168
+ var shown: LampPageHolder.Platform?
1169
+ func remove(closed: Bool) {
1170
+ guard let bar = shown else { return }
1171
+ shown = nil
1172
+ holder.hide(bar)
1173
+ gone(closed)
1174
+ }
1175
+ let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "This app"
1176
+ let bar = LampPageBlocked(site: site, allow: {
1177
+ // The page chose what was blocked, and when: the person sees the whole site before it's let in.
1178
+ LampPageGuard.ask("Let \(name) reach this site?", site, button: "Allow", over: view) { yes in
1179
+ guard yes else { return }
1180
+ // Allowed before the bar goes, so the next site's turn is decided under what the person just chose.
1181
+ LampPageGuard.allow(site)
1182
+ remove(closed: false)
1183
+ }
1184
+ }, allowAll: {
1185
+ LampPageGuard.ask("Let \(name) reach any site?", "It can then send what it has anywhere. This stays until you remove the app.",
1186
+ button: "Allow All", over: view) { yes in
1187
+ guard yes else { return }
1188
+ // Every site is let in first: no other site's bar follows this one.
1189
+ LampPageGuard.allowEverySite()
1190
+ remove(closed: false)
1191
+ }
1192
+ }, close: { remove(closed: true) })
1193
+ let hosted = Self.hosting(bar)
1194
+ shown = hosted
1195
+ holder.show(hosted)
1196
+ return {
1197
+ guard let bar = shown else { return }
1198
+ shown = nil
1199
+ holder.hide(bar)
1200
+ }
1201
+ }
1202
+
1203
+ #if os(macOS)
1204
+ @MainActor private static func hosting(_ bar: LampPageBlocked) -> NSView { NSHostingView(rootView: bar) }
1205
+ #else
1206
+ @MainActor private static func hosting(_ bar: LampPageBlocked) -> UIView {
1207
+ let shown = UIHostingController(rootView: bar).view!
1208
+ shown.backgroundColor = .clear
1209
+ return shown
1210
+ }
1211
+ #endif
1212
+ }
1213
+
1214
+ #if os(macOS)
1215
+ typealias LampPageHolderBase = NSView
1216
+ #else
1217
+ typealias LampPageHolderBase = UIView
1218
+ #endif
1219
+
1220
+ /// Where a page sits in its window: the page fills it, but for a bar at its top (`LampPageBlocked`), which has room of
1221
+ /// its own while it's shown, inside the window's safe area (below a title bar, clear of a notch), with the page laid
1222
+ /// out below it. The page is never covered: a control fixed at its top stays where it can be seen and clicked, and
1223
+ /// clicks by point find it where it is. A page laid out at a fixed size of its own (`canvas`) is zoomed to the room it has.
1224
+ @MainActor class LampPageHolder: LampPageHolderBase {
1225
+ typealias Platform = LampPageHolderBase
1226
+
1227
+ let page: WKWebView
1228
+ let canvas: CGSize?
1229
+ private var bar: Platform?
1230
+ private var pageTop: NSLayoutConstraint
1231
+
1232
+ init(holding page: WKWebView, canvas: CGSize? = nil) {
1233
+ self.page = page
1234
+ self.canvas = canvas
1235
+ page.translatesAutoresizingMaskIntoConstraints = false
1236
+ pageTop = NSLayoutConstraint()
1237
+ super.init(frame: .zero)
1238
+ addSubview(page)
1239
+ pageTop = page.topAnchor.constraint(equalTo: topAnchor)
1240
+ NSLayoutConstraint.activate([
1241
+ pageTop, page.leadingAnchor.constraint(equalTo: leadingAnchor), page.trailingAnchor.constraint(equalTo: trailingAnchor),
1242
+ page.bottomAnchor.constraint(equalTo: bottomAnchor),
1243
+ ])
1244
+ }
1245
+
1246
+ required init?(coder: NSCoder) { nil }
1247
+
1248
+ /// The holder of a page, if it has one.
1249
+ static func of(_ page: WKWebView) -> LampPageHolder? {
1250
+ sequence(first: page.superview, next: { $0?.superview }).lazy.compactMap { $0 as? LampPageHolder }.first
1251
+ }
1252
+
1253
+ /// Puts `bar` at the top, in place of any shown, and the page below it.
1254
+ func show(_ shown: Platform) {
1255
+ bar?.removeFromSuperview()
1256
+ bar = shown
1257
+ shown.translatesAutoresizingMaskIntoConstraints = false
1258
+ addSubview(shown)
1259
+ let room = safeAreaLayoutGuide
1260
+ pageTop.isActive = false
1261
+ pageTop = page.topAnchor.constraint(equalTo: shown.bottomAnchor)
1262
+ NSLayoutConstraint.activate([
1263
+ shown.topAnchor.constraint(equalTo: room.topAnchor), shown.centerXAnchor.constraint(equalTo: room.centerXAnchor),
1264
+ shown.widthAnchor.constraint(lessThanOrEqualTo: room.widthAnchor), pageTop,
1265
+ ])
1266
+ }
1267
+
1268
+ /// Takes `shown` away, if it's the bar shown, and gives the page its whole room again.
1269
+ func hide(_ shown: Platform) {
1270
+ guard bar === shown else { return }
1271
+ shown.removeFromSuperview()
1272
+ bar = nil
1273
+ pageTop.isActive = false
1274
+ pageTop = page.topAnchor.constraint(equalTo: topAnchor)
1275
+ pageTop.isActive = true
1276
+ }
1277
+
1278
+ #if os(macOS)
1279
+ override func layout() {
1280
+ super.layout()
1281
+ LampPageView.fit(page, in: page.bounds.size, canvas: canvas)
1282
+ }
1283
+ #else
1284
+ override func layoutSubviews() {
1285
+ super.layoutSubviews()
1286
+ LampPageView.fit(page, in: page.bounds.size, canvas: canvas)
1287
+ }
1288
+ #endif
1289
+ }
1290
+
1291
+ /// The web view the page is shown in: its sound and pictures without a tap first, nothing to scroll or bounce.
1292
+ @MainActor enum LampPageView {
1293
+ /// A page that logs its own errors (a game's runtime does) leaves `catchErrors` off, or each would be logged twice.
1294
+ /// `live` takes Lamp's changes while the app is open (`LampPageLive`). `gameChanges`, a game's page: one that doesn't
1295
+ /// run on Lamp's game runtime still takes a change while it's played where it can (`LampPageLive.gameScript`), where
1296
+ /// any other page opens again for it.
1297
+ static func make(_ files: LampPageFiles, catchErrors: Bool = true, live: Bool = false, gameChanges: Bool = false) -> WKWebView {
1298
+ let configuration = WKWebViewConfiguration()
1299
+ configuration.setURLSchemeHandler(files, forURLScheme: files.scheme)
1300
+ // LampAI and the page's calls of this device, and its console into the app's log.
1301
+ LampPage.attach(to: configuration, scheme: files.scheme, catchErrors: catchErrors)
1302
+ // A shared page: held to its own files and its sites.
1303
+ LampPageGuard.attach(to: configuration, scheme: files.scheme)
1304
+ let changes = live ? LampPageLive(files: files) : nil
1305
+ if let changes { configuration.userContentController.add(changes, contentWorld: .page, name: "lampLive") }
1306
+ if changes != nil, gameChanges {
1307
+ configuration.userContentController.addUserScript(WKUserScript(source: LampPageLive.gameScript, injectionTime: .atDocumentStart,
1308
+ forMainFrameOnly: true, in: .page))
1309
+ }
1310
+ configuration.mediaTypesRequiringUserActionForPlayback = []
1311
+ #if os(iOS)
1312
+ configuration.allowsInlineMediaPlayback = true
1313
+ #endif
1314
+ let view = WKWebView(frame: .zero, configuration: configuration)
1315
+ #if os(macOS)
1316
+ view.setValue(false, forKey: "drawsBackground")
1317
+ #else
1318
+ view.isOpaque = false
1319
+ view.backgroundColor = .black
1320
+ view.scrollView.isScrollEnabled = false
1321
+ view.scrollView.bounces = false
1322
+ view.scrollView.contentInsetAdjustmentBehavior = .never
1323
+ #endif
1324
+ changes?.start(view)
1325
+ LampPageGuard.watch(view, scheme: files.scheme)
1326
+ #if os(macOS)
1327
+ LampPageReader.watch(view)
1328
+ // The app menu's Reload, when Lamp has no change waiting: the page of the window in front opens again, a game
1329
+ // where the player was. The pages are the reader's (`LampPageReader.page`), so a closed window's isn't chosen.
1330
+ LampReload.page = {
1331
+ LampPageReader.page(in: NSApp.keyWindow ?? NSApp.mainWindow).map(reopen)
1332
+ }
1333
+ #endif
1334
+ view.load(URLRequest(url: URL(string: "\(files.scheme)://app/index.html")!))
1335
+ return view
1336
+ }
1337
+
1338
+ // begin LampPageView.reopen
1339
+ /// Opens the page again from its files, its document and workers made afresh (so it's served under the rules as they
1340
+ /// are now), the way Lamp's Reload does: a game keeps where the player was (`__lampReloadNow` in lamp-game.js saves it
1341
+ /// for this session first, and the game takes it back as it opens); any other page opens again as a browser reloads.
1342
+ static func reopen(_ view: WKWebView) {
1343
+ view.evaluateJavaScript("window.__lampReloadNow ? window.__lampReloadNow() : location.reload()", in: nil, in: .page) { _ in }
1344
+ }
1345
+ // end LampPageView.reopen
1346
+
1347
+ /// A page laid out at a fixed size of its own (`canvas`), zoomed to the space it has; nil leaves it at the window's.
1348
+ static func fit(_ view: WKWebView, in size: CGSize, canvas: CGSize?) {
1349
+ guard let canvas, size.width > 0, size.height > 0 else { return }
1350
+ let zoom = min(size.width / canvas.width, size.height / canvas.height)
1351
+ if abs(view.pageZoom - zoom) > 0.001 { view.pageZoom = zoom }
1352
+ }
1353
+ }
1354
+
1355
+ #if os(macOS)
1356
+ // MARK: - The page as text
1357
+
1358
+ /// The page's own things for the screen as text that Lamp's builder reads and acts on (`LampPageItems` in
1359
+ /// LampSupport.swift): the accessibility tree LampSupport reads stops at a web view's edge, so the page is read by a
1360
+ /// script in it, the same one Lamp's preview reads a page with, and each thing's box is turned into pixels of the window's
1361
+ /// picture. Clicks and keys then reach the page as the person's do, at those points.
1362
+ @MainActor enum LampPageReader {
1363
+ private static let pages = NSHashTable<WKWebView>.weakObjects()
1364
+ /// What each window's pages held when last read, by the window's number.
1365
+ private static var read: [Int: [LampScreen.Item]] = [:]
1366
+ private static let script = "{{PAGE_SCREEN_SCRIPT}}"
1367
+ /// A click that says what it landed on, and won't click a thing named that's dimmed or under another (`PageScreen.click`).
1368
+ private static let click = "{{PAGE_CLICK_SCRIPT}}"
1369
+
1370
+ /// The page shown in a window, or, with none there, the first shown anywhere: what the app menu's Reload reopens.
1371
+ static func page(in window: NSWindow?) -> WKWebView? {
1372
+ let shown = pages.allObjects.filter { $0.window != nil }
1373
+ return shown.first { $0.window === window } ?? shown.first { $0.window?.isVisible == true } ?? shown.first
1374
+ }
1375
+
1376
+ static func watch(_ view: WKWebView) {
1377
+ pages.add(view)
1378
+ LampPageItems.refresh = refresh
1379
+ LampPageItems.items = { read[$0.windowNumber] ?? [] }
1380
+ LampPageItems.act = { act($0, at: $1, meant: $2, in: $3, said: $4) }
1381
+ LampPageItems.own = { own($0, batch: $1) }
1382
+ LampPageItems.hold = { hold($0, in: $1, for: $2, said: $3) }
1383
+ }
1384
+
1385
+ /// A game's controller given to Lamp for batch `id`'s `use_app` moves (`seconds` it may run), or back (nil), in every
1386
+ /// page shown (lamp-game.js, "Lamp's hands on the controller"): for the whole batch the person's input takes it back.
1387
+ /// Given back by name, so a batch's end that reaches the page late gives back nothing a newer batch has.
1388
+ private static func own(_ seconds: Double?, batch id: String) {
1389
+ let name = quoted(id)
1390
+ let call = seconds.map { "window.__lampOwn && window.__lampOwn(\($0), \(name))" } ?? "window.__lampOwn && window.__lampOwn(null, \(name))"
1391
+ for page in pages.allObjects where page.window != nil { page.evaluateJavaScript(call) }
1392
+ }
1393
+
1394
+ /// Each hold's name in the page: its batch's id and a number of its own.
1395
+ private static var holds = 0
1396
+
1397
+ /// A game's controller held for a `stick` or `button` action of `batch`, in the game's own script, for `amount`
1398
+ /// seconds and let go after, or at once if the batch stops or a newer one takes its place. What the report says is the
1399
+ /// page's answer: held, or why not (no Lamp runtime in the page yet, or the page failing). False when the app has no
1400
+ /// game. The hold is let go by its name, so a release that reaches the page late lets go of nothing newer.
1401
+ private static func hold(_ action: LampDriver.Action, in window: NSWindow, for batch: LampDriver.Handover,
1402
+ said: @escaping @MainActor (String, Bool) -> Void) -> Bool {
1403
+ let shown = pages.allObjects.filter { $0.window === window }
1404
+ guard LampPageItems.playing != nil, let held = action.controller, let page = shown.first ?? pages.allObjects.first(where: { $0.window != nil })
1405
+ else { return false }
1406
+ let seconds = min(max(action.amount ?? 0, 1.0 / 60), 10)
1407
+ guard let json = try? JSONSerialization.data(withJSONObject: held.merging(["seconds": seconds]) { $1 }) else { return false }
1408
+ holds += 1
1409
+ let name = quoted("\(batch.id)#\(holds)")
1410
+ let release = { page.evaluateJavaScript("window.__lampDrive && window.__lampDrive(null, \(name))") }
1411
+ let call = "window.__lampDrive(\(String(decoding: json, as: UTF8.self)), \(name), \(quoted(batch.id)))"
1412
+ page.evaluateJavaScript("window.__lampDrive ? \(call) : 'missing'") { answer, error in
1413
+ MainActor.assumeIsolated {
1414
+ // Stopped or replaced while the page answered (the batch gave up waiting for it, too): nothing more is
1415
+ // Lamp's, and the batch has said how it ended, so this answer adds nothing to its report.
1416
+ guard batch.carriesOn else {
1417
+ if answer as? Bool == true { release() }
1418
+ return
1419
+ }
1420
+ if answer as? String == "ended" {
1421
+ return said("\(action.action): stopped before the game took it", false)
1422
+ }
1423
+ if let error {
1424
+ return said("\(action.action): the game's page couldn't take it (\(error.localizedDescription))", false)
1425
+ }
1426
+ guard answer as? Bool == true else {
1427
+ return said("\(action.action): the game didn't take Lamp's controller: its page has no Lamp game runtime running (still loading, or built by an older Lamp)", false)
1428
+ }
1429
+ batch.hold(for: seconds, release: release)
1430
+ said(action.text ?? "held the game's controller for \(LampScreen.number(seconds)) s", true)
1431
+ }
1432
+ }
1433
+ return true
1434
+ }
1435
+
1436
+ /// A string as a script's literal.
1437
+ private static func quoted(_ text: String) -> String {
1438
+ (try? JSONSerialization.data(withJSONObject: [text])).map { String(decoding: $0.dropFirst().dropLast(), as: UTF8.self) } ?? "null"
1439
+ }
1440
+
1441
+ /// The keys an action names, as the page reads them (`e.key`, and the code lamp.js and older pages read).
1442
+ private static let keys: [String: (key: String, code: Int)] = [
1443
+ "left": ("ArrowLeft", 37), "up": ("ArrowUp", 38), "right": ("ArrowRight", 39), "down": ("ArrowDown", 40),
1444
+ "return": ("Enter", 13), "enter": ("Enter", 13), "ok": ("Enter", 13), "escape": ("Escape", 27), "back": ("Escape", 27),
1445
+ "space": (" ", 32), "tab": ("Tab", 9), "delete": ("Backspace", 8),
1446
+ // A remote's media and color keys, by the codes a TV sends and lamp.js names (as the preview presses them).
1447
+ "play": ("MediaPlay", 415), "pause": ("MediaPause", 19), "playpause": ("MediaPlayPause", 10252), "stop": ("MediaStop", 413),
1448
+ "fastforward": ("MediaFastForward", 417), "rewind": ("MediaRewind", 412), "red": ("ColorF0Red", 403),
1449
+ "green": ("ColorF1Green", 404), "yellow": ("ColorF2Yellow", 405), "blue": ("ColorF3Blue", 406),
1450
+ "channelup": ("ChannelUp", 427), "channeldown": ("ChannelDown", 428), "info": ("Info", 457),
1451
+ ]
1452
+
1453
+ /// A click, a key or typing on a page, carried out in the page: the point (in the window's frame view) turned into
1454
+ /// the page's own, or, with none, whatever the page has focused while it holds the window's keyboard. A click's
1455
+ /// outcome is the page's answer, given to `said` once it comes (with whether it clicked): what it landed on, and for
1456
+ /// the thing `meant` (one of the page's own, as listed), whether it was that thing at all.
1457
+ private static func act(_ action: LampDriver.Action, at point: NSPoint?, meant: LampScreen.Item?, in window: NSWindow,
1458
+ said: @escaping @MainActor (String, Bool) -> Void) -> String? {
1459
+ guard let frame = window.contentView?.superview ?? window.contentView else { return nil }
1460
+ let shown = pages.allObjects.filter { $0.window === window }
1461
+ let page: WKWebView, spot: String
1462
+ var css = CGPoint.zero, zoom: CGFloat = 1
1463
+ if let point {
1464
+ guard let hit = shown.first(where: { $0.frame.contains($0.superview?.convert(point, from: frame) ?? .zero) }) else { return nil }
1465
+ let local = hit.convert(point, from: frame)
1466
+ zoom = max(hit.pageZoom, 0.01)
1467
+ let y = hit.isFlipped ? local.y : hit.bounds.height - local.y
1468
+ page = hit
1469
+ css = CGPoint(x: local.x / zoom, y: y / zoom)
1470
+ spot = "document.elementFromPoint(\(css.x), \(css.y))"
1471
+ } else {
1472
+ guard let focused = shown.first(where: { page in
1473
+ (window.firstResponder as? NSView).map { $0 === page || $0.isDescendant(of: page) } ?? false
1474
+ }) ?? (shown.count == 1 ? shown.first : nil) else { return nil }
1475
+ page = focused
1476
+ spot = "document.activeElement || document.body"
1477
+ }
1478
+ func run(_ source: String) { page.evaluateJavaScript("(function () { var el = \(spot); if (!el) return ''; \(source) return ''; })();") }
1479
+ switch action.action {
1480
+ case "click", "double_click":
1481
+ guard point != nil else { return nil }
1482
+ let clicks = action.action == "click" ? 1 : 2
1483
+ // The thing as the page's read listed it, with its box in the page's own pixels: the page clicks that very
1484
+ // thing, once it's checked it's still there, at its middle (the point).
1485
+ let scale = LampRuntime.scale(of: window)
1486
+ let want = meant.map { "{ ref: \($0.ref ?? -1), w: \($0.frame.width / scale / zoom), h: \($0.frame.height / scale / zoom) }" } ?? "null"
1487
+ page.evaluateJavaScript(click + "(\(css.x), \(css.y), \(want), \(clicks))") { answer, error in
1488
+ MainActor.assumeIsolated {
1489
+ guard error == nil, let json = answer as? String,
1490
+ let told = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any], let words = told["said"] as? String else {
1491
+ return said("the page couldn't take the click (\(error?.localizedDescription ?? "it gave no answer")), so it may not have happened", false)
1492
+ }
1493
+ let did = told["did"] as? Bool ?? false
1494
+ // Sent, not proof: a game takes a click in its own time, and may not take it at all.
1495
+ said(words + (did && action.page == true ? "; the picture shows whether the game took it" : ""), did)
1496
+ }
1497
+ }
1498
+ return LampPageItems.sent
1499
+ case "type":
1500
+ let text = action.text ?? ""
1501
+ let quoted = String(decoding: (try? JSONSerialization.data(withJSONObject: [text])) ?? Data("[\"\"]".utf8), as: UTF8.self)
1502
+ run("""
1503
+ if (!('value' in el)) return '';
1504
+ el.focus();
1505
+ el.value = \(quoted)[0];
1506
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1507
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1508
+ """)
1509
+ if action.page == true { return (text.isEmpty ? "cleared it" : "typed \"\(text)\"") + " in the game's page; the picture shows whether the game took it" }
1510
+ return text.isEmpty ? "cleared it" : "typed \"\(text)\" in the page"
1511
+ case "key":
1512
+ let name = (action.key ?? "").lowercased()
1513
+ let single = name.count == 1 ? name.unicodeScalars.first : nil
1514
+ guard let key = keys[name] ?? single.flatMap({ $0.isASCII && CharacterSet.alphanumerics.contains($0) ? (String($0), Int(String($0).uppercased().unicodeScalars.first!.value)) : nil }) else {
1515
+ return nil // a shortcut with ⌘ and the like: the app's menus have it
1516
+ }
1517
+ let hold = min(max(action.amount ?? 0, 0), 10)
1518
+ // A game reads which key it is by `code` (KeyW, Digit1, Space), where the page's forms read `key`.
1519
+ let code = single.map { $0.properties.numericType != nil ? "Digit\($0)" : "Key\(String($0).uppercased())" }
1520
+ ?? (key.key == " " ? "Space" : key.key)
1521
+ let fire = """
1522
+ function fire(type) {
1523
+ var e = new KeyboardEvent(type, { bubbles: true, cancelable: true, key: \(String(reflecting: key.key)), code: \(String(reflecting: code)) });
1524
+ Object.defineProperty(e, 'keyCode', { get: function () { return \(key.code); } });
1525
+ Object.defineProperty(e, 'which', { get: function () { return \(key.code); } });
1526
+ return el.dispatchEvent(e);
1527
+ }
1528
+ """
1529
+ if point != nil { run("if (el.focus) el.focus();") }
1530
+ run(fire + """
1531
+ var allowed = fire('keydown');
1532
+ if (allowed && \(key.code) === 13 && /^(BUTTON|A)$/.test(el.tagName)) el.click();
1533
+ if (\(hold > 0 ? "false" : "true")) fire('keyup');
1534
+ """)
1535
+ // Let go after the hold, or as soon as the person takes the app back or a newer batch begins (`Handover`).
1536
+ if hold > 0 {
1537
+ if let batch = LampDriver.Handover.current { batch.hold(for: hold) { run(fire + "fire('keyup');") } }
1538
+ else { run(fire + "fire('keyup');") }
1539
+ }
1540
+ return hold > 0 ? "held \(name) for \(LampScreen.number(hold)) s in the page" : "pressed \(name) in the page"
1541
+ default:
1542
+ return nil
1543
+ }
1544
+ }
1545
+
1546
+ /// Reads every page on screen again, then calls `done`: once each has answered, or after a second whatever there is.
1547
+ private static func refresh(_ done: @escaping @MainActor () -> Void) {
1548
+ let shown = pages.allObjects.filter { $0.window?.isVisible == true }
1549
+ var waiting = shown.count, called = false
1550
+ func finish() {
1551
+ guard !called else { return }
1552
+ called = true
1553
+ done()
1554
+ }
1555
+ guard waiting > 0 else { read = [:]; return finish() }
1556
+ var fresh: [Int: [LampScreen.Item]] = [:]
1557
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { MainActor.assumeIsolated { finish() } }
1558
+ for page in shown {
1559
+ page.evaluateJavaScript(script) { result, _ in
1560
+ MainActor.assumeIsolated {
1561
+ if let window = page.window, let json = result as? String,
1562
+ let list = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]] {
1563
+ fresh[window.windowNumber, default: []] += items(list, of: page, in: window)
1564
+ }
1565
+ waiting -= 1
1566
+ if waiting == 0 {
1567
+ read = fresh
1568
+ finish()
1569
+ }
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+
1575
+ /// The page's things with their boxes in the window's picture: its pixels from the top left of the window with its
1576
+ /// title bar, as LampSupport pictures it.
1577
+ private static func items(_ list: [[String: Any]], of page: WKWebView, in window: NSWindow) -> [LampScreen.Item] {
1578
+ guard let frame = window.contentView?.superview ?? window.contentView else { return [] }
1579
+ let box = page.convert(page.bounds, to: frame)
1580
+ let top = frame.isFlipped ? box.minY : frame.bounds.height - box.maxY
1581
+ let scale = LampRuntime.scale(of: window), zoom = page.pageZoom
1582
+ return list.compactMap { entry in
1583
+ guard let kind = entry["kind"] as? String else { return nil }
1584
+ func n(_ key: String) -> CGFloat { CGFloat((entry[key] as? NSNumber)?.doubleValue ?? 0) * zoom }
1585
+ let role = ["button": "button", "field": "text field", "choice": "choice", "image": "picture"][kind] ?? "text"
1586
+ return LampScreen.Item(role: role, label: entry["name"] as? String ?? "", value: entry["value"] as? String ?? "",
1587
+ frame: CGRect(x: (box.minX + n("x")) * scale, y: (top + n("y")) * scale, width: n("w") * scale, height: n("h") * scale),
1588
+ selected: entry["focused"] as? Bool ?? false, ref: (entry["ref"] as? NSNumber)?.intValue)
1589
+ }
1590
+ }
1591
+ }
1592
+ #endif
1593
+
1594
+ // MARK: - Changes while it's open
1595
+
1596
+ /// Lamp's changes to the page while the app is open. The app asks Lamp what has changed (a request Lamp answers when
1597
+ /// there's a change, or after a while with nothing), fetches the files that differ from its bundle into its caches, has
1598
+ /// `LampPageFiles` serve them in place of the bundle's, and tells the page (`window.__lampLive` in lamp-game.js), which
1599
+ /// puts new pictures, models, sounds and the code its modules take in as it runs, and opens itself again for code they
1600
+ /// don't, keeping the player's place. A game's page that doesn't run on the runtime is told by `gameScript`, which puts
1601
+ /// in what it safely can and holds the rest for Reload; any other page opens again.
1602
+ /// What the page did is told back to Lamp, which says so in the chat. The files are kept for the next launch of this
1603
+ /// same bundle, so the app opens as Lamp last changed it; a new bundle (Lamp installed the app again) starts from its own.
1604
+ @MainActor final class LampPageLive: NSObject, WKScriptMessageHandler {
1605
+ #if os(macOS)
1606
+ /// The one page this app takes changes for, which says when it has drawn (`LampPagePlaying`).
1607
+ private(set) weak static var current: LampPageLive?
1608
+ #endif
1609
+ private let files: LampPageFiles
1610
+ /// A game's page that doesn't run on Lamp's game runtime takes changes with this (lamp-live.js, written in by Lamp),
1611
+ /// given to it before any script of its own; the runtime, as it loads, takes over from it.
1612
+ static let gameScript = "{{PAGE_LIVE_SCRIPT}}"
1613
+ private weak var page: WKWebView?
1614
+ private var epoch = "", gen = 0
1615
+ private var have: [String: String] = [:]
1616
+ private var served: Set<String> = []
1617
+ /// The phone page Lamp last handed this copy, nil for the one in its bundle.
1618
+ private var phone: (version: String, files: [String: String])?
1619
+ /// Under the app's own name: an app that isn't sandboxed shares the caches folder with every other.
1620
+ private static let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
1621
+ .appendingPathComponent(Bundle.main.bundleIdentifier ?? "LampPage").appendingPathComponent("LampPage")
1622
+ private static var manifest: URL { cache.appendingPathComponent("changes.json") }
1623
+ /// Where this copy is, for Lamp: a phone's copy takes a change when Lamp installs it again, not this way.
1624
+ #if os(macOS)
1625
+ private static let place = "mac"
1626
+ #elseif targetEnvironment(simulator)
1627
+ private static let place = "simulator"
1628
+ #else
1629
+ private static let place = "phone"
1630
+ #endif
1631
+
1632
+ /// This bundle, by the build of the page it carries (lamp-build.js, a name for its files) and its program: a new
1633
+ /// install is another. On the Mac every game is a copy of one player, so the program alone doesn't tell them apart.
1634
+ private static let bundle: String = {
1635
+ let page = Bundle.main.resourceURL?.appendingPathComponent("Web/lamp-build.js")
1636
+ let built = page.flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? ""
1637
+ guard let url = Bundle.main.executableURL,
1638
+ let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) else { return built }
1639
+ return built + "\(Int(values.contentModificationDate?.timeIntervalSince1970 ?? 0))-\(values.fileSize ?? 0)"
1640
+ }()
1641
+
1642
+ init(files: LampPageFiles) {
1643
+ self.files = files
1644
+ super.init()
1645
+ #if os(macOS)
1646
+ Self.current = self
1647
+ #endif
1648
+ // The changes this bundle was last given, kept from an earlier launch.
1649
+ if let data = try? Data(contentsOf: Self.manifest), let kept = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1650
+ kept["bundle"] as? String == Self.bundle, let hashes = kept["files"] as? [String: String] {
1651
+ let gone = Set(kept["gone"] as? [String] ?? [])
1652
+ if hashes.values.allSatisfy({ FileManager.default.fileExists(atPath: Self.cache.appendingPathComponent($0).path) }) {
1653
+ have = hashes
1654
+ served = gone
1655
+ files.take(changed: hashes.mapValues { Self.cache.appendingPathComponent($0) }, gone: gone)
1656
+ }
1657
+ if let version = kept["phone"] as? String, let hashes = kept["phoneFiles"] as? [String: String],
1658
+ FileManager.default.fileExists(atPath: Self.phoneFolder(version).path) {
1659
+ phone = (version, hashes)
1660
+ LampControllers.use(page: Self.phoneFolder(version), version: version)
1661
+ }
1662
+ }
1663
+ }
1664
+
1665
+ /// Where a phone page Lamp handed over is kept, one folder to a version: written whole, then served.
1666
+ private static func phoneFolder(_ version: String) -> URL { cache.appendingPathComponent("phone-" + version) }
1667
+
1668
+ func start(_ view: WKWebView) {
1669
+ page = view
1670
+ guard Self.place != "phone" else { return }
1671
+ Task { await listen() }
1672
+ }
1673
+
1674
+ private func listen() async {
1675
+ var pause: UInt64 = 2
1676
+ while page != nil {
1677
+ do {
1678
+ let answer = try await call("/app/page/live", ["after": gen, "epoch": epoch, "place": Self.place, "bundle": Self.bundle, "phone": true], timeout: 60)
1679
+ pause = 2
1680
+ if let e = answer["epoch"] as? String, e != epoch { epoch = e; gen = 0 }
1681
+ if answer["send"] as? String == "saved" { await sendSaved(); continue }
1682
+ guard let next = answer["gen"] as? Int, next > gen || answer["now"] as? Bool == true else { continue }
1683
+ try await take(answer, gen: next)
1684
+ } catch {
1685
+ // Lamp closed, or not reachable: asked again in a while.
1686
+ try? await Task.sleep(nanoseconds: pause * 1_000_000_000)
1687
+ pause = min(pause * 2, 30)
1688
+ }
1689
+ }
1690
+ }
1691
+
1692
+ private func take(_ answer: [String: Any], gen next: Int) async throws {
1693
+ let hashes = answer["files"] as? [String: String] ?? [:]
1694
+ let gone = Set(answer["gone"] as? [String] ?? [])
1695
+ let now = answer["now"] as? Bool ?? false
1696
+ let newPhone = (answer["phone"] as? [String: Any]).flatMap { said -> (version: String, files: [String: String])? in
1697
+ guard let version = said["version"] as? String, let files = said["files"] as? [String: String] else { return nil }
1698
+ return (version, files)
1699
+ }
1700
+ let phoneChanged = newPhone?.version != phone?.version
1701
+ // What this copy already serves (Lamp asked again after its own restart): nothing for the page to do.
1702
+ if hashes == have, gone == served, !now, !phoneChanged { gen = next; return }
1703
+ try FileManager.default.createDirectory(at: Self.cache, withIntermediateDirectories: true)
1704
+ for (_, hash) in hashes where !FileManager.default.fileExists(atPath: Self.cache.appendingPathComponent(hash).path) {
1705
+ let file = try await call("/app/page/file", ["place": Self.place, "hash": hash], timeout: 60)
1706
+ // Kept under its hash only when it is what the hash names.
1707
+ guard let text = file["data"] as? String, let data = Data(base64Encoded: text), Self.name(of: data) == hash else {
1708
+ throw URLError(.cannotDecodeContentData)
1709
+ }
1710
+ try data.write(to: Self.cache.appendingPathComponent(hash), options: .atomic)
1711
+ }
1712
+ if phoneChanged { try await takePhone(newPhone) }
1713
+ files.take(changed: hashes.mapValues { Self.cache.appendingPathComponent($0) }, gone: gone)
1714
+ // Files no copy names any more go.
1715
+ let kept = Set(hashes.values)
1716
+ for old in Set(have.values).subtracting(kept) { try? FileManager.default.removeItem(at: Self.cache.appendingPathComponent(old)) }
1717
+ have = hashes
1718
+ served = gone
1719
+ var manifest: [String: Any] = ["bundle": Self.bundle, "files": hashes, "gone": Array(gone)]
1720
+ if let phone { manifest["phone"] = phone.version; manifest["phoneFiles"] = phone.files }
1721
+ if let data = try? JSONSerialization.data(withJSONObject: manifest) { try? data.write(to: Self.manifest, options: .atomic) }
1722
+ gen = next
1723
+ let pageChanged = answer["changed"] as? [String] ?? []
1724
+ // Only the phone page: the game's own page has nothing to do.
1725
+ if pageChanged.isEmpty, !now { tell(next, "phone"); return }
1726
+ let change: [String: Any] = ["gen": next, "changed": pageChanged, "removed": answer["removed"] as? [String] ?? [], "now": now, "pill": Self.place != "mac",
1727
+ "read": files.asked]
1728
+ guard let page, let json = try? JSONSerialization.data(withJSONObject: change) else { return }
1729
+ let taken = try? await page.evaluateJavaScript("window.__lampLive ? window.__lampLive(\(String(decoding: json, as: UTF8.self))) : false", in: nil, contentWorld: .page)
1730
+ // A page that takes no changes (a web app's, a screen's) is opened again as it is.
1731
+ if taken as? Bool != true {
1732
+ page.reload()
1733
+ tell(next, "reloading")
1734
+ }
1735
+ }
1736
+
1737
+ /// Serves the phone page Lamp handed over, whole: its files fetched and checked, written to a folder of its own and
1738
+ /// only then swapped in, so a phone reads the old page or the new one and never a mix. Nil: back to the bundle's.
1739
+ private func takePhone(_ new: (version: String, files: [String: String])?) async throws {
1740
+ let bundled = Bundle.main.resourceURL?.appendingPathComponent("Phone") ?? URL(fileURLWithPath: "/nonexistent")
1741
+ guard let new else {
1742
+ LampControllers.use(page: bundled, version: nil)
1743
+ if let old = phone { try? FileManager.default.removeItem(at: Self.phoneFolder(old.version)) }
1744
+ phone = nil
1745
+ return
1746
+ }
1747
+ let folder = Self.phoneFolder(new.version)
1748
+ if !FileManager.default.fileExists(atPath: folder.path) {
1749
+ let partial = Self.cache.appendingPathComponent("partial-" + UUID().uuidString)
1750
+ defer { try? FileManager.default.removeItem(at: partial) }
1751
+ for (path, hash) in new.files {
1752
+ let file = try await call("/app/page/file", ["place": Self.place, "hash": hash], timeout: 60)
1753
+ guard let text = file["data"] as? String, let data = Data(base64Encoded: text), Self.name(of: data) == hash,
1754
+ !path.split(separator: "/").contains(where: { $0 == ".." || $0.hasPrefix(".") }) else { throw URLError(.cannotDecodeContentData) }
1755
+ let target = partial.appendingPathComponent(path)
1756
+ try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
1757
+ try data.write(to: target)
1758
+ }
1759
+ try FileManager.default.moveItem(at: partial, to: folder)
1760
+ }
1761
+ LampControllers.use(page: folder, version: new.version)
1762
+ if let old = phone, old.version != new.version { try? FileManager.default.removeItem(at: Self.phoneFolder(old.version)) }
1763
+ phone = new
1764
+ }
1765
+
1766
+ /// What the page has saved (its localStorage, where LampStore keeps it), for Lamp's preview of the app to start from:
1767
+ /// Lamp asks for it as a preview opens, and nothing comes back from the preview.
1768
+ private func sendSaved() async {
1769
+ let read = "(function () { var o = {}; for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); o[k] = localStorage.getItem(k); } return JSON.stringify(o); })()"
1770
+ guard let page, let json = try? await page.evaluateJavaScript(read, in: nil, contentWorld: .defaultClient) as? String,
1771
+ let data = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: String] else { return }
1772
+ _ = try? await call("/app/page/saved", ["place": Self.place, "data": data], timeout: 10)
1773
+ }
1774
+
1775
+ /// A file's name in the caches, as Lamp names it: the first half of its SHA-256.
1776
+ private static func name(of data: Data) -> String {
1777
+ SHA256.hash(data: data).prefix(16).map { String(format: "%02x", $0) }.joined()
1778
+ }
1779
+
1780
+ /// What the page did with a change (lamp-game.js): put in, reopened, waiting for a pause, back where it was.
1781
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
1782
+ guard message.frameInfo.isMainFrame, let said = message.body as? [String: Any], let state = said["state"] as? String else { return }
1783
+ // A report also says what was built again, what waits for Reload and how long the game stood still for it, and
1784
+ // a held change whether it goes in at the game's next pause by itself: passed on as the page gave them.
1785
+ let more = said.filter { ["built", "kept", "waits", "pause", "frozen", "firstFrame", "later"].contains($0.key) }
1786
+ tell(said["gen"] as? Int ?? gen, state, why: said["why"] as? String, more: more)
1787
+ }
1788
+
1789
+ #if os(macOS)
1790
+ /// The page has drawn: told to Lamp as this process's, so Lamp can tell this launch from an earlier one.
1791
+ func sayPlaying() { tell(gen, "playing", more: ["pid": Int(ProcessInfo.processInfo.processIdentifier)]) }
1792
+ #endif
1793
+
1794
+ /// `why` is the page's reason for opening again or holding a change (code it couldn't swap in), or what came through.
1795
+ private func tell(_ gen: Int, _ state: String, why: String? = nil, more: [String: Any] = [:]) {
1796
+ var body: [String: Any] = ["gen": gen, "state": state, "epoch": epoch, "place": Self.place]
1797
+ if let why { body["why"] = String(why.prefix(600)) }
1798
+ for (key, value) in more where JSONSerialization.isValidJSONObject([key: value]) { body[key] = value }
1799
+ Task { _ = try? await call("/app/page/state", body, timeout: 10) }
1800
+ }
1801
+
1802
+ private func call(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> [String: Any] {
1803
+ guard let url = URL(string: lampGateway + path) else { throw URLError(.badURL) }
1804
+ var request = URLRequest(url: url, timeoutInterval: timeout)
1805
+ request.httpMethod = "POST"
1806
+ request.setValue("Bearer " + LampInstall.token, forHTTPHeaderField: "Authorization")
1807
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
1808
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
1809
+ let (data, response) = try await URLSession.shared.data(for: request)
1810
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
1811
+ return (try JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
1812
+ }
1813
+ }