azurkit 1.0.0__py3-none-any.whl
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.
- azurkit/__init__.py +46 -0
- azurkit/__main__.py +18 -0
- azurkit/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/__pycache__/__main__.cpython-314.pyc +0 -0
- azurkit/__pycache__/cli.cpython-314.pyc +0 -0
- azurkit/__pycache__/sdk.cpython-314.pyc +0 -0
- azurkit/__pycache__/store.cpython-314.pyc +0 -0
- azurkit/app/__init__.py +1290 -0
- azurkit/app/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/ahead.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/art.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/engine.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/live.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/memo.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/procpool.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/swapcache.cpython-314.pyc +0 -0
- azurkit/app/__pycache__/warm.cpython-314.pyc +0 -0
- azurkit/app/ahead.py +359 -0
- azurkit/app/art.py +45 -0
- azurkit/app/engine.py +660 -0
- azurkit/app/live.py +186 -0
- azurkit/app/memo.py +235 -0
- azurkit/app/procpool.py +428 -0
- azurkit/app/swapcache.py +320 -0
- azurkit/app/warm.py +335 -0
- azurkit/cli.py +455 -0
- azurkit/commit.txt +1 -0
- azurkit/core/__init__.py +2 -0
- azurkit/core/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/coff.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/facts.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/fdlibm.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/filelist.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/gate.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/host.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/inflate.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/iso.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/jsondepth.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/native.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/progress.cpython-314.pyc +0 -0
- azurkit/core/__pycache__/xbe.cpython-314.pyc +0 -0
- azurkit/core/_native/emscripten-wasm32.so +0 -0
- azurkit/core/_native/linux-aarch64.so +0 -0
- azurkit/core/_native/linux-x86_64.so +0 -0
- azurkit/core/_native/macos-arm64.dylib +0 -0
- azurkit/core/_native/macos-x86_64.dylib +0 -0
- azurkit/core/_native/windows-x86_64.dll +0 -0
- azurkit/core/coff.py +211 -0
- azurkit/core/facts.europe.json +4097 -0
- azurkit/core/facts.py +156 -0
- azurkit/core/facts.usa.json +3826 -0
- azurkit/core/fdlibm.py +532 -0
- azurkit/core/filelist.py +68 -0
- azurkit/core/gate.py +287 -0
- azurkit/core/host.py +166 -0
- azurkit/core/inflate.py +36 -0
- azurkit/core/iso.py +823 -0
- azurkit/core/jsondepth.py +28 -0
- azurkit/core/manifests/europe.json +98 -0
- azurkit/core/manifests/usa.json +1200 -0
- azurkit/core/native.py +805 -0
- azurkit/core/progress.py +132 -0
- azurkit/core/xbe.py +224 -0
- azurkit/core/xbr/__init__.py +8 -0
- azurkit/core/xbr/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/append.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/clone.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/document.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/edits.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/graph.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/keyed.cpython-314.pyc +0 -0
- azurkit/core/xbr/__pycache__/nodes.cpython-314.pyc +0 -0
- azurkit/core/xbr/append.py +430 -0
- azurkit/core/xbr/clone.py +182 -0
- azurkit/core/xbr/document.py +195 -0
- azurkit/core/xbr/edits.py +128 -0
- azurkit/core/xbr/graph.py +514 -0
- azurkit/core/xbr/keyed.py +161 -0
- azurkit/core/xbr/node_types.json +764 -0
- azurkit/core/xbr/nodes.py +213 -0
- azurkit/edits/__init__.py +3 -0
- azurkit/edits/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/edits/__pycache__/files.cpython-314.pyc +0 -0
- azurkit/edits/__pycache__/layer.cpython-314.pyc +0 -0
- azurkit/edits/__pycache__/manifest.cpython-314.pyc +0 -0
- azurkit/edits/__pycache__/preset.cpython-314.pyc +0 -0
- azurkit/edits/__pycache__/xbr.cpython-314.pyc +0 -0
- azurkit/edits/files.py +145 -0
- azurkit/edits/layer.py +306 -0
- azurkit/edits/manifest.py +335 -0
- azurkit/edits/preset.py +205 -0
- azurkit/edits/xbr.py +211 -0
- azurkit/sdk.py +1646 -0
- azurkit/store.py +276 -0
- azurkit/tools/__init__.py +3 -0
- azurkit/tools/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/characters/__init__.py +547 -0
- azurkit/tools/characters/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_borrow.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_character.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_crossfade.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_damage_marker.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_fx_ring.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_kit.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_match.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_power_fx.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_slots.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_sounds.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_throw.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_variants.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_victory.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/_player_weapons.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/body_codec.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/characters.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/clip_import.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/content.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/decimate.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/draw.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/fx_edit.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/fx_mesh.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/fx_templates.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/gltf.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/gltf_import.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/grades.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/index_xbr.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/jiggle.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/jiggle_rt.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/lods.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/mesh_edit.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/model_chains.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/model_inspect.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/paint.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/picker.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/pool.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/ports.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/pose.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/render.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/retarget.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/shipped.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/skeleton.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/synth.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/textures.cpython-314.pyc +0 -0
- azurkit/tools/characters/__pycache__/xbox_adpcm.cpython-314.pyc +0 -0
- azurkit/tools/characters/_player_borrow.py +150 -0
- azurkit/tools/characters/_player_character.py +11603 -0
- azurkit/tools/characters/_player_crossfade.py +219 -0
- azurkit/tools/characters/_player_damage_marker.py +229 -0
- azurkit/tools/characters/_player_fx_ring.py +815 -0
- azurkit/tools/characters/_player_kit.py +309 -0
- azurkit/tools/characters/_player_match.py +156 -0
- azurkit/tools/characters/_player_power_fx.py +554 -0
- azurkit/tools/characters/_player_slots.py +536 -0
- azurkit/tools/characters/_player_sounds.py +648 -0
- azurkit/tools/characters/_player_throw.py +762 -0
- azurkit/tools/characters/_player_variants.py +4366 -0
- azurkit/tools/characters/_player_victory.py +238 -0
- azurkit/tools/characters/_player_weapons.py +3257 -0
- azurkit/tools/characters/body_codec.py +934 -0
- azurkit/tools/characters/chains.json +47 -0
- azurkit/tools/characters/characters.py +835 -0
- azurkit/tools/characters/clip_import.py +561 -0
- azurkit/tools/characters/clips/townsfolk/children.glb +0 -0
- azurkit/tools/characters/clips/townsfolk/kit.json +179 -0
- azurkit/tools/characters/clips/townsfolk/townsmen.glb +0 -0
- azurkit/tools/characters/clips/townsfolk/townswomen.glb +0 -0
- azurkit/tools/characters/content.py +521 -0
- azurkit/tools/characters/decimate.py +711 -0
- azurkit/tools/characters/draw.py +412 -0
- azurkit/tools/characters/fx/air_elemental/air_funnel.glb +0 -0
- azurkit/tools/characters/fx/air_elemental/air_funnel_swirl.png +0 -0
- azurkit/tools/characters/fx_edit.py +705 -0
- azurkit/tools/characters/fx_mesh.py +232 -0
- azurkit/tools/characters/fx_templates.json +250 -0
- azurkit/tools/characters/fx_templates.py +417 -0
- azurkit/tools/characters/gltf.py +1027 -0
- azurkit/tools/characters/gltf_import.py +489 -0
- azurkit/tools/characters/grades.py +32 -0
- azurkit/tools/characters/index_xbr.py +182 -0
- azurkit/tools/characters/jiggle.py +1602 -0
- azurkit/tools/characters/jiggle_rt.py +1750 -0
- azurkit/tools/characters/lods.py +143 -0
- azurkit/tools/characters/mesh_edit.py +5718 -0
- azurkit/tools/characters/mode_grades.json +112 -0
- azurkit/tools/characters/model_chains.py +245 -0
- azurkit/tools/characters/model_inspect.py +694 -0
- azurkit/tools/characters/paint.py +1112 -0
- azurkit/tools/characters/picker.py +399 -0
- azurkit/tools/characters/player_model/air_blast.c +215 -0
- azurkit/tools/characters/player_model/air_blast.o +0 -0
- azurkit/tools/characters/player_model/attack_box.c +143 -0
- azurkit/tools/characters/player_model/attack_box.o +0 -0
- azurkit/tools/characters/player_model/attack_carry.c +423 -0
- azurkit/tools/characters/player_model/attack_carry.o +0 -0
- azurkit/tools/characters/player_model/attack_rotation.c +131 -0
- azurkit/tools/characters/player_model/attack_rotation.o +0 -0
- azurkit/tools/characters/player_model/attack_sets.c +335 -0
- azurkit/tools/characters/player_model/attack_sets.o +0 -0
- azurkit/tools/characters/player_model/body_key.c +51 -0
- azurkit/tools/characters/player_model/body_key.o +0 -0
- azurkit/tools/characters/player_model/borrow.c +178 -0
- azurkit/tools/characters/player_model/borrow.o +0 -0
- azurkit/tools/characters/player_model/borrow_first.c +76 -0
- azurkit/tools/characters/player_model/borrow_first.o +0 -0
- azurkit/tools/characters/player_model/climb.c +598 -0
- azurkit/tools/characters/player_model/climb.o +0 -0
- azurkit/tools/characters/player_model/damage_marker.c +219 -0
- azurkit/tools/characters/player_model/damage_marker.o +0 -0
- azurkit/tools/characters/player_model/drain.c +907 -0
- azurkit/tools/characters/player_model/drain.o +0 -0
- azurkit/tools/characters/player_model/eye.c +459 -0
- azurkit/tools/characters/player_model/eye.o +0 -0
- azurkit/tools/characters/player_model/eye_hang.c +112 -0
- azurkit/tools/characters/player_model/eye_hang.o +0 -0
- azurkit/tools/characters/player_model/eye_sweep.c +353 -0
- azurkit/tools/characters/player_model/eye_sweep.o +0 -0
- azurkit/tools/characters/player_model/fade_floor.c +513 -0
- azurkit/tools/characters/player_model/fade_floor.o +0 -0
- azurkit/tools/characters/player_model/finish_range.c +69 -0
- azurkit/tools/characters/player_model/finish_range.o +0 -0
- azurkit/tools/characters/player_model/fx_default.c +56 -0
- azurkit/tools/characters/player_model/fx_default.o +0 -0
- azurkit/tools/characters/player_model/fx_ring.c +1178 -0
- azurkit/tools/characters/player_model/fx_ring.o +0 -0
- azurkit/tools/characters/player_model/hang_ease.c +356 -0
- azurkit/tools/characters/player_model/hang_ease.o +0 -0
- azurkit/tools/characters/player_model/hang_hold.c +749 -0
- azurkit/tools/characters/player_model/hang_hold.o +0 -0
- azurkit/tools/characters/player_model/hit_delay.c +94 -0
- azurkit/tools/characters/player_model/hit_delay.o +0 -0
- azurkit/tools/characters/player_model/hit_fx.c +83 -0
- azurkit/tools/characters/player_model/hit_fx.o +0 -0
- azurkit/tools/characters/player_model/hit_reaction.c +114 -0
- azurkit/tools/characters/player_model/hit_reaction.o +0 -0
- azurkit/tools/characters/player_model/idle_pick.c +141 -0
- azurkit/tools/characters/player_model/idle_pick.o +0 -0
- azurkit/tools/characters/player_model/jiggle.c +1079 -0
- azurkit/tools/characters/player_model/jiggle.o +0 -0
- azurkit/tools/characters/player_model/light_hit.c +156 -0
- azurkit/tools/characters/player_model/light_hit.o +0 -0
- azurkit/tools/characters/player_model/near_draw.c +91 -0
- azurkit/tools/characters/player_model/near_draw.o +0 -0
- azurkit/tools/characters/player_model/over_top.c +179 -0
- azurkit/tools/characters/player_model/over_top.o +0 -0
- azurkit/tools/characters/player_model/roll.c +125 -0
- azurkit/tools/characters/player_model/roll.o +0 -0
- azurkit/tools/characters/player_model/seek.c +109 -0
- azurkit/tools/characters/player_model/seek.o +0 -0
- azurkit/tools/characters/player_model/slots.c +126 -0
- azurkit/tools/characters/player_model/slots.o +0 -0
- azurkit/tools/characters/player_model/sounds.c +89 -0
- azurkit/tools/characters/player_model/sounds.o +0 -0
- azurkit/tools/characters/player_model/stance_term.c +183 -0
- azurkit/tools/characters/player_model/stance_term.o +0 -0
- azurkit/tools/characters/player_model/target_search.c +89 -0
- azurkit/tools/characters/player_model/target_search.o +0 -0
- azurkit/tools/characters/player_model/throw.c +774 -0
- azurkit/tools/characters/player_model/throw.o +0 -0
- azurkit/tools/characters/player_model/thud.c +78 -0
- azurkit/tools/characters/player_model/thud.o +0 -0
- azurkit/tools/characters/player_model/touch.c +61 -0
- azurkit/tools/characters/player_model/touch.o +0 -0
- azurkit/tools/characters/player_model/victory_cancel.c +103 -0
- azurkit/tools/characters/player_model/victory_cancel.o +0 -0
- azurkit/tools/characters/player_model/water_push.c +464 -0
- azurkit/tools/characters/player_model/water_push.o +0 -0
- azurkit/tools/characters/player_model/wings.c +336 -0
- azurkit/tools/characters/player_model/wings.o +0 -0
- azurkit/tools/characters/pool.py +88 -0
- azurkit/tools/characters/ports.py +1068 -0
- azurkit/tools/characters/pose.py +61 -0
- azurkit/tools/characters/render.py +305 -0
- azurkit/tools/characters/retarget.py +1303 -0
- azurkit/tools/characters/shipped.json +3437 -0
- azurkit/tools/characters/shipped.py +451 -0
- azurkit/tools/characters/skeleton.py +203 -0
- azurkit/tools/characters/species.json +1 -0
- azurkit/tools/characters/synth.py +125 -0
- azurkit/tools/characters/textures.py +322 -0
- azurkit/tools/characters/variant_grades.json +59 -0
- azurkit/tools/characters/variants.json +1028 -0
- azurkit/tools/characters/weapon_grades.json +52 -0
- azurkit/tools/characters/weapons.json +104 -0
- azurkit/tools/characters/xbox_adpcm.py +184 -0
- azurkit/tools/data/__init__.py +441 -0
- azurkit/tools/data/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/data/__pycache__/views.cpython-314.pyc +0 -0
- azurkit/tools/data/config.xbr +0 -0
- azurkit/tools/data/schema.json +1371 -0
- azurkit/tools/data/views.py +574 -0
- azurkit/tools/mods/__init__.py +142 -0
- azurkit/tools/mods/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/mods/__pycache__/community.cpython-314.pyc +0 -0
- azurkit/tools/mods/__pycache__/engine.cpython-314.pyc +0 -0
- azurkit/tools/mods/community.py +259 -0
- azurkit/tools/mods/engine.py +651 -0
- azurkit/tools/mods/packs/__init__.py +1 -0
- azurkit/tools/mods/packs/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/air_shield_flaps.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/enemy_damage.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/enemy_scaling.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/fps_unlock.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/player_max_hp.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/player_physics.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_alloc_race.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_memory.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_photo_mode.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_pickup_anims.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_popups.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_save_warp.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/__pycache__/qol_skip_logo.cpython-314.pyc +0 -0
- azurkit/tools/mods/packs/air_shield_flaps.py +28 -0
- azurkit/tools/mods/packs/enemy_damage.py +98 -0
- azurkit/tools/mods/packs/enemy_scaling.py +240 -0
- azurkit/tools/mods/packs/fps_unlock.py +125 -0
- azurkit/tools/mods/packs/player_max_hp.py +37 -0
- azurkit/tools/mods/packs/player_physics.py +311 -0
- azurkit/tools/mods/packs/qol_alloc_race.py +148 -0
- azurkit/tools/mods/packs/qol_memory.py +124 -0
- azurkit/tools/mods/packs/qol_photo_mode.py +37 -0
- azurkit/tools/mods/packs/qol_pickup_anims.py +16 -0
- azurkit/tools/mods/packs/qol_popups.py +31 -0
- azurkit/tools/mods/packs/qol_save_warp.py +28 -0
- azurkit/tools/mods/packs/qol_skip_logo.py +31 -0
- azurkit/tools/mods/shims/enemy_scaling.c +356 -0
- azurkit/tools/mods/shims/enemy_scaling.o +0 -0
- azurkit/tools/mods/shims/player_physics.c +273 -0
- azurkit/tools/mods/shims/player_physics.o +0 -0
- azurkit/tools/mods/shims/qol_alloc_race.c +222 -0
- azurkit/tools/mods/shims/qol_alloc_race.o +0 -0
- azurkit/tools/mods/shims/qol_memory_budget.c +45 -0
- azurkit/tools/mods/shims/qol_memory_budget.o +0 -0
- azurkit/tools/mods/shims/qol_memory_heap.h +330 -0
- azurkit/tools/mods/shims/qol_memory_upper256.c +57 -0
- azurkit/tools/mods/shims/qol_memory_upper256.o +0 -0
- azurkit/tools/mods/shims/qol_memory_upper64.c +80 -0
- azurkit/tools/mods/shims/qol_memory_upper64.o +0 -0
- azurkit/tools/mods/shims/qol_memory_upper64_gpu.c +183 -0
- azurkit/tools/mods/shims/qol_memory_upper64_gpu.o +0 -0
- azurkit/tools/mods/shims/qol_photo_mode.c +208 -0
- azurkit/tools/mods/shims/qol_photo_mode.o +0 -0
- azurkit/tools/mods/shims/qol_save_warp.c +306 -0
- azurkit/tools/mods/shims/qol_save_warp.o +0 -0
- azurkit/tools/mods/shims/qol_skip_logo.c +26 -0
- azurkit/tools/mods/shims/qol_skip_logo.o +0 -0
- azurkit/tools/mods/shims/xboxkrnl_resolver.c +74 -0
- azurkit/tools/mods/shims/xboxkrnl_resolver.o +0 -0
- azurkit/tools/randomizer/__init__.py +461 -0
- azurkit/tools/randomizer/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/dbcache.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/generator.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/layer.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/levels.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/movement.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/needs.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/physics.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/ports.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/tracker.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/tracking.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/__pycache__/writer.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__init__.py +196 -0
- azurkit/tools/randomizer/art/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/collision.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/draw.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/icons.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/maps.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/nodes.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/reach.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/render.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/__pycache__/tern.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/art/collision.py +142 -0
- azurkit/tools/randomizer/art/draw.py +345 -0
- azurkit/tools/randomizer/art/icons.py +265 -0
- azurkit/tools/randomizer/art/maps.py +805 -0
- azurkit/tools/randomizer/art/node_types.json +764 -0
- azurkit/tools/randomizer/art/nodes.py +526 -0
- azurkit/tools/randomizer/art/reach.py +75 -0
- azurkit/tools/randomizer/art/render.py +305 -0
- azurkit/tools/randomizer/art/tern.py +188 -0
- azurkit/tools/randomizer/data/contact.json +509 -0
- azurkit/tools/randomizer/data/derived/a1.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/a3.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/a5.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/a6.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/airship.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/d1.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/d2.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/e2.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/e5.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/e6.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/e7.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/f1.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/f2.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/f3.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/f4.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/f6.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/life.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/town.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/w1.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/w2.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/w3.json.gz +0 -0
- azurkit/tools/randomizer/data/derived/w4.json.gz +0 -0
- azurkit/tools/randomizer/data/forcing.json +2452 -0
- azurkit/tools/randomizer/data/generated/a1.json +2148 -0
- azurkit/tools/randomizer/data/generated/a3.json +606 -0
- azurkit/tools/randomizer/data/generated/a5.json +2301 -0
- azurkit/tools/randomizer/data/generated/a6.json +1687 -0
- azurkit/tools/randomizer/data/generated/airship.json +156 -0
- azurkit/tools/randomizer/data/generated/d1.json +1423 -0
- azurkit/tools/randomizer/data/generated/d2.json +1297 -0
- azurkit/tools/randomizer/data/generated/e2.json +1598 -0
- azurkit/tools/randomizer/data/generated/e5.json +3671 -0
- azurkit/tools/randomizer/data/generated/e6.json +1902 -0
- azurkit/tools/randomizer/data/generated/e7.json +3607 -0
- azurkit/tools/randomizer/data/generated/f1.json +3022 -0
- azurkit/tools/randomizer/data/generated/f2.json +2243 -0
- azurkit/tools/randomizer/data/generated/f3.json +2369 -0
- azurkit/tools/randomizer/data/generated/f4.json +2669 -0
- azurkit/tools/randomizer/data/generated/f6.json +2104 -0
- azurkit/tools/randomizer/data/generated/life.json +2115 -0
- azurkit/tools/randomizer/data/generated/town.json +3555 -0
- azurkit/tools/randomizer/data/generated/training_room.json +375 -0
- azurkit/tools/randomizer/data/generated/w1.json +2692 -0
- azurkit/tools/randomizer/data/generated/w2.json +2400 -0
- azurkit/tools/randomizer/data/generated/w3.json +2964 -0
- azurkit/tools/randomizer/data/generated/w4.json +2686 -0
- azurkit/tools/randomizer/data/items.toml +398 -0
- azurkit/tools/randomizer/data/levels/a1.toml +1213 -0
- azurkit/tools/randomizer/data/levels/a3.toml +344 -0
- azurkit/tools/randomizer/data/levels/a5.toml +1397 -0
- azurkit/tools/randomizer/data/levels/a6.toml +1263 -0
- azurkit/tools/randomizer/data/levels/airship.toml +37 -0
- azurkit/tools/randomizer/data/levels/d1.toml +876 -0
- azurkit/tools/randomizer/data/levels/d2.toml +716 -0
- azurkit/tools/randomizer/data/levels/e2.toml +3393 -0
- azurkit/tools/randomizer/data/levels/e5.toml +1580 -0
- azurkit/tools/randomizer/data/levels/e6.toml +1349 -0
- azurkit/tools/randomizer/data/levels/e7.toml +865 -0
- azurkit/tools/randomizer/data/levels/f1.toml +2544 -0
- azurkit/tools/randomizer/data/levels/f2.toml +1590 -0
- azurkit/tools/randomizer/data/levels/f3.toml +1249 -0
- azurkit/tools/randomizer/data/levels/f4.toml +1061 -0
- azurkit/tools/randomizer/data/levels/f6.toml +1100 -0
- azurkit/tools/randomizer/data/levels/life.toml +571 -0
- azurkit/tools/randomizer/data/levels/town.toml +1139 -0
- azurkit/tools/randomizer/data/levels/w1.toml +1131 -0
- azurkit/tools/randomizer/data/levels/w2.toml +1024 -0
- azurkit/tools/randomizer/data/levels/w3.toml +1743 -0
- azurkit/tools/randomizer/data/levels/w4.toml +422 -0
- azurkit/tools/randomizer/data/movement/a1.json +321 -0
- azurkit/tools/randomizer/data/movement/a3.json +93 -0
- azurkit/tools/randomizer/data/movement/a5.json +141 -0
- azurkit/tools/randomizer/data/movement/a6.json +110 -0
- azurkit/tools/randomizer/data/movement/airship.json +12 -0
- azurkit/tools/randomizer/data/movement/d1.json +60 -0
- azurkit/tools/randomizer/data/movement/d2.json +60 -0
- azurkit/tools/randomizer/data/movement/e2.json +867 -0
- azurkit/tools/randomizer/data/movement/e5.json +392 -0
- azurkit/tools/randomizer/data/movement/e6.json +234 -0
- azurkit/tools/randomizer/data/movement/e7.json +124 -0
- azurkit/tools/randomizer/data/movement/f1.json +601 -0
- azurkit/tools/randomizer/data/movement/f2.json +395 -0
- azurkit/tools/randomizer/data/movement/f3.json +332 -0
- azurkit/tools/randomizer/data/movement/f4.json +250 -0
- azurkit/tools/randomizer/data/movement/f6.json +220 -0
- azurkit/tools/randomizer/data/movement/life.json +12 -0
- azurkit/tools/randomizer/data/movement/town.json +142 -0
- azurkit/tools/randomizer/data/movement/w1.json +188 -0
- azurkit/tools/randomizer/data/movement/w2.json +193 -0
- azurkit/tools/randomizer/data/movement/w3.json +155 -0
- azurkit/tools/randomizer/data/movement/w4.json +59 -0
- azurkit/tools/randomizer/data/proofs.jsonl +21 -0
- azurkit/tools/randomizer/data/route.toml +3627 -0
- azurkit/tools/randomizer/data/settings/defaults.toml +16 -0
- azurkit/tools/randomizer/data/slider_tiers.json +19 -0
- azurkit/tools/randomizer/data/tricks.toml +2432 -0
- azurkit/tools/randomizer/data/world.toml +368 -0
- azurkit/tools/randomizer/dbcache.py +197 -0
- azurkit/tools/randomizer/draft/__init__.py +11 -0
- azurkit/tools/randomizer/draft/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/draft/__pycache__/envelope.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/draft/__pycache__/floors.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/draft/__pycache__/geometry.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/draft/__pycache__/wiring.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/draft/envelope.py +211 -0
- azurkit/tools/randomizer/draft/floors.py +221 -0
- azurkit/tools/randomizer/draft/geometry.py +682 -0
- azurkit/tools/randomizer/draft/tiers.toml +97 -0
- azurkit/tools/randomizer/draft/wiring.py +632 -0
- azurkit/tools/randomizer/generator.py +466 -0
- azurkit/tools/randomizer/layer.py +302 -0
- azurkit/tools/randomizer/levels.py +1134 -0
- azurkit/tools/randomizer/live/__init__.py +15 -0
- azurkit/tools/randomizer/live/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/__pycache__/errors.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/__pycache__/game.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/__pycache__/link.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/__pycache__/pages.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/__pycache__/recipes.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/__pycache__/regions.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/live/errors.py +8 -0
- azurkit/tools/randomizer/live/game.py +775 -0
- azurkit/tools/randomizer/live/link.py +534 -0
- azurkit/tools/randomizer/live/pages.py +76 -0
- azurkit/tools/randomizer/live/recipes.py +168 -0
- azurkit/tools/randomizer/live/regions.py +42 -0
- azurkit/tools/randomizer/movement.py +116 -0
- azurkit/tools/randomizer/needs.py +344 -0
- azurkit/tools/randomizer/physics.py +711 -0
- azurkit/tools/randomizer/planner/__init__.py +8 -0
- azurkit/tools/randomizer/planner/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/difficulty.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/fill.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/grammar.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/loader.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/plan.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/proofs.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/settings.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/__pycache__/solver.cpython-314.pyc +0 -0
- azurkit/tools/randomizer/planner/difficulty.py +547 -0
- azurkit/tools/randomizer/planner/fill.py +1084 -0
- azurkit/tools/randomizer/planner/grammar.py +138 -0
- azurkit/tools/randomizer/planner/loader.py +1764 -0
- azurkit/tools/randomizer/planner/plan.py +905 -0
- azurkit/tools/randomizer/planner/proofs.py +144 -0
- azurkit/tools/randomizer/planner/settings.py +414 -0
- azurkit/tools/randomizer/planner/solver.py +1192 -0
- azurkit/tools/randomizer/ports.py +70 -0
- azurkit/tools/randomizer/shipped/ec11036b553991593c84383ad37ebd62.pickle +4 -0
- azurkit/tools/randomizer/tracker.py +376 -0
- azurkit/tools/randomizer/tracking.py +309 -0
- azurkit/tools/randomizer/writer.py +384 -0
- azurkit/tools/saves/__init__.py +93 -0
- azurkit/tools/saves/__pycache__/__init__.cpython-314.pyc +0 -0
- azurkit/tools/saves/__pycache__/container.cpython-314.pyc +0 -0
- azurkit/tools/saves/__pycache__/fatx.cpython-314.pyc +0 -0
- azurkit/tools/saves/__pycache__/session.cpython-314.pyc +0 -0
- azurkit/tools/saves/__pycache__/sign.cpython-314.pyc +0 -0
- azurkit/tools/saves/container.py +275 -0
- azurkit/tools/saves/fatx.py +275 -0
- azurkit/tools/saves/session.py +92 -0
- azurkit/tools/saves/sign.py +117 -0
- azurkit-1.0.0.dist-info/METADATA +102 -0
- azurkit-1.0.0.dist-info/RECORD +547 -0
- azurkit-1.0.0.dist-info/WHEEL +4 -0
- azurkit-1.0.0.dist-info/entry_points.txt +3 -0
- azurkit-1.0.0.dist-info/licenses/LICENSE +21 -0
- azurkit-1.0.0.dist-info/licenses/NOTICE.md +2108 -0
azurkit/app/__init__.py
ADDED
|
@@ -0,0 +1,1290 @@
|
|
|
1
|
+
"""The app host (D732): the web page (``web/``) with the native engine
|
|
2
|
+
behind it (D866), in a pywebview window, or, where there is no webview
|
|
3
|
+
(Linux without pywebview's GTK or Qt extras, say), in the default browser.
|
|
4
|
+
|
|
5
|
+
Either way the page comes from a server on 127.0.0.1 (:func:`serve`) that
|
|
6
|
+
answers only a request that names it as the host (no DNS rebinding) and
|
|
7
|
+
carries this launch's token: in its URL the first time, then in the cookie
|
|
8
|
+
that answer sets. The cookie is named for the launch's port: browsers keep
|
|
9
|
+
cookies by host, not port, so a second launch's never replaces the first's.
|
|
10
|
+
The engine below never starts a thread; this host does, for the server,
|
|
11
|
+
and the app's engine process (:mod:`.engine`).
|
|
12
|
+
|
|
13
|
+
The server is the page's engine too. ``host.json`` tells the page it is in
|
|
14
|
+
the app, with the catalog, AzurKit's version and the commit it was built
|
|
15
|
+
from (``azurkit.commit``, About's), the image to start from (the
|
|
16
|
+
one the last build read, remembered in platformdirs' config folder, else a
|
|
17
|
+
release's own image), whether there are file dialogs (the window's), the
|
|
18
|
+
site's address, which its share links name (:data:`SITE`, D884), and the
|
|
19
|
+
theme the player last picked (dark until they pick light, D897; ``POST
|
|
20
|
+
api/theme`` ``{"theme"}`` remembers it there too: the page's address, and
|
|
21
|
+
so its storage, is new each launch) and the page's setup as it was last
|
|
22
|
+
left (D915: the packs ticked, the settings changed and a loaded recipe's
|
|
23
|
+
config.xbr cells, never the image or anything read from it; ``POST
|
|
24
|
+
api/setup`` ``{"setup"}`` remembers it, ``null`` forgets it, and ``POST
|
|
25
|
+
api/restore`` ``{"setup"}`` gives back what still applies,
|
|
26
|
+
``sdk.restore``). The page's ``theme.js``, which sets
|
|
27
|
+
the theme before its first paint, is the server's own (:func:`_theme_js`),
|
|
28
|
+
and the window opens on that theme's ground (:data:`GROUNDS`), so a player
|
|
29
|
+
who picked light never sees a dark first frame.
|
|
30
|
+
``POST api/build`` ``{"iso", "packs", "xbr", "randomizer"}`` plans the build (``sdk.image``: the
|
|
31
|
+
report, the size, the build's id, and where to save it: the Downloads
|
|
32
|
+
folder, under the site's download's name), and ``POST api/save``
|
|
33
|
+
``{"recipe", "out", "build"}`` writes it: the image that build made, kept
|
|
34
|
+
and hashed in the engine from the moment it was made (``sdk.save``), else,
|
|
35
|
+
when the engine keeps it no more or its input changed, the recipe built
|
|
36
|
+
again (``sdk.build``); never over an existing file but the one the save
|
|
37
|
+
dialog chose (it asked first), with ``POST api/cancel``. ``GET
|
|
38
|
+
api/progress`` gives the progress of the calls in flight, each a
|
|
39
|
+
``core.progress`` record (``{"stage", "label", "done", "total",
|
|
40
|
+
"percent"}``): the newest's at its top, and each kind's in ``calls``
|
|
41
|
+
(``build``, ``save``: their named stages, the swap's items counted;
|
|
42
|
+
``preview``, ``match``: the call's own line, the swap's items counted;
|
|
43
|
+
``plan``: its logic read, fill and placements; ``tracker``: a plan's
|
|
44
|
+
first view's seed logic and world map, the maps' exits counted on it
|
|
45
|
+
outside Live's view; ``randomizer``, ``restore``,
|
|
46
|
+
``vanilla``, ``spoiler``: the call's own line, no count), which the page
|
|
47
|
+
shows as its bar.
|
|
48
|
+
Both run in the engine (:mod:`.engine`): ``serve``'s on a thread of the
|
|
49
|
+
server, the app's (:func:`main`) in a process of its own, with the seed's
|
|
50
|
+
plan and the Characters tab's pictures and G4 match, so the server's own
|
|
51
|
+
answers never wait on a build, and Cancel answers at once.
|
|
52
|
+
Work ahead (:mod:`.ahead`, D1007): ``POST api/prefetch`` ``{"gen", "recipe",
|
|
53
|
+
"view"}`` plans the page's typed seed (``{seed, share}``, as Track asks it)
|
|
54
|
+
and reads that plan's first tracker view, and ``POST api/speculate`` ``{"gen",
|
|
55
|
+
"iso", "packs", "xbr", "randomizer", "character"}`` builds the page's settled
|
|
56
|
+
setup, each at the page's change number; Track, the tracker's first ask and
|
|
57
|
+
Build take them. ``api/install`` carries ``gen`` too.
|
|
58
|
+
``POST api/spoiler`` ``{}`` gives the last randomized build's spoiler
|
|
59
|
+
(``{"text"}``), which the build leaves out (``sdk.image``'s ``spoiler``
|
|
60
|
+
false) and the engine makes from the plan it kept the first time it is
|
|
61
|
+
asked (``sdk.spoiler(plan, built=True)``), and ``{"out"}`` writes it there,
|
|
62
|
+
on the same terms. ``POST api/randomizer`` ``{"given"}`` gives what
|
|
63
|
+
the Randomizer tab shows (``sdk.randomizer_settings``).
|
|
64
|
+
``POST api/settings_file`` ``{"out", "settings"}`` writes the Randomizer
|
|
65
|
+
tab's settings as a settings file (D144; ``out`` null: ``settings.toml``
|
|
66
|
+
in the Downloads folder), never over an existing file but one the save
|
|
67
|
+
dialog chose.
|
|
68
|
+
``POST api/tracker`` ``{"given"}`` gives what the item tracker shows
|
|
69
|
+
(``sdk.tracker``; a shaped plan's Spoiler's openers are the engine's, as
|
|
70
|
+
its work ahead keeps them; with no plan the vanilla game's spots, never in
|
|
71
|
+
the slot a typed seed's first view is read ahead in); with Live on for its plan, it first merges what Live
|
|
72
|
+
read since the last ask (:mod:`.live`: the spots the game shows taken, the
|
|
73
|
+
element levels it holds) and gives Live's status and where it saw the
|
|
74
|
+
player (``live``). ``POST api/live`` ``{"on", "plan", "marks",
|
|
75
|
+
"endpoints"}`` turns Live on for a plan (reading the first of the
|
|
76
|
+
endpoints that answers as xemu: the fork's shared RAM, or QMP; Modkit's
|
|
77
|
+
grammar, empty for the defaults) or off. ``POST api/plan`` ``{"recipe"}`` plans a seed to track
|
|
78
|
+
(``sdk.plan``), and ``POST api/tracked`` ``{"tracker"}`` remembers the
|
|
79
|
+
plan last tracked and the marks of the last plans (``host.json``'s
|
|
80
|
+
``tracker``: the randomizer's names and numbers, never the image's).
|
|
81
|
+
``POST api/art`` gives the tracker's pictures that ship with the page
|
|
82
|
+
(:mod:`.art`: its maps' index and its icons' names, Step 4.3f);
|
|
83
|
+
``GET art/<version>/<name>`` serves one.
|
|
84
|
+
Live (``api/live``'s ``gems``) reads the gem spots too while the tracker
|
|
85
|
+
shows them (R1.43).
|
|
86
|
+
``POST api/vanilla`` ``{"iso"}`` reads the image's own values of the
|
|
87
|
+
sliders at its XBE's sites (``sdk.vanilla``), or, when the image is an
|
|
88
|
+
AzurKit build, ``{"opened": its recipe}`` (``sdk.open_build`` of the files
|
|
89
|
+
it changed).
|
|
90
|
+
``POST api/preset`` ``{"recipe", "report"}`` makes a share link's fragment
|
|
91
|
+
(``sdk.preset_encode``) and ``POST api/decode`` ``{"text"}`` reads one
|
|
92
|
+
(``sdk.preset_decode``).
|
|
93
|
+
The Characters tab's calls (Step 4.3d): ``POST api/pickers`` ``{"given"}``
|
|
94
|
+
(``sdk.pickers``), ``api/preview`` and ``api/match`` ``{"recipe"}`` (the
|
|
95
|
+
pictures as base64 PNG), and Azurik-Custom-Content's ``api/content``
|
|
96
|
+
``{"source"}`` (``sdk.content``) and ``api/install`` ``{"id", "source"}``
|
|
97
|
+
(``sdk.content_install``, into the content folder); ``api/community``
|
|
98
|
+
``{}`` the community packs that folder holds (``sdk.community``); ``api/replace``
|
|
99
|
+
``{"path", "file"}`` picks a computer file to replace one of the image's (``sdk.file_entry``, held for this session);
|
|
100
|
+
``api/saves`` ``{"given"}`` the Tools tab's Saves (``sdk.saves``); ``api/content_source``
|
|
101
|
+
``{"source"}`` remembers the tab's Content from (``null``: the default),
|
|
102
|
+
which ``host.json``'s ``content_source`` gives the next page, and
|
|
103
|
+
``api/show_objects`` ``{"show"}`` its Show objects (D1034: ``false``, the
|
|
104
|
+
default, hides the objects), ``host.json``'s ``show_objects``.
|
|
105
|
+
The Data tab's calls (Step 4.7a): ``POST api/data`` ``{"given"}`` gives a
|
|
106
|
+
view of config.xbr's cells (``sdk.data``: the vanilla file AzurKit ships,
|
|
107
|
+
no image) and ``api/data_edit`` ``{"given"}`` checks a cell's edit into the
|
|
108
|
+
recipe's ``xbr`` operations (``sdk.data_edit``); the page keeps them.
|
|
109
|
+
The Tools tab's calls (Step 4.7c): ``POST api/info`` ``{"iso"}`` says what
|
|
110
|
+
the image is (``sdk.image_info``), ``api/verify_all`` ``{"iso"}`` checks
|
|
111
|
+
every file of it (``sdk.verify_all``: only what its kept check hasn't
|
|
112
|
+
hashed, its bar in ``api/progress``), ``api/files`` ``{"iso"}`` lists its
|
|
113
|
+
files (``sdk.files``) and ``api/extract`` ``{"iso", "path", "out"}``
|
|
114
|
+
writes one (``sdk.extract``); ``api/open`` ``{"iso"}`` reads an AzurKit
|
|
115
|
+
build's recipe (``sdk.open_build`` of the files it changed);
|
|
116
|
+
``api/mods_file`` ``{"text"}`` reads a mods file and ``{"recipe" |
|
|
117
|
+
"preset", "out"}`` writes one (``sdk.mods_file``), ``api/plan_spoiler``
|
|
118
|
+
``{"plan"}`` gives a plan file's spoiler (the engine's ``sdk.spoiler``) and
|
|
119
|
+
``api/report`` ``{"text", "out", "name"}`` writes a text the tab shows (the
|
|
120
|
+
last build's report); each save goes to ``out``, else the Downloads folder,
|
|
121
|
+
never over an existing file but the one the save dialog chose, nor a
|
|
122
|
+
vanilla image or the image it reads (D36). ``api/storage`` ``{"clear"}``
|
|
123
|
+
lists what the app keeps on this computer, by name (:func:`kept`), and
|
|
124
|
+
clears those entries alone: never an image or the folder that may hold one
|
|
125
|
+
(SEC-10); asked as the tab opens, it has the engine build the randomizer's
|
|
126
|
+
logic meanwhile, for a plan file's spoiler.
|
|
127
|
+
``POST api/choose`` ``{"kind": "iso" | "build" | "out" | ..., "path"}`` opens the window's
|
|
128
|
+
file dialog. A POST is JSON from the page's own origin. A refusal (the
|
|
129
|
+
sdk's) comes back as ``{"refused": message}``; one build or save runs at a
|
|
130
|
+
time. Closing the app cancels either (:meth:`Server.quit`, AZ-823) and
|
|
131
|
+
never waits on a build (the app's engine process: it ends it).
|
|
132
|
+
|
|
133
|
+
The app (:func:`main`) keeps the images it checked and the swap's answers
|
|
134
|
+
across launches in platformdirs' cache folder (:mod:`.swapcache`), so an
|
|
135
|
+
image picked again isn't hashed again and a body dressed once isn't
|
|
136
|
+
dressed again; ``serve`` alone (the tests') keeps nothing past its process.
|
|
137
|
+
|
|
138
|
+
The tests run with :data:`NO_WINDOW` set (``tests/conftest.py``): the owner
|
|
139
|
+
works on this screen (D839), so there :func:`main` refuses at once, before it
|
|
140
|
+
serves anything or opens a window or a browser tab. ``serve`` (``python -m
|
|
141
|
+
azurkit serve``, the frozen app's ``AzurKit serve``) opens neither in any
|
|
142
|
+
mode: it prints the page's address and serves until its input ends.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
from __future__ import annotations
|
|
146
|
+
|
|
147
|
+
import contextlib
|
|
148
|
+
import glob
|
|
149
|
+
import hashlib
|
|
150
|
+
import hmac
|
|
151
|
+
import http.server
|
|
152
|
+
import json
|
|
153
|
+
import math
|
|
154
|
+
import os
|
|
155
|
+
import secrets
|
|
156
|
+
import sys
|
|
157
|
+
import threading
|
|
158
|
+
import time
|
|
159
|
+
import urllib.parse
|
|
160
|
+
import webbrowser
|
|
161
|
+
from collections.abc import Callable
|
|
162
|
+
from pathlib import Path
|
|
163
|
+
|
|
164
|
+
from azurkit import __version__, commit, sdk
|
|
165
|
+
from azurkit.core import gate
|
|
166
|
+
from azurkit.core.progress import record
|
|
167
|
+
from azurkit.edits import files as replaced
|
|
168
|
+
from azurkit.edits import xbr
|
|
169
|
+
from azurkit.tools import randomizer
|
|
170
|
+
from azurkit.tools.mods import community as communal
|
|
171
|
+
from azurkit.tools.randomizer import dbcache, tracking
|
|
172
|
+
from azurkit.tools.randomizer.live import link
|
|
173
|
+
|
|
174
|
+
from . import ahead, live, swapcache, warm
|
|
175
|
+
from . import art as pictures
|
|
176
|
+
from . import engine as engines
|
|
177
|
+
from .engine import Cancelled
|
|
178
|
+
|
|
179
|
+
SITE = "https://azurik-modding-hub.github.io/AzurKit/" # the site (D884), which the app's share links open
|
|
180
|
+
|
|
181
|
+
PAGE = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parents[3])) / "web" # the frozen app's, else the checkout's
|
|
182
|
+
NO_WINDOW = "AZURKIT_NO_WINDOW" # set: main() opens nothing (the tests' mode)
|
|
183
|
+
MAX_BODY = 1 << 20
|
|
184
|
+
KEEP = 1.0
|
|
185
|
+
"""s a call may run before its answer's headers go, and between the spaces that follow them until the answer (JSON
|
|
186
|
+
reads past them): Firefox aborts every connection that carried no byte in the 5 s after the computer's network
|
|
187
|
+
changes (``network.http.network-changed.timeout``), and a call that sent nothing until its answer lost it, a plan's or
|
|
188
|
+
a spoiler's of seconds, tens on a slow runner ("NetworkError"; a Mac's IPv6 changed every 1-8 min here, Step 4.4ci36)."""
|
|
189
|
+
IMAGES = ("Disc images (*.iso;*.xiso)",) # the open dialog's filter (another name can be typed in)
|
|
190
|
+
SAVES = ("out", "spoiler", "settings", "extract", "mods", "report", "saves") # the kinds of save the save dialog chooses for
|
|
191
|
+
MODS = "AzurKit mods.json" # the Tools tab's mods file, by default (web/tools.mjs's)
|
|
192
|
+
NEVER_CLEARED = (".iso", ".xiso") # an image AzurKit's Clear never removes, wherever it is (SEC-10)
|
|
193
|
+
# text_select: pywebview otherwise injects a <style> to stop selection, which the page's CSP refuses
|
|
194
|
+
WINDOW = {"title": "AzurKit", "width": 1100, "height": 820, "text_select": True}
|
|
195
|
+
# The page's themes (D897: dark until the player picks light, which the app remembers) and each one's ground
|
|
196
|
+
# (web/app.css's --bg), which the window shows until the page draws
|
|
197
|
+
GROUNDS = {"dark": "#0d1117", "light": "#e8ecf1"}
|
|
198
|
+
THEMES = tuple(GROUNDS)
|
|
199
|
+
QUIT_WAIT = 1.5 # seconds a close waits, in all, for a cancelled save to remove its part file (AZ-823)
|
|
200
|
+
QUITTING = "AzurKit is quitting: nothing was written"
|
|
201
|
+
CONTENT_TYPES = {".css": "text/css", ".glb": "model/gltf-binary", ".gltf": "model/gltf+json", ".html": "text/html",
|
|
202
|
+
".ico": "image/x-icon", ".js": "text/javascript", ".json": "application/json", ".md": "text/markdown",
|
|
203
|
+
".mjs": "text/javascript", ".png": "image/png", ".wasm": "application/wasm", ".wav": "audio/x-wav",
|
|
204
|
+
".zip": "application/zip"}
|
|
205
|
+
"""What a browser is told of each kind of file the page, Pyodide's core and the content folder hold: this table, never
|
|
206
|
+
``mimetypes``, whose guess on Windows is the registry's (``text/plain`` for a ``.mjs`` on many a machine, which a browser
|
|
207
|
+
refuses to run as a module, so the page never starts)."""
|
|
208
|
+
IDLE = {"stage": None, "label": "", "done": 0, "total": 0, "percent": None} # api/progress with nothing in flight
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def content_type(name: str) -> str:
|
|
212
|
+
"""The ``Content-Type`` of a file called ``name``: :data:`CONTENT_TYPES`'s, else plain bytes."""
|
|
213
|
+
return CONTENT_TYPES.get(Path(name).suffix.lower(), "application/octet-stream")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _config() -> Path:
|
|
217
|
+
from platformdirs import user_config_path
|
|
218
|
+
return user_config_path("AzurKit", appauthor=False) / "app.json"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_REMEMBERING = threading.RLock()
|
|
222
|
+
"""Held to read the config and to write it: a build's thread and the page's calls each remember a part of it, and
|
|
223
|
+
Windows refuses to replace a file another thread has open."""
|
|
224
|
+
WAITS = (0.05, 0.1, 0.2, 0.4, 0.8, 0)
|
|
225
|
+
"""s between the tries of the config's read or replace that Windows refuses while another process (a virus scanner)
|
|
226
|
+
holds the file open for a moment; refused at the last, it fails."""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _tried(attempt: Callable[[], object]) -> object:
|
|
230
|
+
"""``attempt()``, tried again after each of :data:`WAITS` it raises PermissionError."""
|
|
231
|
+
for wait in WAITS:
|
|
232
|
+
try:
|
|
233
|
+
return attempt()
|
|
234
|
+
except PermissionError:
|
|
235
|
+
if not wait:
|
|
236
|
+
raise
|
|
237
|
+
time.sleep(wait)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _read() -> dict:
|
|
241
|
+
"""What the config holds, read past a moment it is held open (:func:`_tried`): ``{}`` when there is none;
|
|
242
|
+
ValueError for a file that holds no settings object, OSError for one it still can't read."""
|
|
243
|
+
with _REMEMBERING:
|
|
244
|
+
try:
|
|
245
|
+
kept = json.loads(_tried(lambda: _config().read_text(encoding="utf-8")))
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
return {}
|
|
248
|
+
if not isinstance(kept, dict):
|
|
249
|
+
raise ValueError(f"a JSON {type(kept).__name__}, not an object")
|
|
250
|
+
return kept
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _logic_ahead() -> None:
|
|
254
|
+
"""The randomizer's logic, read ahead from the server's start on D1008's
|
|
255
|
+
intent (Randomize on in the remembered setup, a tracked plan
|
|
256
|
+
remembered), off the launch's path, which checks the setup against the
|
|
257
|
+
shipped skip table alone: the Randomizer tab's first ask or the tracker's
|
|
258
|
+
view that follows finds it read, or waits for this read (Step 4.4bd).
|
|
259
|
+
Only the wheel's or the frozen app's shipped one, never a checkout's
|
|
260
|
+
check (about 4 s)."""
|
|
261
|
+
with contextlib.suppress(Exception): # a refusal: the page's own call names it
|
|
262
|
+
if dbcache.shipped_skips(randomizer.DATA_DIR) is not None:
|
|
263
|
+
randomizer.kept_logic()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _kept() -> dict:
|
|
267
|
+
"""What the app remembers (``{"iso", "theme", "setup"}``), else nothing. Read past a moment it is held open too: a
|
|
268
|
+
page handed no setup for one would remember the next change over it."""
|
|
269
|
+
try:
|
|
270
|
+
return _read()
|
|
271
|
+
except (OSError, ValueError):
|
|
272
|
+
return {}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _start_image(kept: dict | None = None) -> str:
|
|
276
|
+
"""The image the last build read (in ``kept``, else the config), else a release's own image, else ``""``."""
|
|
277
|
+
last = (_kept() if kept is None else kept).get("iso")
|
|
278
|
+
if isinstance(last, str) and Path(last).is_file():
|
|
279
|
+
return last
|
|
280
|
+
return str(next(filter(None, map(gate.find, gate.RELEASES)), ""))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _theme(kept: dict | None = None) -> str:
|
|
284
|
+
"""The theme the player last picked (in ``kept``, else the config), else dark (D897)."""
|
|
285
|
+
return theme if (theme := (_kept() if kept is None else kept).get("theme")) in THEMES else THEMES[0]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _theme_js() -> bytes:
|
|
289
|
+
"""The app's ``theme.js`` (the site's reads the browser's storage): it
|
|
290
|
+
sets the remembered theme before the page's first paint."""
|
|
291
|
+
return f'document.documentElement.dataset.theme = "{_theme()}";\n'.encode()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _remember(**kept: object) -> None:
|
|
295
|
+
"""Remember ``kept`` beside what the config holds (``None`` forgets a
|
|
296
|
+
key), written whole or not at all, past a moment another process (a
|
|
297
|
+
virus scanner, on Windows) holds the file open, to read it or to
|
|
298
|
+
replace it (:func:`_read`): a read refused past that writes nothing,
|
|
299
|
+
as what it couldn't read would be lost, and a file that holds no
|
|
300
|
+
settings is written over. Each says why on stderr, and no more: a build
|
|
301
|
+
doesn't fail for it."""
|
|
302
|
+
def say(text: str) -> None:
|
|
303
|
+
with contextlib.suppress(OSError, ValueError): # a stderr gone with its reader
|
|
304
|
+
print(f"azurkit: {text}", file=sys.stderr)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
with _REMEMBERING:
|
|
308
|
+
try:
|
|
309
|
+
was = _read()
|
|
310
|
+
except ValueError as exc:
|
|
311
|
+
say(f"the config held no settings ({exc}): remembering {', '.join(kept)} writes it over")
|
|
312
|
+
was = {}
|
|
313
|
+
_config().parent.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
part = _config().with_suffix(".json.part")
|
|
315
|
+
part.write_text(json.dumps({k: v for k, v in (was | kept).items() if v is not None}) + "\n",
|
|
316
|
+
encoding="utf-8")
|
|
317
|
+
_tried(lambda: part.replace(_config()))
|
|
318
|
+
except OSError as exc:
|
|
319
|
+
say(f"can't remember {', '.join(kept)}: {exc}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _set_theme(body: dict) -> dict:
|
|
323
|
+
"""``api/theme``: remember the theme the page's toggle picked."""
|
|
324
|
+
if (theme := body.get("theme")) not in THEMES:
|
|
325
|
+
raise ValueError(f"theme: {theme!r} is none of {', '.join(THEMES)}")
|
|
326
|
+
_remember(theme=theme)
|
|
327
|
+
return {}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _set_setup(body: dict) -> dict:
|
|
331
|
+
"""``api/setup``: remember the page's setup (D915), or forget it
|
|
332
|
+
(``null``): pack and setting names (identifiers, so never a path), their
|
|
333
|
+
numbers and config.xbr ``set`` operations only, so never a
|
|
334
|
+
``replace_bytes``' bytes. A ``set``'s table, entity, prop and text
|
|
335
|
+
value are free strings (config.xbr's names hold spaces; text cells hold
|
|
336
|
+
text), checked by :func:`xbr.normalize` alone: the page fills them from
|
|
337
|
+
the catalog and never puts the image's path in one. The Randomizer
|
|
338
|
+
tab's (``randomizer``: on, the seed the player typed, the settings and
|
|
339
|
+
the obsidian cost) is checked as a build checks it
|
|
340
|
+
(``tools.randomizer.remembered``), and the Characters tab's
|
|
341
|
+
(``character``) as a share link carries one: names and numbers."""
|
|
342
|
+
if (setup := body.get("setup")) is not None:
|
|
343
|
+
if not (isinstance(setup, dict) and set(setup) <= set(sdk.SETUP)):
|
|
344
|
+
raise ValueError(f"setup: an object of {', '.join(sdk.SETUP)} only, or null")
|
|
345
|
+
ticked, values, ops = setup.get("ticked", []), setup.get("values", {}), setup.get("xbr", [])
|
|
346
|
+
if not (isinstance(ticked, list) and all(map(_named, ticked))):
|
|
347
|
+
raise ValueError("setup: ticked holds pack names only")
|
|
348
|
+
if not (isinstance(values, dict) and all(_named(pack) and _numbers(settings) for pack, settings in values.items())):
|
|
349
|
+
raise ValueError("setup: values holds {pack: {setting: number}} only")
|
|
350
|
+
if not (isinstance(ops, list) and all(isinstance(op, dict) and op.get("op") == "set" for op in ops)):
|
|
351
|
+
raise ValueError("setup: xbr holds config.xbr set operations only (Q174)")
|
|
352
|
+
xbr.normalize(ops) # each set with exactly its keys, else refused by name
|
|
353
|
+
if "randomizer" in setup:
|
|
354
|
+
try:
|
|
355
|
+
randomizer.remembered(setup["randomizer"])
|
|
356
|
+
except ValueError as exc:
|
|
357
|
+
raise ValueError(f"setup: randomizer: {exc}") from None
|
|
358
|
+
if "character" in setup and (lost := sdk.restore({"character": setup["character"]})["dropped"]):
|
|
359
|
+
raise ValueError(f"setup: {lost[0]}") # by name and numbers, never a recipe file's path
|
|
360
|
+
if "community" in setup:
|
|
361
|
+
communal.normalize(setup["community"]) # ids and pins only, else refused by name
|
|
362
|
+
_remember(setup=setup)
|
|
363
|
+
return {}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
TRACKED = 20 # the plans whose marks the app keeps (the page's KEEP)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _set_tracked(body: dict) -> dict:
|
|
370
|
+
"""``api/tracked``: remember the tracker's plan last tracked (a plan
|
|
371
|
+
file's object: the randomizer's names and numbers) and the marks of
|
|
372
|
+
the last :data:`TRACKED` plans (``{sha256: marks}``), Live's endpoints,
|
|
373
|
+
Gems and Show collected, or forget them (``null``); marks the tracker
|
|
374
|
+
can't read are refused by name."""
|
|
375
|
+
if (kept := body.get("tracker")) is not None:
|
|
376
|
+
if not (isinstance(kept, dict) and set(kept) <= {"plan", "marks", "endpoints", "gems", "collected"}
|
|
377
|
+
and all(kept.get(k, False) in (True, False) for k in ("gems", "collected"))):
|
|
378
|
+
raise ValueError("tracker: an object of plan, marks, endpoints, gems and collected only, or null")
|
|
379
|
+
if not isinstance(endpoints := kept.get("endpoints", ""), str):
|
|
380
|
+
raise ValueError("tracker: endpoints is a list's text")
|
|
381
|
+
try:
|
|
382
|
+
link.parse(endpoints)
|
|
383
|
+
except ValueError as exc:
|
|
384
|
+
raise ValueError(f"tracker: endpoints: {exc}") from None
|
|
385
|
+
plan, marks = kept.get("plan"), kept.get("marks", {})
|
|
386
|
+
if not (plan is None or (isinstance(plan, dict) and isinstance(plan.get("sha256"), str))):
|
|
387
|
+
raise ValueError("tracker: plan is a plan file's object")
|
|
388
|
+
if not (isinstance(marks, dict) and len(marks) <= TRACKED and all(isinstance(k, str) for k in marks)):
|
|
389
|
+
raise ValueError(f"tracker: marks is {{plan sha256: marks}} of at most {TRACKED} plans")
|
|
390
|
+
for each in marks.values():
|
|
391
|
+
tracking.marks_of(each)
|
|
392
|
+
_remember(tracker=kept)
|
|
393
|
+
return {}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _set_source(body: dict) -> dict:
|
|
397
|
+
"""``api/content_source``: remember the Characters tab's Content from
|
|
398
|
+
(an http(s) URL or a folder's path, as ``sdk.content`` reads it), or
|
|
399
|
+
forget it (``null``: the default)."""
|
|
400
|
+
if (source := body.get("source")) is not None:
|
|
401
|
+
if not (isinstance(source, str) and len(source) <= 2048 and (
|
|
402
|
+
urllib.parse.urlsplit(source.strip()).scheme in ("http", "https")
|
|
403
|
+
or Path(source.strip()).expanduser().is_absolute())):
|
|
404
|
+
raise ValueError("content_source: an http(s) URL or a folder's full path")
|
|
405
|
+
_remember(content_source=source)
|
|
406
|
+
return {}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _set_objects(body: dict) -> dict:
|
|
410
|
+
"""``api/show_objects``: remember the Characters tab's Show objects
|
|
411
|
+
(``true``; ``false`` forgets it: the objects hidden, the default)."""
|
|
412
|
+
if not isinstance(show := body.get("show"), bool):
|
|
413
|
+
raise ValueError("show_objects: show is true or false")
|
|
414
|
+
_remember(show_objects=show or None)
|
|
415
|
+
return {}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _downloads() -> Path:
|
|
419
|
+
"""The Downloads folder (made where it isn't): where a save without a path goes."""
|
|
420
|
+
from platformdirs import user_downloads_path
|
|
421
|
+
folder = user_downloads_path()
|
|
422
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
423
|
+
return folder
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _written(out: Path, data: bytes) -> dict:
|
|
427
|
+
"""``data`` at ``out``, whole or not at all (a part file renamed into place)."""
|
|
428
|
+
part = out.with_name(f"{out.name}.part")
|
|
429
|
+
try:
|
|
430
|
+
part.write_bytes(data)
|
|
431
|
+
part.replace(out)
|
|
432
|
+
finally:
|
|
433
|
+
part.unlink(missing_ok=True)
|
|
434
|
+
return {"out": str(out), "size": len(data)}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def kept() -> list[tuple[str, str, Path]]:
|
|
438
|
+
"""What the app keeps on this computer (``api/storage``), each a named
|
|
439
|
+
entry of its own (SEC-10): ``(name, what, path)``."""
|
|
440
|
+
from platformdirs import user_data_path
|
|
441
|
+
return [("settings", "Your setup, theme, tracker marks, Content from and the last image's path", _config()),
|
|
442
|
+
("stamps", "The images AzurKit checked (each one's path, size and times)", swapcache.root() / "stamps.json"),
|
|
443
|
+
("swap", "The characters' dressed bodies, pictures and G4 matches", swapcache.root() / "swap"),
|
|
444
|
+
("content", "Installed community content", user_data_path("AzurKit", appauthor=False) / "content")]
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _size(path: Path) -> int:
|
|
448
|
+
if not path.is_dir() or path.is_symlink():
|
|
449
|
+
return path.lstat().st_size
|
|
450
|
+
return sum((Path(root) / name).lstat().st_size for root, _, names in os.walk(path) for name in names)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _clear(path: Path) -> bool:
|
|
454
|
+
"""Remove ``path``, a :func:`kept` entry: a file, or a folder's files
|
|
455
|
+
and then the folders they leave empty; never an image
|
|
456
|
+
(:data:`NEVER_CLEARED`), nor a folder still holding one (SEC-10).
|
|
457
|
+
Whether it held anything."""
|
|
458
|
+
if not (path.exists() or path.is_symlink()):
|
|
459
|
+
return False
|
|
460
|
+
if path.is_symlink() or not path.is_dir():
|
|
461
|
+
if path.suffix.lower() in NEVER_CLEARED:
|
|
462
|
+
return False
|
|
463
|
+
path.unlink()
|
|
464
|
+
return True
|
|
465
|
+
for root, folders, names in os.walk(path, topdown=False):
|
|
466
|
+
for name in [*names, *(f for f in folders if (Path(root) / f).is_symlink())]:
|
|
467
|
+
if Path(name).suffix.lower() not in NEVER_CLEARED:
|
|
468
|
+
(Path(root) / name).unlink()
|
|
469
|
+
with contextlib.suppress(OSError): # it still holds an image
|
|
470
|
+
Path(root).rmdir()
|
|
471
|
+
return True
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _storage(body: dict) -> dict:
|
|
475
|
+
"""``api/storage``: each :func:`kept` entry there is, its size in bytes;
|
|
476
|
+
with ``clear``, each removed first (:func:`_clear`) and named in
|
|
477
|
+
``cleared``."""
|
|
478
|
+
cleared = [name for name, _, path in kept() if body.get("clear") and _clear(path)]
|
|
479
|
+
return {"entries": [{"name": name, "what": what, "size": _size(path)} for name, what, path in kept()
|
|
480
|
+
if path.exists() or path.is_symlink()]} | ({"cleared": cleared} if body.get("clear") else {})
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _named(name: object) -> bool:
|
|
484
|
+
"""``name`` is a pack's or a setting's name (an identifier: never a path)."""
|
|
485
|
+
return isinstance(name, str) and name.isidentifier()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _numbers(settings: object) -> bool:
|
|
489
|
+
"""``settings`` is ``{setting: a finite number}``."""
|
|
490
|
+
return isinstance(settings, dict) and all(_named(name) and type(value) in (int, float) and math.isfinite(value)
|
|
491
|
+
for name, value in settings.items())
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _path(given: object, what: str) -> Path:
|
|
495
|
+
"""``given`` (``~`` expanded) as an absolute path; ``ValueError`` otherwise."""
|
|
496
|
+
if not isinstance(given, str) or not given.strip():
|
|
497
|
+
raise ValueError(f"name the {what}'s path")
|
|
498
|
+
path = Path(given.strip()).expanduser()
|
|
499
|
+
if not path.is_absolute():
|
|
500
|
+
raise ValueError(f"{given}: give the {what}'s full path")
|
|
501
|
+
return path
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class Server(http.server.ThreadingHTTPServer):
|
|
505
|
+
"""The page's server and engine, on its own thread. :attr:`url` opens it."""
|
|
506
|
+
|
|
507
|
+
daemon_threads = True
|
|
508
|
+
|
|
509
|
+
def __init__(self, page: Path, engine: engines.Here | engines.Spawned | None = None):
|
|
510
|
+
super().__init__(("127.0.0.1", 0), _Handler)
|
|
511
|
+
self.engine = engine or engines.Here() # where builds, saves, plans and pictures run
|
|
512
|
+
self.page, self.token = page.resolve(), secrets.token_urlsafe(32)
|
|
513
|
+
self.host, self.cookie = f"127.0.0.1:{self.server_port}", f"azurkit-token-{self.server_port}"
|
|
514
|
+
self.url = f"http://{self.host}/?token={self.token}"
|
|
515
|
+
self.window = None # the pywebview window, once there is one: its file dialogs
|
|
516
|
+
self.started = time.monotonic() # host.json's age: how long this launch has run (the page's bar, D980)
|
|
517
|
+
self.busy = threading.Lock() # one build or save at a time
|
|
518
|
+
self.writing = threading.Lock() # held by a save, which quit() waits on (never a build)
|
|
519
|
+
self.out: Path | None = None # where the save in flight writes
|
|
520
|
+
self.calls: dict[object, tuple[str, dict]] = {} # the calls in flight: their kind and last record
|
|
521
|
+
self.following = threading.Lock() # self.calls
|
|
522
|
+
self.cancelled = threading.Event()
|
|
523
|
+
self.quitting = False # set by quit(), before it cancels: no build or save starts after it
|
|
524
|
+
self.deadline: float | None = None # quit()'s first call's, plus QUIT_WAIT
|
|
525
|
+
self.chosen: tuple[str, Path] | None = None # the save dialog's last answer and its kind (it asked first)
|
|
526
|
+
self.seeded: dict | None = None # the last randomized build's plan, whose spoiler api/spoiler gives
|
|
527
|
+
self.spoiler: str | None = None # and that spoiler, once the engine made it
|
|
528
|
+
self.source: Path | None = None # and the image it was built from, which it never replaces (D36)
|
|
529
|
+
self.tracking = threading.Lock() # the tracker the page's asks and Live's feed share
|
|
530
|
+
self.feed: live.Feed | None = None # Live, while it is on
|
|
531
|
+
self.drawing = pictures.Drawing() # the tracker's maps and icons, shipped with the page
|
|
532
|
+
self.picks: dict[str, Path] = {} # the files picked this session to replace the image's, by its path
|
|
533
|
+
self.slots = ahead.Ahead(kill=self._quiet) # the work ahead, each click's way to it (D1007)
|
|
534
|
+
if warm.intent((kept := _kept()).get("setup"), kept.get("tracker")):
|
|
535
|
+
threading.Thread(target=_logic_ahead, name="azurkit-logic", daemon=True).start()
|
|
536
|
+
threading.Thread(target=self.serve_forever, name="azurkit-page", daemon=True).start()
|
|
537
|
+
|
|
538
|
+
def quit(self) -> None:
|
|
539
|
+
"""Cancel a build or a save in flight, within :data:`QUIT_WAIT`
|
|
540
|
+
(AZ-823): the window's closing hook (its close button, and Cmd-Q,
|
|
541
|
+
after which no ``finally`` runs) and :meth:`close`. A build writes
|
|
542
|
+
nothing, so this never waits on one: its answer is refused (the
|
|
543
|
+
app's engine process, which ends here, ends it). A save
|
|
544
|
+
is waited on until it has removed its part file (D35), as its thread
|
|
545
|
+
is a daemon, which the interpreter's exit would kill mid-write; if
|
|
546
|
+
it hasn't by then (a stalled disk), its part file is removed here.
|
|
547
|
+
A build or save that takes ``busy`` after ``quitting`` is set
|
|
548
|
+
refuses; a save that took it before sees ``cancelled``, set after
|
|
549
|
+
``quitting``, before its part file is made and after each chunk."""
|
|
550
|
+
self.quitting = True
|
|
551
|
+
self.cancelled.set()
|
|
552
|
+
self.slots.close() # every job ahead stops, and a click on one is let go (D1012)
|
|
553
|
+
if (feed := self.feed) is not None:
|
|
554
|
+
feed.stop()
|
|
555
|
+
self.deadline = self.deadline or time.monotonic() + QUIT_WAIT
|
|
556
|
+
if saved := self.writing.acquire(timeout=max(0.0, self.deadline - time.monotonic())):
|
|
557
|
+
self.writing.release()
|
|
558
|
+
self.engine.close() # the app's engine process ends here, before a stalled save's part file goes
|
|
559
|
+
if not saved and (out := self.out) is not None:
|
|
560
|
+
_unpart(out)
|
|
561
|
+
|
|
562
|
+
def close(self) -> None:
|
|
563
|
+
self.quit()
|
|
564
|
+
self.shutdown()
|
|
565
|
+
self.server_close()
|
|
566
|
+
|
|
567
|
+
def cancel(self) -> dict:
|
|
568
|
+
"""``api/cancel``: a save stops at its next chunk; a build answers
|
|
569
|
+
Cancelled at once, and its work runs on as work ahead through a
|
|
570
|
+
seed's fill, for the next Build of these settings (:mod:`.ahead`),
|
|
571
|
+
then stops at its next record."""
|
|
572
|
+
self.cancelled.set()
|
|
573
|
+
self.slots.cancel("build")
|
|
574
|
+
return {}
|
|
575
|
+
|
|
576
|
+
def _quiet(self, job: ahead.Job) -> None:
|
|
577
|
+
"""``job``, stopped or cancelled, reached no record within
|
|
578
|
+
``ahead.GRACE``: the engine's process ends and another takes its
|
|
579
|
+
place, but for a view (the server's own) and beside a save, which
|
|
580
|
+
that would write again from nothing: that job ends at its next
|
|
581
|
+
record."""
|
|
582
|
+
with self.following:
|
|
583
|
+
saving = any(kind == "save" for kind, _ in self.calls.values())
|
|
584
|
+
if job.kind != "view" and not saving:
|
|
585
|
+
self.engine.cancel()
|
|
586
|
+
|
|
587
|
+
@contextlib.contextmanager
|
|
588
|
+
def follow(self, kind: str, first: dict = IDLE):
|
|
589
|
+
"""A call of ``kind`` in flight for ``api/progress``, from its record
|
|
590
|
+
``first``: ``report(record)`` gives its next, until it ends."""
|
|
591
|
+
ident = object()
|
|
592
|
+
with self.following:
|
|
593
|
+
self.calls[ident] = (kind, first)
|
|
594
|
+
|
|
595
|
+
def report(step: dict) -> None:
|
|
596
|
+
with self.following:
|
|
597
|
+
if ident in self.calls:
|
|
598
|
+
self.calls[ident] = (kind, step)
|
|
599
|
+
try:
|
|
600
|
+
yield report
|
|
601
|
+
finally:
|
|
602
|
+
with self.following:
|
|
603
|
+
self.calls.pop(ident, None)
|
|
604
|
+
|
|
605
|
+
def progress(self) -> dict:
|
|
606
|
+
"""``api/progress``: the newest call's record, and each kind's (``calls``)."""
|
|
607
|
+
with self.following:
|
|
608
|
+
calls = list(self.calls.values())
|
|
609
|
+
return {**(calls[-1][1] if calls else IDLE), "calls": dict(calls)}
|
|
610
|
+
|
|
611
|
+
def host_info(self) -> dict:
|
|
612
|
+
kept = _kept()
|
|
613
|
+
return {"engine": "app", "version": __version__, "commit": commit(), "catalog": sdk.catalog(), "iso": _start_image(kept),
|
|
614
|
+
"dialogs": self.window is not None, "site": SITE, "theme": _theme(kept),
|
|
615
|
+
"setup": kept.get("setup"), "tracker": kept.get("tracker"),
|
|
616
|
+
"content_source": kept.get("content_source"), "show_objects": kept.get("show_objects") is True,
|
|
617
|
+
"age": round(time.monotonic() - self.started, 3)}
|
|
618
|
+
|
|
619
|
+
def _recipe(self, body: dict) -> dict:
|
|
620
|
+
recipe = {"iso": str(_path(body.get("iso"), "image")), "packs": body.get("packs", {})}
|
|
621
|
+
return self._held(recipe | {key: body[key] for key in ("xbr", "randomizer", "character", "community", "files")
|
|
622
|
+
if body.get(key)})
|
|
623
|
+
|
|
624
|
+
def _held(self, recipe: object) -> object:
|
|
625
|
+
"""``recipe`` with the files its ``files`` names that the player
|
|
626
|
+
picked this session (``api/replace``), by the server's own record,
|
|
627
|
+
and never a ``picked`` of its own, with ``files`` or without: no
|
|
628
|
+
recipe, and no page, names a computer path it reads (SEC-11)."""
|
|
629
|
+
if not isinstance(recipe, dict):
|
|
630
|
+
return recipe
|
|
631
|
+
held = {k: v for k, v in recipe.items() if k != "picked"}
|
|
632
|
+
if not isinstance(recipe.get("files"), list):
|
|
633
|
+
return held
|
|
634
|
+
named = [e["path"] for e in recipe["files"] if isinstance(e, dict) and isinstance(e.get("path"), str)]
|
|
635
|
+
return held | {"picked": {path: str(self.picks[path]) for path in named if path in self.picks}}
|
|
636
|
+
|
|
637
|
+
def saves(self, body: dict) -> dict:
|
|
638
|
+
"""``api/saves`` (``sdk.saves``): the computer paths it reads through
|
|
639
|
+
``edits.files.source`` (SEC-11), the BIOS files read for this ask
|
|
640
|
+
alone and refused by size before they are read; a slot saved to
|
|
641
|
+
``out``, a new folder (:meth:`writable`), else under its id in the
|
|
642
|
+
Downloads folder; no zip: the site's."""
|
|
643
|
+
from azurkit.tools.saves import session
|
|
644
|
+
|
|
645
|
+
given = dict(body.get("given") or {})
|
|
646
|
+
if given.get("do") == "zip" or not {"flash", "mcpx"}.isdisjoint(given):
|
|
647
|
+
raise ValueError("saves: the app reads your files by path, and saves a folder")
|
|
648
|
+
for key in ("folder", "image"):
|
|
649
|
+
if key in given:
|
|
650
|
+
given[key] = str(_path(replaced.source(str(given[key])), "save"))
|
|
651
|
+
if given.get("do") == "sign":
|
|
652
|
+
given["iso"] = str(_path(given.get("iso"), "image"))
|
|
653
|
+
for key in ("flash", "mcpx"):
|
|
654
|
+
if not (where := given.pop(f"{key}_file", None)):
|
|
655
|
+
continue
|
|
656
|
+
file = _path(replaced.source(str(where)), key)
|
|
657
|
+
if file.stat().st_size > session.MAX_BIOS:
|
|
658
|
+
raise ValueError(f"saves: {file.name} is no BIOS file: past the {session.MAX_BIOS:,} B AzurKit reads as one")
|
|
659
|
+
given[key] = file.read_bytes()
|
|
660
|
+
if given.get("do") == "save":
|
|
661
|
+
name = str(given.get("id"))
|
|
662
|
+
given["out"] = str(self.writable(_path(given["out"], "slot's folder") if given.get("out")
|
|
663
|
+
else _downloads() / name, "saves"))
|
|
664
|
+
return sdk.saves(given)
|
|
665
|
+
|
|
666
|
+
def replace(self, body: dict) -> dict:
|
|
667
|
+
"""``api/replace``: ``{"path", "file"}``, a computer file the player
|
|
668
|
+
picked to stand in for the image's ``path``: its entry
|
|
669
|
+
(``sdk.file_entry``), and the file held for this session's builds. A
|
|
670
|
+
network share or device path is refused before anything is opened
|
|
671
|
+
(``edits.files.source``)."""
|
|
672
|
+
path, file = body.get("path"), replaced.source(str(body.get("file") or ""))
|
|
673
|
+
given = _path(file, "replacement")
|
|
674
|
+
sdk.file_entry({"path": path, "sha256": "0" * 64, "size": given.stat().st_size}) # refused before it is read
|
|
675
|
+
with self.follow("replace", record("check", f"Reading {given.name}")), open(given, "rb") as f:
|
|
676
|
+
sha = hashlib.file_digest(f, "sha256").hexdigest()
|
|
677
|
+
entry = sdk.file_entry({"path": path, "sha256": sha, "size": f.tell()})
|
|
678
|
+
self.picks[path] = given
|
|
679
|
+
return entry
|
|
680
|
+
|
|
681
|
+
def _image(self, recipe: dict) -> Callable[[ahead.Job], dict]:
|
|
682
|
+
"""A build of ``recipe`` in the engine, as a slot's job, its peak told to the slots (``ahead.room``)."""
|
|
683
|
+
def run(job: ahead.Job) -> dict:
|
|
684
|
+
built = self.engine.call("image", recipe, progress=job.progress, fore=job.fore)
|
|
685
|
+
self.slots.peaked(job.key, built["peak"])
|
|
686
|
+
return built
|
|
687
|
+
return run
|
|
688
|
+
|
|
689
|
+
def _planned(self, recipe: object) -> Callable[[ahead.Job], dict]:
|
|
690
|
+
"""A seed's plan (``{seed, share}``, as Track asks it) in the engine, as a slot's job; meanwhile, here, what
|
|
691
|
+
its first tracker view compiles (:meth:`_view_ahead`)."""
|
|
692
|
+
def run(job: ahead.Job) -> dict:
|
|
693
|
+
threading.Thread(target=self._view_ahead, args=(recipe, job), name="azurkit-view-ahead",
|
|
694
|
+
daemon=True).start()
|
|
695
|
+
return self.engine.call("plan", recipe, progress=job.progress, fore=job.fore)
|
|
696
|
+
return run
|
|
697
|
+
|
|
698
|
+
def _view_ahead(self, recipe: object, job: ahead.Job) -> None:
|
|
699
|
+
"""``randomizer.tracker_ahead`` of the plan ``job`` works out in the engine, on a thread of this process
|
|
700
|
+
(whose Logic the view reads) as low as the job runs (D1007), each compile waiting while a call other than
|
|
701
|
+
that plan's is in flight here or a build runs ahead (its answers pass this process's GIL, which a low thread
|
|
702
|
+
holds while the busy computer runs others: a Build then waits on it), until the job ends or stops (the view,
|
|
703
|
+
once the plan is in, compiles what is left)."""
|
|
704
|
+
low, over = ahead.Low(), lambda: job.done.is_set() or job.state == "stopped"
|
|
705
|
+
|
|
706
|
+
def stop() -> bool:
|
|
707
|
+
while not over() and (self._others("plan") or (built := self.slots.job("build")) is not None
|
|
708
|
+
and not built.done.is_set()):
|
|
709
|
+
time.sleep(0.05)
|
|
710
|
+
low(job.state != "taken")
|
|
711
|
+
return over()
|
|
712
|
+
low(job.state != "taken")
|
|
713
|
+
with contextlib.suppress(Exception): # a head start only: the view works it out
|
|
714
|
+
randomizer.tracker_ahead(recipe, stop)
|
|
715
|
+
|
|
716
|
+
def _others(self, kind: str) -> bool:
|
|
717
|
+
"""Whether a call of another kind than ``kind`` is in flight (:meth:`follow`)."""
|
|
718
|
+
with self.following:
|
|
719
|
+
return any(other != kind for other, _ in self.calls.values())
|
|
720
|
+
|
|
721
|
+
def speculate(self, body: dict) -> dict:
|
|
722
|
+
"""``api/speculate``: build the page's settled recipe ahead at its
|
|
723
|
+
change ``gen`` (D1007), so its Build takes that build; a seed's fill
|
|
724
|
+
is the plan's ahead, which the build waits on. No image yet, a save
|
|
725
|
+
or a dress in flight (the page offers again once it ends): nothing
|
|
726
|
+
ahead, and what was stops."""
|
|
727
|
+
gen = body.get("gen")
|
|
728
|
+
if not isinstance(gen, int) or self.quitting:
|
|
729
|
+
return {"ahead": False}
|
|
730
|
+
try:
|
|
731
|
+
recipe = self._recipe(body)
|
|
732
|
+
except ValueError:
|
|
733
|
+
recipe = None
|
|
734
|
+
with self.following:
|
|
735
|
+
dressing = any(kind in ("save", "preview", "match") for kind, _ in self.calls.values())
|
|
736
|
+
key = None if recipe is None or dressing else ahead.key(recipe)
|
|
737
|
+
return {"ahead": self.slots.offer("build", gen, key, self._image(recipe))}
|
|
738
|
+
|
|
739
|
+
def prefetch(self, body: dict) -> dict:
|
|
740
|
+
"""``api/prefetch``: plan the page's typed seed ahead at its change
|
|
741
|
+
``gen`` (``recipe``: ``{seed, share}``, as Track asks it; null:
|
|
742
|
+
nothing ahead), then that plan's first tracker view (``view``: the
|
|
743
|
+
tracker's ``spoiler`` and ``gems``, as its first ask sends them; none
|
|
744
|
+
while Live reads the tracker), so Track and its first view take them."""
|
|
745
|
+
gen, recipe, view = body.get("gen"), body.get("recipe"), body.get("view")
|
|
746
|
+
if not isinstance(gen, int) or self.quitting:
|
|
747
|
+
return {"ahead": False}
|
|
748
|
+
view = {k: view[k] for k in ("spoiler", "gems") if k in view} if isinstance(view, dict) else {}
|
|
749
|
+
|
|
750
|
+
def then(job: ahead.Job) -> None:
|
|
751
|
+
given = {"plan": job.value, "marks": {}, **view}
|
|
752
|
+
# a shaped plan's Spoiler's openers are the engine's work ahead already, which only a click may ask for
|
|
753
|
+
if self.feed is None and not (view.get("spoiler") and job.value["settings"].get("seed_difficulty")):
|
|
754
|
+
self.slots.offer("view", job.gen, ahead.key(given), lambda j: sdk.tracker(given, progress=j.progress))
|
|
755
|
+
key = ahead.key(recipe) if isinstance(recipe, dict) and recipe.get("seed") else None
|
|
756
|
+
return {"ahead": self.slots.offer("plan", gen, key, self._planned(recipe), then=then)}
|
|
757
|
+
|
|
758
|
+
def build(self, body: dict) -> dict:
|
|
759
|
+
recipe = self._recipe(body)
|
|
760
|
+
with self.busy, self.follow("build") as report:
|
|
761
|
+
if self.quitting:
|
|
762
|
+
raise ValueError(QUITTING)
|
|
763
|
+
try: # the job of this recipe, if there is one (its records from its last)
|
|
764
|
+
built = self.slots.take("build", ahead.key(recipe), self._image(recipe), report=report)
|
|
765
|
+
except Cancelled:
|
|
766
|
+
raise ValueError(QUITTING if self.quitting else "Cancelled: nothing was built") from None
|
|
767
|
+
except engines.Gone as exc:
|
|
768
|
+
raise ValueError(QUITTING if self.quitting else str(exc)) from None
|
|
769
|
+
if self.quitting: # closed while it built (AZ-823)
|
|
770
|
+
raise ValueError(QUITTING)
|
|
771
|
+
report = built["report"]
|
|
772
|
+
self.seeded, self.spoiler, self.source = report.get("plan"), None, Path(recipe["iso"])
|
|
773
|
+
_remember(iso=recipe["iso"])
|
|
774
|
+
from platformdirs import user_downloads_path
|
|
775
|
+
iso = Path(recipe["iso"])
|
|
776
|
+
stem = iso.stem if iso.suffix.lower() in (".iso", ".xiso") else iso.name # the site's download's name
|
|
777
|
+
recipe = {k: v for k, v in recipe.items() if k != "picked"} # the server holds it (_held)
|
|
778
|
+
return {"report": report, "size": built["size"], "recipe": recipe, "build": built["build"],
|
|
779
|
+
"out": str(user_downloads_path() / f"{stem} (AzurKit).iso")}
|
|
780
|
+
|
|
781
|
+
def writable(self, out: Path, kind: str) -> Path:
|
|
782
|
+
"""``out``, where a save of ``kind`` (one of :data:`SAVES`: ``out``,
|
|
783
|
+
the image) may write (SEC-16): never over an existing file but the
|
|
784
|
+
one the save dialog chose for that kind (it asked first), nor the
|
|
785
|
+
image the last build read or a vanilla image (D36)."""
|
|
786
|
+
if out.exists() and self.chosen != (kind, out):
|
|
787
|
+
raise ValueError(f"{out} exists: choose another name, or delete it first")
|
|
788
|
+
if out.exists() and any(image and image.exists() and out.samefile(image)
|
|
789
|
+
for image in (self.source, *map(gate.find, gate.RELEASES))):
|
|
790
|
+
raise ValueError(f"{out}: it is a vanilla image, which AzurKit never writes over")
|
|
791
|
+
return out
|
|
792
|
+
|
|
793
|
+
def save(self, body: dict) -> dict:
|
|
794
|
+
recipe, out = self._held(body.get("recipe")), self.writable(_path(body.get("out"), "saved image"), "out")
|
|
795
|
+
|
|
796
|
+
with self.busy, self.writing, self.follow("save", record("write")) as report:
|
|
797
|
+
def progress(step: dict) -> None:
|
|
798
|
+
report(step)
|
|
799
|
+
if self.cancelled.is_set():
|
|
800
|
+
raise Cancelled
|
|
801
|
+
self.cancelled.clear() # before the check: a quit after it still cancels this save
|
|
802
|
+
if self.quitting:
|
|
803
|
+
raise ValueError(QUITTING)
|
|
804
|
+
self.out = out
|
|
805
|
+
try:
|
|
806
|
+
return {"saved": self.engine.call("save", recipe, str(out), body.get("build"), progress=progress)}
|
|
807
|
+
except Cancelled:
|
|
808
|
+
raise ValueError("Cancelled: nothing was written") from None
|
|
809
|
+
except engines.Gone as exc: # its process ended mid-write: its part file goes here
|
|
810
|
+
_unpart(out)
|
|
811
|
+
raise ValueError("Cancelled: nothing was written" if self.cancelled.is_set()
|
|
812
|
+
else f"{exc}: nothing was written") from None
|
|
813
|
+
|
|
814
|
+
def randomizer(self, body: dict) -> dict:
|
|
815
|
+
"""``api/randomizer``: what the Randomizer tab shows; the engine
|
|
816
|
+
builds the randomizer's logic meanwhile, for a seed's build."""
|
|
817
|
+
self.engine.warm("randomizer")
|
|
818
|
+
with self.follow("randomizer", record("plan", "Reading the randomizer's logic")) as report:
|
|
819
|
+
return sdk.randomizer_settings(body.get("given"), progress=report)
|
|
820
|
+
|
|
821
|
+
def restore(self, body: dict) -> dict:
|
|
822
|
+
"""``api/restore``: what of the setup still applies; with Randomize
|
|
823
|
+
on, the engine builds the randomizer's logic meanwhile."""
|
|
824
|
+
setup = body.get("setup")
|
|
825
|
+
if isinstance(setup, dict) and isinstance(setup.get("randomizer"), dict) and setup["randomizer"].get("on"):
|
|
826
|
+
self.engine.warm("randomizer")
|
|
827
|
+
with self.follow("restore", record("read", "Restoring your setup")):
|
|
828
|
+
return sdk.restore(setup)
|
|
829
|
+
|
|
830
|
+
def vanilla(self, body: dict) -> dict:
|
|
831
|
+
"""``api/vanilla``: the image's own slider values, or an AzurKit
|
|
832
|
+
build's recipe; the engine reads a vanilla image meanwhile, for its
|
|
833
|
+
pictures."""
|
|
834
|
+
with self.follow("vanilla", record("check")):
|
|
835
|
+
read = _vanilla(body)
|
|
836
|
+
if "opened" not in read:
|
|
837
|
+
self.engine.warm("image", str(_path(body.get("iso"), "image")))
|
|
838
|
+
return read
|
|
839
|
+
|
|
840
|
+
def _openers(self, report: Callable[[dict], object]) -> Callable[[dict], list]:
|
|
841
|
+
"""A click's tracker's ``openers``: a shaped plan's Spoiler's, from the engine, where its work ahead keeps
|
|
842
|
+
them, and never again in this process (5 s, with no record to end them at); ``report`` hears its count."""
|
|
843
|
+
return lambda plan: self.engine.call("openers", plan, progress=report)
|
|
844
|
+
|
|
845
|
+
def track(self, body: dict) -> dict:
|
|
846
|
+
"""``api/tracker``: what the tracker shows (``sdk.tracker``), one ask
|
|
847
|
+
at a time; with Live on for its plan, Live's events since the last
|
|
848
|
+
ask merged into its marks, the reach holding the element levels the
|
|
849
|
+
game holds, and Live's status (``live``). As Modkit's: the marks keep
|
|
850
|
+
the saved slot the game last showed, the next session opens on their
|
|
851
|
+
journal (a lost link, Live off and on), and Reset reads the game
|
|
852
|
+
again."""
|
|
853
|
+
given = body.get("given")
|
|
854
|
+
with self.tracking, self.follow("tracker", record("plan", "Reading the seed's logic")) as report:
|
|
855
|
+
feed = self.feed
|
|
856
|
+
plan = given.get("plan") if isinstance(given, dict) else None
|
|
857
|
+
if isinstance(given, dict) and plan is None: # the maps browsed with no plan (Step 4.4u3): the work
|
|
858
|
+
return sdk.tracker(given, progress=report) # ahead's view of a typed seed goes on
|
|
859
|
+
if feed is None or not isinstance(plan, dict) or plan.get("sha256") != feed.sha256:
|
|
860
|
+
try: # a plan's first view, read ahead (api/prefetch) if it was
|
|
861
|
+
return self.slots.take("view", ahead.key(given), lambda job: sdk.tracker(
|
|
862
|
+
given, progress=job.progress, openers=self._openers(job.progress)), report=report)
|
|
863
|
+
except Cancelled:
|
|
864
|
+
raise ValueError(QUITTING) from None
|
|
865
|
+
if (ahead_view := self.slots.job("view")) is not None: # both read the plan's one tracker
|
|
866
|
+
ahead_view.done.wait()
|
|
867
|
+
events, seen = feed.take()
|
|
868
|
+
slot = seen.pop("slot")
|
|
869
|
+
out = tracking.view({**given, "kit": seen.pop("kit"), "held": seen.pop("held")}, events, report,
|
|
870
|
+
self._openers(report))
|
|
871
|
+
t = tracking.tracker_of(plan)
|
|
872
|
+
if slot is not None and slot != t.slot:
|
|
873
|
+
t.slot = slot
|
|
874
|
+
out["marks"] = t.state()
|
|
875
|
+
if given.get("reset"):
|
|
876
|
+
feed.resync()
|
|
877
|
+
feed.keep(t.journal())
|
|
878
|
+
return out | {"live": seen}
|
|
879
|
+
|
|
880
|
+
def live(self, body: dict) -> dict:
|
|
881
|
+
"""``api/live``: Live on for ``plan`` (its marks the journal a
|
|
882
|
+
first settle checks the game against), reading the first of
|
|
883
|
+
``endpoints`` that answers (empty: the defaults), or off."""
|
|
884
|
+
with self.tracking:
|
|
885
|
+
if self.feed is not None:
|
|
886
|
+
self.feed.stop()
|
|
887
|
+
self.feed = None
|
|
888
|
+
if body.get("on"):
|
|
889
|
+
if self.quitting:
|
|
890
|
+
raise ValueError(QUITTING)
|
|
891
|
+
endpoints = body.get("endpoints") or ""
|
|
892
|
+
if not isinstance(endpoints, str):
|
|
893
|
+
raise ValueError("live: endpoints is a list's text, as Endpoints… takes it")
|
|
894
|
+
try:
|
|
895
|
+
link.parse(endpoints)
|
|
896
|
+
except ValueError as exc:
|
|
897
|
+
raise ValueError(f"Endpoints: {exc}") from None
|
|
898
|
+
self.feed = live.Feed(body.get("plan"), body.get("marks"), endpoints, bool(body.get("gems"))).start()
|
|
899
|
+
return {}
|
|
900
|
+
|
|
901
|
+
def dressed(self, job: str, body: dict) -> dict:
|
|
902
|
+
"""``api/preview`` and ``api/match``: the engine's, followed by its line (a variant's dress takes minutes); the
|
|
903
|
+
build ahead stops at its next record, as no dress runs beside it, and the page offers it again once this ends."""
|
|
904
|
+
recipe = body.get("recipe")
|
|
905
|
+
character = recipe.get("character") if isinstance(recipe, dict) else None
|
|
906
|
+
species = character.get("species") if isinstance(character, dict) else None
|
|
907
|
+
what = "draw it" if job == "preview" else "match garret4"
|
|
908
|
+
first = record("dress", f"Dressing {species} to {what}" if isinstance(species, str) else None)
|
|
909
|
+
with self.follow(job, first) as report, self.slots.turn(): # the swap's items counted on its line
|
|
910
|
+
self.slots.offer("build", self.slots.gen, None, None) # a dress beside its GiBs passes 6 (D1017): it stops
|
|
911
|
+
return self.engine.call(job, recipe, progress=lambda step: report(step | {"label": first["label"]}))
|
|
912
|
+
|
|
913
|
+
def plan(self, body: dict) -> dict:
|
|
914
|
+
"""``api/plan``: a seed's plan to track (the engine's; planned
|
|
915
|
+
ahead, ``api/prefetch``, if its seed was)."""
|
|
916
|
+
recipe = body.get("recipe")
|
|
917
|
+
with self.follow("plan", record("plan")) as report:
|
|
918
|
+
try:
|
|
919
|
+
return {"plan": self.slots.take("plan", ahead.key(recipe), self._planned(recipe), report=report)}
|
|
920
|
+
except Cancelled:
|
|
921
|
+
raise ValueError(QUITTING) from None
|
|
922
|
+
|
|
923
|
+
def install(self, body: dict) -> dict:
|
|
924
|
+
"""``api/install``: ``sdk.content_install``; then work ahead of an
|
|
925
|
+
older change than the page's ``gen`` stops (it read the content
|
|
926
|
+
before)."""
|
|
927
|
+
try:
|
|
928
|
+
return sdk.content_install({"id": body.get("id"), "source": body.get("source")})
|
|
929
|
+
finally:
|
|
930
|
+
if isinstance(gen := body.get("gen"), int):
|
|
931
|
+
self.slots.change(gen)
|
|
932
|
+
|
|
933
|
+
def art(self, body: dict) -> dict:
|
|
934
|
+
"""``api/art``: the tracker's shipped pictures
|
|
935
|
+
(:meth:`.art.Drawing.status`)."""
|
|
936
|
+
return self.drawing.status()
|
|
937
|
+
|
|
938
|
+
def spoiled(self, body: dict) -> dict:
|
|
939
|
+
"""``api/spoiler``: the last randomized build's spoiler (``{"text"}``,
|
|
940
|
+
made by the engine from the plan it kept, once), or with ``out`` that
|
|
941
|
+
spoiler written there, never over an existing file but the one the
|
|
942
|
+
save dialog chose, nor a vanilla image or the image it was built from
|
|
943
|
+
(D36); whole or not at all."""
|
|
944
|
+
if (plan := self.seeded) is None:
|
|
945
|
+
raise ValueError("No spoiler: build with Randomize ticked first")
|
|
946
|
+
out = self.writable(_path(body["out"], "spoiler"), "spoiler") if body.get("out") else None
|
|
947
|
+
if (text := self.spoiler) is None:
|
|
948
|
+
with self.follow("spoiler", record("spoiler")) as report:
|
|
949
|
+
text = self.engine.call("spoiler", plan, progress=report)["text"]
|
|
950
|
+
if self.seeded is plan: # no build since
|
|
951
|
+
self.spoiler = text
|
|
952
|
+
if out is None:
|
|
953
|
+
return {"text": text}
|
|
954
|
+
data = text.encode("utf-8")
|
|
955
|
+
part = out.with_name(f"{out.name}.part")
|
|
956
|
+
part.write_bytes(data)
|
|
957
|
+
part.replace(out)
|
|
958
|
+
return {"spoiler": {"out": str(out), "size": len(data)}}
|
|
959
|
+
|
|
960
|
+
def settings_file(self, body: dict) -> dict:
|
|
961
|
+
"""``api/settings_file``: the Randomizer tab's ``settings`` (a
|
|
962
|
+
settings table, checked as a build checks it) written as a settings
|
|
963
|
+
file (Modkit's ``--save-settings`` file) to ``out``, never over an
|
|
964
|
+
existing file but the one the save dialog chose; whole or not at all."""
|
|
965
|
+
from platformdirs import user_downloads_path
|
|
966
|
+
out = self.writable(_path(body["out"], "settings file") if body.get("out")
|
|
967
|
+
else user_downloads_path() / "settings.toml", "settings")
|
|
968
|
+
data = randomizer.settings_file(body.get("settings")).encode("utf-8")
|
|
969
|
+
part = out.with_name(f"{out.name}.part")
|
|
970
|
+
part.write_bytes(data)
|
|
971
|
+
part.replace(out)
|
|
972
|
+
return {"saved": {"out": str(out), "size": len(data)}}
|
|
973
|
+
|
|
974
|
+
def info(self, body: dict) -> dict:
|
|
975
|
+
"""``api/info``: what the image is (``sdk.image_info``)."""
|
|
976
|
+
with self.follow("info", record("check", "Reading your image")):
|
|
977
|
+
return sdk.image_info(str(_path(body.get("iso"), "image")))
|
|
978
|
+
|
|
979
|
+
def verify_all(self, body: dict) -> dict:
|
|
980
|
+
"""``api/verify_all``: every file of the image checked, its bytes counted (``sdk.verify_all``)."""
|
|
981
|
+
with self.follow("verify_all", record("check", sdk.CHECK_ALL)) as report:
|
|
982
|
+
return sdk.verify_all(str(_path(body.get("iso"), "image")), progress=report)
|
|
983
|
+
|
|
984
|
+
def extract(self, body: dict) -> dict:
|
|
985
|
+
"""``api/extract``: the image's file ``path`` written to ``out``,
|
|
986
|
+
else under its name in the Downloads folder (``sdk.extract``)."""
|
|
987
|
+
iso, member = _path(body.get("iso"), "image"), body.get("path")
|
|
988
|
+
if not (isinstance(member, str) and member.strip("/")):
|
|
989
|
+
raise ValueError("extract: name the image's file")
|
|
990
|
+
out = self.writable(_path(body["out"], "extracted file") if body.get("out")
|
|
991
|
+
else _downloads() / member.rstrip("/").rpartition("/")[2], "extract")
|
|
992
|
+
with self.follow("extract", record("write", f"Extracting {member}")) as report:
|
|
993
|
+
return sdk.extract(str(iso), member, str(out), progress=report)
|
|
994
|
+
|
|
995
|
+
def mods_file(self, body: dict) -> dict:
|
|
996
|
+
"""``api/mods_file``: ``{"text"}`` read (``sdk.mods_file``'s preset);
|
|
997
|
+
with ``out``, its ``recipe`` or ``preset`` written there as a mods
|
|
998
|
+
file, else in the Downloads folder (:data:`MODS`)."""
|
|
999
|
+
given = {key: body[key] for key in ("text", "recipe", "preset") if key in body}
|
|
1000
|
+
if "out" not in body:
|
|
1001
|
+
return sdk.mods_file(given)
|
|
1002
|
+
text = sdk.mods_file(given)["text"]
|
|
1003
|
+
out = self.writable(_path(body["out"], "mods file") if body["out"] else _downloads() / MODS, "mods")
|
|
1004
|
+
return _written(out, text.encode("utf-8"))
|
|
1005
|
+
|
|
1006
|
+
def report(self, body: dict) -> dict:
|
|
1007
|
+
"""``api/report``: a text the Tools tab shows (the last build's
|
|
1008
|
+
report, a plan's spoiler) written to ``out``, else under ``name`` in
|
|
1009
|
+
the Downloads folder; whole or not at all."""
|
|
1010
|
+
text, name = body.get("text"), body.get("name") or "AzurKit build report.txt"
|
|
1011
|
+
if not isinstance(text, str) or len(text) > MAX_BODY:
|
|
1012
|
+
raise ValueError("report: text is the report's text")
|
|
1013
|
+
if not (isinstance(name, str) and name not in (".", "..") and "/" not in name and "\\" not in name):
|
|
1014
|
+
raise ValueError("report: name is a file's name")
|
|
1015
|
+
out = self.writable(_path(body["out"], "report") if body.get("out") else _downloads() / name, "report")
|
|
1016
|
+
return _written(out, text.encode("utf-8"))
|
|
1017
|
+
|
|
1018
|
+
def storage(self, body: dict) -> dict:
|
|
1019
|
+
"""``api/storage`` (:func:`_storage`), which the Tools tab asks as it
|
|
1020
|
+
opens: the engine builds the randomizer's logic meanwhile, for a plan
|
|
1021
|
+
file's spoiler."""
|
|
1022
|
+
if not body.get("clear"):
|
|
1023
|
+
self.engine.warm("randomizer")
|
|
1024
|
+
return _storage(body)
|
|
1025
|
+
|
|
1026
|
+
def plan_spoiler(self, body: dict) -> dict:
|
|
1027
|
+
"""``api/plan_spoiler``: a plan file's spoiler (the engine's), its
|
|
1028
|
+
bar counting the randomizer's logic's checks while the engine still
|
|
1029
|
+
reads it (the tab's opening started it), then the spoiler's stage."""
|
|
1030
|
+
with self.follow("plan_spoiler", record("plan", "Reading the randomizer's logic")) as report:
|
|
1031
|
+
return self.engine.call("plan_spoiler", body.get("plan"), progress=report)
|
|
1032
|
+
|
|
1033
|
+
def choose(self, body: dict) -> dict:
|
|
1034
|
+
"""The window's file dialog: an image to read (``iso``, an AzurKit
|
|
1035
|
+
``build``), or where to save one (``out``) or another of
|
|
1036
|
+
:data:`SAVES` (the spoiler, the settings, an extracted file, a mods
|
|
1037
|
+
file, a report)."""
|
|
1038
|
+
import webview
|
|
1039
|
+
self.chosen = None # each dialog's answer is its own: a cancelled one vouches for nothing
|
|
1040
|
+
given = Path(body["path"]).expanduser() if isinstance(body.get("path"), str) and body["path"] else None
|
|
1041
|
+
folder = str(given.parent) if given else ""
|
|
1042
|
+
if body.get("kind") in SAVES:
|
|
1043
|
+
picked = self.window.create_file_dialog(webview.FileDialog.SAVE, directory=folder,
|
|
1044
|
+
save_filename=given.name if given else "")
|
|
1045
|
+
else:
|
|
1046
|
+
picked = self.window.create_file_dialog(
|
|
1047
|
+
webview.FileDialog.FOLDER if body.get("kind") == "folder" else webview.FileDialog.OPEN, directory=folder,
|
|
1048
|
+
**({} if body.get("kind") in ("replacement", "folder") else {"file_types": IMAGES}))
|
|
1049
|
+
if isinstance(picked, (list, tuple)):
|
|
1050
|
+
picked = picked[0] if picked else None
|
|
1051
|
+
if picked and body.get("kind") in SAVES:
|
|
1052
|
+
self.chosen = (body["kind"], Path(str(picked)))
|
|
1053
|
+
return {"path": str(picked) if picked else None}
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
class _Handler(http.server.BaseHTTPRequestHandler):
|
|
1057
|
+
server: Server
|
|
1058
|
+
|
|
1059
|
+
def _url(self) -> urllib.parse.SplitResult | None:
|
|
1060
|
+
"""The request's URL when it carries this launch's token and names
|
|
1061
|
+
this server as its host, else ``None``."""
|
|
1062
|
+
url = urllib.parse.urlsplit(self.path)
|
|
1063
|
+
given = urllib.parse.parse_qs(url.query).get("token", [""])[0]
|
|
1064
|
+
cookie = next((v for k, _, v in (c.strip().partition("=") for c in self.headers.get("Cookie", "").split(";"))
|
|
1065
|
+
if k == self.server.cookie), "")
|
|
1066
|
+
token = self.server.token.encode()
|
|
1067
|
+
if self.headers.get("Host") != self.server.host or not (
|
|
1068
|
+
hmac.compare_digest(given.encode(), token) or hmac.compare_digest(cookie.encode(), token)):
|
|
1069
|
+
return None
|
|
1070
|
+
return url
|
|
1071
|
+
|
|
1072
|
+
def _send(self, data: bytes, kind: str, cookie: bool = False) -> None:
|
|
1073
|
+
self.send_response(200)
|
|
1074
|
+
self.send_header("Content-Type", kind)
|
|
1075
|
+
self.send_header("Content-Length", str(len(data)))
|
|
1076
|
+
self.send_header("Cache-Control", "no-store")
|
|
1077
|
+
if cookie:
|
|
1078
|
+
self.send_header("Set-Cookie",
|
|
1079
|
+
f"{self.server.cookie}={self.server.token}; Path=/; HttpOnly; SameSite=Strict")
|
|
1080
|
+
self.end_headers()
|
|
1081
|
+
self.wfile.write(data)
|
|
1082
|
+
|
|
1083
|
+
def _json(self, value: dict) -> None:
|
|
1084
|
+
self._send(json.dumps(value).encode(), "application/json")
|
|
1085
|
+
|
|
1086
|
+
def do_GET(self) -> None:
|
|
1087
|
+
if (url := self._url()) is None:
|
|
1088
|
+
return self.send_error(403)
|
|
1089
|
+
if url.path == "/host.json":
|
|
1090
|
+
return self._json(self.server.host_info())
|
|
1091
|
+
if url.path == "/theme.js":
|
|
1092
|
+
return self._send(_theme_js(), "text/javascript")
|
|
1093
|
+
if url.path == "/api/progress":
|
|
1094
|
+
return self._json(self.server.progress())
|
|
1095
|
+
if url.path.startswith("/art/") and url.path != "/art/icons.css": # the tracker's pictures (that's a page file)
|
|
1096
|
+
version, _, rel = urllib.parse.unquote(url.path).removeprefix("/art/").partition("/")
|
|
1097
|
+
root = self.server.drawing.where
|
|
1098
|
+
file = (root / rel).resolve()
|
|
1099
|
+
ok = version == tracking.ART_VERSION and file.suffix in (".png", ".json") and file.is_relative_to(
|
|
1100
|
+
root.resolve()) and file.is_file()
|
|
1101
|
+
return self._send(file.read_bytes(), content_type(file.name)) if ok else self.send_error(404)
|
|
1102
|
+
try:
|
|
1103
|
+
file = (self.server.page / (urllib.parse.unquote(url.path).lstrip("/") or "index.html")).resolve()
|
|
1104
|
+
data = file.read_bytes() if file.is_relative_to(self.server.page) and file.is_file() else None
|
|
1105
|
+
except (OSError, ValueError): # a NUL in the path, say
|
|
1106
|
+
data = None
|
|
1107
|
+
if data is None:
|
|
1108
|
+
return self.send_error(404)
|
|
1109
|
+
self._send(data, content_type(file.name),
|
|
1110
|
+
cookie="token" in urllib.parse.parse_qs(url.query))
|
|
1111
|
+
|
|
1112
|
+
def do_POST(self) -> None:
|
|
1113
|
+
if (url := self._url()) is None or self.headers.get("Origin", f"http://{self.server.host}") != (
|
|
1114
|
+
f"http://{self.server.host}") or self.headers.get_content_type() != "application/json":
|
|
1115
|
+
return self.send_error(403)
|
|
1116
|
+
call = {"/api/build": self.server.build, "/api/save": self.server.save, "/api/vanilla": self.server.vanilla,
|
|
1117
|
+
"/api/preset": lambda body: {"fragment": sdk.preset_encode(body.get("recipe"), body.get("report"))},
|
|
1118
|
+
"/api/decode": lambda body: {"preset": sdk.preset_decode(body.get("text"))},
|
|
1119
|
+
"/api/theme": _set_theme, "/api/setup": _set_setup,
|
|
1120
|
+
"/api/restore": self.server.restore,
|
|
1121
|
+
"/api/randomizer": self.server.randomizer,
|
|
1122
|
+
"/api/tracker": self.server.track,
|
|
1123
|
+
"/api/plan": self.server.plan,
|
|
1124
|
+
"/api/tracked": _set_tracked, "/api/live": self.server.live, "/api/art": self.server.art,
|
|
1125
|
+
"/api/settings_file": self.server.settings_file,
|
|
1126
|
+
"/api/pickers": lambda body: sdk.pickers(body.get("given")),
|
|
1127
|
+
"/api/preview": lambda body: self.server.dressed("preview", body),
|
|
1128
|
+
"/api/match": lambda body: self.server.dressed("match", body),
|
|
1129
|
+
"/api/content": lambda body: sdk.content({"source": body.get("source")}),
|
|
1130
|
+
"/api/community": lambda body: sdk.community(), "/api/replace": self.server.replace, "/api/saves": self.server.saves,
|
|
1131
|
+
"/api/install": self.server.install,
|
|
1132
|
+
"/api/speculate": self.server.speculate, "/api/prefetch": self.server.prefetch,
|
|
1133
|
+
"/api/content_source": _set_source, "/api/show_objects": _set_objects,
|
|
1134
|
+
"/api/spoiler": self.server.spoiled,
|
|
1135
|
+
"/api/data": lambda body: sdk.data(body.get("given")),
|
|
1136
|
+
"/api/data_edit": lambda body: sdk.data_edit(body.get("given")),
|
|
1137
|
+
"/api/info": self.server.info, "/api/verify_all": self.server.verify_all,
|
|
1138
|
+
"/api/files": lambda body: sdk.files(str(_path(body.get("iso"), "image"))),
|
|
1139
|
+
"/api/extract": self.server.extract,
|
|
1140
|
+
"/api/open": lambda body: {"preset": sdk.open_build(str(_path(body.get("iso"), "build")), whole=False)},
|
|
1141
|
+
"/api/mods_file": self.server.mods_file, "/api/report": self.server.report,
|
|
1142
|
+
"/api/plan_spoiler": self.server.plan_spoiler, "/api/storage": self.server.storage,
|
|
1143
|
+
"/api/cancel": lambda body: self.server.cancel()}.get(url.path)
|
|
1144
|
+
if url.path == "/api/choose" and self.server.window is not None:
|
|
1145
|
+
call = self.server.choose
|
|
1146
|
+
size = int(self.headers.get("Content-Length") or 0)
|
|
1147
|
+
if call is None or not 0 < size <= MAX_BODY:
|
|
1148
|
+
return self.send_error(404 if call is None else 413)
|
|
1149
|
+
try:
|
|
1150
|
+
body = json.loads(self.rfile.read(size))
|
|
1151
|
+
except ValueError:
|
|
1152
|
+
return self.send_error(400)
|
|
1153
|
+
if not isinstance(body, dict):
|
|
1154
|
+
return self.send_error(400)
|
|
1155
|
+
with self._kept() as sent:
|
|
1156
|
+
try:
|
|
1157
|
+
reply = call(body)
|
|
1158
|
+
except (OSError, ValueError, engines.Gone) as exc: # the sdk's refusals name what and why, never game bytes
|
|
1159
|
+
reply = {"refused": str(exc)}
|
|
1160
|
+
if sent:
|
|
1161
|
+
self.wfile.write(json.dumps(reply).encode())
|
|
1162
|
+
else:
|
|
1163
|
+
self._json(reply)
|
|
1164
|
+
|
|
1165
|
+
@contextlib.contextmanager
|
|
1166
|
+
def _kept(self):
|
|
1167
|
+
"""While a call runs, its connection never quiet (:data:`KEEP`): past ``KEEP`` s its answer's headers go (200,
|
|
1168
|
+
JSON, no length: the answer ends at the close) and a space each ``KEEP`` s after them. Yields a list that holds
|
|
1169
|
+
``True`` once they went."""
|
|
1170
|
+
sent, done, lock = [], threading.Event(), threading.Lock()
|
|
1171
|
+
|
|
1172
|
+
def keep() -> None:
|
|
1173
|
+
while not done.wait(KEEP):
|
|
1174
|
+
with lock:
|
|
1175
|
+
if done.is_set():
|
|
1176
|
+
return
|
|
1177
|
+
try:
|
|
1178
|
+
if not sent:
|
|
1179
|
+
self.send_response(200)
|
|
1180
|
+
self.send_header("Content-Type", "application/json")
|
|
1181
|
+
self.send_header("Cache-Control", "no-store")
|
|
1182
|
+
self.end_headers()
|
|
1183
|
+
sent.append(True)
|
|
1184
|
+
self.wfile.write(b" ")
|
|
1185
|
+
except OSError: # the page went: the answer's own write says so
|
|
1186
|
+
return
|
|
1187
|
+
keeper = threading.Thread(target=keep, name="azurkit-keep", daemon=True)
|
|
1188
|
+
keeper.start()
|
|
1189
|
+
try:
|
|
1190
|
+
yield sent
|
|
1191
|
+
finally:
|
|
1192
|
+
with lock:
|
|
1193
|
+
done.set()
|
|
1194
|
+
keeper.join()
|
|
1195
|
+
|
|
1196
|
+
def log_message(self, *args) -> None: # the token is in the first request's line
|
|
1197
|
+
pass
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _vanilla(body: dict) -> dict:
|
|
1201
|
+
"""``api/vanilla``: the image's own slider values, or an AzurKit build's recipe."""
|
|
1202
|
+
iso = str(_path(body.get("iso"), "image"))
|
|
1203
|
+
try:
|
|
1204
|
+
return sdk.vanilla(iso)
|
|
1205
|
+
except gate.NotVanillaError as exc:
|
|
1206
|
+
try:
|
|
1207
|
+
return {"opened": sdk.open_build(iso, whole=False)}
|
|
1208
|
+
except (OSError, ValueError):
|
|
1209
|
+
raise exc from None
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
def _unpart(out: Path) -> None:
|
|
1213
|
+
"""Remove a save's part files beside ``out`` (``iso.pack``'s), its writer ended (D35)."""
|
|
1214
|
+
for part in out.parent.glob(f"{glob.escape(out.name)}.*.part"):
|
|
1215
|
+
with contextlib.suppress(OSError): # a disk that won't: nothing more to do here
|
|
1216
|
+
part.unlink(missing_ok=True)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def serve(page: Path = PAGE, engine: engines.Here | engines.Spawned | None = None) -> Server:
|
|
1220
|
+
"""Serve ``page`` (a folder holding ``index.html``) on 127.0.0.1 with a
|
|
1221
|
+
fresh token, on a thread, its calls run by ``engine`` (on the request's
|
|
1222
|
+
thread: :class:`.engine.Here`); :meth:`Server.close` stops both."""
|
|
1223
|
+
if not (page / "index.html").is_file():
|
|
1224
|
+
raise FileNotFoundError(f"{page}: no index.html (the page)")
|
|
1225
|
+
return Server(page, engine)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _window(server: Server) -> bool:
|
|
1229
|
+
"""Show the page in a pywebview window until it closes; ``False`` when
|
|
1230
|
+
there is no webview here."""
|
|
1231
|
+
try:
|
|
1232
|
+
import webview
|
|
1233
|
+
from webview.errors import WebViewException
|
|
1234
|
+
except ImportError:
|
|
1235
|
+
return False
|
|
1236
|
+
try:
|
|
1237
|
+
server.window = webview.create_window(url=server.url, **WINDOW, background_color=GROUNDS[_theme()])
|
|
1238
|
+
server.window.events.closing += server.quit
|
|
1239
|
+
webview.start(private_mode=True)
|
|
1240
|
+
except WebViewException:
|
|
1241
|
+
server.window = None
|
|
1242
|
+
return False
|
|
1243
|
+
return True
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _input_kept_from_children() -> None:
|
|
1247
|
+
"""On Windows, keep this process's input from the processes it starts: each takes its parent's standard handles,
|
|
1248
|
+
and a read blocked on a pipe holds that pipe's lock, so a child's Python stops at its first look at its own stdin
|
|
1249
|
+
until the read ends. ``serve``'s read would hold the engine there for good: it never answers, and it dies at its
|
|
1250
|
+
start when the app is killed. Its standard input becomes NUL; ``sys.stdin`` still reads the pipe."""
|
|
1251
|
+
if os.name == "nt":
|
|
1252
|
+
import ctypes
|
|
1253
|
+
import msvcrt
|
|
1254
|
+
set_std_handle = ctypes.WinDLL("kernel32").SetStdHandle
|
|
1255
|
+
set_std_handle.argtypes = (ctypes.c_ulong, ctypes.c_void_p)
|
|
1256
|
+
set_std_handle(-10 & 0xFFFFFFFF, msvcrt.get_osfhandle(os.open(os.devnull, os.O_RDONLY))) # STD_INPUT_HANDLE
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1260
|
+
"""Open the page: in a window, else in the default browser (served until
|
|
1261
|
+
Ctrl-C); exit 1 at once when :data:`NO_WINDOW` is set. ``["serve"]``
|
|
1262
|
+
opens neither: it prints the page's address and serves until its input
|
|
1263
|
+
ends (Ctrl-D) or Ctrl-C."""
|
|
1264
|
+
argv = list(argv or [])
|
|
1265
|
+
if argv not in ([], ["serve"]):
|
|
1266
|
+
print("usage: AzurKit [serve]", file=sys.stderr)
|
|
1267
|
+
return 2
|
|
1268
|
+
if not argv and os.environ.get(NO_WINDOW):
|
|
1269
|
+
print(f"azurkit: no window while {NO_WINDOW} is set (the tests' mode)", file=sys.stderr)
|
|
1270
|
+
return 1
|
|
1271
|
+
if not (PAGE / "index.html").is_file():
|
|
1272
|
+
print(f"azurkit: {PAGE}: no index.html (the page)", file=sys.stderr)
|
|
1273
|
+
return 1
|
|
1274
|
+
swapcache.install() # the images checked and the swap's answers, kept across launches (the engine's too)
|
|
1275
|
+
if argv:
|
|
1276
|
+
_input_kept_from_children() # before the engine's spawn
|
|
1277
|
+
server = serve(engine=engines.Spawned(stores=True))
|
|
1278
|
+
try:
|
|
1279
|
+
if argv:
|
|
1280
|
+
print(server.url, flush=True)
|
|
1281
|
+
sys.stdin.read()
|
|
1282
|
+
elif not _window(server):
|
|
1283
|
+
print(f"AzurKit: the page is at {server.url} (Ctrl-C quits)", flush=True)
|
|
1284
|
+
webbrowser.open(server.url)
|
|
1285
|
+
threading.Event().wait()
|
|
1286
|
+
except KeyboardInterrupt:
|
|
1287
|
+
pass
|
|
1288
|
+
finally:
|
|
1289
|
+
server.close()
|
|
1290
|
+
return 0
|