diff --git a/__init__.py b/__init__.py index 649e269..9c8df1e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.0.1" +__version__ = "0.0.5" __author__ = "wei" __email__ = "weidnite1222@gmail.com" __description__ = """ diff --git a/constant.py b/constant.py index 188d45b..5462651 100644 --- a/constant.py +++ b/constant.py @@ -1,4 +1,4 @@ -LAUNCHER_VERSION = "0.0.4-alpha" +LAUNCHER_VERSION = "0.0.5-alpha" LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)" LAUNCHER_DIR_NAME = "TestLauncher" MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" diff --git a/core_lib/runtime/java.py b/core_lib/runtime/java.py index ab85695..7ee2af3 100644 --- a/core_lib/runtime/java.py +++ b/core_lib/runtime/java.py @@ -26,6 +26,11 @@ def get_java_version(java_executable_path: str | Path) -> tuple[int, str]: [java_executable_path, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=( + subprocess.CREATE_NO_WINDOW + if sys.platform == "win32" + else 0 + ), text=True, encoding="utf-8", errors="replace", @@ -159,4 +164,4 @@ def find_java_installations(extra_install_patterns: list | None=None) -> tuple[l installations.sort(key=lambda item: item["major"], reverse=True) - return installations, errors \ No newline at end of file + return installations, errors diff --git a/main.py b/main.py index 45ec42e..f30b4a7 100644 --- a/main.py +++ b/main.py @@ -3,10 +3,10 @@ # nuitka-project: --enable-plugin=pyside6 # nuitka-project: --include-data-dir={MAIN_DIRECTORY}/resources=resources # nuitka-project: --output-dir={MAIN_DIRECTORY}/build -# nuitka-project: --output-filename=TestLauncher -# nuitka-project: --product-name=TestLauncher -# nuitka-project: --file-description=TestLauncher Minecraft Launcher -# nuitka-project: --copyright=Copyright (c) wei +# nuitka-project: --output-filename=launcher +# nuitka-project: --product-name=TapLauncher +# nuitka-project: --file-description=A Minecraft Launcher +# nuitka-project: --copyright=Copyright (c) Wei's Garage # nuitka-project: --assume-yes-for-downloads # nuitka-project-if: {OS} == "Windows": # nuitka-project: --windows-console-mode=disable @@ -63,7 +63,7 @@ def main(): else: try: # Use user application data directory - work_dir = user_data_dir(LAUNCHER_DIR_NAME, appauthor=False) + work_dir = user_data_dir(LAUNCHER_DIR_NAME, appauthor=False, roaming=True) os.makedirs(work_dir, exist_ok=True) except Exception as e: logger.warning("Failed to get application directory: %s", e) diff --git a/manager/account.py b/manager/account.py index dc5a45d..a9ffc24 100644 --- a/manager/account.py +++ b/manager/account.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from pathlib import Path import keyring -from PySide6.QtCore import QObject, Signal, QThread, QSize, Qt +from PySide6.QtCore import QObject, Signal, QThread, QTimer, QSize, Qt from PySide6.QtGui import QPixmap, QImage from PySide6.QtWidgets import QMessageBox from keyring.errors import KeyringError, PasswordDeleteError @@ -36,7 +36,8 @@ from core_lib.game.account import ( update_account as update_account_raw, ) from core_lib.game.authentication import get_xbox_token_and_user_hash, get_microsoft_token, get_xsts_token, \ - get_minecraft_access_token, get_account_entitlements, get_minecraft_profile, has_minecraft_license, get_device_code + get_minecraft_access_token, get_account_entitlements, get_minecraft_profile, has_minecraft_license, \ + get_device_code, refresh_microsoft_token from core_lib.game.third_party import get_minecraft_head from core_lib.qt.hack import data_url_to_qt_image from manager.widgets import AskForRequest @@ -150,6 +151,11 @@ class AccountManager(QObject): current_account_changed = Signal(object) # str | None error_occurred = Signal(str, str) replace_relative_path = Signal(str) + token_refreshed = Signal(str) + token_refresh_failed = Signal(str, str) + + TOKEN_REFRESH_INTERVAL_MS = 5 * 60 * 1000 + TOKEN_REFRESH_MARGIN = datetime.timedelta(minutes=10) def __init__(self, app, widget_manager): super().__init__(app) @@ -165,6 +171,13 @@ class AccountManager(QObject): self.login_thread: QThread | None = None self.login_worker: LoginWorker | None = None + # Refresh expiring credentials without blocking the GUI thread. + self._refresh_timer = QTimer(self) + self._refresh_timer.setInterval(self.TOKEN_REFRESH_INTERVAL_MS) + self._refresh_timer.timeout.connect(self.refresh_expiring_tokens) + self._refresh_thread: QThread | None = None + self._refresh_worker: TokenRefreshWorker | None = None + # Account head downloads. Keep thread and worker references alive until # QThread.finished so Qt does not destroy a running thread. self.head_signal = HeadSignal(self) @@ -251,6 +264,8 @@ class AccountManager(QObject): self._is_modified = False self.accounts_changed.emit() + self.start_token_refresh_daemon() + return True, copy.deepcopy(result) if return_value else None except (AccountException, AccountLoadException) as exc: self._show_error(f"Unable to load account file: {exc.user_message}", "load_failed") @@ -910,6 +925,93 @@ class AccountManager(QObject): # # Account Login, Refresh, API Handle # + @staticmethod + def _as_utc_datetime(value: str | datetime.datetime) -> datetime.datetime | None: + try: + if isinstance(value, str): + value = datetime.datetime.fromisoformat(value.replace("Z", "+00:00")) + if not isinstance(value, datetime.datetime): + return None + if value.tzinfo is None: + value = value.replace(tzinfo=datetime.timezone.utc) + return value.astimezone(datetime.timezone.utc) + except ValueError: + return None + + def start_token_refresh_daemon(self) -> None: + """Start periodic token checks and perform an initial check immediately.""" + if not self._refresh_timer.isActive(): + self._refresh_timer.start() + QTimer.singleShot(0, self.refresh_expiring_tokens) + + def stop_token_refresh_daemon(self) -> None: + self._refresh_timer.stop() + + def refresh_expiring_tokens(self) -> bool: + """Refresh one expiring MSA account; later accounts run on following ticks.""" + if self._refresh_thread is not None or not self.is_loaded: + return False + + deadline = datetime.datetime.now(datetime.timezone.utc) + self.TOKEN_REFRESH_MARGIN + for summary in self.list_accounts(): + if summary.account_type != "msa": + continue + account = self.get_account(summary.id) + if account is None: + continue + expires_at = self._as_utc_datetime(account.access_token_expires_at) + if expires_at is not None and expires_at > deadline: + continue + + refresh_token = account.refresh_token + if refresh_token == SYSTEM_KEYRING_KEYWORD and self.use_system_keyring: + refresh_token = self.get_token(account.id, REFRESH_TOKEN_KEYWORD) + if not refresh_token or refresh_token == SYSTEM_KEYRING_KEYWORD: + self.app.logger.warning("Cannot refresh account %s: refresh token is missing", account.id) + continue + + self._refresh_thread = QThread(self) + self._refresh_worker = TokenRefreshWorker(account.id, refresh_token, CLIENT_ID) + self._refresh_worker.moveToThread(self._refresh_thread) + self._refresh_thread.started.connect(self._refresh_worker.run) + self._refresh_worker.succeeded.connect(self._token_refresh_succeeded) + self._refresh_worker.failed.connect(self._token_refresh_failed) + self._refresh_worker.finished.connect(self._refresh_thread.quit) + self._refresh_worker.finished.connect(self._refresh_worker.deleteLater) + self._refresh_thread.finished.connect(self._token_refresh_finished) + self._refresh_thread.start() + return True + return False + + def _token_refresh_succeeded(self, account_id: str, result: dict) -> None: + if not self.contains(account_id): + return + updated = self.update_account( + account_id, + UpdateAccountRequest( + access_token=result["access_token"], + refresh_token=result["refresh_token"], + access_token_expires_at=result["access_token_expires_at"], + refresh_token_expires_at=result["refresh_token_expires_at"], + ), + ) + if updated and self.save(): + self.app.logger.info("Refreshed token for account %s", account_id) + self.token_refreshed.emit(account_id) + else: + self._token_refresh_failed(account_id, "Unable to persist refreshed credentials") + + def _token_refresh_failed(self, account_id: str, message: str) -> None: + self.app.logger.warning("Unable to refresh token for account %s: %s", account_id, message) + self.token_refresh_failed.emit(account_id, message) + + def _token_refresh_finished(self) -> None: + thread = self._refresh_thread + self._refresh_worker = None + self._refresh_thread = None + if thread is not None: + thread.deleteLater() + def start_login(self, client_id: str = CLIENT_ID): if self.login_thread is not None: return False @@ -1195,3 +1297,53 @@ class LoginWorker(QObject): self.login_failed.emit(f"Unexpected authentication error: {exc}") finally: self.finished.emit() + + +class TokenRefreshWorker(QObject): + """ + A simple token refresh worker. + + Refresh the complete Minecraft credential chain in a background thread. + """ + + succeeded = Signal(str, dict) + failed = Signal(str, str) + finished = Signal() + + def __init__(self, account_id: str, refresh_token: str, client_id: str): + super().__init__() + self.account_id = account_id + self.refresh_token = refresh_token + self.client_id = client_id + + def run(self) -> None: + try: + microsoft_token, refresh_token, microsoft = refresh_microsoft_token( + self.client_id, self.refresh_token + ) + xbox_token, user_hash, _ = get_xbox_token_and_user_hash(microsoft_token) + xsts_token, _ = get_xsts_token(xbox_token) + minecraft_token, minecraft = get_minecraft_access_token(user_hash, xsts_token) + + now = datetime.datetime.now(datetime.timezone.utc) + try: + expires_in = max(0, int(minecraft.get("expires_in", 86400))) + except (TypeError, ValueError): + expires_in = 86400 + try: + refresh_expires_in = max(0, int(microsoft.get("refresh_token_expires_in", 90 * 86400))) + except (TypeError, ValueError): + refresh_expires_in = 90 * 86400 + + self.succeeded.emit(self.account_id, { + "access_token": minecraft_token, + "refresh_token": refresh_token, + "access_token_expires_at": now + datetime.timedelta(seconds=expires_in), + "refresh_token_expires_at": now + datetime.timedelta(seconds=refresh_expires_in), + }) + except LauncherException as exc: + self.failed.emit(self.account_id, exc.user_message) + except Exception as exc: + self.failed.emit(self.account_id, str(exc)) + finally: + self.finished.emit() diff --git a/manager/game.py b/manager/game.py index 71fb217..998f720 100644 --- a/manager/game.py +++ b/manager/game.py @@ -1,6 +1,7 @@ import json import os import subprocess +import sys import threading import time import traceback @@ -863,6 +864,11 @@ class LaunchManager: cwd=game_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + creationflags=( + subprocess.CREATE_NO_WINDOW + if sys.platform == "win32" + else 0 + ), text=True, encoding="utf-8", errors="replace", @@ -926,4 +932,4 @@ class GameManager(QObject): self.manifest = ManifestManager(self.app) self.java = JavaManager(self.app) self.game_file = GameFileManager(self.app, manifest_manager=self.manifest) - self.launch = LaunchManager(self.app, self.manifest, self.java, self.game_file, self.account_manager) \ No newline at end of file + self.launch = LaunchManager(self.app, self.manifest, self.java, self.game_file, self.account_manager) diff --git a/pages/test.py b/pages/test.py index 8cc8d75..b73d96b 100644 --- a/pages/test.py +++ b/pages/test.py @@ -1,5 +1,6 @@ import json import subprocess +import sys import textwrap import threading import traceback @@ -827,6 +828,11 @@ class TestPage(QWidget): cwd=game_dir, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + creationflags=( + subprocess.CREATE_NO_WINDOW + if sys.platform == "win32" + else 0 + ), text=True, encoding="utf-8", errors="replace",