diff --git a/dist/ba_data/python/babase/__init__.py b/dist/ba_data/python/babase/__init__.py index 7253a15..43a4f55 100644 --- a/dist/ba_data/python/babase/__init__.py +++ b/dist/ba_data/python/babase/__init__.py @@ -24,7 +24,6 @@ from _babase import ( add_clean_frame_callback, allows_ticket_sales, android_get_external_files_dir, - app_instance_uuid, appname, appnameupper, apptime, @@ -234,7 +233,6 @@ __all__ = [ 'AppIntentExec', 'AppMode', 'AppState', - 'app_instance_uuid', 'applog', 'appname', 'appnameupper', diff --git a/dist/ba_data/python/babase/_accountv2.py b/dist/ba_data/python/babase/_accountv2.py index 0a24d40..657432a 100644 --- a/dist/ba_data/python/babase/_accountv2.py +++ b/dist/ba_data/python/babase/_accountv2.py @@ -4,9 +4,11 @@ from __future__ import annotations +import time import hashlib import logging from functools import partial +from dataclasses import dataclass from typing import TYPE_CHECKING, assert_never from efro.error import CommunicationError @@ -19,6 +21,8 @@ import _babase if TYPE_CHECKING: from typing import Any, Callable + import bacommon.cloud + from babase._login import LoginAdapter, LoginInfo @@ -62,6 +66,9 @@ class AccountV2Subsystem: Callable[[AccountV2Handle | None], None] ] = CallbackSet() + # Request state per global-app-instance-id + self._auth_requests: dict[str, _AuthRequest] = {} + adapter: LoginAdapter if _babase.using_google_play_game_services(): adapter = LoginAdapterGPGS() @@ -104,6 +111,9 @@ class AccountV2Subsystem: """ assert _babase.in_logic_thread() + # Blow away any outstanding auth-requests. + self._auth_requests = {} + # Inform the base layer of new names/etc. if account is not None: _babase.set_account_sign_in_state(True, account.tag) @@ -201,6 +211,78 @@ class AccountV2Subsystem: self._initial_sign_in_completed = True _babase.app.on_initial_sign_in_complete() + def auth_request( + self, global_app_instance_id: str + ) -> None | tuple[bool, str]: + """Start/process an auth request.""" + import bacommon.cloud + + assert _babase.in_logic_thread() + plus = _babase.app.plus + assert plus is not None + + now = time.monotonic() + + # If there are any expired ones, do a prune pass. + if any(r.expire_time <= now for r in self._auth_requests.values()): + self._auth_requests = { + rid: r + for rid, r in self._auth_requests.items() + if r.expire_time > now + } + + auth_request = self._auth_requests.get(global_app_instance_id) + + # If we find no attempt in progress, kick one off. + if ( + auth_request is None + and plus.cloud.connected + and self.primary is not None + ): + # print('SENDING AUTH REQUEST') + auth_request = self._auth_requests[global_app_instance_id] = ( + _AuthRequest(expire_time=now + 10.0, error=None, token=None) + ) + with self.primary: + plus.cloud.send_message_cb( + bacommon.cloud.AuthRequestMessage(global_app_instance_id), + on_response=partial( + self._on_auth_request_response, auth_request + ), + ) + + # If we found results, return them. + if auth_request is None: + return None + if auth_request.error is not None: + assert auth_request.token is None + return (False, auth_request.error) + if auth_request.token is not None: + assert auth_request.error is None + return (True, auth_request.token) + # No error or token; its still in flight. + return None + + def _on_auth_request_response( + self, + auth_request: _AuthRequest, + response: bacommon.cloud.AuthRequestResponse | Exception, + ) -> None: + assert _babase.in_logic_thread() + + assert auth_request.error is None + assert auth_request.token is None + + if isinstance(response, Exception): + auth_request.error = 'An error has occurred.' + else: + # print('SETTING AUTH RESPONSE') + auth_request.error = response.error + auth_request.token = response.token + # Make sure this sticks around for long enough to complete + # the connection. + auth_request.expire_time = time.monotonic() + 10.0 + @staticmethod def _hashstr(val: str) -> str: md5 = hashlib.md5() @@ -501,3 +583,10 @@ class AccountV2Handle: This allows cloud messages to be sent on our behalf. """ + + +@dataclass +class _AuthRequest: + expire_time: float + error: str | None + token: str | None diff --git a/dist/ba_data/python/babase/_app.py b/dist/ba_data/python/babase/_app.py index 8ac0177..3807b88 100644 --- a/dist/ba_data/python/babase/_app.py +++ b/dist/ba_data/python/babase/_app.py @@ -242,7 +242,6 @@ class App: loop. Hopefully this situation will be improved in the future with a unified event loop. """ - assert _babase.in_logic_thread() assert self._asyncio_loop is not None return self._asyncio_loop diff --git a/dist/ba_data/python/babase/_appconfig.py b/dist/ba_data/python/babase/_appconfig.py index 56f271a..89716b4 100644 --- a/dist/ba_data/python/babase/_appconfig.py +++ b/dist/ba_data/python/babase/_appconfig.py @@ -11,7 +11,7 @@ import _babase if TYPE_CHECKING: from typing import Any -_g_pending_apply = False # pylint: disable=invalid-name +_g_pending_apply = False class AppConfig(dict): diff --git a/dist/ba_data/python/babase/_asyncio.py b/dist/ba_data/python/babase/_asyncio.py index 3d49012..89f9ae7 100644 --- a/dist/ba_data/python/babase/_asyncio.py +++ b/dist/ba_data/python/babase/_asyncio.py @@ -23,8 +23,8 @@ if TYPE_CHECKING: import babase # Our timer and event loop for the ballistica logic thread. -_asyncio_timer: babase.AppTimer | None = None -_asyncio_event_loop: asyncio.AbstractEventLoop | None = None +_g_asyncio_timer: babase.AppTimer | None = None +_g_asyncio_event_loop: asyncio.AbstractEventLoop | None = None DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1' @@ -46,12 +46,12 @@ def setup_asyncio() -> asyncio.AbstractEventLoop: except RuntimeError: pass - global _asyncio_event_loop - _asyncio_event_loop = asyncio.new_event_loop() - _asyncio_event_loop.set_default_executor(babase.app.threadpool) + global _g_asyncio_event_loop + _g_asyncio_event_loop = asyncio.new_event_loop() + _g_asyncio_event_loop.set_default_executor(babase.app.threadpool) # Try to avoid reference loops from exceptions. - _asyncio_event_loop.set_exception_handler(_exception_handler) + _g_asyncio_event_loop.set_exception_handler(_exception_handler) # Ideally we should integrate asyncio into our C++ Thread class's # low level event loop so that asyncio timers/sockets/etc. could @@ -62,10 +62,10 @@ def setup_asyncio() -> asyncio.AbstractEventLoop: # See https://stackoverflow.com/questions/29782377/ # is-it-possible-to-run-only-a-single-step-of-the-asyncio-event-loop def run_cycle() -> None: - assert _asyncio_event_loop is not None - _asyncio_event_loop.call_soon(_asyncio_event_loop.stop) + assert _g_asyncio_event_loop is not None + _g_asyncio_event_loop.call_soon(_g_asyncio_event_loop.stop) starttime = time.monotonic() if DEBUG_TIMING else 0 - _asyncio_event_loop.run_forever() + _g_asyncio_event_loop.run_forever() endtime = time.monotonic() if DEBUG_TIMING else 0 # Let's aim to have nothing take longer than 1/120 of a second. @@ -79,21 +79,21 @@ def setup_asyncio() -> asyncio.AbstractEventLoop: warn_time, ) - global _asyncio_timer - _asyncio_timer = _babase.AppTimer(1.0 / 30.0, run_cycle, repeat=True) + global _g_asyncio_timer + _g_asyncio_timer = _babase.AppTimer(1.0 / 30.0, run_cycle, repeat=True) if bool(False): async def aio_test() -> None: print('TEST AIO TASK STARTING') - assert _asyncio_event_loop is not None - assert asyncio.get_running_loop() is _asyncio_event_loop + assert _g_asyncio_event_loop is not None + assert asyncio.get_running_loop() is _g_asyncio_event_loop await asyncio.sleep(2.0) print('TEST AIO TASK ENDING') - _testtask = _asyncio_event_loop.create_task(aio_test()) + _testtask = _g_asyncio_event_loop.create_task(aio_test()) - return _asyncio_event_loop + return _g_asyncio_event_loop def _exception_handler( diff --git a/dist/ba_data/python/babase/_hooks.py b/dist/ba_data/python/babase/_hooks.py index 5021d57..e5df00d 100644 --- a/dist/ba_data/python/babase/_hooks.py +++ b/dist/ba_data/python/babase/_hooks.py @@ -462,3 +462,36 @@ def copy_dev_console_history() -> None: _babase.clipboard_set_text('\n'.join(lines)) _babase.screenmessage(Lstr(resource='copyConfirmText'), color=(0, 1, 0)) _babase.getsimplesound('gunCocking').play() + + +def v2_auth_request(global_app_instance_id: str) -> None | tuple[bool, str]: + """Kick off or process v2 auth requests. + + Return None if no results or (success, error/token) + """ + assert _babase.app.plus is not None + out: None | tuple[bool, str] = _babase.app.plus.accounts.auth_request( + global_app_instance_id + ) + return out + + +def v2_auth_data(token: str) -> None | tuple[str, str, dict]: + """Look up autheneticated v2 account data via a token.""" + assert _babase.in_logic_thread() + + classic = _babase.app.classic + if classic is None: + return None + + now = time.monotonic() + authdata = classic.v2_auth_datas.get(token) + if authdata is None or authdata.expire_time <= now: + return None + + # Success! + return ( + authdata.account_id, + authdata.account_tag, + authdata.player_profiles, + ) diff --git a/dist/ba_data/python/baclassic/_appsubsystem.py b/dist/ba_data/python/baclassic/_appsubsystem.py index 65381dd..9f7ac83 100644 --- a/dist/ba_data/python/baclassic/_appsubsystem.py +++ b/dist/ba_data/python/baclassic/_appsubsystem.py @@ -6,10 +6,12 @@ from __future__ import annotations +import time import random import logging import weakref -from typing import TYPE_CHECKING, override, assert_never +from dataclasses import dataclass +from typing import TYPE_CHECKING, override, assert_never, final from efro.dataclassio import dataclass_from_dict import babase @@ -26,7 +28,8 @@ from baclassic._store import StoreSubsystem from baclassic import _input if TYPE_CHECKING: - from typing import Callable, Any, Sequence + import datetime + from typing import Callable, Any, Sequence, Awaitable import bacommon.classic import bacommon.clienteffect as clfx @@ -39,16 +42,72 @@ if TYPE_CHECKING: class ClassicAppSubsystem(babase.AppSubsystem): - """Subsystem for classic functionality in the app. + """Subsystem for classic bombsquad functionality in the app. The single shared instance of this app can be accessed at - babase.app.classic. Note that it is possible for babase.app.classic to - be None if the classic package is not present, and code should handle - that case gracefully. + babase.app.classic. Note that it is possible for babase.app.classic + to be None if the classic package is not present, and futureproof + code should handle that case gracefully. """ # pylint: disable=too-many-public-methods + @dataclass + class V2AuthRequest: + """What is passed in to V2 auth handler.""" + + #: V2 account id of the connecting account (a-XXX). + account_id: str + + #: Globally unique tag of the connecting account. + account_tag: str + + #: When the connecting account was created. + account_create_time: datetime.datetime + + #: Total number of days the connecting account has been active. + account_total_active_days: int + + #: An abstract value generated from the connecting client's + #: app-instance-id. Can be used to identify repeat connection + #: attempts/etc. Note that this value changes each time the + #: client re-launches the app. + app_instance_signature: str + + #: An abstract value generated from the connecting client's ip + #: address. Can be used to identify repeat connection + #: attempts/etc. + address_signature: str + + #: An abstract value generated from the connecting client's + #: device. Can be used to identify repeat connection + #: attempts/etc. This value may change over time for a given + #: device but should be mostly constant. + device_signature: str + + @dataclass + class V2AuthResponse: + """What a V2 auth handler returns.""" + + #: Whether to allow this client to enter the server. + allow: bool + + #: A message to be shown to the client if allow is False. A + #: defualt rejection message will be shown if none is present + #: here. This will be translated using the 'serverResponses' + #: translation category so it can be good to use one of the + #: entries there if your server has multilingual users. + error_message: str | None = None + + @dataclass + class V2AuthData: + """Authenticated data we store for accepted clients.""" + + account_id: str + account_tag: str + player_profiles: dict + expire_time: float + from baclassic._music import MusicPlayMode def __init__(self) -> None: @@ -95,6 +154,21 @@ class ClassicAppSubsystem(babase.AppSubsystem): # Server Mode. self.server: ServerController | None = None + #: V2 authentication handler. + #: + #: To customize who is allowed in your server, assign your own + #: custom handler function to this attribute. This will be called + #: for all connecting clients before they are allowed in the + #: game. Note that protocol must be set to 36 or newer and + #: authenticate_clients must be enabled. For servers you can set + #: those values in the server config. + self.v2_auth_handler: Callable[ + [ClassicAppSubsystem.V2AuthRequest], + Awaitable[ClassicAppSubsystem.V2AuthResponse], + ] = self.default_v2_auth_handler + self.v2_auth_datas: dict[str, ClassicAppSubsystem.V2AuthData] = {} + + # Logging/debugging. self.log_have_new = False self.log_upload_timer_started = False self.printed_live_object_warning = False @@ -129,8 +203,48 @@ class ClassicAppSubsystem(babase.AppSubsystem): self.pro_sale_start_time: int | None = None self.pro_sale_start_val: int | None = None + async def default_v2_auth_handler( + self, request: V2AuthRequest + ) -> V2AuthResponse: + """Default auth handler function. Just allows everyone.""" + + # A custom handler would look at request here to determine + # whether to let this client in. + del request # Unused. + + return self.V2AuthResponse(allow=True) + + @final + async def run_v2_auth_handler( + self, request: V2AuthRequest, player_profiles: Any, token: str + ) -> V2AuthResponse: + """:meta private:""" + assert babase.in_logic_thread() + result = await self.v2_auth_handler(request) + + # If it was accepted, keep their auth data around just long enough + # for them to connect. + if result.allow: + now = time.monotonic() + self.v2_auth_datas[token] = self.V2AuthData( + account_id=request.account_id, + account_tag=request.account_tag, + player_profiles=player_profiles, + expire_time=now + 30.0, + ) + + # Lazily prune expired auth-data. + if any(a.expire_time <= now for a in self.v2_auth_datas.values()): + self.v2_auth_datas = { + k: v + for k, v in self.v2_auth_datas.items() + if v.expire_time > now + } + + return result + def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None: - """(internal)""" + """:meta private:""" # If there's no main window up, just call immediately. if not babase.app.ui_v1.has_main_window(): @@ -174,7 +288,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): return self._env['platform'] def scene_v1_protocol_version(self) -> int: - """(internal)""" + """:meta private:""" return bascenev1.protocol_version() @property @@ -464,7 +578,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): ) def game_begin_analytics(self) -> None: - """(internal)""" + """:meta private:""" from baclassic import _analytics _analytics.game_begin_analytics() @@ -656,11 +770,11 @@ class ClassicAppSubsystem(babase.AppSubsystem): return bascenev1.get_player_profile_colors(profilename, profiles) def get_foreground_host_session(self) -> bascenev1.Session | None: - """(internal)""" + """:meta private:""" return bascenev1.get_foreground_host_session() def get_foreground_host_activity(self) -> bascenev1.Activity | None: - """(internal)""" + """:meta private:""" return bascenev1.get_foreground_host_activity() def value_test( @@ -669,26 +783,26 @@ class ClassicAppSubsystem(babase.AppSubsystem): change: float | None = None, absolute: float | None = None, ) -> float: - """(internal)""" + """:meta private:""" return _baclassic.value_test(arg, change, absolute) def set_master_server_source(self, source: int) -> None: - """(internal)""" + """:meta private:""" bascenev1.set_master_server_source(source) def get_game_port(self) -> int: - """(internal)""" + """:meta private:""" return bascenev1.get_game_port() def v2_upgrade_window(self, login_name: str, code: str) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.v2upgrade import V2UpgradeWindow V2UpgradeWindow(login_name, code) def server_dialog(self, delay: float, data: dict[str, Any]) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.serverdialog import ( ServerDialogData, ServerDialogWindow, @@ -709,13 +823,13 @@ class ClassicAppSubsystem(babase.AppSubsystem): ) def show_url_window(self, address: str) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.url import ShowURLWindow ShowURLWindow(address) def quit_window(self, quit_type: babase.QuitType) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.confirm import QuitWindow QuitWindow(quit_type) @@ -731,7 +845,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): offset: tuple[float, float] = (0.0, 0.0), on_close_call: Callable[[], Any] | None = None, ) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.tournamententry import TournamentEntryWindow TournamentEntryWindow( @@ -745,7 +859,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): ) def get_main_menu_session(self) -> type[bascenev1.Session]: - """(internal)""" + """:meta private:""" from bascenev1lib.mainmenu import MainMenuSession return MainMenuSession @@ -796,7 +910,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): logging.exception('Error preloading map preview media.') def party_icon_activate(self, origin: Sequence[float]) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.party import PartyWindow from babase import app @@ -817,7 +931,7 @@ class ClassicAppSubsystem(babase.AppSubsystem): self.party_window = weakref.ref(PartyWindow(origin=origin)) def request_main_ui(self) -> None: - """(internal)""" + """:meta private:""" from bauiv1lib.ingamemenu import InGameMenuWindow assert babase.app is not None diff --git a/dist/ba_data/python/baclassic/_servermode.py b/dist/ba_data/python/baclassic/_servermode.py index d083818..a1eb898 100644 --- a/dist/ba_data/python/baclassic/_servermode.py +++ b/dist/ba_data/python/baclassic/_servermode.py @@ -108,6 +108,12 @@ class ServerController: self._playlist_fetch_got_response = False self._playlist_fetch_code = -1 + # We do most configuration *after* we've fetched playlists or + # whatever other prep stuff we need to do, but this we should + # set immediately; otherwise unauthenticated clients can sneak + # into auth-enabled servers while they're bootstrapping. + bascenev1.set_authenticate_clients(self._config.authenticate_clients) + # Now sit around doing any pre-launch prep such as waiting for # account sign-in or fetching playlists; this will kick off the # session once done. @@ -428,8 +434,6 @@ class ServerController: classic.teams_series_length = self._config.teams_series_length classic.ffa_series_length = self._config.ffa_series_length - bascenev1.set_authenticate_clients(self._config.authenticate_clients) - bascenev1.set_enable_default_kick_voting( self._config.enable_default_kick_voting ) @@ -452,7 +456,6 @@ class ServerController: bascenev1.set_player_rejoin_cooldown( self._config.player_rejoin_cooldown ) - bascenev1.set_max_players_override( self._config.session_max_players_override ) @@ -471,6 +474,6 @@ class ServerController: bascenev1.new_host_session(sessiontype) # Run an access check if we're trying to make a public party. - if not self._ran_access_check : + if self._config.party_is_public and not self._ran_access_check: self._run_access_check() self._ran_access_check = True diff --git a/dist/ba_data/python/bacommon/cloud.py b/dist/ba_data/python/bacommon/cloud.py index 1895a66..c12690b 100644 --- a/dist/ba_data/python/bacommon/cloud.py +++ b/dist/ba_data/python/bacommon/cloud.py @@ -462,3 +462,25 @@ class AnalyticsEventMessage(Message): """Have a nice analytics event!""" event: Annotated[AnalyticsEvent, IOAttrs('e')] + + +@ioprepped +@dataclass +class AuthRequestMessage(Message): + """Request access to a server for a current account.""" + + global_app_instance_uuid: Annotated[str, IOAttrs('a')] + + @override + @classmethod + def get_response_types(cls) -> list[type[Response] | None]: + return [AuthRequestResponse] + + +@ioprepped +@dataclass +class AuthRequestResponse(Response): + """Here's that access ya asked for boss.""" + + error: Annotated[str | None, IOAttrs('e')] + token: Annotated[str | None, IOAttrs('t')] diff --git a/dist/ba_data/python/bacommon/servermanager.py b/dist/ba_data/python/bacommon/servermanager.py index 1b464dc..e053098 100644 --- a/dist/ba_data/python/bacommon/servermanager.py +++ b/dist/ba_data/python/bacommon/servermanager.py @@ -30,13 +30,19 @@ class ServerConfig: # 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. + # internet connection. Note that if you set protocol_version to 36 + # or newer, client authentication uses V2 account info. This is + # highly recommended as it does not have spoofing vulnerabilities + # like the earlier V1 authentication. 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. + # a vote. If protocol_version is set to 36 or newer this will use V2 + # account ids (a-XXX); otherwise it will use V1 ids (pb-XXX). To get + # your V2 account id, poke the 'manage account' button in the + # account window in-game. To get your V1 account id, enter + # 'getaccountid' in Settings->Advanced->Send Info admins: list[str] = field(default_factory=list) # Whether the default kick-voting system is enabled. @@ -176,7 +182,10 @@ class ServerConfig: # Protocol version we host with. Currently the default is 33 which # still allows older 1.4 game clients to connect. Explicitly setting # to 35 no longer allows those clients but adds/fixes a few things - # such as making camera shake properly work in net games. + # such as making camera shake properly work in net games. Protocol + # 36 enables V2 account ids (a-XXX) for client authentication, which + # does not suffer from spoofing vulnerabilities that V1 account ids + # (pb-XXX) did. protocol_version: int | None = None # (internal) stress-testing mode. diff --git a/dist/ba_data/python/baenv.py b/dist/ba_data/python/baenv.py index 45e0309..a9995ce 100644 --- a/dist/ba_data/python/baenv.py +++ b/dist/ba_data/python/baenv.py @@ -57,7 +57,7 @@ logger = logging.getLogger('ba.env') # Build number and version of the ballistica binary we expect to be # using. -TARGET_BALLISTICA_BUILD = 22712 +TARGET_BALLISTICA_BUILD = 22714 TARGET_BALLISTICA_VERSION = '1.7.61' diff --git a/dist/ba_data/python/baplus/_cloud.py b/dist/ba_data/python/baplus/_cloud.py index 83d527a..bdf2e0a 100644 --- a/dist/ba_data/python/baplus/_cloud.py +++ b/dist/ba_data/python/baplus/_cloud.py @@ -332,6 +332,16 @@ class CloudSubsystem(babase.AppSubsystem): ], ) -> None: ... + @overload + def send_message_cb( + self, + msg: bacommon.cloud.AuthRequestMessage, + on_response: Callable[ + [bacommon.cloud.AuthRequestResponse | Exception], + None, + ], + ) -> None: ... + def send_message_cb( self, msg: Message, diff --git a/dist/ba_data/python/bascenev1/_lobby.py b/dist/ba_data/python/bascenev1/_lobby.py index df73f3d..69814a5 100644 --- a/dist/ba_data/python/bascenev1/_lobby.py +++ b/dist/ba_data/python/bascenev1/_lobby.py @@ -339,10 +339,10 @@ class Chooser: if inputdevice.is_controller_app and '_random' in profilenames: return profilenames.index('_random') - # If its a client connection, for now just force - # the account profile if possible.. (need to provide a - # way for clients to specify/remember their default - # profile on remote servers that do not already know them). + # If its a client connection, for now just force the account + # profile if possible. (need to provide a way for clients to + # specify/remember their default profile on remote servers that + # do not already know them). if inputdevice.is_remote_client and '__account__' in profilenames: return profilenames.index('__account__') diff --git a/dist/ba_data/python/bascenev1/_session.py b/dist/ba_data/python/bascenev1/_session.py index b93bb50..b021839 100644 --- a/dist/ba_data/python/bascenev1/_session.py +++ b/dist/ba_data/python/bascenev1/_session.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: _g_player_rejoin_cooldown: float = 0.0 # overrides the session's decision of max_players -_max_players_override: int | None = None +_g_max_players_override: int | None = None def set_player_rejoin_cooldown(cooldown: float) -> None: @@ -36,8 +36,8 @@ def set_player_rejoin_cooldown(cooldown: float) -> None: def set_max_players_override(max_players: int | None) -> None: """Set the override for how many players can join a session""" - global _max_players_override # pylint: disable=global-statement - _max_players_override = max_players + global _g_max_players_override # pylint: disable=global-statement + _g_max_players_override = max_players class Session: @@ -176,8 +176,8 @@ class Session: self.min_players = min_players self.max_players = ( max_players - if _max_players_override is None - else _max_players_override + if _g_max_players_override is None + else _g_max_players_override ) self.submit_score = submit_score diff --git a/dist/ba_data/python/bauiv1/__init__.py b/dist/ba_data/python/bauiv1/__init__.py index e3887c9..8a06a83 100644 --- a/dist/ba_data/python/bauiv1/__init__.py +++ b/dist/ba_data/python/bauiv1/__init__.py @@ -14,8 +14,6 @@ from __future__ import annotations # pylint: disable=redefined-builtin -import logging - from babase import ( accountlog, AccountV2Handle, @@ -319,7 +317,7 @@ __all__ = [ if __debug__: for _mdl in 'babase', '_babase': if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'): - logging.warning( + balog.warning( '%s was imported before %s finished importing;' ' should not happen.', __name__, diff --git a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py index 36d964f..621bda8 100644 --- a/dist/ba_data/python/bauiv1lib/gather/nearbytab.py +++ b/dist/ba_data/python/bauiv1lib/gather/nearbytab.py @@ -45,14 +45,17 @@ class NetScanner: bui.widget(edit=self._columnwidget, up_widget=tab_button) self._width = width self._last_selected_host: dict[str, Any] | None = None + self._last_scan: list[dict[str, str]] | None = None self._update_timer = bui.AppTimer( - 1.0, bui.WeakCallStrict(self.update), repeat=True + 1.0, bui.WeakCallStrict(self._update), repeat=True ) - # Go ahead and run a few *almost* immediately so we don't - # have to wait a second. - self.update() - bui.apptimer(0.25, bui.WeakCallStrict(self.update)) + # Run two cycles pretty immediately - this should send out a + # "who's there" and update the list with any immediate-ish + # results so we may not have to wait a second to see things + # appear. + self._update() + bui.apptimer(0.25, bui.WeakCallStrict(self._update)) def __del__(self) -> None: bs.end_host_scanning() @@ -74,23 +77,33 @@ class NetScanner: bs.connect_to_party(host['address']) - def update(self) -> None: + def _update(self) -> None: """(internal)""" # In case our UI was killed from under us. if not self._columnwidget: - print( - f'ERROR: NetScanner running without UI at time {bui.apptime()}.' + bui.uilog.error( + 'nearbytab NetScanner running without UI at time %s.', + bui.apptime(), ) return + hosts = bs.host_scan_cycle() + + # If nothing has changed since our last run, do nothing. If we + # do redundant rebuilds then we are likely to lose some clicks + # due to rebuilding after a click starts but before it ends. + if hosts == self._last_scan: + return + + self._last_scan = hosts + t_scale = 1.6 for child in self._columnwidget.get_children(): child.delete() # Grab this now this since adding widgets will change it. last_selected_host = self._last_selected_host - hosts = bs.host_scan_cycle() for i, host in enumerate(hosts): txt3 = bui.textwidget( parent=self._columnwidget, diff --git a/dist/ba_data/python/efro/terminal.py b/dist/ba_data/python/efro/terminal.py index 796d693..4ddbcf1 100644 --- a/dist/ba_data/python/efro/terminal.py +++ b/dist/ba_data/python/efro/terminal.py @@ -315,7 +315,7 @@ class ClrNever(ClrBase): _envval = os.environ.get('EFRO_TERMCOLORS') -color_enabled: bool = ( +color_enabled: bool = ( # pylint: disable=invalid-name True if _envval == '1' else False if _envval == '0' else _default_color_enabled() diff --git a/dist/ba_data/python/efro/util.py b/dist/ba_data/python/efro/util.py index 74a7d2f..9ded139 100644 --- a/dist/ba_data/python/efro/util.py +++ b/dist/ba_data/python/efro/util.py @@ -1089,3 +1089,17 @@ def strip_exception_tracebacks(exc: BaseException) -> None: cause = getattr(e, '__cause__', None) if cause is not None: stack.append(cause) + + +def secure_id() -> str: + """Generate a 20 char cryptographically secure string. + + Basically what firestore does for its random document ids. + If its good enough for firestore its good enough for us. + """ + import secrets + import string + + alphabet = string.ascii_letters + string.digits # 62 chars + + return ''.join(secrets.choice(alphabet) for _ in range(20)) diff --git a/dist/bombsquad_headless b/dist/bombsquad_headless index b324e25..aa48416 100644 Binary files a/dist/bombsquad_headless and b/dist/bombsquad_headless differ