From 671b26efa7dd0701a3a54aef386600d062d8c7a5 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 8 May 2024 10:18:01 -0700 Subject: [PATCH] 1.7.35 work in progress --- .efrocachemap | 128 +- CHANGELOG.md | 42 +- src/assets/.asset_manifest_public.json | 4 +- src/assets/Makefile | 4 +- src/assets/ba_data/python/babase/_net.py | 2 + .../ba_data/python/baclassic/_servermode.py | 16 +- src/assets/ba_data/python/baenv.py | 2 +- src/assets/ba_data/python/baplus/_cloud.py | 4 +- .../ba_data/python/bascenev1/__init__.py | 4 + .../ba_data/python/bauiv1lib/account/link.py | 8 +- .../python/bauiv1lib/account/settings.py | 57 +- .../python/bauiv1lib/account/v2proxy.py | 148 +- .../python/bauiv1lib/gather/privatetab.py | 4 +- .../python/bauiv1lib/playlist/share.py | 8 +- .../bauiv1lib/{promocode.py => sendinfo.py} | 173 +- .../python/bauiv1lib/settings/advanced.py | 75 +- .../python/bauiv1lib/settings/nettesting.py | 8 +- src/ballistica/base/base.cc | 3 +- src/ballistica/base/base.h | 13 +- src/ballistica/base/graphics/texture/ktx.cc | 2 +- src/ballistica/base/platform/base_platform.cc | 102 ++ src/ballistica/base/platform/base_platform.h | 15 +- src/ballistica/base/support/stdio_console.cc | 115 +- src/ballistica/base/support/stdio_console.h | 7 +- .../classic/python/classic_python.cc | 24 +- src/ballistica/core/core.h | 8 + .../methods/python_methods_networking.cc | 70 + .../scene_v1/support/scene_v1_app_mode.h | 18 + src/ballistica/shared/ballistica.cc | 12 +- .../shared/foundation/fatal_error.cc | 7 + src/external/httprequest/httprequest.hpp | 1439 ++++++++++++----- tools/bacommon/cloud.py | 15 +- tools/bacommon/net.py | 18 +- tools/bacommon/servermanager.py | 153 +- tools/batools/build.py | 2 + tools/efro/dataclassio/__init__.py | 2 + tools/efro/dataclassio/_api.py | 29 +- tools/efro/debug.py | 14 +- tools/efro/log.py | 36 +- tools/efro/util.py | 21 + 40 files changed, 2052 insertions(+), 760 deletions(-) rename src/assets/ba_data/python/bauiv1lib/{promocode.py => sendinfo.py} (53%) diff --git a/.efrocachemap b/.efrocachemap index 6c2169fa7..1a134371d 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -421,43 +421,43 @@ "build/assets/ba_data/audio/zoeOw.ogg": "74befe45a8417e95b6a2233c51992a26", "build/assets/ba_data/audio/zoePickup01.ogg": "48ab8cddfcde36a750856f3f81dd20c8", "build/assets/ba_data/audio/zoeScream01.ogg": "2b468aedfa8741090247f04eb9e6df55", - "build/assets/ba_data/data/langdata.json": "1de1f6150bd8e602b839378a669f3634", - "build/assets/ba_data/data/languages/arabic.json": "2c2915e10124bb8f69206da9c608d57c", - "build/assets/ba_data/data/languages/belarussian.json": "09954e550d13d3d9cb5a635a1d32a151", - "build/assets/ba_data/data/languages/chinese.json": "5fa538e855bcfe20e727e0ad5831efad", + "build/assets/ba_data/data/langdata.json": "582c633a37b78e3326e20d2a5b8969a0", + "build/assets/ba_data/data/languages/arabic.json": "5c27239be3d4f8daefd9f3bd7e99ff8d", + "build/assets/ba_data/data/languages/belarussian.json": "0a2b0ae82298cec42764558b5b49e4dd", + "build/assets/ba_data/data/languages/chinese.json": "fcd59e90c12e8106ce418b65b97b3db6", "build/assets/ba_data/data/languages/chinesetraditional.json": "319565f8a15667488f48dbce59278e39", "build/assets/ba_data/data/languages/croatian.json": "e671b9d0c012be1a30f9c15eb1b81860", "build/assets/ba_data/data/languages/czech.json": "15be4fd59895135bad0265f79b362d5b", "build/assets/ba_data/data/languages/danish.json": "8e57db30c5250df2abff14a822f83ea7", "build/assets/ba_data/data/languages/dutch.json": "b0900d572c9141897d53d6574c471343", - "build/assets/ba_data/data/languages/english.json": "48fe4c6f97b07420238244309b54a61e", + "build/assets/ba_data/data/languages/english.json": "b7a0d185b50957f731db80897313a055", "build/assets/ba_data/data/languages/esperanto.json": "0e397cfa5f3fb8cef5f4a64f21cda880", - "build/assets/ba_data/data/languages/filipino.json": "838148a9390d5a19ba2514da7c48bc98", - "build/assets/ba_data/data/languages/french.json": "917e4174d6f0eb7f00c27fd79cfbb924", + "build/assets/ba_data/data/languages/filipino.json": "5d28e03d97a3626e790481401ee894a4", + "build/assets/ba_data/data/languages/french.json": "ee2a81129519d7030a617308da8c9195", "build/assets/ba_data/data/languages/german.json": "eaf3f1bf633566de133c61f4f5377e62", - "build/assets/ba_data/data/languages/gibberish.json": "a1afce99249645003017ebec50e716fe", + "build/assets/ba_data/data/languages/gibberish.json": "217a21b35406d1e97954b5c2dbb2c936", "build/assets/ba_data/data/languages/greek.json": "ad3c0d38f34d809824892d6f22808dbf", - "build/assets/ba_data/data/languages/hindi.json": "90f54663e15d85a163f1848a8e9d8d07", - "build/assets/ba_data/data/languages/hungarian.json": "796a290a8c44a1e7635208c2ff5fdc6e", + "build/assets/ba_data/data/languages/hindi.json": "bb3548531daf7bc7fee4a28d48228c32", + "build/assets/ba_data/data/languages/hungarian.json": "6b08fea24b72cc805ed0dc59e11c4cd6", "build/assets/ba_data/data/languages/indonesian.json": "9103845242b572aa8ba48e24f81ddb68", - "build/assets/ba_data/data/languages/italian.json": "59159a9ca784709e807e0855a7ba28b6", + "build/assets/ba_data/data/languages/italian.json": "abac9bc027257fdb757c5c1dc4686a47", "build/assets/ba_data/data/languages/korean.json": "4e3524327a0174250aff5e1ef4c0c597", "build/assets/ba_data/data/languages/malay.json": "f6ce0426d03a62612e3e436ed5d1be1f", - "build/assets/ba_data/data/languages/persian.json": "d42aa034d03f487edd15e651d6f469ab", - "build/assets/ba_data/data/languages/polish.json": "b90feb3cc20a80284ef44546df7099e6", - "build/assets/ba_data/data/languages/portuguese.json": "5dcc9a324a8e926a6d5dd109cceaee1a", + "build/assets/ba_data/data/languages/persian.json": "fbf51bb87c6f5fe63c6a3aee38713f31", + "build/assets/ba_data/data/languages/polish.json": "ac63e339b68819009300f839a9bbd3b2", + "build/assets/ba_data/data/languages/portuguese.json": "ab295421a4449ae01aeed3633426ba2f", "build/assets/ba_data/data/languages/romanian.json": "b3e46efd6f869dbd78014570e037c290", - "build/assets/ba_data/data/languages/russian.json": "3efaaf5eac320fceef029501dec4109b", + "build/assets/ba_data/data/languages/russian.json": "cba5f250a272a4a4eea28ceece9fd549", "build/assets/ba_data/data/languages/serbian.json": "d7452dd72ac0e51680cb39b5ebaa1c69", - "build/assets/ba_data/data/languages/slovak.json": "c00fb27cf982ffad5a4370ad3b16bd21", - "build/assets/ba_data/data/languages/spanish.json": "124e1f0073e3ee6af2de70dcd1a834d1", + "build/assets/ba_data/data/languages/slovak.json": "3c08c748c96c71bd9e1d7291fb8817b6", + "build/assets/ba_data/data/languages/spanish.json": "c380ea87d11cfb129b661dfd3781edc9", "build/assets/ba_data/data/languages/swedish.json": "5142a96597d17d8344be96a603da64ac", "build/assets/ba_data/data/languages/tamil.json": "b9fcc523639f55e05c7f4e7914f3321a", "build/assets/ba_data/data/languages/thai.json": "1d665629361f302693dead39de8fa945", - "build/assets/ba_data/data/languages/turkish.json": "fcd90d63b5d3eae3eda5e94174008327", - "build/assets/ba_data/data/languages/ukrainian.json": "3378b122cea7aa9e05ad50d50809b199", + "build/assets/ba_data/data/languages/turkish.json": "270c07e826bf799246906ac919d78545", + "build/assets/ba_data/data/languages/ukrainian.json": "76ad64cb4911c8d5a3e4815b865ce5bd", "build/assets/ba_data/data/languages/venetian.json": "c0aceb82c26a9361421479d01edaa388", - "build/assets/ba_data/data/languages/vietnamese.json": "921cd1e50f60fe3e101f246e172750ba", + "build/assets/ba_data/data/languages/vietnamese.json": "7e40fcd270b34c1e836ba51a2c6cbce7", "build/assets/ba_data/data/maps/big_g.json": "1dd301d490643088a435ce75df971054", "build/assets/ba_data/data/maps/bridgit.json": "6aea74805f4880cc11237c5734a24422", "build/assets/ba_data/data/maps/courtyard.json": "4b836554c8949bcd2ae382f5e3c1a9cc", @@ -4038,50 +4038,50 @@ "build/assets/windows/Win32/ucrtbased.dll": "2def5335207d41b21b9823f6805997f1", "build/assets/windows/Win32/vc_redist.x86.exe": "b08a55e2e77623fe657bea24f223a3ae", "build/assets/windows/Win32/vcruntime140d.dll": "865b2af4d1e26a1a8073c89acb06e599", - "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "bfd58a687b408c5c6ed5bc63d97095d9", - "build/prefab/full/linux_arm64_gui/release/ballisticakit": "f73ecc7f635b3988851021b2bf7c87d7", - "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "5921b09e1ea841986fd0e8c348f1ba96", - "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "d2d5506c256a6374c9ad3ef403948849", - "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "84dc1f1bf91f985f3814752e305073cf", - "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "1697df9c8b40249705d6e597f3f38385", - "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "37fa3c5cf296f4751cd4fd48b5090288", - "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "f2bf600abed20a7bb626ba11c672af4e", - "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "241cb5e70f31a1bf4b837d1372dc78e1", - "build/prefab/full/mac_arm64_gui/release/ballisticakit": "8d1e211c491ae485cd5e00c27fa01e03", - "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "804a6819db1e8107f4e757903cdbf273", - "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "634597ba33aa0c29625fa81bcb50c608", - "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "60c7782d742f24a67352cc49e4080efa", - "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "5d2abee6403963b60b6b422d84d58738", - "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "fce8370bb7ea6b1b3208dc4efa4b20df", - "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "6979de360c31b792f53572182438f5b0", - "build/prefab/full/windows_x86_gui/debug/BallisticaKit.exe": "c8161cd1a54a17a4cc4e17c0b2ea0fe4", - "build/prefab/full/windows_x86_gui/release/BallisticaKit.exe": "4982637e226891d5afa48400f7ee619b", - "build/prefab/full/windows_x86_server/debug/dist/BallisticaKitHeadless.exe": "ca12bee3cb430eccfa0235719a5d1048", - "build/prefab/full/windows_x86_server/release/dist/BallisticaKitHeadless.exe": "94d1c2579f2fbc99f4725975f08bb150", - "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "55c07828ad7fccc584dd96d1ffebd760", - "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "7ca8b0b5c34766ce9df9babb6ec8311f", - "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "55c07828ad7fccc584dd96d1ffebd760", - "build/prefab/lib/linux_arm64_server/release/libballisticaplus.a": "7ca8b0b5c34766ce9df9babb6ec8311f", - "build/prefab/lib/linux_x86_64_gui/debug/libballisticaplus.a": "6918f36d76061951f51c33d1a8dea572", - "build/prefab/lib/linux_x86_64_gui/release/libballisticaplus.a": "e9e4da9ad759e92741ab10212c51270a", - "build/prefab/lib/linux_x86_64_server/debug/libballisticaplus.a": "6918f36d76061951f51c33d1a8dea572", - "build/prefab/lib/linux_x86_64_server/release/libballisticaplus.a": "e9e4da9ad759e92741ab10212c51270a", - "build/prefab/lib/mac_arm64_gui/debug/libballisticaplus.a": "17da2884d5ca518c84a93d3d2b0edd79", - "build/prefab/lib/mac_arm64_gui/release/libballisticaplus.a": "52f4b8d0b8908a5261d1160feba46327", - "build/prefab/lib/mac_arm64_server/debug/libballisticaplus.a": "17da2884d5ca518c84a93d3d2b0edd79", - "build/prefab/lib/mac_arm64_server/release/libballisticaplus.a": "52f4b8d0b8908a5261d1160feba46327", - "build/prefab/lib/mac_x86_64_gui/debug/libballisticaplus.a": "0db136ec64c90a522e112acbbabfb11d", - "build/prefab/lib/mac_x86_64_gui/release/libballisticaplus.a": "ab81671e4e3be14b17ce721eb835b426", - "build/prefab/lib/mac_x86_64_server/debug/libballisticaplus.a": "6a26caeb1dd4d4871d52e8e2fb2c11ef", - "build/prefab/lib/mac_x86_64_server/release/libballisticaplus.a": "ab81671e4e3be14b17ce721eb835b426", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.lib": "d3626b90791c87180f16ae80b05b088e", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.pdb": "05bd119dcb343f201f2030eff9216eef", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.lib": "e1c3d622bbbd66770ba019fc92abba85", - "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.pdb": "79cbceebbbfa08cef06358cf4ca07634", - "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.lib": "c66603084bf3da24a796655a84c8dd44", - "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.pdb": "d4a2fce87510ef0a47997e04b5508c4b", - "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.lib": "e2df70a204ac392d5afd6ef14f656687", - "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.pdb": "431d30bd06bbb3568a8a219c19c5817a", + "build/prefab/full/linux_arm64_gui/debug/ballisticakit": "9a3a53a5a5894ed950c3d45c68d15372", + "build/prefab/full/linux_arm64_gui/release/ballisticakit": "c57a8d0742c9465ada46a01b62ad75ba", + "build/prefab/full/linux_arm64_server/debug/dist/ballisticakit_headless": "738d3996ff299bde2857df59dde0f5f4", + "build/prefab/full/linux_arm64_server/release/dist/ballisticakit_headless": "f989d6393056783307de70a2bdfa098b", + "build/prefab/full/linux_x86_64_gui/debug/ballisticakit": "5fe35efb6f34e13392575b8a0b7469cc", + "build/prefab/full/linux_x86_64_gui/release/ballisticakit": "b3b64e3df4ea39091e75e95a40efab0a", + "build/prefab/full/linux_x86_64_server/debug/dist/ballisticakit_headless": "a523863d1dc98162536c43a2ec77975c", + "build/prefab/full/linux_x86_64_server/release/dist/ballisticakit_headless": "00bacc4b6d42688712813966df7d6a42", + "build/prefab/full/mac_arm64_gui/debug/ballisticakit": "92b9c2787b61f3f2972253ab9be6309a", + "build/prefab/full/mac_arm64_gui/release/ballisticakit": "5f1c8cdcdd6ce276d039d36b3734f507", + "build/prefab/full/mac_arm64_server/debug/dist/ballisticakit_headless": "fef789ff0160ea56366a2463b3c6c39c", + "build/prefab/full/mac_arm64_server/release/dist/ballisticakit_headless": "8ec6e4ecef744cb6fa64f3617ec49a2c", + "build/prefab/full/mac_x86_64_gui/debug/ballisticakit": "6e00380f58d4ff96c618b454b94d7c3c", + "build/prefab/full/mac_x86_64_gui/release/ballisticakit": "bb2fac09a8e572721b48b22cd2718417", + "build/prefab/full/mac_x86_64_server/debug/dist/ballisticakit_headless": "98e7ef0bf26e9df1088fe22da0a4286b", + "build/prefab/full/mac_x86_64_server/release/dist/ballisticakit_headless": "68cf87da69fa5d004c18e9661179b88e", + "build/prefab/full/windows_x86_gui/debug/BallisticaKit.exe": "509ed7a3ec78263793c20b8e4fe24cdb", + "build/prefab/full/windows_x86_gui/release/BallisticaKit.exe": "af01eab4ab71cc0cb27cfdd6579efce2", + "build/prefab/full/windows_x86_server/debug/dist/BallisticaKitHeadless.exe": "7b1567efe48e0f174ca1fe6d12cce83f", + "build/prefab/full/windows_x86_server/release/dist/BallisticaKitHeadless.exe": "a6e4ba2782551897e24b7f31937df01c", + "build/prefab/lib/linux_arm64_gui/debug/libballisticaplus.a": "24c1641a1bef7c56d8b3805fbd01ac30", + "build/prefab/lib/linux_arm64_gui/release/libballisticaplus.a": "3da37afad8903a3c24c38fb698a19ce1", + "build/prefab/lib/linux_arm64_server/debug/libballisticaplus.a": "24c1641a1bef7c56d8b3805fbd01ac30", + "build/prefab/lib/linux_arm64_server/release/libballisticaplus.a": "3da37afad8903a3c24c38fb698a19ce1", + "build/prefab/lib/linux_x86_64_gui/debug/libballisticaplus.a": "d8b9d06d24d68ea28f271630fe7927d8", + "build/prefab/lib/linux_x86_64_gui/release/libballisticaplus.a": "20e6bd566fa26ab469f18ee07301b2a5", + "build/prefab/lib/linux_x86_64_server/debug/libballisticaplus.a": "d8b9d06d24d68ea28f271630fe7927d8", + "build/prefab/lib/linux_x86_64_server/release/libballisticaplus.a": "20e6bd566fa26ab469f18ee07301b2a5", + "build/prefab/lib/mac_arm64_gui/debug/libballisticaplus.a": "2cae1591b40b3e514dc8bfa53c381ca0", + "build/prefab/lib/mac_arm64_gui/release/libballisticaplus.a": "9cb9babbe43f393f286c596c572f3687", + "build/prefab/lib/mac_arm64_server/debug/libballisticaplus.a": "2cae1591b40b3e514dc8bfa53c381ca0", + "build/prefab/lib/mac_arm64_server/release/libballisticaplus.a": "9cb9babbe43f393f286c596c572f3687", + "build/prefab/lib/mac_x86_64_gui/debug/libballisticaplus.a": "e991ed53b63acb73579097a38ba63731", + "build/prefab/lib/mac_x86_64_gui/release/libballisticaplus.a": "2ff4914fca4dbd5ad144b32b9d89c3fb", + "build/prefab/lib/mac_x86_64_server/debug/libballisticaplus.a": "a53b90db9b3d05d8048dcd63e56debd3", + "build/prefab/lib/mac_x86_64_server/release/libballisticaplus.a": "2ff4914fca4dbd5ad144b32b9d89c3fb", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.lib": "c86657aaf33d885d4dbf9b88e6168012", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitGenericPlus.pdb": "ae4e9ad706f71ca3566fa6440c49ae5e", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.lib": "d1188f99c618449e49555c5d50326e6e", + "build/prefab/lib/windows/Debug_Win32/BallisticaKitHeadlessPlus.pdb": "c2b3f66f80934e4ad07212a83bc5889b", + "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.lib": "5249d461409c214ea0a91bc9baf10599", + "build/prefab/lib/windows/Release_Win32/BallisticaKitGenericPlus.pdb": "e2d37edade6cd5868e342b782ceda44d", + "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.lib": "af49482fc819b4df01c78996110c5a80", + "build/prefab/lib/windows/Release_Win32/BallisticaKitHeadlessPlus.pdb": "2997322e5ec3233d230ab6b1d581cabb", "src/assets/ba_data/python/babase/_mgen/__init__.py": "f885fed7f2ed98ff2ba271f9dbe3391c", "src/assets/ba_data/python/babase/_mgen/enums.py": "b611c090513a21e2fe90e56582724e9d", "src/ballistica/base/mgen/pyembed/binding_base.inc": "72bfed2cce8ff19741989dec28302f3f", diff --git a/CHANGELOG.md b/CHANGELOG.md index 804f3b3ee..25da2adfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,44 @@ -### 1.7.35 (build 21827, api 8, 2024-04-30) +### 1.7.35 (build 21848, api 8, 2024-05-08) +- Fixed an issue where the engine would block at exit on some version of Linux + until Ctrl-D was pressed in the calling terminal. +- Reworked the 'Enter Code' dialog into a 'Send Info' dialog. The `sendinfo` + command is 99% of the reason for 'Enter Code' existing, so this simplifies + things for that use case and hopefully clarifies its purpose so I can spend + less time responding to app reviewers and more time improving the game. +- The `Network Testing` panel no longer requires being signed in (it just skips + one test if not signed in). +- Took a pass through the engine and its servers to make things more ipv6 + friendly and prep for an eventual ipv6-only world (though ipv4 won't be going + anywhere for a long time). The existing half-hearted state of ipv6 support was + starting to cause problems when testing in certain ipv6-only environments, so + it was time to clean it up. +- The engine will now establish its persistent v2-transport connections to + regional servers using ipv6 when that is the fastest option based on ping + tests. +- Improved the efficiency of the `connectivity` system which determines which + regional ballistica server to establish a connection to (All V2 server + communication goes through this connection). It now takes geography into + account, so if it gets a low ping to a server in South America it won't try + pinging Warsaw, etc. Set the env var `BA_DEBUG_LOG_CONNECTIVITY=1` if you want + to watch it do it's thing and holler if you see any bad results. +- Servers can now provide their public ipv4 and ipv6 addresses in their configs. + Previously, a server's address was always determined automatically based on + how it connected to the master server, but this would only provide one of the + two forms. Now it is possible to provide both. +- (WORK IN PROGRESS) As of this version, servers are *required* to be accessible + via ipv4 to appear in the public listing. So they may need to provide an ipv4 + address in their config if the automatically detected one is ipv6. This should + reduce the confusion of ipv6-only servers appearing greyed out for lots of + ipv4-only people. Pretty much everyone can connect to ipv4. +- (WORK IN PROGRESS) There is now more personalized error feedback for the + connectivity checks when poking `Make My Party Public` or when launching the + command line server. Hopefully this will help navigate the new dual ipv4/ipv6 + situation. +- (WORK IN PROGRESS) The low level `ConnectionToHostUDP` class can now accept + multiple `SockAddr`s; it will attempt to contact the host on all of them and + use whichever responds first. This allows us to pass both ipv4 and ipv6 + addresses when available and transparently use whichever is more performant. + ### 1.7.34 (build 21823, api 8, 2024-04-26) - Bumped Python version from 3.11 to 3.12 for all builds and project tools. One diff --git a/src/assets/.asset_manifest_public.json b/src/assets/.asset_manifest_public.json index 21c9dde43..07ddf68c9 100644 --- a/src/assets/.asset_manifest_public.json +++ b/src/assets/.asset_manifest_public.json @@ -386,12 +386,12 @@ "ba_data/python/bauiv1lib/__pycache__/play.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/playoptions.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/popup.cpython-312.opt-1.pyc", - "ba_data/python/bauiv1lib/__pycache__/promocode.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/purchase.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/qrcode.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/radiogroup.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/report.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/resourcetypeinfo.cpython-312.opt-1.pyc", + "ba_data/python/bauiv1lib/__pycache__/sendinfo.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/serverdialog.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/specialoffer.cpython-312.opt-1.pyc", "ba_data/python/bauiv1lib/__pycache__/tabs.cpython-312.opt-1.pyc", @@ -495,12 +495,12 @@ "ba_data/python/bauiv1lib/profile/browser.py", "ba_data/python/bauiv1lib/profile/edit.py", "ba_data/python/bauiv1lib/profile/upgrade.py", - "ba_data/python/bauiv1lib/promocode.py", "ba_data/python/bauiv1lib/purchase.py", "ba_data/python/bauiv1lib/qrcode.py", "ba_data/python/bauiv1lib/radiogroup.py", "ba_data/python/bauiv1lib/report.py", "ba_data/python/bauiv1lib/resourcetypeinfo.py", + "ba_data/python/bauiv1lib/sendinfo.py", "ba_data/python/bauiv1lib/serverdialog.py", "ba_data/python/bauiv1lib/settings/__init__.py", "ba_data/python/bauiv1lib/settings/__pycache__/__init__.cpython-312.opt-1.pyc", diff --git a/src/assets/Makefile b/src/assets/Makefile index 13318c004..c40d490a6 100644 --- a/src/assets/Makefile +++ b/src/assets/Makefile @@ -389,12 +389,12 @@ SCRIPT_TARGETS_PY_PUBLIC = \ $(BUILD_DIR)/ba_data/python/bauiv1lib/profile/browser.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/profile/edit.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/profile/upgrade.py \ - $(BUILD_DIR)/ba_data/python/bauiv1lib/promocode.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/purchase.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/qrcode.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/radiogroup.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/report.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/resourcetypeinfo.py \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/sendinfo.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/serverdialog.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/settings/__init__.py \ $(BUILD_DIR)/ba_data/python/bauiv1lib/settings/advanced.py \ @@ -665,12 +665,12 @@ SCRIPT_TARGETS_PYC_PUBLIC = \ $(BUILD_DIR)/ba_data/python/bauiv1lib/profile/__pycache__/browser.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/profile/__pycache__/edit.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/profile/__pycache__/upgrade.cpython-312.opt-1.pyc \ - $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/promocode.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/purchase.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/qrcode.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/radiogroup.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/report.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/resourcetypeinfo.cpython-312.opt-1.pyc \ + $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/sendinfo.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/__pycache__/serverdialog.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/settings/__pycache__/__init__.cpython-312.opt-1.pyc \ $(BUILD_DIR)/ba_data/python/bauiv1lib/settings/__pycache__/advanced.cpython-312.opt-1.pyc \ diff --git a/src/assets/ba_data/python/babase/_net.py b/src/assets/ba_data/python/babase/_net.py index a02e3c317..226fcb9e7 100644 --- a/src/assets/ba_data/python/babase/_net.py +++ b/src/assets/ba_data/python/babase/_net.py @@ -32,6 +32,8 @@ def __init__(self) -> None: # For debugging. self.v1_test_log: str = '' self.v1_ctest_results: dict[int, str] = {} + self.connectivity_state = 'uninited' + self.transport_state = 'uninited' self.server_time_offset_hours: float | None = None @property diff --git a/src/assets/ba_data/python/baclassic/_servermode.py b/src/assets/ba_data/python/baclassic/_servermode.py index a5479955d..7fa97d54a 100644 --- a/src/assets/ba_data/python/baclassic/_servermode.py +++ b/src/assets/ba_data/python/baclassic/_servermode.py @@ -102,8 +102,8 @@ def __init__(self, config: ServerConfig) -> None: self._shutdown_reason: ShutdownReason | None = None self._executing_shutdown = False - # Make note if they want us to import a playlist; - # we'll need to do that first if so. + # Make note if they want us to import a playlist; we'll need to + # do that first if so. self._playlist_fetch_running = self._config.playlist_code is not None self._playlist_fetch_sent_request = False self._playlist_fetch_got_response = False @@ -366,7 +366,8 @@ def _launch_server_session(self) -> None: raise RuntimeError(f'Unknown session type {sessiontype}') # Need to add this in a transaction instead of just setting - # it directly or it will get overwritten by the master-server. + # it directly or it will get overwritten by the + # master-server. plus.add_v1_account_transaction( { 'type': 'ADD_PLAYLIST', @@ -407,7 +408,7 @@ def _launch_server_session(self) -> None: appcfg['Teams Series Length'] = self._config.teams_series_length appcfg['FFA Series Length'] = self._config.ffa_series_length - # deprecated, left here in order to not break mods + # Deprecated; left here in order to not break mods. classic.teams_series_length = self._config.teams_series_length classic.ffa_series_length = self._config.ffa_series_length @@ -423,6 +424,13 @@ def _launch_server_session(self) -> None: bascenev1.set_public_party_queue_enabled(self._config.enable_queue) bascenev1.set_public_party_name(self._config.party_name) bascenev1.set_public_party_stats_url(self._config.stats_url) + bascenev1.set_public_party_public_address_ipv4( + self._config.public_ipv4_address + ) + bascenev1.set_public_party_public_address_ipv6( + self._config.public_ipv6_address + ) + bascenev1.set_public_party_enabled(self._config.party_is_public) bascenev1.set_player_rejoin_cooldown( diff --git a/src/assets/ba_data/python/baenv.py b/src/assets/ba_data/python/baenv.py index 0aaf66c4d..0301e787d 100644 --- a/src/assets/ba_data/python/baenv.py +++ b/src/assets/ba_data/python/baenv.py @@ -52,7 +52,7 @@ # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 21827 +TARGET_BALLISTICA_BUILD = 21848 TARGET_BALLISTICA_VERSION = '1.7.35' diff --git a/src/assets/ba_data/python/baplus/_cloud.py b/src/assets/ba_data/python/baplus/_cloud.py index 32d6d141c..0a7b4aff1 100644 --- a/src/assets/ba_data/python/baplus/_cloud.py +++ b/src/assets/ba_data/python/baplus/_cloud.py @@ -144,8 +144,8 @@ def send_message(self, msg: Message) -> Response | None: @overload async def send_message_async( - self, msg: bacommon.cloud.PromoCodeMessage - ) -> bacommon.cloud.PromoCodeResponse: ... + self, msg: bacommon.cloud.SendInfoMessage + ) -> bacommon.cloud.SendInfoResponse: ... @overload async def send_message_async( diff --git a/src/assets/ba_data/python/bascenev1/__init__.py b/src/assets/ba_data/python/bascenev1/__init__.py index ebfb54186..b35134806 100644 --- a/src/assets/ba_data/python/bascenev1/__init__.py +++ b/src/assets/ba_data/python/bascenev1/__init__.py @@ -134,6 +134,8 @@ set_public_party_enabled, set_public_party_max_size, set_public_party_name, + set_public_party_public_address_ipv4, + set_public_party_public_address_ipv6, set_public_party_queue_enabled, set_public_party_stats_url, set_replay_speed_exponent, @@ -429,6 +431,8 @@ 'set_public_party_enabled', 'set_public_party_max_size', 'set_public_party_name', + 'set_public_party_public_address_ipv4', + 'set_public_party_public_address_ipv6', 'set_public_party_queue_enabled', 'set_public_party_stats_url', 'set_player_rejoin_cooldown', diff --git a/src/assets/ba_data/python/bauiv1lib/account/link.py b/src/assets/ba_data/python/bauiv1lib/account/link.py index 79c4fd855..58828ad8c 100644 --- a/src/assets/ba_data/python/bauiv1lib/account/link.py +++ b/src/assets/ba_data/python/bauiv1lib/account/link.py @@ -126,10 +126,12 @@ def _generate_press(self) -> None: plus.run_v1_account_transactions() def _enter_code_press(self) -> None: - from bauiv1lib import promocode + from bauiv1lib.sendinfo import SendInfoWindow - promocode.PromoCodeWindow( - modal=True, origin_widget=self._enter_code_button + SendInfoWindow( + modal=True, + legacy_code_mode=True, + origin_widget=self._enter_code_button, ) bui.containerwidget( edit=self._root_widget, transition=self._transition_out diff --git a/src/assets/ba_data/python/bauiv1lib/account/settings.py b/src/assets/ba_data/python/bauiv1lib/account/settings.py index 004486300..df7ec57fd 100644 --- a/src/assets/ba_data/python/bauiv1lib/account/settings.py +++ b/src/assets/ba_data/python/bauiv1lib/account/settings.py @@ -609,7 +609,7 @@ def _refresh(self) -> None: autoselect=True, size=(button_width, 60), label=bui.Lstr( - value='${A}${B}', + value='${A} ${B}', subs=[ ( '${A}', @@ -654,7 +654,7 @@ def _refresh(self) -> None: # in all languages. Can revisit if not true. # https://developer.apple.com/forums/thread/725779 label=bui.Lstr( - value='${A}${B}', + value='${A} ${B}', subs=[ ( '${A}', @@ -695,39 +695,58 @@ def _refresh(self) -> None: label='', on_activate_call=self._v2_proxy_sign_in_press, ) + + # TODO: Add translation strings for these. + v2labeltext: bui.Lstr | str = ( + 'Sign in with an email/password' + if show_game_center_sign_in_button + # else bui.Lstr(resource=self._r + '.signInWithV2Text') + else bui.Lstr(resource=self._r + '.signInText') + ) + v2infotext: bui.Lstr | str | None = None + # ( + # None + # if show_game_center_sign_in_button + # else bui.Lstr(resource=self._r + '.signInWithV2InfoText') + # ) + bui.textwidget( parent=self._subcontainer, draw_controller=btn, h_align='center', v_align='center', size=(0, 0), - position=(self._sub_width * 0.5, v + 17), + position=( + self._sub_width * 0.5, + v + (17 if v2infotext is not None else 10), + ), text=bui.Lstr( - value='${A}${B}', + value='${A} ${B}', subs=[ ('${A}', bui.charstr(bui.SpecialChar.V2_LOGO)), ( '${B}', - bui.Lstr(resource=self._r + '.signInWithV2Text'), + v2labeltext, ), ], ), maxwidth=button_width * 0.8, color=(0.75, 1.0, 0.7), ) - bui.textwidget( - parent=self._subcontainer, - draw_controller=btn, - h_align='center', - v_align='center', - size=(0, 0), - position=(self._sub_width * 0.5, v - 4), - text=bui.Lstr(resource=self._r + '.signInWithV2InfoText'), - flatness=1.0, - scale=0.57, - maxwidth=button_width * 0.9, - color=(0.55, 0.8, 0.5), - ) + if v2infotext is not None: + bui.textwidget( + parent=self._subcontainer, + draw_controller=btn, + h_align='center', + v_align='center', + size=(0, 0), + position=(self._sub_width * 0.5, v - 4), + text=v2infotext, + flatness=1.0, + scale=0.57, + maxwidth=button_width * 0.9, + color=(0.55, 0.8, 0.5), + ) if first_selectable is None: first_selectable = btn if bui.app.ui_v1.use_toolbars: @@ -770,7 +789,7 @@ def _refresh(self) -> None: size=(0, 0), position=(self._sub_width * 0.5, v + 17), text=bui.Lstr( - value='${A}${B}', + value='${A} ${B}', subs=[ ('${A}', bui.charstr(bui.SpecialChar.LOCAL_ACCOUNT)), ( diff --git a/src/assets/ba_data/python/bauiv1lib/account/v2proxy.py b/src/assets/ba_data/python/bauiv1lib/account/v2proxy.py index 52d93ea5f..0ee06a33b 100644 --- a/src/assets/ba_data/python/bauiv1lib/account/v2proxy.py +++ b/src/assets/ba_data/python/bauiv1lib/account/v2proxy.py @@ -39,17 +39,39 @@ def __init__(self, origin_widget: bui.Widget): ) ) - self._loading_text = bui.textwidget( + self._state_text = bui.textwidget( parent=self._root_widget, - position=(self._width * 0.5, self._height * 0.5), + position=(self._width * 0.5, self._height * 0.6), h_align='center', v_align='center', size=(0, 0), + scale=1.4, maxwidth=0.9 * self._width, text=bui.Lstr( value='${A}...', subs=[('${A}', bui.Lstr(resource='loadingText'))], ), + color=(1, 1, 1), + ) + self._sub_state_text = bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.5, self._height * 0.55), + h_align='center', + v_align='top', + scale=0.85, + size=(0, 0), + maxwidth=0.9 * self._width, + text='', + ) + self._sub_state_text2 = bui.textwidget( + parent=self._root_widget, + position=(self._width * 0.1, self._height * 0.3), + h_align='left', + v_align='top', + scale=0.7, + size=(0, 0), + maxwidth=0.9 * self._width, + text='', ) self._cancel_button = bui.buttonwidget( @@ -66,14 +88,79 @@ def __init__(self, origin_widget: bui.Widget): edit=self._root_widget, cancel_button=self._cancel_button ) - self._update_timer: bui.AppTimer | None = None + self._message_in_flight = False + self._complete = False + # self._delay_ticks = 0 + self._connection_wait = 5 + + # self._update_timer: bui.AppTimer | None = None + self._update_timer = bui.AppTimer( + 1.23, bui.WeakCall(self._update), repeat=True + ) + bui.pushcall(bui.WeakCall(self._update)) # Ask the cloud for a proxy login id. - assert bui.app.plus is not None - bui.app.plus.cloud.send_message_cb( + # assert bui.app.plus is not None + # bui.app.plus.cloud.send_message_cb( + # bacommon.cloud.LoginProxyRequestMessage(), + # on_response=bui.WeakCall(self._on_proxy_request_response), + # ) + + def _update(self) -> None: + # print('hello from update', time.monotonic()) + + if self._message_in_flight or self._complete: + return + + plus = bui.app.plus + assert plus is not None + + # Spin for a moment if it looks like we have no server + # connection; it might still be getting on its feed. + if not plus.cloud.connected and self._connection_wait > 0: + self._connection_wait -= 1 + return + + plus.cloud.send_message_cb( bacommon.cloud.LoginProxyRequestMessage(), on_response=bui.WeakCall(self._on_proxy_request_response), ) + self._message_in_flight = True + + def _get_server_address(self) -> str: + plus = bui.app.plus + assert plus is not None + return plus.get_master_server_address(version=2) + + def _set_error_state(self, error_location: str) -> None: + msaddress = self._get_server_address() + addr = msaddress.removeprefix('https://') + bui.textwidget( + edit=self._state_text, + text=f'Unable to connect to {addr}.', + color=(1, 0, 0), + ) + support_email = 'support@froemling.net' + bui.textwidget( + edit=self._sub_state_text, + text=( + f'Usually this means your internet is down.\n' + f'Please contact {support_email} if this is not the case.' + ), + color=(1, 0, 0), + ) + bui.textwidget( + edit=self._sub_state_text2, + text=( + f'debug-info:\n' + f' error-location: {error_location}\n' + f' connectivity: {bui.app.net.connectivity_state}\n' + f' transport: {bui.app.net.transport_state}' + ), + color=(0.8, 0.2, 0.3), + flatness=1.0, + shadow=0.0, + ) def _on_proxy_request_response( self, response: bacommon.cloud.LoginProxyRequestResponse | Exception @@ -81,17 +168,51 @@ def _on_proxy_request_response( plus = bui.app.plus assert plus is not None - # Something went wrong. Show an error message and that's it. - if isinstance(response, Exception): - bui.textwidget( - edit=self._loading_text, - text=bui.Lstr(resource='internal.unavailableNoConnectionText'), - color=(1, 0, 0), + if not self._message_in_flight: + logging.warning( + 'v2proxy got _on_proxy_request_response' + ' without _message_in_flight set; unexpected.' ) + self._message_in_flight = False + + # if bool(True) and random.random() < 1.0: + # response = Exception('dummy') + + msaddress = self._get_server_address() + + # Something went wrong. Show an error message and schedule retry. + if isinstance(response, Exception): + # addr = msaddress.removeprefix('https://') + # bui.textwidget( + # edit=self._state_text, + # text=f'Unable to connect to {addr}.', + # color=(1, 0, 0), + # ) + # bui.textwidget( + # edit=self._sub_state_text, + # text='Will retry in a moment...', + # color=(1, 0, 0), + # ) + # self._delay_ticks = 3 + self._set_error_state(f'response exc ({type(response).__name__})') + self._complete = True + + # bui.textwidget( + # edit=self._state_text, + # text=bui.Lstr( + # resource='internal.unavailableNoConnectionText'), + # color=(1, 0, 0), + # ) return + self._complete = True + + self._state_text.delete() + self._sub_state_text.delete() + self._sub_state_text2.delete() + # Show link(s) the user can use to sign in. - address = plus.get_master_server_address(version=2) + response.url + address = msaddress + response.url address_pretty = address.removeprefix('https://') assert bui.app.classic is not None @@ -172,12 +293,11 @@ def _ask_for_status(self) -> None: def _got_status( self, response: bacommon.cloud.LoginProxyStateQueryResponse | Exception ) -> None: - # For now, if anything goes wrong on the server-side, just abort - # with a vague error message. Can be more verbose later if need be. if ( isinstance(response, bacommon.cloud.LoginProxyStateQueryResponse) and response.state is response.State.FAIL ): + logging.info('LoginProxy failed.') bui.getsound('error').play() bui.screenmessage(bui.Lstr(resource='errorText'), color=(1, 0, 0)) self._done() diff --git a/src/assets/ba_data/python/bauiv1lib/gather/privatetab.py b/src/assets/ba_data/python/bauiv1lib/gather/privatetab.py index e50bdf549..00bb4a2cc 100644 --- a/src/assets/ba_data/python/bauiv1lib/gather/privatetab.py +++ b/src/assets/ba_data/python/bauiv1lib/gather/privatetab.py @@ -989,8 +989,8 @@ def _connect_response(self, result: dict[str, Any] | None) -> None: bui.getsound('error').play() return self._debug_server_comm('got valid connect response') - assert cresult.addr is not None and cresult.port is not None - bs.connect_to_party(cresult.addr, port=cresult.port) + assert cresult.address4 is not None and cresult.port is not None + bs.connect_to_party(cresult.address4, port=cresult.port) except Exception: self._debug_server_comm('got connect response error') bui.getsound('error').play() diff --git a/src/assets/ba_data/python/bauiv1lib/playlist/share.py b/src/assets/ba_data/python/bauiv1lib/playlist/share.py index 2824d3b3e..2f8d7a211 100644 --- a/src/assets/ba_data/python/bauiv1lib/playlist/share.py +++ b/src/assets/ba_data/python/bauiv1lib/playlist/share.py @@ -7,14 +7,14 @@ import time from typing import TYPE_CHECKING, override -from bauiv1lib.promocode import PromoCodeWindow +from bauiv1lib.sendinfo import SendInfoWindow import bauiv1 as bui if TYPE_CHECKING: from typing import Any, Callable -class SharePlaylistImportWindow(PromoCodeWindow): +class SharePlaylistImportWindow(SendInfoWindow): """Window for importing a shared playlist.""" def __init__( @@ -22,7 +22,9 @@ def __init__( origin_widget: bui.Widget | None = None, on_success_callback: Callable[[], Any] | None = None, ): - PromoCodeWindow.__init__(self, modal=True, origin_widget=origin_widget) + SendInfoWindow.__init__( + self, modal=True, legacy_code_mode=True, origin_widget=origin_widget + ) self._on_success_callback = on_success_callback def _on_import_response(self, response: dict[str, Any] | None) -> None: diff --git a/src/assets/ba_data/python/bauiv1lib/promocode.py b/src/assets/ba_data/python/bauiv1lib/sendinfo.py similarity index 53% rename from src/assets/ba_data/python/bauiv1lib/promocode.py rename to src/assets/ba_data/python/bauiv1lib/sendinfo.py index 5dcf3fb61..5915e4f72 100644 --- a/src/assets/ba_data/python/bauiv1lib/promocode.py +++ b/src/assets/ba_data/python/bauiv1lib/sendinfo.py @@ -14,12 +14,17 @@ from typing import Any -class PromoCodeWindow(bui.Window): - """Window for entering promo codes.""" +class SendInfoWindow(bui.Window): + """Window for sending info to the developer.""" def __init__( - self, modal: bool = False, origin_widget: bui.Widget | None = None + self, + modal: bool = False, + legacy_code_mode: bool = False, + origin_widget: bui.Widget | None = None, ): + self._legacy_code_mode = legacy_code_mode + scale_origin: tuple[float, float] | None if origin_widget is not None: self._transition_out = 'out_scale' @@ -30,8 +35,8 @@ def __init__( scale_origin = None transition = 'in_right' - width = 450 - height = 330 + width = 450 if legacy_code_mode else 600 + height = 200 if legacy_code_mode else 300 self._modal = modal self._r = 'promoCodeWindow' @@ -66,56 +71,73 @@ def __init__( ) v = height - 74 - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr(resource='codesExplainText'), - maxwidth=width * 0.9, - position=(width * 0.5, v), - color=(0.7, 0.7, 0.7, 1.0), - size=(0, 0), - scale=0.8, - h_align='center', - v_align='center', - ) - v -= 60 - bui.textwidget( - parent=self._root_widget, - text=bui.Lstr( - resource='supportEmailText', - subs=[('${EMAIL}', 'support@froemling.net')], - ), - maxwidth=width * 0.9, - position=(width * 0.5, v), - color=(0.7, 0.7, 0.7, 1.0), - size=(0, 0), - scale=0.65, - h_align='center', - v_align='center', - ) + if legacy_code_mode: + v -= 20 + else: + v -= 20 + bui.textwidget( + parent=self._root_widget, + text=bui.Lstr(resource='sendInfoDescriptionText'), + maxwidth=width * 0.9, + position=(width * 0.5, v), + color=(0.7, 0.7, 0.7, 1.0), + size=(0, 0), + scale=0.8, + h_align='center', + v_align='center', + ) + v -= 20 - v -= 80 + # bui.textwidget( + # parent=self._root_widget, + # text=bui.Lstr( + # resource='supportEmailText', + # subs=[('${EMAIL}', 'support@froemling.net')], + # ), + # maxwidth=width * 0.9, + # position=(width * 0.5, v), + # color=(0.7, 0.7, 0.7, 1.0), + # size=(0, 0), + # scale=0.65, + # h_align='center', + # v_align='center', + # ) + v -= 80 bui.textwidget( parent=self._root_widget, - text=bui.Lstr(resource=self._r + '.codeText'), + text=bui.Lstr( + resource=( + self._r + '.codeText' + if legacy_code_mode + else 'descriptionText' + ) + ), position=(22, v), color=(0.8, 0.8, 0.8, 1.0), size=(90, 30), h_align='right', + maxwidth=100, ) v -= 8 self._text_field = bui.textwidget( parent=self._root_widget, position=(125, v), - size=(280, 46), + size=(280 if legacy_code_mode else 380, 46), text='', h_align='left', v_align='center', max_chars=64, color=(0.9, 0.9, 0.9, 1.0), - description=bui.Lstr(resource=self._r + '.codeText'), + description=bui.Lstr( + resource=( + self._r + '.codeText' + if legacy_code_mode + else 'descriptionText' + ) + ), editable=True, padding=4, on_return_press_call=self._activate_enter_button, @@ -166,6 +188,9 @@ def _do_enter(self) -> None: # pylint: disable=cyclic-import from bauiv1lib.settings.advanced import AdvancedSettingsWindow + plus = bui.app.plus + assert plus is not None + # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: return @@ -180,38 +205,84 @@ def _do_enter(self) -> None: from_window=self._root_widget, ) - code: Any = bui.textwidget(query=self._text_field) - assert isinstance(code, str) + description: Any = bui.textwidget(query=self._text_field) + assert isinstance(description, str) - bui.app.create_async_task(_run_code(code)) + # Used for things like unlocking shared playlists or linking + # accounts: talk directly to V1 server via transactions. + if self._legacy_code_mode: + if plus.get_v1_account_state() != 'signed_in': + bui.screenmessage( + bui.Lstr(resource='notSignedInErrorText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + else: + plus.add_v1_account_transaction( + { + 'type': 'PROMO_CODE', + 'expire_time': time.time() + 5, + 'code': description, + } + ) + plus.run_v1_account_transactions() + else: + bui.app.create_async_task(_send_info(description)) -async def _run_code(code: str) -> None: - from bacommon.cloud import PromoCodeMessage +async def _send_info(description: str) -> None: + from bacommon.cloud import SendInfoMessage plus = bui.app.plus assert plus is not None try: - # If we're signed in with a V2 account, ship this to V2 server. + # Don't allow *anything* if our V2 transport connection isn't up. + if not plus.cloud.connected: + bui.screenmessage( + bui.Lstr(resource='internal.unavailableNoConnectionText'), + color=(1, 0, 0), + ) + bui.getsound('error').play() + return + + # Ship to V2 server, with or without account info. if plus.accounts.primary is not None: with plus.accounts.primary: response = await plus.cloud.send_message_async( - PromoCodeMessage(code) + SendInfoMessage(description) ) - # If V2 handled it, we're done. - if response.valid: - # Support simple message printing from v2 server. - if response.message is not None: - bui.screenmessage(response.message, color=(0, 1, 0)) - return - - # If V2 didn't accept it (or isn't signed in) kick it over to V1. + else: + response = await plus.cloud.send_message_async( + SendInfoMessage(description) + ) + + # Support simple message printing from v2 server. + if response.message is not None: + bui.screenmessage(response.message, color=(0, 1, 0)) + + # If V2 handled it, we're done. + if response.handled: + return + + # Ok; V2 didn't handle it. Try V1 if we're signed in there. + if plus.get_v1_account_state() != 'signed_in': + bui.screenmessage( + bui.Lstr(resource='notSignedInErrorText'), color=(1, 0, 0) + ) + bui.getsound('error').play() + return + + # Push it along to v1 as an old style code. Allow v2 response to + # sub in its own code. plus.add_v1_account_transaction( { 'type': 'PROMO_CODE', 'expire_time': time.time() + 5, - 'code': code, + 'code': ( + description + if response.legacy_code is None + else response.legacy_code + ), } ) plus.run_v1_account_transactions() diff --git a/src/assets/ba_data/python/bauiv1lib/settings/advanced.py b/src/assets/ba_data/python/bauiv1lib/settings/advanced.py index 2585da7da..133c5c53f 100644 --- a/src/assets/ba_data/python/bauiv1lib/settings/advanced.py +++ b/src/assets/ba_data/python/bauiv1lib/settings/advanced.py @@ -1,7 +1,6 @@ # Released under the MIT License. See LICENSE for details. # """UI functionality for advanced settings.""" -# pylint: disable=too-many-lines from __future__ import annotations @@ -91,7 +90,7 @@ def __init__( self._scroll_width = self._width - (100 + 2 * x_inset) self._scroll_height = self._height - 115.0 self._sub_width = self._scroll_width * 0.95 - self._sub_height = 808.0 + self._sub_height = 870.0 if self._show_always_use_internal_keyboard: self._sub_height += 62 @@ -191,7 +190,7 @@ def _preload_modules() -> None: from bauiv1lib.settings import nettesting as _unused4 from bauiv1lib import appinvite as _unused5 from bauiv1lib import account as _unused6 - from bauiv1lib import promocode as _unused7 + from bauiv1lib import sendinfo as _unused7 from bauiv1lib import debug as _unused8 from bauiv1lib.settings import plugins as _unused9 from bauiv1lib.settings import moddingtools as _unused10 @@ -289,33 +288,16 @@ def _rebuild(self) -> None: this_button_width = 410 - self._promo_code_button = bui.buttonwidget( - parent=self._subcontainer, - position=(self._sub_width / 2 - this_button_width / 2, v - 14), - size=(this_button_width, 60), - autoselect=True, - label=bui.Lstr(resource=f'{self._r}.enterPromoCodeText'), - text_scale=1.0, - on_activate_call=self._on_promo_code_press, - ) - if self._back_button is not None: - bui.widget( - edit=self._promo_code_button, - up_widget=self._back_button, - left_widget=self._back_button, - ) - v -= self._extra_button_spacing * 0.8 - assert bui.app.classic is not None bui.textwidget( parent=self._subcontainer, - position=(200, v + 10), + position=(70, v + 10), size=(0, 0), text=bui.Lstr(resource=f'{self._r}.languageText'), maxwidth=150, - scale=0.95, + scale=1.2, color=bui.app.ui_v1.title_color, - h_align='right', + h_align='left', v_align='center', ) @@ -394,7 +376,7 @@ def _rebuild(self) -> None: bui.textwidget( parent=self._subcontainer, - position=(self._sub_width * 0.5, v + 10), + position=(90, v + 10), size=(0, 0), text=bui.Lstr( resource=f'{self._r}.helpTranslateText', @@ -405,7 +387,7 @@ def _rebuild(self) -> None: flatness=1.0, scale=0.65, color=(0.4, 0.9, 0.4, 0.8), - h_align='center', + h_align='left', v_align='center', ) v -= self._spacing * 1.9 @@ -436,7 +418,7 @@ def _rebuild(self) -> None: maxwidth=400.0, ) self._update_lang_status() - v -= 40 + v -= 50 lang_inform = plus.get_v1_account_misc_val('langInform', False) @@ -688,6 +670,17 @@ def _rebuild(self) -> None: on_activate_call=self._on_benchmark_press, ) + v -= 100 + self._send_info_button = bui.buttonwidget( + parent=self._subcontainer, + position=(self._sub_width / 2 - this_button_width / 2, v - 14), + size=(this_button_width, 60), + autoselect=True, + label=bui.Lstr(resource=f'{self._r}.sendInfoText'), + text_scale=1.0, + on_activate_call=self._on_send_info_press, + ) + for child in self._subcontainer.get_children(): bui.widget(edit=child, show_buffer_bottom=30, show_buffer_top=20) @@ -740,14 +733,6 @@ def _on_net_test_press(self) -> None: if not self._root_widget or self._root_widget.transitioning_out: return - # Net-testing requires a signed in v1 account. - if plus.get_v1_account_state() != 'signed_in': - bui.screenmessage( - bui.Lstr(resource='notSignedInErrorText'), color=(1, 0, 0) - ) - bui.getsound('error').play() - return - self._save_state() bui.containerwidget(edit=self._root_widget, transition='out_left') assert bui.app.classic is not None @@ -801,9 +786,8 @@ def _on_modding_tools_button_press(self) -> None: from_window=self._root_widget, ) - def _on_promo_code_press(self) -> None: - from bauiv1lib.promocode import PromoCodeWindow - from bauiv1lib.account import show_sign_in_prompt + def _on_send_info_press(self) -> None: + from bauiv1lib.sendinfo import SendInfoWindow # no-op if our underlying widget is dead or on its way out. if not self._root_widget or self._root_widget.transitioning_out: @@ -812,17 +796,12 @@ def _on_promo_code_press(self) -> None: plus = bui.app.plus assert plus is not None - # We have to be logged in for promo-codes to work. - if plus.get_v1_account_state() != 'signed_in': - show_sign_in_prompt() - return - self._save_state() bui.containerwidget(edit=self._root_widget, transition='out_left') assert bui.app.classic is not None bui.app.ui_v1.set_main_menu_window( - PromoCodeWindow( - origin_widget=self._promo_code_button + SendInfoWindow( + origin_widget=self._send_info_button ).get_root_widget(), from_window=self._root_widget, ) @@ -853,8 +832,8 @@ def _save_state(self) -> None: sel_name = 'VRTest' elif sel == self._net_test_button: sel_name = 'NetTest' - elif sel == self._promo_code_button: - sel_name = 'PromoCode' + elif sel == self._send_info_button: + sel_name = 'SendInfo' elif sel == self._benchmarks_button: sel_name = 'Benchmarks' elif sel == self._kick_idle_players_check_box.widget: @@ -924,8 +903,8 @@ def _restore_state(self) -> None: sel = self._vr_test_button elif sel_name == 'NetTest': sel = self._net_test_button - elif sel_name == 'PromoCode': - sel = self._promo_code_button + elif sel_name == 'SendInfo': + sel = self._send_info_button elif sel_name == 'Benchmarks': sel = self._benchmarks_button elif sel_name == 'KickIdlePlayers': diff --git a/src/assets/ba_data/python/bauiv1lib/settings/nettesting.py b/src/assets/ba_data/python/bauiv1lib/settings/nettesting.py index 7cb824c42..2e7f7e8c6 100644 --- a/src/assets/ba_data/python/bauiv1lib/settings/nettesting.py +++ b/src/assets/ba_data/python/bauiv1lib/settings/nettesting.py @@ -162,6 +162,7 @@ def _done(self) -> None: def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None: # pylint: disable=too-many-statements + # pylint: disable=too-many-branches from efro.util import utc_now @@ -248,8 +249,11 @@ def _print_test_results(call: Callable[[], Any]) -> bool: curv1addr = plus.get_master_server_address(version=1) _print(f'\nUsing V1 address: {curv1addr}') - _print('\nRunning V1 transaction...') - _print_test_results(_test_v1_transaction) + if plus.get_v1_account_state() == 'signed_in': + _print('\nRunning V1 transaction...') + _print_test_results(_test_v1_transaction) + else: + _print('\nSkipping V1 transaction (Not signed into V1).') # V2 ping baseaddr = plus.get_master_server_address(version=2) diff --git a/src/ballistica/base/base.cc b/src/ballistica/base/base.cc index a063ba131..ea251397b 100644 --- a/src/ballistica/base/base.cc +++ b/src/ballistica/base/base.cc @@ -414,9 +414,8 @@ void BaseFeatureSet::OnAppShutdownComplete() { assert(g_core); assert(g_base); - g_core->LifecycleLog("app exiting (main thread)"); - // Flag our own event loop to exit (or ask the OS to if they're managing). + g_core->LifecycleLog("app exiting (main thread)"); if (app_adapter->ManagesMainThreadEventLoop()) { app_adapter->DoExitMainThreadEventLoop(); } else { diff --git a/src/ballistica/base/base.h b/src/ballistica/base/base.h index 00ac62621..93acf16d3 100644 --- a/src/ballistica/base/base.h +++ b/src/ballistica/base/base.h @@ -588,10 +588,12 @@ enum class SysMeshID : uint8_t { }; // Our feature-set's globals. -// Feature-sets should NEVER directly access globals in another feature-set's -// namespace. All functionality we need from other feature-sets should be -// imported into globals in our own namespace. Generally we do this when we -// are initially imported (just as regular Python modules do). +// +// Feature-sets should NEVER directly access globals in another +// feature-set's namespace. All functionality we need from other +// feature-sets should be imported into globals in our own namespace. +// Generally we do this when we are initially imported (just as regular +// Python modules do). extern core::CoreFeatureSet* g_core; extern base::BaseFeatureSet* g_base; @@ -653,8 +655,6 @@ class BaseFeatureSet : public FeatureSetNativeComponent, /// their own event loop). void RunAppToCompletion() override; - // void PrimeAppMainThreadEventPump() override; - auto CurrentContext() -> const ContextRef& { assert(InLogicThread()); // Up to caller to ensure this. return *context_ref; @@ -663,6 +663,7 @@ class BaseFeatureSet : public FeatureSetNativeComponent, /// Utility call to print 'Success!' with a happy sound. /// Safe to call from any thread. void SuccessScreenMessage(); + /// Utility call to print 'Error.' with a beep sound. /// Safe to call from any thread. void ErrorScreenMessage(); diff --git a/src/ballistica/base/graphics/texture/ktx.cc b/src/ballistica/base/graphics/texture/ktx.cc index 8b5dd24f5..aaa1a6d2f 100644 --- a/src/ballistica/base/graphics/texture/ktx.cc +++ b/src/ballistica/base/graphics/texture/ktx.cc @@ -32,7 +32,7 @@ typedef unsigned int GLenum; typedef int GLint; #define KTX_IDENTIFIER_REF \ - { 0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A } + {0xAB, 0x4B, 0x54, 0x58, 0x20, 0x31, 0x31, 0xBB, 0x0D, 0x0A, 0x1A, 0x0A} #define KTX_ENDIAN_REF (0x04030201) #define KTX_ENDIAN_REF_REV (0x01020304) #define KTX_HEADER_SIZE (64) diff --git a/src/ballistica/base/platform/base_platform.cc b/src/ballistica/base/platform/base_platform.cc index f05d2b264..8bb42adae 100644 --- a/src/ballistica/base/platform/base_platform.cc +++ b/src/ballistica/base/platform/base_platform.cc @@ -4,6 +4,11 @@ #include +#if !BA_OSTYPE_WINDOWS +#include +#include +#endif + #include "ballistica/base/base.h" #include "ballistica/base/input/input.h" #include "ballistica/base/logic/logic.h" @@ -229,4 +234,101 @@ void BasePlatform::OpenFileExternally(const std::string& path) { Log(LogLevel::kError, "OpenFileExternally() unimplemented"); } +void BasePlatform::SafeStdinFGetSInit() { +#if BA_OSTYPE_WINDOWS + // Do nothing on Windows. We seem to be ok with blocking reads there. +#else + + // Actually should not be necessary now that we're using poll(). + + // Set stdin up for non-blocking reads. + // int flags = fcntl(STDIN_FILENO, F_GETFL, 0); + // fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); +#endif // BA_OSTYPE_WINDOWS +} + +auto BasePlatform::SafeStdinFGetS(char* s, int n, FILE* iop) -> char* { +#if BA_OSTYPE_WINDOWS + // Use plain old vanilla fgets on Windows since blocking stdin reads + // don't seem to prevent the app from exiting there. + return fgets(s, n, iop); + +#else + + // On unixy platforms, plug in a vanilla fgets() implementation (see + // https://stackoverflow.com/questions/16397832/fgets-implementation-kr) + // but replace the getc() with a custom version of our own that uses + // poll() to periodically check if we should bail while waiting for input. + int c{}; + char* cs{}; + cs = s; + + while (--n > 0 && (c = SmartGetC_(iop)) != EOF) { + if ((*cs++ = c) == '\n') { + break; + } + } + + *cs = '\0'; + return (c == EOF && cs == s) ? NULL : s; +#endif // BA_OSTYPE_WINDOWS +} + +int BasePlatform::SmartGetC_(FILE* stream) { +#if BA_OSTYPE_WINDOWS + return -1; +#else + // Refill our buffer if needed. + while (stdin_buffer_.empty()) { + struct pollfd fds[1]; + + // Initialize the pollfd structure for stdin + fds[0].fd = STDIN_FILENO; + fds[0].events = POLLIN; + + // Let's break approximately 4 times per second to see if we should + // bail. + int ret = poll(fds, 1, 287); + + if (ret == 0) { + // Poll timed out. Check whether we should bail and then do it again. + + // If the app is working on gracefully shutting down OR the engine has + // died (from a fatal error or whatever else), fake an EOF. + if (g_base->logic->shutting_down() || g_core->engine_done()) { + return EOF; + } + + continue; + } + if (ret == -1) { + // Error in poll + perror("poll"); + return EOF; + } + + if (fds[0].revents & POLLIN) { + // stdin is ready for reading. + char buffer[256]; + + // Read characters from stdin + ssize_t bytes_read = read(STDIN_FILENO, buffer, sizeof(buffer)); + + if (bytes_read == -1) { + // Error reading from stdin + perror("read"); + return EOF; + } + + for (int i = 0; i < bytes_read; ++i) { + stdin_buffer_.push_back(buffer[i]); + } + } + } + auto out = stdin_buffer_.front(); + stdin_buffer_.pop_front(); + return out; +#endif // BA_OSTYPE_WINDOWS +} + } // namespace ballistica::base diff --git a/src/ballistica/base/platform/base_platform.h b/src/ballistica/base/platform/base_platform.h index b38716ff7..15d9e38a2 100644 --- a/src/ballistica/base/platform/base_platform.h +++ b/src/ballistica/base/platform/base_platform.h @@ -3,6 +3,8 @@ #ifndef BALLISTICA_BASE_PLATFORM_BASE_PLATFORM_H_ #define BALLISTICA_BASE_PLATFORM_BASE_PLATFORM_H_ +#include + #include "ballistica/base/base.h" #include "ballistica/shared/python/python_ref.h" @@ -33,14 +35,20 @@ class BasePlatform { virtual void OnScreenSizeChange(); virtual void DoApplyAppConfig(); + /// Prepares stdin reading that won't block process exit. + virtual void SafeStdinFGetSInit(); + + /// Equivalent of fgets() but modified to not block process exit. + auto SafeStdinFGetS(char* s, int n, FILE* iop) -> char*; + #pragma mark IN APP PURCHASES -------------------------------------------------- void Purchase(const std::string& item); - // Restore purchases (currently only relevant on Apple platforms). + /// Restore purchases (currently only relevant on Apple platforms). virtual void RestorePurchases(); - // Purchase was ack'ed by the master-server (so can consume). + /// Purchase was ack'ed by the master-server (so can consume). virtual void PurchaseAck(const std::string& purchase, const std::string& order_id); @@ -119,9 +127,12 @@ class BasePlatform { virtual ~BasePlatform(); private: + int SmartGetC_(FILE* stream); + bool ran_base_post_init_{}; PythonRef string_edit_adapter_{}; std::string public_device_uuid_; + std::deque stdin_buffer_; }; } // namespace ballistica::base diff --git a/src/ballistica/base/support/stdio_console.cc b/src/ballistica/base/support/stdio_console.cc index 15263f3e5..521db5a5f 100644 --- a/src/ballistica/base/support/stdio_console.cc +++ b/src/ballistica/base/support/stdio_console.cc @@ -7,6 +7,7 @@ #include "ballistica/base/app_adapter/app_adapter.h" #include "ballistica/base/app_mode/app_mode.h" #include "ballistica/base/logic/logic.h" +#include "ballistica/base/platform/base_platform.h" #include "ballistica/base/support/context.h" #include "ballistica/core/platform/core_platform.h" #include "ballistica/shared/foundation/event_loop.h" @@ -18,10 +19,10 @@ namespace ballistica::base { StdioConsole::StdioConsole() = default; void StdioConsole::Start() { - g_base->app_adapter->PushMainThreadCall([this] { StartInMainThread(); }); + g_base->app_adapter->PushMainThreadCall([this] { StartInMainThread_(); }); } -void StdioConsole::StartInMainThread() { +void StdioConsole::StartInMainThread_() { assert(g_core && g_core->InMainThread()); // Spin up our thread. @@ -32,10 +33,12 @@ void StdioConsole::StartInMainThread() { event_loop()->PushCall([this] { bool stdin_is_terminal = g_core->platform->is_stdin_a_terminal(); + g_base->platform->SafeStdinFGetSInit(); + while (true) { - // Print a prompt if we're a tty. - // We send this to the logic thread so it happens AFTER the - // results of the last script-command message we may have just sent. + // Print a prompt if we're a tty. We send this to the logic thread so + // it happens AFTER the results of the last script-command message we + // may have just sent. if (stdin_is_terminal) { g_base->logic->event_loop()->PushCall([] { if (!g_base->logic->shutting_down()) { @@ -45,62 +48,57 @@ void StdioConsole::StartInMainThread() { }); } - // Was using getline, but switched to - // new fgets based approach (more portable). - // Ideally at some point we can wire up to the Python api to get behavior - // more like the actual Python command line. + // Was using getline, but switched to new fgets based approach (more + // portable). Ideally at some point we can wire up to the Python api + // to get behavior more like the actual Python command line. char buffer[4096]; - char* val = fgets(buffer, sizeof(buffer), stdin); + char* val; + + // Use our fancy safe version of fgets(); on some platforms this will + // return a fake EOF once the app/engine starts going down. This + // avoids some scenarios where regular blocking fgets() prevents the + // process from exiting (until they press Ctrl-D in the terminal). + if (explicit_bool(true)) { + val = g_base->platform->SafeStdinFGetS(buffer, sizeof(buffer), stdin); + } else { + val = fgets(buffer, sizeof(buffer), stdin); + } if (val) { - if (val == std::string("@clear\n")) { - int retval{-1}; - if (g_buildconfig.ostype_macos() || g_buildconfig.ostype_linux()) { - // Attempt to run actual clear command on unix-y systems to - // plop our prompt back at the top of the screen. - retval = core::CorePlatform::System("clear"); - } - // As a fallback, just spit out a bunch of newlines. - if (retval != 0) { - std::string space; - for (int i = 0; i < 100; ++i) { - space += "\n"; - } - printf("%s", space.c_str()); - } - continue; - } pending_input_ += val; if (!pending_input_.empty() && pending_input_[pending_input_.size() - 1] == '\n') { // Get rid of the last newline and ship it to the game. pending_input_.pop_back(); - PushCommand(pending_input_); + + // Handle special cases ourself. + if (pending_input_ == std::string("@clear")) { + Clear_(); + } else { + // Otherwise ship it off to the engine to run. + PushCommand_(pending_input_); + } pending_input_.clear(); } } else { - // At the moment we bail on any read error. - if (feof(stdin)) { - if (stdin_is_terminal) { - // Ok this is strange: on windows consoles, it seems that Ctrl-C in - // a terminal immediately closes our stdin even if we catch the - // interrupt, and then our python interrupt handler runs a moment - // later. This means we wind up telling the user that EOF was - // reached and they should Ctrl-C to quit right after they've hit - // Ctrl-C to quit. To hopefully avoid this, let's hold off on the - // print for a second and see if a shutdown has begun first. - // (or, more likely, just never print because the app has exited). - if (g_buildconfig.windows_console_build()) { - core::CorePlatform::SleepMillisecs(250); - } - if (!g_base->logic->shutting_down()) { - printf("Stdin EOF reached. Use Ctrl-C to quit.\n"); - fflush(stdout); - } + // Bail on any error (could be actual EOF or one of our fake ones). + if (stdin_is_terminal) { + // Ok this is strange: on windows consoles, it seems that Ctrl-C + // in a terminal immediately closes our stdin even if we catch + // the interrupt, and then our Python interrupt handler runs a + // moment later. This means we wind up telling the user that EOF + // was reached and they should Ctrl-C to quit right after + // they've hit Ctrl-C to quit. To hopefully avoid this, let's + // hold off on the print for a second and see if a shutdown has + // begun first. (or, more likely, just never print because the + // app has exited). + if (g_buildconfig.windows_console_build()) { + core::CorePlatform::SleepMillisecs(250); + } + if (!g_base->logic->shutting_down()) { + printf("Stdin EOF reached. Use Ctrl-C to quit.\n"); + fflush(stdout); } - } else { - Log(LogLevel::kError, "StdioConsole got non-eof error reading stdin: " - + std::to_string(ferror(stdin))); } break; } @@ -108,7 +106,24 @@ void StdioConsole::StartInMainThread() { }); } -void StdioConsole::PushCommand(const std::string& command) { +void StdioConsole::Clear_() { + int retval{-1}; + if (g_buildconfig.ostype_macos() || g_buildconfig.ostype_linux()) { + // Attempt to run actual clear command on unix-y systems to plop + // our prompt back at the top of the screen. + retval = core::CorePlatform::System("clear"); + } + // As a fallback, just spit out a bunch of newlines. + if (retval != 0) { + std::string space; + for (int i = 0; i < 100; ++i) { + space += "\n"; + } + printf("%s", space.c_str()); + } +} + +void StdioConsole::PushCommand_(const std::string& command) { g_base->logic->event_loop()->PushCall([command] { // These are always run in whichever context is 'visible'. ScopedSetContext ssc(g_base->app_mode()->GetForegroundContext()); diff --git a/src/ballistica/base/support/stdio_console.h b/src/ballistica/base/support/stdio_console.h index f2ca1f373..14bcef1d5 100644 --- a/src/ballistica/base/support/stdio_console.h +++ b/src/ballistica/base/support/stdio_console.h @@ -3,6 +3,8 @@ #ifndef BALLISTICA_BASE_SUPPORT_STDIO_CONSOLE_H_ #define BALLISTICA_BASE_SUPPORT_STDIO_CONSOLE_H_ +#include + #include "ballistica/shared/ballistica.h" namespace ballistica::base { @@ -14,8 +16,9 @@ class StdioConsole { auto event_loop() const -> EventLoop* { return event_loop_; } private: - void StartInMainThread(); - void PushCommand(const std::string& command); + void StartInMainThread_(); + void PushCommand_(const std::string& command); + void Clear_(); EventLoop* event_loop_{}; std::string pending_input_; }; diff --git a/src/ballistica/classic/python/classic_python.cc b/src/ballistica/classic/python/classic_python.cc index d3c6aa9e4..70769f9cb 100644 --- a/src/ballistica/classic/python/classic_python.cc +++ b/src/ballistica/classic/python/classic_python.cc @@ -92,15 +92,35 @@ auto ClassicPython::GetControllerFloatValue( auto ClassicPython::BuildPublicPartyStateVal() -> PyObject* { auto* appmode = scene_v1::SceneV1AppMode::GetActiveOrThrow(); + + auto&& public_ipv4 = appmode->public_party_public_address_ipv4(); + PyObject* ipv4obj; + if (public_ipv4.has_value()) { + ipv4obj = PyUnicode_FromString(public_ipv4->c_str()); + } else { + ipv4obj = Py_None; + Py_INCREF(ipv4obj); + } + + auto&& public_ipv6 = appmode->public_party_public_address_ipv6(); + PyObject* ipv6obj; + if (public_ipv6.has_value()) { + ipv6obj = PyUnicode_FromString(public_ipv6->c_str()); + } else { + ipv6obj = Py_None; + Py_INCREF(ipv6obj); + } + return Py_BuildValue( - "(iiiiisssi)", static_cast(appmode->public_party_enabled()), + "(iiiiisssiOO)", static_cast(appmode->public_party_enabled()), appmode->public_party_size(), appmode->public_party_max_size(), appmode->public_party_player_count(), appmode->public_party_max_player_count(), appmode->public_party_name().c_str(), appmode->public_party_min_league().c_str(), appmode->public_party_stats_url().c_str(), - static_cast(appmode->public_party_queue_enabled())); + static_cast(appmode->public_party_queue_enabled()), ipv4obj, + ipv6obj); } } // namespace ballistica::classic diff --git a/src/ballistica/core/core.h b/src/ballistica/core/core.h index 7f42dc96f..3c0437798 100644 --- a/src/ballistica/core/core.h +++ b/src/ballistica/core/core.h @@ -150,6 +150,13 @@ class CoreFeatureSet { /// Should be called by a thread before it exits. void UnregisterThread(); + /// A bool set just before returning from main or calling exit() or + /// whatever is intended to be the last gasp of life for the binary. This + /// can be polled periodically by background threads that may otherwise + /// keep the process from exiting. + auto engine_done() const { return engine_done_; } + void set_engine_done() { engine_done_ = true; } + // Subsystems. CorePython* const python; CorePlatform* const platform; @@ -195,6 +202,7 @@ class CoreFeatureSet { bool have_ba_env_vals_{}; bool vr_mode_{}; bool using_custom_app_python_dir_{}; + bool engine_done_{}; std::thread::id main_thread_id_{}; CoreConfig core_config_; diff --git a/src/ballistica/scene_v1/python/methods/python_methods_networking.cc b/src/ballistica/scene_v1/python/methods/python_methods_networking.cc index 699d605ec..c66059283 100644 --- a/src/ballistica/scene_v1/python/methods/python_methods_networking.cc +++ b/src/ballistica/scene_v1/python/methods/python_methods_networking.cc @@ -216,6 +216,74 @@ static PyMethodDef PySetPublicPartyQueueEnabledDef = { "(internal)", }; +// ----------------- set_public_party_public_address_ipv4 ---------------------- + +static auto PySetPublicPartyPublicAddressIPV4(PyObject* self, PyObject* args, + PyObject* keywds) -> PyObject* { + BA_PYTHON_TRY; + PyObject* address_obj; + static const char* kwlist[] = {"address", nullptr}; + if (!PyArg_ParseTupleAndKeywords(args, keywds, "O", + const_cast(kwlist), &address_obj)) { + return nullptr; + } + auto* appmode = SceneV1AppMode::GetActiveOrThrow(); + + // The call expects an empty string for the no-url option. + + std::optional address{}; + if (address_obj != Py_None) { + address = Python::GetPyString(address_obj); + } + appmode->set_public_party_public_address_ipv4(address); + Py_RETURN_NONE; + BA_PYTHON_CATCH; +} + +static PyMethodDef PySetPublicPartyPublicAddressIPV4Def = { + "set_public_party_public_address_ipv4", // name + (PyCFunction)PySetPublicPartyPublicAddressIPV4, // method + METH_VARARGS | METH_KEYWORDS, // flags + + "set_public_party_public_address_ipv4(address: str | None) -> None\n" + "\n" + "(internal)", +}; + +// ----------------- set_public_party_public_address_ipv6 ---------------------- + +static auto PySetPublicPartyPublicAddressIPV6(PyObject* self, PyObject* args, + PyObject* keywds) -> PyObject* { + BA_PYTHON_TRY; + PyObject* address_obj; + static const char* kwlist[] = {"address", nullptr}; + if (!PyArg_ParseTupleAndKeywords(args, keywds, "O", + const_cast(kwlist), &address_obj)) { + return nullptr; + } + auto* appmode = SceneV1AppMode::GetActiveOrThrow(); + + // The call expects an empty string for the no-url option. + + std::optional address{}; + if (address_obj != Py_None) { + address = Python::GetPyString(address_obj); + } + appmode->set_public_party_public_address_ipv6(address); + Py_RETURN_NONE; + BA_PYTHON_CATCH; +} + +static PyMethodDef PySetPublicPartyPublicAddressIPV6Def = { + "set_public_party_public_address_ipv6", // name + (PyCFunction)PySetPublicPartyPublicAddressIPV6, // method + METH_VARARGS | METH_KEYWORDS, // flags + + "set_public_party_public_address_ipv6(address: str | None) -> None\n" + "\n" + "(internal)", +}; + // ------------------------ set_authenticate_clients --------------------------- static auto PySetAuthenticateClients(PyObject* self, PyObject* args, @@ -834,6 +902,8 @@ auto PythonMethodsNetworking::GetMethods() -> std::vector { PyGetConnectionToHostInfo2Def, PyClientInfoQueryResponseDef, PyConnectToPartyDef, + PySetPublicPartyPublicAddressIPV4Def, + PySetPublicPartyPublicAddressIPV6Def, PySetAuthenticateClientsDef, PySetAdminsDef, PySetEnableDefaultKickVotingDef, diff --git a/src/ballistica/scene_v1/support/scene_v1_app_mode.h b/src/ballistica/scene_v1/support/scene_v1_app_mode.h index 0e47e4fdc..45bcd8b77 100644 --- a/src/ballistica/scene_v1/support/scene_v1_app_mode.h +++ b/src/ballistica/scene_v1/support/scene_v1_app_mode.h @@ -191,6 +191,22 @@ class SceneV1AppMode : public base::AppMode { return host_protocol_version_; } + auto public_party_public_address_ipv4() const { + return public_party_public_address_ipv4_; + } + void set_public_party_public_address_ipv4( + const std::optional& val) { + public_party_public_address_ipv4_ = val; + } + + auto public_party_public_address_ipv6() const { + return public_party_public_address_ipv6_; + } + void set_public_party_public_address_ipv6( + const std::optional& val) { + public_party_public_address_ipv6_ = val; + } + private: SceneV1AppMode(); void PruneScanResults_(); @@ -264,6 +280,8 @@ class SceneV1AppMode : public base::AppMode { std::list > banned_players_; std::optional idle_exit_minutes_{}; std::optional internal_music_play_id_{}; + std::optional public_party_public_address_ipv4_{}; + std::optional public_party_public_address_ipv6_{}; }; } // namespace ballistica::scene_v1 diff --git a/src/ballistica/shared/ballistica.cc b/src/ballistica/shared/ballistica.cc index 938deca48..a8492899e 100644 --- a/src/ballistica/shared/ballistica.cc +++ b/src/ballistica/shared/ballistica.cc @@ -39,7 +39,7 @@ auto main(int argc, char** argv) -> int { namespace ballistica { // These are set automatically via script; don't modify them here. -const int kEngineBuildNumber = 21827; +const int kEngineBuildNumber = 21848; const char* kEngineVersion = "1.7.35"; const int kEngineApiVersion = 8; @@ -79,6 +79,10 @@ auto MonolithicMain(const core::CoreConfig& core_config) -> int { bool success = PythonCommand(*l_core->core_config().call_command, "") .Exec(true, nullptr, nullptr); + + // Let anyone interested know we're trying to go down NOW. + l_core->set_engine_done(); + exit(success ? 0 : 1); } @@ -141,6 +145,8 @@ auto MonolithicMain(const core::CoreConfig& core_config) -> int { if (l_base->AppManagesMainThreadEventLoop()) { // In environments where we control the event loop, do that. l_base->RunAppToCompletion(); + // Let anyone interested know we're trying to go down NOW. + l_core->set_engine_done(); } else { // If the environment is managing events, we now simply return and let // it feed us those events. @@ -171,6 +177,10 @@ auto MonolithicMain(const core::CoreConfig& core_config) -> int { // If it's not been handled, take the app down ourself. if (!handled) { + // Let anyone interested know we're trying to go down NOW. + if (l_core) { + l_core->set_engine_done(); + } if (try_to_exit_cleanly) { exit(1); } else { diff --git a/src/ballistica/shared/foundation/fatal_error.cc b/src/ballistica/shared/foundation/fatal_error.cc index 5eca6f17c..ab7208602 100644 --- a/src/ballistica/shared/foundation/fatal_error.cc +++ b/src/ballistica/shared/foundation/fatal_error.cc @@ -205,6 +205,13 @@ auto FatalError::HandleFatalError(bool exit_cleanly, if (!in_top_level_exception_handler) { if (exit_cleanly) { Logging::EmitLog("root", LogLevel::kCritical, "Calling exit(1)..."); + + // Inform anyone who cares that the engine is going down NOW. + // This value can be polled by threads that may otherwise block us + // from exiting cleanly. As an example, I've seen recent linux builds + // hang on exit because a bg thread is blocked in a read of stdin. + g_core->set_engine_done(); + exit(1); } else { Logging::EmitLog("root", LogLevel::kCritical, "Calling abort()..."); diff --git a/src/external/httprequest/httprequest.hpp b/src/external/httprequest/httprequest.hpp index 11869512a..e34dc1c18 100644 --- a/src/external/httprequest/httprequest.hpp +++ b/src/external/httprequest/httprequest.hpp @@ -8,7 +8,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -16,486 +19,1159 @@ #include #include #include +#include #include -#ifdef _WIN32 +#if defined(_WIN32) || defined(__CYGWIN__) # pragma push_macro("WIN32_LEAN_AND_MEAN") # pragma push_macro("NOMINMAX") # ifndef WIN32_LEAN_AND_MEAN # define WIN32_LEAN_AND_MEAN -# endif +# endif // WIN32_LEAN_AND_MEAN # ifndef NOMINMAX # define NOMINMAX -# endif +# endif // NOMINMAX # include # if _WIN32_WINNT < _WIN32_WINNT_WINXP -char* strdup(const char* src) -{ - std::size_t length = 0; - while (src[length]) ++length; - char* result = static_cast(malloc(length + 1)); - char* p = result; - while (*src) *p++ = *src++; - *p = '\0'; - return result; -} +extern "C" char *_strdup(const char *strSource); +# define strdup _strdup # include -# endif +# endif // _WIN32_WINNT < _WIN32_WINNT_WINXP # include # pragma pop_macro("WIN32_LEAN_AND_MEAN") # pragma pop_macro("NOMINMAX") #else -# include +# include +# include # include # include +# include +# include +# include # include -# include -#endif +#endif // defined(_WIN32) || defined(__CYGWIN__) namespace http { class RequestError final: public std::logic_error { public: - explicit RequestError(const char* str): std::logic_error(str) {} - explicit RequestError(const std::string& str): std::logic_error(str) {} + using logic_error::logic_error; + using logic_error::operator=; }; class ResponseError final: public std::runtime_error { public: - explicit ResponseError(const char* str): std::runtime_error(str) {} - explicit ResponseError(const std::string& str): std::runtime_error(str) {} + using runtime_error::runtime_error; + using runtime_error::operator=; }; enum class InternetProtocol: std::uint8_t { - V4, - V6 + v4, + v6 + }; + + struct Uri final + { + std::string scheme; + std::string user; + std::string password; + std::string host; + std::string port; + std::string path; + std::string query; + std::string fragment; + }; + + struct Version final + { + uint16_t major; + uint16_t minor; + }; + + struct Status final + { + // RFC 7231, 6. Response Status Codes + enum Code: std::uint16_t + { + Continue = 100, + SwitchingProtocol = 101, + Processing = 102, + EarlyHints = 103, + + Ok = 200, + Created = 201, + Accepted = 202, + NonAuthoritativeInformation = 203, + NoContent = 204, + ResetContent = 205, + PartialContent = 206, + MultiStatus = 207, + AlreadyReported = 208, + ImUsed = 226, + + MultipleChoice = 300, + MovedPermanently = 301, + Found = 302, + SeeOther = 303, + NotModified = 304, + UseProxy = 305, + TemporaryRedirect = 307, + PermanentRedirect = 308, + + BadRequest = 400, + Unauthorized = 401, + PaymentRequired = 402, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + NotAcceptable = 406, + ProxyAuthenticationRequired = 407, + RequestTimeout = 408, + Conflict = 409, + Gone = 410, + LengthRequired = 411, + PreconditionFailed = 412, + PayloadTooLarge = 413, + UriTooLong = 414, + UnsupportedMediaType = 415, + RangeNotSatisfiable = 416, + ExpectationFailed = 417, + MisdirectedRequest = 421, + UnprocessableEntity = 422, + Locked = 423, + FailedDependency = 424, + TooEarly = 425, + UpgradeRequired = 426, + PreconditionRequired = 428, + TooManyRequests = 429, + RequestHeaderFieldsTooLarge = 431, + UnavailableForLegalReasons = 451, + + InternalServerError = 500, + NotImplemented = 501, + BadGateway = 502, + ServiceUnavailable = 503, + GatewayTimeout = 504, + HttpVersionNotSupported = 505, + VariantAlsoNegotiates = 506, + InsufficientStorage = 507, + LoopDetected = 508, + NotExtended = 510, + NetworkAuthenticationRequired = 511 + }; + + Version version; + std::uint16_t code; + std::string reason; + }; + + using HeaderField = std::pair; + using HeaderFields = std::vector; + + struct Response final + { + Status status; + HeaderFields headerFields; + std::vector body; }; inline namespace detail { -#ifdef _WIN32 - class WinSock final +#if defined(_WIN32) || defined(__CYGWIN__) + namespace winsock { - public: - WinSock() + class ErrorCategory final: public std::error_category { - WSADATA wsaData; - const auto error = WSAStartup(MAKEWORD(2, 2), &wsaData); - if (error != 0) - throw std::system_error(error, std::system_category(), "WSAStartup failed"); + public: + const char* name() const noexcept override + { + return "Windows Sockets API"; + } - if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) + std::string message(const int condition) const override { - WSACleanup(); - throw std::runtime_error("Invalid WinSock version"); + switch (condition) + { + case WSA_INVALID_HANDLE: return "Specified event object handle is invalid"; + case WSA_NOT_ENOUGH_MEMORY: return "Insufficient memory available"; + case WSA_INVALID_PARAMETER: return "One or more parameters are invalid"; + case WSA_OPERATION_ABORTED: return "Overlapped operation aborted"; + case WSA_IO_INCOMPLETE: return "Overlapped I/O event object not in signaled state"; + case WSA_IO_PENDING: return "Overlapped operations will complete later"; + case WSAEINTR: return "Interrupted function call"; + case WSAEBADF: return "File handle is not valid"; + case WSAEACCES: return "Permission denied"; + case WSAEFAULT: return "Bad address"; + case WSAEINVAL: return "Invalid argument"; + case WSAEMFILE: return "Too many open files"; + case WSAEWOULDBLOCK: return "Resource temporarily unavailable"; + case WSAEINPROGRESS: return "Operation now in progress"; + case WSAEALREADY: return "Operation already in progress"; + case WSAENOTSOCK: return "Socket operation on nonsocket"; + case WSAEDESTADDRREQ: return "Destination address required"; + case WSAEMSGSIZE: return "Message too long"; + case WSAEPROTOTYPE: return "Protocol wrong type for socket"; + case WSAENOPROTOOPT: return "Bad protocol option"; + case WSAEPROTONOSUPPORT: return "Protocol not supported"; + case WSAESOCKTNOSUPPORT: return "Socket type not supported"; + case WSAEOPNOTSUPP: return "Operation not supported"; + case WSAEPFNOSUPPORT: return "Protocol family not supported"; + case WSAEAFNOSUPPORT: return "Address family not supported by protocol family"; + case WSAEADDRINUSE: return "Address already in use"; + case WSAEADDRNOTAVAIL: return "Cannot assign requested address"; + case WSAENETDOWN: return "Network is down"; + case WSAENETUNREACH: return "Network is unreachable"; + case WSAENETRESET: return "Network dropped connection on reset"; + case WSAECONNABORTED: return "Software caused connection abort"; + case WSAECONNRESET: return "Connection reset by peer"; + case WSAENOBUFS: return "No buffer space available"; + case WSAEISCONN: return "Socket is already connected"; + case WSAENOTCONN: return "Socket is not connected"; + case WSAESHUTDOWN: return "Cannot send after socket shutdown"; + case WSAETOOMANYREFS: return "Too many references"; + case WSAETIMEDOUT: return "Connection timed out"; + case WSAECONNREFUSED: return "Connection refused"; + case WSAELOOP: return "Cannot translate name"; + case WSAENAMETOOLONG: return "Name too long"; + case WSAEHOSTDOWN: return "Host is down"; + case WSAEHOSTUNREACH: return "No route to host"; + case WSAENOTEMPTY: return "Directory not empty"; + case WSAEPROCLIM: return "Too many processes"; + case WSAEUSERS: return "User quota exceeded"; + case WSAEDQUOT: return "Disk quota exceeded"; + case WSAESTALE: return "Stale file handle reference"; + case WSAEREMOTE: return "Item is remote"; + case WSASYSNOTREADY: return "Network subsystem is unavailable"; + case WSAVERNOTSUPPORTED: return "Winsock.dll version out of range"; + case WSANOTINITIALISED: return "Successful WSAStartup not yet performed"; + case WSAEDISCON: return "Graceful shutdown in progress"; + case WSAENOMORE: return "No more results"; + case WSAECANCELLED: return "Call has been canceled"; + case WSAEINVALIDPROCTABLE: return "Procedure call table is invalid"; + case WSAEINVALIDPROVIDER: return "Service provider is invalid"; + case WSAEPROVIDERFAILEDINIT: return "Service provider failed to initialize"; + case WSASYSCALLFAILURE: return "System call failure"; + case WSASERVICE_NOT_FOUND: return "Service not found"; + case WSATYPE_NOT_FOUND: return "Class type not found"; + case WSA_E_NO_MORE: return "No more results"; + case WSA_E_CANCELLED: return "Call was canceled"; + case WSAEREFUSED: return "Database query was refused"; + case WSAHOST_NOT_FOUND: return "Host not found"; + case WSATRY_AGAIN: return "Nonauthoritative host not found"; + case WSANO_RECOVERY: return "This is a nonrecoverable error"; + case WSANO_DATA: return "Valid name, no data record of requested type"; + case WSA_QOS_RECEIVERS: return "QoS receivers"; + case WSA_QOS_SENDERS: return "QoS senders"; + case WSA_QOS_NO_SENDERS: return "No QoS senders"; + case WSA_QOS_NO_RECEIVERS: return "QoS no receivers"; + case WSA_QOS_REQUEST_CONFIRMED: return "QoS request confirmed"; + case WSA_QOS_ADMISSION_FAILURE: return "QoS admission error"; + case WSA_QOS_POLICY_FAILURE: return "QoS policy failure"; + case WSA_QOS_BAD_STYLE: return "QoS bad style"; + case WSA_QOS_BAD_OBJECT: return "QoS bad object"; + case WSA_QOS_TRAFFIC_CTRL_ERROR: return "QoS traffic control error"; + case WSA_QOS_GENERIC_ERROR: return "QoS generic error"; + case WSA_QOS_ESERVICETYPE: return "QoS service type error"; + case WSA_QOS_EFLOWSPEC: return "QoS flowspec error"; + case WSA_QOS_EPROVSPECBUF: return "Invalid QoS provider buffer"; + case WSA_QOS_EFILTERSTYLE: return "Invalid QoS filter style"; + case WSA_QOS_EFILTERTYPE: return "Invalid QoS filter type"; + case WSA_QOS_EFILTERCOUNT: return "Incorrect QoS filter count"; + case WSA_QOS_EOBJLENGTH: return "Invalid QoS object length"; + case WSA_QOS_EFLOWCOUNT: return "Incorrect QoS flow count"; + case WSA_QOS_EUNKOWNPSOBJ: return "Unrecognized QoS object"; + case WSA_QOS_EPOLICYOBJ: return "Invalid QoS policy object"; + case WSA_QOS_EFLOWDESC: return "Invalid QoS flow descriptor"; + case WSA_QOS_EPSFLOWSPEC: return "Invalid QoS provider-specific flowspec"; + case WSA_QOS_EPSFILTERSPEC: return "Invalid QoS provider-specific filterspec"; + case WSA_QOS_ESDMODEOBJ: return "Invalid QoS shape discard mode object"; + case WSA_QOS_ESHAPERATEOBJ: return "Invalid QoS shaping rate object"; + case WSA_QOS_RESERVED_PETYPE: return "Reserved policy QoS element type"; + default: return "Unknown error (" + std::to_string(condition) + ")"; + } } + }; - started = true; - } + inline const ErrorCategory errorCategory; - ~WinSock() + class Api final { - if (started) WSACleanup(); - } + public: + Api() + { + WSADATA wsaData; + const auto error = WSAStartup(MAKEWORD(2, 2), &wsaData); + if (error != 0) + throw std::system_error{error, errorCategory, "WSAStartup failed"}; - WinSock(WinSock&& other) noexcept: - started(other.started) - { - other.started = false; - } + if (LOBYTE(wsaData.wVersion) != 2 || HIBYTE(wsaData.wVersion) != 2) + { + WSACleanup(); + throw std::runtime_error{"Invalid WinSock version"}; + } - WinSock& operator=(WinSock&& other) noexcept - { - if (&other == this) return *this; - if (started) WSACleanup(); - started = other.started; - other.started = false; - return *this; - } + started = true; + } - private: - bool started = false; - }; -#endif + ~Api() + { + if (started) WSACleanup(); + } - inline int getLastError() noexcept - { -#ifdef _WIN32 - return WSAGetLastError(); -#else - return errno; -#endif + Api(Api&& other) noexcept: + started{std::exchange(other.started, false)} + { + } + + Api& operator=(Api&& other) noexcept + { + if (&other == this) return *this; + if (started) WSACleanup(); + started = std::exchange(other.started, false); + return *this; + } + + private: + bool started = false; + }; } +#endif // defined(_WIN32) || defined(__CYGWIN__) - constexpr int getAddressFamily(InternetProtocol internetProtocol) + constexpr int getAddressFamily(const InternetProtocol internetProtocol) { - return (internetProtocol == InternetProtocol::V4) ? AF_INET : - (internetProtocol == InternetProtocol::V6) ? AF_INET6 : - throw RequestError("Unsupported protocol"); + return (internetProtocol == InternetProtocol::v4) ? AF_INET : + (internetProtocol == InternetProtocol::v6) ? AF_INET6 : + throw RequestError{"Unsupported protocol"}; } -#ifdef _WIN32 - constexpr auto closeSocket = closesocket; -#else - constexpr auto closeSocket = close; -#endif - -#if defined(__APPLE__) || defined(_WIN32) - constexpr int noSignal = 0; -#else - constexpr int noSignal = MSG_NOSIGNAL; -#endif - class Socket final { public: -#ifdef _WIN32 +#if defined(_WIN32) || defined(__CYGWIN__) using Type = SOCKET; static constexpr Type invalid = INVALID_SOCKET; #else using Type = int; static constexpr Type invalid = -1; -#endif +#endif // defined(_WIN32) || defined(__CYGWIN__) - explicit Socket(InternetProtocol internetProtocol): - endpoint(socket(getAddressFamily(internetProtocol), SOCK_STREAM, IPPROTO_TCP)) + explicit Socket(const InternetProtocol internetProtocol): + endpoint{socket(getAddressFamily(internetProtocol), SOCK_STREAM, IPPROTO_TCP)} { if (endpoint == invalid) - throw std::system_error(getLastError(), std::system_category(), "Failed to create socket"); +#if defined(_WIN32) || defined(__CYGWIN__) + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to create socket"}; +#else + throw std::system_error{errno, std::system_category(), "Failed to create socket"}; +#endif // defined(_WIN32) || defined(__CYGWIN__) + +#if defined(_WIN32) || defined(__CYGWIN__) + ULONG mode = 1; + if (ioctlsocket(endpoint, FIONBIO, &mode) == SOCKET_ERROR) + { + close(); + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to get socket flags"}; + } +#else + const auto flags = fcntl(endpoint, F_GETFL); + if (flags == -1) + { + close(); + throw std::system_error{errno, std::system_category(), "Failed to get socket flags"}; + } -#if defined(__APPLE__) + if (fcntl(endpoint, F_SETFL, flags | O_NONBLOCK) == -1) + { + close(); + throw std::system_error{errno, std::system_category(), "Failed to set socket flags"}; + } +#endif // defined(_WIN32) || defined(__CYGWIN__) + +#ifdef __APPLE__ const int value = 1; if (setsockopt(endpoint, SOL_SOCKET, SO_NOSIGPIPE, &value, sizeof(value)) == -1) - throw std::system_error(getLastError(), std::system_category(), "Failed to set socket option"); -#endif + { + close(); + throw std::system_error{errno, std::system_category(), "Failed to set socket option"}; + } +#endif // __APPLE__ } ~Socket() { - if (endpoint != invalid) closeSocket(endpoint); + if (endpoint != invalid) close(); } Socket(Socket&& other) noexcept: - endpoint(other.endpoint) + endpoint{std::exchange(other.endpoint, invalid)} { - other.endpoint = invalid; } Socket& operator=(Socket&& other) noexcept { if (&other == this) return *this; - if (endpoint != invalid) closeSocket(endpoint); - endpoint = other.endpoint; - other.endpoint = invalid; + if (endpoint != invalid) close(); + endpoint = std::exchange(other.endpoint, invalid); return *this; } - void connect(const struct sockaddr* address, socklen_t addressSize) + void connect(const struct sockaddr* address, const socklen_t addressSize, const std::int64_t timeout) { +#if defined(_WIN32) || defined(__CYGWIN__) auto result = ::connect(endpoint, address, addressSize); - -#ifdef _WIN32 while (result == -1 && WSAGetLastError() == WSAEINTR) result = ::connect(endpoint, address, addressSize); + + if (result == -1) + { + if (WSAGetLastError() == WSAEWOULDBLOCK) + { + select(SelectType::write, timeout); + + char socketErrorPointer[sizeof(int)]; + socklen_t optionLength = sizeof(socketErrorPointer); + if (getsockopt(endpoint, SOL_SOCKET, SO_ERROR, socketErrorPointer, &optionLength) == SOCKET_ERROR) + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to get socket option"}; + + int socketError; + std::memcpy(&socketError, socketErrorPointer, sizeof(socketErrorPointer)); + + if (socketError != 0) + throw std::system_error{socketError, winsock::errorCategory, "Failed to connect"}; + } + else + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to connect"}; + } #else + auto result = ::connect(endpoint, address, addressSize); while (result == -1 && errno == EINTR) result = ::connect(endpoint, address, addressSize); -#endif if (result == -1) - throw std::system_error(getLastError(), std::system_category(), "Failed to connect"); + { + if (errno == EINPROGRESS) + { + select(SelectType::write, timeout); + + int socketError; + socklen_t optionLength = sizeof(socketError); + if (getsockopt(endpoint, SOL_SOCKET, SO_ERROR, &socketError, &optionLength) == -1) + throw std::system_error{errno, std::system_category(), "Failed to get socket option"}; + + if (socketError != 0) + throw std::system_error{socketError, std::system_category(), "Failed to connect"}; + } + else + throw std::system_error{errno, std::system_category(), "Failed to connect"}; + } +#endif // defined(_WIN32) || defined(__CYGWIN__) } - size_t send(const void* buffer, size_t length, int flags) + std::size_t send(const void* buffer, const std::size_t length, const std::int64_t timeout) { -#ifdef _WIN32 + select(SelectType::write, timeout); +#if defined(_WIN32) || defined(__CYGWIN__) auto result = ::send(endpoint, reinterpret_cast(buffer), - static_cast(length), flags); + static_cast(length), 0); - while (result == -1 && WSAGetLastError() == WSAEINTR) + while (result == SOCKET_ERROR && WSAGetLastError() == WSAEINTR) result = ::send(endpoint, reinterpret_cast(buffer), - static_cast(length), flags); + static_cast(length), 0); + if (result == SOCKET_ERROR) + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to send data"}; #else auto result = ::send(endpoint, reinterpret_cast(buffer), - length, flags); + length, noSignal); while (result == -1 && errno == EINTR) result = ::send(endpoint, reinterpret_cast(buffer), - length, flags); -#endif - if (result == -1) - throw std::system_error(getLastError(), std::system_category(), "Failed to send data"); + length, noSignal); - return static_cast(result); + if (result == -1) + throw std::system_error{errno, std::system_category(), "Failed to send data"}; +#endif // defined(_WIN32) || defined(__CYGWIN__) + return static_cast(result); } - size_t recv(void* buffer, size_t length, int flags) + std::size_t recv(void* buffer, const std::size_t length, const std::int64_t timeout) { -#ifdef _WIN32 + select(SelectType::read, timeout); +#if defined(_WIN32) || defined(__CYGWIN__) auto result = ::recv(endpoint, reinterpret_cast(buffer), - static_cast(length), flags); + static_cast(length), 0); - while (result == -1 && WSAGetLastError() == WSAEINTR) + while (result == SOCKET_ERROR && WSAGetLastError() == WSAEINTR) result = ::recv(endpoint, reinterpret_cast(buffer), - static_cast(length), flags); + static_cast(length), 0); + + if (result == SOCKET_ERROR) + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to read data"}; #else auto result = ::recv(endpoint, reinterpret_cast(buffer), - length, flags); + length, noSignal); while (result == -1 && errno == EINTR) result = ::recv(endpoint, reinterpret_cast(buffer), - length, flags); -#endif + length, noSignal); + if (result == -1) - throw std::system_error(getLastError(), std::system_category(), "Failed to read data"); + throw std::system_error{errno, std::system_category(), "Failed to read data"}; +#endif // defined(_WIN32) || defined(__CYGWIN__) + return static_cast(result); + } - return static_cast(result); + private: + enum class SelectType + { + read, + write + }; + + void select(const SelectType type, const std::int64_t timeout) + { + fd_set descriptorSet; + FD_ZERO(&descriptorSet); + FD_SET(endpoint, &descriptorSet); + +#if defined(_WIN32) || defined(__CYGWIN__) + TIMEVAL selectTimeout{ + static_cast(timeout / 1000), + static_cast((timeout % 1000) * 1000) + }; + auto count = ::select(0, + (type == SelectType::read) ? &descriptorSet : nullptr, + (type == SelectType::write) ? &descriptorSet : nullptr, + nullptr, + (timeout >= 0) ? &selectTimeout : nullptr); + + while (count == SOCKET_ERROR && WSAGetLastError() == WSAEINTR) + count = ::select(0, + (type == SelectType::read) ? &descriptorSet : nullptr, + (type == SelectType::write) ? &descriptorSet : nullptr, + nullptr, + (timeout >= 0) ? &selectTimeout : nullptr); + + if (count == SOCKET_ERROR) + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to select socket"}; + else if (count == 0) + throw ResponseError{"Request timed out"}; +#else + timeval selectTimeout{ + static_cast(timeout / 1000), + static_cast((timeout % 1000) * 1000) + }; + auto count = ::select(endpoint + 1, + (type == SelectType::read) ? &descriptorSet : nullptr, + (type == SelectType::write) ? &descriptorSet : nullptr, + nullptr, + (timeout >= 0) ? &selectTimeout : nullptr); + + while (count == -1 && errno == EINTR) + count = ::select(endpoint + 1, + (type == SelectType::read) ? &descriptorSet : nullptr, + (type == SelectType::write) ? &descriptorSet : nullptr, + nullptr, + (timeout >= 0) ? &selectTimeout : nullptr); + + if (count == -1) + throw std::system_error{errno, std::system_category(), "Failed to select socket"}; + else if (count == 0) + throw ResponseError{"Request timed out"}; +#endif // defined(_WIN32) || defined(__CYGWIN__) } - operator Type() const noexcept { return endpoint; } + void close() noexcept + { +#if defined(_WIN32) || defined(__CYGWIN__) + closesocket(endpoint); +#else + ::close(endpoint); +#endif // defined(_WIN32) || defined(__CYGWIN__) + } + +#if defined(__unix__) && !defined(__APPLE__) && !defined(__CYGWIN__) + static constexpr int noSignal = MSG_NOSIGNAL; +#else + static constexpr int noSignal = 0; +#endif // defined(__unix__) && !defined(__APPLE__) - private: Type endpoint = invalid; }; - } - inline std::string urlEncode(const std::string& str) - { - constexpr char hexChars[16] = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; + inline char toLower(const char c) noexcept + { + return (c >= 'A' && c <= 'Z') ? c - ('A' - 'a') : c; + } + + template + T toLower(const T& s) + { + T result = s; + for (auto& c : result) c = toLower(c); + return result; + } - std::string result; + // RFC 7230, 3.2.3. WhiteSpace + template + constexpr bool isWhiteSpaceChar(const C c) noexcept + { + return c == 0x20 || c == 0x09; // space or tab + }; + + // RFC 5234, Appendix B.1. Core Rules + template + constexpr bool isDigitChar(const C c) noexcept + { + return c >= 0x30 && c <= 0x39; // 0 - 9 + } + + // RFC 5234, Appendix B.1. Core Rules + template + constexpr bool isAlphaChar(const C c) noexcept + { + return + (c >= 0x61 && c <= 0x7A) || // a - z + (c >= 0x41 && c <= 0x5A); // A - Z + } - for (auto i = str.begin(); i != str.end(); ++i) + // RFC 7230, 3.2.6. Field Value Components + template + constexpr bool isTokenChar(const C c) noexcept { - const std::uint8_t cp = *i & 0xFF; + return c == 0x21 || // ! + c == 0x23 || // # + c == 0x24 || // $ + c == 0x25 || // % + c == 0x26 || // & + c == 0x27 || // ' + c == 0x2A || // * + c == 0x2B || // + + c == 0x2D || // - + c == 0x2E || // . + c == 0x5E || // ^ + c == 0x5F || // _ + c == 0x60 || // ` + c == 0x7C || // | + c == 0x7E || // ~ + isDigitChar(c) || + isAlphaChar(c); + }; - if ((cp >= 0x30 && cp <= 0x39) || // 0-9 - (cp >= 0x41 && cp <= 0x5A) || // A-Z - (cp >= 0x61 && cp <= 0x7A) || // a-z - cp == 0x2D || cp == 0x2E || cp == 0x5F) // - . _ - result += static_cast(cp); - else if (cp <= 0x7F) // length = 1 - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - else if ((cp >> 5) == 0x06) // length = 2 + // RFC 5234, Appendix B.1. Core Rules + template + constexpr bool isVisibleChar(const C c) noexcept + { + return c >= 0x21 && c <= 0x7E; + } + + // RFC 7230, Appendix B. Collected ABNF + template + constexpr bool isObsoleteTextChar(const C c) noexcept + { + return static_cast(c) >= 0x80 && + static_cast(c) <= 0xFF; + } + + template + Iterator skipWhiteSpaces(const Iterator begin, const Iterator end) + { + auto i = begin; + for (i = begin; i != end; ++i) + if (!isWhiteSpaceChar(*i)) + break; + + return i; + } + + // RFC 5234, Appendix B.1. Core Rules + template ::value>::type* = nullptr> + constexpr T digitToUint(const C c) + { + // DIGIT + return (c >= 0x30 && c <= 0x39) ? static_cast(c - 0x30) : // 0 - 9 + throw ResponseError{"Invalid digit"}; + } + + // RFC 5234, Appendix B.1. Core Rules + template ::value>::type* = nullptr> + constexpr T hexDigitToUint(const C c) + { + // HEXDIG + return (c >= 0x30 && c <= 0x39) ? static_cast(c - 0x30) : // 0 - 9 + (c >= 0x41 && c <= 0x46) ? static_cast(c - 0x41) + T(10) : // A - Z + (c >= 0x61 && c <= 0x66) ? static_cast(c - 0x61) + T(10) : // a - z, some services send lower-case hex digits + throw ResponseError{"Invalid hex digit"}; + } + + // RFC 3986, 3. Syntax Components + template + Uri parseUri(const Iterator begin, const Iterator end) + { + Uri result; + + // RFC 3986, 3.1. Scheme + auto i = begin; + if (i == end || !isAlphaChar(*begin)) + throw RequestError{"Invalid scheme"}; + + result.scheme.push_back(*i++); + + for (; i != end && (isAlphaChar(*i) || isDigitChar(*i) || *i == '+' || *i == '-' || *i == '.'); ++i) + result.scheme.push_back(*i); + + if (i == end || *i++ != ':') + throw RequestError{"Invalid scheme"}; + if (i == end || *i++ != '/') + throw RequestError{"Invalid scheme"}; + if (i == end || *i++ != '/') + throw RequestError{"Invalid scheme"}; + + // RFC 3986, 3.2. Authority + std::string authority = std::string(i, end); + + // RFC 3986, 3.5. Fragment + const auto fragmentPosition = authority.find('#'); + if (fragmentPosition != std::string::npos) { - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - if (++i == str.end()) break; - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; + result.fragment = authority.substr(fragmentPosition + 1); + authority.resize(fragmentPosition); // remove the fragment part } - else if ((cp >> 4) == 0x0E) // length = 3 + + // RFC 3986, 3.4. Query + const auto queryPosition = authority.find('?'); + if (queryPosition != std::string::npos) { - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - if (++i == str.end()) break; - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - if (++i == str.end()) break; - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; + result.query = authority.substr(queryPosition + 1); + authority.resize(queryPosition); // remove the query part } - else if ((cp >> 3) == 0x1E) // length = 4 + + // RFC 3986, 3.3. Path + const auto pathPosition = authority.find('/'); + if (pathPosition != std::string::npos) { - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - if (++i == str.end()) break; - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - if (++i == str.end()) break; - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; - if (++i == str.end()) break; - result += std::string("%") + hexChars[(*i & 0xF0) >> 4] + hexChars[*i & 0x0F]; + // RFC 3986, 3.3. Path + result.path = authority.substr(pathPosition); + authority.resize(pathPosition); } + else + result.path = "/"; + + // RFC 3986, 3.2.1. User Information + std::string userinfo; + const auto hostPosition = authority.find('@'); + if (hostPosition != std::string::npos) + { + userinfo = authority.substr(0, hostPosition); + + const auto passwordPosition = userinfo.find(':'); + if (passwordPosition != std::string::npos) + { + result.user = userinfo.substr(0, passwordPosition); + result.password = userinfo.substr(passwordPosition + 1); + } + else + result.user = userinfo; + + result.host = authority.substr(hostPosition + 1); + } + else + result.host = authority; + + // RFC 3986, 3.2.2. Host + const auto portPosition = result.host.find(':'); + if (portPosition != std::string::npos) + { + // RFC 3986, 3.2.3. Port + result.port = result.host.substr(portPosition + 1); + result.host.resize(portPosition); + } + + return result; } - return result; - } + // RFC 7230, 2.6. Protocol Versioning + template + std::pair parseVersion(const Iterator begin, const Iterator end) + { + auto i = begin; - struct Response final - { - enum Status + if (i == end || *i++ != 'H') + throw ResponseError{"Invalid HTTP version"}; + if (i == end || *i++ != 'T') + throw ResponseError{"Invalid HTTP version"}; + if (i == end || *i++ != 'T') + throw ResponseError{"Invalid HTTP version"}; + if (i == end || *i++ != 'P') + throw ResponseError{"Invalid HTTP version"}; + if (i == end || *i++ != '/') + throw ResponseError{"Invalid HTTP version"}; + + if (i == end) + throw ResponseError{"Invalid HTTP version"}; + + const auto majorVersion = digitToUint(*i++); + + if (i == end || *i++ != '.') + throw ResponseError{"Invalid HTTP version"}; + + if (i == end) + throw ResponseError{"Invalid HTTP version"}; + + const auto minorVersion = digitToUint(*i++); + + return {i, Version{majorVersion, minorVersion}}; + } + + // RFC 7230, 3.1.2. Status Line + template + std::pair parseStatusCode(const Iterator begin, const Iterator end) { - Continue = 100, - SwitchingProtocol = 101, - Processing = 102, - EarlyHints = 103, + std::uint16_t result = 0; - Ok = 200, - Created = 201, - Accepted = 202, - NonAuthoritativeInformation = 203, - NoContent = 204, - ResetContent = 205, - PartialContent = 206, - MultiStatus = 207, - AlreadyReported = 208, - ImUsed = 226, + auto i = begin; + while (i != end && isDigitChar(*i)) + result = static_cast(result * 10U) + digitToUint(*i++); - MultipleChoice = 300, - MovedPermanently = 301, - Found = 302, - SeeOther = 303, - NotModified = 304, - UseProxy = 305, - TemporaryRedirect = 307, - PermanentRedirect = 308, + if (std::distance(begin, i) != 3) + throw ResponseError{"Invalid status code"}; - BadRequest = 400, - Unauthorized = 401, - PaymentRequired = 402, - Forbidden = 403, - NotFound = 404, - MethodNotAllowed = 405, - NotAcceptable = 406, - ProxyAuthenticationRequired = 407, - RequestTimeout = 408, - Conflict = 409, - Gone = 410, - LengthRequired = 411, - PreconditionFailed = 412, - PayloadTooLarge = 413, - UriTooLong = 414, - UnsupportedMediaType = 415, - RangeNotSatisfiable = 416, - ExpectationFailed = 417, - ImaTeapot = 418, - MisdirectedRequest = 421, - UnprocessableEntity = 422, - Locked = 423, - FailedDependency = 424, - TooEarly = 425, - UpgradeRequired = 426, - PreconditionRequired = 428, - TooManyRequests = 429, - RequestHeaderFieldsTooLarge = 431, - UnavailableForLegalReasons = 451, + return {i, result}; + } - InternalServerError = 500, - NotImplemented = 501, - BadGateway = 502, - ServiceUnavailable = 503, - GatewayTimeout = 504, - HttpVersionNotSupported = 505, - VariantAlsoNegotiates = 506, - InsufficientStorage = 507, - LoopDetected = 508, - NotExtended = 510, - NetworkAuthenticationRequired = 511 - }; + // RFC 7230, 3.1.2. Status Line + template + std::pair parseReasonPhrase(const Iterator begin, const Iterator end) + { + std::string result; - int status = 0; - std::vector headers; - std::vector body; - }; + auto i = begin; + for (; i != end && (isWhiteSpaceChar(*i) || isVisibleChar(*i) || isObsoleteTextChar(*i)); ++i) + result.push_back(static_cast(*i)); - class Request final - { - public: - explicit Request(const std::string& url, - InternetProtocol protocol = InternetProtocol::V4): - internetProtocol(protocol) + return {i, std::move(result)}; + } + + // RFC 7230, 3.2.6. Field Value Components + template + std::pair parseToken(const Iterator begin, const Iterator end) { - const auto schemeEndPosition = url.find("://"); + std::string result; - if (schemeEndPosition != std::string::npos) - { - scheme = url.substr(0, schemeEndPosition); - path = url.substr(schemeEndPosition + 3); - } - else + auto i = begin; + for (; i != end && isTokenChar(*i); ++i) + result.push_back(static_cast(*i)); + + if (result.empty()) + throw ResponseError{"Invalid token"}; + + return {i, std::move(result)}; + } + + // RFC 7230, 3.2. Header Fields + template + std::pair parseFieldValue(const Iterator begin, const Iterator end) + { + std::string result; + + auto i = begin; + for (; i != end && (isWhiteSpaceChar(*i) || isVisibleChar(*i) || isObsoleteTextChar(*i)); ++i) + result.push_back(static_cast(*i)); + + // trim white spaces + result.erase(std::find_if(result.rbegin(), result.rend(), [](const char c) noexcept { + return !isWhiteSpaceChar(c); + }).base(), result.end()); + + return {i, std::move(result)}; + } + + // RFC 7230, 3.2. Header Fields + template + std::pair parseFieldContent(const Iterator begin, const Iterator end) + { + std::string result; + + auto i = begin; + + for (;;) { - scheme = "http"; - path = url; + const auto fieldValueResult = parseFieldValue(i, end); + i = fieldValueResult.first; + result += fieldValueResult.second; + + // Handle obsolete fold as per RFC 7230, 3.2.4. Field Parsing + // Obsolete folding is known as linear white space (LWS) in RFC 2616, 2.2 Basic Rules + auto obsoleteFoldIterator = i; + if (obsoleteFoldIterator == end || *obsoleteFoldIterator++ != '\r') + break; + + if (obsoleteFoldIterator == end || *obsoleteFoldIterator++ != '\n') + break; + + if (obsoleteFoldIterator == end || !isWhiteSpaceChar(*obsoleteFoldIterator++)) + break; + + result.push_back(' '); + i = obsoleteFoldIterator; } - const auto fragmentPosition = path.find('#'); + return {i, std::move(result)}; + } - // remove the fragment part - if (fragmentPosition != std::string::npos) - path.resize(fragmentPosition); + // RFC 7230, 3.2. Header Fields + template + std::pair parseHeaderField(const Iterator begin, const Iterator end) + { + auto tokenResult = parseToken(begin, end); + auto i = tokenResult.first; + auto fieldName = toLower(tokenResult.second); + + if (i == end || *i++ != ':') + throw ResponseError{"Invalid header"}; + + i = skipWhiteSpaces(i, end); + + auto valueResult = parseFieldContent(i, end); + i = valueResult.first; + auto fieldValue = std::move(valueResult.second); + + if (i == end || *i++ != '\r') + throw ResponseError{"Invalid header"}; + + if (i == end || *i++ != '\n') + throw ResponseError{"Invalid header"}; + + return {i, {std::move(fieldName), std::move(fieldValue)}}; + } + + // RFC 7230, 3.1.2. Status Line + template + std::pair parseStatusLine(const Iterator begin, const Iterator end) + { + const auto versionResult = parseVersion(begin, end); + auto i = versionResult.first; + + if (i == end || *i++ != ' ') + throw ResponseError{"Invalid status line"}; - const auto pathPosition = path.find('/'); + const auto statusCodeResult = parseStatusCode(i, end); + i = statusCodeResult.first; + + if (i == end || *i++ != ' ') + throw ResponseError{"Invalid status line"}; + + auto reasonPhraseResult = parseReasonPhrase(i, end); + i = reasonPhraseResult.first; + + if (i == end || *i++ != '\r') + throw ResponseError{"Invalid status line"}; + + if (i == end || *i++ != '\n') + throw ResponseError{"Invalid status line"}; + + return {i, Status{ + versionResult.second, + statusCodeResult.second, + std::move(reasonPhraseResult.second) + }}; + } + + // RFC 7230, 4.1. Chunked Transfer Coding + template ::value>::type* = nullptr> + T stringToUint(const Iterator begin, const Iterator end) + { + T result = 0; + for (auto i = begin; i != end; ++i) + result = T(10U) * result + digitToUint(*i); + + return result; + } + + template ::value>::type* = nullptr> + T hexStringToUint(const Iterator begin, const Iterator end) + { + T result = 0; + for (auto i = begin; i != end; ++i) + result = T(16U) * result + hexDigitToUint(*i); + + return result; + } - if (pathPosition == std::string::npos) + // RFC 7230, 3.1.1. Request Line + inline std::string encodeRequestLine(const std::string& method, const std::string& target) + { + return method + " " + target + " HTTP/1.1\r\n"; + } + + // RFC 7230, 3.2. Header Fields + inline std::string encodeHeaderFields(const HeaderFields& headerFields) + { + std::string result; + for (const auto& headerField : headerFields) { - domain = path; - path = "/"; + if (headerField.first.empty()) + throw RequestError{"Invalid header field name"}; + + for (const auto c : headerField.first) + if (!isTokenChar(c)) + throw RequestError{"Invalid header field name"}; + + for (const auto c : headerField.second) + if (!isWhiteSpaceChar(c) && !isVisibleChar(c) && !isObsoleteTextChar(c)) + throw RequestError{"Invalid header field value"}; + + result += headerField.first + ": " + headerField.second + "\r\n"; } - else + + return result; + } + + // RFC 4648, 4. Base 64 Encoding + template + std::string encodeBase64(const Iterator begin, const Iterator end) + { + constexpr std::array chars{ + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', + 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', + 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '+', '/' + }; + + std::string result; + std::size_t c = 0; + std::array charArray; + + for (auto i = begin; i != end; ++i) { - domain = path.substr(0, pathPosition); - path = path.substr(pathPosition); + charArray[c++] = static_cast(*i); + if (c == 3) + { + result += chars[static_cast((charArray[0] & 0xFC) >> 2)]; + result += chars[static_cast(((charArray[0] & 0x03) << 4) + ((charArray[1] & 0xF0) >> 4))]; + result += chars[static_cast(((charArray[1] & 0x0F) << 2) + ((charArray[2] & 0xC0) >> 6))]; + result += chars[static_cast(charArray[2] & 0x3f)]; + c = 0; + } } - const auto portPosition = domain.find(':'); - - if (portPosition != std::string::npos) + if (c) { - port = domain.substr(portPosition + 1); - domain.resize(portPosition); + result += chars[static_cast((charArray[0] & 0xFC) >> 2)]; + + if (c == 1) + result += chars[static_cast((charArray[0] & 0x03) << 4)]; + else // c == 2 + { + result += chars[static_cast(((charArray[0] & 0x03) << 4) + ((charArray[1] & 0xF0) >> 4))]; + result += chars[static_cast((charArray[1] & 0x0F) << 2)]; + } + + while (++c < 4) result += '='; // padding } - else - port = "80"; + + return result; } - Response send(const std::string& method, - const std::map& parameters, - const std::vector& headers = {}) + inline std::vector encodeHtml(const Uri& uri, + const std::string& method, + const std::vector& body, + HeaderFields headerFields) { - std::string body; - bool first = true; + if (uri.scheme != "http") + throw RequestError{"Only HTTP scheme is supported"}; - for (const auto& parameter : parameters) - { - if (!first) body += "&"; - first = false; + // RFC 7230, 5.3. Request Target + const std::string requestTarget = uri.path + (uri.query.empty() ? "" : '?' + uri.query); - body += urlEncode(parameter.first) + "=" + urlEncode(parameter.second); + // RFC 7230, 5.4. Host + headerFields.push_back({"Host", uri.host}); + + // RFC 7230, 3.3.2. Content-Length + headerFields.push_back({"Content-Length", std::to_string(body.size())}); + + // RFC 7617, 2. The 'Basic' Authentication Scheme + if (!uri.user.empty() || !uri.password.empty()) + { + std::string userinfo = uri.user + ':' + uri.password; + headerFields.push_back({"Authorization", "Basic " + encodeBase64(userinfo.begin(), userinfo.end())}); } - return send(method, body, headers); + const auto headerData = encodeRequestLine(method, requestTarget) + + encodeHeaderFields(headerFields) + + "\r\n"; + + std::vector result(headerData.begin(), headerData.end()); + result.insert(result.end(), body.begin(), body.end()); + + return result; + } + } + + class Request final + { + public: + explicit Request(const std::string& uriString, + const InternetProtocol protocol = InternetProtocol::v4): + internetProtocol{protocol}, + uri{parseUri(uriString.begin(), uriString.end())} + { } Response send(const std::string& method = "GET", const std::string& body = "", - const std::vector& headers = {}) + const HeaderFields& headerFields = {}, + const std::chrono::milliseconds timeout = std::chrono::milliseconds{-1}) { return send(method, std::vector(body.begin(), body.end()), - headers); + headerFields, + timeout); } Response send(const std::string& method, const std::vector& body, - const std::vector& headers) + const HeaderFields& headerFields = {}, + const std::chrono::milliseconds timeout = std::chrono::milliseconds{-1}) { - if (scheme != "http") - throw RequestError("Only HTTP scheme is supported"); + const auto stopTime = std::chrono::steady_clock::now() + timeout; + + if (uri.scheme != "http") + throw RequestError{"Only HTTP scheme is supported"}; addrinfo hints = {}; hints.ai_family = getAddressFamily(internetProtocol); hints.ai_socktype = SOCK_STREAM; - addrinfo* info; - if (getaddrinfo(domain.c_str(), port.c_str(), &hints, &info) != 0) - throw std::system_error(getLastError(), std::system_category(), "Failed to get address info of " + domain); - - std::unique_ptr addressInfo(info, freeaddrinfo); + const char* port = uri.port.empty() ? "80" : uri.port.c_str(); - std::string headerData = method + " " + path + " HTTP/1.1\r\n"; + addrinfo* info; + if (getaddrinfo(uri.host.c_str(), port, &hints, &info) != 0) +#if defined(_WIN32) || defined(__CYGWIN__) + throw std::system_error{WSAGetLastError(), winsock::errorCategory, "Failed to get address info of " + uri.host}; +#else + throw std::system_error{errno, std::system_category(), "Failed to get address info of " + uri.host}; +#endif // defined(_WIN32) || defined(__CYGWIN__) - for (const std::string& header : headers) - headerData += header + "\r\n"; + const std::unique_ptr addressInfo{info, freeaddrinfo}; - headerData += "Host: " + domain + "\r\n" - "Content-Length: " + std::to_string(body.size()) + "\r\n" - "\r\n"; + const auto requestData = encodeHtml(uri, method, body, headerFields); - std::vector requestData(headerData.begin(), headerData.end()); - requestData.insert(requestData.end(), body.begin(), body.end()); + Socket socket{internetProtocol}; - Socket socket(internetProtocol); + const auto getRemainingMilliseconds = [](const std::chrono::steady_clock::time_point time) noexcept -> std::int64_t { + const auto now = std::chrono::steady_clock::now(); + const auto remainingTime = std::chrono::duration_cast(time - now); + return (remainingTime.count() > 0) ? remainingTime.count() : 0; + }; // take the first address from the list - socket.connect(addressInfo->ai_addr, static_cast(addressInfo->ai_addrlen)); + socket.connect(addressInfo->ai_addr, static_cast(addressInfo->ai_addrlen), + (timeout.count() >= 0) ? getRemainingMilliseconds(stopTime) : -1); auto remaining = requestData.size(); auto sendData = requestData.data(); @@ -503,125 +1179,99 @@ namespace http // send the request while (remaining > 0) { - const auto size = socket.send(sendData, remaining, noSignal); + const auto size = socket.send(sendData, remaining, + (timeout.count() >= 0) ? getRemainingMilliseconds(stopTime) : -1); remaining -= size; sendData += size; } - std::uint8_t tempBuffer[4096]; - constexpr std::uint8_t crlf[] = {'\r', '\n'}; + std::array tempBuffer; + constexpr std::array crlf = {'\r', '\n'}; + constexpr std::array headerEnd = {'\r', '\n', '\r', '\n'}; Response response; std::vector responseData; - bool firstLine = true; - bool parsedHeaders = false; + bool parsingBody = false; bool contentLengthReceived = false; - unsigned long contentLength = 0; + std::size_t contentLength = 0U; bool chunkedResponse = false; - std::size_t expectedChunkSize = 0; + std::size_t expectedChunkSize = 0U; bool removeCrlfAfterChunk = false; // read the response for (;;) { - const auto size = socket.recv(tempBuffer, sizeof(tempBuffer), noSignal); + const auto size = socket.recv(tempBuffer.data(), tempBuffer.size(), + (timeout.count() >= 0) ? getRemainingMilliseconds(stopTime) : -1); + if (size == 0) // disconnected + return response; - if (size == 0) - break; // disconnected + responseData.insert(responseData.end(), tempBuffer.begin(), tempBuffer.begin() + size); + + if (!parsingBody) + { + // RFC 7230, 3. Message Format + // Empty line indicates the end of the header section (RFC 7230, 2.1. Client/Server Messaging) + const auto endIterator = std::search(responseData.cbegin(), responseData.cend(), + headerEnd.cbegin(), headerEnd.cend()); + if (endIterator == responseData.cend()) break; // two consecutive CRLFs not found - responseData.insert(responseData.end(), tempBuffer, tempBuffer + size); + const auto headerBeginIterator = responseData.cbegin(); + const auto headerEndIterator = endIterator + 2; + + auto statusLineResult = parseStatusLine(headerBeginIterator, headerEndIterator); + auto i = statusLineResult.first; + + response.status = std::move(statusLineResult.second); - if (!parsedHeaders) for (;;) { - const auto i = std::search(responseData.begin(), responseData.end(), std::begin(crlf), std::end(crlf)); - - // didn't find a newline - if (i == responseData.end()) break; + auto headerFieldResult = parseHeaderField(i, headerEndIterator); + i = headerFieldResult.first; - const std::string line(responseData.begin(), i); - responseData.erase(responseData.begin(), i + 2); + auto fieldName = std::move(headerFieldResult.second.first); + auto fieldValue = std::move(headerFieldResult.second.second); - // empty line indicates the end of the header section - if (line.empty()) + if (fieldName == "transfer-encoding") { - parsedHeaders = true; - break; + // RFC 7230, 3.3.1. Transfer-Encoding + if (fieldValue == "chunked") + chunkedResponse = true; + else + throw ResponseError{"Unsupported transfer encoding: " + fieldValue}; } - else if (firstLine) // first line + else if (fieldName == "content-length") { - firstLine = false; - - std::string::size_type lastPos = 0; - const auto length = line.length(); - std::vector parts; - - // tokenize first line - while (lastPos < length + 1) - { - auto pos = line.find(' ', lastPos); - if (pos == std::string::npos) pos = length; - - if (pos != lastPos) - parts.emplace_back(line.data() + lastPos, - static_cast::size_type>(pos) - lastPos); - - lastPos = pos + 1; - } - - if (parts.size() >= 2) - response.status = std::stoi(parts[1]); + // RFC 7230, 3.3.2. Content-Length + contentLength = stringToUint(fieldValue.cbegin(), fieldValue.cend()); + contentLengthReceived = true; + response.body.reserve(contentLength); } - else // headers - { - response.headers.push_back(line); - - const auto pos = line.find(':'); - - if (pos != std::string::npos) - { - std::string headerName = line.substr(0, pos); - std::string headerValue = line.substr(pos + 1); - - // ltrim - headerValue.erase(headerValue.begin(), - std::find_if(headerValue.begin(), headerValue.end(), - [](int c) {return !std::isspace(c);})); - // rtrim - headerValue.erase(std::find_if(headerValue.rbegin(), headerValue.rend(), - [](int c) {return !std::isspace(c);}).base(), - headerValue.end()); + response.headerFields.push_back({std::move(fieldName), std::move(fieldValue)}); - if (headerName == "Content-Length") - { - contentLength = std::stoul(headerValue); - contentLengthReceived = true; - response.body.reserve(contentLength); - } - else if (headerName == "Transfer-Encoding") - { - if (headerValue == "chunked") - chunkedResponse = true; - else - throw ResponseError("Unsupported transfer encoding: " + headerValue); - } - } - } + if (i == headerEndIterator) + break; } - if (parsedHeaders) + responseData.erase(responseData.cbegin(), headerEndIterator + 2); + parsingBody = true; + } + + if (parsingBody) { - // Content-Length must be ignored if Transfer-Encoding is received + // Content-Length must be ignored if Transfer-Encoding is received (RFC 7230, 3.2. Content-Length) if (chunkedResponse) { - bool dataReceived = false; + // RFC 7230, 4.1. Chunked Transfer Coding for (;;) { if (expectedChunkSize > 0) { - const auto toWrite = std::min(expectedChunkSize, responseData.size()); - response.body.insert(response.body.end(), responseData.begin(), responseData.begin() + static_cast(toWrite)); - responseData.erase(responseData.begin(), responseData.begin() + static_cast(toWrite)); + const auto toWrite = (std::min)(expectedChunkSize, responseData.size()); + response.body.insert(response.body.end(), responseData.begin(), + responseData.begin() + static_cast(toWrite)); + responseData.erase(responseData.begin(), + responseData.begin() + static_cast(toWrite)); expectedChunkSize -= toWrite; if (expectedChunkSize == 0) removeCrlfAfterChunk = true; @@ -631,33 +1281,27 @@ namespace http { if (removeCrlfAfterChunk) { - if (responseData.size() >= 2) - { - removeCrlfAfterChunk = false; - responseData.erase(responseData.begin(), responseData.begin() + 2); - } - else break; + if (responseData.size() < 2) break; + + if (!std::equal(crlf.begin(), crlf.end(), responseData.begin())) + throw ResponseError{"Invalid chunk"}; + + removeCrlfAfterChunk = false; + responseData.erase(responseData.begin(), responseData.begin() + 2); } - const auto i = std::search(responseData.begin(), responseData.end(), std::begin(crlf), std::end(crlf)); + const auto i = std::search(responseData.begin(), responseData.end(), + crlf.begin(), crlf.end()); if (i == responseData.end()) break; - const std::string line(responseData.begin(), i); + expectedChunkSize = detail::hexStringToUint(responseData.begin(), i); responseData.erase(responseData.begin(), i + 2); - expectedChunkSize = std::stoul(line, nullptr, 16); - if (expectedChunkSize == 0) - { - dataReceived = true; - break; - } + return response; } } - - if (dataReceived) - break; } else { @@ -666,7 +1310,7 @@ namespace http // got the whole content if (contentLengthReceived && response.body.size() >= contentLength) - break; + return response; } } } @@ -675,15 +1319,12 @@ namespace http } private: -#ifdef _WIN32 - WinSock winSock; -#endif +#if defined(_WIN32) || defined(__CYGWIN__) + winsock::Api winSock; +#endif // defined(_WIN32) || defined(__CYGWIN__) InternetProtocol internetProtocol; - std::string scheme; - std::string domain; - std::string port; - std::string path; + Uri uri; }; } -#endif +#endif // HTTPREQUEST_HPP diff --git a/tools/bacommon/cloud.py b/tools/bacommon/cloud.py index 4ebf4a50c..66d57dd22 100644 --- a/tools/bacommon/cloud.py +++ b/tools/bacommon/cloud.py @@ -122,24 +122,25 @@ class TestResponse(Response): @ioprepped @dataclass -class PromoCodeMessage(Message): - """User is entering a promo code""" +class SendInfoMessage(Message): + """User is using the send-info function""" - code: Annotated[str, IOAttrs('c')] + description: Annotated[str, IOAttrs('c')] @override @classmethod def get_response_types(cls) -> list[type[Response] | None]: - return [PromoCodeResponse] + return [SendInfoResponse] @ioprepped @dataclass -class PromoCodeResponse(Response): - """Applied that promo code for ya, boss.""" +class SendInfoResponse(Response): + """Response to sending into the server.""" - valid: Annotated[bool, IOAttrs('v')] + handled: Annotated[bool, IOAttrs('v')] message: Annotated[str | None, IOAttrs('m', store_default=False)] = None + legacy_code: Annotated[str | None, IOAttrs('l', store_default=False)] = None @ioprepped diff --git a/tools/bacommon/net.py b/tools/bacommon/net.py index d07f774a3..0f1460260 100644 --- a/tools/bacommon/net.py +++ b/tools/bacommon/net.py @@ -20,6 +20,11 @@ class ServerNodeEntry: """Information about a specific server.""" zone: Annotated[str, IOAttrs('r')] + + # TODO: Remove soft_default after all master-servers upgraded. + latlong: Annotated[ + tuple[float, float] | None, IOAttrs('ll', soft_default=None) + ] address: Annotated[str, IOAttrs('a')] port: Annotated[int, IOAttrs('p')] @@ -32,6 +37,16 @@ class ServerNodeQueryResponse: # The current utc time on the master server. time: Annotated[datetime.datetime, IOAttrs('t')] + # Where the master server sees the query as coming from. + latlong: Annotated[tuple[float, float] | None, IOAttrs('ll')] + + ping_per_dist: Annotated[float, IOAttrs('ppd')] + max_dist: Annotated[float, IOAttrs('md')] + + debug_log_seconds: Annotated[ + float | None, IOAttrs('d', store_default=False) + ] = None + # If present, something went wrong, and this describes it. error: Annotated[str | None, IOAttrs('e', store_default=False)] = None @@ -78,6 +93,7 @@ class PrivatePartyConnectResult: """Info about a server we get back when connecting.""" error: str | None = None - addr: str | None = None + address4: Annotated[str | None, IOAttrs('addr')] = None + address6: Annotated[str | None, IOAttrs('addr6')] = None port: int | None = None password: str | None = None diff --git a/tools/bacommon/servermanager.py b/tools/bacommon/servermanager.py index 6cc5cf4be..31f235f98 100644 --- a/tools/bacommon/servermanager.py +++ b/tools/bacommon/servermanager.py @@ -22,113 +22,138 @@ class ServerConfig: party_name: str = 'FFA' # If True, your party will show up in the global public party list - # Otherwise it will still be joinable via LAN or connecting by IP address. + # Otherwise it will still be joinable via LAN or connecting by IP + # address. party_is_public: bool = True - # If True, all connecting clients will be authenticated through the master - # server to screen for fake account info. Generally this should always - # be enabled unless you are hosting on a LAN with no internet connection. + # If True, all connecting clients will be authenticated through the + # master server to screen for fake account info. Generally this + # should always be enabled unless you are hosting on a LAN with no + # internet connection. authenticate_clients: bool = True - # IDs of server admins. Server admins are not kickable through the default - # kick vote system and they are able to kick players without a vote. To get - # your account id, enter 'getaccountid' in settings->advanced->enter-code. + # IDs of server admins. Server admins are not kickable through the + # default kick vote system and they are able to kick players without + # a vote. To get your account id, enter 'getaccountid' in + # settings->advanced->enter-code. admins: list[str] = field(default_factory=list) # Whether the default kick-voting system is enabled. enable_default_kick_voting: bool = True - # UDP port to host on. Change this to work around firewalls or run multiple - # servers on one machine. - # 43210 is the default and the only port that will show up in the LAN - # browser tab. + # To be included in the public server list, your server MUST be + # accessible via an ipv4 address. By default, the master server will + # try to use the address your server contacts it from, but this may + # be an ipv6 address these days so you may need to provide an ipv4 + # address explicitly. + public_ipv4_address: str | None = None + + # You can optionally provide an ipv6 address for your server for the + # public server list. Unlike ipv4, a server is not required to have + # an ipv6 address to appear in the list, but is still good to + # provide when available since more and more devices are using ipv6 + # these days. Your server's ipv6 address will be autodetected if + # your server uses ipv6 when communicating with the master server. You + # can pass an empty string here to explicitly disable the ipv6 + # address. + public_ipv6_address: str | None = None + + # UDP port to host on. Change this to work around firewalls or run + # multiple servers on one machine. + # + # 43210 is the default and the only port that will show up in the + # LAN browser tab. port: int = 43210 - # Max devices in the party. Note that this does *NOT* mean max players. - # Any device in the party can have more than one player on it if they have - # multiple controllers. Also, this number currently includes the server so - # generally make it 1 bigger than you need. + # Max devices in the party. Note that this does *NOT* mean max + # players. Any device in the party can have more than one player on + # it if they have multiple controllers. Also, this number currently + # includes the server so generally make it 1 bigger than you need. max_party_size: int = 6 - # Max players that can join a session. If present this will override the - # session's preferred max_players. if a value below 0 is given player limit - # will be removed. + # Max players that can join a session. If present this will override + # the session's preferred max_players. if a value below 0 is given + # player limit will be removed. session_max_players_override: int | None = None - # Options here are 'ffa' (free-for-all), 'teams' and 'coop' (cooperative) - # This value is ignored if you supply a playlist_code (see below). + # Options here are 'ffa' (free-for-all), 'teams' and 'coop' + # (cooperative) This value is ignored if you supply a playlist_code + # (see below). session_type: str = 'ffa' - # Playlist-code for teams or free-for-all mode sessions. - # To host your own custom playlists, use the 'share' functionality in the - # playlist editor in the regular version of the game. - # This will give you a numeric code you can enter here to host that - # playlist. + # Playlist-code for teams or free-for-all mode sessions. To host + # your own custom playlists, use the 'share' functionality in the + # playlist editor in the regular version of the game. This will give + # you a numeric code you can enter here to host that playlist. playlist_code: int | None = None - # Alternately, you can embed playlist data here instead of using codes. - # Make sure to set session_type to the correct type for the data here. + # Alternately, you can embed playlist data here instead of using + # codes. Make sure to set session_type to the correct type for the + # data here. playlist_inline: list[dict[str, Any]] | None = None - # Whether to shuffle the playlist or play its games in designated order. + # Whether to shuffle the playlist or play its games in designated + # order. playlist_shuffle: bool = True - # If True, keeps team sizes equal by disallowing joining the largest team - # (teams mode only). + # If True, keeps team sizes equal by disallowing joining the largest + # team (teams mode only). auto_balance_teams: bool = True - # The campaign used when in co-op session mode. - # Do print(ba.app.campaigns) to see available campaign names. + # The campaign used when in co-op session mode. Do + # print(ba.app.campaigns) to see available campaign names. coop_campaign: str = 'Easy' - # The level name within the campaign used in co-op session mode. - # For campaign name FOO, do print(ba.app.campaigns['FOO'].levels) to see + # The level name within the campaign used in co-op session mode. For + # campaign name FOO, do print(ba.app.campaigns['FOO'].levels) to see # available level names. coop_level: str = 'Onslaught Training' # Whether to enable telnet access. - # IMPORTANT: This option is no longer available, as it was being used - # for exploits. Live access to the running server is still possible through - # the mgr.cmd() function in the server script. Run your server through - # tools such as 'screen' or 'tmux' and you can reconnect to it remotely - # over a secure ssh connection. + # + # IMPORTANT: This option is no longer available, as it was being + # used for exploits. Live access to the running server is still + # possible through the mgr.cmd() function in the server script. Run + # your server through tools such as 'screen' or 'tmux' and you can + # reconnect to it remotely over a secure ssh connection. enable_telnet: bool = False # Series length in teams mode (7 == 'best-of-7' series; a team must # get 4 wins) teams_series_length: int = 7 - # Points to win in free-for-all mode (Points are awarded per game based on - # performance) + # Points to win in free-for-all mode (Points are awarded per game + # based on performance) ffa_series_length: int = 24 - # If you have a custom stats webpage for your server, you can use this - # to provide a convenient in-game link to it in the server-browser - # alongside the server name. + # If you have a custom stats webpage for your server, you can use + # this to provide a convenient in-game link to it in the + # server-browser alongside the server name. + # # if ${ACCOUNT} is present in the string, it will be replaced by the # currently-signed-in account's id. To fetch info about an account, # your back-end server can use the following url: # https://legacy.ballistica.net/accountquery?id=ACCOUNT_ID_HERE stats_url: str | None = None - # If present, the server subprocess will attempt to gracefully exit after - # this amount of time. A graceful exit can occur at the end of a series - # or other opportune time. Server-managers set to auto-restart (the - # default) will then spin up a fresh subprocess. This mechanism can be - # useful to clear out any memory leaks or other accumulated bad state - # in the server subprocess. + # If present, the server subprocess will attempt to gracefully exit + # after this amount of time. A graceful exit can occur at the end of + # a series or other opportune time. Server-managers set to + # auto-restart (the default) will then spin up a fresh subprocess. + # This mechanism can be useful to clear out any memory leaks or + # other accumulated bad state in the server subprocess. clean_exit_minutes: float | None = None - # If present, the server subprocess will shut down immediately after this - # amount of time. This can be useful as a fallback for clean_exit_time. - # The server manager will then spin up a fresh server subprocess if - # auto-restart is enabled (the default). + # If present, the server subprocess will shut down immediately after + # this amount of time. This can be useful as a fallback for + # clean_exit_time. The server manager will then spin up a fresh + # server subprocess if auto-restart is enabled (the default). unclean_exit_minutes: float | None = None - # If present, the server subprocess will shut down immediately if this - # amount of time passes with no activity from any players. The server - # manager will then spin up a fresh server subprocess if auto-restart is - # enabled (the default). + # If present, the server subprocess will shut down immediately if + # this amount of time passes with no activity from any players. The + # server manager will then spin up a fresh server subprocess if + # auto-restart is enabled (the default). idle_exit_minutes: float | None = None # Should the tutorial be shown at the beginning of games? @@ -142,9 +167,9 @@ class ServerConfig: tuple[tuple[float, float, float], tuple[float, float, float]] | None ) = None - # Whether to enable the queue where players can line up before entering - # your server. Disabling this can be used as a workaround to deal with - # queue spamming attacks. + # Whether to enable the queue where players can line up before + # entering your server. Disabling this can be used as a workaround + # to deal with queue spamming attacks. enable_queue: bool = True # Protocol version we host with. Currently the default is 33 which @@ -162,9 +187,9 @@ class ServerConfig: player_rejoin_cooldown: float = 10.0 -# NOTE: as much as possible, communication from the server-manager to the -# child-process should go through these and not ad-hoc Python string commands -# since this way is type safe. +# NOTE: as much as possible, communication from the server-manager to +# the child-process should go through these and not ad-hoc Python string +# commands since this way is type safe. class ServerCommand: """Base class for commands that can be sent to the server.""" diff --git a/tools/batools/build.py b/tools/batools/build.py index 24a22dcc2..bb0bf65c0 100644 --- a/tools/batools/build.py +++ b/tools/batools/build.py @@ -465,6 +465,8 @@ def _get_server_config_template_toml(projroot: str) -> str: cfg.playlist_inline = [] cfg.team_names = ('Red', 'Blue') cfg.team_colors = ((0.1, 0.25, 1.0), (1.0, 0.25, 0.2)) + cfg.public_ipv4_address = '123.123.123.123' + cfg.public_ipv6_address = '123A::A123:23A1:A312:12A3:A213:2A13' lines_in = _get_server_config_raw_contents(projroot).splitlines() diff --git a/tools/efro/dataclassio/__init__.py b/tools/efro/dataclassio/__init__.py index eae9c8206..5c76cc79b 100644 --- a/tools/efro/dataclassio/__init__.py +++ b/tools/efro/dataclassio/__init__.py @@ -32,6 +32,7 @@ dataclass_from_dict, dataclass_from_json, dataclass_validate, + dataclass_hash, ) __all__ = [ @@ -47,6 +48,7 @@ 'dataclass_to_dict', 'dataclass_to_json', 'dataclass_validate', + 'dataclass_hash', 'ioprep', 'ioprepped', 'is_ioprepped_dataclass', diff --git a/tools/efro/dataclassio/_api.py b/tools/efro/dataclassio/_api.py index 0bd5f895e..ae0554e38 100644 --- a/tools/efro/dataclassio/_api.py +++ b/tools/efro/dataclassio/_api.py @@ -10,6 +10,7 @@ from __future__ import annotations +import json from enum import Enum from typing import TYPE_CHECKING, TypeVar @@ -79,7 +80,6 @@ def dataclass_to_json( By default, keys are sorted for pretty output and not otherwise, but this can be overridden by supplying a value for the 'sort_keys' arg. """ - import json jdict = dataclass_to_dict( obj=obj, coerce_to_float=coerce_to_float, codec=Codec.JSON @@ -142,11 +142,10 @@ def dataclass_from_json( allow_unknown_attrs: bool = True, discard_unknown_attrs: bool = False, ) -> T: - """Utility function; return a dataclass instance given a json string. + """Return a dataclass instance given a json string. Basically dataclass_from_dict(json.loads(...)) """ - import json return dataclass_from_dict( cls=cls, @@ -167,3 +166,27 @@ def dataclass_validate( _Outputter( obj, create=False, codec=codec, coerce_to_float=coerce_to_float ).run() + + +def dataclass_hash(obj: Any, coerce_to_float: bool = True) -> str: + """Calculate a hash for the provided dataclass. + + Basically this emits json for the dataclass (with keys sorted + to keep things deterministic) and hashes the resulting string. + """ + import hashlib + from base64 import urlsafe_b64encode + + json_dict = dataclass_to_dict( + obj, codec=Codec.JSON, coerce_to_float=coerce_to_float + ) + + # Need to sort keys to keep things deterministic. + json_str = json.dumps(json_dict, separators=(',', ':'), sort_keys=True) + + sha = hashlib.sha256() + sha.update(json_str.encode()) + + # Go with urlsafe base64 instead of the usual hex to save some + # space, and kill those ugly padding chars at the end. + return urlsafe_b64encode(sha.digest()).decode().strip('=') diff --git a/tools/efro/debug.py b/tools/efro/debug.py index 8262a34c7..024efcc16 100644 --- a/tools/efro/debug.py +++ b/tools/efro/debug.py @@ -417,6 +417,8 @@ def __init__( logger: Logger | None = None, logextra: dict | None = None, ) -> None: + from efro.util import caller_source_location + # pylint: disable=not-context-manager cls = type(self) if cls.watchers_lock is None or cls.watchers is None: @@ -433,6 +435,13 @@ def __init__( self.noted_expire = False self.logger = logger self.logextra = logextra + self.caller_source_loc = caller_source_location() + curthread = threading.current_thread() + self.thread_id = ( + '' + if curthread.ident is None + else hex(curthread.ident).removeprefix('0x') + ) with cls.watchers_lock: cls.watchers.append(weakref.ref(self)) @@ -492,8 +501,11 @@ def _deadlock_watcher_thread_main(cls) -> None: # should check stderr for a dump. if w.logger is not None: w.logger.error( - 'DeadlockWatcher with time %.2f expired;' + 'DeadlockWatcher at %s in thread %s' + ' with time %.2f expired;' ' check stderr for stack traces.', + w.caller_source_loc, + w.thread_id, w.timeout, extra=w.logextra, ) diff --git a/tools/efro/log.py b/tools/efro/log.py index 0c1cf9a9d..55a567890 100644 --- a/tools/efro/log.py +++ b/tools/efro/log.py @@ -150,6 +150,7 @@ def __init__( self._cache = deque[tuple[int, LogEntry]]() self._cache_index_offset = 0 self._cache_lock = Lock() + # self._report_blocking_io_on_echo_error = False self._printed_callback_error = False self._thread_bootstrapped = False self._thread = Thread(target=self._log_thread_main, daemon=True) @@ -364,13 +365,32 @@ def emit(self, record: logging.LogRecord) -> None: # thread because the delay can throw off command line prompts or # make tight debugging harder. if self._echofile is not None: + # try: + # if self._report_blocking_io_on_echo_error: + # premsg = ( + # 'WARNING: BlockingIOError ON LOG ECHO OUTPUT;' + # ' YOU ARE PROBABLY MISSING LOGS\n' + # ) + # self._report_blocking_io_on_echo_error = False + # else: + # premsg = '' ends = LEVELNO_COLOR_CODES.get(record.levelno) namepre = f'{Clr.WHT}{record.name}:{Clr.RST} ' if ends is not None: - self._echofile.write(f'{namepre}{ends[0]}{msg}{ends[1]}\n') + self._echofile.write( + f'{namepre}{ends[0]}' + f'{msg}{ends[1]}\n' + # f'{namepre}{ends[0]}' f'{premsg}{msg}{ends[1]}\n' + ) else: self._echofile.write(f'{namepre}{msg}\n') self._echofile.flush() + # except BlockingIOError: + # # Ran into this when doing a bunch of logging; assuming + # # this is asyncio's doing?.. For now trying to survive + # # the error but telling the user something is probably + # # missing in their output. + # self._report_blocking_io_on_echo_error = True if __debug__: echotime = time.monotonic() @@ -603,9 +623,23 @@ def __init__( self._name = name self._handler = handler + # Think this was a result of setting non-blocking stdin somehow; + # probably not needed. + # self._report_blocking_io_error = False + def write(self, output: Any) -> None: """Override standard write call.""" + # try: + # if self._report_blocking_io_error: + # self._report_blocking_io_error = False + # self._original.write( + # 'WARNING: BlockingIOError ENCOUNTERED;' + # ' OUTPUT IS PROBABLY MISSING' + # ) + self._original.write(output) + # except BlockingIOError: + # self._report_blocking_io_error = True self._handler.file_write(self._name, output) def flush(self) -> None: diff --git a/tools/efro/util.py b/tools/efro/util.py index 65b92aff8..5034b1bac 100644 --- a/tools/efro/util.py +++ b/tools/efro/util.py @@ -711,6 +711,27 @@ def compact_id(num: int) -> str: ) +def caller_source_location() -> str: + """Returns source file name and line of the code calling us. + + Example: 'mymodule.py:23' + """ + try: + import inspect + + frame = inspect.currentframe() + for _i in range(2): + if frame is None: + raise RuntimeError() + frame = frame.f_back + if frame is None: + raise RuntimeError() + fname = os.path.basename(frame.f_code.co_filename) + return f'{fname}:{frame.f_lineno}' + except Exception: + return '' + + def unchanging_hostname() -> str: """Return an unchanging name for the local device.