Now launch game process no longer blocking main thread.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
LAUNCHER_VERSION = "0.0.4-alpha"
|
LAUNCHER_VERSION = "0.0.4-alpha"
|
||||||
LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)"
|
LAUNCHER_NAME = "TestLauncher (Aka TapLauncher)"
|
||||||
|
LAUNCHER_DIR_NAME = "TestLauncher"
|
||||||
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
|
MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
|
||||||
|
|
||||||
CLIENT_ID = "30cbb657-edab-4d06-9fb7-cf5734a40088"
|
CLIENT_ID = "30cbb657-edab-4d06-9fb7-cf5734a40088"
|
||||||
MICROSOFT_VERIFY_ENDPOINT = "https://www.microsoft.com/link"
|
MICROSOFT_VERIFY_ENDPOINT = "https://www.microsoft.com/link"
|
||||||
LAUNCHER_USER_AGENT = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION} (Powered by request module)"
|
LAUNCHER_USER_AGENT = f"{LAUNCHER_NAME}/{LAUNCHER_VERSION} (Powered by request module)"
|
||||||
|
|||||||
@@ -471,10 +471,17 @@ def check_natives_exists(manifest: dict, libraries_dir: Path, natives_dir: Path)
|
|||||||
|
|
||||||
|
|
||||||
def check_full_libraries_exists(manifest: dict, libraries_dir: Path, natives_dir: Path) -> bool:
|
def check_full_libraries_exists(manifest: dict, libraries_dir: Path, natives_dir: Path) -> bool:
|
||||||
if not check_natives_exists(manifest, libraries_dir, natives_dir) or not check_libraries_exists(manifest, libraries_dir):
|
natives_exist = check_natives_exists(
|
||||||
return False
|
manifest,
|
||||||
|
libraries_dir,
|
||||||
|
natives_dir,
|
||||||
|
)
|
||||||
|
missing_libraries = check_libraries_exists(
|
||||||
|
manifest,
|
||||||
|
libraries_dir,
|
||||||
|
)
|
||||||
|
|
||||||
return True
|
return natives_exist and not missing_libraries
|
||||||
|
|
||||||
|
|
||||||
def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path):
|
def generate_classpath(manifest: dict, libraries_dir: Path, core_file_path: Path):
|
||||||
|
|||||||
@@ -18,12 +18,23 @@ import argparse
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from PySide6.QtWidgets import QApplication, QMainWindow
|
from PySide6.QtWidgets import QApplication, QMainWindow
|
||||||
|
from platformdirs import (
|
||||||
|
user_data_dir,
|
||||||
|
)
|
||||||
|
|
||||||
from app import Launcher
|
from app import Launcher
|
||||||
|
from constant import LAUNCHER_DIR_NAME
|
||||||
|
|
||||||
from core_lib.log.default import DEFAULT_FORMAT, DEFAULT_DATE_FORMAT, get_colorful_handler
|
from core_lib.log.default import DEFAULT_FORMAT, DEFAULT_DATE_FORMAT, get_colorful_handler
|
||||||
from core_lib.qt import messagebox
|
from core_lib.qt import messagebox
|
||||||
|
|
||||||
|
def show_error(message):
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("Launcher Bootstrapper")
|
||||||
|
window = QMainWindow()
|
||||||
|
messagebox.error(window, "Error", message)
|
||||||
|
app.exit()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
logger = logging.getLogger("Launcher.Main")
|
logger = logging.getLogger("Launcher.Main")
|
||||||
@@ -32,10 +43,10 @@ def main():
|
|||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("-m", "--mode", choices=['lightweight', 'full', 'choice'], default='choice',
|
parser.add_argument("-m", "--mode", choices=['lightweight', 'full', 'choice'], default='choice',
|
||||||
help="Select a mode (lightweight for TUI, full for GUI, or choice to open a mode-selection window). "
|
help="Select a mode (lightweight for TUI, full for GUI, or choice to open a mode-selection window). "
|
||||||
"TUI is not implemented yet.", required=False)
|
"TUI is not implemented yet.", required=False, deprecated=True)
|
||||||
parser.add_argument("-d", "--debug", action="store_true",
|
parser.add_argument("-d", "--debug", action="store_true",
|
||||||
help="Enable debug mode", default=False)
|
help="Enable debug mode", default=False)
|
||||||
parser.add_argument("-wd", "--work-dir", default=os.getcwd(), help="Work directory")
|
parser.add_argument("-wd", "--work-dir", default=None, help="Work directory")
|
||||||
|
|
||||||
args, unknown = parser.parse_known_args()
|
args, unknown = parser.parse_known_args()
|
||||||
for arg in unknown:
|
for arg in unknown:
|
||||||
@@ -43,21 +54,31 @@ def main():
|
|||||||
|
|
||||||
debug = args.debug
|
debug = args.debug
|
||||||
|
|
||||||
# check workdir
|
work_dir = args.work_dir
|
||||||
if not os.path.exists(args.work_dir) or not os.path.isdir(args.work_dir):
|
|
||||||
app = QApplication(sys.argv)
|
if work_dir is None:
|
||||||
app.setApplicationName("Launcher Bootstrapper")
|
if debug:
|
||||||
window = QMainWindow()
|
# Use working directory if launcher is in debug mode
|
||||||
messagebox.error(window, "Error", f"Specified work directory \"{args.work_dir}\" does not exist or"
|
work_dir = os.getcwd()
|
||||||
" is not a directory.")
|
else:
|
||||||
logger.error("Work directory '%s' does not exist. Exiting..." % args.work_dir)
|
try:
|
||||||
app.exit()
|
# Use user application data directory
|
||||||
|
work_dir = user_data_dir(LAUNCHER_DIR_NAME, appauthor=False)
|
||||||
|
os.makedirs(work_dir, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to get application directory: %s", e)
|
||||||
|
show_error("Unable to get application directory. Try specifying 'work_dir' argument.")
|
||||||
|
sys.exit(-1)
|
||||||
|
else:
|
||||||
|
# check workdir
|
||||||
|
logger.error("Work directory '%s' does not exist." % work_dir)
|
||||||
|
show_error(f"Specified work directory \"{work_dir}\" does not exist or is not a directory.")
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
|
|
||||||
if os.getcwd() != args.work_dir:
|
if os.getcwd() != args.work_dir:
|
||||||
os.chdir(args.work_dir)
|
os.chdir(work_dir)
|
||||||
|
|
||||||
logger.info("Current working directory is {}".format(args.work_dir))
|
logger.info("Current working directory is {}".format(work_dir))
|
||||||
|
|
||||||
# Init lib
|
# Init lib
|
||||||
level = logging.DEBUG if debug else logging.INFO
|
level = logging.DEBUG if debug else logging.INFO
|
||||||
|
|||||||
+321
-29
@@ -1,17 +1,20 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
import types
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Literal
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from PySide6.QtCore import QObject
|
from PySide6.QtCore import QObject, Signal
|
||||||
from PySide6.QtWidgets import QInputDialog
|
|
||||||
|
|
||||||
from constant import CLIENT_ID, LAUNCHER_VERSION
|
from constant import CLIENT_ID, LAUNCHER_VERSION
|
||||||
from core_lib.common.common import FileObject
|
from core_lib.common.common import FileObject, download_multiple_files
|
||||||
from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \
|
from core_lib.common.exception import VersionManifestFetchException, VersionManifestSaveException, \
|
||||||
NoSpecifiedVersionKeyException
|
NoSpecifiedVersionKeyException
|
||||||
from core_lib.game.argument import ArgumentMappings, generate_launch_command
|
from core_lib.game.argument import ArgumentMappings, generate_launch_command
|
||||||
@@ -24,8 +27,9 @@ from core_lib.game.version_info import fetch_and_save_version_manifest, get_spec
|
|||||||
from core_lib.runtime.java import find_java_installations
|
from core_lib.runtime.java import find_java_installations
|
||||||
from manager.account import AccountManager, LaunchAccount
|
from manager.account import AccountManager, LaunchAccount
|
||||||
from manager.profile import LaunchProfile
|
from manager.profile import LaunchProfile
|
||||||
from manager.widgets import SelectPathRequest, AskForRequest
|
from manager.widgets import SelectPathRequest, AskForRequest, SignalTransferItem
|
||||||
|
|
||||||
|
MAX_DOWNLOAD_ATTEMPTS = 3
|
||||||
|
|
||||||
class ManifestManager:
|
class ManifestManager:
|
||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
@@ -226,7 +230,7 @@ class ManifestManager:
|
|||||||
version_id: str
|
version_id: str
|
||||||
client_version_id: str
|
client_version_id: str
|
||||||
|
|
||||||
def resolve_launch_manifest(self, version_id: str, visited: set[str] | None = None) -> ResolvedVersionManifest | None:
|
def resolve_manifest(self, version_id: str, visited: set[str] | None = None) -> ResolvedVersionManifest | None:
|
||||||
visited = set() if visited is None else visited
|
visited = set() if visited is None else visited
|
||||||
|
|
||||||
if version_id in visited:
|
if version_id in visited:
|
||||||
@@ -259,7 +263,7 @@ class ManifestManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get inherits if needed
|
# Get inherits if needed
|
||||||
parent = self.resolve_launch_manifest(parent_id, visited)
|
parent = self.resolve_manifest(parent_id, visited)
|
||||||
|
|
||||||
if parent is None:
|
if parent is None:
|
||||||
return None
|
return None
|
||||||
@@ -396,6 +400,251 @@ class JavaManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
class GameFileDownloadRequest(SignalTransferItem):
|
||||||
|
def __init__(self, job_name: Literal["download_client", "download_libraries", "download_assets"],
|
||||||
|
args: tuple):
|
||||||
|
SignalTransferItem.__init__(self)
|
||||||
|
self.job_name: str = job_name
|
||||||
|
self.args: tuple = args
|
||||||
|
|
||||||
|
class GameFileManager:
|
||||||
|
def __init__(self, app, manifest_manager: ManifestManager):
|
||||||
|
self.app = app
|
||||||
|
self.widget_mgr = app.widget_manager
|
||||||
|
self.manifest_mgr = manifest_manager
|
||||||
|
self.jobs = {
|
||||||
|
"download_client": self.download_client,
|
||||||
|
"download_libraries": self.download_libraries,
|
||||||
|
"download_assets": self.download_assets,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_manifest(self, target_version: str,
|
||||||
|
manifest: dict | None, failed_message: str,
|
||||||
|
vanilla: bool=False) -> dict | None:
|
||||||
|
if manifest is None:
|
||||||
|
# Version manifest
|
||||||
|
if not vanilla:
|
||||||
|
resolved = self.manifest_mgr.resolve_manifest(target_version)
|
||||||
|
else:
|
||||||
|
resolved = self.manifest_mgr.get_cached_version_manifest(target_version)
|
||||||
|
|
||||||
|
if resolved is None:
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
failed_message,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(resolved, self.manifest_mgr.ResolvedVersionManifest):
|
||||||
|
manifest = resolved.manifest
|
||||||
|
else:
|
||||||
|
manifest = resolved
|
||||||
|
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def download_client(self, target_version: str, core_file_path, manifest: dict | None=None) -> bool:
|
||||||
|
"""
|
||||||
|
Download client
|
||||||
|
:param target_version: THIS should be the vanilla version id!
|
||||||
|
:param core_file_path:
|
||||||
|
:param manifest:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
manifest = self.get_manifest(target_version, manifest,
|
||||||
|
"Unable to download client for version {}, failed to fetch version manifest".format(target_version),
|
||||||
|
vanilla=True)
|
||||||
|
|
||||||
|
if manifest is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
callback, task_id = self.manifest_mgr.create_download_progress_callback(
|
||||||
|
"Downloading dependencies..."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_core_file(manifest, core_file_path,
|
||||||
|
raise_for_null_hash=True,
|
||||||
|
progress_callback=callback)
|
||||||
|
except Exception as e:
|
||||||
|
self.widget_mgr.download_signal.failed.emit(task_id, str(e))
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
"Unable to download client for version {}".format(target_version)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.widget_mgr.download_signal.finished.emit(task_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def download_libraries(self, target_version: str, libraries_dir: Path, natives_dir: Path, manifest: dict | None=None) -> bool:
|
||||||
|
manifest = self.get_manifest(target_version, manifest,
|
||||||
|
"Unable to download dependencies for version {}, failed to fetch version manifest.".format(
|
||||||
|
target_version)
|
||||||
|
)
|
||||||
|
if manifest is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
callback, task_id = self.manifest_mgr.create_download_progress_callback(
|
||||||
|
"Downloading dependencies..."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_full_libraries(manifest, libraries_dir, natives_dir,
|
||||||
|
progress_callback=callback,
|
||||||
|
redownload_callback=self.redownload_files)
|
||||||
|
except Exception as e:
|
||||||
|
self.widget_mgr.download_signal.failed.emit(task_id, str(e))
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
"Unable to download dependencies for version {}".format(target_version)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.widget_mgr.download_signal.finished.emit(task_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def download_assets(self, target_version: str, assets_dir: Path, manifest: dict | None = None,
|
||||||
|
allow_missing: bool = True) -> bool:
|
||||||
|
"""
|
||||||
|
Download assets
|
||||||
|
:param target_version:
|
||||||
|
:param assets_dir:
|
||||||
|
:param manifest:
|
||||||
|
:param allow_missing:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
manifest = self.get_manifest(target_version, manifest,
|
||||||
|
"Unable to download assets for version {}, failed to fetch version manifest.".format(target_version))
|
||||||
|
|
||||||
|
if manifest is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
callback, task_id = self.manifest_mgr.create_download_progress_callback(
|
||||||
|
"Downloading assets"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
fails = download_assets(
|
||||||
|
manifest,
|
||||||
|
assets_dir,
|
||||||
|
no_hash_check=True,
|
||||||
|
progress_callback=callback,
|
||||||
|
launcher_root=self.app.work_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
result, fails = self.redownload_files(fails)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
if allow_missing:
|
||||||
|
self.widget_mgr.signal.show_warning_message.emit(
|
||||||
|
"Some assets could not be downloaded. They are not required to launch the game,"
|
||||||
|
" but missing assets may cause texture issues while you play."
|
||||||
|
" (Allow missing assets flag is enabled)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
"Some assets could not be downloaded. They are not required to launch the game,"
|
||||||
|
" but missing assets may cause texture issues while you play."
|
||||||
|
" Try relaunching to download them automatically."
|
||||||
|
)
|
||||||
|
self.widget_mgr.download_signal.failed.emit(task_id, "Some assets could not be downloaded.")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
self.widget_mgr.download_signal.failed.emit(task_id, str(e))
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
"Unable to download assets for version {}".format(target_version)
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
self.widget_mgr.download_signal.finished.emit(task_id)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def start_task(self, request: GameFileDownloadRequest) -> Thread:
|
||||||
|
job_name = request.job_name
|
||||||
|
args = request.args
|
||||||
|
|
||||||
|
# Check target job function is existing
|
||||||
|
if not job_name in self.jobs:
|
||||||
|
raise NotImplementedError("Job {} not implemented".format(job_name))
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
result = self.jobs[request.job_name](*request.args)
|
||||||
|
request.result_queue.put(result)
|
||||||
|
except Exception as exc:
|
||||||
|
self.app.logger.exception("Game file task failed")
|
||||||
|
request.result_queue.put(exc)
|
||||||
|
finally:
|
||||||
|
request.wait_event.set()
|
||||||
|
|
||||||
|
thread = Thread(target=worker, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return thread
|
||||||
|
|
||||||
|
def redownload_files(self, files: list[FileObject]) -> tuple[bool, list[FileObject]]:
|
||||||
|
if not files:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
message = (
|
||||||
|
"Some files failed to download.\n"
|
||||||
|
"Would you like to retry downloading them?\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
request = AskForRequest("Redownload", message)
|
||||||
|
request.details = "\n".join(file.posix_path for file in files)
|
||||||
|
|
||||||
|
self.widget_mgr.signal.askyesno.emit(request)
|
||||||
|
|
||||||
|
if not request.wait(60):
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
"Timed out while waiting for confirmation. Please try again."
|
||||||
|
)
|
||||||
|
return False, files
|
||||||
|
|
||||||
|
result = request.result()
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return False, files
|
||||||
|
|
||||||
|
# copy
|
||||||
|
pending = list(files)
|
||||||
|
|
||||||
|
callback, task_id = self.manifest_mgr.create_download_progress_callback(f"Redownloading {len(files)} files.")
|
||||||
|
|
||||||
|
for retry_time in range(1, MAX_DOWNLOAD_ATTEMPTS + 1):
|
||||||
|
if not pending:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
self.widget_mgr.download_signal.log.emit(f"Attempt {retry_time}/{MAX_DOWNLOAD_ATTEMPTS}")
|
||||||
|
|
||||||
|
fails, successes = download_multiple_files(pending,
|
||||||
|
progress_callback=callback)
|
||||||
|
|
||||||
|
for file in successes:
|
||||||
|
self.widget_mgr.download_signal.log.emit(f"Downloaded {file.posix_path}.")
|
||||||
|
|
||||||
|
if fails:
|
||||||
|
pending = fails
|
||||||
|
else:
|
||||||
|
self.widget_mgr.download_signal.finished.emit(task_id)
|
||||||
|
pending = []
|
||||||
|
|
||||||
|
if not pending:
|
||||||
|
return True, []
|
||||||
|
|
||||||
|
failed_list = "\n".join(file.posix_path for file in pending)
|
||||||
|
self.widget_mgr.download_signal.failed.emit(
|
||||||
|
task_id,
|
||||||
|
f"{len(pending)} files still failed after retries.",
|
||||||
|
)
|
||||||
|
self.app.logger.warning(
|
||||||
|
"Unable to download these files:\n{}".format(failed_list)
|
||||||
|
)
|
||||||
|
|
||||||
|
return False, pending
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LaunchResult:
|
class LaunchResult:
|
||||||
process: subprocess.Popen | None = None
|
process: subprocess.Popen | None = None
|
||||||
@@ -414,13 +663,19 @@ class LaunchResult:
|
|||||||
self.warnings.append(message)
|
self.warnings.append(message)
|
||||||
|
|
||||||
class LaunchManager:
|
class LaunchManager:
|
||||||
def __init__(self, app, manifest_manager: ManifestManager, java_manager: JavaManager, account_manager: AccountManager):
|
def __init__(self, app, manifest_manager: ManifestManager, java_manager: JavaManager, game_file_manager: GameFileManager,
|
||||||
|
account_manager: AccountManager):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.manifest_mgr = manifest_manager
|
self.manifest_mgr = manifest_manager
|
||||||
self.widget_mgr = app.widget_manager
|
self.widget_mgr = app.widget_manager
|
||||||
self.java_mgr = java_manager
|
self.java_mgr = java_manager
|
||||||
|
self.game_file_mgr = game_file_manager
|
||||||
self.account_mgr = account_manager
|
self.account_mgr = account_manager
|
||||||
self.launched_profiles: list[str] = []
|
self.launched_profiles: list[str] = []
|
||||||
|
self.signal = self.LaunchSignal()
|
||||||
|
|
||||||
|
class LaunchSignal(QObject):
|
||||||
|
finished = Signal(object, object) # LaunchProfile, LaunchResult
|
||||||
|
|
||||||
def launch(self, profile: LaunchProfile) -> LaunchResult:
|
def launch(self, profile: LaunchProfile) -> LaunchResult:
|
||||||
result = LaunchResult()
|
result = LaunchResult()
|
||||||
@@ -428,8 +683,11 @@ class LaunchManager:
|
|||||||
version_id = profile.version_id
|
version_id = profile.version_id
|
||||||
version_id = self.manifest_mgr.resolve_version_alias(version_id)
|
version_id = self.manifest_mgr.resolve_version_alias(version_id)
|
||||||
|
|
||||||
|
if version_id is None:
|
||||||
|
return result.fail("Unable to resolve the correct version id of the profile.")
|
||||||
|
|
||||||
# Version manifest
|
# Version manifest
|
||||||
resolved = self.manifest_mgr.resolve_launch_manifest(version_id)
|
resolved = self.manifest_mgr.resolve_manifest(version_id)
|
||||||
|
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
return result.fail(
|
return result.fail(
|
||||||
@@ -461,11 +719,37 @@ class LaunchManager:
|
|||||||
|
|
||||||
# Check client
|
# Check client
|
||||||
if not core_file_path.exists():
|
if not core_file_path.exists():
|
||||||
download_core_file(version_manifest, core_file_path, raise_for_null_hash=True)
|
request = GameFileDownloadRequest(
|
||||||
|
"download_client",
|
||||||
|
(client_version_id, core_file_path, version_manifest,)
|
||||||
|
)
|
||||||
|
self.game_file_mgr.start_task(request)
|
||||||
|
|
||||||
|
request.wait(None)
|
||||||
|
|
||||||
|
client_result = request.result()
|
||||||
|
|
||||||
|
if client_result is not True:
|
||||||
|
return result.fail(
|
||||||
|
f"Unable to launch game. Client download failed."
|
||||||
|
)
|
||||||
|
|
||||||
# Libraries
|
# Libraries
|
||||||
if not check_full_libraries_exists(version_manifest, libraries_dir, natives_dir):
|
if not check_full_libraries_exists(version_manifest, libraries_dir, natives_dir):
|
||||||
download_full_libraries(version_manifest, libraries_dir, natives_dir)
|
request = GameFileDownloadRequest(
|
||||||
|
"download_libraries",
|
||||||
|
(version_id, libraries_dir, natives_dir, version_manifest)
|
||||||
|
)
|
||||||
|
self.game_file_mgr.start_task(request)
|
||||||
|
|
||||||
|
request.wait(None)
|
||||||
|
|
||||||
|
lib_result = request.result()
|
||||||
|
|
||||||
|
if lib_result is not True:
|
||||||
|
return result.fail(
|
||||||
|
"Unable to launch game due to certain dependencies download failure."
|
||||||
|
)
|
||||||
|
|
||||||
current_account_id = self.account_mgr.get_current_account_id()
|
current_account_id = self.account_mgr.get_current_account_id()
|
||||||
|
|
||||||
@@ -479,27 +763,20 @@ class LaunchManager:
|
|||||||
# Asset
|
# Asset
|
||||||
default_assets_dir = Path(self.app.work_dir, "assets")
|
default_assets_dir = Path(self.app.work_dir, "assets")
|
||||||
if not check_assets_exist(version_manifest, default_assets_dir, launcher_root=self.app.work_dir):
|
if not check_assets_exist(version_manifest, default_assets_dir, launcher_root=self.app.work_dir):
|
||||||
callback, task_id = self.manifest_mgr.create_download_progress_callback(
|
request = GameFileDownloadRequest(
|
||||||
"Downloading missing assets"
|
"download_assets",
|
||||||
|
(version_id, default_assets_dir, version_manifest)
|
||||||
)
|
)
|
||||||
|
self.game_file_mgr.start_task(request)
|
||||||
|
|
||||||
try:
|
request.wait(None)
|
||||||
download_assets(
|
|
||||||
version_manifest,
|
asset_result = request.result()
|
||||||
default_assets_dir,
|
|
||||||
no_hash_check=True,
|
if asset_result is not True:
|
||||||
progress_callback=callback,
|
return result.fail(
|
||||||
launcher_root=self.app.work_dir,
|
"Unable to launch game due to certain assets download failure."
|
||||||
)
|
)
|
||||||
except Exception as e:
|
|
||||||
self.widget_mgr.download_signal.failed.emit(task_id, str(e))
|
|
||||||
result.add_warning(
|
|
||||||
"Some assets could not be downloaded. They are not required to launch the game,"
|
|
||||||
" but missing assets may cause texture issues while you play."
|
|
||||||
" Try relaunching to download them automatically."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.widget_mgr.download_signal.finished.emit(task_id)
|
|
||||||
|
|
||||||
assets_dir = find_correct_assets_dir(asset_id, default_assets_dir, self.app.work_dir)
|
assets_dir = find_correct_assets_dir(asset_id, default_assets_dir, self.app.work_dir)
|
||||||
|
|
||||||
@@ -602,6 +879,20 @@ class LaunchManager:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def start_launch_task(self, profile: LaunchProfile) -> Thread:
|
||||||
|
def worker():
|
||||||
|
result = self.launch(profile)
|
||||||
|
self.signal.finished.emit(profile, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=worker,
|
||||||
|
name=f"launch-{profile.profile_id}",
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
return thread
|
||||||
|
|
||||||
def is_profile_running(self, profile_id):
|
def is_profile_running(self, profile_id):
|
||||||
return profile_id in self.launched_profiles
|
return profile_id in self.launched_profiles
|
||||||
|
|
||||||
@@ -623,4 +914,5 @@ class GameManager(QObject):
|
|||||||
self.widget_mgr = app.widget_manager
|
self.widget_mgr = app.widget_manager
|
||||||
self.manifest = ManifestManager(self.app)
|
self.manifest = ManifestManager(self.app)
|
||||||
self.java = JavaManager(self.app)
|
self.java = JavaManager(self.app)
|
||||||
self.launch = LaunchManager(self.app, self.manifest, self.java, self.account_manager)
|
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)
|
||||||
+101
-52
@@ -1,13 +1,13 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal, Slot, QTimer
|
||||||
from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter
|
from PySide6.QtGui import QIcon, QMouseEvent, QHideEvent, QPixmap, QPainter
|
||||||
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QFrame,
|
from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QHBoxLayout, QPushButton, QScrollArea, QFrame,
|
||||||
QSizePolicy, QApplication)
|
QSizePolicy, QApplication)
|
||||||
|
|
||||||
from manager.game import LaunchManager
|
from manager.game import LaunchManager, LaunchResult
|
||||||
from core_lib.game.version_info import LATEST_VERSION_ALIASES
|
from core_lib.game.version_info import LATEST_VERSION_ALIASES
|
||||||
from manager.profile import ProfileSummary
|
from manager.profile import ProfileSummary, LaunchProfile, ProfileManager
|
||||||
from manager.widgets import AskForRequest, WidgetManager
|
from manager.widgets import AskForRequest, WidgetManager
|
||||||
from pages.process_log import ProcessLogWindow
|
from pages.process_log import ProcessLogWindow
|
||||||
|
|
||||||
@@ -161,6 +161,9 @@ class ProfilePopup(QFrame):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def load_profiles(self, profiles: list[ProfileSummary]) -> None:
|
def load_profiles(self, profiles: list[ProfileSummary]) -> None:
|
||||||
|
# Clean old profiles
|
||||||
|
self.clean_profiles()
|
||||||
|
|
||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
icon_path = profile.icon
|
icon_path = profile.icon
|
||||||
|
|
||||||
@@ -176,6 +179,14 @@ class ProfilePopup(QFrame):
|
|||||||
item.set_icon(icon_path)
|
item.set_icon(icon_path)
|
||||||
self.content_layout.addWidget(item)
|
self.content_layout.addWidget(item)
|
||||||
|
|
||||||
|
def clean_profiles(self) -> None:
|
||||||
|
while self.content_layout.count():
|
||||||
|
layout_item = self.content_layout.takeAt(0)
|
||||||
|
widget = layout_item.widget()
|
||||||
|
|
||||||
|
if widget is not None:
|
||||||
|
widget.hide()
|
||||||
|
widget.deleteLater()
|
||||||
|
|
||||||
def hideEvent(self, event: QHideEvent) -> None:
|
def hideEvent(self, event: QHideEvent) -> None:
|
||||||
self.closed.emit()
|
self.closed.emit()
|
||||||
@@ -187,7 +198,7 @@ class LaunchProfilePage(QWidget):
|
|||||||
super(LaunchProfilePage, self).__init__(parent)
|
super(LaunchProfilePage, self).__init__(parent)
|
||||||
self.app = app
|
self.app = app
|
||||||
self.widget_mgr: WidgetManager = app.widget_manager
|
self.widget_mgr: WidgetManager = app.widget_manager
|
||||||
self.profile_manager = app.profile_manager
|
self.profile_manager: ProfileManager = app.profile_manager
|
||||||
self.game_manager = app.game_manager
|
self.game_manager = app.game_manager
|
||||||
self.launch_manager: LaunchManager = self.game_manager.launch
|
self.launch_manager: LaunchManager = self.game_manager.launch
|
||||||
self.process_log_windows: list[ProcessLogWindow] = []
|
self.process_log_windows: list[ProcessLogWindow] = []
|
||||||
@@ -271,7 +282,15 @@ class LaunchProfilePage(QWidget):
|
|||||||
|
|
||||||
self.app.apply_qss(Path("profile.qss"), self.setStyleSheet)
|
self.app.apply_qss(Path("profile.qss"), self.setStyleSheet)
|
||||||
|
|
||||||
self.load_profiles()
|
# Handle launch() result
|
||||||
|
self.launch_manager.signal.finished.connect(
|
||||||
|
self.launch_finished,
|
||||||
|
Qt.ConnectionType.QueuedConnection,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.profile_manager.profiles_changed.connect(
|
||||||
|
self.load_profiles,
|
||||||
|
)
|
||||||
|
|
||||||
class ProfileBackgroundFrame(QFrame):
|
class ProfileBackgroundFrame(QFrame):
|
||||||
def __init__(self, image_path, parent=None):
|
def __init__(self, image_path, parent=None):
|
||||||
@@ -294,6 +313,51 @@ class LaunchProfilePage(QWidget):
|
|||||||
y = (self.height() - scaled.height()) // 2
|
y = (self.height() - scaled.height()) // 2
|
||||||
painter.drawPixmap(x, y, scaled)
|
painter.drawPixmap(x, y, scaled)
|
||||||
|
|
||||||
|
@Slot(object, object)
|
||||||
|
def launch_finished(self, launch_profile: LaunchProfile, result: LaunchResult) -> None:
|
||||||
|
"""
|
||||||
|
Handle launch finished signal
|
||||||
|
:param launch_profile:
|
||||||
|
:param result:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
if not result.success:
|
||||||
|
self.status_label.setText("Launch Failed")
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
f"Unable to launch profile "
|
||||||
|
f"{launch_profile.display_name}: "
|
||||||
|
f"{result.error_message}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create log view
|
||||||
|
log_window = ProcessLogWindow(
|
||||||
|
result.process,
|
||||||
|
launch_profile.profile_id,
|
||||||
|
title=(
|
||||||
|
f"Minecraft Log - {launch_profile.display_name} "
|
||||||
|
f"({launch_profile.version_id})"
|
||||||
|
),
|
||||||
|
parent=None,
|
||||||
|
finished_callback=self.launch_manager.profile_finished,
|
||||||
|
)
|
||||||
|
|
||||||
|
for warning in result.warnings:
|
||||||
|
log_window.append_message(
|
||||||
|
f"[launcher warning] {warning}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.process_log_windows.append(log_window)
|
||||||
|
log_window.show()
|
||||||
|
|
||||||
|
self.status_label.setText("Launched!")
|
||||||
|
|
||||||
|
# Cleanup for status label
|
||||||
|
timer = QTimer(self)
|
||||||
|
timer.setSingleShot(True)
|
||||||
|
timer.timeout.connect(lambda: self.status_label.setText("Select a profile to launch"))
|
||||||
|
timer.start(3000)
|
||||||
|
|
||||||
def load_profiles(self):
|
def load_profiles(self):
|
||||||
profiles: list[ProfileSummary] = self.profile_manager.list_profiles()
|
profiles: list[ProfileSummary] = self.profile_manager.list_profiles()
|
||||||
self.profile_popup.load_profiles(profiles)
|
self.profile_popup.load_profiles(profiles)
|
||||||
@@ -363,54 +427,39 @@ class LaunchProfilePage(QWidget):
|
|||||||
self.profile_button.set_popup_open(False)
|
self.profile_button.set_popup_open(False)
|
||||||
|
|
||||||
def launch_profile(self) -> None:
|
def launch_profile(self) -> None:
|
||||||
try:
|
profile_id = self.profile_button.property("profile_id")
|
||||||
profile_id = self.profile_button.property("profile_id")
|
|
||||||
self.status_label.setText("Preparing to launch...")
|
|
||||||
launch_profile = self.app.profile_manager.get_launch_profile(profile_id)
|
|
||||||
|
|
||||||
if launch_profile is None:
|
if profile_id is None:
|
||||||
self.status_label.setText("Unable to load the selected profile.")
|
self.widget_mgr.signal.show_warning_message.emit(
|
||||||
|
"There is no profile is selected."
|
||||||
|
" Please create one first."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.status_label.setText("Preparing to launch...")
|
||||||
|
launch_profile = self.app.profile_manager.get_launch_profile(profile_id)
|
||||||
|
|
||||||
|
if launch_profile is None:
|
||||||
|
self.status_label.setText("Unable to load the selected profile.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.launch_manager.is_profile_running(launch_profile.profile_id):
|
||||||
|
request = AskForRequest(
|
||||||
|
"Launch Request",
|
||||||
|
"Target profile {} is running. Are you sure you want to launch it again?".format(
|
||||||
|
launch_profile.display_name
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.widget_mgr.signal.askyesno.emit(request)
|
||||||
|
|
||||||
|
if not request.wait(60):
|
||||||
|
self.widget_mgr.signal.show_error_message.emit(
|
||||||
|
"Time out waiting for request for launch."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.launch_manager.is_profile_running(launch_profile.profile_id):
|
if not request.result() is True:
|
||||||
request = AskForRequest(
|
return
|
||||||
"Launch Request",
|
|
||||||
"Target profile {} is running. Are you sure you want to launch it again?".format(
|
|
||||||
launch_profile.display_name
|
|
||||||
),
|
|
||||||
)
|
|
||||||
self.widget_mgr.signal.askyesno.emit(request)
|
|
||||||
|
|
||||||
if not request.wait(60):
|
self.launch_manager.start_launch_task(launch_profile)
|
||||||
self.widget_mgr.signal.show_error_message.emit(
|
# The log view window creation process has been moved to the method self.launch_finished, move to there!
|
||||||
"Time out waiting for request for launch."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not request.result() is True:
|
|
||||||
return
|
|
||||||
|
|
||||||
result = self.launch_manager.launch(launch_profile)
|
|
||||||
|
|
||||||
if result.success:
|
|
||||||
self.status_label.setText("Launched!")
|
|
||||||
log_window = ProcessLogWindow(
|
|
||||||
result.process,
|
|
||||||
launch_profile.profile_id,
|
|
||||||
title=f"Minecraft Log - {launch_profile.display_name} ({launch_profile.version_id})",
|
|
||||||
parent=None,
|
|
||||||
finished_callback=self.launch_manager.profile_finished,
|
|
||||||
)
|
|
||||||
for warning in result.warnings:
|
|
||||||
log_window.append_message(f"[launcher warning] {warning}")
|
|
||||||
self.process_log_windows.append(log_window)
|
|
||||||
log_window.show()
|
|
||||||
else:
|
|
||||||
self.widget_mgr.signal.show_error_message.emit(
|
|
||||||
"Unable to launch profile {}: {}".format(
|
|
||||||
launch_profile.display_name,
|
|
||||||
result.error_message,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self.status_label.setText("Select a profile to launch")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user