@lampdotdev/lamp 0.0.0-stage → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (464) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +357 -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 +1646 -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/scaffold/Game/game.cpp +142 -0
  29. package/Resources/GameKit/scaffold/Game/scene.glsl +49 -0
  30. package/Resources/GameKit/scaffold-web/Web/game.js +31 -0
  31. package/Resources/GameKit/scaffold-web/Web/index.html +13 -0
  32. package/Resources/GameKit/scaffold-web/Web/making.js +33 -0
  33. package/Resources/GameKit/scaffold-web/Web/world.js +33 -0
  34. package/Resources/GameKit/web/lamp-game.js +3036 -0
  35. package/Resources/GameKit/web/lamp-phaser.js +128 -0
  36. package/Resources/WebHost/App.exe +0 -0
  37. package/Resources/WebHost/Microsoft.Web.WebView2.Core.dll +0 -0
  38. package/Resources/WebHost/WebView2-LICENSE.txt +27 -0
  39. package/Resources/WebHost/lamp_controllers.h +1506 -0
  40. package/Resources/WebHost/lamp_web_host.cpp +1173 -0
  41. package/Resources/WebLibraries/earlier.txt +318 -0
  42. package/Resources/WebLibraries/phaser/LICENSE.md +20 -0
  43. package/Resources/WebLibraries/phaser/NOTICE.md +2 -0
  44. package/Resources/WebLibraries/phaser/phaser.esm.min.js +1 -0
  45. package/Resources/WebLibraries/qrcode/LICENSE +21 -0
  46. package/Resources/WebLibraries/qrcode/NOTICE.md +4 -0
  47. package/Resources/WebLibraries/qrcode/qrcode.js +2297 -0
  48. package/Resources/WebLibraries/rapier/LICENSE +201 -0
  49. package/Resources/WebLibraries/rapier/NOTICE.md +3 -0
  50. package/Resources/WebLibraries/rapier/rapier.js +2 -0
  51. package/Resources/WebLibraries/three/LICENSE +21 -0
  52. package/Resources/WebLibraries/three/NOTICE.md +4 -0
  53. package/Resources/WebLibraries/three/addons/animation/AnimationClipCreator.js +168 -0
  54. package/Resources/WebLibraries/three/addons/animation/CCDIKSolver.js +607 -0
  55. package/Resources/WebLibraries/three/addons/controls/ArcballControls.js +3543 -0
  56. package/Resources/WebLibraries/three/addons/controls/DragControls.js +452 -0
  57. package/Resources/WebLibraries/three/addons/controls/FirstPersonControls.js +512 -0
  58. package/Resources/WebLibraries/three/addons/controls/FlyControls.js +384 -0
  59. package/Resources/WebLibraries/three/addons/controls/MapControls.js +116 -0
  60. package/Resources/WebLibraries/three/addons/controls/OrbitControls.js +1985 -0
  61. package/Resources/WebLibraries/three/addons/controls/PointerLockControls.js +264 -0
  62. package/Resources/WebLibraries/three/addons/controls/TrackballControls.js +1106 -0
  63. package/Resources/WebLibraries/three/addons/controls/TransformControls.js +2003 -0
  64. package/Resources/WebLibraries/three/addons/curves/CurveExtras.js +694 -0
  65. package/Resources/WebLibraries/three/addons/curves/NURBSCurve.js +169 -0
  66. package/Resources/WebLibraries/three/addons/curves/NURBSSurface.js +98 -0
  67. package/Resources/WebLibraries/three/addons/curves/NURBSUtils.js +532 -0
  68. package/Resources/WebLibraries/three/addons/curves/NURBSVolume.js +82 -0
  69. package/Resources/WebLibraries/three/addons/effects/AnaglyphEffect.js +274 -0
  70. package/Resources/WebLibraries/three/addons/effects/AsciiEffect.js +323 -0
  71. package/Resources/WebLibraries/three/addons/effects/OutlineEffect.js +489 -0
  72. package/Resources/WebLibraries/three/addons/effects/ParallaxBarrierEffect.js +155 -0
  73. package/Resources/WebLibraries/three/addons/effects/StereoEffect.js +91 -0
  74. package/Resources/WebLibraries/three/addons/environments/ColorEnvironment.js +59 -0
  75. package/Resources/WebLibraries/three/addons/environments/DebugEnvironment.js +102 -0
  76. package/Resources/WebLibraries/three/addons/environments/RoomEnvironment.js +185 -0
  77. package/Resources/WebLibraries/three/addons/geometries/BoxLineGeometry.js +92 -0
  78. package/Resources/WebLibraries/three/addons/geometries/ConvexGeometry.js +72 -0
  79. package/Resources/WebLibraries/three/addons/geometries/DecalGeometry.js +420 -0
  80. package/Resources/WebLibraries/three/addons/geometries/LoftGeometry.js +355 -0
  81. package/Resources/WebLibraries/three/addons/geometries/ParametricFunctions.js +100 -0
  82. package/Resources/WebLibraries/three/addons/geometries/ParametricGeometry.js +172 -0
  83. package/Resources/WebLibraries/three/addons/geometries/RoundedBoxGeometry.js +216 -0
  84. package/Resources/WebLibraries/three/addons/geometries/TeapotGeometry.js +689 -0
  85. package/Resources/WebLibraries/three/addons/geometries/TextGeometry.js +102 -0
  86. package/Resources/WebLibraries/three/addons/helpers/AnimationPathHelper.js +302 -0
  87. package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelper.js +166 -0
  88. package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelperWebGL.js +221 -0
  89. package/Resources/WebLibraries/three/addons/helpers/LightProbeHelper.js +166 -0
  90. package/Resources/WebLibraries/three/addons/helpers/LightProbeHelperGPU.js +102 -0
  91. package/Resources/WebLibraries/three/addons/helpers/OctreeHelper.js +109 -0
  92. package/Resources/WebLibraries/three/addons/helpers/PositionalAudioHelper.js +169 -0
  93. package/Resources/WebLibraries/three/addons/helpers/RapierHelper.js +59 -0
  94. package/Resources/WebLibraries/three/addons/helpers/RectAreaLightHelper.js +118 -0
  95. package/Resources/WebLibraries/three/addons/helpers/TextureHelper.js +265 -0
  96. package/Resources/WebLibraries/three/addons/helpers/TextureHelperGPU.js +214 -0
  97. package/Resources/WebLibraries/three/addons/helpers/VertexNormalsHelper.js +155 -0
  98. package/Resources/WebLibraries/three/addons/helpers/VertexTangentsHelper.js +133 -0
  99. package/Resources/WebLibraries/three/addons/helpers/ViewHelper.js +529 -0
  100. package/Resources/WebLibraries/three/addons/interactive/HTMLMesh.js +617 -0
  101. package/Resources/WebLibraries/three/addons/interactive/InteractiveGroup.js +224 -0
  102. package/Resources/WebLibraries/three/addons/interactive/SelectionBox.js +324 -0
  103. package/Resources/WebLibraries/three/addons/interactive/SelectionHelper.js +150 -0
  104. package/Resources/WebLibraries/three/addons/libs/fflate.module.js +2699 -0
  105. package/Resources/WebLibraries/three/addons/libs/lil-gui.module.min.js +8 -0
  106. package/Resources/WebLibraries/three/addons/libs/meshopt_decoder.module.js +196 -0
  107. package/Resources/WebLibraries/three/addons/libs/potpack.module.js +125 -0
  108. package/Resources/WebLibraries/three/addons/libs/stats.module.js +167 -0
  109. package/Resources/WebLibraries/three/addons/libs/tween.module.js +876 -0
  110. package/Resources/WebLibraries/three/addons/lighting/ClusteredLighting.js +153 -0
  111. package/Resources/WebLibraries/three/addons/lighting/DynamicLighting.js +82 -0
  112. package/Resources/WebLibraries/three/addons/lighting/LightMapper.js +295 -0
  113. package/Resources/WebLibraries/three/addons/lighting/LightProbeGrid.js +923 -0
  114. package/Resources/WebLibraries/three/addons/lighting/LightProbeGridUtils.js +107 -0
  115. package/Resources/WebLibraries/three/addons/lighting/LightProbeGridWebGL.js +846 -0
  116. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIConeTracer.js +243 -0
  117. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIIrradiance.js +43 -0
  118. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGINode.js +617 -0
  119. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGISceneCollector.js +793 -0
  120. package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIVolume.js +1440 -0
  121. package/Resources/WebLibraries/three/addons/lights/LightProbeGenerator.js +337 -0
  122. package/Resources/WebLibraries/three/addons/lights/RectAreaLightTexturesLib.js +127 -0
  123. package/Resources/WebLibraries/three/addons/lights/RectAreaLightUniformsLib.js +40 -0
  124. package/Resources/WebLibraries/three/addons/lights/SunLight.js +100 -0
  125. package/Resources/WebLibraries/three/addons/lights/SunLightNode.js +56 -0
  126. package/Resources/WebLibraries/three/addons/lights/SunLightShadow.js +319 -0
  127. package/Resources/WebLibraries/three/addons/lights/SunShadowNode.js +312 -0
  128. package/Resources/WebLibraries/three/addons/lines/Line2.js +56 -0
  129. package/Resources/WebLibraries/three/addons/lines/LineGeometry.js +157 -0
  130. package/Resources/WebLibraries/three/addons/lines/LineMaterial.js +742 -0
  131. package/Resources/WebLibraries/three/addons/lines/LineSegments2.js +434 -0
  132. package/Resources/WebLibraries/three/addons/lines/LineSegmentsGeometry.js +298 -0
  133. package/Resources/WebLibraries/three/addons/lines/Wireframe.js +108 -0
  134. package/Resources/WebLibraries/three/addons/lines/WireframeGeometry2.js +49 -0
  135. package/Resources/WebLibraries/three/addons/lines/webgpu/Line2.js +46 -0
  136. package/Resources/WebLibraries/three/addons/lines/webgpu/LineSegments2.js +419 -0
  137. package/Resources/WebLibraries/three/addons/lines/webgpu/Wireframe.js +86 -0
  138. package/Resources/WebLibraries/three/addons/loaders/EXRLoader.js +3411 -0
  139. package/Resources/WebLibraries/three/addons/loaders/FBXLoader.js +4660 -0
  140. package/Resources/WebLibraries/three/addons/loaders/FontLoader.js +262 -0
  141. package/Resources/WebLibraries/three/addons/loaders/GLTFLoader.js +5006 -0
  142. package/Resources/WebLibraries/three/addons/loaders/HDRLoader.js +463 -0
  143. package/Resources/WebLibraries/three/addons/loaders/MTLLoader.js +601 -0
  144. package/Resources/WebLibraries/three/addons/loaders/OBJLoader.js +961 -0
  145. package/Resources/WebLibraries/three/addons/loaders/PLYLoader.js +992 -0
  146. package/Resources/WebLibraries/three/addons/loaders/RGBELoader.js +18 -0
  147. package/Resources/WebLibraries/three/addons/loaders/STLLoader.js +423 -0
  148. package/Resources/WebLibraries/three/addons/loaders/SVGLoader.js +3297 -0
  149. package/Resources/WebLibraries/three/addons/loaders/TGALoader.js +539 -0
  150. package/Resources/WebLibraries/three/addons/loaders/UltraHDRLoader.js +757 -0
  151. package/Resources/WebLibraries/three/addons/math/Capsule.js +159 -0
  152. package/Resources/WebLibraries/three/addons/math/ColorConverter.js +62 -0
  153. package/Resources/WebLibraries/three/addons/math/ColorSpaces.js +147 -0
  154. package/Resources/WebLibraries/three/addons/math/ConvexHull.js +1695 -0
  155. package/Resources/WebLibraries/three/addons/math/ImprovedNoise.js +88 -0
  156. package/Resources/WebLibraries/three/addons/math/Lut.js +317 -0
  157. package/Resources/WebLibraries/three/addons/math/MeshSurfaceSampler.js +315 -0
  158. package/Resources/WebLibraries/three/addons/math/OBB.js +535 -0
  159. package/Resources/WebLibraries/three/addons/math/Octree.js +825 -0
  160. package/Resources/WebLibraries/three/addons/math/SimplexNoise.js +470 -0
  161. package/Resources/WebLibraries/three/addons/misc/ConvexObjectBreaker.js +539 -0
  162. package/Resources/WebLibraries/three/addons/misc/GPUComputationRenderer.js +508 -0
  163. package/Resources/WebLibraries/three/addons/misc/Gyroscope.js +78 -0
  164. package/Resources/WebLibraries/three/addons/misc/MorphAnimMesh.js +119 -0
  165. package/Resources/WebLibraries/three/addons/misc/MorphBlendMesh.js +425 -0
  166. package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMap.js +370 -0
  167. package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMapGPU.js +322 -0
  168. package/Resources/WebLibraries/three/addons/misc/RollerCoaster.js +661 -0
  169. package/Resources/WebLibraries/three/addons/misc/Sculptor.js +1679 -0
  170. package/Resources/WebLibraries/three/addons/misc/SculptorMesh.js +1504 -0
  171. package/Resources/WebLibraries/three/addons/misc/SculptorTools.js +1180 -0
  172. package/Resources/WebLibraries/three/addons/misc/SculptorUtils.js +345 -0
  173. package/Resources/WebLibraries/three/addons/misc/TileCreasedNormalsPlugin.js +270 -0
  174. package/Resources/WebLibraries/three/addons/misc/TubePainter.js +611 -0
  175. package/Resources/WebLibraries/three/addons/misc/Volume.js +520 -0
  176. package/Resources/WebLibraries/three/addons/misc/VolumeSlice.js +275 -0
  177. package/Resources/WebLibraries/three/addons/modifiers/CurveModifier.js +374 -0
  178. package/Resources/WebLibraries/three/addons/modifiers/CurveModifierGPU.js +256 -0
  179. package/Resources/WebLibraries/three/addons/modifiers/EdgeSplitModifier.js +299 -0
  180. package/Resources/WebLibraries/three/addons/modifiers/TessellateModifier.js +340 -0
  181. package/Resources/WebLibraries/three/addons/objects/GroundedSkybox.js +69 -0
  182. package/Resources/WebLibraries/three/addons/objects/Lensflare.js +489 -0
  183. package/Resources/WebLibraries/three/addons/objects/LensflareMesh.js +376 -0
  184. package/Resources/WebLibraries/three/addons/objects/MarchingCubes.js +1261 -0
  185. package/Resources/WebLibraries/three/addons/objects/Reflector.js +390 -0
  186. package/Resources/WebLibraries/three/addons/objects/ReflectorForSSRPass.js +393 -0
  187. package/Resources/WebLibraries/three/addons/objects/Refractor.js +389 -0
  188. package/Resources/WebLibraries/three/addons/objects/ShadowMesh.js +130 -0
  189. package/Resources/WebLibraries/three/addons/objects/Sky.js +342 -0
  190. package/Resources/WebLibraries/three/addons/objects/SkyMesh.js +403 -0
  191. package/Resources/WebLibraries/three/addons/objects/Water.js +374 -0
  192. package/Resources/WebLibraries/three/addons/objects/Water2.js +403 -0
  193. package/Resources/WebLibraries/three/addons/objects/Water2Mesh.js +199 -0
  194. package/Resources/WebLibraries/three/addons/objects/WaterMesh.js +194 -0
  195. package/Resources/WebLibraries/three/addons/postprocessing/AfterimagePass.js +187 -0
  196. package/Resources/WebLibraries/three/addons/postprocessing/BloomPass.js +274 -0
  197. package/Resources/WebLibraries/three/addons/postprocessing/BokehPass.js +218 -0
  198. package/Resources/WebLibraries/three/addons/postprocessing/ClearPass.js +97 -0
  199. package/Resources/WebLibraries/three/addons/postprocessing/CubeTexturePass.js +146 -0
  200. package/Resources/WebLibraries/three/addons/postprocessing/DotScreenPass.js +114 -0
  201. package/Resources/WebLibraries/three/addons/postprocessing/EffectComposer.js +365 -0
  202. package/Resources/WebLibraries/three/addons/postprocessing/FXAAPass.js +40 -0
  203. package/Resources/WebLibraries/three/addons/postprocessing/FilmPass.js +113 -0
  204. package/Resources/WebLibraries/three/addons/postprocessing/GTAOPass.js +735 -0
  205. package/Resources/WebLibraries/three/addons/postprocessing/GlitchPass.js +177 -0
  206. package/Resources/WebLibraries/three/addons/postprocessing/HalftonePass.js +134 -0
  207. package/Resources/WebLibraries/three/addons/postprocessing/LUTPass.js +138 -0
  208. package/Resources/WebLibraries/three/addons/postprocessing/MaskPass.js +195 -0
  209. package/Resources/WebLibraries/three/addons/postprocessing/OutlinePass.js +776 -0
  210. package/Resources/WebLibraries/three/addons/postprocessing/OutputPass.js +148 -0
  211. package/Resources/WebLibraries/three/addons/postprocessing/Pass.js +191 -0
  212. package/Resources/WebLibraries/three/addons/postprocessing/RenderPass.js +193 -0
  213. package/Resources/WebLibraries/three/addons/postprocessing/RenderPixelatedPass.js +314 -0
  214. package/Resources/WebLibraries/three/addons/postprocessing/RenderTransitionPass.js +267 -0
  215. package/Resources/WebLibraries/three/addons/postprocessing/SAOPass.js +407 -0
  216. package/Resources/WebLibraries/three/addons/postprocessing/SMAAPass.js +230 -0
  217. package/Resources/WebLibraries/three/addons/postprocessing/SSAARenderPass.js +313 -0
  218. package/Resources/WebLibraries/three/addons/postprocessing/SSAOPass.js +527 -0
  219. package/Resources/WebLibraries/three/addons/postprocessing/SSRPass.js +858 -0
  220. package/Resources/WebLibraries/three/addons/postprocessing/SavePass.js +132 -0
  221. package/Resources/WebLibraries/three/addons/postprocessing/ShaderPass.js +135 -0
  222. package/Resources/WebLibraries/three/addons/postprocessing/TAARenderPass.js +243 -0
  223. package/Resources/WebLibraries/three/addons/postprocessing/TexturePass.js +131 -0
  224. package/Resources/WebLibraries/three/addons/postprocessing/UnrealBloomPass.js +541 -0
  225. package/Resources/WebLibraries/three/addons/shaders/ACESFilmicToneMappingShader.js +96 -0
  226. package/Resources/WebLibraries/three/addons/shaders/AfterimageShader.js +63 -0
  227. package/Resources/WebLibraries/three/addons/shaders/BasicShader.js +36 -0
  228. package/Resources/WebLibraries/three/addons/shaders/BleachBypassShader.js +68 -0
  229. package/Resources/WebLibraries/three/addons/shaders/BlendShader.js +56 -0
  230. package/Resources/WebLibraries/three/addons/shaders/BokehShader.js +151 -0
  231. package/Resources/WebLibraries/three/addons/shaders/BokehShader2.js +404 -0
  232. package/Resources/WebLibraries/three/addons/shaders/BrightnessContrastShader.js +62 -0
  233. package/Resources/WebLibraries/three/addons/shaders/ColorCorrectionShader.js +59 -0
  234. package/Resources/WebLibraries/three/addons/shaders/ColorifyShader.js +57 -0
  235. package/Resources/WebLibraries/three/addons/shaders/ConvolutionShader.js +74 -0
  236. package/Resources/WebLibraries/three/addons/shaders/CopyShader.js +52 -0
  237. package/Resources/WebLibraries/three/addons/shaders/DOFMipMapShader.js +63 -0
  238. package/Resources/WebLibraries/three/addons/shaders/DepthLimitedBlurShader.js +183 -0
  239. package/Resources/WebLibraries/three/addons/shaders/DigitalGlitch.js +104 -0
  240. package/Resources/WebLibraries/three/addons/shaders/DotScreenShader.js +75 -0
  241. package/Resources/WebLibraries/three/addons/shaders/ExposureShader.js +51 -0
  242. package/Resources/WebLibraries/three/addons/shaders/FXAAShader.js +298 -0
  243. package/Resources/WebLibraries/three/addons/shaders/FilmShader.js +73 -0
  244. package/Resources/WebLibraries/three/addons/shaders/FocusShader.js +94 -0
  245. package/Resources/WebLibraries/three/addons/shaders/FreiChenShader.js +103 -0
  246. package/Resources/WebLibraries/three/addons/shaders/GTAOShader.js +437 -0
  247. package/Resources/WebLibraries/three/addons/shaders/GammaCorrectionShader.js +52 -0
  248. package/Resources/WebLibraries/three/addons/shaders/HalftoneShader.js +332 -0
  249. package/Resources/WebLibraries/three/addons/shaders/HorizontalBlurShader.js +68 -0
  250. package/Resources/WebLibraries/three/addons/shaders/HorizontalTiltShiftShader.js +70 -0
  251. package/Resources/WebLibraries/three/addons/shaders/HueSaturationShader.js +74 -0
  252. package/Resources/WebLibraries/three/addons/shaders/KaleidoShader.js +65 -0
  253. package/Resources/WebLibraries/three/addons/shaders/LuminosityHighPassShader.js +68 -0
  254. package/Resources/WebLibraries/three/addons/shaders/LuminosityShader.js +54 -0
  255. package/Resources/WebLibraries/three/addons/shaders/MirrorShader.js +62 -0
  256. package/Resources/WebLibraries/three/addons/shaders/NormalMapShader.js +60 -0
  257. package/Resources/WebLibraries/three/addons/shaders/OutputShader.js +103 -0
  258. package/Resources/WebLibraries/three/addons/shaders/PoissonDenoiseShader.js +230 -0
  259. package/Resources/WebLibraries/three/addons/shaders/RGBShiftShader.js +61 -0
  260. package/Resources/WebLibraries/three/addons/shaders/SAOShader.js +201 -0
  261. package/Resources/WebLibraries/three/addons/shaders/SMAAShader.js +489 -0
  262. package/Resources/WebLibraries/three/addons/shaders/SSAOShader.js +331 -0
  263. package/Resources/WebLibraries/three/addons/shaders/SSRShader.js +396 -0
  264. package/Resources/WebLibraries/three/addons/shaders/SepiaShader.js +57 -0
  265. package/Resources/WebLibraries/three/addons/shaders/SobelOperatorShader.js +98 -0
  266. package/Resources/WebLibraries/three/addons/shaders/SubsurfaceScatteringShader.js +95 -0
  267. package/Resources/WebLibraries/three/addons/shaders/TechnicolorShader.js +51 -0
  268. package/Resources/WebLibraries/three/addons/shaders/ToonShader.js +349 -0
  269. package/Resources/WebLibraries/three/addons/shaders/TriangleBlurShader.js +79 -0
  270. package/Resources/WebLibraries/three/addons/shaders/UnpackDepthRGBAShader.js +60 -0
  271. package/Resources/WebLibraries/three/addons/shaders/VelocityShader.js +137 -0
  272. package/Resources/WebLibraries/three/addons/shaders/VerticalBlurShader.js +66 -0
  273. package/Resources/WebLibraries/three/addons/shaders/VerticalTiltShiftShader.js +70 -0
  274. package/Resources/WebLibraries/three/addons/shaders/VignetteShader.js +56 -0
  275. package/Resources/WebLibraries/three/addons/shaders/VolumeShader.js +285 -0
  276. package/Resources/WebLibraries/three/addons/shaders/WaterRefractionShader.js +106 -0
  277. package/Resources/WebLibraries/three/addons/textures/FlakesTexture.js +54 -0
  278. package/Resources/WebLibraries/three/addons/tsl/WebGLNodesHandler.js +607 -0
  279. package/Resources/WebLibraries/three/addons/tsl/display/AfterImageNode.js +256 -0
  280. package/Resources/WebLibraries/three/addons/tsl/display/AnaglyphPassNode.js +550 -0
  281. package/Resources/WebLibraries/three/addons/tsl/display/BilateralBlurNode.js +377 -0
  282. package/Resources/WebLibraries/three/addons/tsl/display/BleachBypass.js +33 -0
  283. package/Resources/WebLibraries/three/addons/tsl/display/BloomNode.js +599 -0
  284. package/Resources/WebLibraries/three/addons/tsl/display/CRT.js +150 -0
  285. package/Resources/WebLibraries/three/addons/tsl/display/ChromaticAberrationNode.js +163 -0
  286. package/Resources/WebLibraries/three/addons/tsl/display/DenoiseNode.js +354 -0
  287. package/Resources/WebLibraries/three/addons/tsl/display/DepthOfFieldNode.js +572 -0
  288. package/Resources/WebLibraries/three/addons/tsl/display/DotScreenNode.js +104 -0
  289. package/Resources/WebLibraries/three/addons/tsl/display/FSR1Node.js +481 -0
  290. package/Resources/WebLibraries/three/addons/tsl/display/FXAANode.js +357 -0
  291. package/Resources/WebLibraries/three/addons/tsl/display/FilmNode.js +101 -0
  292. package/Resources/WebLibraries/three/addons/tsl/display/GTAONode.js +727 -0
  293. package/Resources/WebLibraries/three/addons/tsl/display/GaussianBlurNode.js +412 -0
  294. package/Resources/WebLibraries/three/addons/tsl/display/GodraysNode.js +604 -0
  295. package/Resources/WebLibraries/three/addons/tsl/display/ImportanceSampledEnvironment.js +564 -0
  296. package/Resources/WebLibraries/three/addons/tsl/display/LensflareNode.js +282 -0
  297. package/Resources/WebLibraries/three/addons/tsl/display/Lut3DNode.js +109 -0
  298. package/Resources/WebLibraries/three/addons/tsl/display/MotionBlur.js +33 -0
  299. package/Resources/WebLibraries/three/addons/tsl/display/OITPassNode.js +341 -0
  300. package/Resources/WebLibraries/three/addons/tsl/display/OutlineNode.js +916 -0
  301. package/Resources/WebLibraries/three/addons/tsl/display/ParallaxBarrierPassNode.js +90 -0
  302. package/Resources/WebLibraries/three/addons/tsl/display/PixelationPassNode.js +227 -0
  303. package/Resources/WebLibraries/three/addons/tsl/display/RGBShiftNode.js +96 -0
  304. package/Resources/WebLibraries/three/addons/tsl/display/RecurrentDenoiseNode.js +812 -0
  305. package/Resources/WebLibraries/three/addons/tsl/display/RetroPassNode.js +263 -0
  306. package/Resources/WebLibraries/three/addons/tsl/display/SMAANode.js +729 -0
  307. package/Resources/WebLibraries/three/addons/tsl/display/SSAAPassNode.js +344 -0
  308. package/Resources/WebLibraries/three/addons/tsl/display/SSAONode.js +422 -0
  309. package/Resources/WebLibraries/three/addons/tsl/display/SSGINode.js +698 -0
  310. package/Resources/WebLibraries/three/addons/tsl/display/SSRNode.js +1430 -0
  311. package/Resources/WebLibraries/three/addons/tsl/display/SSSNode.js +479 -0
  312. package/Resources/WebLibraries/three/addons/tsl/display/Sepia.js +24 -0
  313. package/Resources/WebLibraries/three/addons/tsl/display/Shape.js +29 -0
  314. package/Resources/WebLibraries/three/addons/tsl/display/SharpenNode.js +284 -0
  315. package/Resources/WebLibraries/three/addons/tsl/display/SobelOperatorNode.js +168 -0
  316. package/Resources/WebLibraries/three/addons/tsl/display/StereoCompositePassNode.js +190 -0
  317. package/Resources/WebLibraries/three/addons/tsl/display/StereoPassNode.js +117 -0
  318. package/Resources/WebLibraries/three/addons/tsl/display/TAAUNode.js +700 -0
  319. package/Resources/WebLibraries/three/addons/tsl/display/TRAANode.js +609 -0
  320. package/Resources/WebLibraries/three/addons/tsl/display/TemporalReprojectNode.js +1024 -0
  321. package/Resources/WebLibraries/three/addons/tsl/display/TransitionNode.js +141 -0
  322. package/Resources/WebLibraries/three/addons/tsl/display/boxBlur.js +65 -0
  323. package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlend.js +80 -0
  324. package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlur.js +63 -0
  325. package/Resources/WebLibraries/three/addons/tsl/display/hashBlur.js +55 -0
  326. package/Resources/WebLibraries/three/addons/tsl/display/lensDirt.js +30 -0
  327. package/Resources/WebLibraries/three/addons/tsl/display/radialBlur.js +68 -0
  328. package/Resources/WebLibraries/three/addons/tsl/lighting/ClusteredLightsNode.js +994 -0
  329. package/Resources/WebLibraries/three/addons/tsl/lighting/DynamicLightsNode.js +201 -0
  330. package/Resources/WebLibraries/three/addons/tsl/lighting/LightProbeGridNode.js +183 -0
  331. package/Resources/WebLibraries/three/addons/tsl/lighting/data/AmbientLightDataNode.js +67 -0
  332. package/Resources/WebLibraries/three/addons/tsl/lighting/data/DirectionalLightDataNode.js +117 -0
  333. package/Resources/WebLibraries/three/addons/tsl/lighting/data/HemisphereLightDataNode.js +105 -0
  334. package/Resources/WebLibraries/three/addons/tsl/lighting/data/PointLightDataNode.js +140 -0
  335. package/Resources/WebLibraries/three/addons/tsl/lighting/data/SpotLightDataNode.js +167 -0
  336. package/Resources/WebLibraries/three/addons/tsl/math/Bayer.js +84 -0
  337. package/Resources/WebLibraries/three/addons/tsl/math/curlNoise.js +107 -0
  338. package/Resources/WebLibraries/three/addons/tsl/math/voronoiNoise.js +112 -0
  339. package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNode.js +542 -0
  340. package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNodeHelper.js +212 -0
  341. package/Resources/WebLibraries/three/addons/tsl/utils/GroundedSkybox.js +62 -0
  342. package/Resources/WebLibraries/three/addons/tsl/utils/RNoise.js +51 -0
  343. package/Resources/WebLibraries/three/addons/tsl/utils/Raymarching.js +70 -0
  344. package/Resources/WebLibraries/three/addons/tsl/utils/SoftParticles.js +53 -0
  345. package/Resources/WebLibraries/three/addons/tsl/utils/SpecularHelpers.js +302 -0
  346. package/Resources/WebLibraries/three/addons/tsl/utils/TAAUtils.js +184 -0
  347. package/Resources/WebLibraries/three/addons/utils/BufferGeometryUtils.js +1523 -0
  348. package/Resources/WebLibraries/three/addons/utils/CameraUtils.js +82 -0
  349. package/Resources/WebLibraries/three/addons/utils/ColorUtils.js +76 -0
  350. package/Resources/WebLibraries/three/addons/utils/GaussianSplatUtils.js +229 -0
  351. package/Resources/WebLibraries/three/addons/utils/GeometryCompressionUtils.js +547 -0
  352. package/Resources/WebLibraries/three/addons/utils/GeometryUtils.js +226 -0
  353. package/Resources/WebLibraries/three/addons/utils/LDrawUtils.js +211 -0
  354. package/Resources/WebLibraries/three/addons/utils/SceneOptimizer.js +459 -0
  355. package/Resources/WebLibraries/three/addons/utils/SceneUtils.js +363 -0
  356. package/Resources/WebLibraries/three/addons/utils/ShadowMapViewer.js +244 -0
  357. package/Resources/WebLibraries/three/addons/utils/ShadowMapViewerGPU.js +233 -0
  358. package/Resources/WebLibraries/three/addons/utils/SkeletonUtils.js +496 -0
  359. package/Resources/WebLibraries/three/addons/utils/SortUtils.js +175 -0
  360. package/Resources/WebLibraries/three/addons/utils/UVsDebug.js +173 -0
  361. package/Resources/WebLibraries/three/addons/utils/WebGLTextureUtils.js +115 -0
  362. package/Resources/WebLibraries/three/addons/utils/WebGPUTextureUtils.js +81 -0
  363. package/Resources/WebLibraries/three/addons/utils/WorkerPool.js +167 -0
  364. package/Resources/WebLibraries/three/addons/utils/unwrapUVs.js +865 -0
  365. package/Resources/WebLibraries/three/three.core.js +61162 -0
  366. package/Resources/WebLibraries/three/three.module.js +19537 -0
  367. package/Resources/WebLibraries/three/three.tsl.js +41903 -0
  368. package/Resources/WebLibraries/three/three.webgpu.js +53714 -0
  369. package/Resources/skills/2d-game/SKILL.md +305 -0
  370. package/Resources/skills/3d-game/SKILL.md +274 -0
  371. package/Resources/skills/accounts/SKILL.md +41 -0
  372. package/Resources/skills/android-design/SKILL.md +159 -0
  373. package/Resources/skills/app-ai/SKILL.md +154 -0
  374. package/Resources/skills/app-intents/SKILL.md +31 -0
  375. package/Resources/skills/app-kinds/SKILL.md +122 -0
  376. package/Resources/skills/apple-watch/SKILL.md +43 -0
  377. package/Resources/skills/assets/SKILL.md +183 -0
  378. package/Resources/skills/building-blocks/SKILL.md +97 -0
  379. package/Resources/skills/device-control/SKILL.md +56 -0
  380. package/Resources/skills/device-integrations/SKILL.md +278 -0
  381. package/Resources/skills/finder-files/SKILL.md +19 -0
  382. package/Resources/skills/game/SKILL.md +414 -0
  383. package/Resources/skills/game-3d/SKILL.md +12 -0
  384. package/Resources/skills/game-art/SKILL.md +11 -0
  385. package/Resources/skills/game-feel/SKILL.md +13 -0
  386. package/Resources/skills/game-levels/SKILL.md +12 -0
  387. package/Resources/skills/game-live/SKILL.md +12 -0
  388. package/Resources/skills/game-physics/SKILL.md +13 -0
  389. package/Resources/skills/game-playtest/SKILL.md +26 -0
  390. package/Resources/skills/game-ui/SKILL.md +278 -0
  391. package/Resources/skills/github-projects/SKILL.md +59 -0
  392. package/Resources/skills/icloud-sync/SKILL.md +30 -0
  393. package/Resources/skills/iphone-hardware/SKILL.md +57 -0
  394. package/Resources/skills/iphone-widgets/SKILL.md +53 -0
  395. package/Resources/skills/lg-developer-mode/SKILL.md +152 -0
  396. package/Resources/skills/mac-widgets/SKILL.md +60 -0
  397. package/Resources/skills/maps/SKILL.md +8 -0
  398. package/Resources/skills/menu-bar/SKILL.md +36 -0
  399. package/Resources/skills/more-apps/SKILL.md +53 -0
  400. package/Resources/skills/phone-controllers/SKILL.md +79 -0
  401. package/Resources/skills/phone-pages/SKILL.md +173 -0
  402. package/Resources/skills/roku-developer-mode/SKILL.md +106 -0
  403. package/Resources/skills/scene-3d/SKILL.md +27 -0
  404. package/Resources/skills/scripting/SKILL.md +17 -0
  405. package/Resources/skills/share-extension/SKILL.md +22 -0
  406. package/Resources/skills/tv-design/SKILL.md +72 -0
  407. package/Resources/skills/web-app/SKILL.md +136 -0
  408. package/Resources/skills/web-game/SKILL.md +95 -0
  409. package/Resources/skills/web-or-native/SKILL.md +103 -0
  410. package/Resources/skills/wish-links/SKILL.md +156 -0
  411. package/THIRD_PARTY_NOTICES.md +170 -0
  412. package/cli/android.mjs +941 -0
  413. package/cli/browser.mjs +268 -0
  414. package/cli/calls.mjs +390 -0
  415. package/cli/dev.mjs +773 -0
  416. package/cli/devices.mjs +984 -0
  417. package/cli/lamp.mjs +1016 -0
  418. package/cli/lampapp.mjs +332 -0
  419. package/cli/lamps/android-host/LampPageGateway.kt +345 -0
  420. package/cli/lamps/android-host/app/build.gradle.kts +40 -0
  421. package/cli/lamps/android-host/build.gradle.kts +4 -0
  422. package/cli/lamps/android-host/game/AndroidManifest.xml +33 -0
  423. package/cli/lamps/android-host/game/MainActivity.kt +173 -0
  424. package/cli/lamps/android-host/gradle/libs.versions.toml +39 -0
  425. package/cli/lamps/android-host/gradle.properties +8 -0
  426. package/cli/lamps/android-host/page/AndroidManifest.xml +35 -0
  427. package/cli/lamps/android-host/page/MainActivity.kt +193 -0
  428. package/cli/lamps/android-host/settings.gradle.kts +30 -0
  429. package/cli/lamps/android-host/tools.json +43 -0
  430. package/cli/lamps/lamp-controllers.js +2 -0
  431. package/cli/lamps/lamp-for-games.js +6863 -0
  432. package/cli/lamps/lamp.js +7023 -0
  433. package/cli/lamps/mac-starter/.lamp/meta.json +1 -0
  434. package/cli/lamps/mac-starter/APP.md +12 -0
  435. package/cli/lamps/mac-starter/Package.swift +8 -0
  436. package/cli/lamps/mac-starter/Sources/App.swift +11 -0
  437. package/cli/lamps/mac-starter/Sources/ContentView.swift +17 -0
  438. package/cli/lamps/mac-starter/Sources/LampSupport.swift +18097 -0
  439. package/cli/lamps/mac-starter/build.sh +336 -0
  440. package/cli/lamps/mac-starter/gitignore +3 -0
  441. package/cli/lamps/mac-starter/lamp-starter.json +6 -0
  442. package/cli/lamps/web-starter/APP.md +8 -0
  443. package/cli/lamps/web-starter/app.js +1 -0
  444. package/cli/lamps/web-starter/index.html +17 -0
  445. package/cli/lamps/web-starter/lamp-starter.json +5 -0
  446. package/cli/lamps/web-starter/lamp.js +7023 -0
  447. package/cli/lamps/web-starter/style.css +25 -0
  448. package/cli/link.mjs +138 -0
  449. package/cli/mac.mjs +621 -0
  450. package/cli/mdns.mjs +205 -0
  451. package/cli/msix.mjs +413 -0
  452. package/cli/network.mjs +209 -0
  453. package/cli/outside.mjs +140 -0
  454. package/cli/page.js +165 -0
  455. package/cli/place.mjs +298 -0
  456. package/cli/png.mjs +184 -0
  457. package/cli/share.mjs +202 -0
  458. package/cli/skill/SKILL.md +255 -0
  459. package/cli/skills.mjs +71 -0
  460. package/cli/state.mjs +173 -0
  461. package/cli/update.mjs +57 -0
  462. package/package.json +20 -4
  463. package/site/lib/quota.js +59 -0
  464. package/site/lib/shared-wishes.js +996 -0
@@ -0,0 +1,1646 @@
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
+ Self.ask(url, body: call["body"] as? String) { status, body in replyHandler(["status": status, "body": body], nil) }
133
+ }
134
+
135
+ private static func ask(_ url: URL, body: String?, done: @escaping @MainActor @Sendable (Int, String) -> Void) {
136
+ var request = URLRequest(url: url, timeoutInterval: 240)
137
+ request.httpMethod = "POST"
138
+ request.setValue("Bearer " + LampInstall.token, forHTTPHeaderField: "Authorization")
139
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
140
+ request.httpBody = Data((body ?? "{}").utf8)
141
+ URLSession.shared.dataTask(with: request) { data, response, _ in
142
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 502
143
+ let body = data.map { String(decoding: $0, as: UTF8.self) } ?? #"{"error":"Couldn't reach Lamp on your Mac. Open Lamp and try again."}"#
144
+ DispatchQueue.main.async { done(status, body) }
145
+ }.resume()
146
+ }
147
+ }
148
+
149
+ /// The game's phones as controllers (`ControllerRuntime`, in LampControllerKit.swift): its phone page from the copy Lamp
150
+ /// 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
151
+ /// phones join, in this process, opened as the page's `Lamp.controllers` starts and closed as it stops or the game quits.
152
+ ///
153
+ /// On an iPhone or iPad only while the game is in front: a game the person leaves is suspended by the system, which
154
+ /// would leave phones holding buttons on a game that can't hear them, so leaving ends the session (every phone let go,
155
+ /// both listeners closed) and coming back opens a new one at the page's next ask, which phones join by scanning its
156
+ /// code again. A moment away that isn't leaving (a permission's alert, the app switcher pulled half up) ends nothing.
157
+ ///
158
+ /// A page held to its sites keeps the port it reaches the relay at through all of that, on this device alone, since its
159
+ /// rules name that port for as long as it's open (`ControllerRuntime`'s `holdingPagePort`).
160
+ enum LampControllers {
161
+ private static let runtime: ControllerRuntime = {
162
+ let runtime = ControllerRuntime(app: LampInstall.slug.isEmpty ? (Bundle.main.bundleIdentifier ?? "game") : LampInstall.slug,
163
+ page: Bundle.main.resourceURL?.appendingPathComponent("Phone") ?? URL(fileURLWithPath: "/nonexistent"),
164
+ holdingPagePort: LampPageGuard.current != nil)
165
+ runtime.onPagePortMoved = { DispatchQueue.main.async { MainActor.assumeIsolated { LampPageGuard.retirePages() } } }
166
+ #if os(iOS)
167
+ NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in
168
+ runtime.stop {}
169
+ }
170
+ #endif
171
+ return runtime
172
+ }()
173
+
174
+ /// What a held page's rules let it connect to for its phones.
175
+ static var pageSites: [String] { runtime.pageSites() }
176
+
177
+ /// Serves this phone page from now on, under the session that's open (`LampPageLive`). Nil: the bundle's, by its own
178
+ /// version.
179
+ static func use(page: URL, version: String?) {
180
+ runtime.use(page: page, version: version ?? (try? ControllerSnapshot.read(page))?.version ?? "")
181
+ }
182
+
183
+ static func answer(stop: Bool, _ done: @escaping @Sendable (Int, [String: Any]) -> Void) {
184
+ // Both on the runtime's own queue, in the order the page asked: a stop then a start for the next round opens a
185
+ // fresh session, never one the stop then closes.
186
+ if stop { return runtime.stop { done(200, [:]) } }
187
+ guard inFront else { return done(409, ["error": "The game is in the background: phones join again when it's back in front."]) }
188
+ runtime.start(done)
189
+ }
190
+
191
+ /// Whether the game may host phones now: always on the Mac; on an iPhone or iPad unless it's in the background.
192
+ private static var inFront: Bool {
193
+ #if os(iOS)
194
+ // Asked from the page's bridge, on the main thread, where the app's state is read.
195
+ let state = Thread.isMainThread ? MainActor.assumeIsolated { UIApplication.shared.applicationState }
196
+ : DispatchQueue.main.sync { UIApplication.shared.applicationState }
197
+ return state != .background
198
+ #else
199
+ return true
200
+ #endif
201
+ }
202
+ }
203
+
204
+ /// The page's `LampVoice` (Web/lamp.js): what the person says, recorded by this device's microphone as 16 kHz mono
205
+ /// 16-bit PCM in a WAV, for the page to have transcribed (`LampAI.hear`); and what the page says back, read out. Asking
206
+ /// what's possible (`status`) never prompts: only `listen` asks for the microphone. One recording at a time, each tagged
207
+ /// with the page's session; a stop keeps it, a cancel throws it away, and the page reloading or going away (or, on
208
+ /// the iPhone and iPad, the app leaving the screen) cancels it whether the page says so or not.
209
+ @MainActor final class LampPageVoice: NSObject, WKScriptMessageHandlerWithReply, AVSpeechSynthesizerDelegate {
210
+ static let rate = 16_000.0
211
+
212
+ private let scheme: String
213
+ private weak var page: WKWebView?
214
+ private var loading: NSKeyValueObservation?
215
+ private var engine: AVAudioEngine?
216
+ private var session = ""
217
+ private var pcm = Data()
218
+ private var reply: (@MainActor @Sendable (Any?, String?) -> Void)?
219
+ private var end = LampVoiceEnd(), started = 0.0
220
+ /// Asks `end` on the clock, so a recording ends on time even when audio stops coming.
221
+ private var tick: Timer?
222
+ private let voice = AVSpeechSynthesizer()
223
+ /// Each utterance (kept, so its identity isn't reused) with its reply, so a late callback for one never answers another.
224
+ private var spoken: [ObjectIdentifier: (AVSpeechUtterance, @MainActor @Sendable (Any?, String?) -> Void)] = [:]
225
+ #if os(iOS)
226
+ private var leaving: NSObjectProtocol?
227
+ #endif
228
+
229
+ init(scheme: String) {
230
+ self.scheme = scheme
231
+ super.init()
232
+ voice.delegate = self
233
+ #if os(iOS)
234
+ leaving = NotificationCenter.default.addObserver(forName: UIApplication.willResignActiveNotification, object: nil, queue: .main) { [weak self] _ in
235
+ MainActor.assumeIsolated { self?.finish("cancelled") }
236
+ }
237
+ #endif
238
+ }
239
+
240
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
241
+ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
242
+ guard LampPage.isOwnPage(message, scheme: scheme), let call = message.body as? [String: Any], let command = call["cmd"] as? String else {
243
+ return replyHandler(["error": "Not for this page."], nil)
244
+ }
245
+ if page !== message.webView {
246
+ page = message.webView
247
+ // A reload or another page: whatever was being heard is let go.
248
+ loading = message.webView?.observe(\.isLoading, options: [.new]) { [weak self] _, change in
249
+ guard change.newValue == true else { return }
250
+ MainActor.assumeIsolated { self?.finish("cancelled") }
251
+ }
252
+ }
253
+ let tag = call["session"] as? String ?? ""
254
+ switch command {
255
+ case "status": replyHandler(status(), nil)
256
+ case "listen": Task { await listen(session: tag, replyHandler) }
257
+ case "stop": if tag == session { finish("stopped") }; replyHandler(true, nil)
258
+ case "cancel": if tag == session { finish("cancelled") }; replyHandler(true, nil)
259
+ case "speak": speak(call["text"] as? String ?? "", replyHandler)
260
+ case "hush": if voice.isSpeaking { voice.stopSpeaking(at: .immediate) }; replyHandler(true, nil)
261
+ default: replyHandler(["error": "Unknown."], nil)
262
+ }
263
+ }
264
+
265
+ /// What this device can do, without asking anything of the person.
266
+ func status() -> [String: Any] {
267
+ #if os(iOS)
268
+ let input = AVAudioSession.sharedInstance().isInputAvailable
269
+ let permission: String = switch AVAudioApplication.shared.recordPermission {
270
+ case .granted: "granted"; case .denied: "denied"; default: "ask"
271
+ }
272
+ #else
273
+ let input = AVCaptureDevice.default(for: .audio) != nil
274
+ let permission: String = switch AVCaptureDevice.authorizationStatus(for: .audio) {
275
+ case .authorized: "granted"; case .denied, .restricted: "denied"; default: "ask"
276
+ }
277
+ #endif
278
+ return ["listen": input, "speak": true, "permission": permission, "why": input ? "" : "This device has no microphone.",
279
+ "maxSeconds": LampVoiceEnd.maxSeconds]
280
+ }
281
+
282
+ private func listen(session tag: String, _ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) async {
283
+ finish("cancelled")
284
+ session = tag
285
+ #if os(iOS)
286
+ let allowed = await AVAudioApplication.requestRecordPermission()
287
+ #else
288
+ let allowed = await AVCaptureDevice.requestAccess(for: .audio)
289
+ #endif
290
+ // Closed, or another recording asked for, while the system was asking: nothing starts.
291
+ guard session == tag, page != nil else { return replyHandler(["ended": "cancelled"], nil) }
292
+ guard allowed else { session = ""; return replyHandler(["ended": "denied"], nil) }
293
+ if voice.isSpeaking { voice.stopSpeaking(at: .immediate) }
294
+ #if os(iOS)
295
+ try? AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .measurement, options: [.duckOthers, .defaultToSpeaker])
296
+ try? AVAudioSession.sharedInstance().setActive(true, options: .notifyOthersOnDeactivation)
297
+ #endif
298
+ let engine = AVAudioEngine()
299
+ let input = engine.inputNode
300
+ let from = input.outputFormat(forBus: 0)
301
+ guard from.sampleRate > 0, let to = AVAudioFormat(commonFormat: .pcmFormatInt16, sampleRate: Self.rate, channels: 1, interleaved: true),
302
+ let converter = AVAudioConverter(from: from, to: to) else {
303
+ session = ""
304
+ return replyHandler(["ended": "error", "error": "The microphone isn't available."], nil)
305
+ }
306
+ pcm = Data(); end = LampVoiceEnd(); started = ProcessInfo.processInfo.systemUptime
307
+ reply = replyHandler
308
+ input.installTap(onBus: 0, bufferSize: 2048, format: from) { [weak self] buffer, _ in
309
+ // On the audio thread: converted to 16 kHz mono 16-bit here, the rest on the main thread.
310
+ guard let chunk = Self.convert(buffer, with: converter, to: to) else { return }
311
+ Task { @MainActor in self?.heard(chunk, session: tag) }
312
+ }
313
+ do { try engine.start() } catch {
314
+ input.removeTap(onBus: 0)
315
+ reply = nil; session = ""
316
+ return replyHandler(["ended": "error", "error": "The microphone didn't start: \(error.localizedDescription)"], nil)
317
+ }
318
+ self.engine = engine
319
+ tick = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
320
+ MainActor.assumeIsolated { self?.check(session: tag) }
321
+ }
322
+ }
323
+
324
+ private var elapsed: Double { ProcessInfo.processInfo.systemUptime - started }
325
+
326
+ private func check(session tag: String) {
327
+ guard tag == session, engine != nil, let ended = end.ended(at: elapsed) else { return }
328
+ finish(ended)
329
+ }
330
+
331
+ nonisolated private static func convert(_ buffer: AVAudioPCMBuffer, with converter: AVAudioConverter, to format: AVAudioFormat) -> Data? {
332
+ let capacity = AVAudioFrameCount(Double(buffer.frameLength) * format.sampleRate / buffer.format.sampleRate) + 32
333
+ guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return nil }
334
+ var given = false
335
+ var error: NSError?
336
+ converter.convert(to: out, error: &error) { _, status in
337
+ if given { status.pointee = .noDataNow; return nil }
338
+ given = true; status.pointee = .haveData; return buffer
339
+ }
340
+ guard error == nil, out.frameLength > 0, let samples = out.int16ChannelData else { return nil }
341
+ return Data(bytes: samples[0], count: Int(out.frameLength) * 2)
342
+ }
343
+
344
+ /// Each converted chunk: kept, its level told to the page, and whether that ends it asked of `end`.
345
+ private func heard(_ chunk: Data, session tag: String) {
346
+ guard tag == session, engine != nil else { return }
347
+ pcm.append(chunk)
348
+ var sum = 0.0, top = 0.0
349
+ chunk.withUnsafeBytes { raw in
350
+ for s in raw.bindMemory(to: Int16.self) { let v = Double(s) / 32768; sum += v * v; top = max(top, abs(v)) }
351
+ }
352
+ let level = (sum / Double(max(1, chunk.count / 2))).squareRoot()
353
+ end.heard(level: level, top: top, at: elapsed)
354
+ tell(["event": "level", "session": tag, "level": min(1, level * 4), "speech": end.speechAt != nil])
355
+ check(session: tag)
356
+ }
357
+
358
+ /// Ends the recording: kept as a WAV unless it was cancelled, or nothing was said.
359
+ private func finish(_ ended: String) {
360
+ guard let engine else { if ended == "cancelled" { session = "" }; return }
361
+ engine.inputNode.removeTap(onBus: 0)
362
+ engine.stop()
363
+ self.engine = nil
364
+ tick?.invalidate(); tick = nil
365
+ #if os(iOS)
366
+ try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
367
+ #endif
368
+ let seconds = Double(pcm.count / 2) / Self.rate
369
+ var answer: [String: Any] = ["ended": ended, "seconds": seconds, "peak": end.peak, "speech": end.speechAt != nil]
370
+ // The microphone stopped sending mid-way: what it heard may be cut off, so it's an error, not something to act on.
371
+ if ended == "lost" { answer["ended"] = "error"; answer["error"] = "The microphone stopped sending sound. Try again." }
372
+ if ended == "stopped" || ended == "quiet" || ended == "cap" { answer["audio"] = Self.wav(pcm).base64EncodedString() }
373
+ pcm = Data(); session = ""
374
+ reply?(answer, nil); reply = nil
375
+ }
376
+
377
+ static func wav(_ pcm: Data) -> Data {
378
+ var d = Data()
379
+ func u32(_ v: Int) { var x = UInt32(v).littleEndian; d.append(Data(bytes: &x, count: 4)) }
380
+ func u16(_ v: Int) { var x = UInt16(v).littleEndian; d.append(Data(bytes: &x, count: 2)) }
381
+ d.append(Data("RIFF".utf8)); u32(36 + pcm.count); d.append(Data("WAVEfmt ".utf8)); u32(16); u16(1); u16(1)
382
+ u32(Int(rate)); u32(Int(rate) * 2); u16(2); u16(16); d.append(Data("data".utf8)); u32(pcm.count); d.append(pcm)
383
+ return d
384
+ }
385
+
386
+ private func tell(_ message: [String: Any]) {
387
+ guard let page, let data = try? JSONSerialization.data(withJSONObject: message) else { return }
388
+ page.evaluateJavaScript("window.__lampVoice && window.__lampVoice(\(String(decoding: data, as: UTF8.self)))", in: nil, in: .page) { _ in }
389
+ }
390
+
391
+ private func speak(_ text: String, _ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
392
+ guard !text.isEmpty, engine == nil else { return replyHandler(false, nil) }
393
+ if voice.isSpeaking { voice.stopSpeaking(at: .immediate) }
394
+ #if os(iOS)
395
+ try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.duckOthers])
396
+ try? AVAudioSession.sharedInstance().setActive(true)
397
+ #endif
398
+ let utterance = AVSpeechUtterance(string: text)
399
+ utterance.voice = AVSpeechSynthesisVoice(language: Locale.current.identifier.replacingOccurrences(of: "_", with: "-"))
400
+ ?? AVSpeechSynthesisVoice(language: "en-US")
401
+ spoken[ObjectIdentifier(utterance)] = (utterance, replyHandler)
402
+ voice.speak(utterance)
403
+ }
404
+
405
+ nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
406
+ let key = ObjectIdentifier(utterance)
407
+ Task { @MainActor in self.spoken.removeValue(forKey: key)?.1(true, nil) }
408
+ }
409
+ nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
410
+ let key = ObjectIdentifier(utterance)
411
+ Task { @MainActor in self.spoken.removeValue(forKey: key)?.1(false, nil) }
412
+ }
413
+ }
414
+
415
+ /// When one recording ends, from its levels and the clock alone (the same rule as LampVoiceEnd in Android's
416
+ /// LampPageGateway.kt). Speech is a level three times the room's, and above a floor. The room is the quietest it has
417
+ /// been lately: it follows a dip at once and, until speech is heard, rises back over a couple of seconds, so speech
418
+ /// from the very first moment counts once its first pause shows the room; once speech is heard the room stays put, so
419
+ /// talking on without a pause is never taken for the room. A recording ends as finished (`quiet`) only after 1.2 s of
420
+ /// quiet that was received: audio under the speech level after speech, or under the floor (past 6 s) when there was
421
+ /// only sound. Missing audio is never quiet: a stream that stops coming for a second ends as `lost`, which nothing acts
422
+ /// 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.
423
+ /// 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
424
+ /// way of telling speech from a television.
425
+ struct LampVoiceEnd {
426
+ static let maxSeconds = 15.0, waitSeconds = 6.0, quietSeconds = 1.2, floor = 0.02, rise = 2.0, stallSeconds = 1.0
427
+ private(set) var speechAt: Double?
428
+ private(set) var peak = 0.0
429
+ private var room: Double?, at = 0.0, lastHeard: Double?, sound = false
430
+ /// When the quiet that's going on now began, by the audio received: under the speech level, and under the floor.
431
+ private var quietFrom: Double?, silentFrom: Double?
432
+
433
+ mutating func heard(level: Double, top: Double, at t: Double) {
434
+ peak = max(peak, top)
435
+ if let r = room, level >= r { if speechAt == nil { room = r + (level - r) * min(1, (t - at) / Self.rise) } } else { room = level }
436
+ at = t
437
+ lastHeard = t
438
+ if level > Self.floor { sound = true; silentFrom = nil } else if silentFrom == nil { silentFrom = t }
439
+ if level > max((room ?? 0) * 3, Self.floor) { if speechAt == nil { speechAt = t }; quietFrom = nil }
440
+ else if quietFrom == nil { quietFrom = t }
441
+ }
442
+
443
+ func ended(at t: Double) -> String? {
444
+ if t >= Self.maxSeconds { return "cap" }
445
+ guard let last = lastHeard else { return t >= Self.waitSeconds ? "nothing" : nil }
446
+ if speechAt != nil, let q = quietFrom, last - q >= Self.quietSeconds { return "quiet" }
447
+ if speechAt == nil, sound, t >= Self.waitSeconds, let q = silentFrom, last - q >= Self.quietSeconds { return "quiet" }
448
+ if speechAt == nil, !sound, t >= Self.waitSeconds { return "nothing" }
449
+ // Audio that has stopped coming isn't quiet: what was heard may have been cut off.
450
+ return t - last >= Self.stallSeconds ? "lost" : nil
451
+ }
452
+ }
453
+
454
+ /// The page's calls of this device, answered by the handlers `Native.register()` gave.
455
+ final class LampPageNative: NSObject, WKScriptMessageHandlerWithReply {
456
+ private let scheme: String
457
+ init(scheme: String) { self.scheme = scheme }
458
+
459
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage,
460
+ replyHandler: @escaping @MainActor @Sendable (Any?, String?) -> Void) {
461
+ guard LampPage.isOwnPage(message, scheme: scheme), let call = message.body as? [String: Any],
462
+ let name = call["name"] as? String else { return replyHandler(nil, "Not found") }
463
+ let args = (call["args"] as? String).flatMap { try? JSONSerialization.jsonObject(with: Data($0.utf8)) } as? [String: Any] ?? [:]
464
+ Task { @MainActor in
465
+ guard let handler = LampNative.handlers[name] else {
466
+ return replyHandler(nil, "This app has no call named \(name).")
467
+ }
468
+ do {
469
+ replyHandler(String(decoding: try await handler(args), as: UTF8.self), nil)
470
+ } catch {
471
+ FileHandle.standardError.write(Data("LampNative \(name) failed: \(error.localizedDescription)\n".utf8))
472
+ replyHandler(nil, error.localizedDescription)
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ // MARK: - The project's other apps
479
+
480
+ /// The page's `Lamp.wish` (Web/lamp.js), answered by this app's own `LampWish` (LampSupport.swift): the app is the project's
481
+ /// peer, as every Swift app of a project is (it listens, is found on the network and keeps the values on this device, so
482
+ /// it goes on with Lamp closed), and the page mirrors it. What the app hears goes to the page's `window.__lampWish`.
483
+ /// Only an app whose project has other apps has this, as only it has `LampWish`.
484
+ @MainActor final class LampPageWish: NSObject, WKScriptMessageHandler {
485
+ private let scheme: String
486
+ private weak var page: WKWebView?
487
+ private var listening = false
488
+ private var filesHeard = false
489
+ private var watching: AnyCancellable?
490
+ private var transfers: [Int: LampTransfer] = [:]
491
+ private var watchingTransfers: [Int: AnyCancellable] = [:]
492
+ /// The shared collections the page uses (`LampStore.shared` in lamp.js), which this app keeps: told whole once, then
493
+ /// as they change.
494
+ private var collections: Set<String> = []
495
+ init(scheme: String) { self.scheme = scheme }
496
+
497
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
498
+ guard LampPage.isOwnPage(message, scheme: scheme), let call = message.body as? [String: Any], let op = call["op"] as? String else { return }
499
+ let wish = LampWish.shared
500
+ switch op {
501
+ case "start":
502
+ page = message.webView
503
+ listen(to: wish)
504
+ wish.start()
505
+ tellState()
506
+ tellValues([])
507
+ case "stop":
508
+ wish.stop()
509
+ case "toLamp":
510
+ guard let data = Self.json(call["data"]) else { return }
511
+ wish.toLamp(data)
512
+ case "send":
513
+ guard let data = Self.json(call["data"]) else { return }
514
+ wish.send(data, to: call["to"] as? String)
515
+ case "set":
516
+ guard let key = call["key"] as? String else { return }
517
+ let value = Self.json(call["value"])
518
+ wish.set(key, value is NSNull ? nil : value)
519
+ case "files":
520
+ guard !filesHeard else { return }
521
+ filesHeard = true
522
+ wish.onFile { [weak self] from, file in
523
+ self?.tell(["t": "file", "from": from, "copy": file.copy, "name": file.name, "type": file.type, "data": file.data.base64EncodedString()])
524
+ }
525
+ case "sendFile":
526
+ guard let id = call["id"] as? Int, let to = call["to"] as? String,
527
+ let data = (call["data"] as? String).flatMap({ Data(base64Encoded: $0) }) else { return }
528
+ let transfer = wish.sendFile(data, name: call["name"] as? String ?? "file", type: call["type"] as? String ?? "application/octet-stream", to: to)
529
+ transfers[id] = transfer
530
+ watchingTransfers[id] = transfer.objectWillChange.sink { [weak self] _ in
531
+ DispatchQueue.main.async { MainActor.assumeIsolated { self?.tellTransfer(id) } }
532
+ }
533
+ tellTransfer(id)
534
+ case "cancel":
535
+ guard let id = call["id"] as? Int else { return }
536
+ transfers[id]?.cancel()
537
+ case "shared":
538
+ guard let name = call["s"] as? String, LampWish.isCollection(name) else { return }
539
+ page = message.webView
540
+ // Heard once; told whole each time the page asks, a page loaded again included.
541
+ if collections.insert(name).inserted {
542
+ wish.onItems(in: name) { [weak self] changed, from in self?.tellItems(name, changed.map { ($0.0, $0.1) }, whole: false, from: from) }
543
+ }
544
+ tellItems(name, wish.items(in: name).map { ($0.id, $0.value as Any?) }, whole: true, from: wish.me)
545
+ case "put", "publish":
546
+ guard let name = call["s"] as? String, let list = Self.json(call["items"]) as? [[Any]] else { return }
547
+ let given = list.compactMap { pair -> (String, Any?)? in
548
+ guard pair.count == 2, let id = pair[0] as? String else { return nil }
549
+ return (id, pair[1] is NSNull ? nil : pair[1])
550
+ }
551
+ let kept = op == "put" ? wish.putItems(in: name, given) : wish.publishItems(in: name, given.compactMap { id, value in value.map { (id, $0) } })
552
+ // Turned down (past a limit): the page, which showed it at once, is told the collection as it really is.
553
+ if !kept { tellItems(name, wish.items(in: name).map { ($0.id, $0.value as Any?) }, whole: true, from: wish.me) }
554
+ case "putFile":
555
+ guard let name = call["s"] as? String, let id = call["id"] as? String,
556
+ let data = (call["data"] as? String).flatMap({ Data(base64Encoded: $0) }) else { return }
557
+ let kept = wish.putFile(in: name, id: id, data: data, type: call["type"] as? String ?? "")
558
+ if let ask = call["ask"] as? Int { tell(["t": "fileDone", "ask": ask, "ok": kept]) }
559
+ case "fileBytes":
560
+ guard let name = call["s"] as? String, let id = call["id"] as? String, let ask = call["ask"] as? Int else { return }
561
+ let data = wish.fileURL(in: name, id: id).flatMap { try? Data(contentsOf: $0) }
562
+ tell(["t": "fileBytes", "ask": ask, "data": data.map { $0.base64EncodedString() } ?? NSNull()])
563
+ default:
564
+ break
565
+ }
566
+ }
567
+
568
+ private func listen(to wish: LampWish) {
569
+ guard !listening else { return }
570
+ listening = true
571
+ // Who is open and how this app's link stands, once each change has been made.
572
+ watching = wish.objectWillChange.sink { [weak self] _ in
573
+ DispatchQueue.main.async { MainActor.assumeIsolated { self?.tellState() } }
574
+ }
575
+ wish.onMessage { [weak self] from, data, copy in self?.tell(["t": "message", "from": from, "copy": copy, "data": data]) }
576
+ // Who made each change; the page says to its onChange every value that differs from its copy.
577
+ wish.onChange { [weak self] key, value, from in
578
+ let said: Any = value ?? NSNull()
579
+ self?.tellValues([["key": key, "value": said, "from": from]])
580
+ }
581
+ }
582
+
583
+ private func tellState() {
584
+ let wish = LampWish.shared
585
+ let apps: [[String: Any]] = wish.apps.map { peer in
586
+ let lastSeen: Any = peer.status.lastSeen.map { ($0.timeIntervalSince1970 * 1000).rounded() } ?? NSNull()
587
+ let copies: [[String: Any]] = peer.copies.map { ["id": $0.id, "device": $0.device, "since": ($0.since.timeIntervalSince1970 * 1000).rounded()] }
588
+ return ["peer": peer.id, "kind": peer.kind, "name": peer.name, "device": peer.device, "open": peer.isOpen, "copies": copies,
589
+ "status": ["state": peer.status.state.rawValue, "reason": peer.status.reason, "lastSeen": lastSeen]]
590
+ }
591
+ tell(["t": "state", "me": wish.me, "copy": wish.copy, "apps": apps, "status": ["state": wish.status.state.rawValue, "reason": wish.status.reason]])
592
+ }
593
+
594
+ private func tellItems(_ name: String, _ items: [(String, Any?)], whole: Bool, from: String) {
595
+ let wish = LampWish.shared
596
+ var said: [String: Any] = [:], here: [String: Bool] = [:]
597
+ for (id, value) in items {
598
+ said[id] = value ?? NSNull()
599
+ if name.hasPrefix("files:") { here[id] = wish.fileURL(in: name, id: id) != nil }
600
+ }
601
+ tell(["t": "items", "s": name, "items": said, "whole": whole, "from": from, "here": here])
602
+ }
603
+
604
+ private func tellValues(_ changed: [[String: Any]]) {
605
+ tell(["t": "values", "values": LampWish.shared.values, "changed": changed])
606
+ }
607
+
608
+ private func tellTransfer(_ id: Int) {
609
+ guard let transfer = transfers[id] else { return }
610
+ var said: [String: Any] = ["t": "transfer", "id": id, "sent": transfer.sent]
611
+ switch transfer.state {
612
+ case .sending: said["state"] = "sending"
613
+ case .done: said["state"] = "done"
614
+ case .failed(let why): said["state"] = "failed"; said["error"] = why
615
+ }
616
+ if transfer.state != .sending { transfers[id] = nil; watchingTransfers[id] = nil }
617
+ tell(said)
618
+ }
619
+
620
+ private func tell(_ message: [String: Any]) {
621
+ guard let page, JSONSerialization.isValidJSONObject(message),
622
+ let data = try? JSONSerialization.data(withJSONObject: message, options: [.fragmentsAllowed]) else { return }
623
+ page.evaluateJavaScript("window.__lampWish && window.__lampWish(\(String(decoding: data, as: UTF8.self)))", in: nil, in: .page) { _ in }
624
+ }
625
+
626
+ private static func json(_ text: Any?) -> Any? {
627
+ guard let text = text as? String else { return nil }
628
+ return try? JSONSerialization.jsonObject(with: Data(text.utf8), options: [.fragmentsAllowed])
629
+ }
630
+ }
631
+
632
+ // MARK: - The page's log
633
+
634
+ /// What the page logs, into the app's log.
635
+ final class LampPageLog: NSObject, WKScriptMessageHandler {
636
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
637
+ guard let line = message.body as? String else { return }
638
+ FileHandle.standardError.write(Data((line.prefix(2000) + "\n").utf8))
639
+ }
640
+ }
641
+
642
+ #if os(macOS)
643
+ /// A game's page saying the person played while Lamp held its controller (lamp-game.js, "Lamp's hands on the
644
+ /// controller"): it has let go already, and Lamp's batch ends there, as it does for their click or key in the app.
645
+ final class LampPageDriveTaken: NSObject, WKScriptMessageHandler {
646
+ private let scheme: String
647
+ init(scheme: String) { self.scheme = scheme }
648
+
649
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
650
+ guard LampPage.isOwnPage(message, scheme: scheme) else { return }
651
+ MainActor.assumeIsolated { LampDriver.Handover.current?.stop() }
652
+ }
653
+ }
654
+
655
+ /// 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
656
+ /// waits for play, and is taken a moment after it starts, once the loading screen has faded (`LampRuntime.snapshot`).
657
+ final class LampPagePlaying: NSObject, WKScriptMessageHandler {
658
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
659
+ let said = message.body as? String
660
+ MainActor.assumeIsolated {
661
+ switch said {
662
+ case "loading":
663
+ guard LampPageItems.playing == nil else { return }
664
+ LampPageItems.playing = false
665
+ LampRuntime.gameLoading()
666
+ case "playing":
667
+ LampPageLive.current?.sayPlaying()
668
+ guard LampPageItems.playing != true else { return }
669
+ LampPageItems.playing = true
670
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { MainActor.assumeIsolated { LampRuntime.gamePlaying() } }
671
+ default: break
672
+ }
673
+ }
674
+ }
675
+ }
676
+ #endif
677
+
678
+ // MARK: - Serving and showing the page
679
+
680
+ /// The page's files, from the app's bundle (Web/), each a plain file inside it, at <scheme>://app/; and those Lamp has
681
+ /// changed since the bundle was built, while the app is open (`LampPageLive`), from the copies it was handed.
682
+ final class LampPageFiles: NSObject, WKURLSchemeHandler {
683
+ let scheme: String
684
+ private let folder = Bundle.main.resourceURL?.appendingPathComponent("Web")
685
+ /// The files Lamp changed since this bundle was built, by their path in Web/, and those it took away.
686
+ private let lock = NSLock()
687
+ private var changed: [String: URL] = [:]
688
+ private var gone: Set<String> = []
689
+
690
+ init(scheme: String) { self.scheme = scheme }
691
+
692
+ func take(changed: [String: URL], gone: Set<String>) {
693
+ lock.lock(); defer { lock.unlock() }
694
+ self.changed = changed
695
+ self.gone = gone
696
+ }
697
+
698
+ func webView(_ webView: WKWebView, start task: any WKURLSchemeTask) {
699
+ guard let url = task.request.url else { return task.didFailWithError(URLError(.badURL)) }
700
+ guard let data = file(at: url) else {
701
+ task.didReceive(HTTPURLResponse(url: url, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: LampPageGuard.headers(scheme: scheme))!)
702
+ task.didFinish()
703
+ return
704
+ }
705
+ let ext = url.pathExtension.lowercased()
706
+ let type = ["js": "text/javascript", "mjs": "text/javascript", "html": "text/html", "css": "text/css", "json": "application/json",
707
+ "wasm": "application/wasm"][ext] ?? UTType(filenameExtension: ext)?.preferredMIMEType ?? "application/octet-stream"
708
+ var headers = ["Content-Type": type, "Content-Length": String(data.count), "Cache-Control": "no-store"]
709
+ // Every document and worker of a shared page carries its rules, a frame's and a worker's included: they're served
710
+ // from here, so none is without them.
711
+ headers.merge(LampPageGuard.headers(scheme: scheme)) { $1 }
712
+ task.didReceive(HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: headers)!)
713
+ task.didReceive(data)
714
+ task.didFinish()
715
+ }
716
+
717
+ func webView(_ webView: WKWebView, stop task: any WKURLSchemeTask) {}
718
+
719
+ private func file(at url: URL) -> Data? {
720
+ guard let folder, url.host == "app" else { return nil }
721
+ let root = folder.standardizedFileURL.path + "/"
722
+ let path = url.path.isEmpty || url.path.hasSuffix("/") ? url.path + "index.html" : url.path
723
+ let relative = String(path.drop(while: { $0 == "/" }))
724
+ lock.lock()
725
+ let newer = changed[relative], removed = gone.contains(relative)
726
+ lock.unlock()
727
+ if let newer { return try? Data(contentsOf: newer) }
728
+ if removed { return nil }
729
+ let file = folder.appendingPathComponent(path).standardizedFileURL
730
+ guard file.path.hasPrefix(root), let values = try? file.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]),
731
+ values.isRegularFile == true, values.isSymbolicLink != true else { return nil }
732
+ return try? Data(contentsOf: file)
733
+ }
734
+ }
735
+
736
+ // MARK: - What a shared page may reach
737
+
738
+ /// A page shared with this person, held to its own files and the exact sites its review named (`NetworkPolicy`, which
739
+ /// Lamp writes into this app's Info.plist from its own record, so neither the page, a rebuild nor a change while it's
740
+ /// open can widen it; an app the person made has none, and none of this applies). Its documents and workers are served
741
+ /// with a Content-Security-Policy that names only those (`LampPageFiles`); it never leaves its own address, in its
742
+ /// window or a frame, and a link opens in the browser only when the person clicks it and agrees to where it goes, a
743
+ /// redirect included since each step is decided here; what no policy covers (WebRTC, a link's preconnect) is off
744
+ /// (`NetworkPolicy.holdBack`). A page whose review said
745
+ /// it may connect elsewhere shows what was blocked, and the person can allow that site or, for good, every site.
746
+ /// 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
747
+ /// 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.
748
+ enum LampPageGuard {
749
+ static let policy = NetworkPolicy(plist: Bundle.main.object(forInfoDictionaryKey: NetworkPolicy.plistKey))
750
+ private static let allowedKey = "LampNetworkAllowed", everyKey = "LampNetworkUnrestricted"
751
+
752
+ /// Sites the person allowed on this device, kept where the page can't write.
753
+ static var allowed: [String] { UserDefaults.standard.stringArray(forKey: allowedKey) ?? [] }
754
+
755
+ /// 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
756
+ /// reach every site.
757
+ static var current: NetworkPolicy? {
758
+ guard var policy, !UserDefaults.standard.bool(forKey: everyKey) else { return nil }
759
+ policy.origins += allowed
760
+ return policy.restricts ? policy : nil
761
+ }
762
+
763
+ static func own(_ scheme: String) -> String { "\(scheme)://app" }
764
+
765
+ /// The headers every file of the page is served with.
766
+ static func headers(scheme: String) -> [String: String] {
767
+ // 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
768
+ // the ones the person allowed, or anything the page says.
769
+ guard let held = current, let csp = held.contentSecurityPolicy(own: own(scheme), connect: LampControllers.pageSites) else { return [:] }
770
+ // A name looked up ahead is a request too: a page could spell what it has in one.
771
+ return ["Content-Security-Policy": csp, "X-DNS-Prefetch-Control": "off"]
772
+ }
773
+
774
+ /// 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
775
+ /// of Lamp's own the page can't reach (it hears only refusals WebKit made, never ones the page made up).
776
+ @MainActor static func attach(to configuration: WKWebViewConfiguration, scheme: String) {
777
+ guard policy != nil else { return }
778
+ forgetOldRelays()
779
+ let world = WKContentWorld.world(name: "LampGuard")
780
+ configuration.userContentController.add(LampPageRefused(scheme: scheme), contentWorld: world, name: "lampRefused")
781
+ configuration.userContentController.addUserScript(WKUserScript(source: """
782
+ document.addEventListener('securitypolicyviolation', function (e) {
783
+ if (!e.isTrusted) return;
784
+ try { window.webkit.messageHandlers.lampRefused.postMessage({ url: String(e.blockedURI || ''), what: String(e.effectiveDirective || '') }); } catch (_) {}
785
+ }, true);
786
+ """, injectionTime: .atDocumentStart, forMainFrameOnly: false, in: world))
787
+ guard current != nil else { return }
788
+ NetworkPolicy.holdBack(configuration.preferences)
789
+ }
790
+
791
+ /// A game's relay was once refused here, and the port the person allowed for it was that launch's alone: what's left
792
+ /// of those is a port on this device nothing should be able to reach, so it goes, once, for a game that takes phones.
793
+ private static func forgetOldRelays() {
794
+ let done = "LampRelayAllowancesForgotten"
795
+ guard !UserDefaults.standard.bool(forKey: done),
796
+ let phone = Bundle.main.resourceURL?.appendingPathComponent("Phone/index.html"), FileManager.default.fileExists(atPath: phone.path) else { return }
797
+ UserDefaults.standard.set(allowed.filter { !$0.hasPrefix("ws://127.0.0.1:") }, forKey: allowedKey)
798
+ UserDefaults.standard.set(true, forKey: done)
799
+ }
800
+
801
+ private static let watchers = NSMapTable<WKWebView, LampPageWatcher>.weakToStrongObjects()
802
+
803
+ /// Where the page may go, decided for every navigation of its window and frames.
804
+ @MainActor static func watch(_ view: WKWebView, scheme: String) {
805
+ guard policy != nil else { return }
806
+ let watcher = LampPageWatcher(scheme: scheme)
807
+ watchers.setObject(watcher, forKey: view)
808
+ view.navigationDelegate = watcher
809
+ view.uiDelegate = watcher
810
+ }
811
+
812
+ /// Every page opened again, for rules that have changed under them: what it runs goes with it, its workers too. Each
813
+ /// opens again as Lamp's Reload opens it (`LampPageView.reopen`), so a game in any window keeps where the player was.
814
+ @MainActor static func retirePages() {
815
+ for case let view as WKWebView in watchers.keyEnumerator().allObjects { LampPageView.reopen(view) }
816
+ }
817
+
818
+ /// Lets the page reach `site` from now on, on this device, and opens every page of the app again with it: a page's
819
+ /// rules came with its files, so one in another window would go on refusing the site until it opens again.
820
+ @MainActor static func allow(_ site: String) {
821
+ UserDefaults.standard.set(Array(Set(allowed + [site])).sorted(), forKey: allowedKey)
822
+ retirePages()
823
+ }
824
+
825
+ /// Lets the page reach every site, for good, and opens every page of the app again under that.
826
+ @MainActor static func allowEverySite() {
827
+ UserDefaults.standard.set(true, forKey: everyKey)
828
+ retirePages()
829
+ }
830
+
831
+ /// Asks the person, over the page, and calls `then` with their answer.
832
+ @MainActor static func ask(_ title: String, _ message: String, button: String, over view: WKWebView, then: @escaping @MainActor (Bool) -> Void) {
833
+ #if os(macOS)
834
+ let alert = NSAlert()
835
+ alert.messageText = title
836
+ alert.informativeText = message
837
+ alert.addButton(withTitle: button)
838
+ alert.addButton(withTitle: "Cancel")
839
+ guard let window = view.window else { return then(alert.runModal() == .alertFirstButtonReturn) }
840
+ alert.beginSheetModal(for: window) { answer in then(answer == .alertFirstButtonReturn) }
841
+ #else
842
+ var top = view.window?.rootViewController
843
+ while let shown = top?.presentedViewController { top = shown }
844
+ guard let top else { return then(false) }
845
+ let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
846
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in then(false) })
847
+ alert.addAction(UIAlertAction(title: button, style: .default) { _ in then(true) })
848
+ top.present(alert, animated: true)
849
+ #endif
850
+ }
851
+ }
852
+
853
+ /// The page's window and frames: only the page's own address, and a link the person clicks opened in the browser once
854
+ /// they've seen where it goes.
855
+ @MainActor final class LampPageWatcher: NSObject, WKNavigationDelegate, WKUIDelegate {
856
+ private let scheme: String
857
+ private var asking = false
858
+ init(scheme: String) { self.scheme = scheme }
859
+
860
+ func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
861
+ decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
862
+ guard let url = action.request.url, let policy = LampPageGuard.current,
863
+ !policy.allowsFrame(url, own: LampPageGuard.own(scheme)) else { return decisionHandler(.allow) }
864
+ decisionHandler(.cancel)
865
+ FileHandle.standardError.write(Data("Lamp kept this shared app from opening \(url.absoluteString.prefix(300))\n".utf8))
866
+ if action.targetFrame?.isMainFrame != false { offer(url, for: action, over: webView) }
867
+ }
868
+
869
+ /// A link that opens a window of its own (target="_blank"): never a window here.
870
+ func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
871
+ for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
872
+ if let url = action.request.url { offer(url, for: action, over: webView) }
873
+ return nil
874
+ }
875
+
876
+ /// A website the person clicked a link to, opened in the browser once they've said yes to where it goes. Anything
877
+ /// the page does by itself (setting its address, opening a window, a redirect) goes nowhere.
878
+ private func offer(_ url: URL, for action: WKNavigationAction, over view: WKWebView) {
879
+ guard action.navigationType == .linkActivated, Self.byPerson(action), !asking,
880
+ let scheme = url.scheme?.lowercased(), ["http", "https", "mailto"].contains(scheme) else { return }
881
+ asking = true
882
+ LampPageGuard.ask("Open \(url.host ?? "this website")?", url.absoluteString, button: "Open", over: view) { [weak self] open in
883
+ self?.asking = false
884
+ guard open else { return }
885
+ #if os(macOS)
886
+ NSWorkspace.shared.open(url)
887
+ #else
888
+ UIApplication.shared.open(url)
889
+ #endif
890
+ }
891
+ }
892
+
893
+ /// 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
894
+ /// there, a link's being clicked is all there is to go on.
895
+ private static func byPerson(_ action: WKNavigationAction) -> Bool {
896
+ guard action.responds(to: NSSelectorFromString("_isUserInitiated")) else { return true }
897
+ return (action.value(forKey: "_isUserInitiated") as? Bool) ?? false
898
+ }
899
+ }
900
+
901
+ /// 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
902
+ /// connect elsewhere, shown above it with a way to allow the site.
903
+ @MainActor final class LampPageRefused: NSObject, WKScriptMessageHandler {
904
+ private let scheme: String
905
+ private var bars = LampPageRefusals()
906
+ /// The page whose bar this is.
907
+ private weak var page: WKWebView?
908
+ /// Takes this page's bar away without a word, when what the person did in another window settled it.
909
+ private var takeAway: (@MainActor () -> Void)?
910
+ /// Every page's refusals, so what the person does in one window (closes a site's bar, allows it, allows every site)
911
+ /// reaches the bars other windows show at that moment.
912
+ private static let all = NSHashTable<LampPageRefused>.weakObjects()
913
+
914
+ init(scheme: String) {
915
+ self.scheme = scheme
916
+ super.init()
917
+ Self.all.add(self)
918
+ }
919
+
920
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
921
+ guard let said = message.body as? [String: Any], let address = said["url"] as? String,
922
+ let view = message.webView, let policy = LampPageGuard.current else { return }
923
+ let site = NetworkPolicy.origin(address)
924
+ FileHandle.standardError.write(Data("Lamp blocked this shared app's request to \(site ?? String(address.prefix(200))) (\(said["what"] as? String ?? ""))\n".utf8))
925
+ // The game's own relay is never offered: an allowance is kept, and that port is this launch's.
926
+ guard policy.mayConnect, let site, !LampControllers.pageSites.contains(site) else { return }
927
+ page = view
928
+ show(bars.heard(site, blocked: Self.blocked), over: view)
929
+ }
930
+
931
+ private func show(_ site: String?, over view: WKWebView) {
932
+ guard let site else { return }
933
+ takeAway = LampPageBlocked.show(site, over: view) { [weak self, weak view] closed in
934
+ guard let self else { return }
935
+ self.takeAway = nil
936
+ let next = self.bars.gone(closed: closed, blocked: Self.blocked)
937
+ if let view { self.show(next, over: view) }
938
+ for other in Self.all.allObjects where other !== self { other.settle() }
939
+ }
940
+ }
941
+
942
+ /// Another window's bar went: this page's goes too if its site is now closed, or allowed (the allowing opened every page
943
+ /// again under the new rules, this one too, so the site is reached here as well), and the next waiting has its turn.
944
+ private func settle() {
945
+ let (dropped, next) = bars.settle(blocked: Self.blocked)
946
+ guard dropped else { return }
947
+ takeAway?()
948
+ takeAway = nil
949
+ if let page { show(next, over: page) }
950
+ }
951
+
952
+ /// Whether the page is still kept from `site`: the person may have allowed it, or every site, meanwhile.
953
+ private static func blocked(_ site: String) -> Bool { LampPageGuard.current.map { !$0.origins.contains(site) } ?? false }
954
+ }
955
+
956
+ /// 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
957
+ /// person closed stays quiet for the rest of this launch, in every page and window, though it's still blocked: a page
958
+ /// that asks for it again and again (a map's tiles, a feed polled each second) would otherwise bring the bar straight
959
+ /// back. Quitting the app forgets it. A waiting site the person has allowed meanwhile isn't shown.
960
+ @MainActor struct LampPageRefusals {
961
+ private static var closed: Set<String> = []
962
+ private(set) var showing: String?
963
+ private var waiting: [String] = []
964
+
965
+ /// The page was refused `site`: the site whose bar to show now, if any.
966
+ mutating func heard(_ site: String, blocked: (String) -> Bool) -> String? {
967
+ guard !Self.closed.contains(site), showing != site, !waiting.contains(site) else { return nil }
968
+ waiting.append(site)
969
+ return next(blocked)
970
+ }
971
+
972
+ /// The bar shown went, `closed` by the person or its site allowed: the next site whose bar to show, if any.
973
+ mutating func gone(closed: Bool, blocked: (String) -> Bool) -> String? {
974
+ if closed, let showing { Self.closed.insert(showing) }
975
+ showing = nil
976
+ return next(blocked)
977
+ }
978
+
979
+ /// What the person did elsewhere (closed a site's bar in another window, allowed a site or every site) may settle
980
+ /// the bar shown here: whether it goes, and the next site whose bar to show then, if any.
981
+ mutating func settle(blocked: (String) -> Bool) -> (dropped: Bool, next: String?) {
982
+ guard let site = showing, Self.closed.contains(site) || !blocked(site) else { return (false, nil) }
983
+ showing = nil
984
+ return (true, next(blocked))
985
+ }
986
+
987
+ private mutating func next(_ blocked: (String) -> Bool) -> String? {
988
+ guard showing == nil else { return nil }
989
+ while !waiting.isEmpty {
990
+ let site = waiting.removeFirst()
991
+ guard !Self.closed.contains(site), blocked(site) else { continue }
992
+ showing = site
993
+ return site
994
+ }
995
+ return nil
996
+ }
997
+ }
998
+
999
+ /// The bar above a page that says what was blocked: allow that site, every site, or let it be.
1000
+ struct LampPageBlocked: View {
1001
+ let site: String
1002
+ let allow: () -> Void
1003
+ let allowAll: () -> Void
1004
+ let close: () -> Void
1005
+
1006
+ var body: some View {
1007
+ HStack(spacing: 10) {
1008
+ Image(systemName: "network.slash")
1009
+ Text("Blocked \(site.components(separatedBy: "://").last ?? site)").lineLimit(1).truncationMode(.middle)
1010
+ Spacer(minLength: 8)
1011
+ Button("Allow", action: allow)
1012
+ Button("Allow All…", action: allowAll)
1013
+ Button(action: close) { Image(systemName: "xmark") }.buttonStyle(.plain).accessibilityLabel("Close")
1014
+ }
1015
+ .font(.callout)
1016
+ .padding(.horizontal, 12).padding(.vertical, 8)
1017
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
1018
+ .padding(10)
1019
+ }
1020
+
1021
+ /// Shows the bar for `site` above the page, in room of its own (`LampPageHolder`); `gone` once it's closed by the
1022
+ /// person (true) or its site allowed (false). A question the person cancels leaves the bar as it was.
1023
+ /// What it gives back takes the bar away without calling `gone`, for a bar another window's choice settled.
1024
+ @discardableResult
1025
+ @MainActor static func show(_ site: String, over view: WKWebView, gone: @escaping @MainActor (_ closed: Bool) -> Void) -> (@MainActor () -> Void)? {
1026
+ guard let holder = LampPageHolder.of(view) else { return nil }
1027
+ var shown: LampPageHolder.Platform?
1028
+ func remove(closed: Bool) {
1029
+ guard let bar = shown else { return }
1030
+ shown = nil
1031
+ holder.hide(bar)
1032
+ gone(closed)
1033
+ }
1034
+ let name = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "This app"
1035
+ let bar = LampPageBlocked(site: site, allow: {
1036
+ // The page chose what was blocked, and when: the person sees the whole site before it's let in.
1037
+ LampPageGuard.ask("Let \(name) reach this site?", site, button: "Allow", over: view) { yes in
1038
+ guard yes else { return }
1039
+ // Allowed before the bar goes, so the next site's turn is decided under what the person just chose.
1040
+ LampPageGuard.allow(site)
1041
+ remove(closed: false)
1042
+ }
1043
+ }, allowAll: {
1044
+ LampPageGuard.ask("Let \(name) reach any site?", "It can then send what it has anywhere. This stays until you remove the app.",
1045
+ button: "Allow All", over: view) { yes in
1046
+ guard yes else { return }
1047
+ // Every site is let in first: no other site's bar follows this one.
1048
+ LampPageGuard.allowEverySite()
1049
+ remove(closed: false)
1050
+ }
1051
+ }, close: { remove(closed: true) })
1052
+ let hosted = Self.hosting(bar)
1053
+ shown = hosted
1054
+ holder.show(hosted)
1055
+ return {
1056
+ guard let bar = shown else { return }
1057
+ shown = nil
1058
+ holder.hide(bar)
1059
+ }
1060
+ }
1061
+
1062
+ #if os(macOS)
1063
+ @MainActor private static func hosting(_ bar: LampPageBlocked) -> NSView { NSHostingView(rootView: bar) }
1064
+ #else
1065
+ @MainActor private static func hosting(_ bar: LampPageBlocked) -> UIView {
1066
+ let shown = UIHostingController(rootView: bar).view!
1067
+ shown.backgroundColor = .clear
1068
+ return shown
1069
+ }
1070
+ #endif
1071
+ }
1072
+
1073
+ #if os(macOS)
1074
+ typealias LampPageHolderBase = NSView
1075
+ #else
1076
+ typealias LampPageHolderBase = UIView
1077
+ #endif
1078
+
1079
+ /// Where a page sits in its window: the page fills it, but for a bar at its top (`LampPageBlocked`), which has room of
1080
+ /// its own while it's shown, inside the window's safe area (below a title bar, clear of a notch), with the page laid
1081
+ /// out below it. The page is never covered: a control fixed at its top stays where it can be seen and clicked, and
1082
+ /// 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.
1083
+ @MainActor class LampPageHolder: LampPageHolderBase {
1084
+ typealias Platform = LampPageHolderBase
1085
+
1086
+ let page: WKWebView
1087
+ let canvas: CGSize?
1088
+ private var bar: Platform?
1089
+ private var pageTop: NSLayoutConstraint
1090
+
1091
+ init(holding page: WKWebView, canvas: CGSize? = nil) {
1092
+ self.page = page
1093
+ self.canvas = canvas
1094
+ page.translatesAutoresizingMaskIntoConstraints = false
1095
+ pageTop = NSLayoutConstraint()
1096
+ super.init(frame: .zero)
1097
+ addSubview(page)
1098
+ pageTop = page.topAnchor.constraint(equalTo: topAnchor)
1099
+ NSLayoutConstraint.activate([
1100
+ pageTop, page.leadingAnchor.constraint(equalTo: leadingAnchor), page.trailingAnchor.constraint(equalTo: trailingAnchor),
1101
+ page.bottomAnchor.constraint(equalTo: bottomAnchor),
1102
+ ])
1103
+ }
1104
+
1105
+ required init?(coder: NSCoder) { nil }
1106
+
1107
+ /// The holder of a page, if it has one.
1108
+ static func of(_ page: WKWebView) -> LampPageHolder? {
1109
+ sequence(first: page.superview, next: { $0?.superview }).lazy.compactMap { $0 as? LampPageHolder }.first
1110
+ }
1111
+
1112
+ /// Puts `bar` at the top, in place of any shown, and the page below it.
1113
+ func show(_ shown: Platform) {
1114
+ bar?.removeFromSuperview()
1115
+ bar = shown
1116
+ shown.translatesAutoresizingMaskIntoConstraints = false
1117
+ addSubview(shown)
1118
+ let room = safeAreaLayoutGuide
1119
+ pageTop.isActive = false
1120
+ pageTop = page.topAnchor.constraint(equalTo: shown.bottomAnchor)
1121
+ NSLayoutConstraint.activate([
1122
+ shown.topAnchor.constraint(equalTo: room.topAnchor), shown.centerXAnchor.constraint(equalTo: room.centerXAnchor),
1123
+ shown.widthAnchor.constraint(lessThanOrEqualTo: room.widthAnchor), pageTop,
1124
+ ])
1125
+ }
1126
+
1127
+ /// Takes `shown` away, if it's the bar shown, and gives the page its whole room again.
1128
+ func hide(_ shown: Platform) {
1129
+ guard bar === shown else { return }
1130
+ shown.removeFromSuperview()
1131
+ bar = nil
1132
+ pageTop.isActive = false
1133
+ pageTop = page.topAnchor.constraint(equalTo: topAnchor)
1134
+ pageTop.isActive = true
1135
+ }
1136
+
1137
+ #if os(macOS)
1138
+ override func layout() {
1139
+ super.layout()
1140
+ LampPageView.fit(page, in: page.bounds.size, canvas: canvas)
1141
+ }
1142
+ #else
1143
+ override func layoutSubviews() {
1144
+ super.layoutSubviews()
1145
+ LampPageView.fit(page, in: page.bounds.size, canvas: canvas)
1146
+ }
1147
+ #endif
1148
+ }
1149
+
1150
+ /// The web view the page is shown in: its sound and pictures without a tap first, nothing to scroll or bounce.
1151
+ @MainActor enum LampPageView {
1152
+ /// A page that logs its own errors (a game's runtime does) leaves `catchErrors` off, or each would be logged twice.
1153
+ /// `live` takes Lamp's changes while the app is open (`LampPageLive`).
1154
+ static func make(_ files: LampPageFiles, catchErrors: Bool = true, live: Bool = false) -> WKWebView {
1155
+ let configuration = WKWebViewConfiguration()
1156
+ configuration.setURLSchemeHandler(files, forURLScheme: files.scheme)
1157
+ // LampAI and the page's calls of this device, and its console into the app's log.
1158
+ LampPage.attach(to: configuration, scheme: files.scheme, catchErrors: catchErrors)
1159
+ // A shared page: held to its own files and its sites.
1160
+ LampPageGuard.attach(to: configuration, scheme: files.scheme)
1161
+ let changes = live ? LampPageLive(files: files) : nil
1162
+ if let changes { configuration.userContentController.add(changes, contentWorld: .page, name: "lampLive") }
1163
+ configuration.mediaTypesRequiringUserActionForPlayback = []
1164
+ #if os(iOS)
1165
+ configuration.allowsInlineMediaPlayback = true
1166
+ #endif
1167
+ let view = WKWebView(frame: .zero, configuration: configuration)
1168
+ #if os(macOS)
1169
+ view.setValue(false, forKey: "drawsBackground")
1170
+ #else
1171
+ view.isOpaque = false
1172
+ view.backgroundColor = .black
1173
+ view.scrollView.isScrollEnabled = false
1174
+ view.scrollView.bounces = false
1175
+ view.scrollView.contentInsetAdjustmentBehavior = .never
1176
+ #endif
1177
+ changes?.start(view)
1178
+ LampPageGuard.watch(view, scheme: files.scheme)
1179
+ #if os(macOS)
1180
+ LampPageReader.watch(view)
1181
+ // The app menu's Reload, when Lamp has no change waiting: the page of the window in front opens again, a game
1182
+ // where the player was. The pages are the reader's (`LampPageReader.page`), so a closed window's isn't chosen.
1183
+ LampReload.page = {
1184
+ LampPageReader.page(in: NSApp.keyWindow ?? NSApp.mainWindow).map(reopen)
1185
+ }
1186
+ #endif
1187
+ view.load(URLRequest(url: URL(string: "\(files.scheme)://app/index.html")!))
1188
+ return view
1189
+ }
1190
+
1191
+ // begin LampPageView.reopen
1192
+ /// Opens the page again from its files, its document and workers made afresh (so it's served under the rules as they
1193
+ /// are now), the way Lamp's Reload does: a game keeps where the player was (`__lampReloadNow` in lamp-game.js saves it
1194
+ /// for this session first, and the game takes it back as it opens); any other page opens again as a browser reloads.
1195
+ static func reopen(_ view: WKWebView) {
1196
+ view.evaluateJavaScript("window.__lampReloadNow ? window.__lampReloadNow() : location.reload()", in: nil, in: .page) { _ in }
1197
+ }
1198
+ // end LampPageView.reopen
1199
+
1200
+ /// 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.
1201
+ static func fit(_ view: WKWebView, in size: CGSize, canvas: CGSize?) {
1202
+ guard let canvas, size.width > 0, size.height > 0 else { return }
1203
+ let zoom = min(size.width / canvas.width, size.height / canvas.height)
1204
+ if abs(view.pageZoom - zoom) > 0.001 { view.pageZoom = zoom }
1205
+ }
1206
+ }
1207
+
1208
+ #if os(macOS)
1209
+ // MARK: - The page as text
1210
+
1211
+ /// The page's own things for the screen as text that Lamp's builder reads and acts on (`LampPageItems` in
1212
+ /// LampSupport.swift): the accessibility tree LampSupport reads stops at a web view's edge, so the page is read by a
1213
+ /// 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
1214
+ /// picture. Clicks and keys then reach the page as the person's do, at those points.
1215
+ @MainActor enum LampPageReader {
1216
+ private static let pages = NSHashTable<WKWebView>.weakObjects()
1217
+ /// What each window's pages held when last read, by the window's number.
1218
+ private static var read: [Int: [LampScreen.Item]] = [:]
1219
+ private static let script = "{{PAGE_SCREEN_SCRIPT}}"
1220
+
1221
+ /// The page shown in a window, or, with none there, the first shown anywhere: what the app menu's Reload reopens.
1222
+ static func page(in window: NSWindow?) -> WKWebView? {
1223
+ let shown = pages.allObjects.filter { $0.window != nil }
1224
+ return shown.first { $0.window === window } ?? shown.first { $0.window?.isVisible == true } ?? shown.first
1225
+ }
1226
+
1227
+ static func watch(_ view: WKWebView) {
1228
+ pages.add(view)
1229
+ LampPageItems.refresh = refresh
1230
+ LampPageItems.items = { read[$0.windowNumber] ?? [] }
1231
+ LampPageItems.act = act
1232
+ LampPageItems.own = { own($0, batch: $1) }
1233
+ LampPageItems.hold = { hold($0, in: $1, for: $2, said: $3) }
1234
+ }
1235
+
1236
+ /// A game's controller given to Lamp for batch `id`'s `use_app` moves (`seconds` it may run), or back (nil), in every
1237
+ /// page shown (lamp-game.js, "Lamp's hands on the controller"): for the whole batch the person's input takes it back.
1238
+ /// Given back by name, so a batch's end that reaches the page late gives back nothing a newer batch has.
1239
+ private static func own(_ seconds: Double?, batch id: String) {
1240
+ let name = quoted(id)
1241
+ let call = seconds.map { "window.__lampOwn && window.__lampOwn(\($0), \(name))" } ?? "window.__lampOwn && window.__lampOwn(null, \(name))"
1242
+ for page in pages.allObjects where page.window != nil { page.evaluateJavaScript(call) }
1243
+ }
1244
+
1245
+ /// Each hold's name in the page: its batch's id and a number of its own.
1246
+ private static var holds = 0
1247
+
1248
+ /// A game's controller held for a `stick` or `button` action of `batch`, in the game's own script, for `amount`
1249
+ /// 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
1250
+ /// page's answer: held, or why not (no Lamp runtime in the page yet, or the page failing). False when the app has no
1251
+ /// game. The hold is let go by its name, so a release that reaches the page late lets go of nothing newer.
1252
+ private static func hold(_ action: LampDriver.Action, in window: NSWindow, for batch: LampDriver.Handover,
1253
+ said: @escaping @MainActor (String, Bool) -> Void) -> Bool {
1254
+ let shown = pages.allObjects.filter { $0.window === window }
1255
+ guard LampPageItems.playing != nil, let held = action.controller, let page = shown.first ?? pages.allObjects.first(where: { $0.window != nil })
1256
+ else { return false }
1257
+ let seconds = min(max(action.amount ?? 0, 1.0 / 60), 10)
1258
+ guard let json = try? JSONSerialization.data(withJSONObject: held.merging(["seconds": seconds]) { $1 }) else { return false }
1259
+ holds += 1
1260
+ let name = quoted("\(batch.id)#\(holds)")
1261
+ let release = { page.evaluateJavaScript("window.__lampDrive && window.__lampDrive(null, \(name))") }
1262
+ let call = "window.__lampDrive(\(String(decoding: json, as: UTF8.self)), \(name), \(quoted(batch.id)))"
1263
+ page.evaluateJavaScript("window.__lampDrive ? \(call) : 'missing'") { answer, error in
1264
+ MainActor.assumeIsolated {
1265
+ // Stopped or replaced while the page answered (the batch gave up waiting for it, too): nothing more is
1266
+ // Lamp's, and the batch has said how it ended, so this answer adds nothing to its report.
1267
+ guard batch.carriesOn else {
1268
+ if answer as? Bool == true { release() }
1269
+ return
1270
+ }
1271
+ if answer as? String == "ended" {
1272
+ return said("\(action.action): stopped before the game took it", false)
1273
+ }
1274
+ if let error {
1275
+ return said("\(action.action): the game's page couldn't take it (\(error.localizedDescription))", false)
1276
+ }
1277
+ guard answer as? Bool == true else {
1278
+ 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)
1279
+ }
1280
+ batch.hold(for: seconds, release: release)
1281
+ said(action.text ?? "held the game's controller for \(LampScreen.number(seconds)) s", true)
1282
+ }
1283
+ }
1284
+ return true
1285
+ }
1286
+
1287
+ /// A string as a script's literal.
1288
+ private static func quoted(_ text: String) -> String {
1289
+ (try? JSONSerialization.data(withJSONObject: [text])).map { String(decoding: $0.dropFirst().dropLast(), as: UTF8.self) } ?? "null"
1290
+ }
1291
+
1292
+ /// The keys an action names, as the page reads them (`e.key`, and the code lamp.js and older pages read).
1293
+ private static let keys: [String: (key: String, code: Int)] = [
1294
+ "left": ("ArrowLeft", 37), "up": ("ArrowUp", 38), "right": ("ArrowRight", 39), "down": ("ArrowDown", 40),
1295
+ "return": ("Enter", 13), "enter": ("Enter", 13), "ok": ("Enter", 13), "escape": ("Escape", 27), "back": ("Escape", 27),
1296
+ "space": (" ", 32), "tab": ("Tab", 9), "delete": ("Backspace", 8),
1297
+ // A remote's media and color keys, by the codes a TV sends and lamp.js names (as the preview presses them).
1298
+ "play": ("MediaPlay", 415), "pause": ("MediaPause", 19), "playpause": ("MediaPlayPause", 10252), "stop": ("MediaStop", 413),
1299
+ "fastforward": ("MediaFastForward", 417), "rewind": ("MediaRewind", 412), "red": ("ColorF0Red", 403),
1300
+ "green": ("ColorF1Green", 404), "yellow": ("ColorF2Yellow", 405), "blue": ("ColorF3Blue", 406),
1301
+ "channelup": ("ChannelUp", 427), "channeldown": ("ChannelDown", 428), "info": ("Info", 457),
1302
+ ]
1303
+
1304
+ /// A click, a key or typing on a page, carried out in the page: the point (in the window's frame view) turned into
1305
+ /// the page's own, or, with none, whatever the page has focused while it holds the window's keyboard.
1306
+ private static func act(_ action: LampDriver.Action, at point: NSPoint?, in window: NSWindow) -> String? {
1307
+ guard let frame = window.contentView?.superview ?? window.contentView else { return nil }
1308
+ let shown = pages.allObjects.filter { $0.window === window }
1309
+ let page: WKWebView, spot: String
1310
+ if let point {
1311
+ guard let hit = shown.first(where: { $0.frame.contains($0.superview?.convert(point, from: frame) ?? .zero) }) else { return nil }
1312
+ let local = hit.convert(point, from: frame), zoom = max(hit.pageZoom, 0.01)
1313
+ let y = hit.isFlipped ? local.y : hit.bounds.height - local.y
1314
+ page = hit
1315
+ spot = "document.elementFromPoint(\(local.x / zoom), \(y / zoom))"
1316
+ } else {
1317
+ guard let focused = shown.first(where: { page in
1318
+ (window.firstResponder as? NSView).map { $0 === page || $0.isDescendant(of: page) } ?? false
1319
+ }) ?? (shown.count == 1 ? shown.first : nil) else { return nil }
1320
+ page = focused
1321
+ spot = "document.activeElement || document.body"
1322
+ }
1323
+ func run(_ source: String) { page.evaluateJavaScript("(function () { var el = \(spot); if (!el) return ''; \(source) return ''; })();") }
1324
+ switch action.action {
1325
+ case "click", "double_click":
1326
+ guard point != nil else { return nil }
1327
+ let clicks = action.action == "click" ? 1 : 2
1328
+ run("""
1329
+ var focusable = el.closest('[data-focus], button, a[href], input, select, textarea, [tabindex]') || el;
1330
+ if (focusable.focus) focusable.focus();
1331
+ for (var i = 0; i < \(clicks); i++) if (focusable.click) focusable.click();
1332
+ if (\(clicks) === 2) focusable.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
1333
+ """)
1334
+ // Sent, not proof: a game takes a click in its own time, and may not take it at all.
1335
+ if action.page == true { return (clicks == 1 ? "clicked" : "double-clicked") + " in the game's page; the picture shows whether the game took it" }
1336
+ return clicks == 1 ? "clicked in the page" : "double-clicked in the page"
1337
+ case "type":
1338
+ let text = action.text ?? ""
1339
+ let quoted = String(decoding: (try? JSONSerialization.data(withJSONObject: [text])) ?? Data("[\"\"]".utf8), as: UTF8.self)
1340
+ run("""
1341
+ if (!('value' in el)) return '';
1342
+ el.focus();
1343
+ el.value = \(quoted)[0];
1344
+ el.dispatchEvent(new Event('input', { bubbles: true }));
1345
+ el.dispatchEvent(new Event('change', { bubbles: true }));
1346
+ """)
1347
+ if action.page == true { return (text.isEmpty ? "cleared it" : "typed \"\(text)\"") + " in the game's page; the picture shows whether the game took it" }
1348
+ return text.isEmpty ? "cleared it" : "typed \"\(text)\" in the page"
1349
+ case "key":
1350
+ let name = (action.key ?? "").lowercased()
1351
+ let single = name.count == 1 ? name.unicodeScalars.first : nil
1352
+ guard let key = keys[name] ?? single.flatMap({ $0.isASCII && CharacterSet.alphanumerics.contains($0) ? (String($0), Int(String($0).uppercased().unicodeScalars.first!.value)) : nil }) else {
1353
+ return nil // a shortcut with ⌘ and the like: the app's menus have it
1354
+ }
1355
+ let hold = min(max(action.amount ?? 0, 0), 10)
1356
+ // A game reads which key it is by `code` (KeyW, Digit1, Space), where the page's forms read `key`.
1357
+ let code = single.map { $0.properties.numericType != nil ? "Digit\($0)" : "Key\(String($0).uppercased())" }
1358
+ ?? (key.key == " " ? "Space" : key.key)
1359
+ let fire = """
1360
+ function fire(type) {
1361
+ var e = new KeyboardEvent(type, { bubbles: true, cancelable: true, key: \(String(reflecting: key.key)), code: \(String(reflecting: code)) });
1362
+ Object.defineProperty(e, 'keyCode', { get: function () { return \(key.code); } });
1363
+ Object.defineProperty(e, 'which', { get: function () { return \(key.code); } });
1364
+ return el.dispatchEvent(e);
1365
+ }
1366
+ """
1367
+ if point != nil { run("if (el.focus) el.focus();") }
1368
+ run(fire + """
1369
+ var allowed = fire('keydown');
1370
+ if (allowed && \(key.code) === 13 && /^(BUTTON|A)$/.test(el.tagName)) el.click();
1371
+ if (\(hold > 0 ? "false" : "true")) fire('keyup');
1372
+ """)
1373
+ // Let go after the hold, or as soon as the person takes the app back or a newer batch begins (`Handover`).
1374
+ if hold > 0 {
1375
+ if let batch = LampDriver.Handover.current { batch.hold(for: hold) { run(fire + "fire('keyup');") } }
1376
+ else { run(fire + "fire('keyup');") }
1377
+ }
1378
+ return hold > 0 ? "held \(name) for \(LampScreen.number(hold)) s in the page" : "pressed \(name) in the page"
1379
+ default:
1380
+ return nil
1381
+ }
1382
+ }
1383
+
1384
+ /// Reads every page on screen again, then calls `done`: once each has answered, or after a second whatever there is.
1385
+ private static func refresh(_ done: @escaping @MainActor () -> Void) {
1386
+ let shown = pages.allObjects.filter { $0.window?.isVisible == true }
1387
+ var waiting = shown.count, called = false
1388
+ func finish() {
1389
+ guard !called else { return }
1390
+ called = true
1391
+ done()
1392
+ }
1393
+ guard waiting > 0 else { read = [:]; return finish() }
1394
+ var fresh: [Int: [LampScreen.Item]] = [:]
1395
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { MainActor.assumeIsolated { finish() } }
1396
+ for page in shown {
1397
+ page.evaluateJavaScript(script) { result, _ in
1398
+ MainActor.assumeIsolated {
1399
+ if let window = page.window, let json = result as? String,
1400
+ let list = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: Any]] {
1401
+ fresh[window.windowNumber, default: []] += items(list, of: page, in: window)
1402
+ }
1403
+ waiting -= 1
1404
+ if waiting == 0 {
1405
+ read = fresh
1406
+ finish()
1407
+ }
1408
+ }
1409
+ }
1410
+ }
1411
+ }
1412
+
1413
+ /// The page's things with their boxes in the window's picture: its pixels from the top left of the window with its
1414
+ /// title bar, as LampSupport pictures it.
1415
+ private static func items(_ list: [[String: Any]], of page: WKWebView, in window: NSWindow) -> [LampScreen.Item] {
1416
+ guard let frame = window.contentView?.superview ?? window.contentView else { return [] }
1417
+ let box = page.convert(page.bounds, to: frame)
1418
+ let top = frame.isFlipped ? box.minY : frame.bounds.height - box.maxY
1419
+ let scale = LampRuntime.scale(of: window), zoom = page.pageZoom
1420
+ return list.compactMap { entry in
1421
+ guard let kind = entry["kind"] as? String else { return nil }
1422
+ func n(_ key: String) -> CGFloat { CGFloat((entry[key] as? NSNumber)?.doubleValue ?? 0) * zoom }
1423
+ let role = ["button": "button", "field": "text field", "choice": "choice", "image": "picture"][kind] ?? "text"
1424
+ return LampScreen.Item(role: role, label: entry["name"] as? String ?? "", value: entry["value"] as? String ?? "",
1425
+ frame: CGRect(x: (box.minX + n("x")) * scale, y: (top + n("y")) * scale, width: n("w") * scale, height: n("h") * scale),
1426
+ selected: entry["focused"] as? Bool ?? false)
1427
+ }
1428
+ }
1429
+ }
1430
+ #endif
1431
+
1432
+ // MARK: - Changes while it's open
1433
+
1434
+ /// Lamp's changes to the page while the app is open. The app asks Lamp what has changed (a request Lamp answers when
1435
+ /// there's a change, or after a while with nothing), fetches the files that differ from its bundle into its caches, has
1436
+ /// `LampPageFiles` serve them in place of the bundle's, and tells the page (`window.__lampLive` in lamp-game.js), which
1437
+ /// puts new pictures, models, sounds and the code its modules take in as it runs, and opens itself again for code they
1438
+ /// don't, keeping the player's place.
1439
+ /// 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
1440
+ /// same bundle, so the app opens as Lamp last changed it; a new bundle (Lamp installed the app again) starts from its own.
1441
+ @MainActor final class LampPageLive: NSObject, WKScriptMessageHandler {
1442
+ #if os(macOS)
1443
+ /// The one page this app takes changes for, which says when it has drawn (`LampPagePlaying`).
1444
+ private(set) weak static var current: LampPageLive?
1445
+ #endif
1446
+ private let files: LampPageFiles
1447
+ private weak var page: WKWebView?
1448
+ private var epoch = "", gen = 0
1449
+ private var have: [String: String] = [:]
1450
+ private var served: Set<String> = []
1451
+ /// The phone page Lamp last handed this copy, nil for the one in its bundle.
1452
+ private var phone: (version: String, files: [String: String])?
1453
+ /// Under the app's own name: an app that isn't sandboxed shares the caches folder with every other.
1454
+ private static let cache = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
1455
+ .appendingPathComponent(Bundle.main.bundleIdentifier ?? "LampPage").appendingPathComponent("LampPage")
1456
+ private static var manifest: URL { cache.appendingPathComponent("changes.json") }
1457
+ /// Where this copy is, for Lamp: a phone's copy takes a change when Lamp installs it again, not this way.
1458
+ #if os(macOS)
1459
+ private static let place = "mac"
1460
+ #elseif targetEnvironment(simulator)
1461
+ private static let place = "simulator"
1462
+ #else
1463
+ private static let place = "phone"
1464
+ #endif
1465
+
1466
+ /// This bundle, by the build of the page it carries (lamp-build.js, a name for its files) and its program: a new
1467
+ /// install is another. On the Mac every game is a copy of one player, so the program alone doesn't tell them apart.
1468
+ private static let bundle: String = {
1469
+ let page = Bundle.main.resourceURL?.appendingPathComponent("Web/lamp-build.js")
1470
+ let built = page.flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? ""
1471
+ guard let url = Bundle.main.executableURL,
1472
+ let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) else { return built }
1473
+ return built + "\(Int(values.contentModificationDate?.timeIntervalSince1970 ?? 0))-\(values.fileSize ?? 0)"
1474
+ }()
1475
+
1476
+ init(files: LampPageFiles) {
1477
+ self.files = files
1478
+ super.init()
1479
+ #if os(macOS)
1480
+ Self.current = self
1481
+ #endif
1482
+ // The changes this bundle was last given, kept from an earlier launch.
1483
+ if let data = try? Data(contentsOf: Self.manifest), let kept = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1484
+ kept["bundle"] as? String == Self.bundle, let hashes = kept["files"] as? [String: String] {
1485
+ let gone = Set(kept["gone"] as? [String] ?? [])
1486
+ if hashes.values.allSatisfy({ FileManager.default.fileExists(atPath: Self.cache.appendingPathComponent($0).path) }) {
1487
+ have = hashes
1488
+ served = gone
1489
+ files.take(changed: hashes.mapValues { Self.cache.appendingPathComponent($0) }, gone: gone)
1490
+ }
1491
+ if let version = kept["phone"] as? String, let hashes = kept["phoneFiles"] as? [String: String],
1492
+ FileManager.default.fileExists(atPath: Self.phoneFolder(version).path) {
1493
+ phone = (version, hashes)
1494
+ LampControllers.use(page: Self.phoneFolder(version), version: version)
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ /// Where a phone page Lamp handed over is kept, one folder to a version: written whole, then served.
1500
+ private static func phoneFolder(_ version: String) -> URL { cache.appendingPathComponent("phone-" + version) }
1501
+
1502
+ func start(_ view: WKWebView) {
1503
+ page = view
1504
+ guard Self.place != "phone" else { return }
1505
+ Task { await listen() }
1506
+ }
1507
+
1508
+ private func listen() async {
1509
+ var pause: UInt64 = 2
1510
+ while page != nil {
1511
+ do {
1512
+ let answer = try await call("/app/page/live", ["after": gen, "epoch": epoch, "place": Self.place, "bundle": Self.bundle, "phone": true], timeout: 60)
1513
+ pause = 2
1514
+ if let e = answer["epoch"] as? String, e != epoch { epoch = e; gen = 0 }
1515
+ if answer["send"] as? String == "saved" { await sendSaved(); continue }
1516
+ guard let next = answer["gen"] as? Int, next > gen || answer["now"] as? Bool == true else { continue }
1517
+ try await take(answer, gen: next)
1518
+ } catch {
1519
+ // Lamp closed, or not reachable: asked again in a while.
1520
+ try? await Task.sleep(nanoseconds: pause * 1_000_000_000)
1521
+ pause = min(pause * 2, 30)
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ private func take(_ answer: [String: Any], gen next: Int) async throws {
1527
+ let hashes = answer["files"] as? [String: String] ?? [:]
1528
+ let gone = Set(answer["gone"] as? [String] ?? [])
1529
+ let now = answer["now"] as? Bool ?? false
1530
+ let newPhone = (answer["phone"] as? [String: Any]).flatMap { said -> (version: String, files: [String: String])? in
1531
+ guard let version = said["version"] as? String, let files = said["files"] as? [String: String] else { return nil }
1532
+ return (version, files)
1533
+ }
1534
+ let phoneChanged = newPhone?.version != phone?.version
1535
+ // What this copy already serves (Lamp asked again after its own restart): nothing for the page to do.
1536
+ if hashes == have, gone == served, !now, !phoneChanged { gen = next; return }
1537
+ try FileManager.default.createDirectory(at: Self.cache, withIntermediateDirectories: true)
1538
+ for (_, hash) in hashes where !FileManager.default.fileExists(atPath: Self.cache.appendingPathComponent(hash).path) {
1539
+ let file = try await call("/app/page/file", ["place": Self.place, "hash": hash], timeout: 60)
1540
+ // Kept under its hash only when it is what the hash names.
1541
+ guard let text = file["data"] as? String, let data = Data(base64Encoded: text), Self.name(of: data) == hash else {
1542
+ throw URLError(.cannotDecodeContentData)
1543
+ }
1544
+ try data.write(to: Self.cache.appendingPathComponent(hash), options: .atomic)
1545
+ }
1546
+ if phoneChanged { try await takePhone(newPhone) }
1547
+ files.take(changed: hashes.mapValues { Self.cache.appendingPathComponent($0) }, gone: gone)
1548
+ // Files no copy names any more go.
1549
+ let kept = Set(hashes.values)
1550
+ for old in Set(have.values).subtracting(kept) { try? FileManager.default.removeItem(at: Self.cache.appendingPathComponent(old)) }
1551
+ have = hashes
1552
+ served = gone
1553
+ var manifest: [String: Any] = ["bundle": Self.bundle, "files": hashes, "gone": Array(gone)]
1554
+ if let phone { manifest["phone"] = phone.version; manifest["phoneFiles"] = phone.files }
1555
+ if let data = try? JSONSerialization.data(withJSONObject: manifest) { try? data.write(to: Self.manifest, options: .atomic) }
1556
+ gen = next
1557
+ let pageChanged = answer["changed"] as? [String] ?? []
1558
+ // Only the phone page: the game's own page has nothing to do.
1559
+ if pageChanged.isEmpty, !now { tell(next, "phone"); return }
1560
+ let change: [String: Any] = ["gen": next, "changed": pageChanged, "removed": answer["removed"] as? [String] ?? [], "now": now, "pill": Self.place != "mac"]
1561
+ guard let page, let json = try? JSONSerialization.data(withJSONObject: change) else { return }
1562
+ let taken = try? await page.evaluateJavaScript("window.__lampLive ? window.__lampLive(\(String(decoding: json, as: UTF8.self))) : false", in: nil, contentWorld: .page)
1563
+ // A page without Lamp's game runtime is opened again as it is.
1564
+ if taken as? Bool != true {
1565
+ page.reload()
1566
+ tell(next, "reloading")
1567
+ }
1568
+ }
1569
+
1570
+ /// Serves the phone page Lamp handed over, whole: its files fetched and checked, written to a folder of its own and
1571
+ /// 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.
1572
+ private func takePhone(_ new: (version: String, files: [String: String])?) async throws {
1573
+ let bundled = Bundle.main.resourceURL?.appendingPathComponent("Phone") ?? URL(fileURLWithPath: "/nonexistent")
1574
+ guard let new else {
1575
+ LampControllers.use(page: bundled, version: nil)
1576
+ if let old = phone { try? FileManager.default.removeItem(at: Self.phoneFolder(old.version)) }
1577
+ phone = nil
1578
+ return
1579
+ }
1580
+ let folder = Self.phoneFolder(new.version)
1581
+ if !FileManager.default.fileExists(atPath: folder.path) {
1582
+ let partial = Self.cache.appendingPathComponent("partial-" + UUID().uuidString)
1583
+ defer { try? FileManager.default.removeItem(at: partial) }
1584
+ for (path, hash) in new.files {
1585
+ let file = try await call("/app/page/file", ["place": Self.place, "hash": hash], timeout: 60)
1586
+ guard let text = file["data"] as? String, let data = Data(base64Encoded: text), Self.name(of: data) == hash,
1587
+ !path.split(separator: "/").contains(where: { $0 == ".." || $0.hasPrefix(".") }) else { throw URLError(.cannotDecodeContentData) }
1588
+ let target = partial.appendingPathComponent(path)
1589
+ try FileManager.default.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
1590
+ try data.write(to: target)
1591
+ }
1592
+ try FileManager.default.moveItem(at: partial, to: folder)
1593
+ }
1594
+ LampControllers.use(page: folder, version: new.version)
1595
+ if let old = phone, old.version != new.version { try? FileManager.default.removeItem(at: Self.phoneFolder(old.version)) }
1596
+ phone = new
1597
+ }
1598
+
1599
+ /// What the page has saved (its localStorage, where LampStore keeps it), for Lamp's preview of the app to start from:
1600
+ /// Lamp asks for it as a preview opens, and nothing comes back from the preview.
1601
+ private func sendSaved() async {
1602
+ 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); })()"
1603
+ guard let page, let json = try? await page.evaluateJavaScript(read, in: nil, contentWorld: .defaultClient) as? String,
1604
+ let data = try? JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: String] else { return }
1605
+ _ = try? await call("/app/page/saved", ["place": Self.place, "data": data], timeout: 10)
1606
+ }
1607
+
1608
+ /// A file's name in the caches, as Lamp names it: the first half of its SHA-256.
1609
+ private static func name(of data: Data) -> String {
1610
+ SHA256.hash(data: data).prefix(16).map { String(format: "%02x", $0) }.joined()
1611
+ }
1612
+
1613
+ /// What the page did with a change (lamp-game.js): put in, reopened, waiting for a pause, back where it was.
1614
+ func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
1615
+ guard message.frameInfo.isMainFrame, let said = message.body as? [String: Any], let state = said["state"] as? String else { return }
1616
+ // A report also says what was built again, what waits for Reload and how long the game stood still for it, and
1617
+ // a held change whether it goes in at the game's next pause by itself: passed on as the page gave them.
1618
+ let more = said.filter { ["built", "kept", "waits", "pause", "frozen", "firstFrame", "later"].contains($0.key) }
1619
+ tell(said["gen"] as? Int ?? gen, state, why: said["why"] as? String, more: more)
1620
+ }
1621
+
1622
+ #if os(macOS)
1623
+ /// The page has drawn: told to Lamp as this process's, so Lamp can tell this launch from an earlier one.
1624
+ func sayPlaying() { tell(gen, "playing", more: ["pid": Int(ProcessInfo.processInfo.processIdentifier)]) }
1625
+ #endif
1626
+
1627
+ /// `why` is the page's reason for opening again or holding a change (code it couldn't swap in), or what came through.
1628
+ private func tell(_ gen: Int, _ state: String, why: String? = nil, more: [String: Any] = [:]) {
1629
+ var body: [String: Any] = ["gen": gen, "state": state, "epoch": epoch, "place": Self.place]
1630
+ if let why { body["why"] = String(why.prefix(600)) }
1631
+ for (key, value) in more where JSONSerialization.isValidJSONObject([key: value]) { body[key] = value }
1632
+ Task { _ = try? await call("/app/page/state", body, timeout: 10) }
1633
+ }
1634
+
1635
+ private func call(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> [String: Any] {
1636
+ guard let url = URL(string: lampGateway + path) else { throw URLError(.badURL) }
1637
+ var request = URLRequest(url: url, timeoutInterval: timeout)
1638
+ request.httpMethod = "POST"
1639
+ request.setValue("Bearer " + LampInstall.token, forHTTPHeaderField: "Authorization")
1640
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
1641
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
1642
+ let (data, response) = try await URLSession.shared.data(for: request)
1643
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
1644
+ return (try JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:]
1645
+ }
1646
+ }