Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ on:
description: 'Build and release Windows executables'
type: boolean
default: false
release_gui:
description: 'Include Windows GUI executable (requires GooeyEx)'
type: boolean
default: false

permissions:
contents: read
Expand Down Expand Up @@ -85,14 +81,11 @@ jobs:
python-version: '3.11'
- name: Install dependencies
run: python -m pip install pyinstaller ".[il2cpp]"
- name: Install GUI dependencies
if: inputs.release_gui
run: python -m pip install ".[gui]"
# NOTE: The GUI executable is not built here: its dependency GooeyEx is not
# published on public PyPI. Build it locally with `pyinstaller sssekai-gui.spec`
# from an environment where GooeyEx is installed.
- name: Build Windows executables
run: pyinstaller sssekai.spec
- name: Build Windows GUI executable
if: inputs.release_gui
run: pyinstaller sssekai-gui.spec
- name: Create GitHub release
shell: pwsh
env:
Expand Down
35 changes: 0 additions & 35 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -276,30 +276,6 @@
"console": "integratedTerminal",
"justMyCode": false
},
{
"name": "Python: Convert Sekai Character Mesh",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/sssekai/scripts/blender_mesh_importer.py",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"args": [
"C:\\Users\\mos9527\\.sssekai\\abcache\\live_pv\\model\\character\\body\\21\\0001\\ladies_s"
],
"justMyCode": false
},
{
"name": "Python: Dump Live2D Model Parameter names",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/sssekai/scripts/dump_moc3_assets.py",
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"args": [
"C:\\Users\\mos9527\\.sssekai\\abcache\\live2d\\model"
],
"justMyCode": false
},
{
"name": "Python: apphash",
"type": "python",
Expand Down Expand Up @@ -353,17 +329,6 @@
],
"justMyCode": true
},
{
"name": "Python: live2dextract Generic",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/sssekai/scripts/live2dextract_generic.py",
"args": [
"C:\\Users\\Huang\\Desktop\\Data",
"C:\\Users\\Huang\\Desktop\\Data_result"
],
"justMyCode": false
},
{
"name": "Python: rla2json",
"type": "python",
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ To use the latest development version, you can install directly from GitHub:
pip install -U git+https://github.com/MetaSekaiLab/sssekai
```
## Using the GUI version
Additionally, you can use the GUI version of `sssekai` by installing the `[gui]` extra, which in turn installs [GooeyEx](https://github.com/mos9527/GooeyEx/).
The GUI depends on [GooeyEx](https://github.com/mos9527/GooeyEx/) **>= 0.1.1**, which is not published on PyPI. Install it manually from upstream (a `git` executable and network access are required), then run the GUI:

```bash
pip install -U "sssekai[gui]"
```
Then you can run the GUI version by:
```bash
pip install -U "GooeyEx @ git+https://github.com/mos9527/GooeyEx"
sssekai-gui
```

`GooeyEx` 0.1.0 (the newest version on PyPI) crashes on import; verify you got 0.1.1 or newer with `python -c "import GooeyEx; print(GooeyEx.__version__)"`. If git is unavailable, use the CLI executable from Releases instead.
# 问题反馈
使用问题和缺陷请提交至本仓库的 [Issues](https://github.com/MetaSekaiLab/sssekai/issues)。

Expand Down
7 changes: 6 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@
"coloredlogs",
"fsspec",
],
extras_require={"gui": ["GooeyEx>=0.1.1"], "il2cpp": ["lief"], "criware": ["PyCriCodecsEx"]},
# NOTE: No `gui` extra on purpose. The GUI requires GooeyEx >= 0.1.1, which
# is not published on PyPI (0.1.0 crashes at import without a wx.App), and
# PyPI rejects direct-URL requirements. See README for manual installation.
# The unused `criware` extra was removed with its dependency for the same
# reason: PyCriCodecsEx is not installable and nothing imports it.
extras_require={"il2cpp": ["lief"]},
entry_points={
"console_scripts": [
"sssekai = sssekai.__main__:__main__",
Expand Down
8 changes: 6 additions & 2 deletions sssekai/__gui__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
from GooeyEx import Gooey, GooeyParser
except ImportError as e:
print(
"Please install sssekai[gui] through your Python package manager to use the GUI"
"The GUI requires GooeyEx >= 0.1.1, which is not published on PyPI.\n"
"Install it manually, then retry:\n"
' pip install -U "GooeyEx @ git+https://github.com/mos9527/GooeyEx"\n'
"Otherwise use the CLI executable from the Releases page.",
file=sys.stderr,
)
raise e
raise SystemExit(1) from e


from sssekai.unity import sssekai_set_unity_version
Expand Down
6 changes: 3 additions & 3 deletions sssekai/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ def gooey_only(**kwargs):
)
group = abcache_parser.add_argument_group(
"game version options",
"NOTE: these options are used to *update* the cache database. Use --no-update to skip updating. Also, you may want to read the Wiki to know how to get these values.",
"NOTE: these options are used to *update* the cache database. Use --no-update to skip updating. You can obtain these values with the `apphash` subcommand.",
)
group.add_argument(
"--app-region",
Expand Down Expand Up @@ -329,8 +329,8 @@ def gooey_only(**kwargs):
abserve_parser.add_argument(
"--host",
type=str,
help="""address of the interface to listen on (default: %(default)s)""",
default="0.0.0.0",
help="""address of the interface to listen on (default: %(default)s). Use 0.0.0.0 to expose the server to the network.""",
default="127.0.0.1",
)
abserve_parser.add_argument(
"--port",
Expand Down
118 changes: 111 additions & 7 deletions sssekai/abcache/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@
REGION_OPTIONS = REGION_JP_EN | REGION_ROW


class RedactedStr(str):
"""A credential that behaves like a str but hides its value in repr.

`dataclasses.asdict` deep-copies values, so `field(repr=False)` alone does
not keep tokens out of `repr(asdict(...))`. Pickles as a plain str so cache
files stay compatible with older versions.
"""

def __repr__(self):
return "<redacted>"

def __deepcopy__(self, memo):
return self

def __reduce_ex__(self, protocol):
return str, (str(self),)


def fromdict(klass: type, d: Union[Mapping, List], warn_missing_fields=True):
"""
Recursively convert a dictionary to a dataclass instance, if applicable.
Expand Down Expand Up @@ -74,7 +92,9 @@ class AbCacheConfig:
app_platform: str
app_hash: str

auth_credential: str = None # JWT token for JP/EN, Base64 encoded JWT token for ROW
auth_credential: str = field(
default=None, repr=False
) # JWT token for JP/EN, Base64 encoded JWT token for ROW

row_ab_version: str = None # Override AB version in url for ROW

Expand All @@ -84,6 +104,13 @@ class AbCacheConfig:

version: Tuple[int, int, int] = (0, 0, 0) # Internal versioning

def __setattr__(self, name, value):
# Credentials are also assigned after construction (CLI, auth.py).
if name == "auth_credential" and value is not None:
if not isinstance(value, RedactedStr):
value = RedactedStr(value)
super().__setattr__(name, value)

@property
def auth_jwt(self) -> str:
if self.app_region in REGION_JP_EN:
Expand Down Expand Up @@ -209,10 +236,16 @@ class SekaiUserRegistrationData:
class SekaiUserData:
userRegistration: SekaiUserRegistrationData
# JP/EN Only
credential: Optional[str] = None
credential: Optional[str] = field(default=None, repr=False)
updatedResources: Optional[dict] = None
# ROW
sessionToken: Optional[str] = None
sessionToken: Optional[str] = field(default=None, repr=False)

def __setattr__(self, name, value):
if name in ("credential", "sessionToken") and value is not None:
if not isinstance(value, RedactedStr):
value = RedactedStr(value)
super().__setattr__(name, value)

@property
def user_credentials(self):
Expand All @@ -221,7 +254,7 @@ def user_credentials(self):

@dataclass
class SekaiUserAuthData:
sessionToken: str
sessionToken: str = field(repr=False)
appVersion: str
multiPlayVersion: str
dataVersion: str
Expand All @@ -244,25 +277,94 @@ class SekaiUserAuthData:
cdnVersion: Optional[int] = None
configs: Optional[List[dict]] = None

def __setattr__(self, name, value):
if name == "sessionToken" and value is not None:
if not isinstance(value, RedactedStr):
value = RedactedStr(value)
super().__setattr__(name, value)


class RedactedHeaders(dict):
"""Request headers kept for cache reuse. Hidden from repr/asdict so tokens
(e.g. X-Session-Token) never reach logs. Pickles as a plain dict so cache
files stay loadable by both old and new versions."""

def __repr__(self):
return "<headers: %d values>" % len(self)

def __deepcopy__(self, memo):
# dict's default deepcopy returns a plain dict, which would leak through
# dataclasses.asdict(). Header values are str, so a shallow copy suffices.
return RedactedHeaders(self)

def __reduce_ex__(self, protocol):
return dict, (dict(self),)


@dataclass
class SSSekaiDatabase:
config: AbCacheConfig = None
cached_headers: dict = field(default_factory=dict)
cached_headers: dict = field(default_factory=RedactedHeaders)

sekai_user_data: SekaiUserData = None
sekai_user_auth_data: SekaiUserAuthData = None
sekai_abcache_index: AbCacheIndex = None
sekai_system_data: SekaiSystemData = None
sekai_gameversion_data: SekaiGameVersionData = None

def __repr__(self) -> str:
# Two layers guard this: RedactedHeaders hides its own values, and this
# repr also covers a plain dict (databases built by hand, or caches not
# yet normalized by load()) that would otherwise print in full.
values = ", ".join(
"%s=%r" % (f.name, getattr(self, f.name, None))
for f in fields(self)
if f.name != "cached_headers"
)
return "SSSekaiDatabase(%s, cached_headers=<%d values>)" % (
values,
len(self.cached_headers or ()),
)

def reset(self):
self.sekai_user_data = None
self.sekai_user_auth_data = None
self.sekai_abcache_index = None
self.sekai_system_data = None
self.sekai_gameversion_data = None

def redact_secrets(self):
"""Re-wrap secrets loaded from disk as RedactedStr.

RedactedStr pickles as a plain str (so caches stay readable by older
versions), so values read back from a cache file must be wrapped again
before they can reach a repr/asdict. Assignment goes through each
dataclass's __setattr__, which does the wrapping.
"""
if not isinstance(self.cached_headers, RedactedHeaders):
self.cached_headers = RedactedHeaders(self.cached_headers or {})
# sssekai <= 0.8.2 stored the raw API response here, so old caches may
# hold a plain dict (which also breaks attribute access such as
# SEKAI_CREDENTIAL). Normalize it to the dataclass we expect.
if isinstance(self.sekai_user_data, dict):
self.sekai_user_data = fromdict(
SekaiUserData, self.sekai_user_data, False
)
secrets = []
if self.config is not None:
secrets.append((self.config, ("auth_credential",)))
secrets += [
(self.sekai_user_data, ("credential", "sessionToken")),
(self.sekai_user_auth_data, ("sessionToken",)),
]
for owner, names in secrets:
if owner is None:
continue
for name in names:
value = getattr(owner, name, None)
if value is not None:
setattr(owner, name, value)


class AbCacheBundleNotFoundError(Exception):
bundleName: str
Expand Down Expand Up @@ -716,21 +818,23 @@ def update(self):

def save(self, f: BinaryIO):
self._update_request_headers()
self.database.cached_headers = self.headers
self.database.cached_headers = RedactedHeaders(self.headers)
self.database.config.version = __version_tuple__
logger.info("Saving cache: %s" % self)
dump(self.database, f)

def load(self, f: BinaryIO):
logger.info("Loading cache")
self.database = load(f)
# Values from disk are plain str/dict; re-wrap so they stay out of logs.
self.database.redact_secrets()
logger.info("Loaded cache: %s" % self)
if not self.database.config.is_up_to_date:
logger.warning(
"Cache is outdated (cache: %s, current: %s). It's HIGHLY recommended to update the cache to avoid issues."
% (self.database.config.cache_version_string, __version__)
)
self.headers.update(self.database.cached_headers)
logger.info("Cache loaded: %s" % self)

@staticmethod
def from_file(f: BinaryIO):
Expand Down
4 changes: 2 additions & 2 deletions sssekai/abcache/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ def register_as_anonymous_user_sega(cache: AbCache) -> SekaiUserData:
data = cache.response_to_dict(resp)
cache.config.auth_credential = data["credential"]
logger.info("Success. User ID=%s" % cache.config.auth_userID)
cache.database.sekai_user_data = data
return fromdict(SekaiUserData, data)
cache.database.sekai_user_data = fromdict(SekaiUserData, data, False)
return cache.database.sekai_user_data
2 changes: 1 addition & 1 deletion sssekai/entrypoint/apphash.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ def main_apphash(args):
AbCacheConfig(
app_region="{region}",
app_version="{app_version}",
ab_version="{ab_version}",
row_ab_version="{ab_version}",
app_hash="{app_hash}",
app_platform="{platform}"
)
Expand Down
1 change: 1 addition & 0 deletions sssekai/entrypoint/rla2json.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def __dump_from_frame_gen(frame_gen):
)
header = datas.get("sekai.rlh", None)
assert header, "RLH Header file not found!"
header = json.loads(header.decode("utf-8"))
version = tuple(map(int, header["version"].split(".")))
logger.info("Version: %d.%d" % version)
logger.info("Count: %d" % len(header["splitFileIds"]))
Expand Down
Loading
Loading