mirror of
https://github.com/imayushsaini/Bombsquad-Ballistica-Modded-Server.git
synced 2025-11-14 17:46:03 +00:00
hello API 8 !
This commit is contained in:
parent
3a2b6ade68
commit
0284fee95c
1166 changed files with 26061 additions and 375100 deletions
307
dist/ba_data/python/babase/__init__.py
vendored
Normal file
307
dist/ba_data/python/babase/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Common shared Ballistica components.
|
||||
|
||||
For modding purposes, this package should generally not be used directly.
|
||||
Instead one should use purpose-built packages such as bascenev1 or bauiv1
|
||||
which themselves import various functionality from here and reexpose it in
|
||||
a more focused way.
|
||||
"""
|
||||
# pylint: disable=redefined-builtin
|
||||
|
||||
# The stuff we expose here at the top level is our 'public' api for use
|
||||
# from other modules/packages. Code *within* this package should import
|
||||
# things from this package's submodules directly to reduce the chance of
|
||||
# dependency loops. The exception is TYPE_CHECKING blocks and
|
||||
# annotations since those aren't evaluated at runtime.
|
||||
|
||||
from efro.util import set_canonical_module_names
|
||||
|
||||
|
||||
import _babase
|
||||
from _babase import (
|
||||
add_clean_frame_callback,
|
||||
android_get_external_files_dir,
|
||||
appname,
|
||||
appnameupper,
|
||||
apptime,
|
||||
apptimer,
|
||||
AppTimer,
|
||||
charstr,
|
||||
clipboard_get_text,
|
||||
clipboard_has_text,
|
||||
clipboard_is_supported,
|
||||
clipboard_set_text,
|
||||
ContextCall,
|
||||
ContextRef,
|
||||
displaytime,
|
||||
displaytimer,
|
||||
DisplayTimer,
|
||||
do_once,
|
||||
env,
|
||||
fade_screen,
|
||||
fatal_error,
|
||||
get_display_resolution,
|
||||
get_immediate_return_code,
|
||||
get_low_level_config_value,
|
||||
get_max_graphics_quality,
|
||||
get_replays_dir,
|
||||
get_string_height,
|
||||
get_string_width,
|
||||
getsimplesound,
|
||||
has_gamma_control,
|
||||
have_chars,
|
||||
have_permission,
|
||||
in_logic_thread,
|
||||
increment_analytics_count,
|
||||
is_os_playing_music,
|
||||
is_running_on_fire_tv,
|
||||
is_xcode_build,
|
||||
lock_all_input,
|
||||
mac_music_app_get_library_source,
|
||||
mac_music_app_get_playlists,
|
||||
mac_music_app_get_volume,
|
||||
mac_music_app_init,
|
||||
mac_music_app_play_playlist,
|
||||
mac_music_app_set_volume,
|
||||
mac_music_app_stop,
|
||||
music_player_play,
|
||||
music_player_set_volume,
|
||||
music_player_shutdown,
|
||||
music_player_stop,
|
||||
native_stack_trace,
|
||||
print_load_info,
|
||||
pushcall,
|
||||
quit,
|
||||
reload_media,
|
||||
request_permission,
|
||||
safecolor,
|
||||
screenmessage,
|
||||
set_analytics_screen,
|
||||
set_low_level_config_value,
|
||||
set_stress_testing,
|
||||
set_thread_name,
|
||||
set_ui_input_device,
|
||||
show_progress_bar,
|
||||
SimpleSound,
|
||||
unlock_all_input,
|
||||
user_agent_string,
|
||||
Vec3,
|
||||
workspaces_in_use,
|
||||
)
|
||||
|
||||
from babase._accountv2 import AccountV2Handle, AccountV2Subsystem
|
||||
from babase._app import App
|
||||
from babase._appconfig import commit_app_config
|
||||
from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec
|
||||
from babase._appmode import AppMode
|
||||
from babase._appsubsystem import AppSubsystem
|
||||
from babase._appconfig import AppConfig
|
||||
from babase._apputils import (
|
||||
handle_leftover_v1_cloud_log_file,
|
||||
is_browser_likely_available,
|
||||
garbage_collect,
|
||||
get_remote_app_name,
|
||||
)
|
||||
from babase._cloud import CloudSubsystem
|
||||
from babase._emptyappmode import EmptyAppMode
|
||||
from babase._error import (
|
||||
print_exception,
|
||||
print_error,
|
||||
ContextError,
|
||||
NotFoundError,
|
||||
PlayerNotFoundError,
|
||||
SessionPlayerNotFoundError,
|
||||
NodeNotFoundError,
|
||||
ActorNotFoundError,
|
||||
InputDeviceNotFoundError,
|
||||
WidgetNotFoundError,
|
||||
ActivityNotFoundError,
|
||||
TeamNotFoundError,
|
||||
MapNotFoundError,
|
||||
SessionTeamNotFoundError,
|
||||
SessionNotFoundError,
|
||||
DelegateNotFoundError,
|
||||
)
|
||||
from babase._general import (
|
||||
utf8_all,
|
||||
DisplayTime,
|
||||
AppTime,
|
||||
WeakCall,
|
||||
Call,
|
||||
existing,
|
||||
Existable,
|
||||
verify_object_death,
|
||||
storagename,
|
||||
getclass,
|
||||
get_type_name,
|
||||
json_prep,
|
||||
)
|
||||
from babase._keyboard import Keyboard
|
||||
from babase._language import Lstr, LanguageSubsystem
|
||||
from babase._login import LoginAdapter
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
# (PyCharm inspection bug?)
|
||||
from babase._mgen.enums import (
|
||||
Permission,
|
||||
SpecialChar,
|
||||
InputType,
|
||||
UIScale,
|
||||
)
|
||||
from babase._math import normalized_color, is_point_in_box, vec3validate
|
||||
from babase._meta import MetadataSubsystem
|
||||
from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS
|
||||
from babase._plugin import PluginSpec, Plugin, PluginSubsystem
|
||||
from babase._text import timestring
|
||||
|
||||
_babase.app = app = App()
|
||||
app.postinit()
|
||||
|
||||
__all__ = [
|
||||
'AccountV2Handle',
|
||||
'AccountV2Subsystem',
|
||||
'ActivityNotFoundError',
|
||||
'ActorNotFoundError',
|
||||
'add_clean_frame_callback',
|
||||
'android_get_external_files_dir',
|
||||
'app',
|
||||
'app',
|
||||
'App',
|
||||
'AppConfig',
|
||||
'AppIntent',
|
||||
'AppIntentDefault',
|
||||
'AppIntentExec',
|
||||
'AppMode',
|
||||
'appname',
|
||||
'appnameupper',
|
||||
'AppSubsystem',
|
||||
'apptime',
|
||||
'AppTime',
|
||||
'apptime',
|
||||
'apptimer',
|
||||
'AppTimer',
|
||||
'Call',
|
||||
'charstr',
|
||||
'clipboard_get_text',
|
||||
'clipboard_has_text',
|
||||
'clipboard_is_supported',
|
||||
'clipboard_set_text',
|
||||
'CloudSubsystem',
|
||||
'commit_app_config',
|
||||
'ContextCall',
|
||||
'ContextError',
|
||||
'ContextRef',
|
||||
'DelegateNotFoundError',
|
||||
'DisplayTime',
|
||||
'displaytime',
|
||||
'displaytimer',
|
||||
'DisplayTimer',
|
||||
'do_once',
|
||||
'EmptyAppMode',
|
||||
'env',
|
||||
'Existable',
|
||||
'existing',
|
||||
'fade_screen',
|
||||
'fatal_error',
|
||||
'garbage_collect',
|
||||
'get_display_resolution',
|
||||
'get_immediate_return_code',
|
||||
'get_ip_address_type',
|
||||
'get_low_level_config_value',
|
||||
'get_max_graphics_quality',
|
||||
'get_remote_app_name',
|
||||
'get_replays_dir',
|
||||
'get_string_height',
|
||||
'get_string_width',
|
||||
'get_type_name',
|
||||
'getclass',
|
||||
'getsimplesound',
|
||||
'handle_leftover_v1_cloud_log_file',
|
||||
'has_gamma_control',
|
||||
'have_chars',
|
||||
'have_permission',
|
||||
'in_logic_thread',
|
||||
'increment_analytics_count',
|
||||
'InputDeviceNotFoundError',
|
||||
'InputType',
|
||||
'is_browser_likely_available',
|
||||
'is_browser_likely_available',
|
||||
'is_os_playing_music',
|
||||
'is_point_in_box',
|
||||
'is_running_on_fire_tv',
|
||||
'is_xcode_build',
|
||||
'json_prep',
|
||||
'Keyboard',
|
||||
'LanguageSubsystem',
|
||||
'lock_all_input',
|
||||
'LoginAdapter',
|
||||
'Lstr',
|
||||
'mac_music_app_get_library_source',
|
||||
'mac_music_app_get_playlists',
|
||||
'mac_music_app_get_volume',
|
||||
'mac_music_app_init',
|
||||
'mac_music_app_play_playlist',
|
||||
'mac_music_app_set_volume',
|
||||
'mac_music_app_stop',
|
||||
'MapNotFoundError',
|
||||
'MetadataSubsystem',
|
||||
'music_player_play',
|
||||
'music_player_set_volume',
|
||||
'music_player_shutdown',
|
||||
'music_player_stop',
|
||||
'native_stack_trace',
|
||||
'NodeNotFoundError',
|
||||
'normalized_color',
|
||||
'NotFoundError',
|
||||
'Permission',
|
||||
'PlayerNotFoundError',
|
||||
'Plugin',
|
||||
'PluginSubsystem',
|
||||
'PluginSpec',
|
||||
'print_error',
|
||||
'print_exception',
|
||||
'print_load_info',
|
||||
'pushcall',
|
||||
'quit',
|
||||
'reload_media',
|
||||
'request_permission',
|
||||
'safecolor',
|
||||
'screenmessage',
|
||||
'SessionNotFoundError',
|
||||
'SessionPlayerNotFoundError',
|
||||
'SessionTeamNotFoundError',
|
||||
'set_analytics_screen',
|
||||
'set_low_level_config_value',
|
||||
'set_stress_testing',
|
||||
'set_thread_name',
|
||||
'set_ui_input_device',
|
||||
'show_progress_bar',
|
||||
'SimpleSound',
|
||||
'SpecialChar',
|
||||
'storagename',
|
||||
'TeamNotFoundError',
|
||||
'timestring',
|
||||
'UIScale',
|
||||
'unlock_all_input',
|
||||
'user_agent_string',
|
||||
'utf8_all',
|
||||
'Vec3',
|
||||
'vec3validate',
|
||||
'verify_object_death',
|
||||
'WeakCall',
|
||||
'WidgetNotFoundError',
|
||||
'workspaces_in_use',
|
||||
'DEFAULT_REQUEST_TIMEOUT_SECONDS',
|
||||
]
|
||||
|
||||
# We want stuff to show up as babase.Foo instead of babase._sub.Foo.
|
||||
set_canonical_module_names(globals())
|
||||
|
||||
# Allow the native layer to wrap a few things up.
|
||||
_babase.reached_end_of_babase()
|
||||
|
||||
# Marker we pop down at the very end so other modules can run sanity
|
||||
# checks to make sure we aren't importing them reciprocally when they
|
||||
# import us.
|
||||
_REACHED_END_OF_MODULE = True
|
||||
441
dist/ba_data/python/babase/_accountv2.py
vendored
Normal file
441
dist/ba_data/python/babase/_accountv2.py
vendored
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Account related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.call import tpartial
|
||||
from efro.error import CommunicationError
|
||||
from bacommon.login import LoginType
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
from babase._login import LoginAdapter
|
||||
|
||||
|
||||
DEBUG_LOG = False
|
||||
|
||||
|
||||
class AccountV2Subsystem:
|
||||
"""Subsystem for modern account handling in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at 'ba.app.accounts'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Whether or not everything related to an initial login
|
||||
# (or lack thereof) has completed. This includes things like
|
||||
# workspace syncing. Completion of this is what flips the app
|
||||
# into 'running' state.
|
||||
self._initial_sign_in_completed = False
|
||||
|
||||
self._kicked_off_workspace_load = False
|
||||
|
||||
self.login_adapters: dict[LoginType, LoginAdapter] = {}
|
||||
|
||||
self._implicit_signed_in_adapter: LoginAdapter | None = None
|
||||
self._implicit_state_changed = False
|
||||
self._can_do_auto_sign_in = True
|
||||
|
||||
if _babase.app.classic is None:
|
||||
raise RuntimeError('Needs updating for no-classic case.')
|
||||
|
||||
if (
|
||||
_babase.app.classic.platform == 'android'
|
||||
and _babase.app.classic.subplatform == 'google'
|
||||
):
|
||||
from babase._login import LoginAdapterGPGS
|
||||
|
||||
self.login_adapters[LoginType.GPGS] = LoginAdapterGPGS()
|
||||
|
||||
def on_app_loading(self) -> None:
|
||||
"""Should be called at standard on_app_loading time."""
|
||||
|
||||
for adapter in self.login_adapters.values():
|
||||
adapter.on_app_loading()
|
||||
|
||||
def set_primary_credentials(self, credentials: str | None) -> None:
|
||||
"""Set credentials for the primary app account."""
|
||||
raise RuntimeError('This should be overridden.')
|
||||
|
||||
def have_primary_credentials(self) -> bool:
|
||||
"""Are credentials currently set for the primary app account?
|
||||
|
||||
Note that this does not mean these credentials are currently valid;
|
||||
only that they exist. If/when credentials are validated, the 'primary'
|
||||
account handle will be set.
|
||||
"""
|
||||
raise RuntimeError('This should be overridden.')
|
||||
|
||||
@property
|
||||
def primary(self) -> AccountV2Handle | None:
|
||||
"""The primary account for the app, or None if not logged in."""
|
||||
return self.do_get_primary()
|
||||
|
||||
def do_get_primary(self) -> AccountV2Handle | None:
|
||||
"""Internal - should be overridden by subclass."""
|
||||
return None
|
||||
|
||||
def on_primary_account_changed(
|
||||
self, account: AccountV2Handle | None
|
||||
) -> None:
|
||||
"""Callback run after the primary account changes.
|
||||
|
||||
Will be called with None on log-outs and when new credentials
|
||||
are set but have not yet been verified.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Currently don't do anything special on sign-outs.
|
||||
if account is None:
|
||||
return
|
||||
|
||||
# If this new account has a workspace, update it and ask to be
|
||||
# informed when that process completes.
|
||||
if account.workspaceid is not None:
|
||||
assert account.workspacename is not None
|
||||
if (
|
||||
not self._initial_sign_in_completed
|
||||
and not self._kicked_off_workspace_load
|
||||
):
|
||||
self._kicked_off_workspace_load = True
|
||||
_babase.app.workspaces.set_active_workspace(
|
||||
account=account,
|
||||
workspaceid=account.workspaceid,
|
||||
workspacename=account.workspacename,
|
||||
on_completed=self._on_set_active_workspace_completed,
|
||||
)
|
||||
else:
|
||||
# Don't activate workspaces if we've already told the game
|
||||
# that initial-log-in is done or if we've already kicked
|
||||
# off a workspace load.
|
||||
_babase.screenmessage(
|
||||
f'\'{account.workspacename}\''
|
||||
f' will be activated at next app launch.',
|
||||
color=(1, 1, 0),
|
||||
)
|
||||
_babase.getsimplesound('error').play()
|
||||
return
|
||||
|
||||
# Ok; no workspace to worry about; carry on.
|
||||
if not self._initial_sign_in_completed:
|
||||
self._initial_sign_in_completed = True
|
||||
_babase.app.on_initial_sign_in_completed()
|
||||
|
||||
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
|
||||
"""Should be called when logins for the active account change."""
|
||||
|
||||
for adapter in self.login_adapters.values():
|
||||
adapter.set_active_logins(logins)
|
||||
|
||||
def on_implicit_sign_in(
|
||||
self, login_type: LoginType, login_id: str, display_name: str
|
||||
) -> None:
|
||||
"""An implicit sign-in happened (called by native layer)."""
|
||||
from babase._login import LoginAdapter
|
||||
|
||||
with _babase.ContextRef.empty():
|
||||
self.login_adapters[login_type].set_implicit_login_state(
|
||||
LoginAdapter.ImplicitLoginState(
|
||||
login_id=login_id, display_name=display_name
|
||||
)
|
||||
)
|
||||
|
||||
def on_implicit_sign_out(self, login_type: LoginType) -> None:
|
||||
"""An implicit sign-out happened (called by native layer)."""
|
||||
with _babase.ContextRef.empty():
|
||||
self.login_adapters[login_type].set_implicit_login_state(None)
|
||||
|
||||
def on_no_initial_primary_account(self) -> None:
|
||||
"""Callback run if the app has no primary account after launch.
|
||||
|
||||
Either this callback or on_primary_account_changed will be called
|
||||
within a few seconds of app launch; the app can move forward
|
||||
with the startup sequence at that point.
|
||||
"""
|
||||
if not self._initial_sign_in_completed:
|
||||
self._initial_sign_in_completed = True
|
||||
_babase.app.on_initial_sign_in_completed()
|
||||
|
||||
@staticmethod
|
||||
def _hashstr(val: str) -> str:
|
||||
md5 = hashlib.md5()
|
||||
md5.update(val.encode())
|
||||
return md5.hexdigest()
|
||||
|
||||
def on_implicit_login_state_changed(
|
||||
self,
|
||||
login_type: LoginType,
|
||||
state: LoginAdapter.ImplicitLoginState | None,
|
||||
) -> None:
|
||||
"""Called when implicit login state changes.
|
||||
|
||||
Login systems that tend to sign themselves in/out in the
|
||||
background are considered implicit. We may choose to honor or
|
||||
ignore their states, allowing the user to opt for other login
|
||||
types even if the default implicit one can't be explicitly
|
||||
logged out or otherwise controlled.
|
||||
"""
|
||||
from babase._language import Lstr
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
cfg = _babase.app.config
|
||||
cfgkey = 'ImplicitLoginStates'
|
||||
cfgdict = _babase.app.config.setdefault(cfgkey, {})
|
||||
|
||||
# Store which (if any) adapter is currently implicitly signed in.
|
||||
# Making the assumption there will only ever be one implicit
|
||||
# adapter at a time; may need to update this if that changes.
|
||||
prev_state = cfgdict.get(login_type.value)
|
||||
if state is None:
|
||||
self._implicit_signed_in_adapter = None
|
||||
new_state = cfgdict[login_type.value] = None
|
||||
else:
|
||||
self._implicit_signed_in_adapter = self.login_adapters[login_type]
|
||||
new_state = cfgdict[login_type.value] = self._hashstr(
|
||||
state.login_id
|
||||
)
|
||||
|
||||
# Special case: if the user is already signed in but not with
|
||||
# this implicit login, we may want to let them know that the
|
||||
# 'Welcome back FOO' they likely just saw is not actually
|
||||
# accurate.
|
||||
if (
|
||||
self.primary is not None
|
||||
and not self.login_adapters[login_type].is_back_end_active()
|
||||
):
|
||||
if login_type is LoginType.GPGS:
|
||||
service_str = Lstr(resource='googlePlayText')
|
||||
else:
|
||||
service_str = None
|
||||
if service_str is not None:
|
||||
_babase.apptimer(
|
||||
2.0,
|
||||
tpartial(
|
||||
_babase.screenmessage,
|
||||
Lstr(
|
||||
resource='notUsingAccountText',
|
||||
subs=[
|
||||
('${ACCOUNT}', state.display_name),
|
||||
('${SERVICE}', service_str),
|
||||
],
|
||||
),
|
||||
(1, 0.5, 0),
|
||||
),
|
||||
)
|
||||
|
||||
cfg.commit()
|
||||
|
||||
# We want to respond any time the implicit state changes;
|
||||
# generally this means the user has explicitly signed in/out or
|
||||
# switched accounts within that back-end.
|
||||
if prev_state != new_state:
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Implicit state changed (%s -> %s);'
|
||||
' will update app sign-in state accordingly.',
|
||||
prev_state,
|
||||
new_state,
|
||||
)
|
||||
self._implicit_state_changed = True
|
||||
|
||||
# We may want to auto-sign-in based on this new state.
|
||||
self._update_auto_sign_in()
|
||||
|
||||
def on_cloud_connectivity_changed(self, connected: bool) -> None:
|
||||
"""Should be called with cloud connectivity changes."""
|
||||
del connected # Unused.
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# We may want to auto-sign-in based on this new state.
|
||||
self._update_auto_sign_in()
|
||||
|
||||
def _update_auto_sign_in(self) -> None:
|
||||
plus = _babase.app.plus
|
||||
assert plus is not None
|
||||
|
||||
# If implicit state has changed, try to respond.
|
||||
if self._implicit_state_changed:
|
||||
if self._implicit_signed_in_adapter is None:
|
||||
# If implicit back-end is signed out, follow suit
|
||||
# immediately; no need to wait for network connectivity.
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Signing out as result'
|
||||
' of implicit state change...',
|
||||
)
|
||||
plus.accounts.set_primary_credentials(None)
|
||||
self._implicit_state_changed = False
|
||||
|
||||
# Once we've made a move here we don't want to
|
||||
# do any more automatic stuff.
|
||||
self._can_do_auto_sign_in = False
|
||||
|
||||
else:
|
||||
# Ok; we've got a new implicit state. If we've got
|
||||
# connectivity, let's attempt to sign in with it.
|
||||
# Consider this an 'explicit' sign in because the
|
||||
# implicit-login state change presumably was triggered
|
||||
# by some user action (signing in, signing out, or
|
||||
# switching accounts via the back-end).
|
||||
# NOTE: should test case where we don't have
|
||||
# connectivity here.
|
||||
if plus.cloud.is_connected():
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Signing in as result'
|
||||
' of implicit state change...',
|
||||
)
|
||||
self._implicit_signed_in_adapter.sign_in(
|
||||
self._on_explicit_sign_in_completed,
|
||||
description='implicit state change',
|
||||
)
|
||||
self._implicit_state_changed = False
|
||||
|
||||
# Once we've made a move here we don't want to
|
||||
# do any more automatic stuff.
|
||||
self._can_do_auto_sign_in = False
|
||||
|
||||
if not self._can_do_auto_sign_in:
|
||||
return
|
||||
|
||||
# If we're not currently signed in, we have connectivity, and
|
||||
# we have an available implicit login, auto-sign-in with it once.
|
||||
# The implicit-state-change logic above should keep things
|
||||
# mostly in-sync, but that might not always be the case due to
|
||||
# connectivity or other issues. We prefer to keep people signed
|
||||
# in as a rule, even if there are corner cases where this might
|
||||
# not be what they want (A user signing out and then restarting
|
||||
# may be auto-signed back in).
|
||||
connected = plus.cloud.is_connected()
|
||||
signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
|
||||
signed_in_v2 = plus.accounts.have_primary_credentials()
|
||||
if (
|
||||
connected
|
||||
and not signed_in_v1
|
||||
and not signed_in_v2
|
||||
and self._implicit_signed_in_adapter is not None
|
||||
):
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'AccountV2: Signing in due to on-launch-auto-sign-in...',
|
||||
)
|
||||
self._can_do_auto_sign_in = False # Only ATTEMPT once
|
||||
self._implicit_signed_in_adapter.sign_in(
|
||||
self._on_implicit_sign_in_completed, description='auto-sign-in'
|
||||
)
|
||||
|
||||
def _on_explicit_sign_in_completed(
|
||||
self,
|
||||
adapter: LoginAdapter,
|
||||
result: LoginAdapter.SignInResult | Exception,
|
||||
) -> None:
|
||||
"""A sign-in has completed that the user asked for explicitly."""
|
||||
from babase._language import Lstr
|
||||
|
||||
del adapter # Unused.
|
||||
|
||||
plus = _babase.app.plus
|
||||
assert plus is not None
|
||||
|
||||
# Make some noise on errors since the user knows a
|
||||
# sign-in attempt is happening in this case (the 'explicit' part).
|
||||
if isinstance(result, Exception):
|
||||
# We expect the occasional communication errors;
|
||||
# Log a full exception for anything else though.
|
||||
if not isinstance(result, CommunicationError):
|
||||
logging.warning(
|
||||
'Error on explicit accountv2 sign in attempt.',
|
||||
exc_info=result,
|
||||
)
|
||||
|
||||
# For now just show 'error'. Should do better than this.
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.signInErrorText'),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
_babase.getsimplesound('error').play()
|
||||
|
||||
# Also I suppose we should sign them out in this case since
|
||||
# it could be misleading to be still signed in with the old
|
||||
# account.
|
||||
plus.accounts.set_primary_credentials(None)
|
||||
return
|
||||
|
||||
plus.accounts.set_primary_credentials(result.credentials)
|
||||
|
||||
def _on_implicit_sign_in_completed(
|
||||
self,
|
||||
adapter: LoginAdapter,
|
||||
result: LoginAdapter.SignInResult | Exception,
|
||||
) -> None:
|
||||
"""A sign-in has completed that the user didn't ask for explicitly."""
|
||||
plus = _babase.app.plus
|
||||
assert plus is not None
|
||||
|
||||
del adapter # Unused.
|
||||
|
||||
# Log errors but don't inform the user; they're not aware of this
|
||||
# attempt and ignorance is bliss.
|
||||
if isinstance(result, Exception):
|
||||
# We expect the occasional communication errors;
|
||||
# Log a full exception for anything else though.
|
||||
if not isinstance(result, CommunicationError):
|
||||
logging.warning(
|
||||
'Error on implicit accountv2 sign in attempt.',
|
||||
exc_info=result,
|
||||
)
|
||||
return
|
||||
|
||||
# If we're still connected and still not signed in,
|
||||
# plug in the credentials we got. We want to be extra cautious
|
||||
# in case the user has since explicitly signed in since we
|
||||
# kicked off.
|
||||
connected = plus.cloud.is_connected()
|
||||
signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
|
||||
signed_in_v2 = plus.accounts.have_primary_credentials()
|
||||
if connected and not signed_in_v1 and not signed_in_v2:
|
||||
plus.accounts.set_primary_credentials(result.credentials)
|
||||
|
||||
def _on_set_active_workspace_completed(self) -> None:
|
||||
if not self._initial_sign_in_completed:
|
||||
self._initial_sign_in_completed = True
|
||||
_babase.app.on_initial_sign_in_completed()
|
||||
|
||||
|
||||
class AccountV2Handle:
|
||||
"""Handle for interacting with a V2 account.
|
||||
|
||||
This class supports the 'with' statement, which is how it is
|
||||
used with some operations such as cloud messaging.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tag = '?'
|
||||
|
||||
self.workspacename: str | None = None
|
||||
self.workspaceid: str | None = None
|
||||
|
||||
# Login types and their display-names associated with this account.
|
||||
self.logins: dict[LoginType, str] = {}
|
||||
|
||||
def __enter__(self) -> None:
|
||||
"""Support for "with" statement.
|
||||
|
||||
This allows cloud messages to be sent on our behalf.
|
||||
"""
|
||||
|
||||
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
|
||||
"""Support for "with" statement.
|
||||
|
||||
This allows cloud messages to be sent on our behalf.
|
||||
"""
|
||||
837
dist/ba_data/python/babase/_app.py
vendored
Normal file
837
dist/ba_data/python/babase/_app.py
vendored
Normal file
|
|
@ -0,0 +1,837 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to the high level state of the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import cached_property
|
||||
|
||||
from efro.call import tpartial
|
||||
import _babase
|
||||
from babase._language import LanguageSubsystem
|
||||
from babase._plugin import PluginSubsystem
|
||||
from babase._meta import MetadataSubsystem
|
||||
from babase._net import NetworkSubsystem
|
||||
from babase._workspace import WorkspaceSubsystem
|
||||
from babase._appcomponent import AppComponentSubsystem
|
||||
from babase._appmodeselector import AppModeSelector
|
||||
from babase._appintent import AppIntentDefault, AppIntentExec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
from typing import Any, Callable
|
||||
from concurrent.futures import Future
|
||||
|
||||
import babase
|
||||
from babase import AppIntent, AppMode, AppSubsystem
|
||||
from babase._apputils import AppHealthMonitor
|
||||
|
||||
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
|
||||
# This section generated by batools.appmodule; do not edit.
|
||||
|
||||
from baclassic import ClassicSubsystem
|
||||
from baplus import PlusSubsystem
|
||||
from bauiv1 import UIV1Subsystem
|
||||
|
||||
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__
|
||||
|
||||
|
||||
class App:
|
||||
"""A class for high level app functionality and state.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Use babase.app to access the single shared instance of this class.
|
||||
|
||||
Note that properties not documented here should be considered internal
|
||||
and subject to change without warning.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
|
||||
plugins: PluginSubsystem
|
||||
lang: LanguageSubsystem
|
||||
|
||||
health_monitor: AppHealthMonitor
|
||||
|
||||
class State(Enum):
|
||||
"""High level state the app can be in."""
|
||||
|
||||
# The app launch process has not yet begun.
|
||||
INITIAL = 0
|
||||
|
||||
# Our app subsystems are being inited but should not yet interact.
|
||||
LAUNCHING = 1
|
||||
|
||||
# App subsystems are inited and interacting, but the app has not
|
||||
# yet embarked on a high level course of action. It is doing initial
|
||||
# account logins, workspace & asset downloads, etc. in order to
|
||||
# prepare for this.
|
||||
LOADING = 2
|
||||
|
||||
# All pieces are in place and the app is now doing its thing.
|
||||
RUNNING = 3
|
||||
|
||||
# The app is backgrounded or otherwise suspended.
|
||||
PAUSED = 4
|
||||
|
||||
# The app is shutting down.
|
||||
SHUTTING_DOWN = 5
|
||||
|
||||
@property
|
||||
def aioloop(self) -> asyncio.AbstractEventLoop:
|
||||
"""The logic thread's asyncio event loop.
|
||||
|
||||
This allow async tasks to be run in the logic thread.
|
||||
Note that, at this time, the asyncio loop is encapsulated
|
||||
and explicitly stepped by the engine's logic thread loop and
|
||||
thus things like asyncio.get_running_loop() will not return this
|
||||
loop from most places in the logic thread; only from within a
|
||||
task explicitly created in this loop.
|
||||
"""
|
||||
assert self._aioloop is not None
|
||||
return self._aioloop
|
||||
|
||||
@property
|
||||
def build_number(self) -> int:
|
||||
"""Integer build number.
|
||||
|
||||
This value increases by at least 1 with each release of the game.
|
||||
It is independent of the human readable babase.App.version string.
|
||||
"""
|
||||
assert isinstance(self._env['build_number'], int)
|
||||
return self._env['build_number']
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
"""Name of the device running the game."""
|
||||
assert isinstance(self._env['device_name'], str)
|
||||
return self._env['device_name']
|
||||
|
||||
@property
|
||||
def config_file_path(self) -> str:
|
||||
"""Where the game's config file is stored on disk."""
|
||||
assert isinstance(self._env['config_file_path'], str)
|
||||
return self._env['config_file_path']
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
"""Human-readable version string; something like '1.3.24'.
|
||||
|
||||
This should not be interpreted as a number; it may contain
|
||||
string elements such as 'alpha', 'beta', 'test', etc.
|
||||
If a numeric version is needed, use 'babase.App.build_number'.
|
||||
"""
|
||||
assert isinstance(self._env['version'], str)
|
||||
return self._env['version']
|
||||
|
||||
@property
|
||||
def debug_build(self) -> bool:
|
||||
"""Whether the app was compiled in debug mode.
|
||||
|
||||
Debug builds generally run substantially slower than non-debug
|
||||
builds due to compiler optimizations being disabled and extra
|
||||
checks being run.
|
||||
"""
|
||||
assert isinstance(self._env['debug_build'], bool)
|
||||
return self._env['debug_build']
|
||||
|
||||
@property
|
||||
def test_build(self) -> bool:
|
||||
"""Whether the game was compiled in test mode.
|
||||
|
||||
Test mode enables extra checks and features that are useful for
|
||||
release testing but which do not slow the game down significantly.
|
||||
"""
|
||||
assert isinstance(self._env['test_build'], bool)
|
||||
return self._env['test_build']
|
||||
|
||||
@property
|
||||
def data_directory(self) -> str:
|
||||
"""Path where static app data lives."""
|
||||
assert isinstance(self._env['data_directory'], str)
|
||||
return self._env['data_directory']
|
||||
|
||||
@property
|
||||
def python_directory_user(self) -> str | None:
|
||||
"""Path where ballistica expects its custom user scripts (mods) to live.
|
||||
|
||||
Be aware that this value may be None if ballistica is running in
|
||||
a non-standard environment, and that python-path modifications may
|
||||
cause modules to be loaded from other locations.
|
||||
"""
|
||||
assert isinstance(self._env['python_directory_user'], (str, type(None)))
|
||||
return self._env['python_directory_user']
|
||||
|
||||
@property
|
||||
def python_directory_app(self) -> str | None:
|
||||
"""Path where ballistica expects its bundled modules to live.
|
||||
|
||||
Be aware that this value may be None if ballistica is running in
|
||||
a non-standard environment, and that python-path modifications may
|
||||
cause modules to be loaded from other locations.
|
||||
"""
|
||||
assert isinstance(self._env['python_directory_app'], (str, type(None)))
|
||||
return self._env['python_directory_app']
|
||||
|
||||
@property
|
||||
def python_directory_app_site(self) -> str | None:
|
||||
"""Path where ballistica expects its bundled pip modules to live.
|
||||
|
||||
Be aware that this value may be None if ballistica is running in
|
||||
a non-standard environment, and that python-path modifications may
|
||||
cause modules to be loaded from other locations.
|
||||
"""
|
||||
assert isinstance(
|
||||
self._env['python_directory_app_site'], (str, type(None))
|
||||
)
|
||||
return self._env['python_directory_app_site']
|
||||
|
||||
@property
|
||||
def config(self) -> babase.AppConfig:
|
||||
"""The babase.AppConfig instance representing the app's config state."""
|
||||
assert self._config is not None
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def api_version(self) -> int:
|
||||
"""The app's api version.
|
||||
|
||||
Only Python modules and packages associated with the current API
|
||||
version number will be detected by the game (see the ba_meta tag).
|
||||
This value will change whenever substantial backward-incompatible
|
||||
changes are introduced to ballistica APIs. When that happens,
|
||||
modules/packages should be updated accordingly and set to target
|
||||
the newer API version number.
|
||||
"""
|
||||
from babase._meta import CURRENT_API_VERSION
|
||||
|
||||
return CURRENT_API_VERSION
|
||||
|
||||
@property
|
||||
def on_tv(self) -> bool:
|
||||
"""Whether the game is currently running on a TV."""
|
||||
assert isinstance(self._env['on_tv'], bool)
|
||||
return self._env['on_tv']
|
||||
|
||||
@property
|
||||
def vr_mode(self) -> bool:
|
||||
"""Whether the game is currently running in VR."""
|
||||
assert isinstance(self._env['vr_mode'], bool)
|
||||
return self._env['vr_mode']
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""(internal)
|
||||
|
||||
Do not instantiate this class; use babase.app to access
|
||||
the single shared instance.
|
||||
"""
|
||||
|
||||
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
|
||||
return
|
||||
|
||||
self.state = self.State.INITIAL
|
||||
|
||||
self._subsystems: list[AppSubsystem] = []
|
||||
|
||||
self._app_bootstrapping_complete = False
|
||||
self._called_on_app_launching = False
|
||||
self._launch_completed = False
|
||||
self._initial_sign_in_completed = False
|
||||
self._meta_scan_completed = False
|
||||
self._called_on_app_loading = False
|
||||
self._called_on_app_running = False
|
||||
self._app_paused = False
|
||||
self._subsystem_registration_ended = False
|
||||
self._pending_apply_app_config = False
|
||||
|
||||
# Config.
|
||||
self.config_file_healthy = False
|
||||
|
||||
# This is incremented any time the app is backgrounded/foregrounded;
|
||||
# can be a simple way to determine if network data should be
|
||||
# refreshed/etc.
|
||||
self.fg_state = 0
|
||||
|
||||
self._aioloop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
self._env = _babase.env()
|
||||
self.protocol_version: int = self._env['protocol_version']
|
||||
assert isinstance(self.protocol_version, int)
|
||||
self.toolbar_test: bool = self._env['toolbar_test']
|
||||
assert isinstance(self.toolbar_test, bool)
|
||||
self.demo_mode: bool = self._env['demo_mode']
|
||||
assert isinstance(self.demo_mode, bool)
|
||||
self.arcade_mode: bool = self._env['arcade_mode']
|
||||
assert isinstance(self.arcade_mode, bool)
|
||||
self.headless_mode: bool = self._env['headless_mode']
|
||||
assert isinstance(self.headless_mode, bool)
|
||||
self.iircade_mode: bool = self._env['iircade_mode']
|
||||
assert isinstance(self.iircade_mode, bool)
|
||||
|
||||
# Default executor which can be used for misc background processing.
|
||||
# It should also be passed to any additional asyncio loops we create
|
||||
# so that everything shares the same single set of worker threads.
|
||||
self.threadpool = ThreadPoolExecutor(thread_name_prefix='baworker')
|
||||
|
||||
self._config: babase.AppConfig | None = None
|
||||
|
||||
self.components = AppComponentSubsystem()
|
||||
self.meta = MetadataSubsystem()
|
||||
self.net = NetworkSubsystem()
|
||||
self.workspaces = WorkspaceSubsystem()
|
||||
self._pending_intent: AppIntent | None = None
|
||||
self._intent: AppIntent | None = None
|
||||
self._mode: AppMode | None = None
|
||||
|
||||
# Controls which app-modes we use for handling given app-intents.
|
||||
# Plugins can override this to change high level app behavior and
|
||||
# spinoff projects can change the default implementation for the
|
||||
# same effect.
|
||||
self.mode_selector: AppModeSelector | None = None
|
||||
|
||||
self._asyncio_timer: babase.AppTimer | None = None
|
||||
|
||||
def postinit(self) -> None:
|
||||
"""Called after we've been inited and assigned to babase.app."""
|
||||
|
||||
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
|
||||
return
|
||||
|
||||
# NOTE: the reason we need a postinit here is that
|
||||
# some of this stuff accesses babase.app and that doesn't
|
||||
# exist yet as of our __init__() call.
|
||||
|
||||
self.lang = LanguageSubsystem()
|
||||
self.plugins = PluginSubsystem()
|
||||
|
||||
def register_subsystem(self, subsystem: AppSubsystem) -> None:
|
||||
"""Called by the AppSubsystem class. Do not use directly."""
|
||||
|
||||
# We only allow registering new subsystems if we've not yet
|
||||
# reached the 'running' state. This ensures that all subsystems
|
||||
# receive a consistent set of callbacks starting with
|
||||
# on_app_running().
|
||||
if self._subsystem_registration_ended:
|
||||
raise RuntimeError(
|
||||
'Subsystems can no longer be registered at this point.'
|
||||
)
|
||||
self._subsystems.append(subsystem)
|
||||
|
||||
def _threadpool_no_wait_done(self, fut: Future) -> None:
|
||||
try:
|
||||
fut.result()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in work submitted via threadpool_submit_no_wait()'
|
||||
)
|
||||
|
||||
def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None:
|
||||
"""Submit work to our threadpool and log any errors.
|
||||
|
||||
Use this when you want to run something asynchronously but don't
|
||||
intend to call result() on it to handle errors/etc.
|
||||
"""
|
||||
fut = self.threadpool.submit(call)
|
||||
fut.add_done_callback(self._threadpool_no_wait_done)
|
||||
|
||||
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__
|
||||
# This section generated by batools.appmodule; do not edit.
|
||||
|
||||
@cached_property
|
||||
def classic(self) -> ClassicSubsystem | None:
|
||||
"""Our classic subsystem (if available)."""
|
||||
# pylint: disable=cyclic-import
|
||||
|
||||
try:
|
||||
from baclassic import ClassicSubsystem
|
||||
|
||||
return ClassicSubsystem()
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception:
|
||||
logging.exception('Error importing baclassic.')
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def plus(self) -> PlusSubsystem | None:
|
||||
"""Our plus subsystem (if available)."""
|
||||
# pylint: disable=cyclic-import
|
||||
|
||||
try:
|
||||
from baplus import PlusSubsystem
|
||||
|
||||
return PlusSubsystem()
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception:
|
||||
logging.exception('Error importing baplus.')
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def ui_v1(self) -> UIV1Subsystem:
|
||||
"""Our ui_v1 subsystem (always available)."""
|
||||
# pylint: disable=cyclic-import
|
||||
|
||||
from bauiv1 import UIV1Subsystem
|
||||
|
||||
return UIV1Subsystem()
|
||||
|
||||
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
|
||||
|
||||
def set_intent(self, intent: AppIntent) -> None:
|
||||
"""Set the intent for the app.
|
||||
|
||||
Intent defines what the app is trying to do at a given time.
|
||||
This call is asynchronous; the intent switch will happen in the
|
||||
logic thread in the near future. If set_intent is called
|
||||
repeatedly before the change takes place, the final intent to be
|
||||
set will be used.
|
||||
"""
|
||||
|
||||
# Mark this one as pending. We do this synchronously so that the
|
||||
# last one marked actually takes effect if there is overlap
|
||||
# (doing this in the bg thread could result in race conditions).
|
||||
self._pending_intent = intent
|
||||
|
||||
# Do the actual work of calcing our app-mode/etc. in a bg thread
|
||||
# since it may block for a moment to load modules/etc.
|
||||
self.threadpool_submit_no_wait(tpartial(self._set_intent, intent))
|
||||
|
||||
def _set_intent(self, intent: AppIntent) -> None:
|
||||
# This should be running in a bg thread.
|
||||
assert not _babase.in_logic_thread()
|
||||
try:
|
||||
# Ask the selector what app-mode to use for this intent.
|
||||
if self.mode_selector is None:
|
||||
raise RuntimeError('No AppModeSelector set.')
|
||||
modetype = self.mode_selector.app_mode_for_intent(intent)
|
||||
|
||||
# Make sure the app-mode they return *actually* supports the
|
||||
# intent.
|
||||
if not modetype.supports_intent(intent):
|
||||
raise RuntimeError(
|
||||
f'Intent {intent} is not supported by AppMode class'
|
||||
f' {modetype}'
|
||||
)
|
||||
|
||||
# Kick back to the logic thread to apply.
|
||||
mode = modetype()
|
||||
_babase.pushcall(
|
||||
tpartial(self._apply_intent, intent, mode),
|
||||
from_other_thread=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception('Error setting app intent to %s.', intent)
|
||||
_babase.pushcall(
|
||||
tpartial(self._apply_intent_error, intent),
|
||||
from_other_thread=True,
|
||||
)
|
||||
|
||||
def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# ONLY apply this intent if it is still the most recent one
|
||||
# submitted.
|
||||
if intent is not self._pending_intent:
|
||||
return
|
||||
|
||||
# If the app-mode for this intent is different than the active
|
||||
# one, switch.
|
||||
if type(mode) is not type(self._mode):
|
||||
if self._mode is None:
|
||||
is_initial_mode = True
|
||||
else:
|
||||
is_initial_mode = False
|
||||
try:
|
||||
self._mode.on_deactivate()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error deactivating app-mode %s.', self._mode
|
||||
)
|
||||
self._mode = mode
|
||||
try:
|
||||
mode.on_activate()
|
||||
except Exception:
|
||||
# Hmm; what should we do in this case?...
|
||||
logging.exception('Error activating app-mode %s.', mode)
|
||||
|
||||
if is_initial_mode:
|
||||
_babase.on_initial_app_mode_set()
|
||||
|
||||
try:
|
||||
mode.handle_intent(intent)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error handling intent %s in app-mode %s.', intent, mode
|
||||
)
|
||||
|
||||
def _apply_intent_error(self, intent: AppIntent) -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
del intent # Unused.
|
||||
_babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
|
||||
_babase.getsimplesound('error').play()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the app to completion.
|
||||
|
||||
Note that this only works on platforms where ballistica
|
||||
manages its own event loop.
|
||||
"""
|
||||
_babase.run_app()
|
||||
|
||||
def on_app_bootstrapping_complete(self) -> None:
|
||||
"""Called by the C++ layer once its ready to rock."""
|
||||
assert _babase.in_logic_thread()
|
||||
assert not self._app_bootstrapping_complete
|
||||
self._app_bootstrapping_complete = True
|
||||
self._update_state()
|
||||
|
||||
def on_app_launching(self) -> None:
|
||||
"""Called when the app enters the launching state.
|
||||
|
||||
Here we can put together subsystems and other pieces for the
|
||||
app, but most things should not be doing any work yet.
|
||||
"""
|
||||
# pylint: disable=cyclic-import
|
||||
from babase import _asyncio
|
||||
from babase import _appconfig
|
||||
from babase._apputils import AppHealthMonitor
|
||||
from babase import _env
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
_env.on_app_launching()
|
||||
|
||||
self._aioloop = _asyncio.setup_asyncio()
|
||||
self.health_monitor = AppHealthMonitor()
|
||||
|
||||
# Only proceed if our config file is healthy so we don't
|
||||
# overwrite a broken one or whatnot and wipe out data.
|
||||
if not self.config_file_healthy:
|
||||
if self.classic is not None:
|
||||
handled = self.classic.show_config_error_window()
|
||||
if handled:
|
||||
return
|
||||
|
||||
# For now on other systems we just overwrite the bum config.
|
||||
# At this point settings are already set; lets just commit them
|
||||
# to disk.
|
||||
_appconfig.commit_app_config(force=True)
|
||||
|
||||
# __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__
|
||||
# This section generated by batools.appmodule; do not edit.
|
||||
|
||||
# Poke these attrs to create all our subsystems.
|
||||
_ = self.plus
|
||||
_ = self.classic
|
||||
_ = self.ui_v1
|
||||
|
||||
# __FEATURESET_APP_SUBSYSTEM_CREATE_END__
|
||||
|
||||
self._launch_completed = True
|
||||
self._update_state()
|
||||
|
||||
def on_app_loading(self) -> None:
|
||||
"""Called when the app enters the loading state.
|
||||
|
||||
At this point, all built-in pieces of the app should be in place
|
||||
and can start doing 'work'. Though at a high level, the goal of
|
||||
the app at this point is only to sign in to initial accounts,
|
||||
download workspaces, and otherwise prepare itself to really
|
||||
'run'.
|
||||
"""
|
||||
from babase._apputils import log_dumped_app_state
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Get meta-system scanning built-in stuff in the bg.
|
||||
self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete)
|
||||
|
||||
# If any traceback dumps happened last run, log and clear them.
|
||||
log_dumped_app_state()
|
||||
|
||||
# Inform all app subsystems in the same order they were inited.
|
||||
# Operate on a copy here because subsystems can still be added
|
||||
# at this point.
|
||||
for subsystem in self._subsystems.copy():
|
||||
try:
|
||||
subsystem.on_app_loading()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in on_app_loading for subsystem %s.', subsystem
|
||||
)
|
||||
|
||||
# Normally plus tells us when initial sign-in is done. If it's
|
||||
# not present, however, we just do that ourself so we can
|
||||
# proceed on to the running state.
|
||||
if self.plus is None:
|
||||
_babase.pushcall(self.on_initial_sign_in_completed)
|
||||
|
||||
def on_meta_scan_complete(self) -> None:
|
||||
"""Called when meta-scan is done doing its thing."""
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Now that we know what's out there, build our final plugin set.
|
||||
self.plugins.on_meta_scan_complete()
|
||||
|
||||
assert not self._meta_scan_completed
|
||||
self._meta_scan_completed = True
|
||||
self._update_state()
|
||||
|
||||
def on_app_running(self) -> None:
|
||||
"""Called when the app enters the running state.
|
||||
|
||||
At this point, all workspaces, initial accounts, etc. are in place
|
||||
and we can actually get started doing whatever we're gonna do.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Let our native layer know.
|
||||
_babase.on_app_running()
|
||||
|
||||
# Set a default app-mode-selector. Plugins can then override
|
||||
# this if they want in the on_app_running callback below.
|
||||
self.mode_selector = self.DefaultAppModeSelector()
|
||||
|
||||
# Inform all app subsystems in the same order they were inited.
|
||||
# Operate on a copy here because subsystems can still be added
|
||||
# at this point.
|
||||
for subsystem in self._subsystems.copy():
|
||||
try:
|
||||
subsystem.on_app_running()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in on_app_running for subsystem %s.', subsystem
|
||||
)
|
||||
|
||||
# Cut off new subsystem additions at this point.
|
||||
self._subsystem_registration_ended = True
|
||||
|
||||
# If 'exec' code was provided to the app, always kick that off
|
||||
# here as an intent.
|
||||
exec_cmd = _babase.exec_arg()
|
||||
if exec_cmd is not None:
|
||||
self.set_intent(AppIntentExec(exec_cmd))
|
||||
elif self._pending_intent is None:
|
||||
# Otherwise tell the app to do its default thing *only* if a
|
||||
# plugin hasn't already told it to do something.
|
||||
self.set_intent(AppIntentDefault())
|
||||
|
||||
def push_apply_app_config(self) -> None:
|
||||
"""Internal. Use app.config.apply() to apply app config changes."""
|
||||
# To be safe, let's run this by itself in the event loop.
|
||||
# This avoids potential trouble if this gets called mid-draw or
|
||||
# something like that.
|
||||
self._pending_apply_app_config = True
|
||||
_babase.pushcall(self._apply_app_config, raw=True)
|
||||
|
||||
def _apply_app_config(self) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
_babase.lifecyclelog('apply-app-config')
|
||||
|
||||
# If multiple apply calls have been made, only actually apply once.
|
||||
if not self._pending_apply_app_config:
|
||||
return
|
||||
|
||||
_pending_apply_app_config = False
|
||||
|
||||
# Inform all app subsystems in the same order they were inited.
|
||||
# Operate on a copy here because subsystems may still be able to
|
||||
# be added at this point.
|
||||
for subsystem in self._subsystems.copy():
|
||||
try:
|
||||
subsystem.do_apply_app_config()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in do_apply_app_config for subsystem %s.', subsystem
|
||||
)
|
||||
|
||||
# Let the native layer do its thing.
|
||||
_babase.do_apply_app_config()
|
||||
|
||||
class DefaultAppModeSelector(AppModeSelector):
|
||||
"""Decides which app modes to use to handle intents.
|
||||
|
||||
The behavior here is generated by the project updater based on
|
||||
the set of feature-sets in the project. Spinoff projects can
|
||||
also inject their own behavior by replacing the text
|
||||
'__GOOD_PLACE_FOR_CUSTOM_SPINOFF_LOGIC__' with their own code
|
||||
through spinoff filtering.
|
||||
|
||||
It is also possible to modify mode selection behavior by writing
|
||||
a custom AppModeSelector class and replacing app.mode_selector
|
||||
with an instance of it. This is a good way to go if you are
|
||||
modifying app behavior with a plugin instead of in a spinoff
|
||||
project.
|
||||
"""
|
||||
|
||||
def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode]:
|
||||
# pylint: disable=cyclic-import
|
||||
|
||||
# __GOOD_PLACE_FOR_CUSTOM_SPINOFF_LOGIC__
|
||||
|
||||
# __DEFAULT_APP_MODE_SELECTION_BEGIN__
|
||||
# This section generated by batools.appmodule; do not edit.
|
||||
|
||||
# Hmm; need to think about how we auto-construct this; how
|
||||
# should we determine which app modes to check and in what
|
||||
# order?
|
||||
import bascenev1
|
||||
|
||||
import babase
|
||||
|
||||
if bascenev1.SceneV1AppMode.supports_intent(intent):
|
||||
return bascenev1.SceneV1AppMode
|
||||
|
||||
if babase.EmptyAppMode.supports_intent(intent):
|
||||
return babase.EmptyAppMode
|
||||
|
||||
raise RuntimeError(f'No handler found for intent {type(intent)}.')
|
||||
|
||||
# __DEFAULT_APP_MODE_SELECTION_END__
|
||||
|
||||
def _update_state(self) -> None:
|
||||
# pylint: disable=too-many-branches
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
if self._app_paused:
|
||||
# Entering paused state:
|
||||
if self.state is not self.State.PAUSED:
|
||||
self.state = self.State.PAUSED
|
||||
self.on_app_pause()
|
||||
else:
|
||||
# Leaving paused state:
|
||||
if self.state is self.State.PAUSED:
|
||||
self.on_app_resume()
|
||||
|
||||
# Handle initially entering or returning to other states.
|
||||
if self._initial_sign_in_completed and self._meta_scan_completed:
|
||||
if self.state != self.State.RUNNING:
|
||||
self.state = self.State.RUNNING
|
||||
_babase.lifecyclelog('app state running')
|
||||
if not self._called_on_app_running:
|
||||
self._called_on_app_running = True
|
||||
self.on_app_running()
|
||||
elif self._launch_completed:
|
||||
if self.state is not self.State.LOADING:
|
||||
self.state = self.State.LOADING
|
||||
_babase.lifecyclelog('app state loading')
|
||||
if not self._called_on_app_loading:
|
||||
self._called_on_app_loading = True
|
||||
self.on_app_loading()
|
||||
else:
|
||||
# Only thing left is launching. We shouldn't be getting
|
||||
# called before at least that is complete.
|
||||
assert self._app_bootstrapping_complete
|
||||
if self.state is not self.State.LAUNCHING:
|
||||
self.state = self.State.LAUNCHING
|
||||
_babase.lifecyclelog('app state launching')
|
||||
if not self._called_on_app_launching:
|
||||
self._called_on_app_launching = True
|
||||
self.on_app_launching()
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Should be called by the native layer when the app pauses."""
|
||||
assert not self._app_paused # Should avoid redundant calls.
|
||||
self._app_paused = True
|
||||
self._update_state()
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Should be called by the native layer when the app resumes."""
|
||||
assert self._app_paused # Should avoid redundant calls.
|
||||
self._app_paused = False
|
||||
self._update_state()
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Called when the app goes to a paused state."""
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Pause all app subsystems in the opposite order they were inited.
|
||||
for subsystem in reversed(self._subsystems):
|
||||
try:
|
||||
subsystem.on_app_pause()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in on_app_pause for subsystem %s.', subsystem
|
||||
)
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Called when resuming."""
|
||||
assert _babase.in_logic_thread()
|
||||
self.fg_state += 1
|
||||
|
||||
# Resume all app subsystems in the same order they were inited.
|
||||
for subsystem in self._subsystems:
|
||||
try:
|
||||
subsystem.on_app_resume()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in on_app_resume for subsystem %s.', subsystem
|
||||
)
|
||||
|
||||
def on_app_shutdown(self) -> None:
|
||||
"""(internal)"""
|
||||
assert _babase.in_logic_thread()
|
||||
self.state = self.State.SHUTTING_DOWN
|
||||
|
||||
# Shutdown all app subsystems in the opposite order they were
|
||||
# inited.
|
||||
for subsystem in reversed(self._subsystems):
|
||||
try:
|
||||
subsystem.on_app_shutdown()
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error in on_app_shutdown for subsystem %s.', subsystem
|
||||
)
|
||||
|
||||
def read_config(self) -> None:
|
||||
"""(internal)"""
|
||||
from babase._appconfig import read_app_config
|
||||
|
||||
self._config, self.config_file_healthy = read_app_config()
|
||||
|
||||
def handle_deep_link(self, url: str) -> None:
|
||||
"""Handle a deep link URL."""
|
||||
from babase._language import Lstr
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
appname = _babase.appname()
|
||||
if url.startswith(f'{appname}://code/'):
|
||||
code = url.replace(f'{appname}://code/', '')
|
||||
if self.classic is not None:
|
||||
self.classic.accounts.add_pending_promo_code(code)
|
||||
else:
|
||||
try:
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='errorText'), color=(1, 0, 0)
|
||||
)
|
||||
_babase.getsimplesound('error').play()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
def on_initial_sign_in_completed(self) -> None:
|
||||
"""Callback to be run after initial sign-in (or lack thereof).
|
||||
|
||||
This normally gets called by the plus subsystem.
|
||||
This period includes things such as syncing account workspaces
|
||||
or other data so it may take a substantial amount of time.
|
||||
This should also run after a short amount of time if no login
|
||||
has occurred.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
assert not self._initial_sign_in_completed
|
||||
|
||||
# Tell meta it can start scanning extra stuff that just showed up
|
||||
# (namely account workspaces).
|
||||
self.meta.start_extra_scan()
|
||||
|
||||
self._initial_sign_in_completed = True
|
||||
self._update_state()
|
||||
94
dist/ba_data/python/babase/_appcomponent.py
vendored
Normal file
94
dist/ba_data/python/babase/_appcomponent.py
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides the AppComponent class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypeVar, cast
|
||||
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable, Any
|
||||
|
||||
T = TypeVar('T', bound=type)
|
||||
|
||||
|
||||
class AppComponentSubsystem:
|
||||
"""Subsystem for wrangling AppComponents.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
This subsystem acts as a registry for classes providing particular
|
||||
functionality for the app, and allows plugins or other custom code
|
||||
to easily override said functionality.
|
||||
|
||||
Access the single shared instance of this class at
|
||||
babase.app.components.
|
||||
|
||||
The general idea with this setup is that a base-class Foo is defined
|
||||
to provide some functionality and then anyone wanting that
|
||||
functionality calls getclass(Foo) to return the current registered
|
||||
implementation. The user should not know or care whether they are
|
||||
getting Foo itself or some subclass of it.
|
||||
|
||||
Change-callbacks can also be requested for base classes which will
|
||||
fire in a deferred manner when particular base-classes are
|
||||
overridden.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._implementations: dict[type, type] = {}
|
||||
self._prev_implementations: dict[type, type] = {}
|
||||
self._dirty_base_classes: set[type] = set()
|
||||
self._change_callbacks: dict[type, list[Callable[[Any], None]]] = {}
|
||||
|
||||
def setclass(self, baseclass: type, implementation: type) -> None:
|
||||
"""Set the class providing an implementation of some base-class.
|
||||
|
||||
The provided implementation class must be a subclass of baseclass.
|
||||
"""
|
||||
# Currently limiting this to logic-thread use; can revisit if
|
||||
# needed (would need to guard access to our implementations
|
||||
# dict).
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
if not issubclass(implementation, baseclass):
|
||||
raise TypeError(
|
||||
f'Implementation {implementation}'
|
||||
f' is not a subclass of baseclass {baseclass}.'
|
||||
)
|
||||
|
||||
self._implementations[baseclass] = implementation
|
||||
|
||||
# If we're the first thing getting dirtied, set up a callback to
|
||||
# clean everything. And add ourself to the dirty list
|
||||
# regardless.
|
||||
if not self._dirty_base_classes:
|
||||
_babase.pushcall(self._run_change_callbacks)
|
||||
self._dirty_base_classes.add(baseclass)
|
||||
|
||||
def getclass(self, baseclass: T) -> T:
|
||||
"""Given a base-class, return the current implementation class.
|
||||
|
||||
If no custom implementation has been set, the provided
|
||||
base-class is returned.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
del baseclass # Unused.
|
||||
return cast(T, None)
|
||||
|
||||
def register_change_callback(
|
||||
self, baseclass: T, callback: Callable[[T], None]
|
||||
) -> None:
|
||||
"""Register a callback to fire on class implementation changes.
|
||||
|
||||
The callback will be scheduled to run in the logic thread event
|
||||
loop. Note that any further setclass calls before the callback
|
||||
runs will not result in additional callbacks.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
self._change_callbacks.setdefault(baseclass, []).append(callback)
|
||||
|
||||
def _run_change_callbacks(self) -> None:
|
||||
pass
|
||||
184
dist/ba_data/python/babase/_appconfig.py
vendored
Normal file
184
dist/ba_data/python/babase/_appconfig.py
vendored
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides the AppConfig class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
_g_pending_apply = False # pylint: disable=invalid-name
|
||||
|
||||
|
||||
class AppConfig(dict):
|
||||
"""A special dict that holds the game's persistent configuration values.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
It also provides methods for fetching values with app-defined fallback
|
||||
defaults, applying contained values to the game, and committing the
|
||||
config to storage.
|
||||
|
||||
Call babase.appconfig() to get the single shared instance of this class.
|
||||
|
||||
AppConfig data is stored as json on disk on so make sure to only place
|
||||
json-friendly values in it (dict, list, str, float, int, bool).
|
||||
Be aware that tuples will be quietly converted to lists when stored.
|
||||
"""
|
||||
|
||||
def resolve(self, key: str) -> Any:
|
||||
"""Given a string key, return a config value (type varies).
|
||||
|
||||
This will substitute application defaults for values not present in
|
||||
the config dict, filter some invalid values, etc. Note that these
|
||||
values do not represent the state of the app; simply the state of its
|
||||
config. Use babase.App to access actual live state.
|
||||
|
||||
Raises an Exception for unrecognized key names. To get the list of keys
|
||||
supported by this method, use babase.AppConfig.builtin_keys(). Note
|
||||
that it is perfectly legal to store other data in the config; it just
|
||||
needs to be accessed through standard dict methods and missing values
|
||||
handled manually.
|
||||
"""
|
||||
return _babase.resolve_appconfig_value(key)
|
||||
|
||||
def default_value(self, key: str) -> Any:
|
||||
"""Given a string key, return its predefined default value.
|
||||
|
||||
This is the value that will be returned by babase.AppConfig.resolve()
|
||||
if the key is not present in the config dict or of an incompatible
|
||||
type.
|
||||
|
||||
Raises an Exception for unrecognized key names. To get the list of keys
|
||||
supported by this method, use babase.AppConfig.builtin_keys(). Note
|
||||
that it is perfectly legal to store other data in the config; it just
|
||||
needs to be accessed through standard dict methods and missing values
|
||||
handled manually.
|
||||
"""
|
||||
return _babase.get_appconfig_default_value(key)
|
||||
|
||||
def builtin_keys(self) -> list[str]:
|
||||
"""Return the list of valid key names recognized by babase.AppConfig.
|
||||
|
||||
This set of keys can be used with resolve(), default_value(), etc.
|
||||
It does not vary across platforms and may include keys that are
|
||||
obsolete or not relevant on the current running version. (for instance,
|
||||
VR related keys on non-VR platforms). This is to minimize the amount
|
||||
of platform checking necessary)
|
||||
|
||||
Note that it is perfectly legal to store arbitrary named data in the
|
||||
config, but in that case it is up to the user to test for the existence
|
||||
of the key in the config dict, fall back to consistent defaults, etc.
|
||||
"""
|
||||
return _babase.get_appconfig_builtin_keys()
|
||||
|
||||
def apply(self) -> None:
|
||||
"""Apply config values to the running app.
|
||||
|
||||
This call is thread-safe and asynchronous; changes will happen
|
||||
in the next logic event loop cycle.
|
||||
"""
|
||||
_babase.app.push_apply_app_config()
|
||||
|
||||
def commit(self) -> None:
|
||||
"""Commits the config to local storage.
|
||||
|
||||
Note that this call is asynchronous so the actual write to disk may not
|
||||
occur immediately.
|
||||
"""
|
||||
commit_app_config()
|
||||
|
||||
def apply_and_commit(self) -> None:
|
||||
"""Run apply() followed by commit(); for convenience.
|
||||
|
||||
(This way the commit() will not occur if apply() hits invalid data)
|
||||
"""
|
||||
self.apply()
|
||||
self.commit()
|
||||
|
||||
|
||||
def read_app_config() -> tuple[AppConfig, bool]:
|
||||
"""Read the app config."""
|
||||
import os
|
||||
import json
|
||||
|
||||
config_file_healthy = False
|
||||
|
||||
# NOTE: it is assumed that this only gets called once and the
|
||||
# config object will not change from here on out
|
||||
config_file_path = _babase.app.config_file_path
|
||||
config_contents = ''
|
||||
try:
|
||||
if os.path.exists(config_file_path):
|
||||
with open(config_file_path, encoding='utf-8') as infile:
|
||||
config_contents = infile.read()
|
||||
config = AppConfig(json.loads(config_contents))
|
||||
else:
|
||||
config = AppConfig()
|
||||
config_file_healthy = True
|
||||
|
||||
except Exception as exc:
|
||||
print(
|
||||
(
|
||||
'error reading config file at time '
|
||||
+ str(_babase.apptime())
|
||||
+ ': \''
|
||||
+ config_file_path
|
||||
+ '\':\n'
|
||||
),
|
||||
exc,
|
||||
)
|
||||
|
||||
# Whenever this happens lets back up the broken one just in case it
|
||||
# gets overwritten accidentally.
|
||||
print(
|
||||
(
|
||||
'backing up current config file to \''
|
||||
+ config_file_path
|
||||
+ ".broken\'"
|
||||
)
|
||||
)
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.copyfile(config_file_path, config_file_path + '.broken')
|
||||
except Exception as exc2:
|
||||
print('EXC copying broken config:', exc2)
|
||||
config = AppConfig()
|
||||
|
||||
# Now attempt to read one of our 'prev' backup copies.
|
||||
prev_path = config_file_path + '.prev'
|
||||
try:
|
||||
if os.path.exists(prev_path):
|
||||
with open(prev_path, encoding='utf-8') as infile:
|
||||
config_contents = infile.read()
|
||||
config = AppConfig(json.loads(config_contents))
|
||||
else:
|
||||
config = AppConfig()
|
||||
config_file_healthy = True
|
||||
print('successfully read backup config.')
|
||||
except Exception as exc2:
|
||||
print('EXC reading prev backup config:', exc2)
|
||||
return config, config_file_healthy
|
||||
|
||||
|
||||
def commit_app_config(force: bool = False) -> None:
|
||||
"""Commit the config to persistent storage.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
(internal)
|
||||
"""
|
||||
plus = _babase.app.plus
|
||||
assert plus is not None
|
||||
|
||||
if not _babase.app.config_file_healthy and not force:
|
||||
print(
|
||||
'Current config file is broken; '
|
||||
'skipping write to avoid losing settings.'
|
||||
)
|
||||
return
|
||||
plus.mark_config_dirty()
|
||||
27
dist/ba_data/python/babase/_appintent.py
vendored
Normal file
27
dist/ba_data/python/babase/_appintent.py
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides AppIntent functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class AppIntent:
|
||||
"""A high level directive given to the app.
|
||||
|
||||
Category: **App Classes**
|
||||
"""
|
||||
|
||||
|
||||
class AppIntentDefault(AppIntent):
|
||||
"""Tells the app to simply run in its default mode."""
|
||||
|
||||
|
||||
class AppIntentExec(AppIntent):
|
||||
"""Tells the app to exec some Python code."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
35
dist/ba_data/python/babase/_appmode.py
vendored
Normal file
35
dist/ba_data/python/babase/_appmode.py
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides AppMode functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babase._appintent import AppIntent
|
||||
|
||||
|
||||
class AppMode:
|
||||
"""A high level mode for the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def supports_intent(cls, intent: AppIntent) -> bool:
|
||||
"""Return whether our mode can handle the provided intent."""
|
||||
del intent
|
||||
|
||||
# Say no to everything by default. Let's make mode explicitly
|
||||
# lay out everything they *do* support.
|
||||
return False
|
||||
|
||||
def handle_intent(self, intent: AppIntent) -> None:
|
||||
"""Handle an intent."""
|
||||
|
||||
def on_activate(self) -> None:
|
||||
"""Called when the mode is being activated."""
|
||||
|
||||
def on_deactivate(self) -> None:
|
||||
"""Called when the mode is being deactivated."""
|
||||
32
dist/ba_data/python/babase/_appmodeselector.py
vendored
Normal file
32
dist/ba_data/python/babase/_appmodeselector.py
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides AppMode functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babase._appintent import AppIntent
|
||||
from babase._appmode import AppMode
|
||||
|
||||
|
||||
class AppModeSelector:
|
||||
"""Defines which AppModes to use to handle given AppIntents.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
The app calls an instance of this class when passed an AppIntent to
|
||||
determine which AppMode to use to handle the intent. Plugins or
|
||||
spinoff projects can modify high level app behavior by replacing or
|
||||
modifying this.
|
||||
"""
|
||||
|
||||
def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode]:
|
||||
"""Given an AppIntent, return the AppMode that should handle it.
|
||||
|
||||
If None is returned, the AppIntent will be ignored.
|
||||
|
||||
This is called in a background thread, so avoid any calls
|
||||
limited to logic thread use/etc.
|
||||
"""
|
||||
raise RuntimeError('app_mode_for_intent() should be overridden.')
|
||||
52
dist/ba_data/python/babase/_appsubsystem.py
vendored
Normal file
52
dist/ba_data/python/babase/_appsubsystem.py
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides the AppSubsystem base class."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class AppSubsystem:
|
||||
"""Base class for an app subsystem.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
An app 'subsystem' is a bit of a vague term, as pieces of the app
|
||||
can technically be any class and are not required to use this, but
|
||||
building one out of this base class provides some conveniences such
|
||||
as predefined callbacks during app state changes.
|
||||
|
||||
Subsystems must be registered with the app before it completes its
|
||||
transition to the 'running' state.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
_babase.app.register_subsystem(self)
|
||||
|
||||
def on_app_loading(self) -> None:
|
||||
"""Called when the app reaches the loading state.
|
||||
|
||||
Note that subsystems created after the app switches to the
|
||||
loading state will not receive this callback. Subsystems created
|
||||
by plugins are an example of this.
|
||||
"""
|
||||
|
||||
def on_app_running(self) -> None:
|
||||
"""Called when the app reaches the running state."""
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Called when the app enters the paused state."""
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Called when the app exits the paused state."""
|
||||
|
||||
def on_app_shutdown(self) -> None:
|
||||
"""Called when the app is shutting down."""
|
||||
|
||||
def do_apply_app_config(self) -> None:
|
||||
"""Called when the app config should be applied."""
|
||||
445
dist/ba_data/python/babase/_apputils.py
vendored
Normal file
445
dist/ba_data/python/babase/_apputils.py
vendored
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Utility functionality related to the overall operation of the app."""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import os
|
||||
import logging
|
||||
from threading import Thread
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.call import tpartial
|
||||
from efro.log import LogLevel
|
||||
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
|
||||
import _babase
|
||||
from babase._appsubsystem import AppSubsystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, TextIO
|
||||
|
||||
import babase
|
||||
|
||||
|
||||
def is_browser_likely_available() -> bool:
|
||||
"""Return whether a browser likely exists on the current device.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
If this returns False you may want to avoid calling babase.show_url()
|
||||
with any lengthy addresses. (ba.show_url() will display an address
|
||||
as a string in a window if unable to bring up a browser, but that
|
||||
is only useful for simple URLs.)
|
||||
"""
|
||||
app = _babase.app
|
||||
|
||||
if app.classic is None:
|
||||
logging.warning(
|
||||
'is_browser_likely_available() needs to be updated'
|
||||
' to work without classic.'
|
||||
)
|
||||
return True
|
||||
|
||||
platform = app.classic.platform
|
||||
hastouchscreen = _babase.hastouchscreen()
|
||||
|
||||
# If we're on a vr device or an android device with no touchscreen,
|
||||
# assume no browser.
|
||||
# FIXME: Might not be the case anymore; should make this definable
|
||||
# at the platform level.
|
||||
if app.vr_mode or (platform == 'android' and not hastouchscreen):
|
||||
return False
|
||||
|
||||
# Anywhere else assume we've got one.
|
||||
return True
|
||||
|
||||
|
||||
def get_remote_app_name() -> babase.Lstr:
|
||||
"""(internal)"""
|
||||
from babase import _language
|
||||
|
||||
return _language.Lstr(resource='remote_app.app_name')
|
||||
|
||||
|
||||
def should_submit_debug_info() -> bool:
|
||||
"""(internal)"""
|
||||
return _babase.app.config.get('Submit Debug Info', True)
|
||||
|
||||
|
||||
def handle_v1_cloud_log() -> None:
|
||||
"""Called when new messages have been added to v1-cloud-log.
|
||||
|
||||
When this happens, we can upload our log to the server after a short
|
||||
bit if desired.
|
||||
"""
|
||||
|
||||
app = _babase.app
|
||||
classic = app.classic
|
||||
plus = app.plus
|
||||
|
||||
if classic is None or plus is None:
|
||||
if _babase.do_once():
|
||||
logging.warning(
|
||||
'handle_v1_cloud_log should not be getting called'
|
||||
' without classic and plus present.'
|
||||
)
|
||||
return
|
||||
|
||||
classic.log_have_new = True
|
||||
if not classic.log_upload_timer_started:
|
||||
|
||||
def _put_log() -> None:
|
||||
assert plus is not None
|
||||
assert classic is not None
|
||||
try:
|
||||
sessionname = str(classic.get_foreground_host_session())
|
||||
except Exception:
|
||||
sessionname = 'unavailable'
|
||||
try:
|
||||
activityname = str(classic.get_foreground_host_activity())
|
||||
except Exception:
|
||||
activityname = 'unavailable'
|
||||
|
||||
info = {
|
||||
'log': _babase.get_v1_cloud_log(),
|
||||
'version': app.version,
|
||||
'build': app.build_number,
|
||||
'userAgentString': classic.legacy_user_agent_string,
|
||||
'session': sessionname,
|
||||
'activity': activityname,
|
||||
'fatal': 0,
|
||||
'userRanCommands': _babase.has_user_run_commands(),
|
||||
'time': _babase.apptime(),
|
||||
'userModded': _babase.workspaces_in_use(),
|
||||
'newsShow': plus.get_news_show(),
|
||||
}
|
||||
|
||||
def response(data: Any) -> None:
|
||||
assert classic is not None
|
||||
# A non-None response means success; lets
|
||||
# take note that we don't need to report further
|
||||
# log info this run
|
||||
if data is not None:
|
||||
classic.log_have_new = False
|
||||
_babase.mark_log_sent()
|
||||
|
||||
classic.master_server_v1_post('bsLog', info, response)
|
||||
|
||||
classic.log_upload_timer_started = True
|
||||
|
||||
# Delay our log upload slightly in case other
|
||||
# pertinent info gets printed between now and then.
|
||||
with _babase.ContextRef.empty():
|
||||
_babase.apptimer(3.0, _put_log)
|
||||
|
||||
# After a while, allow another log-put.
|
||||
def _reset() -> None:
|
||||
assert classic is not None
|
||||
classic.log_upload_timer_started = False
|
||||
if classic.log_have_new:
|
||||
handle_v1_cloud_log()
|
||||
|
||||
if not _babase.is_log_full():
|
||||
with _babase.ContextRef.empty():
|
||||
_babase.apptimer(600.0, _reset)
|
||||
|
||||
|
||||
def handle_leftover_v1_cloud_log_file() -> None:
|
||||
"""Handle an un-uploaded v1-cloud-log from a previous run."""
|
||||
|
||||
# Only applies with classic present.
|
||||
if _babase.app.classic is None:
|
||||
return
|
||||
try:
|
||||
import json
|
||||
|
||||
if os.path.exists(_babase.get_v1_cloud_log_file_path()):
|
||||
with open(
|
||||
_babase.get_v1_cloud_log_file_path(), encoding='utf-8'
|
||||
) as infile:
|
||||
info = json.loads(infile.read())
|
||||
infile.close()
|
||||
do_send = should_submit_debug_info()
|
||||
if do_send:
|
||||
|
||||
def response(data: Any) -> None:
|
||||
# Non-None response means we were successful;
|
||||
# lets kill it.
|
||||
if data is not None:
|
||||
try:
|
||||
os.remove(_babase.get_v1_cloud_log_file_path())
|
||||
except FileNotFoundError:
|
||||
# Saw this in the wild. The file just existed
|
||||
# a moment ago but I suppose something could have
|
||||
# killed it since. ¯\_(ツ)_/¯
|
||||
pass
|
||||
|
||||
_babase.app.classic.master_server_v1_post(
|
||||
'bsLog', info, response
|
||||
)
|
||||
else:
|
||||
# If they don't want logs uploaded just kill it.
|
||||
os.remove(_babase.get_v1_cloud_log_file_path())
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('Error handling leftover log file.')
|
||||
|
||||
|
||||
def garbage_collect_session_end() -> None:
|
||||
"""Run explicit garbage collection with extra checks for session end."""
|
||||
gc.collect()
|
||||
|
||||
# Can be handy to print this to check for leaks between games.
|
||||
if bool(False):
|
||||
print('PY OBJ COUNT', len(gc.get_objects()))
|
||||
if gc.garbage:
|
||||
print('PYTHON GC FOUND', len(gc.garbage), 'UNCOLLECTIBLE OBJECTS:')
|
||||
for i, obj in enumerate(gc.garbage):
|
||||
print(str(i) + ':', obj)
|
||||
|
||||
# NOTE: no longer running these checks. Perhaps we can allow
|
||||
# running them with an explicit flag passed, but we should never
|
||||
# run them by default because gc.get_objects() can mess up the app.
|
||||
# See notes at top of efro.debug.
|
||||
# if bool(False):
|
||||
# print_live_object_warnings('after session shutdown')
|
||||
|
||||
|
||||
def garbage_collect() -> None:
|
||||
"""Run an explicit pass of garbage collection.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
May also print warnings/etc. if collection takes too long or if
|
||||
uncollectible objects are found (so use this instead of simply
|
||||
gc.collect().
|
||||
"""
|
||||
gc.collect()
|
||||
|
||||
|
||||
def print_corrupt_file_error() -> None:
|
||||
"""Print an error if a corrupt file is found."""
|
||||
|
||||
# FIXME - filter this out for builds without bauiv1.
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.apptimer(
|
||||
2.0,
|
||||
lambda: _babase.screenmessage(
|
||||
_babase.app.lang.get_resource(
|
||||
'internal.corruptFileText'
|
||||
).replace('${EMAIL}', 'support@froemling.net'),
|
||||
color=(1, 0, 0),
|
||||
),
|
||||
)
|
||||
_babase.apptimer(2.0, _babase.getsimplesound('error').play)
|
||||
|
||||
|
||||
_tb_held_files: list[TextIO] = []
|
||||
|
||||
|
||||
@ioprepped
|
||||
@dataclass
|
||||
class DumpedAppStateMetadata:
|
||||
"""High level info about a dumped app state."""
|
||||
|
||||
reason: str
|
||||
app_time: float
|
||||
log_level: LogLevel
|
||||
|
||||
|
||||
def dump_app_state(
|
||||
delay: float = 0.0,
|
||||
reason: str = 'Unspecified',
|
||||
log_level: LogLevel = LogLevel.WARNING,
|
||||
) -> None:
|
||||
"""Dump various app state for debugging purposes.
|
||||
|
||||
This includes stack traces for all Python threads (and potentially
|
||||
other info in the future).
|
||||
|
||||
This is intended for use debugging deadlock situations. It will dump
|
||||
to preset file location(s) in the app config dir, and will attempt to
|
||||
log and clear the results after dumping. If that should fail (due to
|
||||
a hung app, etc.), then the results will be logged and cleared on the
|
||||
next app run.
|
||||
|
||||
Do not use this call during regular smooth operation of the app; it
|
||||
is should only be used for debugging or in response to confirmed
|
||||
problems as it can leak file descriptors, cause hitches, etc.
|
||||
"""
|
||||
# pylint: disable=consider-using-with
|
||||
import faulthandler
|
||||
|
||||
# Dump our metadata immediately. If a delay is passed, it generally
|
||||
# means we expect things to hang momentarily, so we should not delay
|
||||
# writing our metadata or it will likely not happen. Though we
|
||||
# should remember that metadata doesn't line up perfectly in time with
|
||||
# the dump in that case.
|
||||
try:
|
||||
mdpath = os.path.join(
|
||||
os.path.dirname(_babase.app.config_file_path), '_appstate_dump_md'
|
||||
)
|
||||
with open(mdpath, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(
|
||||
dataclass_to_json(
|
||||
DumpedAppStateMetadata(
|
||||
reason=reason,
|
||||
app_time=_babase.apptime(),
|
||||
log_level=log_level,
|
||||
)
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Abandon whole dump if we can't write metadata.
|
||||
logging.exception('Error writing app state dump metadata.')
|
||||
return
|
||||
|
||||
tbpath = os.path.join(
|
||||
os.path.dirname(_babase.app.config_file_path), '_appstate_dump_tb'
|
||||
)
|
||||
|
||||
tbfile = open(tbpath, 'w', encoding='utf-8')
|
||||
|
||||
# faulthandler needs the raw file descriptor to still be valid when
|
||||
# it fires, so stuff this into a global var to make sure it doesn't get
|
||||
# cleaned up.
|
||||
_tb_held_files.append(tbfile)
|
||||
|
||||
if delay > 0.0:
|
||||
faulthandler.dump_traceback_later(delay, file=tbfile)
|
||||
else:
|
||||
faulthandler.dump_traceback(file=tbfile)
|
||||
|
||||
# Attempt to log shortly after dumping.
|
||||
# Allow sufficient time since we don't know how long the dump takes.
|
||||
# We want this to work from any thread, so need to kick this part
|
||||
# over to the logic thread so timer works.
|
||||
_babase.pushcall(
|
||||
tpartial(_babase.apptimer, delay + 1.0, log_dumped_app_state),
|
||||
from_other_thread=True,
|
||||
suppress_other_thread_warning=True,
|
||||
)
|
||||
|
||||
|
||||
def log_dumped_app_state() -> None:
|
||||
"""If an app-state dump exists, log it and clear it. No-op otherwise."""
|
||||
|
||||
try:
|
||||
out = ''
|
||||
mdpath = os.path.join(
|
||||
os.path.dirname(_babase.app.config_file_path), '_appstate_dump_md'
|
||||
)
|
||||
if os.path.exists(mdpath):
|
||||
# We may be hanging on to open file descriptors for use by
|
||||
# faulthandler (see above). If we are, we need to clear them
|
||||
# now or else we'll get 'file in use' errors below when we
|
||||
# try to unlink it on windows.
|
||||
for heldfile in _tb_held_files:
|
||||
heldfile.close()
|
||||
_tb_held_files.clear()
|
||||
|
||||
with open(mdpath, 'r', encoding='utf-8') as infile:
|
||||
appstatedata = infile.read()
|
||||
|
||||
# Kill the file first in case we can't parse the data; don't
|
||||
# want to get stuck doing this repeatedly.
|
||||
os.unlink(mdpath)
|
||||
|
||||
metadata = dataclass_from_json(DumpedAppStateMetadata, appstatedata)
|
||||
|
||||
out += (
|
||||
f'App state dump:\nReason: {metadata.reason}\n'
|
||||
f'Time: {metadata.app_time:.2f}'
|
||||
)
|
||||
tbpath = os.path.join(
|
||||
os.path.dirname(_babase.app.config_file_path),
|
||||
'_appstate_dump_tb',
|
||||
)
|
||||
if os.path.exists(tbpath):
|
||||
with open(tbpath, 'r', encoding='utf-8') as infile:
|
||||
out += '\nPython tracebacks:\n' + infile.read()
|
||||
os.unlink(tbpath)
|
||||
logging.log(metadata.log_level.python_logging_level, out)
|
||||
except Exception:
|
||||
logging.exception('Error logging dumped app state.')
|
||||
|
||||
|
||||
class AppHealthMonitor(AppSubsystem):
|
||||
"""Logs things like app-not-responding issues."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
super().__init__()
|
||||
self._running = True
|
||||
self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
|
||||
self._thread.start()
|
||||
self._response = False
|
||||
self._first_check = True
|
||||
|
||||
def _app_monitor_thread_main(self) -> None:
|
||||
try:
|
||||
self._monitor_app()
|
||||
except Exception:
|
||||
logging.exception('Error in AppHealthMonitor thread.')
|
||||
|
||||
def _set_response(self) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
self._response = True
|
||||
|
||||
def _check_running(self) -> bool:
|
||||
# Workaround for the fact that mypy assumes _running
|
||||
# doesn't change during the course of a function.
|
||||
return self._running
|
||||
|
||||
def _monitor_app(self) -> None:
|
||||
import time
|
||||
|
||||
while bool(True):
|
||||
# Always sleep a bit between checks.
|
||||
time.sleep(1.234)
|
||||
|
||||
# Do nothing while backgrounded.
|
||||
while not self._running:
|
||||
time.sleep(2.3456)
|
||||
|
||||
# Wait for the logic thread to run something we send it.
|
||||
starttime = time.monotonic()
|
||||
self._response = False
|
||||
_babase.pushcall(self._set_response, raw=True)
|
||||
while not self._response:
|
||||
# Abort this check if we went into the background.
|
||||
if not self._check_running():
|
||||
break
|
||||
|
||||
# Wait a bit longer the first time through since the app
|
||||
# could still be starting up; we generally don't want to
|
||||
# report that.
|
||||
threshold = 10 if self._first_check else 5
|
||||
|
||||
# If we've been waiting too long (and the app is running)
|
||||
# dump the app state and bail. Make an exception for the
|
||||
# first check though since the app could just be taking
|
||||
# a while to get going; we don't want to report that.
|
||||
duration = time.monotonic() - starttime
|
||||
if duration > threshold:
|
||||
dump_app_state(
|
||||
reason=f'Logic thread unresponsive'
|
||||
f' for {threshold} seconds.'
|
||||
)
|
||||
|
||||
# We just do one alert for now.
|
||||
return
|
||||
|
||||
time.sleep(1.042)
|
||||
|
||||
self._first_check = False
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
self._running = False
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
self._running = True
|
||||
247
dist/ba_data/python/babase/_assetmanager.py
vendored
Normal file
247
dist/ba_data/python/babase/_assetmanager.py
vendored
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to managing cloud based assets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Annotated
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import threading
|
||||
import urllib.request
|
||||
import logging
|
||||
import weakref
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
from efro.dataclassio import (
|
||||
ioprepped,
|
||||
IOAttrs,
|
||||
dataclass_from_json,
|
||||
dataclass_to_json,
|
||||
)
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bacommon.assets import AssetPackageFlavor
|
||||
|
||||
|
||||
@ioprepped
|
||||
@dataclass
|
||||
class FileValue:
|
||||
"""State for an individual file."""
|
||||
|
||||
|
||||
@ioprepped
|
||||
@dataclass
|
||||
class State:
|
||||
"""Holds all persistent state for the asset-manager."""
|
||||
|
||||
files: Annotated[dict[str, FileValue], IOAttrs('files')] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
|
||||
|
||||
class AssetManager:
|
||||
"""Wrangles all assets."""
|
||||
|
||||
_state: State
|
||||
|
||||
def __init__(self, rootdir: Path) -> None:
|
||||
print('AssetManager()')
|
||||
assert isinstance(rootdir, Path)
|
||||
self.thread_ident = threading.get_ident()
|
||||
self._rootdir = rootdir
|
||||
self._started = False
|
||||
if not self._rootdir.is_dir():
|
||||
raise RuntimeError(f'Provided rootdir does not exist: "{rootdir}"')
|
||||
|
||||
self.load_state()
|
||||
|
||||
def __del__(self) -> None:
|
||||
print('~AssetManager()')
|
||||
if self._started:
|
||||
logging.warning('AssetManager dying in a started state.')
|
||||
|
||||
def launch_gather(
|
||||
self,
|
||||
packages: list[str],
|
||||
flavor: AssetPackageFlavor,
|
||||
account_token: str,
|
||||
) -> AssetGather:
|
||||
"""Spawn an asset-gather operation from this manager."""
|
||||
print(
|
||||
'would gather',
|
||||
packages,
|
||||
'and flavor',
|
||||
flavor,
|
||||
'with token',
|
||||
account_token,
|
||||
)
|
||||
return AssetGather(self)
|
||||
|
||||
def update(self) -> None:
|
||||
"""Can be called periodically to perform upkeep."""
|
||||
|
||||
def start(self) -> None:
|
||||
"""Tell the manager to start working.
|
||||
|
||||
This will initiate network activity and other processing.
|
||||
"""
|
||||
if self._started:
|
||||
logging.warning('AssetManager.start() called on running manager.')
|
||||
self._started = True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Tell the manager to stop working.
|
||||
|
||||
All network activity should be ceased before this function returns.
|
||||
"""
|
||||
if not self._started:
|
||||
logging.warning('AssetManager.stop() called on stopped manager.')
|
||||
self._started = False
|
||||
self.save_state()
|
||||
|
||||
@property
|
||||
def rootdir(self) -> Path:
|
||||
"""The root directory for this manager."""
|
||||
return self._rootdir
|
||||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
"""The path of the state file."""
|
||||
return Path(self._rootdir, 'state')
|
||||
|
||||
def load_state(self) -> None:
|
||||
"""Loads state from disk. Resets to default state if unable to."""
|
||||
print('ASSET-MANAGER LOADING STATE')
|
||||
try:
|
||||
state_path = self.state_path
|
||||
if state_path.exists():
|
||||
with open(self.state_path, encoding='utf-8') as infile:
|
||||
self._state = dataclass_from_json(State, infile.read())
|
||||
return
|
||||
except Exception:
|
||||
logging.exception('Error loading existing AssetManager state')
|
||||
self._state = State()
|
||||
|
||||
def save_state(self) -> None:
|
||||
"""Save state to disk (if possible)."""
|
||||
|
||||
print('ASSET-MANAGER SAVING STATE')
|
||||
try:
|
||||
with open(self.state_path, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(dataclass_to_json(self._state))
|
||||
except Exception:
|
||||
logging.exception('Error writing AssetManager state')
|
||||
|
||||
|
||||
class AssetGather:
|
||||
"""Wrangles a gathering of assets."""
|
||||
|
||||
def __init__(self, manager: AssetManager) -> None:
|
||||
assert threading.get_ident() == manager.thread_ident
|
||||
self._manager = weakref.ref(manager)
|
||||
# self._valid = True
|
||||
print('AssetGather()')
|
||||
# url = 'https://files.ballistica.net/bombsquad/promo/BSGamePlay.mov'
|
||||
# url = 'http://www.python.org/ftp/python/2.7.3/Python-2.7.3.tgz'
|
||||
# fetch_url(url,
|
||||
# filename=Path(manager.rootdir, 'testdl'),
|
||||
# asset_gather=self)
|
||||
# print('fetch success')
|
||||
thread = threading.Thread(target=self._run)
|
||||
thread.run()
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Run the gather in a background thread."""
|
||||
print('hello from gather bg')
|
||||
|
||||
# First, do some sort of.
|
||||
|
||||
# @property
|
||||
# def valid(self) -> bool:
|
||||
# """Whether this gather is still valid.
|
||||
|
||||
# A gather becomes in valid if its originating AssetManager dies.
|
||||
# """
|
||||
# return True
|
||||
|
||||
def __del__(self) -> None:
|
||||
print('~AssetGather()')
|
||||
|
||||
|
||||
def fetch_url(url: str, filename: Path, asset_gather: AssetGather) -> None:
|
||||
"""Fetch a given url to a given filename for a given AssetGather."""
|
||||
# pylint: disable=consider-using-with
|
||||
import socket
|
||||
|
||||
# We don't want to keep the provided AssetGather alive, but we want
|
||||
# to abort if it dies.
|
||||
assert isinstance(asset_gather, AssetGather)
|
||||
# weak_gather = weakref.ref(asset_gather)
|
||||
|
||||
# Pass a very short timeout to urllib so we have opportunities
|
||||
# to cancel even with network blockage.
|
||||
req = urllib.request.urlopen(
|
||||
urllib.request.Request(
|
||||
url, None, {'User-Agent': _babase.user_agent_string()}
|
||||
),
|
||||
context=_babase.app.net.sslcontext,
|
||||
timeout=1,
|
||||
)
|
||||
file_size = int(req.headers['Content-Length'])
|
||||
print(f'\nDownloading: {filename} Bytes: {file_size:,}')
|
||||
|
||||
def doit() -> None:
|
||||
time.sleep(1)
|
||||
print('dir', type(req.fp), dir(req.fp))
|
||||
print('WOULD DO IT', flush=True)
|
||||
# req.close()
|
||||
# req.fp.close()
|
||||
|
||||
threading.Thread(target=doit).run()
|
||||
|
||||
with open(filename, 'wb') as outfile:
|
||||
file_size_dl = 0
|
||||
|
||||
block_sz = 1024 * 1024 * 1000
|
||||
time_outs = 0
|
||||
while True:
|
||||
try:
|
||||
data = req.read(block_sz)
|
||||
except ValueError:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
print('VALUEERROR', flush=True)
|
||||
break
|
||||
except socket.timeout:
|
||||
print('TIMEOUT', flush=True)
|
||||
# File has not had activity in max seconds.
|
||||
if time_outs > 3:
|
||||
print('\n\n\nsorry -- try back later')
|
||||
os.unlink(filename)
|
||||
raise
|
||||
print(
|
||||
'\nHmmm... little issue... '
|
||||
'I\'ll wait a couple of seconds'
|
||||
)
|
||||
time.sleep(3)
|
||||
time_outs += 1
|
||||
continue
|
||||
|
||||
# We reached the end of the download!
|
||||
if not data:
|
||||
sys.stdout.write('\rDone!\n\n')
|
||||
sys.stdout.flush()
|
||||
break
|
||||
|
||||
file_size_dl += len(data)
|
||||
outfile.write(data)
|
||||
percent = file_size_dl * 1.0 / file_size
|
||||
status = f'{file_size_dl:20,} Bytes [{percent:.2%}] received'
|
||||
sys.stdout.write('\r' + status)
|
||||
sys.stdout.flush()
|
||||
print('done with', req.fp)
|
||||
89
dist/ba_data/python/babase/_asyncio.py
vendored
Normal file
89
dist/ba_data/python/babase/_asyncio.py
vendored
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Asyncio related functionality.
|
||||
|
||||
Exploring the idea of allowing Python coroutines to run gracefully
|
||||
besides our internal event loop. They could prove useful for networking
|
||||
operations or possibly game logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
|
||||
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
|
||||
|
||||
DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1'
|
||||
|
||||
|
||||
def setup_asyncio() -> asyncio.AbstractEventLoop:
|
||||
"""Setup asyncio functionality for the logic thread."""
|
||||
# pylint: disable=global-statement
|
||||
|
||||
import _babase
|
||||
import babase
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Create our event-loop. We don't expect there to be one
|
||||
# running on this thread before we do.
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
print('Found running asyncio loop; unexpected.')
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
global _asyncio_event_loop
|
||||
_asyncio_event_loop = asyncio.new_event_loop()
|
||||
_asyncio_event_loop.set_default_executor(babase.app.threadpool)
|
||||
|
||||
# Ideally we should integrate asyncio into our C++ Thread class's
|
||||
# low level event loop so that asyncio timers/sockets/etc. could
|
||||
# be true first-class citizens. For now, though, we can explicitly
|
||||
# pump an asyncio loop periodically which gets us a decent
|
||||
# approximation of that, which should be good enough for
|
||||
# all but extremely time sensitive uses.
|
||||
# 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)
|
||||
starttime = time.monotonic() if DEBUG_TIMING else 0
|
||||
_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.
|
||||
if DEBUG_TIMING:
|
||||
warn_time = 1.0 / 120
|
||||
duration = endtime - starttime
|
||||
if duration > warn_time:
|
||||
logging.warning(
|
||||
'Asyncio loop step took %.4fs; ideal max is %.4f',
|
||||
duration,
|
||||
warn_time,
|
||||
)
|
||||
|
||||
global _asyncio_timer
|
||||
_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
|
||||
await asyncio.sleep(2.0)
|
||||
print('TEST AIO TASK ENDING')
|
||||
|
||||
_testtask = _asyncio_event_loop.create_task(aio_test())
|
||||
|
||||
return _asyncio_event_loop
|
||||
192
dist/ba_data/python/babase/_cloud.py
vendored
Normal file
192
dist/ba_data/python/babase/_cloud.py
vendored
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to the cloud."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, overload
|
||||
|
||||
import _babase
|
||||
from babase._appsubsystem import AppSubsystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable, Any
|
||||
|
||||
from efro.message import Message, Response
|
||||
import bacommon.cloud
|
||||
|
||||
DEBUG_LOG = False
|
||||
|
||||
# TODO: Should make it possible to define a protocol in bacommon.cloud and
|
||||
# autogenerate this. That would give us type safety between this and
|
||||
# internal protocols.
|
||||
|
||||
|
||||
class CloudSubsystem(AppSubsystem):
|
||||
"""Manages communication with cloud components."""
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Return whether a connection to the cloud is present.
|
||||
|
||||
This is a good indicator (though not for certain) that sending
|
||||
messages will succeed.
|
||||
"""
|
||||
return False # Needs to be overridden
|
||||
|
||||
def on_connectivity_changed(self, connected: bool) -> None:
|
||||
"""Called when cloud connectivity state changes."""
|
||||
if DEBUG_LOG:
|
||||
logging.debug('CloudSubsystem: Connectivity is now %s.', connected)
|
||||
|
||||
plus = _babase.app.plus
|
||||
assert plus is not None
|
||||
|
||||
# Inform things that use this.
|
||||
# (TODO: should generalize this into some sort of registration system)
|
||||
plus.accounts.on_cloud_connectivity_changed(connected)
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.LoginProxyRequestMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.LoginProxyRequestResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.LoginProxyStateQueryMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.LoginProxyStateQueryResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.LoginProxyCompleteMessage,
|
||||
on_response: Callable[[None | Exception], None],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.PingMessage,
|
||||
on_response: Callable[[bacommon.cloud.PingResponse | Exception], None],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.SignInMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.SignInResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: bacommon.cloud.ManageAccountMessage,
|
||||
on_response: Callable[
|
||||
[bacommon.cloud.ManageAccountResponse | Exception], None
|
||||
],
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def send_message_cb(
|
||||
self,
|
||||
msg: Message,
|
||||
on_response: Callable[[Any], None],
|
||||
) -> None:
|
||||
"""Asynchronously send a message to the cloud from the logic thread.
|
||||
|
||||
The provided on_response call will be run in the logic thread
|
||||
and passed either the response or the error that occurred.
|
||||
"""
|
||||
from babase._general import Call
|
||||
|
||||
del msg # Unused.
|
||||
|
||||
_babase.pushcall(
|
||||
Call(
|
||||
on_response,
|
||||
RuntimeError('Cloud functionality is not available.'),
|
||||
)
|
||||
)
|
||||
|
||||
@overload
|
||||
def send_message(
|
||||
self, msg: bacommon.cloud.WorkspaceFetchMessage
|
||||
) -> bacommon.cloud.WorkspaceFetchResponse:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message(
|
||||
self, msg: bacommon.cloud.MerchAvailabilityMessage
|
||||
) -> bacommon.cloud.MerchAvailabilityResponse:
|
||||
...
|
||||
|
||||
@overload
|
||||
def send_message(
|
||||
self, msg: bacommon.cloud.TestMessage
|
||||
) -> bacommon.cloud.TestResponse:
|
||||
...
|
||||
|
||||
def send_message(self, msg: Message) -> Response | None:
|
||||
"""Synchronously send a message to the cloud.
|
||||
|
||||
Must be called from a background thread.
|
||||
"""
|
||||
raise RuntimeError('Cloud functionality is not available.')
|
||||
|
||||
|
||||
def cloud_console_exec(code: str) -> None:
|
||||
"""Called by the cloud console to run code in the logic thread."""
|
||||
import sys
|
||||
import __main__
|
||||
|
||||
try:
|
||||
# First try it as eval.
|
||||
try:
|
||||
evalcode = compile(code, '<console>', 'eval')
|
||||
except SyntaxError:
|
||||
evalcode = None
|
||||
except Exception:
|
||||
# hmm; when we can't compile it as eval will we always get
|
||||
# syntax error?
|
||||
logging.exception(
|
||||
'unexpected error compiling code for cloud-console eval.'
|
||||
)
|
||||
evalcode = None
|
||||
if evalcode is not None:
|
||||
# pylint: disable=eval-used
|
||||
value = eval(evalcode, vars(__main__), vars(__main__))
|
||||
# For eval-able statements, print the resulting value if
|
||||
# it is not None (just like standard Python interpreter).
|
||||
if value is not None:
|
||||
print(repr(value), file=sys.stderr)
|
||||
|
||||
# Fall back to exec if we couldn't compile it as eval.
|
||||
else:
|
||||
execcode = compile(code, '<console>', 'exec')
|
||||
# pylint: disable=exec-used
|
||||
exec(execcode, vars(__main__), vars(__main__))
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
apptime = _babase.apptime()
|
||||
print(f'Exec error at time {apptime:.2f}.', file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
|
||||
# This helps the logging system ship stderr back to the
|
||||
# cloud promptly.
|
||||
sys.stderr.flush()
|
||||
37
dist/ba_data/python/babase/_emptyappmode.py
vendored
Normal file
37
dist/ba_data/python/babase/_emptyappmode.py
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Provides AppMode functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _babase
|
||||
from babase._appmode import AppMode
|
||||
from babase._appintent import AppIntentExec, AppIntentDefault
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babase import AppIntent
|
||||
|
||||
|
||||
class EmptyAppMode(AppMode):
|
||||
"""An empty app mode that can be used as a fallback/etc."""
|
||||
|
||||
@classmethod
|
||||
def supports_intent(cls, intent: AppIntent) -> bool:
|
||||
# We support default and exec intents currently.
|
||||
return isinstance(intent, AppIntentExec | AppIntentDefault)
|
||||
|
||||
def handle_intent(self, intent: AppIntent) -> None:
|
||||
if isinstance(intent, AppIntentExec):
|
||||
_babase.empty_app_mode_handle_intent_exec(intent.code)
|
||||
return
|
||||
assert isinstance(intent, AppIntentDefault)
|
||||
_babase.empty_app_mode_handle_intent_default()
|
||||
|
||||
def on_activate(self) -> None:
|
||||
# Let the native layer do its thing.
|
||||
_babase.empty_app_mode_activate()
|
||||
|
||||
def on_deactivate(self) -> None:
|
||||
# Let the native layer do its thing.
|
||||
_babase.empty_app_mode_deactivate()
|
||||
239
dist/ba_data/python/babase/_env.py
vendored
Normal file
239
dist/ba_data/python/babase/_env.py
vendored
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Environment related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import signal
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.log import LogLevel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
from efro.log import LogEntry, LogHandler
|
||||
|
||||
_g_babase_imported = False # pylint: disable=invalid-name
|
||||
_g_babase_app_started = False # pylint: disable=invalid-name
|
||||
|
||||
|
||||
def on_native_module_import() -> None:
|
||||
"""Called when _babase is being imported.
|
||||
|
||||
This code should do as little as possible; we want to defer all
|
||||
environment modifications until we actually commit to running an
|
||||
app.
|
||||
"""
|
||||
import _babase
|
||||
import baenv
|
||||
|
||||
global _g_babase_imported # pylint: disable=global-statement
|
||||
|
||||
assert not _g_babase_imported
|
||||
_g_babase_imported = True
|
||||
|
||||
# If we have a log_handler set up, wire it up to feed _babase its
|
||||
# output.
|
||||
envconfig = baenv.get_config()
|
||||
if envconfig.log_handler is not None:
|
||||
_feed_logs_to_babase(envconfig.log_handler)
|
||||
|
||||
env = _babase.pre_env()
|
||||
|
||||
# Give a soft warning if we're being used with a different binary
|
||||
# version than we were built for.
|
||||
running_build: int = env['build_number']
|
||||
if running_build != baenv.TARGET_BALLISTICA_BUILD:
|
||||
logging.warning(
|
||||
'These scripts are meant to be used with'
|
||||
' Ballistica build %d, but you are running build %d.'
|
||||
" This might cause problems. Module path: '%s'.",
|
||||
baenv.TARGET_BALLISTICA_BUILD,
|
||||
running_build,
|
||||
__file__,
|
||||
)
|
||||
|
||||
debug_build = env['debug_build']
|
||||
|
||||
# We expect dev_mode on in debug builds and off otherwise;
|
||||
# make noise if that's not the case.
|
||||
if debug_build != sys.flags.dev_mode:
|
||||
logging.warning(
|
||||
'Ballistica was built with debug-mode %s'
|
||||
' but Python is running with dev-mode %s;'
|
||||
' this mismatch may cause problems.'
|
||||
' See https://docs.python.org/3/library/devmode.html',
|
||||
debug_build,
|
||||
sys.flags.dev_mode,
|
||||
)
|
||||
|
||||
|
||||
def on_main_thread_start_app() -> None:
|
||||
"""Called in the main thread when we're starting an app.
|
||||
|
||||
We use this opportunity to set up the Python runtime environment
|
||||
as we like it for running our app stuff. This includes things like
|
||||
signal-handling, garbage-collection, and logging.
|
||||
"""
|
||||
import gc
|
||||
import baenv
|
||||
import _babase
|
||||
|
||||
global _g_babase_app_started # pylint: disable=global-statement
|
||||
|
||||
_g_babase_app_started = True
|
||||
|
||||
assert _g_babase_imported
|
||||
assert baenv.config_exists()
|
||||
|
||||
# If we were unable to set paths earlier, complain now.
|
||||
if baenv.did_paths_set_fail():
|
||||
logging.warning(
|
||||
'Ballistica Python paths have not been set. This may cause'
|
||||
' problems. To ensure paths are set, run baenv.configure()'
|
||||
' BEFORE importing any Ballistica modules.'
|
||||
)
|
||||
|
||||
# Set up interrupt-signal handling.
|
||||
|
||||
# Note: I've found we need to set up our C signal handling AFTER
|
||||
# we've told Python to disable its own; otherwise (on Mac at least)
|
||||
# it wipes out our existing C handler.
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL) # Do default handling.
|
||||
_babase.setup_sigint()
|
||||
|
||||
# Turn off fancy-pants cyclic garbage-collection. We run it only at
|
||||
# explicit times to avoid random hitches and keep things more
|
||||
# deterministic. Non-reference-looped objects will still get cleaned
|
||||
# up immediately, so we should try to structure things to avoid
|
||||
# reference loops (just like Swift, ObjC, etc).
|
||||
|
||||
# FIXME - move this to Python bootstrapping code. or perhaps disable
|
||||
# it completely since we've got more bg stuff happening now?...
|
||||
# (but put safeguards in place to time/minimize gc pauses).
|
||||
gc.disable()
|
||||
|
||||
# pylint: disable=c-extension-no-member
|
||||
if not TYPE_CHECKING:
|
||||
import __main__
|
||||
|
||||
# Clear out the standard quit/exit messages since they don't
|
||||
# work in our embedded situation (should revisit this once we're
|
||||
# usable from a standard interpreter). Note that these don't
|
||||
# exist in the first place for our monolithic builds which don't
|
||||
# use site.py.
|
||||
for attr in ('quit', 'exit'):
|
||||
if hasattr(__main__.__builtins__, attr):
|
||||
delattr(__main__.__builtins__, attr)
|
||||
|
||||
# Also replace standard interactive help with our simplified
|
||||
# non-blocking one which is more friendly to cloud/in-app console
|
||||
# situations.
|
||||
__main__.__builtins__.help = _CustomHelper()
|
||||
|
||||
# On Windows I'm seeing the following error creating asyncio loops
|
||||
# in background threads with the default proactor setup:
|
||||
|
||||
# ValueError: set_wakeup_fd only works in main thread of the main
|
||||
# interpreter.
|
||||
|
||||
# So let's explicitly request selector loops. Interestingly this
|
||||
# error only started showing up once I moved Python init to the main
|
||||
# thread; previously the various asyncio bg thread loops were
|
||||
# working fine (maybe something caused them to default to selector
|
||||
# in that case?..
|
||||
if sys.platform == 'win32':
|
||||
import asyncio
|
||||
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
|
||||
def on_app_launching() -> None:
|
||||
"""Called when the app reaches the launching state."""
|
||||
import _babase
|
||||
import baenv
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Let the user know if the app Python dir is a 'user' one.
|
||||
envconfig = baenv.get_config()
|
||||
if envconfig.is_user_app_python_dir:
|
||||
_babase.screenmessage(
|
||||
f"Using user system scripts: '{envconfig.app_python_dir}'",
|
||||
color=(0.6, 0.6, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _feed_logs_to_babase(log_handler: LogHandler) -> None:
|
||||
"""Route log/print output to internal ballistica console/etc."""
|
||||
import _babase
|
||||
|
||||
def _on_log(entry: LogEntry) -> None:
|
||||
# Forward this along to the engine to display in the in-app
|
||||
# console, in the Android log, etc.
|
||||
_babase.display_log(
|
||||
name=entry.name,
|
||||
level=entry.level.name,
|
||||
message=entry.message,
|
||||
)
|
||||
|
||||
# We also want to feed some logs to the old v1-cloud-log system.
|
||||
# Let's go with anything warning or higher as well as the
|
||||
# stdout/stderr log messages that babase.app.log_handler creates
|
||||
# for us. We should retire or upgrade this system at some point.
|
||||
if entry.level.value >= LogLevel.WARNING.value or entry.name in (
|
||||
'stdout',
|
||||
'stderr',
|
||||
):
|
||||
_babase.v1_cloud_log(entry.message)
|
||||
|
||||
# Add our callback and also feed it all entries already in the
|
||||
# cache. This will feed the engine any logs that happened between
|
||||
# baenv.configure() and now.
|
||||
|
||||
# FIXME: while this works for now, the downside is that these
|
||||
# callbacks fire in a bg thread so certain things like android
|
||||
# logging will be delayed compared to code that uses native logging
|
||||
# calls directly. Perhaps we should add some sort of 'immediate'
|
||||
# callback option to better handle such cases (similar to the
|
||||
# immediate echofile stderr print that already occurs).
|
||||
log_handler.add_callback(_on_log, feed_existing_logs=True)
|
||||
|
||||
|
||||
class _CustomHelper:
|
||||
"""Replacement 'help' that behaves better for our setup."""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return 'Type help(object) for help about object.'
|
||||
|
||||
def __call__(self, *args: Any, **kwds: Any) -> Any:
|
||||
# We get an ugly error importing pydoc on our embedded platforms
|
||||
# due to _sysconfigdata_xxx.py not being present (but then
|
||||
# things mostly work). Let's get the ugly error out of the way
|
||||
# explicitly.
|
||||
|
||||
# FIXME: we shouldn't be seeing this error anymore. Should
|
||||
# revisit this.
|
||||
import sysconfig
|
||||
|
||||
try:
|
||||
# This errors once but seems to run cleanly after, so let's
|
||||
# get the error out of the way.
|
||||
sysconfig.get_path('stdlib')
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
import pydoc
|
||||
|
||||
# Disable pager and interactive help since neither works well
|
||||
# with our funky multi-threaded setup or in-game/cloud consoles.
|
||||
# Let's just do simple text dumps.
|
||||
pydoc.pager = pydoc.plainpager
|
||||
if not args and not kwds:
|
||||
print(
|
||||
'Interactive help is not available in this environment.\n'
|
||||
'Type help(object) for help about object.'
|
||||
)
|
||||
return None
|
||||
return pydoc.help(*args, **kwds)
|
||||
188
dist/ba_data/python/babase/_error.py
vendored
Normal file
188
dist/ba_data/python/babase/_error.py
vendored
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Error related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ContextError(Exception):
|
||||
"""Exception raised when a call is made in an invalid context.
|
||||
|
||||
Category: **Exception Classes**
|
||||
|
||||
Examples of this include calling UI functions within an Activity context
|
||||
or calling scene manipulation functions outside of a game context.
|
||||
"""
|
||||
|
||||
|
||||
class NotFoundError(Exception):
|
||||
"""Exception raised when a referenced object does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class PlayerNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected player does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class SessionPlayerNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected session-player does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class TeamNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected bascenev1.Team does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class MapNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected bascenev1.Map does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class DelegateNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected delegate object does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class SessionTeamNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected session-team does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class NodeNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected Node does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class ActorNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected actor does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class ActivityNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected bascenev1.Activity does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class SessionNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected session does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class InputDeviceNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected input-device does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
class WidgetNotFoundError(NotFoundError):
|
||||
"""Exception raised when an expected widget does not exist.
|
||||
|
||||
Category: **Exception Classes**
|
||||
"""
|
||||
|
||||
|
||||
# TODO: Should integrate some sort of context printing into our
|
||||
# log handling so we can just use logging.exception() and kill these
|
||||
# two functions.
|
||||
|
||||
|
||||
def print_exception(*args: Any, **keywds: Any) -> None:
|
||||
"""Print info about an exception along with pertinent context state.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
Prints all arguments provided along with various info about the
|
||||
current context and the outstanding exception.
|
||||
Pass the keyword 'once' as True if you want the call to only happen
|
||||
one time from an exact calling location.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
if keywds:
|
||||
allowed_keywds = ['once']
|
||||
if any(keywd not in allowed_keywds for keywd in keywds):
|
||||
raise TypeError('invalid keyword(s)')
|
||||
try:
|
||||
# If we're only printing once and already have, bail.
|
||||
if keywds.get('once', False):
|
||||
if not _babase.do_once():
|
||||
return
|
||||
|
||||
err_str = ' '.join([str(a) for a in args])
|
||||
print('ERROR:', err_str)
|
||||
_babase.print_context()
|
||||
print('PRINTED-FROM:')
|
||||
|
||||
# Basically the output of traceback.print_stack()
|
||||
stackstr = ''.join(traceback.format_stack())
|
||||
print(stackstr, end='')
|
||||
print('EXCEPTION:')
|
||||
|
||||
# Basically the output of traceback.print_exc()
|
||||
excstr = traceback.format_exc()
|
||||
print('\n'.join(' ' + l for l in excstr.splitlines()))
|
||||
except Exception:
|
||||
# I suppose using print_exception here would be a bad idea.
|
||||
print('ERROR: exception in babase.print_exception():')
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def print_error(err_str: str, once: bool = False) -> None:
|
||||
"""Print info about an error along with pertinent context state.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
Prints all positional arguments provided along with various info about the
|
||||
current context.
|
||||
Pass the keyword 'once' as True if you want the call to only happen
|
||||
one time from an exact calling location.
|
||||
"""
|
||||
import traceback
|
||||
|
||||
try:
|
||||
# If we're only printing once and already have, bail.
|
||||
if once:
|
||||
if not _babase.do_once():
|
||||
return
|
||||
|
||||
print('ERROR:', err_str)
|
||||
_babase.print_context()
|
||||
|
||||
# Basically the output of traceback.print_stack()
|
||||
stackstr = ''.join(traceback.format_stack())
|
||||
print(stackstr, end='')
|
||||
except Exception:
|
||||
print('ERROR: exception in babase.print_error():')
|
||||
traceback.print_exc()
|
||||
404
dist/ba_data/python/babase/_general.py
vendored
Normal file
404
dist/ba_data/python/babase/_general.py
vendored
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Utility snippets applying to generic Python code."""
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
import weakref
|
||||
import random
|
||||
import inspect
|
||||
from typing import TYPE_CHECKING, TypeVar, Protocol, NewType
|
||||
|
||||
from efro.terminal import Clr
|
||||
import _babase
|
||||
from babase._error import print_error, print_exception
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
from efro.call import Call as Call # 'as Call' so we re-export.
|
||||
|
||||
|
||||
# Declare distinct types for different time measurements we use so the
|
||||
# type-checker can help prevent us from mixing and matching accidentally.
|
||||
|
||||
# Our monotonic time measurement that starts at 0 when the app launches
|
||||
# and pauses while the app is suspended.
|
||||
AppTime = NewType('AppTime', float)
|
||||
|
||||
# Like app-time but incremented at frame draw time and in a smooth
|
||||
# consistent manner; useful to keep animations smooth and jitter-free.
|
||||
DisplayTime = NewType('DisplayTime', float)
|
||||
|
||||
|
||||
class Existable(Protocol):
|
||||
"""A Protocol for objects supporting an exists() method.
|
||||
|
||||
Category: **Protocols**
|
||||
"""
|
||||
|
||||
def exists(self) -> bool:
|
||||
"""Whether this object exists."""
|
||||
|
||||
|
||||
ExistableT = TypeVar('ExistableT', bound=Existable)
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def existing(obj: ExistableT | None) -> ExistableT | None:
|
||||
"""Convert invalid references to None for any babase.Existable object.
|
||||
|
||||
Category: **Gameplay Functions**
|
||||
|
||||
To best support type checking, it is important that invalid references
|
||||
not be passed around and instead get converted to values of None.
|
||||
That way the type checker can properly flag attempts to pass possibly-dead
|
||||
objects (FooType | None) into functions expecting only live ones
|
||||
(FooType), etc. This call can be used on any 'existable' object
|
||||
(one with an exists() method) and will convert it to a None value
|
||||
if it does not exist.
|
||||
|
||||
For more info, see notes on 'existables' here:
|
||||
https://ballistica.net/wiki/Coding-Style-Guide
|
||||
"""
|
||||
assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}'
|
||||
return obj if obj is not None and obj.exists() else None
|
||||
|
||||
|
||||
def getclass(name: str, subclassof: type[T]) -> type[T]:
|
||||
"""Given a full class name such as foo.bar.MyClass, return the class.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
The class will be checked to make sure it is a subclass of the provided
|
||||
'subclassof' class, and a TypeError will be raised if not.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
splits = name.split('.')
|
||||
modulename = '.'.join(splits[:-1])
|
||||
classname = splits[-1]
|
||||
module = importlib.import_module(modulename)
|
||||
cls: type = getattr(module, classname)
|
||||
|
||||
if not issubclass(cls, subclassof):
|
||||
raise TypeError(f'{name} is not a subclass of {subclassof}.')
|
||||
return cls
|
||||
|
||||
|
||||
def json_prep(data: Any) -> Any:
|
||||
"""Return a json-friendly version of the provided data.
|
||||
|
||||
This converts any tuples to lists and any bytes to strings
|
||||
(interpreted as utf-8, ignoring errors). Logs errors (just once)
|
||||
if any data is modified/discarded/unsupported.
|
||||
"""
|
||||
|
||||
if isinstance(data, dict):
|
||||
return dict(
|
||||
(json_prep(key), json_prep(value))
|
||||
for key, value in list(data.items())
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return [json_prep(element) for element in data]
|
||||
if isinstance(data, tuple):
|
||||
print_error('json_prep encountered tuple', once=True)
|
||||
return [json_prep(element) for element in data]
|
||||
if isinstance(data, bytes):
|
||||
try:
|
||||
return data.decode(errors='ignore')
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
print_error('json_prep encountered utf-8 decode error', once=True)
|
||||
return data.decode(errors='ignore')
|
||||
if not isinstance(data, (str, float, bool, type(None), int)):
|
||||
print_error(
|
||||
'got unsupported type in json_prep:' + str(type(data)), once=True
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def utf8_all(data: Any) -> Any:
|
||||
"""Convert any unicode data in provided sequence(s) to utf8 bytes."""
|
||||
if isinstance(data, dict):
|
||||
return dict(
|
||||
(utf8_all(key), utf8_all(value))
|
||||
for key, value in list(data.items())
|
||||
)
|
||||
if isinstance(data, list):
|
||||
return [utf8_all(element) for element in data]
|
||||
if isinstance(data, tuple):
|
||||
return tuple(utf8_all(element) for element in data)
|
||||
if isinstance(data, str):
|
||||
return data.encode('utf-8', errors='ignore')
|
||||
return data
|
||||
|
||||
|
||||
def get_type_name(cls: type) -> str:
|
||||
"""Return a full type name including module for a class."""
|
||||
return cls.__module__ + '.' + cls.__name__
|
||||
|
||||
|
||||
class _WeakCall:
|
||||
"""Wrap a callable and arguments into a single callable object.
|
||||
|
||||
Category: **General Utility Classes**
|
||||
|
||||
When passed a bound method as the callable, the instance portion
|
||||
of it is weak-referenced, meaning the underlying instance is
|
||||
free to die if all other references to it go away. Should this
|
||||
occur, calling the WeakCall is simply a no-op.
|
||||
|
||||
Think of this as a handy way to tell an object to do something
|
||||
at some point in the future if it happens to still exist.
|
||||
|
||||
##### Examples
|
||||
**EXAMPLE A:** this code will create a FooClass instance and call its
|
||||
bar() method 5 seconds later; it will be kept alive even though
|
||||
we overwrite its variable with None because the bound method
|
||||
we pass as a timer callback (foo.bar) strong-references it
|
||||
>>> foo = FooClass()
|
||||
... babase.apptimer(5.0, foo.bar)
|
||||
... foo = None
|
||||
|
||||
**EXAMPLE B:** This code will *not* keep our object alive; it will die
|
||||
when we overwrite it with None and the timer will be a no-op when it
|
||||
fires
|
||||
>>> foo = FooClass()
|
||||
... babase.apptimer(5.0, ba.WeakCall(foo.bar))
|
||||
... foo = None
|
||||
|
||||
**EXAMPLE C:** Wrap a method call with some positional and keyword args:
|
||||
>>> myweakcall = babase.WeakCall(self.dostuff, argval1,
|
||||
... namedarg=argval2)
|
||||
... # Now we have a single callable to run that whole mess.
|
||||
... # The same as calling myobj.dostuff(argval1, namedarg=argval2)
|
||||
... # (provided my_obj still exists; this will do nothing
|
||||
... # otherwise).
|
||||
... myweakcall()
|
||||
|
||||
Note: additional args and keywords you provide to the WeakCall()
|
||||
constructor are stored as regular strong-references; you'll need
|
||||
to wrap them in weakrefs manually if desired.
|
||||
"""
|
||||
|
||||
_did_invalid_call_warning = False
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any) -> None:
|
||||
"""Instantiate a WeakCall.
|
||||
|
||||
Pass a callable as the first arg, followed by any number of
|
||||
arguments or keywords.
|
||||
"""
|
||||
if hasattr(args[0], '__func__'):
|
||||
self._call = WeakMethod(args[0])
|
||||
else:
|
||||
app = _babase.app
|
||||
if not self._did_invalid_call_warning:
|
||||
print(
|
||||
(
|
||||
'Warning: callable passed to babase.WeakCall() is not'
|
||||
' weak-referencable ('
|
||||
+ str(args[0])
|
||||
+ '); use babase.Call() instead to avoid this '
|
||||
'warning. Stack-trace:'
|
||||
)
|
||||
)
|
||||
import traceback
|
||||
|
||||
traceback.print_stack()
|
||||
self._did_invalid_call_warning = True
|
||||
self._call = args[0]
|
||||
self._args = args[1:]
|
||||
self._keywds = keywds
|
||||
|
||||
def __call__(self, *args_extra: Any) -> Any:
|
||||
return self._call(*self._args + args_extra, **self._keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
'<ba.WeakCall object; _call='
|
||||
+ str(self._call)
|
||||
+ ' _args='
|
||||
+ str(self._args)
|
||||
+ ' _keywds='
|
||||
+ str(self._keywds)
|
||||
+ '>'
|
||||
)
|
||||
|
||||
|
||||
class _Call:
|
||||
"""Wraps a callable and arguments into a single callable object.
|
||||
|
||||
Category: **General Utility Classes**
|
||||
|
||||
The callable is strong-referenced so it won't die until this
|
||||
object does.
|
||||
|
||||
Note that a bound method (ex: ``myobj.dosomething``) contains a reference
|
||||
to ``self`` (``myobj`` in that case), so you will be keeping that object
|
||||
alive too. Use babase.WeakCall if you want to pass a method to callback
|
||||
without keeping its object alive.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any):
|
||||
"""Instantiate a Call.
|
||||
|
||||
Pass a callable as the first arg, followed by any number of
|
||||
arguments or keywords.
|
||||
|
||||
##### Example
|
||||
Wrap a method call with 1 positional and 1 keyword arg:
|
||||
>>> mycall = babase.Call(myobj.dostuff, argval, namedarg=argval2)
|
||||
... # Now we have a single callable to run that whole mess.
|
||||
... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
|
||||
... mycall()
|
||||
"""
|
||||
self._call = args[0]
|
||||
self._args = args[1:]
|
||||
self._keywds = keywds
|
||||
|
||||
def __call__(self, *args_extra: Any) -> Any:
|
||||
return self._call(*self._args + args_extra, **self._keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
'<ba.Call object; _call='
|
||||
+ str(self._call)
|
||||
+ ' _args='
|
||||
+ str(self._args)
|
||||
+ ' _keywds='
|
||||
+ str(self._keywds)
|
||||
+ '>'
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Some interaction between our ballistica pylint plugin
|
||||
# and this code is crashing starting on pylint 2.15.0.
|
||||
# This seems to fix things for now.
|
||||
# pylint: disable=all
|
||||
WeakCall = Call
|
||||
Call = Call
|
||||
else:
|
||||
WeakCall = _WeakCall
|
||||
WeakCall.__name__ = 'WeakCall'
|
||||
Call = _Call
|
||||
Call.__name__ = 'Call'
|
||||
|
||||
|
||||
class WeakMethod:
|
||||
"""A weak-referenced bound method.
|
||||
|
||||
Wraps a bound method using weak references so that the original is
|
||||
free to die. If called with a dead target, is simply a no-op.
|
||||
"""
|
||||
|
||||
def __init__(self, call: types.MethodType):
|
||||
assert isinstance(call, types.MethodType)
|
||||
self._func = call.__func__
|
||||
self._obj = weakref.ref(call.__self__)
|
||||
|
||||
def __call__(self, *args: Any, **keywds: Any) -> Any:
|
||||
obj = self._obj()
|
||||
if obj is None:
|
||||
return None
|
||||
return self._func(*((obj,) + args), **keywds)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '<ba.WeakMethod object; call=' + str(self._func) + '>'
|
||||
|
||||
|
||||
def verify_object_death(obj: object) -> None:
|
||||
"""Warn if an object does not get freed within a short period.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
This can be handy to detect and prevent memory/resource leaks.
|
||||
"""
|
||||
|
||||
try:
|
||||
ref = weakref.ref(obj)
|
||||
except Exception:
|
||||
print_exception('Unable to create weak-ref in verify_object_death')
|
||||
return
|
||||
|
||||
# Use a slight range for our checks so they don't all land at once
|
||||
# if we queue a lot of them.
|
||||
delay = random.uniform(2.0, 5.5)
|
||||
|
||||
# Make this timer in an empty context; don't want it dying with the
|
||||
# scene/etc.
|
||||
with _babase.ContextRef.empty():
|
||||
_babase.apptimer(delay, Call(_verify_object_death, ref))
|
||||
|
||||
|
||||
def _verify_object_death(wref: weakref.ref) -> None:
|
||||
obj = wref()
|
||||
if obj is None:
|
||||
return
|
||||
|
||||
try:
|
||||
name = type(obj).__name__
|
||||
except Exception:
|
||||
print(f'Note: unable to get type name for {obj}')
|
||||
name = 'object'
|
||||
|
||||
print(
|
||||
f'{Clr.RED}Error: {name} not dying when expected to:'
|
||||
f' {Clr.BLD}{obj}{Clr.RST}\n'
|
||||
'See efro.debug for ways to debug this.'
|
||||
)
|
||||
|
||||
|
||||
def storagename(suffix: str | None = None) -> str:
|
||||
"""Generate a unique name for storing class data in shared places.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
This consists of a leading underscore, the module path at the
|
||||
call site with dots replaced by underscores, the containing class's
|
||||
qualified name, and the provided suffix. When storing data in public
|
||||
places such as 'customdata' dicts, this minimizes the chance of
|
||||
collisions with other similarly named classes.
|
||||
|
||||
Note that this will function even if called in the class definition.
|
||||
|
||||
##### Examples
|
||||
Generate a unique name for storage purposes:
|
||||
>>> class MyThingie:
|
||||
... # This will give something like
|
||||
... # '_mymodule_submodule_mythingie_data'.
|
||||
... _STORENAME = babase.storagename('data')
|
||||
...
|
||||
... # Use that name to store some data in the Activity we were
|
||||
... # passed.
|
||||
... def __init__(self, activity):
|
||||
... activity.customdata[self._STORENAME] = {}
|
||||
"""
|
||||
frame = inspect.currentframe()
|
||||
if frame is None:
|
||||
raise RuntimeError('Cannot get current stack frame.')
|
||||
fback = frame.f_back
|
||||
|
||||
# Note: We need to explicitly clear frame here to avoid a ref-loop
|
||||
# that keeps all function-dicts in the stack alive until the next
|
||||
# full GC cycle (the stack frame refers to this function's dict,
|
||||
# which refers to the stack frame).
|
||||
del frame
|
||||
|
||||
if fback is None:
|
||||
raise RuntimeError('Cannot get parent stack frame.')
|
||||
modulepath = fback.f_globals.get('__name__')
|
||||
if modulepath is None:
|
||||
raise RuntimeError('Cannot get parent stack module path.')
|
||||
assert isinstance(modulepath, str)
|
||||
qualname = fback.f_locals.get('__qualname__')
|
||||
if qualname is not None:
|
||||
assert isinstance(qualname, str)
|
||||
fullpath = f'_{modulepath}_{qualname.lower()}'
|
||||
else:
|
||||
fullpath = f'_{modulepath}'
|
||||
if suffix is not None:
|
||||
fullpath = f'{fullpath}_{suffix}'
|
||||
return fullpath.replace('.', '_')
|
||||
395
dist/ba_data/python/babase/_hooks.py
vendored
Normal file
395
dist/ba_data/python/babase/_hooks.py
vendored
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Snippets of code for use by the internal layer.
|
||||
|
||||
History: originally the engine would dynamically compile/eval various Python
|
||||
code from within C++ code, but the major downside there was that none of it
|
||||
was type-checked so if names or arguments changed it would go unnoticed
|
||||
until it broke at runtime. By instead defining such snippets here and then
|
||||
capturing references to them all at launch it is possible to allow linting
|
||||
and type-checking magic to happen and most issues will be caught immediately.
|
||||
"""
|
||||
# (most of these are self-explanatory)
|
||||
# pylint: disable=missing-function-docstring
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
def on_app_bootstrapping_complete() -> None:
|
||||
"""Called by C++ layer when bootstrapping finishes."""
|
||||
_babase.app.on_app_bootstrapping_complete()
|
||||
|
||||
|
||||
def reset_to_main_menu() -> None:
|
||||
# Some high-level event wants us to return to the main menu.
|
||||
# an example of this is re-opening the game after we 'soft' quit it
|
||||
# on Android.
|
||||
if _babase.app.classic is not None:
|
||||
_babase.app.classic.return_to_main_menu_session_gracefully()
|
||||
else:
|
||||
logging.warning('reset_to_main_menu: no-op due to classic not present.')
|
||||
|
||||
|
||||
def set_config_fullscreen_on() -> None:
|
||||
"""The OS has changed our fullscreen state and we should take note."""
|
||||
_babase.app.config['Fullscreen'] = True
|
||||
_babase.app.config.commit()
|
||||
|
||||
|
||||
def set_config_fullscreen_off() -> None:
|
||||
"""The OS has changed our fullscreen state and we should take note."""
|
||||
_babase.app.config['Fullscreen'] = False
|
||||
_babase.app.config.commit()
|
||||
|
||||
|
||||
def not_signed_in_screen_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(Lstr(resource='notSignedInErrorText'))
|
||||
|
||||
|
||||
def open_url_with_webbrowser_module(url: str) -> None:
|
||||
"""Show a URL in the browser or print on-screen error if we can't."""
|
||||
import webbrowser
|
||||
from babase._language import Lstr
|
||||
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception:
|
||||
logging.exception("Error displaying url '%s'.", url)
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
|
||||
|
||||
|
||||
def connecting_to_party_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.connectingToPartyText'), color=(1, 1, 1)
|
||||
)
|
||||
|
||||
|
||||
def rejecting_invite_already_in_party_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.rejectingInviteAlreadyInPartyText'),
|
||||
color=(1, 0.5, 0),
|
||||
)
|
||||
|
||||
|
||||
def connection_failed_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.connectionFailedText'), color=(1, 0.5, 0)
|
||||
)
|
||||
|
||||
|
||||
def temporarily_unavailable_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='getTicketsWindow.unavailableTemporarilyText'),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def in_progress_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='getTicketsWindow.inProgressText'),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def error_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
|
||||
|
||||
|
||||
def purchase_not_valid_error() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(
|
||||
resource='store.purchaseNotValidError',
|
||||
subs=[('${EMAIL}', 'support@froemling.net')],
|
||||
),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def purchase_already_in_progress_error() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='store.purchaseAlreadyInProgressText'),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def uuid_str() -> str:
|
||||
import uuid
|
||||
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def orientation_reset_cb_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.vrOrientationResetCardboardText'),
|
||||
color=(0, 1, 0),
|
||||
)
|
||||
|
||||
|
||||
def orientation_reset_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.vrOrientationResetText'), color=(0, 1, 0)
|
||||
)
|
||||
|
||||
|
||||
def on_app_pause() -> None:
|
||||
_babase.app.pause()
|
||||
|
||||
|
||||
def on_app_resume() -> None:
|
||||
_babase.app.resume()
|
||||
|
||||
|
||||
def show_post_purchase_message() -> None:
|
||||
assert _babase.app.classic is not None
|
||||
_babase.app.classic.accounts.show_post_purchase_message()
|
||||
|
||||
|
||||
def language_test_toggle() -> None:
|
||||
_babase.app.lang.setlanguage(
|
||||
'Gibberish' if _babase.app.lang.language == 'English' else 'English'
|
||||
)
|
||||
|
||||
|
||||
def award_in_control_achievement() -> None:
|
||||
if _babase.app.classic is not None:
|
||||
_babase.app.classic.ach.award_local_achievement('In Control')
|
||||
else:
|
||||
logging.warning('award_in_control_achievement is no-op without classic')
|
||||
|
||||
|
||||
def award_dual_wielding_achievement() -> None:
|
||||
if _babase.app.classic is not None:
|
||||
_babase.app.classic.ach.award_local_achievement('Dual Wielding')
|
||||
else:
|
||||
logging.warning(
|
||||
'award_dual_wielding_achievement is no-op without classic'
|
||||
)
|
||||
|
||||
|
||||
def play_gong_sound() -> None:
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('gong').play()
|
||||
|
||||
|
||||
def launch_coop_game(name: str) -> None:
|
||||
assert _babase.app.classic is not None
|
||||
_babase.app.classic.launch_coop_game(name)
|
||||
|
||||
|
||||
def purchases_restored_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='getTicketsWindow.purchasesRestoredText'), color=(0, 1, 0)
|
||||
)
|
||||
|
||||
|
||||
def unavailable_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='getTicketsWindow.unavailableText'), color=(1, 0, 0)
|
||||
)
|
||||
|
||||
|
||||
def set_last_ad_network(sval: str) -> None:
|
||||
if _babase.app.classic is not None:
|
||||
_babase.app.classic.ads.last_ad_network = sval
|
||||
_babase.app.classic.ads.last_ad_network_set_time = time.time()
|
||||
|
||||
|
||||
def google_play_purchases_not_available_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='googlePlayPurchasesNotAvailableText'), color=(1, 0, 0)
|
||||
)
|
||||
|
||||
|
||||
def google_play_services_not_available_message() -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='googlePlayServicesNotAvailableText'), color=(1, 0, 0)
|
||||
)
|
||||
|
||||
|
||||
def empty_call() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def print_trace() -> None:
|
||||
import traceback
|
||||
|
||||
print('Python Traceback (most recent call last):')
|
||||
traceback.print_stack()
|
||||
|
||||
|
||||
def toggle_fullscreen() -> None:
|
||||
cfg = _babase.app.config
|
||||
cfg['Fullscreen'] = not cfg.resolve('Fullscreen')
|
||||
cfg.apply_and_commit()
|
||||
|
||||
|
||||
def read_config() -> None:
|
||||
_babase.app.read_config()
|
||||
|
||||
|
||||
def ui_remote_press() -> None:
|
||||
"""Handle a press by a remote device that is only usable for nav."""
|
||||
from babase._language import Lstr
|
||||
|
||||
if _babase.app.headless_mode:
|
||||
return
|
||||
|
||||
# Can be called without a context; need a context for getsound.
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='internal.controllerForMenusOnlyText'),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
_babase.getsimplesound('error').play()
|
||||
|
||||
|
||||
def remove_in_game_ads_message() -> None:
|
||||
if _babase.app.classic is not None:
|
||||
_babase.app.classic.ads.do_remove_in_game_ads_message()
|
||||
|
||||
|
||||
def do_quit() -> None:
|
||||
_babase.quit()
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
_babase.app.on_app_shutdown()
|
||||
|
||||
|
||||
def hash_strings(inputs: list[str]) -> str:
|
||||
"""Hash provided strings into a short output string."""
|
||||
import hashlib
|
||||
|
||||
sha = hashlib.sha1()
|
||||
for inp in inputs:
|
||||
sha.update(inp.encode())
|
||||
|
||||
return sha.hexdigest()
|
||||
|
||||
|
||||
def have_account_v2_credentials() -> bool:
|
||||
"""Do we have primary account-v2 credentials set?"""
|
||||
assert _babase.app.plus is not None
|
||||
have: bool = _babase.app.plus.accounts.have_primary_credentials()
|
||||
return have
|
||||
|
||||
|
||||
def implicit_sign_in(
|
||||
login_type_str: str, login_id: str, display_name: str
|
||||
) -> None:
|
||||
"""An implicit login happened."""
|
||||
from bacommon.login import LoginType
|
||||
|
||||
assert _babase.app.plus is not None
|
||||
_babase.app.plus.accounts.on_implicit_sign_in(
|
||||
login_type=LoginType(login_type_str),
|
||||
login_id=login_id,
|
||||
display_name=display_name,
|
||||
)
|
||||
|
||||
|
||||
def implicit_sign_out(login_type_str: str) -> None:
|
||||
"""An implicit logout happened."""
|
||||
from bacommon.login import LoginType
|
||||
|
||||
assert _babase.app.plus is not None
|
||||
_babase.app.plus.accounts.on_implicit_sign_out(
|
||||
login_type=LoginType(login_type_str)
|
||||
)
|
||||
|
||||
|
||||
def login_adapter_get_sign_in_token_response(
|
||||
login_type_str: str, attempt_id_str: str, result_str: str
|
||||
) -> None:
|
||||
"""Login adapter do-sign-in completed."""
|
||||
from bacommon.login import LoginType
|
||||
from babase._login import LoginAdapterNative
|
||||
|
||||
login_type = LoginType(login_type_str)
|
||||
attempt_id = int(attempt_id_str)
|
||||
result = None if result_str == '' else result_str
|
||||
|
||||
assert _babase.app.plus is not None
|
||||
adapter = _babase.app.plus.accounts.login_adapters[login_type]
|
||||
assert isinstance(adapter, LoginAdapterNative)
|
||||
adapter.on_sign_in_complete(attempt_id=attempt_id, result=result)
|
||||
|
||||
|
||||
def show_client_too_old_error() -> None:
|
||||
"""Called at launch if the server tells us we're too old to talk to it."""
|
||||
from babase._language import Lstr
|
||||
|
||||
# If you are using an old build of the app and would like to stop
|
||||
# seeing this error at launch, do:
|
||||
# ba.app.config['SuppressClientTooOldErrorForBuild'] = ba.app.build_number
|
||||
# ba.app.config.commit()
|
||||
# Note that you will have to do that again later if you update to
|
||||
# a newer build.
|
||||
if (
|
||||
_babase.app.config.get('SuppressClientTooOldErrorForBuild')
|
||||
== _babase.app.build_number
|
||||
):
|
||||
return
|
||||
|
||||
if not _babase.app.headless_mode:
|
||||
_babase.getsimplesound('error').play()
|
||||
|
||||
_babase.screenmessage(
|
||||
Lstr(
|
||||
translate=(
|
||||
'serverResponses',
|
||||
'Server functionality is no longer supported'
|
||||
' in this version of the game;\n'
|
||||
'Please update to a newer version.',
|
||||
)
|
||||
),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
33
dist/ba_data/python/babase/_keyboard.py
vendored
Normal file
33
dist/ba_data/python/babase/_keyboard.py
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""On-screen Keyboard related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class Keyboard:
|
||||
"""Chars definitions for on-screen keyboard.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Keyboards are discoverable by the meta-tag system
|
||||
and the user can select which one they want to use.
|
||||
On-screen keyboard uses chars from active babase.Keyboard.
|
||||
"""
|
||||
|
||||
name: str
|
||||
"""Displays when user selecting this keyboard."""
|
||||
|
||||
chars: list[tuple[str, ...]]
|
||||
"""Used for row/column lengths."""
|
||||
|
||||
pages: dict[str, tuple[str, ...]]
|
||||
"""Extra chars like emojis."""
|
||||
|
||||
nums: tuple[str, ...]
|
||||
"""The 'num' page."""
|
||||
649
dist/ba_data/python/babase/_language.py
vendored
Normal file
649
dist/ba_data/python/babase/_language.py
vendored
Normal file
|
|
@ -0,0 +1,649 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Language related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, overload
|
||||
|
||||
import _babase
|
||||
from babase._appsubsystem import AppSubsystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any, Sequence
|
||||
|
||||
import babase
|
||||
|
||||
|
||||
class LanguageSubsystem(AppSubsystem):
|
||||
"""Language functionality for the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single instance of this class at 'babase.app.lang'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.default_language: str = self._get_default_language()
|
||||
|
||||
self._language: str | None = None
|
||||
self._language_target: AttrDict | None = None
|
||||
self._language_merged: AttrDict | None = None
|
||||
|
||||
@property
|
||||
def locale(self) -> str:
|
||||
"""Raw country/language code detected by the game (such as 'en_US').
|
||||
|
||||
Generally for language-specific code you should look at
|
||||
babase.App.language, which is the language the game is using
|
||||
(which may differ from locale if the user sets a language, etc.)
|
||||
"""
|
||||
env = _babase.env()
|
||||
assert isinstance(env['locale'], str)
|
||||
return env['locale']
|
||||
|
||||
@property
|
||||
def language(self) -> str:
|
||||
"""The current active language for the app.
|
||||
|
||||
This can be selected explicitly by the user or may be set
|
||||
automatically based on locale or other factors.
|
||||
"""
|
||||
if self._language is None:
|
||||
raise RuntimeError('App language is not yet set.')
|
||||
return self._language
|
||||
|
||||
@property
|
||||
def available_languages(self) -> list[str]:
|
||||
"""A list of all available languages.
|
||||
|
||||
Note that languages that may be present in game assets but which
|
||||
are not displayable on the running version of the game are not
|
||||
included here.
|
||||
"""
|
||||
langs = set()
|
||||
try:
|
||||
names = os.listdir(
|
||||
os.path.join(
|
||||
_babase.app.data_directory, 'ba_data', 'data', 'languages'
|
||||
)
|
||||
)
|
||||
names = [n.replace('.json', '').capitalize() for n in names]
|
||||
|
||||
# FIXME: our simple capitalization fails on multi-word names;
|
||||
# should handle this in a better way...
|
||||
for i, name in enumerate(names):
|
||||
if name == 'Chinesetraditional':
|
||||
names[i] = 'ChineseTraditional'
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception()
|
||||
names = []
|
||||
for name in names:
|
||||
if self._can_display_language(name):
|
||||
langs.add(name)
|
||||
return sorted(
|
||||
name for name in names if self._can_display_language(name)
|
||||
)
|
||||
|
||||
def setlanguage(
|
||||
self,
|
||||
language: str | None,
|
||||
print_change: bool = True,
|
||||
store_to_config: bool = True,
|
||||
) -> None:
|
||||
"""Set the active app language.
|
||||
|
||||
Pass None to use OS default language.
|
||||
"""
|
||||
# pylint: disable=too-many-locals
|
||||
# pylint: disable=too-many-statements
|
||||
# pylint: disable=too-many-branches
|
||||
assert _babase.in_logic_thread()
|
||||
cfg = _babase.app.config
|
||||
cur_language = cfg.get('Lang', None)
|
||||
|
||||
# Store this in the config if its changing.
|
||||
if language != cur_language and store_to_config:
|
||||
if language is None:
|
||||
if 'Lang' in cfg:
|
||||
del cfg['Lang'] # Clear it out for default.
|
||||
else:
|
||||
cfg['Lang'] = language
|
||||
cfg.commit()
|
||||
switched = True
|
||||
else:
|
||||
switched = False
|
||||
|
||||
with open(
|
||||
os.path.join(
|
||||
_babase.app.data_directory,
|
||||
'ba_data',
|
||||
'data',
|
||||
'languages',
|
||||
'english.json',
|
||||
),
|
||||
encoding='utf-8',
|
||||
) as infile:
|
||||
lenglishvalues = json.loads(infile.read())
|
||||
|
||||
# None implies default.
|
||||
if language is None:
|
||||
language = self.default_language
|
||||
try:
|
||||
if language == 'English':
|
||||
lmodvalues = None
|
||||
else:
|
||||
lmodfile = os.path.join(
|
||||
_babase.app.data_directory,
|
||||
'ba_data',
|
||||
'data',
|
||||
'languages',
|
||||
language.lower() + '.json',
|
||||
)
|
||||
with open(lmodfile, encoding='utf-8') as infile:
|
||||
lmodvalues = json.loads(infile.read())
|
||||
except Exception:
|
||||
logging.exception("Error importing language '%s'.", language)
|
||||
_babase.screenmessage(
|
||||
f"Error setting language to '{language}'; see log for details.",
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
switched = False
|
||||
lmodvalues = None
|
||||
|
||||
self._language = language
|
||||
|
||||
# Create an attrdict of *just* our target language.
|
||||
self._language_target = AttrDict()
|
||||
langtarget = self._language_target
|
||||
assert langtarget is not None
|
||||
_add_to_attr_dict(
|
||||
langtarget, lmodvalues if lmodvalues is not None else lenglishvalues
|
||||
)
|
||||
|
||||
# Create an attrdict of our target language overlaid on our base
|
||||
# (english).
|
||||
languages = [lenglishvalues]
|
||||
if lmodvalues is not None:
|
||||
languages.append(lmodvalues)
|
||||
lfull = AttrDict()
|
||||
for lmod in languages:
|
||||
_add_to_attr_dict(lfull, lmod)
|
||||
self._language_merged = lfull
|
||||
|
||||
# Pass some keys/values in for low level code to use; start with
|
||||
# everything in their 'internal' section.
|
||||
internal_vals = [
|
||||
v for v in list(lfull['internal'].items()) if isinstance(v[1], str)
|
||||
]
|
||||
|
||||
# Cherry-pick various other values to include.
|
||||
# (should probably get rid of the 'internal' section
|
||||
# and do everything this way)
|
||||
for value in [
|
||||
'replayNameDefaultText',
|
||||
'replayWriteErrorText',
|
||||
'replayVersionErrorText',
|
||||
'replayReadErrorText',
|
||||
]:
|
||||
internal_vals.append((value, lfull[value]))
|
||||
internal_vals.append(
|
||||
('axisText', lfull['configGamepadWindow']['axisText'])
|
||||
)
|
||||
internal_vals.append(('buttonText', lfull['buttonText']))
|
||||
lmerged = self._language_merged
|
||||
assert lmerged is not None
|
||||
random_names = [
|
||||
n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
|
||||
]
|
||||
random_names = [n for n in random_names if n != '']
|
||||
_babase.set_internal_language_keys(internal_vals, random_names)
|
||||
if switched and print_change:
|
||||
_babase.screenmessage(
|
||||
Lstr(
|
||||
resource='languageSetText',
|
||||
subs=[
|
||||
('${LANGUAGE}', Lstr(translate=('languages', language)))
|
||||
],
|
||||
),
|
||||
color=(0, 1, 0),
|
||||
)
|
||||
|
||||
def do_apply_app_config(self) -> None:
|
||||
assert _babase.in_logic_thread()
|
||||
assert isinstance(_babase.app.config, dict)
|
||||
lang = _babase.app.config.get('Lang', self.default_language)
|
||||
if lang != self._language:
|
||||
self.setlanguage(lang, print_change=False, store_to_config=False)
|
||||
|
||||
def get_resource(
|
||||
self,
|
||||
resource: str,
|
||||
fallback_resource: str | None = None,
|
||||
fallback_value: Any = None,
|
||||
) -> Any:
|
||||
"""Return a translation resource by name.
|
||||
|
||||
DEPRECATED; use babase.Lstr functionality for these purposes.
|
||||
"""
|
||||
try:
|
||||
# If we have no language set, try and set it to english.
|
||||
# Also make a fuss because we should try to avoid this.
|
||||
if self._language_merged is None:
|
||||
try:
|
||||
if _babase.do_once():
|
||||
logging.warning(
|
||||
'get_resource() called before language'
|
||||
' set; falling back to english.'
|
||||
)
|
||||
self.setlanguage(
|
||||
'English', print_change=False, store_to_config=False
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
'Error setting fallback english language.'
|
||||
)
|
||||
raise
|
||||
|
||||
# If they provided a fallback_resource value, try the
|
||||
# target-language-only dict first and then fall back to
|
||||
# trying the fallback_resource value in the merged dict.
|
||||
if fallback_resource is not None:
|
||||
try:
|
||||
values = self._language_target
|
||||
splits = resource.split('.')
|
||||
dicts = splits[:-1]
|
||||
key = splits[-1]
|
||||
for dct in dicts:
|
||||
assert values is not None
|
||||
values = values[dct]
|
||||
assert values is not None
|
||||
val = values[key]
|
||||
return val
|
||||
except Exception:
|
||||
# FIXME: Shouldn't we try the fallback resource in
|
||||
# the merged dict AFTER we try the main resource in
|
||||
# the merged dict?
|
||||
try:
|
||||
values = self._language_merged
|
||||
splits = fallback_resource.split('.')
|
||||
dicts = splits[:-1]
|
||||
key = splits[-1]
|
||||
for dct in dicts:
|
||||
assert values is not None
|
||||
values = values[dct]
|
||||
assert values is not None
|
||||
val = values[key]
|
||||
return val
|
||||
|
||||
except Exception:
|
||||
# If we got nothing for fallback_resource,
|
||||
# default to the normal code which checks or
|
||||
# primary value in the merge dict; there's a
|
||||
# chance we can get an english value for it
|
||||
# (which we weren't looking for the first time
|
||||
# through).
|
||||
pass
|
||||
|
||||
values = self._language_merged
|
||||
splits = resource.split('.')
|
||||
dicts = splits[:-1]
|
||||
key = splits[-1]
|
||||
for dct in dicts:
|
||||
assert values is not None
|
||||
values = values[dct]
|
||||
assert values is not None
|
||||
val = values[key]
|
||||
return val
|
||||
|
||||
except Exception:
|
||||
# Ok, looks like we couldn't find our main or fallback
|
||||
# resource anywhere. Now if we've been given a fallback
|
||||
# value, return it; otherwise fail.
|
||||
from babase import _error
|
||||
|
||||
if fallback_value is not None:
|
||||
return fallback_value
|
||||
raise _error.NotFoundError(
|
||||
f"Resource not found: '{resource}'"
|
||||
) from None
|
||||
|
||||
def translate(
|
||||
self,
|
||||
category: str,
|
||||
strval: str,
|
||||
raise_exceptions: bool = False,
|
||||
print_errors: bool = False,
|
||||
) -> str:
|
||||
"""Translate a value (or return the value if no translation available)
|
||||
|
||||
DEPRECATED; use babase.Lstr functionality for these purposes.
|
||||
"""
|
||||
try:
|
||||
translated = self.get_resource('translations')[category][strval]
|
||||
except Exception as exc:
|
||||
if raise_exceptions:
|
||||
raise
|
||||
if print_errors:
|
||||
print(
|
||||
(
|
||||
'Translate error: category=\''
|
||||
+ category
|
||||
+ '\' name=\''
|
||||
+ strval
|
||||
+ '\' exc='
|
||||
+ str(exc)
|
||||
+ ''
|
||||
)
|
||||
)
|
||||
translated = None
|
||||
translated_out: str
|
||||
if translated is None:
|
||||
translated_out = strval
|
||||
else:
|
||||
translated_out = translated
|
||||
assert isinstance(translated_out, str)
|
||||
return translated_out
|
||||
|
||||
def is_custom_unicode_char(self, char: str) -> bool:
|
||||
"""Return whether a char is in the custom unicode range we use."""
|
||||
assert isinstance(char, str)
|
||||
if len(char) != 1:
|
||||
raise ValueError('Invalid Input; must be length 1')
|
||||
return 0xE000 <= ord(char) <= 0xF8FF
|
||||
|
||||
def _can_display_language(self, language: str) -> bool:
|
||||
"""Tell whether we can display a particular language.
|
||||
|
||||
On some platforms we don't have unicode rendering yet which
|
||||
limits the languages we can draw.
|
||||
"""
|
||||
|
||||
# We don't yet support full unicode display on windows or linux :-(.
|
||||
if (
|
||||
language
|
||||
in {
|
||||
'Chinese',
|
||||
'ChineseTraditional',
|
||||
'Persian',
|
||||
'Korean',
|
||||
'Arabic',
|
||||
'Hindi',
|
||||
'Vietnamese',
|
||||
'Thai',
|
||||
'Tamil',
|
||||
}
|
||||
and not _babase.can_display_full_unicode()
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _get_default_language(self) -> str:
|
||||
languages = {
|
||||
'ar': 'Arabic',
|
||||
'be': 'Belarussian',
|
||||
'zh': 'Chinese',
|
||||
'hr': 'Croatian',
|
||||
'cs': 'Czech',
|
||||
'da': 'Danish',
|
||||
'nl': 'Dutch',
|
||||
'eo': 'Esperanto',
|
||||
'fil': 'Filipino',
|
||||
'fr': 'French',
|
||||
'de': 'German',
|
||||
'el': 'Greek',
|
||||
'hi': 'Hindi',
|
||||
'hu': 'Hungarian',
|
||||
'id': 'Indonesian',
|
||||
'it': 'Italian',
|
||||
'ko': 'Korean',
|
||||
'ms': 'Malay',
|
||||
'fa': 'Persian',
|
||||
'pl': 'Polish',
|
||||
'pt': 'Portuguese',
|
||||
'ro': 'Romanian',
|
||||
'ru': 'Russian',
|
||||
'sr': 'Serbian',
|
||||
'es': 'Spanish',
|
||||
'sk': 'Slovak',
|
||||
'sv': 'Swedish',
|
||||
'ta': 'Tamil',
|
||||
'th': 'Thai',
|
||||
'tr': 'Turkish',
|
||||
'uk': 'Ukrainian',
|
||||
'vec': 'Venetian',
|
||||
'vi': 'Vietnamese',
|
||||
}
|
||||
|
||||
# Special case for Chinese: map specific variations to
|
||||
# traditional. (otherwise will map to 'Chinese' which is
|
||||
# simplified)
|
||||
if self.locale in ('zh_HANT', 'zh_TW'):
|
||||
language = 'ChineseTraditional'
|
||||
else:
|
||||
language = languages.get(self.locale[:2], 'English')
|
||||
if not self._can_display_language(language):
|
||||
language = 'English'
|
||||
return language
|
||||
|
||||
|
||||
class Lstr:
|
||||
"""Used to define strings in a language-independent way.
|
||||
|
||||
Category: **General Utility Classes**
|
||||
|
||||
These should be used whenever possible in place of hard-coded
|
||||
strings so that in-game or UI elements show up correctly on all
|
||||
clients in their currently-active language.
|
||||
|
||||
To see available resource keys, look at any of the bs_language_*.py
|
||||
files in the game or the translations pages at
|
||||
legacy.ballistica.net/translate.
|
||||
|
||||
##### Examples
|
||||
EXAMPLE 1: specify a string from a resource path
|
||||
>>> mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
|
||||
|
||||
EXAMPLE 2: specify a translated string via a category and english
|
||||
value; if a translated value is available, it will be used; otherwise
|
||||
the english value will be. To see available translation categories,
|
||||
look under the 'translations' resource section.
|
||||
>>> mynode.text = babase.Lstr(translate=('gameDescriptions',
|
||||
... 'Defeat all enemies'))
|
||||
|
||||
EXAMPLE 3: specify a raw value and some substitutions. Substitutions
|
||||
can be used with resource and translate modes as well.
|
||||
>>> mynode.text = babase.Lstr(value='${A} / ${B}',
|
||||
... subs=[('${A}', str(score)), ('${B}', str(total))])
|
||||
|
||||
EXAMPLE 4: babase.Lstr's can be nested. This example would display the
|
||||
resource at res_a but replace ${NAME} with the value of the
|
||||
resource at res_b
|
||||
>>> mytextnode.text = babase.Lstr(
|
||||
... resource='res_a',
|
||||
... subs=[('${NAME}', babase.Lstr(resource='res_b'))])
|
||||
"""
|
||||
|
||||
# pylint: disable=dangerous-default-value
|
||||
# noinspection PyDefaultArgument
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
resource: str,
|
||||
fallback_resource: str = '',
|
||||
fallback_value: str = '',
|
||||
subs: Sequence[tuple[str, str | Lstr]] = [],
|
||||
) -> None:
|
||||
"""Create an Lstr from a string resource."""
|
||||
|
||||
# noinspection PyShadowingNames,PyDefaultArgument
|
||||
@overload
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
translate: tuple[str, str],
|
||||
subs: Sequence[tuple[str, str | Lstr]] = [],
|
||||
) -> None:
|
||||
"""Create an Lstr by translating a string in a category."""
|
||||
|
||||
# noinspection PyDefaultArgument
|
||||
@overload
|
||||
def __init__(
|
||||
self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = []
|
||||
) -> None:
|
||||
"""Create an Lstr from a raw string value."""
|
||||
|
||||
# pylint: enable=redefined-outer-name, dangerous-default-value
|
||||
|
||||
def __init__(self, *args: Any, **keywds: Any) -> None:
|
||||
"""Instantiate a Lstr.
|
||||
|
||||
Pass a value for either 'resource', 'translate',
|
||||
or 'value'. (see Lstr help for examples).
|
||||
'subs' can be a sequence of 2-member sequences consisting of values
|
||||
and replacements.
|
||||
'fallback_resource' can be a resource key that will be used if the
|
||||
main one is not present for
|
||||
the current language in place of falling back to the english value
|
||||
('resource' mode only).
|
||||
'fallback_value' can be a literal string that will be used if neither
|
||||
the resource nor the fallback resource is found ('resource' mode only).
|
||||
"""
|
||||
# pylint: disable=too-many-branches
|
||||
if args:
|
||||
raise TypeError('Lstr accepts only keyword arguments')
|
||||
|
||||
# Basically just store the exact args they passed.
|
||||
# However if they passed any Lstr values for subs,
|
||||
# replace them with that Lstr's dict.
|
||||
self.args = keywds
|
||||
our_type = type(self)
|
||||
|
||||
if isinstance(self.args.get('value'), our_type):
|
||||
raise TypeError("'value' must be a regular string; not an Lstr")
|
||||
|
||||
if 'subs' in self.args:
|
||||
subs_new = []
|
||||
for key, value in keywds['subs']:
|
||||
if isinstance(value, our_type):
|
||||
subs_new.append((key, value.args))
|
||||
else:
|
||||
subs_new.append((key, value))
|
||||
self.args['subs'] = subs_new
|
||||
|
||||
# As of protocol 31 we support compact key names
|
||||
# ('t' instead of 'translate', etc). Convert as needed.
|
||||
if 'translate' in keywds:
|
||||
keywds['t'] = keywds['translate']
|
||||
del keywds['translate']
|
||||
if 'resource' in keywds:
|
||||
keywds['r'] = keywds['resource']
|
||||
del keywds['resource']
|
||||
if 'value' in keywds:
|
||||
keywds['v'] = keywds['value']
|
||||
del keywds['value']
|
||||
if 'fallback' in keywds:
|
||||
from babase import _error
|
||||
|
||||
_error.print_error(
|
||||
'deprecated "fallback" arg passed to Lstr(); use '
|
||||
'either "fallback_resource" or "fallback_value"',
|
||||
once=True,
|
||||
)
|
||||
keywds['f'] = keywds['fallback']
|
||||
del keywds['fallback']
|
||||
if 'fallback_resource' in keywds:
|
||||
keywds['f'] = keywds['fallback_resource']
|
||||
del keywds['fallback_resource']
|
||||
if 'subs' in keywds:
|
||||
keywds['s'] = keywds['subs']
|
||||
del keywds['subs']
|
||||
if 'fallback_value' in keywds:
|
||||
keywds['fv'] = keywds['fallback_value']
|
||||
del keywds['fallback_value']
|
||||
|
||||
def evaluate(self) -> str:
|
||||
"""Evaluate the Lstr and returns a flat string in the current language.
|
||||
|
||||
You should avoid doing this as much as possible and instead pass
|
||||
and store Lstr values.
|
||||
"""
|
||||
return _babase.evaluate_lstr(self._get_json())
|
||||
|
||||
def is_flat_value(self) -> bool:
|
||||
"""Return whether the Lstr is a 'flat' value.
|
||||
|
||||
This is defined as a simple string value incorporating no
|
||||
translations, resources, or substitutions. In this case it may
|
||||
be reasonable to replace it with a raw string value, perform
|
||||
string manipulation on it, etc.
|
||||
"""
|
||||
return bool('v' in self.args and not self.args.get('s', []))
|
||||
|
||||
def _get_json(self) -> str:
|
||||
try:
|
||||
return json.dumps(self.args, separators=(',', ':'))
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('_get_json failed for', self.args)
|
||||
return 'JSON_ERR'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '<ba.Lstr: ' + self._get_json() + '>'
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '<ba.Lstr: ' + self._get_json() + '>'
|
||||
|
||||
@staticmethod
|
||||
def from_json(json_string: str) -> babase.Lstr:
|
||||
"""Given a json string, returns a babase.Lstr. Does no validation."""
|
||||
lstr = Lstr(value='')
|
||||
lstr.args = json.loads(json_string)
|
||||
return lstr
|
||||
|
||||
|
||||
def _add_to_attr_dict(dst: AttrDict, src: dict) -> None:
|
||||
for key, value in list(src.items()):
|
||||
if isinstance(value, dict):
|
||||
try:
|
||||
dst_dict = dst[key]
|
||||
except Exception:
|
||||
dst_dict = dst[key] = AttrDict()
|
||||
if not isinstance(dst_dict, AttrDict):
|
||||
raise RuntimeError(
|
||||
"language key '"
|
||||
+ key
|
||||
+ "' is defined both as a dict and value"
|
||||
)
|
||||
_add_to_attr_dict(dst_dict, value)
|
||||
else:
|
||||
if not isinstance(value, (float, int, bool, str, str, type(None))):
|
||||
raise TypeError(
|
||||
"invalid value type for res '"
|
||||
+ key
|
||||
+ "': "
|
||||
+ str(type(value))
|
||||
)
|
||||
dst[key] = value
|
||||
|
||||
|
||||
class AttrDict(dict):
|
||||
"""A dict that can be accessed with dot notation.
|
||||
|
||||
(so foo.bar is equivalent to foo['bar'])
|
||||
"""
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
val = self[attr]
|
||||
assert not isinstance(val, bytes)
|
||||
return val
|
||||
|
||||
def __setattr__(self, attr: str, value: Any) -> None:
|
||||
raise AttributeError()
|
||||
376
dist/ba_data/python/babase/_login.py
vendored
Normal file
376
dist/ba_data/python/babase/_login.py
vendored
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Login related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, final
|
||||
|
||||
from bacommon.login import LoginType
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable
|
||||
|
||||
|
||||
DEBUG_LOG = False
|
||||
|
||||
|
||||
class LoginAdapter:
|
||||
"""Allows using implicit login types in an explicit way.
|
||||
|
||||
Some login types such as Google Play Game Services or Game Center are
|
||||
basically always present and often do not provide a way to log out
|
||||
from within a running app, so this adapter exists to use them in a
|
||||
flexible manner by 'attaching' and 'detaching' from an always-present
|
||||
login, allowing for its use alongside other login types. It also
|
||||
provides common functionality for server-side account verification and
|
||||
other handy bits.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class SignInResult:
|
||||
"""Describes the final result of a sign-in attempt."""
|
||||
|
||||
credentials: str
|
||||
|
||||
@dataclass
|
||||
class ImplicitLoginState:
|
||||
"""Describes the current state of an implicit login."""
|
||||
|
||||
login_id: str
|
||||
display_name: str
|
||||
|
||||
def __init__(self, login_type: LoginType):
|
||||
assert _babase.in_logic_thread()
|
||||
self.login_type = login_type
|
||||
self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = (
|
||||
None
|
||||
)
|
||||
self._on_app_loading_called = False
|
||||
self._implicit_login_state_dirty = False
|
||||
self._back_end_active = False
|
||||
|
||||
# Which login of our type (if any) is associated with the
|
||||
# current active primary account.
|
||||
self._active_login_id: str | None = None
|
||||
|
||||
self._last_sign_in_time: float | None = None
|
||||
self._last_sign_in_desc: str | None = None
|
||||
|
||||
def on_app_loading(self) -> None:
|
||||
"""Should be called for each adapter in on_app_loading."""
|
||||
|
||||
assert not self._on_app_loading_called
|
||||
self._on_app_loading_called = True
|
||||
|
||||
# Any implicit state we received up until now needs to be pushed
|
||||
# to the app account subsystem.
|
||||
self._update_implicit_login_state()
|
||||
|
||||
def set_implicit_login_state(
|
||||
self, state: ImplicitLoginState | None
|
||||
) -> None:
|
||||
"""Keep the adapter informed of implicit login states.
|
||||
|
||||
This should be called by the adapter back-end when an account
|
||||
of their associated type gets logged in or out.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
# Ignore redundant sets.
|
||||
if state == self._implicit_login_state:
|
||||
return
|
||||
|
||||
if DEBUG_LOG:
|
||||
if state is None:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s implicit state changed;'
|
||||
' now signed out.',
|
||||
self.login_type.name,
|
||||
)
|
||||
else:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s implicit state changed;'
|
||||
' now signed in as %s.',
|
||||
self.login_type.name,
|
||||
state.display_name,
|
||||
)
|
||||
|
||||
self._implicit_login_state = state
|
||||
self._implicit_login_state_dirty = True
|
||||
|
||||
# (possibly) push it to the app for handling.
|
||||
self._update_implicit_login_state()
|
||||
|
||||
# This might affect whether we consider that back-end as 'active'.
|
||||
self._update_back_end_active()
|
||||
|
||||
def set_active_logins(self, logins: dict[LoginType, str]) -> None:
|
||||
"""Keep the adapter informed of actively used logins.
|
||||
|
||||
This should be called by the app's account subsystem to
|
||||
keep adapters up to date on the full set of logins attached
|
||||
to the currently-in-use account.
|
||||
Note that the logins dict passed in should be immutable as
|
||||
only a reference to it is stored, not a copy.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter got active logins %s.',
|
||||
self.login_type.name,
|
||||
{k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
|
||||
)
|
||||
|
||||
self._active_login_id = logins.get(self.login_type)
|
||||
self._update_back_end_active()
|
||||
|
||||
def on_back_end_active_change(self, active: bool) -> None:
|
||||
"""Called when active state for the back-end is (possibly) changing.
|
||||
|
||||
Meant to be overridden by subclasses.
|
||||
Being active means that the implicit login provided by the back-end
|
||||
is actually being used by the app. It should therefore register
|
||||
unlocked achievements, leaderboard scores, allow viewing native
|
||||
UIs, etc. When not active it should ignore everything and behave
|
||||
as if logged out, even if it technically is still logged in.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
del active # Unused.
|
||||
|
||||
@final
|
||||
def sign_in(
|
||||
self,
|
||||
result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
|
||||
description: str,
|
||||
) -> None:
|
||||
"""Attempt an explicit sign in via this adapter.
|
||||
|
||||
This can be called even if the back-end is not implicitly signed in;
|
||||
the adapter will attempt to sign in if possible. An exception will
|
||||
be returned if the sign-in attempt fails.
|
||||
"""
|
||||
assert _babase.in_logic_thread()
|
||||
from babase._general import Call
|
||||
|
||||
# Have been seeing multiple sign-in attempts come through
|
||||
# nearly simultaneously which can be problematic server-side.
|
||||
# Let's error if a sign-in attempt is made within a few seconds
|
||||
# of the last one to address this.
|
||||
now = time.monotonic()
|
||||
appnow = _babase.apptime()
|
||||
if self._last_sign_in_time is not None:
|
||||
since_last = now - self._last_sign_in_time
|
||||
if since_last < 1.0:
|
||||
logging.warning(
|
||||
'LoginAdapter: %s adapter sign_in() called too soon'
|
||||
' (%.2fs) after last; this-desc="%s", last-desc="%s",'
|
||||
' ba-app-time=%.2f.',
|
||||
self.login_type.name,
|
||||
since_last,
|
||||
description,
|
||||
self._last_sign_in_desc,
|
||||
appnow,
|
||||
)
|
||||
_babase.pushcall(
|
||||
Call(
|
||||
result_cb,
|
||||
self,
|
||||
RuntimeError('sign_in called too soon after last.'),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
self._last_sign_in_desc = description
|
||||
self._last_sign_in_time = now
|
||||
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter sign_in() called;'
|
||||
' fetching sign-in-token...',
|
||||
self.login_type.name,
|
||||
)
|
||||
|
||||
def _got_sign_in_token_result(result: str | None) -> None:
|
||||
import bacommon.cloud
|
||||
|
||||
# Failed to get a sign-in-token.
|
||||
if result is None:
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter sign-in-token fetch failed;'
|
||||
' aborting sign-in.',
|
||||
self.login_type.name,
|
||||
)
|
||||
_babase.pushcall(
|
||||
Call(
|
||||
result_cb,
|
||||
self,
|
||||
RuntimeError('fetch-sign-in-token failed.'),
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Got a sign-in token! Now pass it to the cloud which will use
|
||||
# it to verify our identity and give us app credentials on
|
||||
# success.
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter sign-in-token fetch succeeded;'
|
||||
' passing to cloud for verification...',
|
||||
self.login_type.name,
|
||||
)
|
||||
|
||||
def _got_sign_in_response(
|
||||
response: bacommon.cloud.SignInResponse | Exception,
|
||||
) -> None:
|
||||
if isinstance(response, Exception):
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter got error'
|
||||
' sign-in response: %s',
|
||||
self.login_type.name,
|
||||
response,
|
||||
)
|
||||
_babase.pushcall(Call(result_cb, self, response))
|
||||
else:
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter got successful'
|
||||
' sign-in response',
|
||||
self.login_type.name,
|
||||
)
|
||||
if response.credentials is None:
|
||||
result2: LoginAdapter.SignInResult | Exception = (
|
||||
RuntimeError(
|
||||
'No credentials returned after'
|
||||
' submitting sign-in-token.'
|
||||
)
|
||||
)
|
||||
else:
|
||||
result2 = self.SignInResult(
|
||||
credentials=response.credentials
|
||||
)
|
||||
_babase.pushcall(Call(result_cb, self, result2))
|
||||
|
||||
assert _babase.app.plus is not None
|
||||
_babase.app.plus.cloud.send_message_cb(
|
||||
bacommon.cloud.SignInMessage(
|
||||
self.login_type,
|
||||
result,
|
||||
description=description,
|
||||
apptime=appnow,
|
||||
),
|
||||
on_response=_got_sign_in_response,
|
||||
)
|
||||
|
||||
# Kick off the process by fetching a sign-in token.
|
||||
self.get_sign_in_token(completion_cb=_got_sign_in_token_result)
|
||||
|
||||
def is_back_end_active(self) -> bool:
|
||||
"""Is this adapter's back-end currently active?"""
|
||||
return self._back_end_active
|
||||
|
||||
def get_sign_in_token(
|
||||
self, completion_cb: Callable[[str | None], None]
|
||||
) -> None:
|
||||
"""Get a sign-in token from the adapter back end.
|
||||
|
||||
This token is then passed to the master-server to complete the
|
||||
login process.
|
||||
The adapter can use this opportunity to bring up account creation
|
||||
UI, call its internal sign_in function, etc. as needed.
|
||||
The provided completion_cb should then be called with either a token
|
||||
or None if sign in failed or was cancelled.
|
||||
"""
|
||||
from babase._general import Call
|
||||
|
||||
# Default implementation simply fails immediately.
|
||||
_babase.pushcall(Call(completion_cb, None))
|
||||
|
||||
def _update_implicit_login_state(self) -> None:
|
||||
# If we've received an implicit login state, schedule it to be
|
||||
# sent along to the app. We wait until on-app-launch has been
|
||||
# called so that account-client-v2 has had a chance to load
|
||||
# any existing state so it can properly respond to this.
|
||||
if self._implicit_login_state_dirty and self._on_app_loading_called:
|
||||
from babase._general import Call
|
||||
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter sending'
|
||||
' implicit-state-changed to app.',
|
||||
self.login_type.name,
|
||||
)
|
||||
|
||||
assert _babase.app.plus is not None
|
||||
_babase.pushcall(
|
||||
Call(
|
||||
_babase.app.plus.accounts.on_implicit_login_state_changed,
|
||||
self.login_type,
|
||||
self._implicit_login_state,
|
||||
)
|
||||
)
|
||||
self._implicit_login_state_dirty = False
|
||||
|
||||
def _update_back_end_active(self) -> None:
|
||||
was_active = self._back_end_active
|
||||
if self._implicit_login_state is None:
|
||||
is_active = False
|
||||
else:
|
||||
is_active = (
|
||||
self._implicit_login_state.login_id == self._active_login_id
|
||||
)
|
||||
if was_active != is_active:
|
||||
if DEBUG_LOG:
|
||||
logging.debug(
|
||||
'LoginAdapter: %s adapter back-end-active is now %s.',
|
||||
self.login_type.name,
|
||||
is_active,
|
||||
)
|
||||
self.on_back_end_active_change(is_active)
|
||||
self._back_end_active = is_active
|
||||
|
||||
|
||||
class LoginAdapterNative(LoginAdapter):
|
||||
"""A login adapter that does its work in the native layer."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(LoginType.GPGS)
|
||||
|
||||
# Store int ids for in-flight attempts since they may go through
|
||||
# various platform layers and back.
|
||||
self._sign_in_attempt_num = 123
|
||||
self._sign_in_attempts: dict[int, Callable[[str | None], None]] = {}
|
||||
|
||||
def get_sign_in_token(
|
||||
self, completion_cb: Callable[[str | None], None]
|
||||
) -> None:
|
||||
attempt_id = self._sign_in_attempt_num
|
||||
self._sign_in_attempts[attempt_id] = completion_cb
|
||||
self._sign_in_attempt_num += 1
|
||||
_babase.login_adapter_get_sign_in_token(
|
||||
self.login_type.value, attempt_id
|
||||
)
|
||||
|
||||
def on_back_end_active_change(self, active: bool) -> None:
|
||||
_babase.login_adapter_back_end_active_change(
|
||||
self.login_type.value, active
|
||||
)
|
||||
|
||||
def on_sign_in_complete(self, attempt_id: int, result: str | None) -> None:
|
||||
"""Called by the native layer on a completed attempt."""
|
||||
assert _babase.in_logic_thread()
|
||||
if attempt_id not in self._sign_in_attempts:
|
||||
logging.exception('sign-in attempt_id %d not found', attempt_id)
|
||||
return
|
||||
callback = self._sign_in_attempts.pop(attempt_id)
|
||||
callback(result)
|
||||
|
||||
|
||||
class LoginAdapterGPGS(LoginAdapterNative):
|
||||
"""Google Play Game Services adapter."""
|
||||
58
dist/ba_data/python/babase/_math.py
vendored
Normal file
58
dist/ba_data/python/babase/_math.py
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Math related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import abc
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def vec3validate(value: Sequence[float]) -> Sequence[float]:
|
||||
"""Ensure a value is valid for use as a Vec3.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
Raises a TypeError exception if not.
|
||||
Valid values include any type of sequence consisting of 3 numeric values.
|
||||
Returns the same value as passed in (but with a definite type
|
||||
so this can be used to disambiguate 'Any' types).
|
||||
Generally this should be used in 'if __debug__' or assert clauses
|
||||
to keep runtime overhead minimal.
|
||||
"""
|
||||
from numbers import Number
|
||||
|
||||
if not isinstance(value, abc.Sequence):
|
||||
raise TypeError(f"Expected a sequence; got {type(value)}")
|
||||
if len(value) != 3:
|
||||
raise TypeError(f"Expected a length-3 sequence (got {len(value)})")
|
||||
if not all(isinstance(i, Number) for i in value):
|
||||
raise TypeError(f"Non-numeric value passed for vec3: {value}")
|
||||
return value
|
||||
|
||||
|
||||
def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
|
||||
"""Return whether a given point is within a given box.
|
||||
|
||||
category: General Utility Functions
|
||||
|
||||
For use with standard def boxes (position|rotate|scale).
|
||||
"""
|
||||
return (
|
||||
(abs(pnt[0] - box[0]) <= box[6] * 0.5)
|
||||
and (abs(pnt[1] - box[1]) <= box[7] * 0.5)
|
||||
and (abs(pnt[2] - box[2]) <= box[8] * 0.5)
|
||||
)
|
||||
|
||||
|
||||
def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
|
||||
"""Scale a color so its largest value is 1; useful for coloring lights.
|
||||
|
||||
category: General Utility Functions
|
||||
"""
|
||||
color_biased = tuple(max(c, 0.01) for c in color) # account for black
|
||||
mult = 1.0 / max(color_biased)
|
||||
return tuple(c * mult for c in color_biased)
|
||||
515
dist/ba_data/python/babase/_meta.py
vendored
Normal file
515
dist/ba_data/python/babase/_meta.py
vendored
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to dynamic discoverability of classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from threading import Thread
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from efro.call import tpartial
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable
|
||||
|
||||
# The meta api version of this build of the game.
|
||||
# Only packages and modules requiring this exact api version
|
||||
# will be considered when scanning directories.
|
||||
# See: https://ballistica.net/wiki/Meta-Tag-System
|
||||
CURRENT_API_VERSION = 8
|
||||
|
||||
# Meta export lines can use these names to represent these classes.
|
||||
# This is purely a convenience; it is possible to use full class paths
|
||||
# instead of these or to make the meta system aware of arbitrary classes.
|
||||
EXPORT_CLASS_NAME_SHORTCUTS: dict[str, str] = {
|
||||
'plugin': 'babase.Plugin',
|
||||
'keyboard': 'babase.Keyboard',
|
||||
}
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScanResults:
|
||||
"""Final results from a meta-scan."""
|
||||
|
||||
exports: dict[str, list[str]] = field(default_factory=dict)
|
||||
incorrect_api_modules: list[str] = field(default_factory=list)
|
||||
announce_errors_occurred: bool = False
|
||||
|
||||
def exports_of_class(self, cls: type) -> list[str]:
|
||||
"""Return exports of a given class."""
|
||||
return self.exports.get(f'{cls.__module__}.{cls.__qualname__}', [])
|
||||
|
||||
|
||||
class MetadataSubsystem:
|
||||
"""Subsystem for working with script metadata in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at 'babase.app.meta'.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._scan: DirectoryScan | None = None
|
||||
|
||||
# Can be populated before starting the scan.
|
||||
self.extra_scan_dirs: list[str] = []
|
||||
|
||||
# Results populated once scan is complete.
|
||||
self.scanresults: ScanResults | None = None
|
||||
|
||||
self._scan_complete_cb: Callable[[], None] | None = None
|
||||
|
||||
def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
|
||||
"""Begin the overall scan.
|
||||
|
||||
This will start scanning built in directories (which for vanilla
|
||||
installs should be the vast majority of the work). This should only
|
||||
be called once.
|
||||
"""
|
||||
assert self._scan_complete_cb is None
|
||||
assert self._scan is None
|
||||
|
||||
self._scan_complete_cb = scan_complete_cb
|
||||
self._scan = DirectoryScan(
|
||||
[
|
||||
path
|
||||
for path in [
|
||||
_babase.app.python_directory_app,
|
||||
_babase.app.python_directory_user,
|
||||
]
|
||||
if path is not None
|
||||
]
|
||||
)
|
||||
|
||||
Thread(target=self._run_scan_in_bg, daemon=True).start()
|
||||
|
||||
def start_extra_scan(self) -> None:
|
||||
"""Proceed to the extra_scan_dirs portion of the scan.
|
||||
|
||||
This is for parts of the scan that must be delayed until
|
||||
workspace sync completion or other such events. This must be
|
||||
called exactly once.
|
||||
"""
|
||||
assert self._scan is not None
|
||||
self._scan.set_extras(self.extra_scan_dirs)
|
||||
|
||||
def load_exported_classes(
|
||||
self,
|
||||
cls: type[T],
|
||||
completion_cb: Callable[[list[type[T]]], None],
|
||||
completion_cb_in_bg_thread: bool = False,
|
||||
) -> None:
|
||||
"""High level function to load meta-exported classes.
|
||||
|
||||
Will wait for scanning to complete if necessary, and will load all
|
||||
registered classes of a particular type in a background thread before
|
||||
calling the passed callback in the logic thread. Errors may be logged
|
||||
to messaged to the user in some way but the callback will be called
|
||||
regardless.
|
||||
To run the completion callback directly in the bg thread where the
|
||||
loading work happens, pass completion_cb_in_bg_thread=True.
|
||||
"""
|
||||
Thread(
|
||||
target=tpartial(
|
||||
self._load_exported_classes,
|
||||
cls,
|
||||
completion_cb,
|
||||
completion_cb_in_bg_thread,
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _load_exported_classes(
|
||||
self,
|
||||
cls: type[T],
|
||||
completion_cb: Callable[[list[type[T]]], None],
|
||||
completion_cb_in_bg_thread: bool,
|
||||
) -> None:
|
||||
from babase._general import getclass
|
||||
|
||||
classes: list[type[T]] = []
|
||||
try:
|
||||
classnames = self._wait_for_scan_results().exports_of_class(cls)
|
||||
for classname in classnames:
|
||||
try:
|
||||
classes.append(getclass(classname, cls))
|
||||
except Exception:
|
||||
logging.exception('error importing %s', classname)
|
||||
|
||||
except Exception:
|
||||
logging.exception('Error loading exported classes.')
|
||||
|
||||
completion_call = tpartial(completion_cb, classes)
|
||||
if completion_cb_in_bg_thread:
|
||||
completion_call()
|
||||
else:
|
||||
_babase.pushcall(completion_call, from_other_thread=True)
|
||||
|
||||
def _wait_for_scan_results(self) -> ScanResults:
|
||||
"""Return scan results, blocking if the scan is not yet complete."""
|
||||
if self.scanresults is None:
|
||||
if _babase.in_logic_thread():
|
||||
logging.warning(
|
||||
'babase.meta._wait_for_scan_results()'
|
||||
' called in logic thread before scan completed;'
|
||||
' this can cause hitches.'
|
||||
)
|
||||
|
||||
# Now wait a bit for the scan to complete.
|
||||
# Eventually error though if it doesn't.
|
||||
starttime = time.time()
|
||||
while self.scanresults is None:
|
||||
time.sleep(0.05)
|
||||
if time.time() - starttime > 10.0:
|
||||
raise TimeoutError(
|
||||
'timeout waiting for meta scan to complete.'
|
||||
)
|
||||
return self.scanresults
|
||||
|
||||
def _run_scan_in_bg(self) -> None:
|
||||
"""Runs a scan (for use in background thread)."""
|
||||
try:
|
||||
assert self._scan is not None
|
||||
self._scan.run()
|
||||
results = self._scan.results
|
||||
self._scan = None
|
||||
except Exception:
|
||||
logging.exception('metascan: Error running scan in bg.')
|
||||
results = ScanResults(announce_errors_occurred=True)
|
||||
|
||||
# Place results and tell the logic thread they're ready.
|
||||
self.scanresults = results
|
||||
_babase.pushcall(self._handle_scan_results, from_other_thread=True)
|
||||
|
||||
def _handle_scan_results(self) -> None:
|
||||
"""Called in the logic thread with results of a completed scan."""
|
||||
from babase._language import Lstr
|
||||
|
||||
assert _babase.in_logic_thread()
|
||||
|
||||
results = self.scanresults
|
||||
assert results is not None
|
||||
|
||||
do_play_error_sound = False
|
||||
|
||||
# If we found modules needing to be updated to the newer api version,
|
||||
# mention that specifically.
|
||||
if results.incorrect_api_modules:
|
||||
if len(results.incorrect_api_modules) > 1:
|
||||
msg = Lstr(
|
||||
resource='scanScriptsMultipleModulesNeedUpdatesText',
|
||||
subs=[
|
||||
('${PATH}', results.incorrect_api_modules[0]),
|
||||
(
|
||||
'${NUM}',
|
||||
str(len(results.incorrect_api_modules) - 1),
|
||||
),
|
||||
('${API}', str(CURRENT_API_VERSION)),
|
||||
],
|
||||
)
|
||||
else:
|
||||
msg = Lstr(
|
||||
resource='scanScriptsSingleModuleNeedsUpdatesText',
|
||||
subs=[
|
||||
('${PATH}', results.incorrect_api_modules[0]),
|
||||
('${API}', str(CURRENT_API_VERSION)),
|
||||
],
|
||||
)
|
||||
_babase.screenmessage(msg, color=(1, 0, 0))
|
||||
do_play_error_sound = True
|
||||
|
||||
# Let the user know if there's warning/errors in their log
|
||||
# they may want to look at.
|
||||
if results.announce_errors_occurred:
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='scanScriptsErrorText'), color=(1, 0, 0)
|
||||
)
|
||||
do_play_error_sound = True
|
||||
|
||||
if do_play_error_sound:
|
||||
_babase.getsimplesound('error').play()
|
||||
|
||||
# Let the game know we're done.
|
||||
assert self._scan_complete_cb is not None
|
||||
self._scan_complete_cb()
|
||||
|
||||
|
||||
class DirectoryScan:
|
||||
"""Scans directories for metadata."""
|
||||
|
||||
def __init__(self, paths: list[str]):
|
||||
"""Given one or more paths, parses available meta information.
|
||||
|
||||
It is assumed that these paths are also in PYTHONPATH.
|
||||
It is also assumed that any subdirectories are Python packages.
|
||||
"""
|
||||
|
||||
# Skip non-existent paths completely.
|
||||
self.base_paths = [Path(p) for p in paths if os.path.isdir(p)]
|
||||
self.extra_paths: list[Path] = []
|
||||
self.extra_paths_set = False
|
||||
self.results = ScanResults()
|
||||
|
||||
def set_extras(self, paths: list[str]) -> None:
|
||||
"""Set extra portion."""
|
||||
# Skip non-existent paths completely.
|
||||
self.extra_paths += [Path(p) for p in paths if os.path.isdir(p)]
|
||||
self.extra_paths_set = True
|
||||
|
||||
def run(self) -> None:
|
||||
"""Do the thing."""
|
||||
for pathlist in [self.base_paths, self.extra_paths]:
|
||||
# Spin and wait until extra paths are provided before doing them.
|
||||
if pathlist is self.extra_paths:
|
||||
while not self.extra_paths_set:
|
||||
time.sleep(0.001)
|
||||
|
||||
modules: list[tuple[Path, Path]] = []
|
||||
for path in pathlist:
|
||||
self._get_path_module_entries(path, '', modules)
|
||||
for moduledir, subpath in modules:
|
||||
try:
|
||||
self._scan_module(moduledir, subpath)
|
||||
except Exception:
|
||||
logging.exception("metascan: Error scanning '%s'.", subpath)
|
||||
|
||||
# Sort our results
|
||||
for exportlist in self.results.exports.values():
|
||||
exportlist.sort()
|
||||
|
||||
def _get_path_module_entries(
|
||||
self, path: Path, subpath: str | Path, modules: list[tuple[Path, Path]]
|
||||
) -> None:
|
||||
"""Scan provided path and add module entries to provided list."""
|
||||
try:
|
||||
# Special case: let's save some time and skip the whole 'babase'
|
||||
# package since we know it doesn't contain any meta tags.
|
||||
fullpath = Path(path, subpath)
|
||||
entries = [
|
||||
(path, Path(subpath, name))
|
||||
for name in os.listdir(fullpath)
|
||||
# Actually scratch that for now; trying to avoid special cases.
|
||||
# if name != 'babase'
|
||||
]
|
||||
except PermissionError:
|
||||
# Expected sometimes.
|
||||
entries = []
|
||||
except Exception:
|
||||
# Unexpected; report this.
|
||||
logging.exception('metascan: Error in _get_path_module_entries.')
|
||||
self.results.announce_errors_occurred = True
|
||||
entries = []
|
||||
|
||||
# Now identify python packages/modules out of what we found.
|
||||
for entry in entries:
|
||||
if entry[1].name.endswith('.py'):
|
||||
modules.append(entry)
|
||||
elif (
|
||||
Path(entry[0], entry[1]).is_dir()
|
||||
and Path(entry[0], entry[1], '__init__.py').is_file()
|
||||
):
|
||||
modules.append(entry)
|
||||
|
||||
def _scan_module(self, moduledir: Path, subpath: Path) -> None:
|
||||
"""Scan an individual module and add the findings to results."""
|
||||
if subpath.name.endswith('.py'):
|
||||
fpath = Path(moduledir, subpath)
|
||||
ispackage = False
|
||||
else:
|
||||
fpath = Path(moduledir, subpath, '__init__.py')
|
||||
ispackage = True
|
||||
with fpath.open(encoding='utf-8') as infile:
|
||||
flines = infile.readlines()
|
||||
meta_lines = {
|
||||
lnum: l[1:].split()
|
||||
for lnum, l in enumerate(flines)
|
||||
if '# ba_meta ' in l
|
||||
}
|
||||
is_top_level = len(subpath.parts) <= 1
|
||||
required_api = self._get_api_requirement(
|
||||
subpath, meta_lines, is_top_level
|
||||
)
|
||||
|
||||
# Top level modules with no discernible api version get ignored.
|
||||
if is_top_level and required_api is None:
|
||||
return
|
||||
|
||||
# If we find a module requiring a different api version, warn
|
||||
# and ignore.
|
||||
if required_api is not None and required_api != CURRENT_API_VERSION:
|
||||
logging.warning(
|
||||
'metascan: %s requires api %s but we are running'
|
||||
' %s. Ignoring module.',
|
||||
subpath,
|
||||
required_api,
|
||||
CURRENT_API_VERSION,
|
||||
)
|
||||
self.results.incorrect_api_modules.append(
|
||||
self._module_name_for_subpath(subpath)
|
||||
)
|
||||
return
|
||||
|
||||
# Ok; can proceed with a full scan of this module.
|
||||
self._process_module_meta_tags(subpath, flines, meta_lines)
|
||||
|
||||
# If its a package, recurse into its subpackages.
|
||||
if ispackage:
|
||||
try:
|
||||
submodules: list[tuple[Path, Path]] = []
|
||||
self._get_path_module_entries(moduledir, subpath, submodules)
|
||||
for submodule in submodules:
|
||||
if submodule[1].name != '__init__.py':
|
||||
self._scan_module(submodule[0], submodule[1])
|
||||
except Exception:
|
||||
logging.exception('metascan: Error scanning %s.', subpath)
|
||||
|
||||
def _module_name_for_subpath(self, subpath: Path) -> str:
|
||||
# (should not be getting these)
|
||||
assert '__init__.py' not in str(subpath)
|
||||
|
||||
return '.'.join(subpath.parts).removesuffix('.py')
|
||||
|
||||
def _process_module_meta_tags(
|
||||
self, subpath: Path, flines: list[str], meta_lines: dict[int, list[str]]
|
||||
) -> None:
|
||||
"""Pull data from a module based on its ba_meta tags."""
|
||||
for lindex, mline in meta_lines.items():
|
||||
# meta_lines is just anything containing '# ba_meta '; make sure
|
||||
# the ba_meta is in the right place.
|
||||
if mline[0] != 'ba_meta':
|
||||
logging.warning(
|
||||
'metascan: %s:%d: malformed ba_meta statement.',
|
||||
subpath,
|
||||
lindex + 1,
|
||||
)
|
||||
self.results.announce_errors_occurred = True
|
||||
elif (
|
||||
len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api'
|
||||
):
|
||||
# Ignore 'require api X' lines in this pass.
|
||||
pass
|
||||
elif len(mline) != 3 or mline[1] != 'export':
|
||||
# Currently we only support 'ba_meta export FOO';
|
||||
# complain for anything else we see.
|
||||
logging.warning(
|
||||
'metascan: %s:%d: unrecognized ba_meta statement.',
|
||||
subpath,
|
||||
lindex + 1,
|
||||
)
|
||||
self.results.announce_errors_occurred = True
|
||||
else:
|
||||
# Looks like we've got a valid export line!
|
||||
modulename = self._module_name_for_subpath(subpath)
|
||||
exporttypestr = mline[2]
|
||||
export_class_name = self._get_export_class_name(
|
||||
subpath, flines, lindex
|
||||
)
|
||||
if export_class_name is not None:
|
||||
classname = modulename + '.' + export_class_name
|
||||
|
||||
# Since we'll soon have multiple versions of 'game'
|
||||
# classes we need to migrate people to using base
|
||||
# class names for them.
|
||||
if exporttypestr == 'game':
|
||||
logging.warning(
|
||||
"metascan: %s:%d: '# ba_meta export"
|
||||
" game' tag should be replaced by '# ba_meta"
|
||||
" export bascenev1.GameActivity'.",
|
||||
subpath,
|
||||
lindex + 1,
|
||||
)
|
||||
self.results.announce_errors_occurred = True
|
||||
else:
|
||||
# If export type is one of our shortcuts, sub in the
|
||||
# actual class path. Otherwise assume its a classpath
|
||||
# itself.
|
||||
exporttype = EXPORT_CLASS_NAME_SHORTCUTS.get(
|
||||
exporttypestr
|
||||
)
|
||||
if exporttype is None:
|
||||
exporttype = exporttypestr
|
||||
self.results.exports.setdefault(exporttype, []).append(
|
||||
classname
|
||||
)
|
||||
|
||||
def _get_export_class_name(
|
||||
self, subpath: Path, lines: list[str], lindex: int
|
||||
) -> str | None:
|
||||
"""Given line num of an export tag, returns its operand class name."""
|
||||
lindexorig = lindex
|
||||
classname = None
|
||||
while True:
|
||||
lindex += 1
|
||||
if lindex >= len(lines):
|
||||
break
|
||||
lbits = lines[lindex].split()
|
||||
if not lbits:
|
||||
continue # Skip empty lines.
|
||||
if lbits[0] != 'class':
|
||||
break
|
||||
if len(lbits) > 1:
|
||||
cbits = lbits[1].split('(')
|
||||
if len(cbits) > 1 and cbits[0].isidentifier():
|
||||
classname = cbits[0]
|
||||
break # Success!
|
||||
if classname is None:
|
||||
logging.warning(
|
||||
'metascan: %s:%d: class definition not found below'
|
||||
" 'ba_meta export' statement.",
|
||||
subpath,
|
||||
lindexorig + 1,
|
||||
)
|
||||
self.results.announce_errors_occurred = True
|
||||
return classname
|
||||
|
||||
def _get_api_requirement(
|
||||
self,
|
||||
subpath: Path,
|
||||
meta_lines: dict[int, list[str]],
|
||||
toplevel: bool,
|
||||
) -> int | None:
|
||||
"""Return an API requirement integer or None if none present.
|
||||
|
||||
Malformed api requirement strings will be logged as warnings.
|
||||
"""
|
||||
lines = [
|
||||
l
|
||||
for l in meta_lines.values()
|
||||
if len(l) == 4
|
||||
and l[0] == 'ba_meta'
|
||||
and l[1] == 'require'
|
||||
and l[2] == 'api'
|
||||
and l[3].isdigit()
|
||||
]
|
||||
|
||||
# We're successful if we find exactly one properly formatted
|
||||
# line.
|
||||
if len(lines) == 1:
|
||||
return int(lines[0][3])
|
||||
|
||||
# Ok; not successful. lets issue warnings for a few error cases.
|
||||
if len(lines) > 1:
|
||||
logging.warning(
|
||||
"metascan: %s: multiple '# ba_meta require api <NUM>'"
|
||||
' lines found; ignoring module.',
|
||||
subpath,
|
||||
)
|
||||
self.results.announce_errors_occurred = True
|
||||
elif not lines and toplevel and meta_lines:
|
||||
# If we're a top-level module containing meta lines but no
|
||||
# valid "require api" line found, complain.
|
||||
logging.warning(
|
||||
"metascan: %s: no valid '# ba_meta require api <NUM>"
|
||||
' line found; ignoring module.',
|
||||
subpath,
|
||||
)
|
||||
self.results.announce_errors_occurred = True
|
||||
return None
|
||||
2
dist/ba_data/python/babase/_mgen/__init__.py
vendored
Normal file
2
dist/ba_data/python/babase/_mgen/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
205
dist/ba_data/python/babase/_mgen/enums.py
vendored
Normal file
205
dist/ba_data/python/babase/_mgen/enums.py
vendored
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
"""Enum vals generated by batools.pythonenumsmodule; do not edit by hand."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class InputType(Enum):
|
||||
"""Types of input a controller can send to the game.
|
||||
|
||||
Category: Enums
|
||||
|
||||
"""
|
||||
|
||||
UP_DOWN = 2
|
||||
LEFT_RIGHT = 3
|
||||
JUMP_PRESS = 4
|
||||
JUMP_RELEASE = 5
|
||||
PUNCH_PRESS = 6
|
||||
PUNCH_RELEASE = 7
|
||||
BOMB_PRESS = 8
|
||||
BOMB_RELEASE = 9
|
||||
PICK_UP_PRESS = 10
|
||||
PICK_UP_RELEASE = 11
|
||||
RUN = 12
|
||||
FLY_PRESS = 13
|
||||
FLY_RELEASE = 14
|
||||
START_PRESS = 15
|
||||
START_RELEASE = 16
|
||||
HOLD_POSITION_PRESS = 17
|
||||
HOLD_POSITION_RELEASE = 18
|
||||
LEFT_PRESS = 19
|
||||
LEFT_RELEASE = 20
|
||||
RIGHT_PRESS = 21
|
||||
RIGHT_RELEASE = 22
|
||||
UP_PRESS = 23
|
||||
UP_RELEASE = 24
|
||||
DOWN_PRESS = 25
|
||||
DOWN_RELEASE = 26
|
||||
|
||||
|
||||
class UIScale(Enum):
|
||||
"""The overall scale the UI is being rendered for. Note that this is
|
||||
independent of pixel resolution. For example, a phone and a desktop PC
|
||||
might render the game at similar pixel resolutions but the size they
|
||||
display content at will vary significantly.
|
||||
|
||||
Category: Enums
|
||||
|
||||
'large' is used for devices such as desktop PCs where fine details can
|
||||
be clearly seen. UI elements are generally smaller on the screen
|
||||
and more content can be seen at once.
|
||||
|
||||
'medium' is used for devices such as tablets, TVs, or VR headsets.
|
||||
This mode strikes a balance between clean readability and amount of
|
||||
content visible.
|
||||
|
||||
'small' is used primarily for phones or other small devices where
|
||||
content needs to be presented as large and clear in order to remain
|
||||
readable from an average distance.
|
||||
"""
|
||||
|
||||
LARGE = 0
|
||||
MEDIUM = 1
|
||||
SMALL = 2
|
||||
|
||||
|
||||
class TimeType(Enum):
|
||||
"""Specifies the type of time for various operations to target/use.
|
||||
|
||||
Category: Enums
|
||||
|
||||
'sim' time is the local simulation time for an activity or session.
|
||||
It can proceed at different rates depending on game speed, stops
|
||||
for pauses, etc.
|
||||
|
||||
'base' is the baseline time for an activity or session. It proceeds
|
||||
consistently regardless of game speed or pausing, but may stop during
|
||||
occurrences such as network outages.
|
||||
|
||||
'real' time is mostly based on clock time, with a few exceptions. It may
|
||||
not advance while the app is backgrounded for instance. (the engine
|
||||
attempts to prevent single large time jumps from occurring)
|
||||
"""
|
||||
|
||||
SIM = 0
|
||||
BASE = 1
|
||||
REAL = 2
|
||||
|
||||
|
||||
class TimeFormat(Enum):
|
||||
"""Specifies the format time values are provided in.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
|
||||
SECONDS = 0
|
||||
MILLISECONDS = 1
|
||||
|
||||
|
||||
class Permission(Enum):
|
||||
"""Permissions that can be requested from the OS.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
|
||||
STORAGE = 0
|
||||
|
||||
|
||||
class SpecialChar(Enum):
|
||||
"""Special characters the game can print.
|
||||
|
||||
Category: Enums
|
||||
"""
|
||||
|
||||
DOWN_ARROW = 0
|
||||
UP_ARROW = 1
|
||||
LEFT_ARROW = 2
|
||||
RIGHT_ARROW = 3
|
||||
TOP_BUTTON = 4
|
||||
LEFT_BUTTON = 5
|
||||
RIGHT_BUTTON = 6
|
||||
BOTTOM_BUTTON = 7
|
||||
DELETE = 8
|
||||
SHIFT = 9
|
||||
BACK = 10
|
||||
LOGO_FLAT = 11
|
||||
REWIND_BUTTON = 12
|
||||
PLAY_PAUSE_BUTTON = 13
|
||||
FAST_FORWARD_BUTTON = 14
|
||||
DPAD_CENTER_BUTTON = 15
|
||||
OUYA_BUTTON_O = 16
|
||||
OUYA_BUTTON_U = 17
|
||||
OUYA_BUTTON_Y = 18
|
||||
OUYA_BUTTON_A = 19
|
||||
OUYA_LOGO = 20
|
||||
LOGO = 21
|
||||
TICKET = 22
|
||||
GOOGLE_PLAY_GAMES_LOGO = 23
|
||||
GAME_CENTER_LOGO = 24
|
||||
DICE_BUTTON1 = 25
|
||||
DICE_BUTTON2 = 26
|
||||
DICE_BUTTON3 = 27
|
||||
DICE_BUTTON4 = 28
|
||||
GAME_CIRCLE_LOGO = 29
|
||||
PARTY_ICON = 30
|
||||
TEST_ACCOUNT = 31
|
||||
TICKET_BACKING = 32
|
||||
TROPHY1 = 33
|
||||
TROPHY2 = 34
|
||||
TROPHY3 = 35
|
||||
TROPHY0A = 36
|
||||
TROPHY0B = 37
|
||||
TROPHY4 = 38
|
||||
LOCAL_ACCOUNT = 39
|
||||
EXPLODINARY_LOGO = 40
|
||||
FLAG_UNITED_STATES = 41
|
||||
FLAG_MEXICO = 42
|
||||
FLAG_GERMANY = 43
|
||||
FLAG_BRAZIL = 44
|
||||
FLAG_RUSSIA = 45
|
||||
FLAG_CHINA = 46
|
||||
FLAG_UNITED_KINGDOM = 47
|
||||
FLAG_CANADA = 48
|
||||
FLAG_INDIA = 49
|
||||
FLAG_JAPAN = 50
|
||||
FLAG_FRANCE = 51
|
||||
FLAG_INDONESIA = 52
|
||||
FLAG_ITALY = 53
|
||||
FLAG_SOUTH_KOREA = 54
|
||||
FLAG_NETHERLANDS = 55
|
||||
FEDORA = 56
|
||||
HAL = 57
|
||||
CROWN = 58
|
||||
YIN_YANG = 59
|
||||
EYE_BALL = 60
|
||||
SKULL = 61
|
||||
HEART = 62
|
||||
DRAGON = 63
|
||||
HELMET = 64
|
||||
MUSHROOM = 65
|
||||
NINJA_STAR = 66
|
||||
VIKING_HELMET = 67
|
||||
MOON = 68
|
||||
SPIDER = 69
|
||||
FIREBALL = 70
|
||||
FLAG_UNITED_ARAB_EMIRATES = 71
|
||||
FLAG_QATAR = 72
|
||||
FLAG_EGYPT = 73
|
||||
FLAG_KUWAIT = 74
|
||||
FLAG_ALGERIA = 75
|
||||
FLAG_SAUDI_ARABIA = 76
|
||||
FLAG_MALAYSIA = 77
|
||||
FLAG_CZECH_REPUBLIC = 78
|
||||
FLAG_AUSTRALIA = 79
|
||||
FLAG_SINGAPORE = 80
|
||||
OCULUS_LOGO = 81
|
||||
STEAM_LOGO = 82
|
||||
NVIDIA_LOGO = 83
|
||||
FLAG_IRAN = 84
|
||||
FLAG_POLAND = 85
|
||||
FLAG_ARGENTINA = 86
|
||||
FLAG_PHILIPPINES = 87
|
||||
FLAG_CHILE = 88
|
||||
MIKIROG = 89
|
||||
V2_LOGO = 90
|
||||
75
dist/ba_data/python/babase/_net.py
vendored
Normal file
75
dist/ba_data/python/babase/_net.py
vendored
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Networking related functionality."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import socket
|
||||
|
||||
# Timeout for standard functions talking to the master-server/etc.
|
||||
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
|
||||
|
||||
|
||||
class NetworkSubsystem:
|
||||
"""Network related app subsystem."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Anyone accessing/modifying zone_pings should hold this lock,
|
||||
# as it is updated by a background thread.
|
||||
self.zone_pings_lock = threading.Lock()
|
||||
|
||||
# Zone IDs mapped to average pings. This will remain empty
|
||||
# until enough pings have been run to be reasonably certain
|
||||
# that a nearby server has been pinged.
|
||||
self.zone_pings: dict[str, float] = {}
|
||||
|
||||
self._sslcontext: ssl.SSLContext | None = None
|
||||
|
||||
# For debugging.
|
||||
self.v1_test_log: str = ''
|
||||
self.v1_ctest_results: dict[int, str] = {}
|
||||
self.server_time_offset_hours: float | None = None
|
||||
|
||||
@property
|
||||
def sslcontext(self) -> ssl.SSLContext:
|
||||
"""Create/return our shared SSLContext.
|
||||
|
||||
This can be reused for all standard urllib requests/etc.
|
||||
"""
|
||||
# Note: I've run into older Android devices taking upwards of 1 second
|
||||
# to put together a default SSLContext, so recycling one can definitely
|
||||
# be a worthwhile optimization. This was suggested to me in this
|
||||
# thread by one of Python's SSL maintainers:
|
||||
# https://github.com/python/cpython/issues/94637
|
||||
if self._sslcontext is None:
|
||||
self._sslcontext = ssl.create_default_context()
|
||||
return self._sslcontext
|
||||
|
||||
|
||||
def get_ip_address_type(addr: str) -> socket.AddressFamily:
|
||||
"""Return socket.AF_INET6 or socket.AF_INET4 for the provided address."""
|
||||
import socket
|
||||
|
||||
socket_type = None
|
||||
|
||||
# First try it as an ipv4 address.
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET, addr)
|
||||
socket_type = socket.AF_INET
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Hmm apparently not ipv4; try ipv6.
|
||||
if socket_type is None:
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, addr)
|
||||
socket_type = socket.AF_INET6
|
||||
except OSError:
|
||||
pass
|
||||
if socket_type is None:
|
||||
raise ValueError(f'addr seems to be neither v4 or v6: {addr}')
|
||||
return socket_type
|
||||
333
dist/ba_data/python/babase/_plugin.py
vendored
Normal file
333
dist/ba_data/python/babase/_plugin.py
vendored
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Plugin related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import importlib.util
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import _babase
|
||||
from babase._appsubsystem import AppSubsystem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
import babase
|
||||
|
||||
|
||||
class PluginSubsystem(AppSubsystem):
|
||||
"""Subsystem for plugin handling in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at `ba.app.plugins`.
|
||||
"""
|
||||
|
||||
AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins'
|
||||
AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
# Info about plugins that we are aware of. This may include
|
||||
# plugins discovered through meta-scanning as well as plugins
|
||||
# registered in the app-config. This may include plugins that
|
||||
# cannot be loaded for various reasons or that have been
|
||||
# intentionally disabled.
|
||||
self.plugin_specs: dict[str, babase.PluginSpec] = {}
|
||||
|
||||
# The set of live active plugin objects.
|
||||
self.active_plugins: list[babase.Plugin] = []
|
||||
|
||||
def on_meta_scan_complete(self) -> None:
|
||||
"""Called when meta-scanning is complete."""
|
||||
from babase._language import Lstr
|
||||
|
||||
config_changed = False
|
||||
found_new = False
|
||||
plugstates: dict[str, dict] = _babase.app.config.setdefault(
|
||||
'Plugins', {}
|
||||
)
|
||||
assert isinstance(plugstates, dict)
|
||||
|
||||
results = _babase.app.meta.scanresults
|
||||
assert results is not None
|
||||
|
||||
auto_enable_new_plugins = (
|
||||
_babase.app.config.get(
|
||||
self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
|
||||
self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
assert not self.plugin_specs
|
||||
assert not self.active_plugins
|
||||
|
||||
# Create a plugin-spec for each plugin class we found in the
|
||||
# meta-scan.
|
||||
for class_path in results.exports_of_class(Plugin):
|
||||
assert class_path not in self.plugin_specs
|
||||
plugspec = self.plugin_specs[class_path] = PluginSpec(
|
||||
class_path=class_path, loadable=True
|
||||
)
|
||||
|
||||
# Auto-enable new ones if desired.
|
||||
if auto_enable_new_plugins:
|
||||
if class_path not in plugstates:
|
||||
plugspec.enabled = True
|
||||
config_changed = True
|
||||
found_new = True
|
||||
|
||||
# If we're *not* auto-enabling, just let the user know if we
|
||||
# found new ones.
|
||||
if found_new and not auto_enable_new_plugins:
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='pluginsDetectedText'), color=(0, 1, 0)
|
||||
)
|
||||
_babase.getsimplesound('ding').play()
|
||||
|
||||
# Ok, now go through all plugins registered in the app-config
|
||||
# that weren't covered by the meta stuff above, either creating
|
||||
# plugin-specs for them or clearing them out. This covers
|
||||
# plugins with api versions not matching ours, plugins without
|
||||
# ba_meta tags, and plugins that have since disappeared.
|
||||
assert isinstance(plugstates, dict)
|
||||
wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules]
|
||||
|
||||
disappeared_plugs: set[str] = set()
|
||||
|
||||
for class_path in sorted(plugstates.keys()):
|
||||
# Already have a spec for it; nothing to be done.
|
||||
if class_path in self.plugin_specs:
|
||||
continue
|
||||
|
||||
# If this plugin corresponds to any modules that we've
|
||||
# identified as having incorrect api versions, we'll take
|
||||
# note of its existence but we won't try to load it.
|
||||
if any(
|
||||
class_path.startswith(prefix) for prefix in wrong_api_prefixes
|
||||
):
|
||||
plugspec = self.plugin_specs[class_path] = PluginSpec(
|
||||
class_path=class_path, loadable=False
|
||||
)
|
||||
continue
|
||||
|
||||
# Ok, it seems to be a class we have no metadata for. Look
|
||||
# to see if it appears to be an actual class we could
|
||||
# theoretically load. If so, we'll try. If not, we consider
|
||||
# the plugin to have disappeared and inform the user as
|
||||
# such.
|
||||
try:
|
||||
spec = importlib.util.find_spec(
|
||||
'.'.join(class_path.split('.')[:-1])
|
||||
)
|
||||
except Exception:
|
||||
spec = None
|
||||
|
||||
if spec is None:
|
||||
disappeared_plugs.add(class_path)
|
||||
continue
|
||||
|
||||
# If plugins disappeared, let the user know gently and remove them
|
||||
# from the config so we'll again let the user know if they later
|
||||
# reappear. This makes it much smoother to switch between users
|
||||
# or workspaces.
|
||||
if disappeared_plugs:
|
||||
_babase.getsimplesound('shieldDown').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(
|
||||
resource='pluginsRemovedText',
|
||||
subs=[('${NUM}', str(len(disappeared_plugs)))],
|
||||
),
|
||||
color=(1, 1, 0),
|
||||
)
|
||||
|
||||
plugnames = ', '.join(disappeared_plugs)
|
||||
logging.info(
|
||||
'%d plugin(s) no longer found: %s.',
|
||||
len(disappeared_plugs),
|
||||
plugnames,
|
||||
)
|
||||
for goneplug in disappeared_plugs:
|
||||
del _babase.app.config['Plugins'][goneplug]
|
||||
_babase.app.config.commit()
|
||||
|
||||
if config_changed:
|
||||
_babase.app.config.commit()
|
||||
|
||||
def on_app_running(self) -> None:
|
||||
# Load up our plugins and go ahead and call their on_app_running
|
||||
# calls.
|
||||
self.load_plugins()
|
||||
for plugin in self.active_plugins:
|
||||
try:
|
||||
plugin.on_app_running()
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('Error in plugin on_app_running()')
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
for plugin in self.active_plugins:
|
||||
try:
|
||||
plugin.on_app_pause()
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('Error in plugin on_app_pause()')
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
for plugin in self.active_plugins:
|
||||
try:
|
||||
plugin.on_app_resume()
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('Error in plugin on_app_resume()')
|
||||
|
||||
def on_app_shutdown(self) -> None:
|
||||
for plugin in self.active_plugins:
|
||||
try:
|
||||
plugin.on_app_shutdown()
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('Error in plugin on_app_shutdown()')
|
||||
|
||||
def load_plugins(self) -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# Load plugins from any specs that are enabled & able to.
|
||||
for _class_path, plug_spec in sorted(self.plugin_specs.items()):
|
||||
plugin = plug_spec.attempt_load_if_enabled()
|
||||
if plugin is not None:
|
||||
self.active_plugins.append(plugin)
|
||||
|
||||
|
||||
class PluginSpec:
|
||||
"""Represents a plugin the engine knows about.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
The 'enabled' attr represents whether this plugin is set to load.
|
||||
Getting or setting that attr affects the corresponding app-config
|
||||
key. Remember to commit the app-config after making any changes.
|
||||
|
||||
The 'attempted_load' attr will be True if the engine has attempted
|
||||
to load the plugin. If 'attempted_load' is True for a plugin-spec
|
||||
but the 'plugin' attr is None, it means there was an error loading
|
||||
the plugin. If a plugin's api-version does not match the running
|
||||
app, if a new plugin is detected with auto-enable-plugins disabled,
|
||||
or if the user has explicitly disabled a plugin, the engine will not
|
||||
even attempt to load it.
|
||||
"""
|
||||
|
||||
def __init__(self, class_path: str, loadable: bool):
|
||||
self.class_path = class_path
|
||||
self.loadable = loadable
|
||||
self.attempted_load = False
|
||||
self.plugin: Plugin | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether the user wants this plugin to load."""
|
||||
plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {})
|
||||
assert isinstance(plugstates, dict)
|
||||
val = plugstates.get(self.class_path, {}).get('enabled', False) is True
|
||||
return val
|
||||
|
||||
@enabled.setter
|
||||
def enabled(self, val: bool) -> None:
|
||||
plugstates: dict[str, dict] = _babase.app.config.setdefault(
|
||||
'Plugins', {}
|
||||
)
|
||||
assert isinstance(plugstates, dict)
|
||||
plugstate = plugstates.setdefault(self.class_path, {})
|
||||
plugstate['enabled'] = val
|
||||
|
||||
def attempt_load_if_enabled(self) -> Plugin | None:
|
||||
"""Possibly load the plugin and report errors."""
|
||||
from babase._general import getclass
|
||||
from babase._language import Lstr
|
||||
|
||||
assert not self.attempted_load
|
||||
assert self.plugin is None
|
||||
|
||||
if not self.enabled:
|
||||
return None
|
||||
self.attempted_load = True
|
||||
if not self.loadable:
|
||||
return None
|
||||
try:
|
||||
cls = getclass(self.class_path, Plugin)
|
||||
except Exception as exc:
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(
|
||||
resource='pluginClassLoadErrorText',
|
||||
subs=[
|
||||
('${PLUGIN}', self.class_path),
|
||||
('${ERROR}', str(exc)),
|
||||
],
|
||||
),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
logging.exception(
|
||||
"Error loading plugin class '%s'.", self.class_path
|
||||
)
|
||||
return None
|
||||
try:
|
||||
self.plugin = cls()
|
||||
return self.plugin
|
||||
except Exception as exc:
|
||||
from babase import _error
|
||||
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(
|
||||
resource='pluginInitErrorText',
|
||||
subs=[
|
||||
('${PLUGIN}', self.class_path),
|
||||
('${ERROR}', str(exc)),
|
||||
],
|
||||
),
|
||||
color=(1, 0, 0),
|
||||
)
|
||||
logging.exception(
|
||||
"Error initing plugin class: '%s'.", self.class_path
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class Plugin:
|
||||
"""A plugin to alter app behavior in some way.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Plugins are discoverable by the meta-tag system
|
||||
and the user can select which ones they want to activate.
|
||||
Active plugins are then called at specific times as the
|
||||
app is running in order to modify its behavior in some way.
|
||||
"""
|
||||
|
||||
def on_app_running(self) -> None:
|
||||
"""Called when the app reaches the running state."""
|
||||
|
||||
def on_app_pause(self) -> None:
|
||||
"""Called after pausing game activity."""
|
||||
|
||||
def on_app_resume(self) -> None:
|
||||
"""Called after the game continues."""
|
||||
|
||||
def on_app_shutdown(self) -> None:
|
||||
"""Called before closing the application."""
|
||||
|
||||
def has_settings_ui(self) -> bool:
|
||||
"""Called to ask if we have settings UI we can show."""
|
||||
return False
|
||||
|
||||
def show_settings_ui(self, source_widget: Any | None) -> None:
|
||||
"""Called to show our settings UI."""
|
||||
90
dist/ba_data/python/babase/_text.py
vendored
Normal file
90
dist/ba_data/python/babase/_text.py
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Text related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import babase
|
||||
|
||||
|
||||
def timestring(
|
||||
timeval: float | int,
|
||||
centi: bool = True,
|
||||
) -> babase.Lstr:
|
||||
"""Generate a babase.Lstr for displaying a time value.
|
||||
|
||||
Category: **General Utility Functions**
|
||||
|
||||
Given a time value, returns a babase.Lstr with:
|
||||
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
|
||||
|
||||
WARNING: the underlying Lstr value is somewhat large so don't use this
|
||||
to rapidly update Node text values for an onscreen timer or you may
|
||||
consume significant network bandwidth. For that purpose you should
|
||||
use a 'timedisplay' Node and attribute connections.
|
||||
|
||||
"""
|
||||
from babase._language import Lstr
|
||||
|
||||
# We take float seconds but operate on int milliseconds internally.
|
||||
timeval = int(1000 * timeval)
|
||||
bits = []
|
||||
subs = []
|
||||
hval = (timeval // 1000) // (60 * 60)
|
||||
if hval != 0:
|
||||
bits.append('${H}')
|
||||
subs.append(
|
||||
(
|
||||
'${H}',
|
||||
Lstr(
|
||||
resource='timeSuffixHoursText',
|
||||
subs=[('${COUNT}', str(hval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
mval = ((timeval // 1000) // 60) % 60
|
||||
if mval != 0:
|
||||
bits.append('${M}')
|
||||
subs.append(
|
||||
(
|
||||
'${M}',
|
||||
Lstr(
|
||||
resource='timeSuffixMinutesText',
|
||||
subs=[('${COUNT}', str(mval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# We add seconds if its non-zero *or* we haven't added anything else.
|
||||
if centi:
|
||||
# pylint: disable=consider-using-f-string
|
||||
sval = timeval / 1000.0 % 60.0
|
||||
if sval >= 0.005 or not bits:
|
||||
bits.append('${S}')
|
||||
subs.append(
|
||||
(
|
||||
'${S}',
|
||||
Lstr(
|
||||
resource='timeSuffixSecondsText',
|
||||
subs=[('${COUNT}', ('%.2f' % sval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
sval = timeval // 1000 % 60
|
||||
if sval != 0 or not bits:
|
||||
bits.append('${S}')
|
||||
subs.append(
|
||||
(
|
||||
'${S}',
|
||||
Lstr(
|
||||
resource='timeSuffixSecondsText',
|
||||
subs=[('${COUNT}', str(sval))],
|
||||
),
|
||||
)
|
||||
)
|
||||
return Lstr(value=' '.join(bits), subs=subs)
|
||||
217
dist/ba_data/python/babase/_workspace.py
vendored
Normal file
217
dist/ba_data/python/babase/_workspace.py
vendored
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Workspace related functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from efro.call import tpartial
|
||||
from efro.error import CleanError
|
||||
import _babase
|
||||
import bacommon.cloud
|
||||
from bacommon.transfer import DirectoryManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Callable
|
||||
|
||||
import babase
|
||||
|
||||
|
||||
class WorkspaceSubsystem:
|
||||
"""Subsystem for workspace handling in the app.
|
||||
|
||||
Category: **App Classes**
|
||||
|
||||
Access the single shared instance of this class at `ba.app.workspaces`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def set_active_workspace(
|
||||
self,
|
||||
account: babase.AccountV2Handle,
|
||||
workspaceid: str,
|
||||
workspacename: str,
|
||||
on_completed: Callable[[], None],
|
||||
) -> None:
|
||||
"""(internal)"""
|
||||
|
||||
# Do our work in a background thread so we don't destroy
|
||||
# interactivity.
|
||||
Thread(
|
||||
target=lambda: self._set_active_workspace_bg(
|
||||
account=account,
|
||||
workspaceid=workspaceid,
|
||||
workspacename=workspacename,
|
||||
on_completed=on_completed,
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
def _errmsg(self, msg: babase.Lstr) -> None:
|
||||
_babase.screenmessage(msg, color=(1, 0, 0))
|
||||
_babase.getsimplesound('error').play()
|
||||
|
||||
def _successmsg(self, msg: babase.Lstr) -> None:
|
||||
_babase.screenmessage(msg, color=(0, 1, 0))
|
||||
_babase.getsimplesound('gunCocking').play()
|
||||
|
||||
def _set_active_workspace_bg(
|
||||
self,
|
||||
account: babase.AccountV2Handle,
|
||||
workspaceid: str,
|
||||
workspacename: str,
|
||||
on_completed: Callable[[], None],
|
||||
) -> None:
|
||||
from babase._language import Lstr
|
||||
|
||||
class _SkipSyncError(RuntimeError):
|
||||
pass
|
||||
|
||||
plus = _babase.app.plus
|
||||
assert plus is not None
|
||||
|
||||
set_path = True
|
||||
wspath = Path(
|
||||
_babase.get_volatile_data_directory(), 'workspaces', workspaceid
|
||||
)
|
||||
try:
|
||||
# If it seems we're offline, don't even attempt a sync,
|
||||
# but allow using the previous synced state.
|
||||
# (is this a good idea?)
|
||||
if not plus.cloud.is_connected():
|
||||
raise _SkipSyncError()
|
||||
|
||||
manifest = DirectoryManifest.create_from_disk(wspath)
|
||||
|
||||
# FIXME: Should implement a way to pass account credentials in
|
||||
# from the logic thread.
|
||||
state = bacommon.cloud.WorkspaceFetchState(manifest=manifest)
|
||||
|
||||
while True:
|
||||
with account:
|
||||
response = plus.cloud.send_message(
|
||||
bacommon.cloud.WorkspaceFetchMessage(
|
||||
workspaceid=workspaceid, state=state
|
||||
)
|
||||
)
|
||||
state = response.state
|
||||
self._handle_deletes(
|
||||
workspace_dir=wspath, deletes=response.deletes
|
||||
)
|
||||
self._handle_downloads_inline(
|
||||
workspace_dir=wspath,
|
||||
downloads_inline=response.downloads_inline,
|
||||
)
|
||||
if response.done:
|
||||
# Server only deals in files; let's clean up any
|
||||
# leftover empty dirs after the dust has cleared.
|
||||
self._handle_dir_prune_empty(str(wspath))
|
||||
break
|
||||
state.iteration += 1
|
||||
|
||||
_babase.pushcall(
|
||||
tpartial(
|
||||
self._successmsg,
|
||||
Lstr(
|
||||
resource='activatedText',
|
||||
subs=[('${THING}', workspacename)],
|
||||
),
|
||||
),
|
||||
from_other_thread=True,
|
||||
)
|
||||
|
||||
except _SkipSyncError:
|
||||
_babase.pushcall(
|
||||
tpartial(
|
||||
self._errmsg,
|
||||
Lstr(
|
||||
resource='workspaceSyncReuseText',
|
||||
subs=[('${WORKSPACE}', workspacename)],
|
||||
),
|
||||
),
|
||||
from_other_thread=True,
|
||||
)
|
||||
|
||||
except CleanError as exc:
|
||||
# Avoid reusing existing if we fail in the middle; could
|
||||
# be in wonky state.
|
||||
set_path = False
|
||||
_babase.pushcall(
|
||||
tpartial(self._errmsg, Lstr(value=str(exc))),
|
||||
from_other_thread=True,
|
||||
)
|
||||
except Exception:
|
||||
# Ditto.
|
||||
set_path = False
|
||||
logging.exception("Error syncing workspace '%s'.", workspacename)
|
||||
_babase.pushcall(
|
||||
tpartial(
|
||||
self._errmsg,
|
||||
Lstr(
|
||||
resource='workspaceSyncErrorText',
|
||||
subs=[('${WORKSPACE}', workspacename)],
|
||||
),
|
||||
),
|
||||
from_other_thread=True,
|
||||
)
|
||||
|
||||
if set_path and wspath.is_dir():
|
||||
# Add to Python paths and also to list of stuff to be scanned
|
||||
# for meta tags.
|
||||
sys.path.insert(0, str(wspath))
|
||||
_babase.app.meta.extra_scan_dirs.append(str(wspath))
|
||||
|
||||
# Job's done!
|
||||
_babase.pushcall(on_completed, from_other_thread=True)
|
||||
|
||||
def _handle_deletes(self, workspace_dir: Path, deletes: list[str]) -> None:
|
||||
"""Handle file deletes."""
|
||||
for fname in deletes:
|
||||
fname = os.path.join(workspace_dir, fname)
|
||||
# Server shouldn't be sending us dir paths here.
|
||||
assert not os.path.isdir(fname)
|
||||
os.unlink(fname)
|
||||
|
||||
def _handle_downloads_inline(
|
||||
self,
|
||||
workspace_dir: Path,
|
||||
downloads_inline: dict[str, bytes],
|
||||
) -> None:
|
||||
"""Handle inline file data to be saved to the client."""
|
||||
for fname, fdata in downloads_inline.items():
|
||||
fname = os.path.join(workspace_dir, fname)
|
||||
# If there's a directory where we want our file to go, clear it
|
||||
# out first. File deletes should have run before this so
|
||||
# everything under it should be empty and thus killable via rmdir.
|
||||
if os.path.isdir(fname):
|
||||
for basename, dirnames, _fn in os.walk(fname, topdown=False):
|
||||
for dirname in dirnames:
|
||||
os.rmdir(os.path.join(basename, dirname))
|
||||
os.rmdir(fname)
|
||||
|
||||
dirname = os.path.dirname(fname)
|
||||
if dirname:
|
||||
os.makedirs(dirname, exist_ok=True)
|
||||
with open(fname, 'wb') as outfile:
|
||||
outfile.write(fdata)
|
||||
|
||||
def _handle_dir_prune_empty(self, prunedir: str) -> None:
|
||||
"""Handle pruning empty directories."""
|
||||
# Walk the tree bottom-up so we can properly kill recursive empty dirs.
|
||||
for basename, dirnames, filenames in os.walk(prunedir, topdown=False):
|
||||
# It seems that child dirs we kill during the walk are still
|
||||
# listed when the parent dir is visited, so lets make sure
|
||||
# to only acknowledge still-existing ones.
|
||||
dirnames = [
|
||||
d for d in dirnames if os.path.exists(os.path.join(basename, d))
|
||||
]
|
||||
if not dirnames and not filenames and basename != prunedir:
|
||||
os.rmdir(basename)
|
||||
190
dist/ba_data/python/babase/modutils.py
vendored
Normal file
190
dist/ba_data/python/babase/modutils.py
vendored
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# Released under the MIT License. See LICENSE for details.
|
||||
#
|
||||
"""Functionality related to modding."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
import os
|
||||
|
||||
import _babase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Sequence
|
||||
|
||||
|
||||
def get_human_readable_user_scripts_path() -> str:
|
||||
"""Return a human readable location of user-scripts.
|
||||
|
||||
This is NOT a valid filesystem path; may be something like "(SD Card)".
|
||||
"""
|
||||
app = _babase.app
|
||||
path: str | None = app.python_directory_user
|
||||
if path is None:
|
||||
return '<Not Available>'
|
||||
|
||||
# These days, on Android, we use getExternalFilesDir() as the base of our
|
||||
# app's user-scripts dir, which gives us paths like:
|
||||
# /storage/emulated/0/Android/data/net.froemling.bombsquad/files
|
||||
# Userspace apps tend to show that as:
|
||||
# Android/data/net.froemling.bombsquad/files
|
||||
# We'd like to display it that way, but I'm not sure if there's a clean
|
||||
# way to get the root of the external storage area (/storage/emulated/0)
|
||||
# so that we could strip it off. There is
|
||||
# Environment.getExternalStorageDirectory() but that is deprecated.
|
||||
# So for now let's just be conservative and trim off recognized prefixes
|
||||
# and show the whole ugly path as a fallback.
|
||||
# Note that we used to use externalStorageText resource but gonna try
|
||||
# without it for now. (simply 'foo' instead of <External Storage>/foo).
|
||||
if app.classic is not None and app.classic.platform == 'android':
|
||||
for pre in ['/storage/emulated/0/']:
|
||||
if path.startswith(pre):
|
||||
path = path.removeprefix(pre)
|
||||
break
|
||||
return path
|
||||
|
||||
|
||||
def _request_storage_permission() -> bool:
|
||||
"""If needed, requests storage permission from the user (& return true)."""
|
||||
from babase._language import Lstr
|
||||
|
||||
# noinspection PyProtectedMember
|
||||
# (PyCharm inspection bug?)
|
||||
from babase._mgen.enums import Permission
|
||||
|
||||
if not _babase.have_permission(Permission.STORAGE):
|
||||
_babase.getsimplesound('error').play()
|
||||
_babase.screenmessage(
|
||||
Lstr(resource='storagePermissionAccessText'), color=(1, 0, 0)
|
||||
)
|
||||
_babase.apptimer(
|
||||
1.0, lambda: _babase.request_permission(Permission.STORAGE)
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def show_user_scripts() -> None:
|
||||
"""Open or nicely print the location of the user-scripts directory."""
|
||||
app = _babase.app
|
||||
|
||||
# First off, if we need permission for this, ask for it.
|
||||
if _request_storage_permission():
|
||||
return
|
||||
|
||||
# If we're running in a nonstandard environment its possible this is unset.
|
||||
if app.python_directory_user is None:
|
||||
_babase.screenmessage('<unset>')
|
||||
return
|
||||
|
||||
# Secondly, if the dir doesn't exist, attempt to make it.
|
||||
if not os.path.exists(app.python_directory_user):
|
||||
os.makedirs(app.python_directory_user)
|
||||
|
||||
# On android, attempt to write a file in their user-scripts dir telling
|
||||
# them about modding. This also has the side-effect of allowing us to
|
||||
# media-scan that dir so it shows up in android-file-transfer, since it
|
||||
# doesn't seem like there's a way to inform the media scanner of an empty
|
||||
# directory, which means they would have to reboot their device before
|
||||
# they can see it.
|
||||
if app.classic is not None and app.classic.platform == 'android':
|
||||
try:
|
||||
usd: str | None = app.python_directory_user
|
||||
if usd is not None and os.path.isdir(usd):
|
||||
file_name = usd + '/about_this_folder.txt'
|
||||
with open(file_name, 'w', encoding='utf-8') as outfile:
|
||||
outfile.write(
|
||||
'You can drop files in here to mod the game.'
|
||||
' See settings/advanced'
|
||||
' in the game for more info.'
|
||||
)
|
||||
|
||||
except Exception:
|
||||
from babase import _error
|
||||
|
||||
_error.print_exception('error writing about_this_folder stuff')
|
||||
|
||||
# On a few platforms we try to open the dir in the UI.
|
||||
if app.classic is not None and app.classic.platform in ['mac', 'windows']:
|
||||
_babase.open_dir_externally(app.python_directory_user)
|
||||
|
||||
# Otherwise we just print a pretty version of it.
|
||||
else:
|
||||
_babase.screenmessage(get_human_readable_user_scripts_path())
|
||||
|
||||
|
||||
def create_user_system_scripts() -> None:
|
||||
"""Set up a copy of Ballistica app scripts under user scripts dir.
|
||||
|
||||
(for editing and experimenting)
|
||||
"""
|
||||
import shutil
|
||||
|
||||
app = _babase.app
|
||||
|
||||
# First off, if we need permission for this, ask for it.
|
||||
if _request_storage_permission():
|
||||
return
|
||||
|
||||
# Its possible these are unset in non-standard environments.
|
||||
if app.python_directory_user is None:
|
||||
raise RuntimeError('user python dir unset')
|
||||
if app.python_directory_app is None:
|
||||
raise RuntimeError('app python dir unset')
|
||||
|
||||
path = app.python_directory_user + '/sys/' + app.version
|
||||
pathtmp = path + '_tmp'
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path)
|
||||
if os.path.exists(pathtmp):
|
||||
shutil.rmtree(pathtmp)
|
||||
|
||||
def _ignore_filter(src: str, names: Sequence[str]) -> Sequence[str]:
|
||||
del src, names # Unused
|
||||
|
||||
# We simply skip all __pycache__ directories. (the user would have
|
||||
# to blow them away anyway to make changes;
|
||||
# See https://github.com/efroemling/ballistica/wiki
|
||||
# /Knowledge-Nuggets#python-cache-files-gotcha
|
||||
return ('__pycache__',)
|
||||
|
||||
print(f'COPYING "{app.python_directory_app}" -> "{pathtmp}".')
|
||||
shutil.copytree(app.python_directory_app, pathtmp, ignore=_ignore_filter)
|
||||
|
||||
print(f'MOVING "{pathtmp}" -> "{path}".')
|
||||
shutil.move(pathtmp, path)
|
||||
print(
|
||||
f"Created system scripts at :'{path}"
|
||||
f"'\nRestart {_babase.appname()} to use them."
|
||||
f' (use babase.quit() to exit the game)'
|
||||
)
|
||||
if app.classic is not None and app.classic.platform == 'android':
|
||||
print(
|
||||
'Note: the new files may not be visible via '
|
||||
'android-file-transfer until you restart your device.'
|
||||
)
|
||||
|
||||
|
||||
def delete_user_system_scripts() -> None:
|
||||
"""Clean out the scripts created by create_user_system_scripts()."""
|
||||
import shutil
|
||||
|
||||
app = _babase.app
|
||||
|
||||
if app.python_directory_user is None:
|
||||
raise RuntimeError('user python dir unset')
|
||||
|
||||
path = app.python_directory_user + '/sys/' + app.version
|
||||
if os.path.exists(path):
|
||||
shutil.rmtree(path)
|
||||
print(
|
||||
f'User system scripts deleted.\n'
|
||||
f'Restart {_babase.appname()} to use internal'
|
||||
f' scripts. (use babase.quit() to exit the game)'
|
||||
)
|
||||
else:
|
||||
print(f"User system scripts not found at '{path}'.")
|
||||
|
||||
# If the sys path is empty, kill it.
|
||||
dpath = app.python_directory_user + '/sys'
|
||||
if os.path.isdir(dpath) and not os.listdir(dpath):
|
||||
os.rmdir(dpath)
|
||||
Loading…
Add table
Add a link
Reference in a new issue