Add profile module.

This commit is contained in:
wei
2026-07-14 22:22:58 +08:00
parent e15bd758f3
commit e3f761a995
9 changed files with 629 additions and 15 deletions
+86 -9
View File
@@ -5,25 +5,39 @@ from typing import Callable
from PySide6 import QtWidgets from PySide6 import QtWidgets
from PySide6.QtCore import QSize from PySide6.QtCore import QSize
from PySide6.QtGui import QIcon, Qt, QPixmap 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 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 core_lib.qt import messagebox
from pages.account import AccountPage
from pages.test import TestPage 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/" MAIN_REPO_URL = "https://repo.weispace.net/wei/Launcher/"
class ToolBar(QtWidgets.QToolBar): class ToolBar(QtWidgets.QToolBar):
def __init__(self, app: QApplication, parent=None): def __init__(self, app: QApplication, parent=None):
super(ToolBar, self).__init__(parent) super(ToolBar, self).__init__(parent)
self.app = app self.app = app
self.spacer = QWidget()
self.orientationChanged.connect(self._update_widget_size) self.orientationChanged.connect(self._update_widget_size)
def _update_widget_size(self, orientation): def _update_widget_size(self, orientation):
vertical = orientation == Qt.Orientation.Vertical 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): for button in self.findChildren(QToolButton):
if vertical: if vertical:
button.setToolButtonStyle( button.setToolButtonStyle(
@@ -140,9 +154,13 @@ class Launcher(QApplication):
# test page # test page
self.test_page = TestPage(self, self.main_window) self.test_page = TestPage(self, self.main_window)
# Account page
self.account_page = AccountPage(self, self.main_window)
# Add page # Add page
self.central.addWidget(self.home_page) self.central.addWidget(self.home_page)
self.central.addWidget(self.test_page) self.central.addWidget(self.test_page)
self.central.addWidget(self.account_page)
# Bind toolbar and center widget # Bind toolbar and center widget
self.main_window.addToolBar(Qt.ToolBarArea.LeftToolBarArea, self.toolbar) self.main_window.addToolBar(Qt.ToolBarArea.LeftToolBarArea, self.toolbar)
@@ -155,23 +173,29 @@ class Launcher(QApplication):
# Toolbar # Toolbar
# Test Window btn # 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") self.open_test_page.setObjectName("open_test_page")
# Home btn # Home btn
self.home_button = self.toolbar.add_button(icon=QIcon(Path(self.icon_dir, "home.png").as_posix())) self.home_button = self.toolbar.add_button(icon=self.get_icon(Path(self.icon_dir, "home.png")))
self.home_button.clicked.connect(
lambda: self.central.setCurrentWidget(self.home_page) # 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 # Bind tool buttons
self.toolbar.addWidget(self.home_button) self.toolbar.addWidget(self.home_button)
self.toolbar.addSeparator()
self.toolbar.addWidget(self.open_test_page) self.toolbar.addWidget(self.open_test_page)
self.toolbar.setMovable(True) self.toolbar.setMovable(True)
self.toolbar.setFloatable(False) self.toolbar.setFloatable(False)
self.toolbar.setAllowedAreas(Qt.ToolBarArea.AllToolBarAreas) 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( self.home_button.clicked.connect(
lambda: self.set_current_page(self.home_page, self.home_button) 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) 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) self.set_current_page(self.home_page, self.home_button)
def exec(self): def exec(self):
@@ -191,6 +219,55 @@ class Launcher(QApplication):
self.central.setCurrentWidget(page) self.central.setCurrentWidget(page)
related_button.setFocus() 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 @property
def temp_dir(self): def temp_dir(self):
return self.work_dir / "temp" return self.work_dir / "temp"
+1 -1
View File
@@ -200,7 +200,7 @@ def download_file(file: FileObject, overwrite=True, progress_callback: Callable[
return file 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) \ max_workers=THREADED_DOWNLOAD_MAX_WORKERS, progress_callback: Callable[str]=None) \
-> tuple[list[FileObject], list[FileObject]]: -> tuple[list[FileObject], list[FileObject]]:
""" """
+12
View File
@@ -75,3 +75,15 @@ class RuntimeManifestFetchException(LauncherException):
class RuntimeManifestDataException(LauncherException): class RuntimeManifestDataException(LauncherException):
user_message = "Runtime manifest is missing or corrupted." 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."
+1 -1
View File
@@ -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], def download_assets_and_copy(assets: list[FileObject], legacy_assets: list[dict],
no_hash_check: bool=False, allow_missing=False, 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]: 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) Download assets from target version's asset manifest. (Not sure why this function exist, may for compatibly)
+430
View File
@@ -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
+59 -2
View File
@@ -1,6 +1,8 @@
from pathlib import Path
from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt from PySide6.QtCore import QCoreApplication, QThread, QSize, Qt
from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon from PySide6.QtGui import QScreen, QPixmap, QPainter, QPalette, QIcon, QColor
from PySide6.QtWidgets import QMainWindow, QApplication from PySide6.QtWidgets import QMainWindow, QApplication, QWidget
def move_window_to_center(window: QMainWindow): def move_window_to_center(window: QMainWindow):
@@ -14,6 +16,7 @@ def is_main_thread() -> bool:
app = QCoreApplication.instance() app = QCoreApplication.instance()
return app is not None and QThread.currentThread() == app.thread() return app is not None and QThread.currentThread() == app.thread()
# ======================== AI generated ========================
def recolor_standard_icon(widget, standard_pixmap, size=24): def recolor_standard_icon(widget, standard_pixmap, size=24):
source_icon = widget.style().standardIcon(standard_pixmap) source_icon = widget.style().standardIcon(standard_pixmap)
source = source_icon.pixmap(QSize(size, size)) source = source_icon.pixmap(QSize(size, size))
@@ -33,3 +36,57 @@ def recolor_standard_icon(widget, standard_pixmap, size=24):
painter.end() painter.end()
return QIcon(result) 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 ========================
+38
View File
@@ -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)
View File
View File