Fix subprocess will create a terminal window when Minecraft is running.

Add a daemon to refresh account token.
This commit is contained in:
wei
2026-08-03 21:48:00 +08:00
parent 48b70c12af
commit c63c46a3b3
7 changed files with 180 additions and 11 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
__version__ = "0.0.1" __version__ = "0.0.5"
__author__ = "wei" __author__ = "wei"
__email__ = "[email protected]" __email__ = "[email protected]"
__description__ = """ __description__ = """
+1 -1
View File
@@ -1,4 +1,4 @@
LAUNCHER_VERSION = "0.0.4-alpha" LAUNCHER_VERSION = "0.0.5-alpha"
LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)" LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)"
LAUNCHER_DIR_NAME = "TestLauncher" LAUNCHER_DIR_NAME = "TestLauncher"
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
+6 -1
View File
@@ -26,6 +26,11 @@ def get_java_version(java_executable_path: str | Path) -> tuple[int, str]:
[java_executable_path, "-version"], [java_executable_path, "-version"],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=(
subprocess.CREATE_NO_WINDOW
if sys.platform == "win32"
else 0
),
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", 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) installations.sort(key=lambda item: item["major"], reverse=True)
return installations, errors return installations, errors
+5 -5
View File
@@ -3,10 +3,10 @@
# nuitka-project: --enable-plugin=pyside6 # nuitka-project: --enable-plugin=pyside6
# nuitka-project: --include-data-dir={MAIN_DIRECTORY}/resources=resources # nuitka-project: --include-data-dir={MAIN_DIRECTORY}/resources=resources
# nuitka-project: --output-dir={MAIN_DIRECTORY}/build # nuitka-project: --output-dir={MAIN_DIRECTORY}/build
# nuitka-project: --output-filename=TestLauncher # nuitka-project: --output-filename=launcher
# nuitka-project: --product-name=TestLauncher # nuitka-project: --product-name=TapLauncher
# nuitka-project: --file-description=TestLauncher Minecraft Launcher # nuitka-project: --file-description=A Minecraft Launcher
# nuitka-project: --copyright=Copyright (c) wei # nuitka-project: --copyright=Copyright (c) Wei's Garage
# nuitka-project: --assume-yes-for-downloads # nuitka-project: --assume-yes-for-downloads
# nuitka-project-if: {OS} == "Windows": # nuitka-project-if: {OS} == "Windows":
# nuitka-project: --windows-console-mode=disable # nuitka-project: --windows-console-mode=disable
@@ -63,7 +63,7 @@ def main():
else: else:
try: try:
# Use user application data directory # 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) os.makedirs(work_dir, exist_ok=True)
except Exception as e: except Exception as e:
logger.warning("Failed to get application directory: %s", e) logger.warning("Failed to get application directory: %s", e)
+154 -2
View File
@@ -11,7 +11,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import keyring 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.QtGui import QPixmap, QImage
from PySide6.QtWidgets import QMessageBox from PySide6.QtWidgets import QMessageBox
from keyring.errors import KeyringError, PasswordDeleteError from keyring.errors import KeyringError, PasswordDeleteError
@@ -36,7 +36,8 @@ from core_lib.game.account import (
update_account as update_account_raw, update_account as update_account_raw,
) )
from core_lib.game.authentication import get_xbox_token_and_user_hash, get_microsoft_token, get_xsts_token, \ 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.game.third_party import get_minecraft_head
from core_lib.qt.hack import data_url_to_qt_image from core_lib.qt.hack import data_url_to_qt_image
from manager.widgets import AskForRequest from manager.widgets import AskForRequest
@@ -150,6 +151,11 @@ class AccountManager(QObject):
current_account_changed = Signal(object) # str | None current_account_changed = Signal(object) # str | None
error_occurred = Signal(str, str) error_occurred = Signal(str, str)
replace_relative_path = Signal(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): def __init__(self, app, widget_manager):
super().__init__(app) super().__init__(app)
@@ -165,6 +171,13 @@ class AccountManager(QObject):
self.login_thread: QThread | None = None self.login_thread: QThread | None = None
self.login_worker: LoginWorker | 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 # Account head downloads. Keep thread and worker references alive until
# QThread.finished so Qt does not destroy a running thread. # QThread.finished so Qt does not destroy a running thread.
self.head_signal = HeadSignal(self) self.head_signal = HeadSignal(self)
@@ -251,6 +264,8 @@ class AccountManager(QObject):
self._is_modified = False self._is_modified = False
self.accounts_changed.emit() self.accounts_changed.emit()
self.start_token_refresh_daemon()
return True, copy.deepcopy(result) if return_value else None return True, copy.deepcopy(result) if return_value else None
except (AccountException, AccountLoadException) as exc: except (AccountException, AccountLoadException) as exc:
self._show_error(f"Unable to load account file: {exc.user_message}", "load_failed") 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 # 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): def start_login(self, client_id: str = CLIENT_ID):
if self.login_thread is not None: if self.login_thread is not None:
return False return False
@@ -1195,3 +1297,53 @@ class LoginWorker(QObject):
self.login_failed.emit(f"Unexpected authentication error: {exc}") self.login_failed.emit(f"Unexpected authentication error: {exc}")
finally: finally:
self.finished.emit() 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()
+7 -1
View File
@@ -1,6 +1,7 @@
import json import json
import os import os
import subprocess import subprocess
import sys
import threading import threading
import time import time
import traceback import traceback
@@ -863,6 +864,11 @@ class LaunchManager:
cwd=game_dir, cwd=game_dir,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
creationflags=(
subprocess.CREATE_NO_WINDOW
if sys.platform == "win32"
else 0
),
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
@@ -926,4 +932,4 @@ class GameManager(QObject):
self.manifest = ManifestManager(self.app) self.manifest = ManifestManager(self.app)
self.java = JavaManager(self.app) self.java = JavaManager(self.app)
self.game_file = GameFileManager(self.app, manifest_manager=self.manifest) 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) self.launch = LaunchManager(self.app, self.manifest, self.java, self.game_file, self.account_manager)
+6
View File
@@ -1,5 +1,6 @@
import json import json
import subprocess import subprocess
import sys
import textwrap import textwrap
import threading import threading
import traceback import traceback
@@ -827,6 +828,11 @@ class TestPage(QWidget):
cwd=game_dir, cwd=game_dir,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
creationflags=(
subprocess.CREATE_NO_WINDOW
if sys.platform == "win32"
else 0
),
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",