Fix subprocess will create a terminal window when Minecraft is running.
Add a daemon to refresh account token.
This commit is contained in:
+1
-1
@@ -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
@@ -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/"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user