diff --git a/app.py b/app.py index 6f90c6c..9f38566 100644 --- a/app.py +++ b/app.py @@ -5,25 +5,39 @@ from typing import Callable from PySide6 import QtWidgets from PySide6.QtCore import QSize from PySide6.QtGui import QIcon, Qt, QPixmap -from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QPushButton, QStyle, QToolButton, \ +from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QStyle, QToolButton, \ QStackedWidget -from core_lib.qt.hack import move_window_to_center, recolor_standard_icon +from core_lib.qt.hack import move_window_to_center, is_light_theme, \ + recolor_icon from core_lib.qt import messagebox +from pages.account import AccountPage from pages.test import TestPage -LAUNCHER_VERSION = "alpha_0.0.1" +LAUNCHER_VERSION = "alpha_0.0.2" MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/" class ToolBar(QtWidgets.QToolBar): def __init__(self, app: QApplication, parent=None): super(ToolBar, self).__init__(parent) self.app = app + self.spacer = QWidget() self.orientationChanged.connect(self._update_widget_size) def _update_widget_size(self, orientation): vertical = orientation == Qt.Orientation.Vertical + if vertical: + self.spacer.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Expanding, + ) + else: + self.spacer.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Preferred, + ) + for button in self.findChildren(QToolButton): if vertical: button.setToolButtonStyle( @@ -140,9 +154,13 @@ class Launcher(QApplication): # test page self.test_page = TestPage(self, self.main_window) + # Account page + self.account_page = AccountPage(self, self.main_window) + # Add page self.central.addWidget(self.home_page) self.central.addWidget(self.test_page) + self.central.addWidget(self.account_page) # Bind toolbar and center widget self.main_window.addToolBar(Qt.ToolBarArea.LeftToolBarArea, self.toolbar) @@ -155,23 +173,29 @@ class Launcher(QApplication): # Toolbar # Test Window btn - self.open_test_page = self.toolbar.add_button(" Tests", icon=QIcon(Path(self.icon_dir, "debug.png").as_posix())) + self.open_test_page = self.toolbar.add_button(" Tests", icon=self.get_icon(Path(self.icon_dir, "debug.png"))) self.open_test_page.setObjectName("open_test_page") # Home btn - self.home_button = self.toolbar.add_button(icon=QIcon(Path(self.icon_dir, "home.png").as_posix())) - self.home_button.clicked.connect( - lambda: self.central.setCurrentWidget(self.home_page) - ) + self.home_button = self.toolbar.add_button(icon=self.get_icon(Path(self.icon_dir, "home.png"))) + + # Account btn (will be moved to the bottom (or last one item) of the toolbar + self.open_account_page = self.toolbar.add_button(icon=self.get_icon(Path(self.icon_dir, "head.png"))) + + # Profile page # Bind tool buttons self.toolbar.addWidget(self.home_button) - self.toolbar.addSeparator() self.toolbar.addWidget(self.open_test_page) self.toolbar.setMovable(True) self.toolbar.setFloatable(False) self.toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) + self.toolbar.addWidget(self.toolbar.spacer) + self.toolbar.addSeparator() + self.toolbar.addWidget(self.open_account_page) + + # Bind page-related button click event self.home_button.clicked.connect( lambda: self.set_current_page(self.home_page, self.home_button) ) @@ -180,6 +204,10 @@ class Launcher(QApplication): lambda: self.set_current_page(self.test_page, self.open_test_page) ) + self.open_account_page.clicked.connect( + lambda: self.set_current_page(self.account_page, self.open_account_page) + ) + self.set_current_page(self.home_page, self.home_button) def exec(self): @@ -191,6 +219,55 @@ class Launcher(QApplication): self.central.setCurrentWidget(page) related_button.setFocus() + def get_icon(self, path: Path): + icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) + if not path.exists() or not path.is_file(): + messagebox.error( + self.main_window, + "Resource Error", + "Missing icon file: {}".format(path) + ) + + return icon + + try: + if is_light_theme() and path.name.endswith(".png"): + icon = recolor_icon( + path + ) + else: + icon = QIcon(path.as_posix()) + except Exception as e: + messagebox.error( + self.main_window, + "Resource Error", + "Failed to load icon {}: {}".format(path, e) + ) + + return icon + + def get_picture(self, path: Path, size: QSize, custom_error: str=None) -> QPixmap: + picture = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning).pixmap(size) + if not path.exists() or not path.is_file(): + messagebox.error( + self.main_window, + "Resource Error", + "Missing icon file: {}".format(path) if not custom_error else custom_error + ) + + return picture + + try: + picture = QPixmap(path.as_posix()) + except Exception as e: + messagebox.error( + self.main_window, + "Resource Error", + "Failed to load icon {}: {}".format(path, e) + ) + + return picture + @property def temp_dir(self): return self.work_dir / "temp" diff --git a/core_lib/common/common.py b/core_lib/common/common.py index 030a522..a65d6ad 100644 --- a/core_lib/common/common.py +++ b/core_lib/common/common.py @@ -200,7 +200,7 @@ def download_file(file: FileObject, overwrite=True, progress_callback: Callable[ return file -def download_multiple_files(files: List[FileObject], download_callback: Callable[str]=None, +def download_multiple_files(files: List[FileObject], download_callback: Callable[[str], None]=None, max_workers=THREADED_DOWNLOAD_MAX_WORKERS, progress_callback: Callable[str]=None) \ -> tuple[list[FileObject], list[FileObject]]: """ diff --git a/core_lib/common/exception.py b/core_lib/common/exception.py index 145bbf8..5fc2f7c 100644 --- a/core_lib/common/exception.py +++ b/core_lib/common/exception.py @@ -74,4 +74,16 @@ class RuntimeManifestFetchException(LauncherException): user_message = "Unable to fetch runtime manifest." class RuntimeManifestDataException(LauncherException): - user_message = "Runtime manifest is missing or corrupted." \ No newline at end of file + user_message = "Runtime manifest is missing or corrupted." + +class ProfileException(LauncherException): + user_message = "An error occurred while processing profile." + +class ProfileSaveException(LauncherException): + user_message = "Unable to save profile. Try again later." + +class ProfileLoadException(LauncherException): + user_message = "Unable to load profile. Profile may corrupted" + +class ProfileKeyNotFoundException(LauncherException): + user_message = "Specified profile key not found. Profile may corrupted." \ No newline at end of file diff --git a/core_lib/game/asset.py b/core_lib/game/asset.py index dff5c98..927f3dd 100644 --- a/core_lib/game/asset.py +++ b/core_lib/game/asset.py @@ -223,7 +223,7 @@ def collect_assets_from_manifest(manifest: dict, asset_id: str, assets_dir: Path def download_assets_and_copy(assets: list[FileObject], legacy_assets: list[dict], no_hash_check: bool=False, allow_missing=False, - progress_callback: Callable[str]=None, + progress_callback: Callable[[str], None]=None, redownload_callback: Callable[[list[FileObject]], tuple[bool, list[FileObject]]]=None) -> list[FileObject]: """ Download assets from target version's asset manifest. (Not sure why this function exist, may for compatibly) diff --git a/core_lib/game/profile.py b/core_lib/game/profile.py new file mode 100644 index 0000000..cd7f75c --- /dev/null +++ b/core_lib/game/profile.py @@ -0,0 +1,430 @@ +""" +This profile module also compatible with official launcher profile format +""" +import datetime +import json +import logging +import os +import uuid +from copy import deepcopy +from pathlib import Path + +from core_lib.common.exception import ProfileKeyNotFoundException, ProfileException, ProfileSaveException, \ + ProfileLoadException + +logger = logging.getLogger("Launcher.CoreLib") + +def get_profile_sample() -> dict: + return { + "lastPlayedProfileID": None, + "profiles": {}, + "settings": { + # Not all settings are included + "profileSorting": "ByLastPlayed", + "showGameLog": True, + }, + "version": 6 + } + +class Profile: + def __init__(self, version_id: str, created: datetime.datetime, + last_used: datetime.datetime | None, name: str="", icon: str | Path="", + game_dir: str | Path="", java_args: str="", resolution_raw: dict | None = None, type_raw="custom"): + self.version_id = version_id + self.created = created + self.last_used = last_used + self.name = name + self.icon: str | Path = icon + self.game_dir: Path | str = game_dir + self.java_args: str = java_args + self.resolution: dict | None = resolution_raw + self.type: str = type_raw + +def _get_current_datetime_string() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + +def _create_profile_data(version_id: str, version_type: str, java_args: str= "", game_dir: str= "", icon: str= "", name: str= "", resolution: dict=None) -> dict: + content = { + "name": name, + "created": _get_current_datetime_string(), + "gameDir": game_dir, + "lastVersionId": version_id, + "lastUsed": "", + "type": version_type, + "javaArgs": java_args, + "icon": icon, + } + + if isinstance(resolution, dict): + content["resolution"] = resolution + + return content + +def create_profile_data_from_object(p: Profile): + created: datetime.datetime | str = p.created + last_used: datetime.datetime | str | None = p.last_used + + if isinstance(created, datetime.datetime): + created = created.isoformat().replace("+00:00", "Z") + elif not isinstance(created, str): + raise TypeError("\"created\" key must be a datetime.datetime or str") + + game_dir: str | Path = p.game_dir + + if isinstance(game_dir, Path): + game_dir = game_dir.as_posix() + + icon: str | Path = p.icon + + if isinstance(icon, Path): + icon = icon.as_posix() + + content = { + "name": p.name, + "created": created, + "gameDir": game_dir, + "lastVersionId": p.version_id, + "type": p.type, + "javaArgs": p.java_args, + "icon": icon, + } + + if isinstance(last_used, datetime.datetime): + last_used = last_used.isoformat().replace("+00:00", "Z") + elif isinstance(last_used, str): + pass + elif last_used is not None: + raise TypeError("\"last_used\" key must be a datetime.datetime or str") + + if last_used: + content["lastUsed"] = last_used + + if isinstance(p.resolution, dict): + content["resolution"] = p.resolution + + return content + + + +def create_profile(version_id: str, version_type: str, java_args: str="", game_dir: str="", + icon: str="", name: str="", resolution: dict=None, profiles: dict | None=None, + max_generate_retry=10) -> tuple[dict, str]: + """ + Create a new profile with the given arguments + :param version_id: + :param version_type: + :param java_args: + :param game_dir: + :param icon: + :param name: + :param resolution: + :param profiles: If not given, this function will create a new profiles dict instead + :param max_generate_retry: + :return: + profile: dict + new_profile_id: str + """ + if profiles is None: + profiles = get_profile_sample() + + profile = _create_profile_data(version_id, version_type, java_args, game_dir, icon, name, resolution) + + for _ in range(max_generate_retry): + profile_id = str(uuid.uuid4().hex) + + if profile_id not in profiles["profiles"]: + break + else: + raise ProfileException( + "Unable to generate a unique profile ID." + ) + + profiles["profiles"][profile_id] = profile + + return profile, profile_id + +def create_profile_file(profiles: dict, profile_filepath: Path, profile_file_bak_filepath: Path | None=None, + overwrite=True, create_backup=True) -> None: + """ + Create a new profile + :param profiles: + :param profile_filepath: + :param profile_file_bak_filepath: + :param overwrite: + :param create_backup: + :return: + """ + profile_filepath.parent.mkdir(parents=True, exist_ok=True) + + if profile_filepath.exists() and not overwrite: + raise ProfileSaveException( + f"Profile file already exists: {profile_filepath}" + ) + + # Create backup path + if create_backup and profile_file_bak_filepath is None: + profile_file_bak_filepath = profile_filepath.with_suffix( + profile_filepath.suffix + ".bak" + ) + + temporary_path = profile_filepath.with_suffix( + profile_filepath.suffix + ".tmp" + ) + + try: + # Write temp profile file first + temporary_path.write_text( + json.dumps(profiles, indent=4, ensure_ascii=False), + encoding="utf-8", + ) + except Exception as e: + raise ProfileSaveException( + "Unable to create profile file: {}".format(e) + ) + + try: + if create_backup and profile_filepath.exists(): + if profile_file_bak_filepath.exists(): + profile_file_bak_filepath.unlink() + + # Rename old profile with an ".bak" extension + os.replace( + profile_filepath, + profile_file_bak_filepath, + ) + + # Rename new profile file + os.replace(temporary_path, profile_filepath) + except OSError as exc: + if (create_backup and profile_file_bak_filepath is not None and profile_file_bak_filepath.exists() + and not profile_filepath.exists()): + os.replace( + profile_file_bak_filepath, + profile_filepath, + ) + raise ProfileSaveException( + f"Unable to save profile file: {exc}" + ) from exc + except Exception as e: + raise ProfileSaveException( + "Unable to replace path from old to new profile: {}".format(e) + ) + +def read_profile_file(profile_filepath: Path) -> dict: + """ + Read profile from existing file + :param profile_filepath: + :return: + profiles: dict + """ + if not profile_filepath.exists(): + raise ProfileException( + "Profile file does not exist: {}".format(profile_filepath) + ) + + try: + with open(str(profile_filepath), "r", encoding="utf-8") as f: + return json.loads(f.read()) + except Exception as e: + raise ProfileLoadException( + "Unable to read profile file: {}".format(e) + ) + +def _check_profile(profile: dict, fix_wrong=False) -> bool: + if not isinstance(profile, dict): + return False + + profile_name = profile.get("name") + last_version_id = profile.get("lastVersionId", None) + + if not last_version_id or not isinstance(last_version_id, str): + return False + elif profile_name is None or not isinstance(profile_name, str): + return False + + resolution = profile.get("resolution", None) + + if resolution is None: + return True + + if not isinstance(resolution, dict): + if fix_wrong: + profile.pop("resolution", None) + return True + return False + + width = resolution.get("width") + height = resolution.get("height") + + if not isinstance(width, int) or isinstance(width, bool) or width <= 0: + return False + + if not isinstance(height, int) or isinstance(height, bool) or height <= 0: + return False + + return True + +def is_profiles_valid(profiles: dict, fix_wrong=False, ignore_invalid_profile=False) -> bool: + """ + Check if a profiles dictionary is valid + :param profiles: + :param fix_wrong: + :param ignore_invalid_profile: + :return: + filtered_profiles: dict + """ + if not isinstance(profiles, dict): + return False + + profiles_map = profiles.get("profiles", None) + settings = profiles.get("settings", None) + + if not isinstance(profiles_map, dict): + return False + + if not isinstance(settings, dict): + return False + + invalid_ids = [] + + for profile_id, profile in profiles_map.items(): + if not _check_profile(profile, fix_wrong=fix_wrong): + invalid_ids.append(profile_id) + + if ignore_invalid_profile: + for profile_id in invalid_ids: + del profiles_map[profile_id] + return True + + return not invalid_ids + +def list_profiles(profiles: dict) -> dict: + """ + List all profiles that inside profiles data + :param profiles: + :return: + """ + return profiles.get("profiles", {}) + +def get_profile(profiles: dict, profile_id: str) -> dict: + profile_map = list_profiles(profiles) + + try: + return profile_map[profile_id] + except KeyError as exc: + raise ProfileKeyNotFoundException( + f"Specified profile id {profile_id} not found." + ) from exc + +def add_profile(profiles: dict, profile: dict, max_generate_retry=10) -> tuple[dict, str]: + """ + Add a new profile + :param profiles: + :param profile: + :param max_generate_retry: + :return: + profiles: dict + new_profile_id: str + """ + for _ in range(max_generate_retry): + profile_id = str(uuid.uuid4().hex) + + if profile_id not in profiles["profiles"]: + break + else: + raise ProfileException( + "Unable to generate a unique profile ID." + ) + + profiles["profiles"][profile_id] = profile + + return profiles, profile_id + +def remove_profile(profiles: dict, profile_id: str): + """ + Remove a profile + :param profiles: + :param profile_id: + :return: + """ + profiles_map = list_profiles(profiles) + + if not profiles_map: + raise ProfileException( + "No profiles exists" + ) + + try: + del profiles_map[profile_id] + except KeyError: + raise ProfileKeyNotFoundException("Specified profile id {} not found.".format(profile_id)) + +def duplicate_profile(profiles: dict, profile_id: str, max_generate_retry=10) -> str: + """ + Duplicate a profile + :param profiles: + :param profile_id: + :param max_generate_retry: + :return: + new_profile_id: str + """ + profiles_map = list_profiles(profiles) + + if not profiles_map: + raise ProfileException( + "No profiles exists" + ) + + for _ in range(max_generate_retry): + new_profile_id = str(uuid.uuid4().hex) + + if new_profile_id not in profiles_map: + break + else: + raise ProfileException( + "Unable to generate a unique profile ID." + ) + + for p_id in profiles_map: + if p_id == profile_id: + profiles["profiles"][new_profile_id] = deepcopy(profiles_map[profile_id]) + return new_profile_id + + raise ProfileKeyNotFoundException( + "Specified profile id {} not found.".format(profile_id) + ) + +def convert_profile_data_to_object(profiles: dict, profile_id: str) -> Profile: + profile = get_profile(profiles, profile_id) + + created: str | datetime.datetime = profile.get("created", datetime.datetime.now()) + last_used: None | str | datetime.datetime = profile.get("lastUsed", None) + + if not isinstance(created, datetime.datetime): + try: + created = datetime.datetime.fromisoformat(created) + except ValueError: + created = datetime.datetime.now() + + if isinstance(last_used, str): + try: + last_used = datetime.datetime.fromisoformat(last_used) + except ValueError: + last_used = None + + item = Profile( + version_id=profile.get("lastVersionId", ""), + created=created, + last_used=last_used, + name=profile.get("name", ""), + icon=profile.get("icon", ""), + game_dir=profile.get("gameDir", ""), + java_args=profile.get("javaArgs", ""), + resolution_raw=profile.get("resolution", {}), + type_raw=profile.get("type", "custom"), + ) + + return item + + + diff --git a/core_lib/qt/hack.py b/core_lib/qt/hack.py index a9fb3fc..9274831 100644 --- a/core_lib/qt/hack.py +++ b/core_lib/qt/hack.py @@ -1,6 +1,8 @@ +from pathlib import Path + from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt -from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon -from PySide6.QtWidgets import QMainWindow, QApplication +from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor +from PySide6.QtWidgets import QMainWindow, QApplication, QWidget def move_window_to_center(window: QMainWindow): @@ -14,6 +16,7 @@ def is_main_thread() -> bool: app = QCoreApplication.instance() return app is not None and QThread.currentThread() == app.thread() +# ======================== AI generated ======================== def recolor_standard_icon(widget, standard_pixmap, size=24): source_icon = widget.style().standardIcon(standard_pixmap) source = source_icon.pixmap(QSize(size, size)) @@ -32,4 +35,58 @@ def recolor_standard_icon(widget, standard_pixmap, size=24): ) painter.end() - return QIcon(result) \ No newline at end of file + return QIcon(result) + +def recolor_pixmap( + path: str | Path, + color: QColor | None = None, +) -> QPixmap: + source = QPixmap(str(path)) + + if source.isNull(): + raise FileNotFoundError(f"Unable to load image: {path}") + + if color is None: + color = QApplication.palette().color( + QPalette.ColorRole.ButtonText + ) + + result = QPixmap(source.size()) + result.setDevicePixelRatio(source.devicePixelRatio()) + result.fill(Qt.GlobalColor.transparent) + + painter = QPainter(result) + painter.drawPixmap(0, 0, source) + painter.setCompositionMode( + QPainter.CompositionMode.CompositionMode_SourceIn + ) + painter.fillRect(result.rect(), color) + painter.end() + + return result + + +def recolor_icon( + path: str | Path, + color: QColor | None = None, +) -> QIcon: + return QIcon(recolor_pixmap(path, color)) + +def is_light_theme(widget=None) -> bool: + palette = ( + widget.palette() + if widget is not None + else QApplication.palette() + ) + + color = palette.color(QPalette.ColorRole.Window) + + luminance = ( + 0.2126 * color.redF() + + 0.7152 * color.greenF() + + 0.0722 * color.blueF() + ) + + return luminance >= 0.5 + +# ======================== AI generated END ======================== \ No newline at end of file diff --git a/pages/account.py b/pages/account.py new file mode 100644 index 0000000..e43fb1b --- /dev/null +++ b/pages/account.py @@ -0,0 +1,38 @@ +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel + + +class AccountPage(QWidget): + def __init__(self, app, parent=None): + QWidget.__init__(self, parent) + self.app = app + + self.layout = QVBoxLayout() + + # center message (will be deleted if launcher finished development) + content = """Still digging!""" + message = "If you found that something might be a issue. You can report it to the main repo! Any suggestion are welcome." + self.icon_label = QLabel() + self.icon_label.setObjectName("icon_label") + self.dev_info_box = QLabel(content) + self.dev_info_box.setObjectName("dev_info_box") + self.dev_info_2 = QLabel(message) + self.dev_info_2.setObjectName("dev_info_message") + + self.layout.addStretch() + self.layout.addWidget(self.icon_label, alignment=Qt.AlignmentFlag.AlignCenter) + self.layout.addWidget(self.dev_info_box, alignment=Qt.AlignmentFlag.AlignHCenter) + self.layout.addWidget(self.dev_info_2, alignment=Qt.AlignmentFlag.AlignHCenter) + self.layout.addStretch() + + if Path(self.app.resources_dir, "pictures", "in_progress.png").exists(): + image = QPixmap(Path(self.app.resources_dir, "pictures", "in_progress.png")) + self.icon_label.setPixmap(image.scaled(300, 300)) + else: + self.icon_label.setText("Ouch. The icon is missing.") + + self.setLayout(self.layout) + self.app.apply_qss(Path("main.qss"), self.setStyleSheet) \ No newline at end of file diff --git a/pages/create_profile.py b/pages/create_profile.py new file mode 100644 index 0000000..e69de29 diff --git a/pages/settings.py b/pages/settings.py new file mode 100644 index 0000000..e69de29