@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.
- package/LICENSE +21 -0
- package/README.md +357 -2
- package/Resources/Devices/roku.json +410 -0
- package/Resources/Devices/xbox-one.json +367 -0
- package/Resources/GameKit/NOTICE.md +63 -0
- package/Resources/GameKit/apple/LampPage.swift +1646 -0
- package/Resources/GameKit/apple/LampPlayer.swift +46 -0
- package/Resources/GameKit/apple/LampWebApp.swift +63 -0
- package/Resources/GameKit/apple/LampWebGame.swift +87 -0
- package/Resources/GameKit/apple/Native.swift +12 -0
- package/Resources/GameKit/build.sh +55 -0
- package/Resources/GameKit/lamp_game.h +116 -0
- package/Resources/GameKit/lamp_host.h +19 -0
- package/Resources/GameKit/lamp_kit.cpp +61 -0
- package/Resources/GameKit/lamp_sound.cpp +455 -0
- package/Resources/GameKit/lamp_xbox.cpp +374 -0
- package/Resources/GameKit/libraries/HandmadeMath.h +3940 -0
- package/Resources/GameKit/libraries/cgltf.h +7240 -0
- package/Resources/GameKit/libraries/dr_mp3.h +5430 -0
- package/Resources/GameKit/libraries/dr_wav.h +9209 -0
- package/Resources/GameKit/libraries/sokol_debugtext.h +4754 -0
- package/Resources/GameKit/libraries/sokol_gfx.h +29558 -0
- package/Resources/GameKit/libraries/sokol_gl.h +4538 -0
- package/Resources/GameKit/libraries/sokol_log.h +335 -0
- package/Resources/GameKit/libraries/sokol_shape.h +1531 -0
- package/Resources/GameKit/libraries/stb_image.h +7988 -0
- package/Resources/GameKit/libraries/stb_vorbis.c +5584 -0
- package/Resources/GameKit/scaffold/Game/game.cpp +142 -0
- package/Resources/GameKit/scaffold/Game/scene.glsl +49 -0
- package/Resources/GameKit/scaffold-web/Web/game.js +31 -0
- package/Resources/GameKit/scaffold-web/Web/index.html +13 -0
- package/Resources/GameKit/scaffold-web/Web/making.js +33 -0
- package/Resources/GameKit/scaffold-web/Web/world.js +33 -0
- package/Resources/GameKit/web/lamp-game.js +3036 -0
- package/Resources/GameKit/web/lamp-phaser.js +128 -0
- package/Resources/WebHost/App.exe +0 -0
- package/Resources/WebHost/Microsoft.Web.WebView2.Core.dll +0 -0
- package/Resources/WebHost/WebView2-LICENSE.txt +27 -0
- package/Resources/WebHost/lamp_controllers.h +1506 -0
- package/Resources/WebHost/lamp_web_host.cpp +1173 -0
- package/Resources/WebLibraries/earlier.txt +318 -0
- package/Resources/WebLibraries/phaser/LICENSE.md +20 -0
- package/Resources/WebLibraries/phaser/NOTICE.md +2 -0
- package/Resources/WebLibraries/phaser/phaser.esm.min.js +1 -0
- package/Resources/WebLibraries/qrcode/LICENSE +21 -0
- package/Resources/WebLibraries/qrcode/NOTICE.md +4 -0
- package/Resources/WebLibraries/qrcode/qrcode.js +2297 -0
- package/Resources/WebLibraries/rapier/LICENSE +201 -0
- package/Resources/WebLibraries/rapier/NOTICE.md +3 -0
- package/Resources/WebLibraries/rapier/rapier.js +2 -0
- package/Resources/WebLibraries/three/LICENSE +21 -0
- package/Resources/WebLibraries/three/NOTICE.md +4 -0
- package/Resources/WebLibraries/three/addons/animation/AnimationClipCreator.js +168 -0
- package/Resources/WebLibraries/three/addons/animation/CCDIKSolver.js +607 -0
- package/Resources/WebLibraries/three/addons/controls/ArcballControls.js +3543 -0
- package/Resources/WebLibraries/three/addons/controls/DragControls.js +452 -0
- package/Resources/WebLibraries/three/addons/controls/FirstPersonControls.js +512 -0
- package/Resources/WebLibraries/three/addons/controls/FlyControls.js +384 -0
- package/Resources/WebLibraries/three/addons/controls/MapControls.js +116 -0
- package/Resources/WebLibraries/three/addons/controls/OrbitControls.js +1985 -0
- package/Resources/WebLibraries/three/addons/controls/PointerLockControls.js +264 -0
- package/Resources/WebLibraries/three/addons/controls/TrackballControls.js +1106 -0
- package/Resources/WebLibraries/three/addons/controls/TransformControls.js +2003 -0
- package/Resources/WebLibraries/three/addons/curves/CurveExtras.js +694 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSCurve.js +169 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSSurface.js +98 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSUtils.js +532 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSVolume.js +82 -0
- package/Resources/WebLibraries/three/addons/effects/AnaglyphEffect.js +274 -0
- package/Resources/WebLibraries/three/addons/effects/AsciiEffect.js +323 -0
- package/Resources/WebLibraries/three/addons/effects/OutlineEffect.js +489 -0
- package/Resources/WebLibraries/three/addons/effects/ParallaxBarrierEffect.js +155 -0
- package/Resources/WebLibraries/three/addons/effects/StereoEffect.js +91 -0
- package/Resources/WebLibraries/three/addons/environments/ColorEnvironment.js +59 -0
- package/Resources/WebLibraries/three/addons/environments/DebugEnvironment.js +102 -0
- package/Resources/WebLibraries/three/addons/environments/RoomEnvironment.js +185 -0
- package/Resources/WebLibraries/three/addons/geometries/BoxLineGeometry.js +92 -0
- package/Resources/WebLibraries/three/addons/geometries/ConvexGeometry.js +72 -0
- package/Resources/WebLibraries/three/addons/geometries/DecalGeometry.js +420 -0
- package/Resources/WebLibraries/three/addons/geometries/LoftGeometry.js +355 -0
- package/Resources/WebLibraries/three/addons/geometries/ParametricFunctions.js +100 -0
- package/Resources/WebLibraries/three/addons/geometries/ParametricGeometry.js +172 -0
- package/Resources/WebLibraries/three/addons/geometries/RoundedBoxGeometry.js +216 -0
- package/Resources/WebLibraries/three/addons/geometries/TeapotGeometry.js +689 -0
- package/Resources/WebLibraries/three/addons/geometries/TextGeometry.js +102 -0
- package/Resources/WebLibraries/three/addons/helpers/AnimationPathHelper.js +302 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelper.js +166 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelperWebGL.js +221 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeHelper.js +166 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeHelperGPU.js +102 -0
- package/Resources/WebLibraries/three/addons/helpers/OctreeHelper.js +109 -0
- package/Resources/WebLibraries/three/addons/helpers/PositionalAudioHelper.js +169 -0
- package/Resources/WebLibraries/three/addons/helpers/RapierHelper.js +59 -0
- package/Resources/WebLibraries/three/addons/helpers/RectAreaLightHelper.js +118 -0
- package/Resources/WebLibraries/three/addons/helpers/TextureHelper.js +265 -0
- package/Resources/WebLibraries/three/addons/helpers/TextureHelperGPU.js +214 -0
- package/Resources/WebLibraries/three/addons/helpers/VertexNormalsHelper.js +155 -0
- package/Resources/WebLibraries/three/addons/helpers/VertexTangentsHelper.js +133 -0
- package/Resources/WebLibraries/three/addons/helpers/ViewHelper.js +529 -0
- package/Resources/WebLibraries/three/addons/interactive/HTMLMesh.js +617 -0
- package/Resources/WebLibraries/three/addons/interactive/InteractiveGroup.js +224 -0
- package/Resources/WebLibraries/three/addons/interactive/SelectionBox.js +324 -0
- package/Resources/WebLibraries/three/addons/interactive/SelectionHelper.js +150 -0
- package/Resources/WebLibraries/three/addons/libs/fflate.module.js +2699 -0
- package/Resources/WebLibraries/three/addons/libs/lil-gui.module.min.js +8 -0
- package/Resources/WebLibraries/three/addons/libs/meshopt_decoder.module.js +196 -0
- package/Resources/WebLibraries/three/addons/libs/potpack.module.js +125 -0
- package/Resources/WebLibraries/three/addons/libs/stats.module.js +167 -0
- package/Resources/WebLibraries/three/addons/libs/tween.module.js +876 -0
- package/Resources/WebLibraries/three/addons/lighting/ClusteredLighting.js +153 -0
- package/Resources/WebLibraries/three/addons/lighting/DynamicLighting.js +82 -0
- package/Resources/WebLibraries/three/addons/lighting/LightMapper.js +295 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGrid.js +923 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGridUtils.js +107 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGridWebGL.js +846 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIConeTracer.js +243 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIIrradiance.js +43 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGINode.js +617 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGISceneCollector.js +793 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIVolume.js +1440 -0
- package/Resources/WebLibraries/three/addons/lights/LightProbeGenerator.js +337 -0
- package/Resources/WebLibraries/three/addons/lights/RectAreaLightTexturesLib.js +127 -0
- package/Resources/WebLibraries/three/addons/lights/RectAreaLightUniformsLib.js +40 -0
- package/Resources/WebLibraries/three/addons/lights/SunLight.js +100 -0
- package/Resources/WebLibraries/three/addons/lights/SunLightNode.js +56 -0
- package/Resources/WebLibraries/three/addons/lights/SunLightShadow.js +319 -0
- package/Resources/WebLibraries/three/addons/lights/SunShadowNode.js +312 -0
- package/Resources/WebLibraries/three/addons/lines/Line2.js +56 -0
- package/Resources/WebLibraries/three/addons/lines/LineGeometry.js +157 -0
- package/Resources/WebLibraries/three/addons/lines/LineMaterial.js +742 -0
- package/Resources/WebLibraries/three/addons/lines/LineSegments2.js +434 -0
- package/Resources/WebLibraries/three/addons/lines/LineSegmentsGeometry.js +298 -0
- package/Resources/WebLibraries/three/addons/lines/Wireframe.js +108 -0
- package/Resources/WebLibraries/three/addons/lines/WireframeGeometry2.js +49 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/Line2.js +46 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/LineSegments2.js +419 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/Wireframe.js +86 -0
- package/Resources/WebLibraries/three/addons/loaders/EXRLoader.js +3411 -0
- package/Resources/WebLibraries/three/addons/loaders/FBXLoader.js +4660 -0
- package/Resources/WebLibraries/three/addons/loaders/FontLoader.js +262 -0
- package/Resources/WebLibraries/three/addons/loaders/GLTFLoader.js +5006 -0
- package/Resources/WebLibraries/three/addons/loaders/HDRLoader.js +463 -0
- package/Resources/WebLibraries/three/addons/loaders/MTLLoader.js +601 -0
- package/Resources/WebLibraries/three/addons/loaders/OBJLoader.js +961 -0
- package/Resources/WebLibraries/three/addons/loaders/PLYLoader.js +992 -0
- package/Resources/WebLibraries/three/addons/loaders/RGBELoader.js +18 -0
- package/Resources/WebLibraries/three/addons/loaders/STLLoader.js +423 -0
- package/Resources/WebLibraries/three/addons/loaders/SVGLoader.js +3297 -0
- package/Resources/WebLibraries/three/addons/loaders/TGALoader.js +539 -0
- package/Resources/WebLibraries/three/addons/loaders/UltraHDRLoader.js +757 -0
- package/Resources/WebLibraries/three/addons/math/Capsule.js +159 -0
- package/Resources/WebLibraries/three/addons/math/ColorConverter.js +62 -0
- package/Resources/WebLibraries/three/addons/math/ColorSpaces.js +147 -0
- package/Resources/WebLibraries/three/addons/math/ConvexHull.js +1695 -0
- package/Resources/WebLibraries/three/addons/math/ImprovedNoise.js +88 -0
- package/Resources/WebLibraries/three/addons/math/Lut.js +317 -0
- package/Resources/WebLibraries/three/addons/math/MeshSurfaceSampler.js +315 -0
- package/Resources/WebLibraries/three/addons/math/OBB.js +535 -0
- package/Resources/WebLibraries/three/addons/math/Octree.js +825 -0
- package/Resources/WebLibraries/three/addons/math/SimplexNoise.js +470 -0
- package/Resources/WebLibraries/three/addons/misc/ConvexObjectBreaker.js +539 -0
- package/Resources/WebLibraries/three/addons/misc/GPUComputationRenderer.js +508 -0
- package/Resources/WebLibraries/three/addons/misc/Gyroscope.js +78 -0
- package/Resources/WebLibraries/three/addons/misc/MorphAnimMesh.js +119 -0
- package/Resources/WebLibraries/three/addons/misc/MorphBlendMesh.js +425 -0
- package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMap.js +370 -0
- package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMapGPU.js +322 -0
- package/Resources/WebLibraries/three/addons/misc/RollerCoaster.js +661 -0
- package/Resources/WebLibraries/three/addons/misc/Sculptor.js +1679 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorMesh.js +1504 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorTools.js +1180 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorUtils.js +345 -0
- package/Resources/WebLibraries/three/addons/misc/TileCreasedNormalsPlugin.js +270 -0
- package/Resources/WebLibraries/three/addons/misc/TubePainter.js +611 -0
- package/Resources/WebLibraries/three/addons/misc/Volume.js +520 -0
- package/Resources/WebLibraries/three/addons/misc/VolumeSlice.js +275 -0
- package/Resources/WebLibraries/three/addons/modifiers/CurveModifier.js +374 -0
- package/Resources/WebLibraries/three/addons/modifiers/CurveModifierGPU.js +256 -0
- package/Resources/WebLibraries/three/addons/modifiers/EdgeSplitModifier.js +299 -0
- package/Resources/WebLibraries/three/addons/modifiers/TessellateModifier.js +340 -0
- package/Resources/WebLibraries/three/addons/objects/GroundedSkybox.js +69 -0
- package/Resources/WebLibraries/three/addons/objects/Lensflare.js +489 -0
- package/Resources/WebLibraries/three/addons/objects/LensflareMesh.js +376 -0
- package/Resources/WebLibraries/three/addons/objects/MarchingCubes.js +1261 -0
- package/Resources/WebLibraries/three/addons/objects/Reflector.js +390 -0
- package/Resources/WebLibraries/three/addons/objects/ReflectorForSSRPass.js +393 -0
- package/Resources/WebLibraries/three/addons/objects/Refractor.js +389 -0
- package/Resources/WebLibraries/three/addons/objects/ShadowMesh.js +130 -0
- package/Resources/WebLibraries/three/addons/objects/Sky.js +342 -0
- package/Resources/WebLibraries/three/addons/objects/SkyMesh.js +403 -0
- package/Resources/WebLibraries/three/addons/objects/Water.js +374 -0
- package/Resources/WebLibraries/three/addons/objects/Water2.js +403 -0
- package/Resources/WebLibraries/three/addons/objects/Water2Mesh.js +199 -0
- package/Resources/WebLibraries/three/addons/objects/WaterMesh.js +194 -0
- package/Resources/WebLibraries/three/addons/postprocessing/AfterimagePass.js +187 -0
- package/Resources/WebLibraries/three/addons/postprocessing/BloomPass.js +274 -0
- package/Resources/WebLibraries/three/addons/postprocessing/BokehPass.js +218 -0
- package/Resources/WebLibraries/three/addons/postprocessing/ClearPass.js +97 -0
- package/Resources/WebLibraries/three/addons/postprocessing/CubeTexturePass.js +146 -0
- package/Resources/WebLibraries/three/addons/postprocessing/DotScreenPass.js +114 -0
- package/Resources/WebLibraries/three/addons/postprocessing/EffectComposer.js +365 -0
- package/Resources/WebLibraries/three/addons/postprocessing/FXAAPass.js +40 -0
- package/Resources/WebLibraries/three/addons/postprocessing/FilmPass.js +113 -0
- package/Resources/WebLibraries/three/addons/postprocessing/GTAOPass.js +735 -0
- package/Resources/WebLibraries/three/addons/postprocessing/GlitchPass.js +177 -0
- package/Resources/WebLibraries/three/addons/postprocessing/HalftonePass.js +134 -0
- package/Resources/WebLibraries/three/addons/postprocessing/LUTPass.js +138 -0
- package/Resources/WebLibraries/three/addons/postprocessing/MaskPass.js +195 -0
- package/Resources/WebLibraries/three/addons/postprocessing/OutlinePass.js +776 -0
- package/Resources/WebLibraries/three/addons/postprocessing/OutputPass.js +148 -0
- package/Resources/WebLibraries/three/addons/postprocessing/Pass.js +191 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderPass.js +193 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderPixelatedPass.js +314 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderTransitionPass.js +267 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SAOPass.js +407 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SMAAPass.js +230 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSAARenderPass.js +313 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSAOPass.js +527 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSRPass.js +858 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SavePass.js +132 -0
- package/Resources/WebLibraries/three/addons/postprocessing/ShaderPass.js +135 -0
- package/Resources/WebLibraries/three/addons/postprocessing/TAARenderPass.js +243 -0
- package/Resources/WebLibraries/three/addons/postprocessing/TexturePass.js +131 -0
- package/Resources/WebLibraries/three/addons/postprocessing/UnrealBloomPass.js +541 -0
- package/Resources/WebLibraries/three/addons/shaders/ACESFilmicToneMappingShader.js +96 -0
- package/Resources/WebLibraries/three/addons/shaders/AfterimageShader.js +63 -0
- package/Resources/WebLibraries/three/addons/shaders/BasicShader.js +36 -0
- package/Resources/WebLibraries/three/addons/shaders/BleachBypassShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/BlendShader.js +56 -0
- package/Resources/WebLibraries/three/addons/shaders/BokehShader.js +151 -0
- package/Resources/WebLibraries/three/addons/shaders/BokehShader2.js +404 -0
- package/Resources/WebLibraries/three/addons/shaders/BrightnessContrastShader.js +62 -0
- package/Resources/WebLibraries/three/addons/shaders/ColorCorrectionShader.js +59 -0
- package/Resources/WebLibraries/three/addons/shaders/ColorifyShader.js +57 -0
- package/Resources/WebLibraries/three/addons/shaders/ConvolutionShader.js +74 -0
- package/Resources/WebLibraries/three/addons/shaders/CopyShader.js +52 -0
- package/Resources/WebLibraries/three/addons/shaders/DOFMipMapShader.js +63 -0
- package/Resources/WebLibraries/three/addons/shaders/DepthLimitedBlurShader.js +183 -0
- package/Resources/WebLibraries/three/addons/shaders/DigitalGlitch.js +104 -0
- package/Resources/WebLibraries/three/addons/shaders/DotScreenShader.js +75 -0
- package/Resources/WebLibraries/three/addons/shaders/ExposureShader.js +51 -0
- package/Resources/WebLibraries/three/addons/shaders/FXAAShader.js +298 -0
- package/Resources/WebLibraries/three/addons/shaders/FilmShader.js +73 -0
- package/Resources/WebLibraries/three/addons/shaders/FocusShader.js +94 -0
- package/Resources/WebLibraries/three/addons/shaders/FreiChenShader.js +103 -0
- package/Resources/WebLibraries/three/addons/shaders/GTAOShader.js +437 -0
- package/Resources/WebLibraries/three/addons/shaders/GammaCorrectionShader.js +52 -0
- package/Resources/WebLibraries/three/addons/shaders/HalftoneShader.js +332 -0
- package/Resources/WebLibraries/three/addons/shaders/HorizontalBlurShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/HorizontalTiltShiftShader.js +70 -0
- package/Resources/WebLibraries/three/addons/shaders/HueSaturationShader.js +74 -0
- package/Resources/WebLibraries/three/addons/shaders/KaleidoShader.js +65 -0
- package/Resources/WebLibraries/three/addons/shaders/LuminosityHighPassShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/LuminosityShader.js +54 -0
- package/Resources/WebLibraries/three/addons/shaders/MirrorShader.js +62 -0
- package/Resources/WebLibraries/three/addons/shaders/NormalMapShader.js +60 -0
- package/Resources/WebLibraries/three/addons/shaders/OutputShader.js +103 -0
- package/Resources/WebLibraries/three/addons/shaders/PoissonDenoiseShader.js +230 -0
- package/Resources/WebLibraries/three/addons/shaders/RGBShiftShader.js +61 -0
- package/Resources/WebLibraries/three/addons/shaders/SAOShader.js +201 -0
- package/Resources/WebLibraries/three/addons/shaders/SMAAShader.js +489 -0
- package/Resources/WebLibraries/three/addons/shaders/SSAOShader.js +331 -0
- package/Resources/WebLibraries/three/addons/shaders/SSRShader.js +396 -0
- package/Resources/WebLibraries/three/addons/shaders/SepiaShader.js +57 -0
- package/Resources/WebLibraries/three/addons/shaders/SobelOperatorShader.js +98 -0
- package/Resources/WebLibraries/three/addons/shaders/SubsurfaceScatteringShader.js +95 -0
- package/Resources/WebLibraries/three/addons/shaders/TechnicolorShader.js +51 -0
- package/Resources/WebLibraries/three/addons/shaders/ToonShader.js +349 -0
- package/Resources/WebLibraries/three/addons/shaders/TriangleBlurShader.js +79 -0
- package/Resources/WebLibraries/three/addons/shaders/UnpackDepthRGBAShader.js +60 -0
- package/Resources/WebLibraries/three/addons/shaders/VelocityShader.js +137 -0
- package/Resources/WebLibraries/three/addons/shaders/VerticalBlurShader.js +66 -0
- package/Resources/WebLibraries/three/addons/shaders/VerticalTiltShiftShader.js +70 -0
- package/Resources/WebLibraries/three/addons/shaders/VignetteShader.js +56 -0
- package/Resources/WebLibraries/three/addons/shaders/VolumeShader.js +285 -0
- package/Resources/WebLibraries/three/addons/shaders/WaterRefractionShader.js +106 -0
- package/Resources/WebLibraries/three/addons/textures/FlakesTexture.js +54 -0
- package/Resources/WebLibraries/three/addons/tsl/WebGLNodesHandler.js +607 -0
- package/Resources/WebLibraries/three/addons/tsl/display/AfterImageNode.js +256 -0
- package/Resources/WebLibraries/three/addons/tsl/display/AnaglyphPassNode.js +550 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BilateralBlurNode.js +377 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BleachBypass.js +33 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BloomNode.js +599 -0
- package/Resources/WebLibraries/three/addons/tsl/display/CRT.js +150 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ChromaticAberrationNode.js +163 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DenoiseNode.js +354 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DepthOfFieldNode.js +572 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DotScreenNode.js +104 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FSR1Node.js +481 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FXAANode.js +357 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FilmNode.js +101 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GTAONode.js +727 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GaussianBlurNode.js +412 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GodraysNode.js +604 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ImportanceSampledEnvironment.js +564 -0
- package/Resources/WebLibraries/three/addons/tsl/display/LensflareNode.js +282 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Lut3DNode.js +109 -0
- package/Resources/WebLibraries/three/addons/tsl/display/MotionBlur.js +33 -0
- package/Resources/WebLibraries/three/addons/tsl/display/OITPassNode.js +341 -0
- package/Resources/WebLibraries/three/addons/tsl/display/OutlineNode.js +916 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ParallaxBarrierPassNode.js +90 -0
- package/Resources/WebLibraries/three/addons/tsl/display/PixelationPassNode.js +227 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RGBShiftNode.js +96 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RecurrentDenoiseNode.js +812 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RetroPassNode.js +263 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SMAANode.js +729 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSAAPassNode.js +344 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSAONode.js +422 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSGINode.js +698 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSRNode.js +1430 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSSNode.js +479 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Sepia.js +24 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Shape.js +29 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SharpenNode.js +284 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SobelOperatorNode.js +168 -0
- package/Resources/WebLibraries/three/addons/tsl/display/StereoCompositePassNode.js +190 -0
- package/Resources/WebLibraries/three/addons/tsl/display/StereoPassNode.js +117 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TAAUNode.js +700 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TRAANode.js +609 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TemporalReprojectNode.js +1024 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TransitionNode.js +141 -0
- package/Resources/WebLibraries/three/addons/tsl/display/boxBlur.js +65 -0
- package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlend.js +80 -0
- package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlur.js +63 -0
- package/Resources/WebLibraries/three/addons/tsl/display/hashBlur.js +55 -0
- package/Resources/WebLibraries/three/addons/tsl/display/lensDirt.js +30 -0
- package/Resources/WebLibraries/three/addons/tsl/display/radialBlur.js +68 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/ClusteredLightsNode.js +994 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/DynamicLightsNode.js +201 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/LightProbeGridNode.js +183 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/AmbientLightDataNode.js +67 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/DirectionalLightDataNode.js +117 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/HemisphereLightDataNode.js +105 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/PointLightDataNode.js +140 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/SpotLightDataNode.js +167 -0
- package/Resources/WebLibraries/three/addons/tsl/math/Bayer.js +84 -0
- package/Resources/WebLibraries/three/addons/tsl/math/curlNoise.js +107 -0
- package/Resources/WebLibraries/three/addons/tsl/math/voronoiNoise.js +112 -0
- package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNode.js +542 -0
- package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNodeHelper.js +212 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/GroundedSkybox.js +62 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/RNoise.js +51 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/Raymarching.js +70 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/SoftParticles.js +53 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/SpecularHelpers.js +302 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/TAAUtils.js +184 -0
- package/Resources/WebLibraries/three/addons/utils/BufferGeometryUtils.js +1523 -0
- package/Resources/WebLibraries/three/addons/utils/CameraUtils.js +82 -0
- package/Resources/WebLibraries/three/addons/utils/ColorUtils.js +76 -0
- package/Resources/WebLibraries/three/addons/utils/GaussianSplatUtils.js +229 -0
- package/Resources/WebLibraries/three/addons/utils/GeometryCompressionUtils.js +547 -0
- package/Resources/WebLibraries/three/addons/utils/GeometryUtils.js +226 -0
- package/Resources/WebLibraries/three/addons/utils/LDrawUtils.js +211 -0
- package/Resources/WebLibraries/three/addons/utils/SceneOptimizer.js +459 -0
- package/Resources/WebLibraries/three/addons/utils/SceneUtils.js +363 -0
- package/Resources/WebLibraries/three/addons/utils/ShadowMapViewer.js +244 -0
- package/Resources/WebLibraries/three/addons/utils/ShadowMapViewerGPU.js +233 -0
- package/Resources/WebLibraries/three/addons/utils/SkeletonUtils.js +496 -0
- package/Resources/WebLibraries/three/addons/utils/SortUtils.js +175 -0
- package/Resources/WebLibraries/three/addons/utils/UVsDebug.js +173 -0
- package/Resources/WebLibraries/three/addons/utils/WebGLTextureUtils.js +115 -0
- package/Resources/WebLibraries/three/addons/utils/WebGPUTextureUtils.js +81 -0
- package/Resources/WebLibraries/three/addons/utils/WorkerPool.js +167 -0
- package/Resources/WebLibraries/three/addons/utils/unwrapUVs.js +865 -0
- package/Resources/WebLibraries/three/three.core.js +61162 -0
- package/Resources/WebLibraries/three/three.module.js +19537 -0
- package/Resources/WebLibraries/three/three.tsl.js +41903 -0
- package/Resources/WebLibraries/three/three.webgpu.js +53714 -0
- package/Resources/skills/2d-game/SKILL.md +305 -0
- package/Resources/skills/3d-game/SKILL.md +274 -0
- package/Resources/skills/accounts/SKILL.md +41 -0
- package/Resources/skills/android-design/SKILL.md +159 -0
- package/Resources/skills/app-ai/SKILL.md +154 -0
- package/Resources/skills/app-intents/SKILL.md +31 -0
- package/Resources/skills/app-kinds/SKILL.md +122 -0
- package/Resources/skills/apple-watch/SKILL.md +43 -0
- package/Resources/skills/assets/SKILL.md +183 -0
- package/Resources/skills/building-blocks/SKILL.md +97 -0
- package/Resources/skills/device-control/SKILL.md +56 -0
- package/Resources/skills/device-integrations/SKILL.md +278 -0
- package/Resources/skills/finder-files/SKILL.md +19 -0
- package/Resources/skills/game/SKILL.md +414 -0
- package/Resources/skills/game-3d/SKILL.md +12 -0
- package/Resources/skills/game-art/SKILL.md +11 -0
- package/Resources/skills/game-feel/SKILL.md +13 -0
- package/Resources/skills/game-levels/SKILL.md +12 -0
- package/Resources/skills/game-live/SKILL.md +12 -0
- package/Resources/skills/game-physics/SKILL.md +13 -0
- package/Resources/skills/game-playtest/SKILL.md +26 -0
- package/Resources/skills/game-ui/SKILL.md +278 -0
- package/Resources/skills/github-projects/SKILL.md +59 -0
- package/Resources/skills/icloud-sync/SKILL.md +30 -0
- package/Resources/skills/iphone-hardware/SKILL.md +57 -0
- package/Resources/skills/iphone-widgets/SKILL.md +53 -0
- package/Resources/skills/lg-developer-mode/SKILL.md +152 -0
- package/Resources/skills/mac-widgets/SKILL.md +60 -0
- package/Resources/skills/maps/SKILL.md +8 -0
- package/Resources/skills/menu-bar/SKILL.md +36 -0
- package/Resources/skills/more-apps/SKILL.md +53 -0
- package/Resources/skills/phone-controllers/SKILL.md +79 -0
- package/Resources/skills/phone-pages/SKILL.md +173 -0
- package/Resources/skills/roku-developer-mode/SKILL.md +106 -0
- package/Resources/skills/scene-3d/SKILL.md +27 -0
- package/Resources/skills/scripting/SKILL.md +17 -0
- package/Resources/skills/share-extension/SKILL.md +22 -0
- package/Resources/skills/tv-design/SKILL.md +72 -0
- package/Resources/skills/web-app/SKILL.md +136 -0
- package/Resources/skills/web-game/SKILL.md +95 -0
- package/Resources/skills/web-or-native/SKILL.md +103 -0
- package/Resources/skills/wish-links/SKILL.md +156 -0
- package/THIRD_PARTY_NOTICES.md +170 -0
- package/cli/android.mjs +941 -0
- package/cli/browser.mjs +268 -0
- package/cli/calls.mjs +390 -0
- package/cli/dev.mjs +773 -0
- package/cli/devices.mjs +984 -0
- package/cli/lamp.mjs +1016 -0
- package/cli/lampapp.mjs +332 -0
- package/cli/lamps/android-host/LampPageGateway.kt +345 -0
- package/cli/lamps/android-host/app/build.gradle.kts +40 -0
- package/cli/lamps/android-host/build.gradle.kts +4 -0
- package/cli/lamps/android-host/game/AndroidManifest.xml +33 -0
- package/cli/lamps/android-host/game/MainActivity.kt +173 -0
- package/cli/lamps/android-host/gradle/libs.versions.toml +39 -0
- package/cli/lamps/android-host/gradle.properties +8 -0
- package/cli/lamps/android-host/page/AndroidManifest.xml +35 -0
- package/cli/lamps/android-host/page/MainActivity.kt +193 -0
- package/cli/lamps/android-host/settings.gradle.kts +30 -0
- package/cli/lamps/android-host/tools.json +43 -0
- package/cli/lamps/lamp-controllers.js +2 -0
- package/cli/lamps/lamp-for-games.js +6863 -0
- package/cli/lamps/lamp.js +7023 -0
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -0
- package/cli/lamps/mac-starter/APP.md +12 -0
- package/cli/lamps/mac-starter/Package.swift +8 -0
- package/cli/lamps/mac-starter/Sources/App.swift +11 -0
- package/cli/lamps/mac-starter/Sources/ContentView.swift +17 -0
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +18097 -0
- package/cli/lamps/mac-starter/build.sh +336 -0
- package/cli/lamps/mac-starter/gitignore +3 -0
- package/cli/lamps/mac-starter/lamp-starter.json +6 -0
- package/cli/lamps/web-starter/APP.md +8 -0
- package/cli/lamps/web-starter/app.js +1 -0
- package/cli/lamps/web-starter/index.html +17 -0
- package/cli/lamps/web-starter/lamp-starter.json +5 -0
- package/cli/lamps/web-starter/lamp.js +7023 -0
- package/cli/lamps/web-starter/style.css +25 -0
- package/cli/link.mjs +138 -0
- package/cli/mac.mjs +621 -0
- package/cli/mdns.mjs +205 -0
- package/cli/msix.mjs +413 -0
- package/cli/network.mjs +209 -0
- package/cli/outside.mjs +140 -0
- package/cli/page.js +165 -0
- package/cli/place.mjs +298 -0
- package/cli/png.mjs +184 -0
- package/cli/share.mjs +202 -0
- package/cli/skill/SKILL.md +255 -0
- package/cli/skills.mjs +71 -0
- package/cli/state.mjs +173 -0
- package/cli/update.mjs +57 -0
- package/package.json +20 -4
- package/site/lib/quota.js +59 -0
- 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
|
+
}
|