From 21a010628c2b4653ed760f5dd042dd2ffab5868c Mon Sep 17 00:00:00 2001 From: ffgriever-pl <53974091+ffgriever-pl@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:24:20 +0200 Subject: [PATCH 1/4] Add FF12 basic game plugin Adds support for Final Fantasy XII The Zodiac Age Steam/PC version. --- games/ff12/Archive/Loader.py | 24 ++ games/ff12/Archive/Model.py | 293 +++++++++++++++++++ games/ff12/Archive/Reader.py | 199 +++++++++++++ games/ff12/Archive/View.py | 157 +++++++++++ games/ff12/Archive/Widget.py | 117 ++++++++ games/ff12/AutoUpdate.py | 511 ++++++++++++++++++++++++++++++++++ games/ff12/DateHelper.py | 31 +++ games/ff12/ModDataChecker.py | 80 ++++++ games/ff12/SaveGame.py | 48 ++++ games/ff12/SettingsManager.py | 36 +++ games/ff12/SteamHelper.py | 26 ++ games/game_ff12.py | 266 ++++++++++++++++++ 12 files changed, 1788 insertions(+) create mode 100644 games/ff12/Archive/Loader.py create mode 100644 games/ff12/Archive/Model.py create mode 100644 games/ff12/Archive/Reader.py create mode 100644 games/ff12/Archive/View.py create mode 100644 games/ff12/Archive/Widget.py create mode 100644 games/ff12/AutoUpdate.py create mode 100644 games/ff12/DateHelper.py create mode 100644 games/ff12/ModDataChecker.py create mode 100644 games/ff12/SaveGame.py create mode 100644 games/ff12/SettingsManager.py create mode 100644 games/ff12/SteamHelper.py create mode 100644 games/game_ff12.py diff --git a/games/ff12/Archive/Loader.py b/games/ff12/Archive/Loader.py new file mode 100644 index 0000000..672f9e3 --- /dev/null +++ b/games/ff12/Archive/Loader.py @@ -0,0 +1,24 @@ +from pathlib import Path + +from PyQt6.QtCore import QThread, pyqtSignal + +from .Reader import ArchiveReader + + +class ArchiveLoader(QThread): + """Background thread for loading archive.""" + + finished = pyqtSignal(object) + error = pyqtSignal(str) + + def __init__(self, archive_path: Path): + super().__init__() + self._archive_path = archive_path + + def run(self): + """Load archive in background thread.""" + try: + reader = ArchiveReader(self._archive_path) + self.finished.emit(reader) + except Exception as e: + self.error.emit(str(e)) diff --git a/games/ff12/Archive/Model.py b/games/ff12/Archive/Model.py new file mode 100644 index 0000000..dab2cfc --- /dev/null +++ b/games/ff12/Archive/Model.py @@ -0,0 +1,293 @@ +from enum import IntEnum, auto + +from PyQt6.QtCore import QAbstractItemModel, QFileInfo, QModelIndex, Qt +from PyQt6.QtWidgets import QFileIconProvider + +from .Reader import ArchiveReader + + +class TreeNode: + def __init__(self, name: str, parent=None, is_dir=False, size=0, entry=None): + self.name = name + self.parent = parent + self.is_dir = is_dir + self.size = size + self.children = [] + self.entry = entry + + if parent: + parent.children.append(self) + + def child_count(self) -> int: + return len(self.children) + + def child(self, row: int) -> "TreeNode | None": + if 0 <= row < len(self.children): + return self.children[row] + return None + + def row(self) -> int: + if self.parent: + return self.parent.children.index(self) + return 0 + + def path(self) -> str: + """Get the full path to this node.""" + if self.parent and self.parent.parent: + return self.parent.path() + "/" + self.name + return self.name + + def sort_children(self, column: int, order: Qt.SortOrder): + """Sort children by the specified column and order. + Directories always come before files. + Directories only reorder when column == NAME. + """ + dirs = [i for i in self.children if i.is_dir] + if column == ArchiveColumn.NAME: + dirs.sort( + key=lambda node: node.name.lower(), + reverse=(order == Qt.SortOrder.DescendingOrder), + ) + + files = [i for i in self.children if not i.is_dir] + if column == ArchiveColumn.NAME: + files.sort( + key=lambda node: node.name.lower(), + reverse=(order == Qt.SortOrder.DescendingOrder), + ) + elif column == ArchiveColumn.TYPE: + files.sort( + key=lambda node: QFileInfo(node.name).suffix().lower(), + reverse=(order == Qt.SortOrder.DescendingOrder), + ) + elif column == ArchiveColumn.SIZE: + files.sort( + key=lambda node: node.size, + reverse=(order == Qt.SortOrder.DescendingOrder), + ) + + self.children = dirs + files + for i in dirs: + i.sort_children(column, order) + + +class ArchiveColumn(IntEnum): + NAME = 0 + TYPE = auto() + SIZE = auto() + + +class ArchiveModel(QAbstractItemModel): + def __init__(self, parent=None): + super().__init__(parent) + self._reader = None + self._icon_provider = QFileIconProvider() + self._root_node = TreeNode("", None, True) + self._sort_column = ArchiveColumn.NAME + self._sort_order = Qt.SortOrder.AscendingOrder + + def set_data(self, reader: ArchiveReader): + self.beginResetModel() + self._reader = reader + self._build_tree() + self._sort_tree() + self.endResetModel() + + def _build_tree(self): + """Build the tree structure from archive entries.""" + dir_nodes: dict[str, TreeNode] = {"": self._root_node} + sorted_entries = sorted(self._reader._entries.items(), key=lambda x: x[0]) + + for name, entry in sorted_entries: + path_parts = name.split("/") + current_path = "" + current_node = self._root_node + + for i, part in enumerate(path_parts[:-1]): + if current_path: + current_path += "/" + part + else: + current_path = part + + if current_path not in dir_nodes: + dir_node = TreeNode(part, current_node, True) + dir_nodes[current_path] = dir_node + current_node = dir_node + else: + current_node = dir_nodes[current_path] + + filename = path_parts[-1] + TreeNode(filename, current_node, False, entry.original_size, entry) + + def _sort_tree(self): + """Sort the entire tree.""" + self._root_node.sort_children(self._sort_column, self._sort_order) + + def sort(self, column: int, order: Qt.SortOrder): + """Implement sorting with proper persistent index handling""" + persistent_indexes = self.persistentIndexList() + + old_nodes = [] + for index in persistent_indexes: + if index.isValid(): + old_nodes.append(index.internalPointer()) + else: + old_nodes.append(None) + + self.layoutAboutToBeChanged.emit( + [], QAbstractItemModel.LayoutChangeHint.VerticalSortHint + ) + + self._sort_column = column + self._sort_order = order + self._sort_tree() + + new_indexes = [] + for node in old_nodes: + if node is not None: + new_index = self._find_index_for_node(node) + new_indexes.append(new_index) + else: + new_indexes.append(QModelIndex()) + + for old_index, new_index in zip(persistent_indexes, new_indexes): + self.changePersistentIndex(old_index, new_index) + + self.layoutChanged.emit( + [], QAbstractItemModel.LayoutChangeHint.VerticalSortHint + ) + + def _find_index_for_node(self, target_node: TreeNode) -> QModelIndex: + """Find the QModelIndex for a specific TreeNode after sorting""" + if target_node == self._root_node: + return QModelIndex() + + path = [] + current = target_node + while current and current != self._root_node: + path.append(current) + current = current.parent + + current_index = QModelIndex() + for node in reversed(path): + parent_node = node.parent if node.parent else self._root_node + row = parent_node.children.index(node) + current_index = self.index(row, 0, current_index) + + return current_index + + def rowCount(self, parent: QModelIndex) -> int: + if not parent.isValid(): + parent_node = self._root_node + else: + parent_node = parent.internalPointer() + + return parent_node.child_count() + + def columnCount(self, parent: QModelIndex) -> int: + return len(ArchiveColumn) + + def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex: + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if not parent.isValid(): + parent_node = self._root_node + else: + parent_node = parent.internalPointer() + + child_node = parent_node.child(row) + if child_node: + return self.createIndex(row, column, child_node) + + return QModelIndex() + + def parent(self, index: QModelIndex) -> QModelIndex: + if not index.isValid(): + return QModelIndex() + + child_node = index.internalPointer() + parent_node = child_node.parent + + if parent_node == self._root_node or parent_node is None: + return QModelIndex() + + return self.createIndex(parent_node.row(), 0, parent_node) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid(): + return None + + node = index.internalPointer() + column = index.column() + + if role == Qt.ItemDataRole.DisplayRole: + if column == ArchiveColumn.NAME: + return node.name + elif column == ArchiveColumn.TYPE: + if node.is_dir: + return "Folder" + else: + file_info = QFileInfo(node.name) + return file_info.suffix() + elif column == ArchiveColumn.SIZE: + if node.is_dir: + return "" + else: + return self._format_file_size(node.size) + + elif role == Qt.ItemDataRole.DecorationRole and column == ArchiveColumn.NAME: + if node.is_dir: + return self._icon_provider.icon(QFileIconProvider.IconType.Folder) + else: + file_info = QFileInfo(node.name) + return self._icon_provider.icon(file_info) + + return None + + def headerData( + self, + section: int, + orientation: Qt.Orientation, + role: int = Qt.ItemDataRole.DisplayRole, + ) -> str | None: + if ( + orientation != Qt.Orientation.Horizontal + or role != Qt.ItemDataRole.DisplayRole + ): + return None + + match ArchiveColumn(section): + case ArchiveColumn.NAME: + return "Name" + case ArchiveColumn.TYPE: + return "Type" + case ArchiveColumn.SIZE: + return "Size" + + return None + + def get_node(self, index: QModelIndex) -> TreeNode | None: + """Get the TreeNode for a given index.""" + if index.isValid(): + return index.internalPointer() + return None + + @staticmethod + def _format_file_size(size: int) -> str: + """Format file size (B, KB, MB, GB) with proper fallback.""" + if size < 1024: + return f"{size} B" + + size_f = float(size) + for unit in ["KB", "MB", "GB"]: + size_f /= 1024.0 + if size_f < 1024.0: + if size_f < 10: + return f"{size_f:.2f} {unit}" + elif size_f < 100: + return f"{size_f:.1f} {unit}" + else: + return f"{size_f:.0f} {unit}" + + return f"{size_f:.2f} GB" diff --git a/games/ff12/Archive/Reader.py b/games/ff12/Archive/Reader.py new file mode 100644 index 0000000..82b70d8 --- /dev/null +++ b/games/ff12/Archive/Reader.py @@ -0,0 +1,199 @@ +import io +import struct +import zlib +from pathlib import Path +from typing import BinaryIO, Dict, List + +from PyQt6.QtCore import qWarning + + +class ArchiveEntry: + def __init__(self, original_size: int, data_offset: int, block_sizes: List[int]): + self.original_size = original_size + self.data_offset = data_offset + self.block_sizes = block_sizes + + +class ArchiveReader: + _MAX_BLOCK_SIZE = 64 * 1024 + + def __init__(self, path: Path): + self._filename = path + self._entries: Dict[str, ArchiveEntry] = {} + self._file_handle: BinaryIO | None = None + + self._load_metadata() + + def open(self) -> bool: + """ + Open the archive for reading and store the file handle. + Returns True if the file was successfully opened or already open, False otherwise. + """ + if self._file_handle is not None: + return True + + try: + self._file_handle = open(self._filename, "rb") + return True + except Exception as e: + qWarning(f"Failed to open archive: {e}") + return False + + def close(self): + """Close the file handle.""" + if self._file_handle is not None: + self._file_handle.close() + self._file_handle = None + + def __enter__(self): + """Enable 'with' statement to automatically open and close the file handle.""" + self.open() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Enable 'with' statement to automatically open and close the file handle.""" + self.close() + + def _load_metadata(self): + """Load the archive metadata""" + with self as file: + file = file._file_handle + try: + header_data = file.read(16) + if len(header_data) < 16: + raise EOFError("File too short for header") + + magic, header_size, file_count = struct.unpack(" List[tuple]: + """Read metadata for file entries""" + file_struct = struct.Struct(" bytes: + """Read data for file paths""" + size_data = file.read(4) + if len(size_data) < 4: + raise EOFError("Unexpected EOF reading file path data size") + + size = struct.unpack(" int: + """Calculate total number of blocks across all files""" + count = 0 + for _, original_size, _, _ in file_metadata: + blocks = original_size // self._MAX_BLOCK_SIZE + if original_size % self._MAX_BLOCK_SIZE != 0: + blocks += 1 + count += blocks + return count + + def _read_block_sizes(self, file: BinaryIO, count: int) -> List[int]: + """Read block sizes for all files""" + data = file.read(count * 2) + if len(data) < count * 2: + raise ValueError("Unexpected EOF reading block sizes") + + return list(struct.unpack(f"<{count}H", data)) + + def unpack_file(self, file_path: str, out_path: str): + """Unpack a file from the archive""" + if self._file_handle is None: + raise ValueError("Archive file handle is not open") + + entry = self._entries.get(file_path) + if entry is None: + raise FileNotFoundError(f"File '{file_path}' not found in archive entries") + + self._file_handle.seek(entry.data_offset, io.SEEK_SET) + block_count = 0 + + with open(out_path, "wb") as out_file: + for block_size in entry.block_sizes: + if block_size == 0: + block_size = self._MAX_BLOCK_SIZE + + block_data = self._file_handle.read(block_size) + if len(block_data) < block_size: + raise EOFError("Unexpected EOF reading data block") + + is_last_block = block_count == len(entry.block_sizes) - 1 + remaining_size = entry.original_size % self._MAX_BLOCK_SIZE + + if block_size == self._MAX_BLOCK_SIZE or ( + is_last_block and block_size == remaining_size + ): + out_file.write(block_data) + else: + decompressed_data = zlib.decompress(block_data) + out_file.write(decompressed_data) + + block_count += 1 + + @staticmethod + def _read_null_string_lower(data: bytes, offset: int) -> str: + """Read a null-terminated string from byte data and convert to lowercase.""" + end = data.find(b"\0", offset) + if end == -1: + end = len(data) + + return data[offset:end].decode("utf-8", errors="replace").lower() + + +def get_archives(folder: Path) -> list[Path]: + """Return a list of all archives in the given folder.""" + if not folder.exists() or not folder.is_dir(): + return [] + + return list(folder.glob("*.vbf")) diff --git a/games/ff12/Archive/View.py b/games/ff12/Archive/View.py new file mode 100644 index 0000000..30b77c7 --- /dev/null +++ b/games/ff12/Archive/View.py @@ -0,0 +1,157 @@ +from pathlib import Path + +from PyQt6.QtCore import QCoreApplication, QModelIndex, QStandardPaths, Qt +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import ( + QFileDialog, + QMenu, + QMessageBox, + QProgressDialog, + QTreeView, + QWidget, +) + +from .Model import ArchiveColumn, TreeNode + + +class ArchiveView(QTreeView): + def __init__(self, parent: QWidget | None): + super().__init__(parent) + + self.setRootIndex(QModelIndex()) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + self.setSelectionMode(QTreeView.SelectionMode.ExtendedSelection) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_context_menu) + self._last_export_dir = None + self._model = None + + def _setup_view(self): + """Setup view appearance and default sorting.""" + if self._model is None: + return + + self.setColumnWidth(ArchiveColumn.NAME, 290) + self.setColumnWidth(ArchiveColumn.TYPE, 90) + self.setColumnWidth(ArchiveColumn.SIZE, 90) + self.sortByColumn(ArchiveColumn.NAME, Qt.SortOrder.AscendingOrder) + + def setModel(self, model): + """Setup view after model is set.""" + super().setModel(model) + self._model = model + self._setup_view() + + def _show_context_menu(self, position): + """Show context menu at the given position.""" + if self._model is None: + return + + menu = QMenu(self) + + selected_indexes = self._get_selected_indexes() + if selected_indexes: + export_action = QAction("Export", self) + export_action.triggered.connect( + lambda: self._export_selection(selected_indexes) + ) + menu.addAction(export_action) + + if menu.actions(): + menu.exec(self.mapToGlobal(position)) + + def _export_selection(self, indexes): + start_dir = self._last_export_dir or QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DesktopLocation + ) + + export_dir = QFileDialog.getExistingDirectory( + self, "Choose Export Directory", start_dir + ) + if not export_dir: + return + + self._last_export_dir = export_dir + export_path = Path(export_dir) + + files_to_export = self._get_selected_files(indexes) + if not files_to_export: + QMessageBox.information(self, "Export", "No files to export.") + return + + progress = QProgressDialog( + "Exporting files...", "Cancel", 0, len(files_to_export), self + ) + progress.setWindowModality(Qt.WindowModality.WindowModal) + progress.show() + + exported_count = 0 + failed_files = [] + + with self._model._reader as reader: + for i, file_node in enumerate(files_to_export): + if progress.wasCanceled(): + break + + progress.setLabelText(f"Exporting: {file_node.name}") + progress.setValue(i) + QCoreApplication.processEvents() + + try: + relative_path = file_node.path() + output_file = export_path / Path(relative_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + + reader.unpack_file(relative_path, output_file) + + exported_count += 1 + except Exception as e: + failed_files.append(f"{relative_path} ({str(e)})") + + progress.close() + if failed_files: + log_path = export_path / "export.log" + with open(log_path, "w", encoding="utf-8") as log_file: + log_file.write("\n".join(failed_files)) + + total_count = len(files_to_export) + fail_count = len(failed_files) + QMessageBox.warning( + self, + "Export Complete", + f"Only {total_count - fail_count} of {total_count} files were exported successfully.\n" + f"See log for details: {log_path}", + ) + else: + QMessageBox.information( + self, + "Export Complete", + f"Successfully exported {exported_count} file(s) to:\n{export_path}", + ) + + def _get_selected_indexes(self) -> list[QModelIndex]: + indexes = self.selectionModel().selectedIndexes() + + rows = {} + for index in indexes: + if index.column() == 0: + rows[index.row()] = index + + return list(rows.values()) + + def _get_selected_files(self, indexes) -> list[TreeNode]: + files = [] + + def collect(node): + if node.is_dir: + for child in node.children: + collect(child) + else: + files.append(node) + + for index in indexes: + node = self._model.get_node(index) + if node: + collect(node) + return files diff --git a/games/ff12/Archive/Widget.py b/games/ff12/Archive/Widget.py new file mode 100644 index 0000000..7f54114 --- /dev/null +++ b/games/ff12/Archive/Widget.py @@ -0,0 +1,117 @@ +from pathlib import Path + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from .Loader import ArchiveLoader +from .Model import ArchiveModel +from .Reader import ArchiveReader, get_archives +from .View import ArchiveView + + +class ArchiveContainerWidget(QWidget): + def __init__(self, content_path: Path, parent: QWidget | None = None): + super().__init__(parent) + self._content_path = content_path + self._current_content: ArchiveContentWidget | None = None + self._loader = None + + v_layout = QVBoxLayout(self) + v_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + h_layout = QHBoxLayout() + label = QLabel("File") + h_layout.addWidget(label) + + self._combo_box = QComboBox(self) + self._combo_box.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + h_layout.addWidget(self._combo_box) + v_layout.addLayout(h_layout) + + self._content = QVBoxLayout() + v_layout.addLayout(self._content) + + self._combo_box.currentIndexChanged.connect(self._load_selected) + + def _reset_combo_box(self): + self._combo_box.blockSignals(True) + self._combo_box.clear() + for p in sorted(get_archives(self._content_path)): + self._combo_box.addItem(p.name, userData=p) + self._combo_box.blockSignals(False) + + def _clear_current_content(self): + if self._loader and self._loader.isRunning(): + self._loader.finished.disconnect() + self._loader.error.disconnect() + self._loader.quit() + self._loader = None + + if self._current_content: + self._current_content.setParent(None) + self._current_content.deleteLater() + self._current_content = None + + def _load_selected(self): + archive_path: Path = self._combo_box.currentData() + if not archive_path: + return + + self._clear_current_content() + self._current_content = ArchiveContentWidget() + self._content.addWidget(self._current_content) + + self._loader = ArchiveLoader(archive_path) + self._loader.finished.connect(self._on_load_finished_callback) + self._loader.error.connect(self._on_load_error_callback) + self._loader.start() + + def _on_load_finished_callback(self, reader: ArchiveReader): + """Called when archive loading is complete.""" + if self._current_content: + self._current_content.load_data(reader) + + if self._loader: + self._loader.deleteLater() + self._loader = None + + def _on_load_error_callback(self, error_msg): + """Called when archive loading fails.""" + if self._loader: + self._loader.deleteLater() + self._loader = None + + def showEvent(self, event): + super().showEvent(event) + self._reset_combo_box() + if self._combo_box.count() > 0 and not self._current_content: + self._load_selected() + + def hideEvent(self, event): + super().hideEvent(event) + self._clear_current_content() + + +class ArchiveContentWidget(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + + self._model = ArchiveModel() + self._view = ArchiveView(self) + self._view.setModel(self._model) + + self._layout = QVBoxLayout(self) + self._layout.addWidget(self._view) + self.setLayout(self._layout) + + def load_data(self, reader: ArchiveReader): + self._model.set_data(reader) diff --git a/games/ff12/AutoUpdate.py b/games/ff12/AutoUpdate.py new file mode 100644 index 0000000..7b8cbac --- /dev/null +++ b/games/ff12/AutoUpdate.py @@ -0,0 +1,511 @@ +import errno +import json +import os +import re +import shutil +import socket +import sys +import tempfile +import urllib.request +import zipfile +from typing import Callable + +from PyQt6.QtCore import ( + QDateTime, + QObject, + pyqtSignal, + qInfo, + qWarning, +) +from PyQt6.QtWidgets import ( + QApplication, + QDialog, + QDialogButtonBox, + QLabel, + QMainWindow, + QMessageBox, + QTextBrowser, + QVBoxLayout, +) + +import mobase + +from .DateHelper import get_date_from_iso, get_date_time_from_iso + + +class UpdateChecker(QObject): + """ + UpdateChecker is a QObject-based class that manages update checking, notification, and installation for a plugin or application using GitHub releases. + + Methods: + on_update_installed(callback): Registers a callback for when an update is installed. + on_update_remind(callback): Registers a callback for when the user opts to be reminded later. + on_version_skipped(callback): Registers a callback for when the user skips the current version. + check_for_update(skip_version=None): Checks GitHub for available updates and prompts the user if a new version is found. + + Usage: + Instantiate UpdateChecker with the required parameters, set needed callbacks, and call check_for_update(). + """ + + update_installed = pyqtSignal() + update_remind = pyqtSignal(int) + version_skipped = pyqtSignal(str) + + def __init__( + self, + name: str, + repo_owner: str, + repo_name: str, + major: int, + minor: int, + patch: int, + release_type: int, + parent: QMainWindow = None, + update_targets: list[str] = None, + remove_targets: list[str] = None, + skip_version: str = None, + plugin_dir: str = None, + ): + """ + Initializes the AutoUpdate class with plugin and repository information. + + Args: + name (str): The name of the plugin. + repo_owner (str): The owner of the repository. + repo_name (str): The name of the repository. + major (int): Major version number of the current plugin. + minor (int): Minor version number of the current plugin. + patch (int): Patch version number of the current plugin. + release_type (int): The type of release (e.g. mobase.ReleaseType.BETA). + parent (QMainWindow, optional): The parent window for UI integration. Defaults to None. + update_targets (optional): List of targets to update. Defaults to None. + remove_targets (optional): List of targets to remove. Defaults to None. + skip_version (optional): Version to skip during update checks. Defaults to None. + plugin_dir (optional): Directory where the plugin is located. Defaults to None. + """ + super().__init__() + self.name = name + self.repo_owner = repo_owner + self.repo_name = repo_name + self.current_version = (major, minor, patch) + self.release_type = release_type + self.parentWindow = parent + self.update_targets = update_targets + self.remove_targets = remove_targets + self.skip_version = skip_version + self.plugin_dir = plugin_dir + + def on_update_installed(self, callback: Callable[[], None]): + """ + Registers a callback function to be invoked when an update is successfully installed. + + Args: + callback (Callable[[], None]): The function to be called when an update is successfully installed. + """ + self.update_installed.connect(callback) + + def on_update_remind(self, callback: Callable[[int], None]): + """ + Registers a callback to be invoked when the user opts to be reminded later about an available update. + + Args: + callback (Callable[[int], None]): The function to be called when the a remind me later button is clicked. + """ + self.update_remind.connect(callback) + + def on_version_skipped(self, callback: Callable[[str], None]): + """ + Registers a callback to be invoked when the user skips the current version. + + Args: + callback (Callable[[str], None]): The function to be called when a skip version button is clicked. + """ + self.version_skipped.connect(callback) + + def _get_releases(self): + url = ( + f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/releases" + ) + try: + with urllib.request.urlopen(url, timeout=10) as response: + data = response.read() + except urllib.error.HTTPError as e: + if e.code == 404: + raise Exception( + f"GitHub repository {self.repo_owner}/{self.repo_name} not found (404)." + ) + elif e.code == 403: + raise Exception( + "GitHub API rate limit exceeded (403). Please try again later." + ) + else: + raise Exception(f"HTTP error occurred: {e.code} {e.reason}") + except urllib.error.URLError as e: + raise Exception( + f"Network error: {e.reason}. Server unavailable or internet connection issue." + ) + except socket.timeout: + raise Exception("Connection timed out while trying to fetch releases.") + + releases = json.loads(data) + return releases + + def _parse_version(self, tag): + # Handles tags like v1.2.3, 1.2.3, v1.2.3-suffix, 1.2.3-suffix + tag = tag.lstrip("v") + # Remove any suffix after patch number before checking version + main_part = tag.split("-")[0] + parts = main_part.split(".") + if len(parts) < 3: + return None + try: + return tuple(int(p) for p in parts[:3]) + except Exception: + return None + + def _is_newer(self, v1, v2): + if v2 is None: + return True + if v1 is None: + return False + return v1 > v2 + + def check_for_update(self, skip_version=None): + """ + Checks GitHub for available updates and prompts the user if a new version is found. + + Args: + skip_version (str, optional): Version to skip during update checks. If none, the value passed to constructor will be used. + """ + # GitHub API has 60 requests per hour limit for unauthenticated requests. + # So let's not make a big fuss about it and handle errors gracefully. + try: + releases = self._get_releases() + except Exception as e: + qInfo(f"Failed to fetch releases: {e}") + return + include_prerelease = self.release_type != mobase.ReleaseType.FINAL + latest = None + skip_version_val = ( + skip_version if skip_version is not None else self.skip_version + ) + + for rel in releases: + if not include_prerelease and rel.get("prerelease", False): + continue + tag = rel.get("tag_name", "") + ver = self._parse_version(tag) + if ver and self._is_newer(ver, self.current_version): + if latest is None or self._is_newer( + ver, self._parse_version(latest["tag_name"]) + ): + latest = rel + + if latest: + latest_ver = self._parse_version(latest.get("tag_name")) + skip_ver = self._parse_version(skip_version_val) + if not self._is_newer(latest_ver, skip_ver): + self._log_skip_update() + else: + self._show_update_dialog(latest) + else: + self._log_no_update() + + _pr_pattern = re.compile(r"(? str: + return self._pr_pattern.sub( + lambda m: ( + f"[#{m.group(1)}](https://github.com/{self.repo_owner}/{self.repo_name}/pull/{m.group(1)})" + ), + text, + ) + + def _collect_changelogs(self, latest_release): + try: + all_releases = self._get_releases() + include_prerelease = self.release_type != mobase.ReleaseType.FINAL + current_ver = self.current_version + changelogs = [] + for rel in all_releases: + if not include_prerelease and rel.get("prerelease", False): + continue + tag = rel.get("tag_name", "") + ver = self._parse_version(tag) + if ver and self._is_newer(ver, current_ver): + body = rel.get("body", "No patch notes.") + body = self._make_pr_links(body) + changelogs.append((ver, tag, body, rel.get("published_at", ""))) + changelogs.sort(reverse=True) + notes_md = "" + for i, (ver, tag, body, published_at) in enumerate(changelogs): + notes_md += f"## Changes in {tag} []() Date: {get_date_from_iso(published_at)} ([commits](https://github.com/{self.repo_owner}/{self.repo_name}/commits/{tag}))\n{body}" + if i < len(changelogs) - 1: + notes_md += "\n***\n" + else: + notes_md += "\n" + if not notes_md: + body = latest_release.get("body", "No patch notes.") + body = self._make_pr_links(body) + notes_md = f"## Changes in {latest_release.get('tag_name', '')} []() Date: {get_date_from_iso(latest_release.get('published_at', ''))} ([commits](https://github.com/{self.repo_owner}/{self.repo_name}/commits/{latest_release.get('tag_name', '')}))\n{body}\n" + return notes_md + except Exception as e: + qWarning(f"Failed to collect changelogs: {e}") + return "## Error collecting changelogs\nAn error occurred while fetching the changelogs. Please check the log for details." + + def _create_update_dialog( + self, notes_md, current_version, latest_tag, latest_date_str + ): + pluginName = self.name or "Plugin" + + class UpdateDialog(QDialog): + skip_update = pyqtSignal() + remind_later = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(f"{pluginName} Update Available") + self.setMinimumSize(500, 400) + layout = QVBoxLayout(self) + infoLabel = QLabel(f"A new version of {pluginName} is available!") + infoLabel.setStyleSheet("font-weight: bold; font-size: 14pt;") + layout.addWidget(infoLabel) + versionLabel = QLabel( + f"Current version: {current_version}\nNew version: {latest_tag} ({latest_date_str})\n\nPatch notes:" + ) + layout.addWidget(versionLabel) + browser = QTextBrowser() + browser.setMarkdown(notes_md) + browser.setOpenExternalLinks(True) + layout.addWidget(browser) + button_box = QDialogButtonBox() + update_btn = button_box.addButton( + "Update now", QDialogButtonBox.ButtonRole.AcceptRole + ) + remind_btn = button_box.addButton( + "Remind me later", QDialogButtonBox.ButtonRole.DestructiveRole + ) + skip_btn = button_box.addButton( + "Skip this version", QDialogButtonBox.ButtonRole.RejectRole + ) + cancel_btn = button_box.addButton( + "Cancel", QDialogButtonBox.ButtonRole.RejectRole + ) + layout.addWidget(button_box) + update_btn.clicked.connect(self.accept) + remind_btn.clicked.connect(self.remind_later.emit) + skip_btn.clicked.connect(self.skip_update.emit) + cancel_btn.clicked.connect(self.close) + + return UpdateDialog + + def _connect_update_dialog(self, dialog, latest_release, latest_tag): + def on_accept(): + self._download_and_update(latest_release) + dialog.close() + + def on_skip(): + self.version_skipped.emit(latest_tag) + dialog.close() + + def on_remind(): + remind_time = QDateTime.currentDateTime().addDays(1).toSecsSinceEpoch() + self.update_remind.emit(remind_time) + dialog.close() + + dialog.accepted.connect(on_accept) + dialog.skip_update.connect(on_skip) + dialog.remind_later.connect(on_remind) + + def _show_update_dialog(self, latest_release): + notes_md = self._collect_changelogs(latest_release) + current_version = f"v{self.current_version[0]}.{self.current_version[1]}.{self.current_version[2]}" + latest_tag = latest_release.get("tag_name", "") + latest_date_str = get_date_time_from_iso(latest_release.get("published_at", "")) + _app = QApplication.instance() or QApplication(sys.argv) + UpdateDialog = self._create_update_dialog( + notes_md, current_version, latest_tag, latest_date_str + ) + dialog = UpdateDialog(parent=self.parentWindow) + dialog.activateWindow() + dialog.raise_() + self._connect_update_dialog(dialog, latest_release, latest_tag) + dialog.show() + + def _log_no_update(self): + qInfo(f"No updates available for {self.name}.") + + def _log_skip_update(self): + qInfo(f"Skipped update for {self.name}.") + + def _download_and_update(self, release): + asset = self._find_zip_asset(release) + if not asset: + self._show_error("No zip asset found in release.") + return + tmpdir = tempfile.mkdtemp() + zip_path = os.path.join(tmpdir, asset["name"]) + backup_dir = os.path.join(tmpdir, "backup") + try: + self._download_asset(asset["browser_download_url"], zip_path) + found_targets = self._extract_update_files(zip_path, tmpdir) + missing = [t for t in self.update_targets if t not in found_targets] + if missing: + self._show_error(f"Update package missing: {', '.join(missing)}") + return + self._backup_targets(backup_dir) + try: + if not self._replace_plugin_files(found_targets): + self._show_error( + "Failed to replace plugin files, but no changes were made." + ) + return + except Exception as e: + # Attempt restore if replacement fails + try: + self._restore_targets(backup_dir) + except Exception as restore_exc: + self._show_error( + f"Update failed: {e}\nRestore also failed: {restore_exc}\nPlease copy files manually." + ) + self._open_dirs_for_manual_restore(backup_dir) + return + self._show_error(f"Update failed: {e}\nChanges have been reverted.") + return + self._show_restart_dialog() + self.update_installed.emit() + except Exception as e: + self._show_error(f"Update failed: {e}") + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + + def _backup_targets(self, backup_dir): + os.makedirs(backup_dir, exist_ok=True) + plugin_dir = self.plugin_dir + unique_targets = set((self.update_targets or []) + (self.remove_targets or [])) + for target in unique_targets: + src_path = os.path.join(plugin_dir, target) + dst_path = os.path.join(backup_dir, target) + if os.path.exists(src_path): + if os.path.isdir(src_path): + shutil.copytree(src_path, dst_path) + else: + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + shutil.copy2(src_path, dst_path) + + def _restore_targets(self, backup_dir): + plugin_dir = self.plugin_dir + for root, dirs, files in os.walk(backup_dir): + rel_root = os.path.relpath(root, backup_dir) + dest_root = ( + os.path.join(plugin_dir, rel_root) if rel_root != "." else plugin_dir + ) + for d in dirs: + src_dir = os.path.join(root, d) + dest_dir = os.path.join(dest_root, d) + if os.path.exists(dest_dir): + shutil.rmtree(dest_dir, ignore_errors=True) + shutil.copytree(src_dir, dest_dir) + for f in files: + src_file = os.path.join(root, f) + dest_file = os.path.join(dest_root, f) + os.makedirs(os.path.dirname(dest_file), exist_ok=True) + shutil.copy2(src_file, dest_file) + + def _open_dirs_for_manual_restore(self, backup_dir): + plugin_dir = self.plugin_dir + try: + os.startfile(plugin_dir) + os.startfile(backup_dir) + except Exception: + pass + + def _find_zip_asset(self, release): + for a in release.get("assets", []): + if a.get("name", "").endswith(".zip"): + return a + return None + + def _download_asset(self, url, zip_path): + try: + with ( + urllib.request.urlopen(url, timeout=10) as response, + open(zip_path, "wb") as out_file, + ): + shutil.copyfileobj(response, out_file) + except urllib.error.HTTPError as e: + if e.code == 404: + raise Exception(f"Download URL not found (404): {url}") + elif e.code == 403: + raise Exception( + "Rate limit exceeded or access denied. Please try again later." + ) + else: + raise Exception( + f"HTTP error occurred while downloading asset: {e.code} {e.reason}" + ) + except urllib.error.URLError as e: + raise Exception( + f"Network error while downloading asset: {e.reason}. Server unavailable or internet connection issue." + ) + except socket.timeout: + raise Exception("Connection timed out while trying to download asset.") + + def _extract_update_files(self, zip_path, tmpdir): + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(tmpdir) + found_targets = {} + for root, dirs, files in os.walk(tmpdir): + for target in self.update_targets: + if target in files: + found_targets[target] = os.path.join(root, target) + if target in dirs: + found_targets[target] = os.path.join(root, target) + return found_targets + + def _replace_plugin_files(self, found_targets) -> bool: + plugin_dir = self.plugin_dir + changes_done = False + try: + os.makedirs(plugin_dir, exist_ok=True) + # Remove targets first + for target in self.remove_targets: + target_path = os.path.join(plugin_dir, target) + if os.path.isdir(target_path): + shutil.rmtree(target_path) + changes_done = True + elif os.path.isfile(target_path): + os.remove(target_path) + changes_done = True + # Copy new/updated targets + for target, src_path in found_targets.items(): + dest_path = os.path.join(plugin_dir, target) + if os.path.isdir(src_path): + shutil.copytree(src_path, dest_path) + changes_done = True + elif os.path.isfile(src_path): + shutil.copy2(src_path, dest_path) + changes_done = True + except FileNotFoundError: + pass + except OSError as e: + if e.errno != errno.ENOENT: + raise + except Exception: + if changes_done: + raise + else: + return False + return True + + def _show_error(self, msg): + _app = QApplication.instance() or QApplication(sys.argv) + QMessageBox.critical(None, f"{self.name} Update", msg) + + def _show_restart_dialog(self): + _app = QApplication.instance() or QApplication(sys.argv) + QMessageBox.information( + None, + f"{self.name} Update", + "Update complete! Please restart Mod Organizer 2 for changes to take effect.", + ) diff --git a/games/ff12/DateHelper.py b/games/ff12/DateHelper.py new file mode 100644 index 0000000..4c7ab19 --- /dev/null +++ b/games/ff12/DateHelper.py @@ -0,0 +1,31 @@ +from datetime import datetime + + +def get_date_time_from_iso(date_iso: str) -> str: + """ + Convert ISO 8601 string to 'YYYY-MM-DD HH:MM UTC'. + Return input if parsing fails or input is not a string. + """ + if not isinstance(date_iso, str): + return str(date_iso) + + try: + dt = datetime.fromisoformat(date_iso.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d %H:%M UTC") + except Exception: + return date_iso + + +def get_date_from_iso(date_iso: str) -> str: + """ + Convert ISO 8601 string to 'YYYY-MM-DD'. + Return input if parsing fails or input is not a string. + """ + if not isinstance(date_iso, str): + return str(date_iso) + + try: + dt = datetime.fromisoformat(date_iso.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d") + except Exception: + return date_iso diff --git a/games/ff12/ModDataChecker.py b/games/ff12/ModDataChecker.py new file mode 100644 index 0000000..3071cb9 --- /dev/null +++ b/games/ff12/ModDataChecker.py @@ -0,0 +1,80 @@ +import mobase + +from ...basic_features import ( + BasicModDataChecker, + GlobPatterns, +) +from ...basic_features.utils import is_directory + + +class FF12ModDataChecker(BasicModDataChecker): + def __init__(self): + super().__init__( + GlobPatterns( + unfold=["*"], + delete=["*"], + valid=["x64", "mods", "dxgi.dll", "dinput8.dll", "launcher.dll"], + move={ + "scripts": "x64/", + "modules": "x64/", + "gamedata": "mods/deploy/ff12data/", + "jsondata": "mods/deploy/ff12data/", + "prefetchdata": "mods/deploy/ff12data/", + "ps2data": "mods/deploy/ff12data/", + "ff12data": "mods/deploy/", + }, + ) + ) + + def dataLooksValid( + self, filetree: mobase.IFileTree + ) -> mobase.ModDataChecker.CheckReturn: + status = mobase.ModDataChecker.VALID + + rp = self._regex_patterns + for entry in filetree: + name = entry.name().casefold() + + if rp.valid.match(name): + if status is mobase.ModDataChecker.INVALID: + status = mobase.ModDataChecker.VALID + + elif rp.move_match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + + elif rp.unfold.match(name) and is_directory(entry): + status = mobase.ModDataChecker.FIXABLE + new_status = self.dataLooksValid(entry) + if new_status is not mobase.ModDataChecker.VALID: + status = new_status + + elif rp.delete.match(name) is not None: + status = mobase.ModDataChecker.FIXABLE + + else: + status = mobase.ModDataChecker.INVALID + break + return status + + def fix(self, filetree: mobase.IFileTree) -> mobase.IFileTree: + rp = self._regex_patterns + + for entry in list(filetree): + name = entry.name().casefold() + + if rp.valid.match(name): + continue + + elif (move_key := rp.move_match(name)) is not None: + target = self._file_patterns.move[move_key] + filetree.move(entry, target) + + elif rp.unfold.match(name) and is_directory(entry): + filetree.merge(entry) + entry.detach() + self.fix(filetree) + + elif rp.delete.match(name): + entry.detach() + + return filetree diff --git a/games/ff12/SaveGame.py b/games/ff12/SaveGame.py new file mode 100644 index 0000000..b8df1a2 --- /dev/null +++ b/games/ff12/SaveGame.py @@ -0,0 +1,48 @@ +from collections.abc import Mapping +from pathlib import Path + +from PyQt6.QtCore import QDateTime + +import mobase + +from ...basic_features.basic_save_game_info import ( + BasicGameSaveGame, + format_date, +) + + +class FF12SaveGame(BasicGameSaveGame): + def __init__(self, filepath: Path): + super().__init__(filepath) + f_stat = self._filepath.stat() + self._size = f_stat.st_size + self._created = f_stat.st_birthtime + self._modified = f_stat.st_mtime + + def getName(self) -> str: + return f"Slot {self.getSlot()}" + + def getSaveGroupIdentifier(self) -> str: + return "Default" + + def getSlot(self) -> str: + return int(self._filepath.stem[6:9]) + + def getSize(self) -> int: + return self._size + + def getBirthTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._created)) + + def getCreationTime(self) -> QDateTime: + return QDateTime.fromSecsSinceEpoch(int(self._modified)) + + +def getSaveMetadata(savepath: Path, save: mobase.ISaveGame) -> Mapping[str, str]: + assert isinstance(save, FF12SaveGame) + return { + "Slot": save.getSlot(), + "Size": f"{save.getSize() / 1024:.2f} KB", + "Created At": format_date(save.getBirthTime()), + "Last Saved": format_date(save.getCreationTime()), + } diff --git a/games/ff12/SettingsManager.py b/games/ff12/SettingsManager.py new file mode 100644 index 0000000..777f1a7 --- /dev/null +++ b/games/ff12/SettingsManager.py @@ -0,0 +1,36 @@ +from enum import StrEnum + +import mobase + + +class SettingName(StrEnum): + AUTO_STEAM_ID = "autoSteamId" + STEAM_ID_64 = "steamId64" + DISABLE_AUTO_UPDATES = "disableAutoUpdates" + SKIP_UPDATE_VERSION = "skipUpdateVersion" + SKIP_UPDATE_UNTIL_DATE = "skipUpdateUntilDate" + + +class SettingsManager: + _instance = None + + def __init__(self, organizer: mobase.IOrganizer, game_name: str): + self._organizer = organizer + self._game_name = game_name + SettingsManager._instance = self + + @staticmethod + def get_instance(): + if SettingsManager._instance is None: + raise RuntimeError("SettingsManager not initialized.") + return SettingsManager._instance + + def get_setting(self, key: str): + return self._organizer.pluginSetting(self._game_name, key) + + def set_setting(self, key: str, value): + self._organizer.setPluginSetting(self._game_name, key, value) + + +def settings_manager(): + return SettingsManager.get_instance() diff --git a/games/ff12/SteamHelper.py b/games/ff12/SteamHelper.py new file mode 100644 index 0000000..0a32a79 --- /dev/null +++ b/games/ff12/SteamHelper.py @@ -0,0 +1,26 @@ +import vdf + +from ...steam_utils import find_steam_path + + +def get_last_logged_steam_id() -> str | None: + """ + Retrieve the Steam ID of the most recently logged-in user from Steam's loginusers.vdf. + """ + steam_path = find_steam_path() + if steam_path is None: + return None + + loginusers_path = steam_path / "config" / "loginusers.vdf" + try: + with open(loginusers_path, "r", encoding="utf-8") as f: + data = vdf.load(f) + + users = data.get("users", {}) + for steam_id, info in users.items(): + if info.get("MostRecent") == "1": + return steam_id + + return next(iter(users), None) + except Exception: + return None diff --git a/games/game_ff12.py b/games/game_ff12.py new file mode 100644 index 0000000..b4e88a7 --- /dev/null +++ b/games/game_ff12.py @@ -0,0 +1,266 @@ +import os +import shutil +from pathlib import Path + +from PyQt6.QtCore import ( + QDateTime, + QDir, + QFileInfo, + QStandardPaths, + qInfo, +) +from PyQt6.QtWidgets import QMainWindow, QTabWidget + +import mobase + +from ..basic_features import BasicLocalSavegames +from ..basic_features.basic_save_game_info import BasicGameSaveGameInfo +from ..basic_game import BasicGame +from .ff12.Archive.Widget import ArchiveContainerWidget +from .ff12.AutoUpdate import UpdateChecker +from .ff12.ModDataChecker import FF12ModDataChecker +from .ff12.SaveGame import FF12SaveGame, getSaveMetadata +from .ff12.SettingsManager import SettingName, SettingsManager, settings_manager +from .ff12.SteamHelper import get_last_logged_steam_id + +VERSION_MAJOR = 1 +VERSION_MINOR = 0 +VERSION_PATCH = 0 +VERSION_RELEASE_TYPE = mobase.ReleaseType.FINAL + + +class FF12TZAGame(BasicGame): + Name = "Final Fantasy XII TZA Support Plugin" + Author = "ffgriever & Xeavin" + GameName = "Final Fantasy XII The Zodiac Age" + GameShortName = "finalfantasy12" + GameNexusName = "finalfantasy12" + GameBinary = "x64/FFXII_TZA.exe" + GameDataPath = "%GAME_PATH%" + GameSteamId = 595520 + GameSavesDirectory = "%GAME_DOCUMENTS%" + + _archives_tab: ArchiveContainerWidget + + def __init__(self): + super().__init__() + self._suppress_setting_callback = False + + def init(self, organizer: mobase.IOrganizer) -> bool: + super().init(organizer) + SettingsManager(organizer, self.name()) + self._register_feature(FF12ModDataChecker()) + self._register_feature(BasicLocalSavegames(self.savesDirectory())) + self._register_feature(BasicGameSaveGameInfo(get_metadata=getSaveMetadata)) + organizer.onPluginSettingChanged(self._on_plugin_setting_changed_callback) + organizer.onUserInterfaceInitialized( + self._on_user_interface_initialized_callback + ) + + auto_steam_id = settings_manager().get_setting(SettingName.AUTO_STEAM_ID) + if auto_steam_id is True: + self._set_last_logged_steam_id() + + return True + + def version(self): + return mobase.VersionInfo( + VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH, VERSION_RELEASE_TYPE + ) + + def settings(self) -> list[mobase.PluginSetting]: + return [ + mobase.PluginSetting( + SettingName.AUTO_STEAM_ID, + ( + f"If true, automatically set '{SettingName.STEAM_ID_64}' to last logged Steam user." + ), + default_value=True, + ), + mobase.PluginSetting( + SettingName.STEAM_ID_64, + ( + "Unique 64-bit Steam user identifier used to locate saves and configuration files. " + "Leave empty when launching the game without Steam." + ), + default_value="", + ), + mobase.PluginSetting( + SettingName.DISABLE_AUTO_UPDATES, + ("If true, disables automatic updates for the plugin."), + default_value=False, + ), + mobase.PluginSetting( + SettingName.SKIP_UPDATE_VERSION, + ("If set, skips update dialog for this version."), + default_value="v0.0.0", + ), + mobase.PluginSetting( + SettingName.SKIP_UPDATE_UNTIL_DATE, + ( + "If set, skips update dialog until this date (in seconds since epoch)." + ), + default_value=0, + ), + ] + + def documentsDirectory(self) -> QDir: + docs_path = QDir( + QDir( + QStandardPaths.writableLocation( + QStandardPaths.StandardLocation.DocumentsLocation + ) + ).filePath("My Games/FINAL FANTASY XII THE ZODIAC AGE") + ) + + steam_id = settings_manager().get_setting(SettingName.STEAM_ID_64) + if steam_id: + docs_path = QDir(docs_path.absoluteFilePath(steam_id)) + + return docs_path + + def executables(self): + # Windows isn't necessarily installed in "C:\Windows\". + cmd_path = shutil.which("cmd.exe") + + # We're using cmd.exe to launch a launcher, because otherwise it can't be accessed + # using VFS. Otherwise we would have to scan mods and detect where it actually is. + default_launcher_path = self.gameDirectory().absoluteFilePath( + "x64/ff12-launcher.exe" + ) + + # If launcher exists, run it, else set color to red and show message. + launcher_cmd = ( + f'if exist "{default_launcher_path}" ' + f'("{default_launcher_path}") ' + f'else (color 0C && echo Launcher not found: "{default_launcher_path}". && echo Please install External File Loader with MO2 support. && pause && color)' + ) + + return [ + mobase.ExecutableInfo(f"{self.gameName()} (Modded)", QFileInfo(cmd_path)) + .withArgument(f"/c {launcher_cmd}") + .withWorkingDirectory(self.gameDirectory().absoluteFilePath("x64")), + mobase.ExecutableInfo( + f"{self.gameName()} (Vanilla)", + QFileInfo(self.gameDirectory().absoluteFilePath(self.binaryName())), + ), + mobase.ExecutableInfo( + "Configuration Tool", + QFileInfo( + self.gameDirectory().absoluteFilePath( + "x64/FFXII_TZA_GameSetting.exe" + ) + ), + ), + mobase.ExecutableInfo("Reload VFS", QFileInfo(cmd_path)).withArgument("/c"), + ] + + def iniFiles(self): + return ["GameSetting.ini", "keymap.ini"] + + def listSaves(self, folder: QDir) -> list[mobase.ISaveGame]: + return [ + FF12SaveGame(path) + for path in Path(folder.absolutePath()).glob("FFXII_???") + if path.is_file() and path.name[6:9].isdigit() + ] + + def _on_plugin_setting_changed_callback( + self, + plugin_name: str, + setting: str, + old: mobase.MoVariant, + new: mobase.MoVariant, + ): + if plugin_name != self.name() or self._suppress_setting_callback is True: + return + + if setting == SettingName.AUTO_STEAM_ID and old is False and new is True: + self._suppress_setting_callback = True + try: + self._set_last_logged_steam_id() + finally: + self._suppress_setting_callback = False + elif setting == SettingName.STEAM_ID_64 and old != new: + if settings_manager().get_setting(SettingName.AUTO_STEAM_ID) is True: + self._suppress_setting_callback = True + try: + settings_manager().set_setting(SettingName.AUTO_STEAM_ID, False) + finally: + self._suppress_setting_callback = False + + def _set_last_logged_steam_id(self): + last_steam_id = get_last_logged_steam_id() + if not last_steam_id: + return + + cur_steam_id = settings_manager().get_setting(SettingName.STEAM_ID_64) + if last_steam_id == cur_steam_id: + return + + settings_manager().set_setting(SettingName.STEAM_ID_64, last_steam_id) + if cur_steam_id: + qInfo(f"Updated Steam ID from '{cur_steam_id}' to '{last_steam_id}'.") + else: + qInfo(f"Set Steam ID to '{last_steam_id}'.") + + def _on_user_interface_initialized_callback(self, window: QMainWindow): + if self._organizer.managedGame() is not self: + return + + self._add_archives_tab(window) + self._check_for_update(window) + + def _add_archives_tab(self, window: QMainWindow): + tab_widget: QTabWidget = window.findChild(QTabWidget, "tabWidget") + if not tab_widget: + return + + game_path = Path(self.gameDirectory().absolutePath()) + self._archives_tab = ArchiveContainerWidget(game_path) + tab_widget.addTab(self._archives_tab, "Archives") + + def _check_for_update(self, window: QMainWindow): + if settings_manager().get_setting(SettingName.DISABLE_AUTO_UPDATES) is True: + return + + remind_time = settings_manager().get_setting(SettingName.SKIP_UPDATE_UNTIL_DATE) + now_secs = int(QDateTime.currentDateTime().toSecsSinceEpoch()) + + if remind_time and now_secs is not None and remind_time > now_secs: + return + + update_checker = UpdateChecker( + "FF12 Plugin", + "FF12-Modding", + "FF12-MO2-Plugin", + VERSION_MAJOR, + VERSION_MINOR, + VERSION_PATCH, + VERSION_RELEASE_TYPE, + window, + update_targets=["game_ff12.py", "ff12"], + remove_targets=["ff12"], + skip_version=settings_manager().get_setting( + SettingName.SKIP_UPDATE_VERSION + ), + plugin_dir=os.path.dirname(__file__), + ) + + # We're using non-modal dialogs, so we have to use callbacks to clear settings. + def on_update_installed(): + settings_manager().set_setting(SettingName.SKIP_UPDATE_VERSION, "v0.0.0") + settings_manager().set_setting(SettingName.SKIP_UPDATE_UNTIL_DATE, 0) + + def on_version_skipped(version: str): + settings_manager().set_setting(SettingName.SKIP_UPDATE_VERSION, version) + + def on_update_remind(remind_time: int): + settings_manager().set_setting( + SettingName.SKIP_UPDATE_UNTIL_DATE, remind_time + ) + + update_checker.on_update_installed(on_update_installed) + update_checker.on_version_skipped(on_version_skipped) + update_checker.on_update_remind(on_update_remind) + update_checker.check_for_update() From 6275a7cc3bde9d8b4e8513403a697988e2d2da70 Mon Sep 17 00:00:00 2001 From: ffgriever-pl <53974091+ffgriever-pl@users.noreply.github.com> Date: Mon, 1 Jun 2026 14:18:23 +0200 Subject: [PATCH 2/4] Fix linter errors --- games/ff12/Archive/Model.py | 4 ++-- games/ff12/AutoUpdate.py | 30 +++++++++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/games/ff12/Archive/Model.py b/games/ff12/Archive/Model.py index dab2cfc..50f03db 100644 --- a/games/ff12/Archive/Model.py +++ b/games/ff12/Archive/Model.py @@ -103,7 +103,7 @@ def _build_tree(self): current_path = "" current_node = self._root_node - for i, part in enumerate(path_parts[:-1]): + for part in path_parts[:-1]: if current_path: current_path += "/" + part else: @@ -150,7 +150,7 @@ def sort(self, column: int, order: Qt.SortOrder): else: new_indexes.append(QModelIndex()) - for old_index, new_index in zip(persistent_indexes, new_indexes): + for old_index, new_index in zip(persistent_indexes, new_indexes, strict=False): self.changePersistentIndex(old_index, new_index) self.layoutChanged.emit( diff --git a/games/ff12/AutoUpdate.py b/games/ff12/AutoUpdate.py index 7b8cbac..a9673ba 100644 --- a/games/ff12/AutoUpdate.py +++ b/games/ff12/AutoUpdate.py @@ -133,19 +133,21 @@ def _get_releases(self): if e.code == 404: raise Exception( f"GitHub repository {self.repo_owner}/{self.repo_name} not found (404)." - ) + ) from e elif e.code == 403: raise Exception( "GitHub API rate limit exceeded (403). Please try again later." - ) + ) from e else: - raise Exception(f"HTTP error occurred: {e.code} {e.reason}") + raise Exception(f"HTTP error occurred: {e.code} {e.reason}") from e except urllib.error.URLError as e: raise Exception( f"Network error: {e.reason}. Server unavailable or internet connection issue." - ) - except socket.timeout: - raise Exception("Connection timed out while trying to fetch releases.") + ) from e + except socket.timeout as e: + raise Exception( + "Connection timed out while trying to fetch releases." + ) from e releases = json.loads(data) return releases @@ -238,7 +240,7 @@ def _collect_changelogs(self, latest_release): changelogs.append((ver, tag, body, rel.get("published_at", ""))) changelogs.sort(reverse=True) notes_md = "" - for i, (ver, tag, body, published_at) in enumerate(changelogs): + for i, (_ver, tag, body, published_at) in enumerate(changelogs): notes_md += f"## Changes in {tag} []() Date: {get_date_from_iso(published_at)} ([commits](https://github.com/{self.repo_owner}/{self.repo_name}/commits/{tag}))\n{body}" if i < len(changelogs) - 1: notes_md += "\n***\n" @@ -435,21 +437,23 @@ def _download_asset(self, url, zip_path): shutil.copyfileobj(response, out_file) except urllib.error.HTTPError as e: if e.code == 404: - raise Exception(f"Download URL not found (404): {url}") + raise Exception(f"Download URL not found (404): {url}") from e elif e.code == 403: raise Exception( "Rate limit exceeded or access denied. Please try again later." - ) + ) from e else: raise Exception( f"HTTP error occurred while downloading asset: {e.code} {e.reason}" - ) + ) from e except urllib.error.URLError as e: raise Exception( f"Network error while downloading asset: {e.reason}. Server unavailable or internet connection issue." - ) - except socket.timeout: - raise Exception("Connection timed out while trying to download asset.") + ) from e + except socket.timeout as e: + raise Exception( + "Connection timed out while trying to download asset." + ) from e def _extract_update_files(self, zip_path, tmpdir): with zipfile.ZipFile(zip_path, "r") as zip_ref: From f97cffbd8ad04e8dad1553a17d9c169598c1e342 Mon Sep 17 00:00:00 2001 From: ffgriever-pl <53974091+ffgriever-pl@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:04:55 +0200 Subject: [PATCH 3/4] Fix type issues (pyright) --- games/ff12/Archive/Model.py | 79 ++++++++++++------- games/ff12/Archive/Reader.py | 34 +++++--- games/ff12/Archive/View.py | 59 +++++++++----- games/ff12/Archive/Widget.py | 42 ++++++---- games/ff12/AutoUpdate.py | 142 +++++++++++++++++++--------------- games/ff12/DateHelper.py | 10 +-- games/ff12/ModDataChecker.py | 4 +- games/ff12/SaveGame.py | 4 +- games/ff12/SettingsManager.py | 10 +-- games/ff12/SteamHelper.py | 12 ++- games/game_ff12.py | 23 ++++-- 11 files changed, 254 insertions(+), 165 deletions(-) diff --git a/games/ff12/Archive/Model.py b/games/ff12/Archive/Model.py index 50f03db..a80af5e 100644 --- a/games/ff12/Archive/Model.py +++ b/games/ff12/Archive/Model.py @@ -1,4 +1,5 @@ from enum import IntEnum, auto +from typing import Any, cast from PyQt6.QtCore import QAbstractItemModel, QFileInfo, QModelIndex, Qt from PyQt6.QtWidgets import QFileIconProvider @@ -7,15 +8,22 @@ class TreeNode: - def __init__(self, name: str, parent=None, is_dir=False, size=0, entry=None): + def __init__( + self, + name: str, + parent: "TreeNode | None" = None, + is_dir: bool = False, + size: int = 0, + entry: Any | None = None, + ): self.name = name self.parent = parent self.is_dir = is_dir self.size = size - self.children = [] + self.children: list[TreeNode] = [] self.entry = entry - if parent: + if parent is not None: parent.children.append(self) def child_count(self) -> int: @@ -37,7 +45,7 @@ def path(self) -> str: return self.parent.path() + "/" + self.name return self.name - def sort_children(self, column: int, order: Qt.SortOrder): + def sort_children(self, column: int, order: Qt.SortOrder) -> None: """Sort children by the specified column and order. Directories always come before files. Directories only reorder when column == NAME. @@ -78,25 +86,28 @@ class ArchiveColumn(IntEnum): class ArchiveModel(QAbstractItemModel): - def __init__(self, parent=None): + def __init__(self, parent: Any | None = None): super().__init__(parent) - self._reader = None + self._reader: ArchiveReader | None = None self._icon_provider = QFileIconProvider() self._root_node = TreeNode("", None, True) self._sort_column = ArchiveColumn.NAME self._sort_order = Qt.SortOrder.AscendingOrder - def set_data(self, reader: ArchiveReader): + def set_data(self, reader: ArchiveReader) -> None: self.beginResetModel() self._reader = reader self._build_tree() self._sort_tree() self.endResetModel() - def _build_tree(self): + def _build_tree(self) -> None: """Build the tree structure from archive entries.""" + if self._reader is None: + return + dir_nodes: dict[str, TreeNode] = {"": self._root_node} - sorted_entries = sorted(self._reader._entries.items(), key=lambda x: x[0]) + sorted_entries = sorted(self._reader._entries.items(), key=lambda x: x[0]) # pyright: ignore[reportPrivateUsage] for name, entry in sorted_entries: path_parts = name.split("/") @@ -119,18 +130,22 @@ def _build_tree(self): filename = path_parts[-1] TreeNode(filename, current_node, False, entry.original_size, entry) - def _sort_tree(self): + def _sort_tree(self) -> None: """Sort the entire tree.""" self._root_node.sort_children(self._sort_column, self._sort_order) - def sort(self, column: int, order: Qt.SortOrder): + def sort( + self, + column: int, + order: Qt.SortOrder = Qt.SortOrder.AscendingOrder, + ) -> None: """Implement sorting with proper persistent index handling""" persistent_indexes = self.persistentIndexList() - old_nodes = [] + old_nodes: list[TreeNode | None] = [] for index in persistent_indexes: if index.isValid(): - old_nodes.append(index.internalPointer()) + old_nodes.append(cast(TreeNode, index.internalPointer())) else: old_nodes.append(None) @@ -142,7 +157,7 @@ def sort(self, column: int, order: Qt.SortOrder): self._sort_order = order self._sort_tree() - new_indexes = [] + new_indexes: list[QModelIndex] = [] for node in old_nodes: if node is not None: new_index = self._find_index_for_node(node) @@ -162,9 +177,9 @@ def _find_index_for_node(self, target_node: TreeNode) -> QModelIndex: if target_node == self._root_node: return QModelIndex() - path = [] - current = target_node - while current and current != self._root_node: + path: list[TreeNode] = [] + current: TreeNode | None = target_node + while current is not None and current != self._root_node: path.append(current) current = current.parent @@ -176,25 +191,28 @@ def _find_index_for_node(self, target_node: TreeNode) -> QModelIndex: return current_index - def rowCount(self, parent: QModelIndex) -> int: + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: if not parent.isValid(): parent_node = self._root_node else: - parent_node = parent.internalPointer() + parent_node = cast(TreeNode, parent.internalPointer()) return parent_node.child_count() - def columnCount(self, parent: QModelIndex) -> int: + def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: + _ = parent return len(ArchiveColumn) - def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex: + def index( + self, row: int, column: int, parent: QModelIndex = QModelIndex() + ) -> QModelIndex: if not self.hasIndex(row, column, parent): return QModelIndex() if not parent.isValid(): parent_node = self._root_node else: - parent_node = parent.internalPointer() + parent_node = cast(TreeNode, parent.internalPointer()) child_node = parent_node.child(row) if child_node: @@ -202,11 +220,11 @@ def index(self, row: int, column: int, parent: QModelIndex) -> QModelIndex: return QModelIndex() - def parent(self, index: QModelIndex) -> QModelIndex: - if not index.isValid(): + def parent(self, child: QModelIndex) -> QModelIndex: # pyright: ignore[reportIncompatibleMethodOverride] + if not child.isValid(): return QModelIndex() - child_node = index.internalPointer() + child_node = cast(TreeNode, child.internalPointer()) parent_node = child_node.parent if parent_node == self._root_node or parent_node is None: @@ -214,11 +232,13 @@ def parent(self, index: QModelIndex) -> QModelIndex: return self.createIndex(parent_node.row(), 0, parent_node) - def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + def data( + self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole + ) -> Any | None: if not index.isValid(): return None - node = index.internalPointer() + node = cast(TreeNode, index.internalPointer()) column = index.column() if role == Qt.ItemDataRole.DisplayRole: @@ -267,10 +287,13 @@ def headerData( return None + def get_reader(self) -> ArchiveReader | None: + return self._reader + def get_node(self, index: QModelIndex) -> TreeNode | None: """Get the TreeNode for a given index.""" if index.isValid(): - return index.internalPointer() + return cast(TreeNode, index.internalPointer()) return None @staticmethod diff --git a/games/ff12/Archive/Reader.py b/games/ff12/Archive/Reader.py index 82b70d8..0b1c846 100644 --- a/games/ff12/Archive/Reader.py +++ b/games/ff12/Archive/Reader.py @@ -2,13 +2,14 @@ import struct import zlib from pathlib import Path -from typing import BinaryIO, Dict, List +from types import TracebackType +from typing import BinaryIO from PyQt6.QtCore import qWarning class ArchiveEntry: - def __init__(self, original_size: int, data_offset: int, block_sizes: List[int]): + def __init__(self, original_size: int, data_offset: int, block_sizes: list[int]): self.original_size = original_size self.data_offset = data_offset self.block_sizes = block_sizes @@ -19,7 +20,7 @@ class ArchiveReader: def __init__(self, path: Path): self._filename = path - self._entries: Dict[str, ArchiveEntry] = {} + self._entries: dict[str, ArchiveEntry] = {} self._file_handle: BinaryIO | None = None self._load_metadata() @@ -50,20 +51,27 @@ def __enter__(self): self.open() return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: """Enable 'with' statement to automatically open and close the file handle.""" self.close() def _load_metadata(self): """Load the archive metadata""" - with self as file: - file = file._file_handle + with self: + file = self._file_handle + if file is None: + return try: header_data = file.read(16) if len(header_data) < 16: raise EOFError("File too short for header") - magic, header_size, file_count = struct.unpack(" List[tuple]: + def _read_file_metadata( + self, file: BinaryIO, count: int + ) -> list[tuple[int, int, int, int]]: """Read metadata for file entries""" file_struct = struct.Struct(" bytes: return path_data - def _get_block_count(self, file_metadata: List[tuple]) -> int: + def _get_block_count(self, file_metadata: list[tuple[int, int, int, int]]) -> int: """Calculate total number of blocks across all files""" count = 0 for _, original_size, _, _ in file_metadata: @@ -139,7 +149,7 @@ def _get_block_count(self, file_metadata: List[tuple]) -> int: count += blocks return count - def _read_block_sizes(self, file: BinaryIO, count: int) -> List[int]: + def _read_block_sizes(self, file: BinaryIO, count: int) -> list[int]: """Read block sizes for all files""" data = file.read(count * 2) if len(data) < count * 2: @@ -147,7 +157,7 @@ def _read_block_sizes(self, file: BinaryIO, count: int) -> List[int]: return list(struct.unpack(f"<{count}H", data)) - def unpack_file(self, file_path: str, out_path: str): + def unpack_file(self, file_path: str, out_path: str | Path): """Unpack a file from the archive""" if self._file_handle is None: raise ValueError("Archive file handle is not open") diff --git a/games/ff12/Archive/View.py b/games/ff12/Archive/View.py index 30b77c7..f27085a 100644 --- a/games/ff12/Archive/View.py +++ b/games/ff12/Archive/View.py @@ -1,6 +1,13 @@ from pathlib import Path -from PyQt6.QtCore import QCoreApplication, QModelIndex, QStandardPaths, Qt +from PyQt6.QtCore import ( + QAbstractItemModel, + QCoreApplication, + QModelIndex, + QPoint, + QStandardPaths, + Qt, +) from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( QFileDialog, @@ -11,7 +18,7 @@ QWidget, ) -from .Model import ArchiveColumn, TreeNode +from .Model import ArchiveColumn, ArchiveModel, TreeNode class ArchiveView(QTreeView): @@ -23,11 +30,13 @@ def __init__(self, parent: QWidget | None): self.setSortingEnabled(True) self.setSelectionMode(QTreeView.SelectionMode.ExtendedSelection) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - self.customContextMenuRequested.connect(self._show_context_menu) - self._last_export_dir = None - self._model = None + self.customContextMenuRequested.connect( # pyright: ignore[reportUnknownMemberType] + self._show_context_menu + ) + self._last_export_dir: str | None = None + self._model: ArchiveModel | None = None - def _setup_view(self): + def _setup_view(self) -> None: """Setup view appearance and default sorting.""" if self._model is None: return @@ -37,13 +46,13 @@ def _setup_view(self): self.setColumnWidth(ArchiveColumn.SIZE, 90) self.sortByColumn(ArchiveColumn.NAME, Qt.SortOrder.AscendingOrder) - def setModel(self, model): + def setModel(self, model: QAbstractItemModel | None) -> None: """Setup view after model is set.""" super().setModel(model) - self._model = model + self._model = model if isinstance(model, ArchiveModel) else None self._setup_view() - def _show_context_menu(self, position): + def _show_context_menu(self, position: QPoint) -> None: """Show context menu at the given position.""" if self._model is None: return @@ -53,15 +62,15 @@ def _show_context_menu(self, position): selected_indexes = self._get_selected_indexes() if selected_indexes: export_action = QAction("Export", self) - export_action.triggered.connect( + export_action.triggered.connect( # pyright: ignore[reportUnknownMemberType] lambda: self._export_selection(selected_indexes) ) - menu.addAction(export_action) + menu.addAction(export_action) # pyright: ignore[reportUnknownMemberType] if menu.actions(): menu.exec(self.mapToGlobal(position)) - def _export_selection(self, indexes): + def _export_selection(self, indexes: list[QModelIndex]) -> None: start_dir = self._last_export_dir or QStandardPaths.writableLocation( QStandardPaths.StandardLocation.DesktopLocation ) @@ -87,9 +96,15 @@ def _export_selection(self, indexes): progress.show() exported_count = 0 - failed_files = [] + failed_files: list[str] = [] + + assert self._model is not None + reader = self._model.get_reader() + if reader is None: + progress.close() + return - with self._model._reader as reader: + with reader: for i, file_node in enumerate(files_to_export): if progress.wasCanceled(): break @@ -98,8 +113,8 @@ def _export_selection(self, indexes): progress.setValue(i) QCoreApplication.processEvents() + relative_path = file_node.path() try: - relative_path = file_node.path() output_file = export_path / Path(relative_path) output_file.parent.mkdir(parents=True, exist_ok=True) @@ -131,19 +146,21 @@ def _export_selection(self, indexes): ) def _get_selected_indexes(self) -> list[QModelIndex]: - indexes = self.selectionModel().selectedIndexes() + sm = self.selectionModel() + assert sm is not None + indexes = sm.selectedIndexes() - rows = {} + rows: dict[int, QModelIndex] = {} for index in indexes: if index.column() == 0: rows[index.row()] = index return list(rows.values()) - def _get_selected_files(self, indexes) -> list[TreeNode]: - files = [] + def _get_selected_files(self, indexes: list[QModelIndex]) -> list[TreeNode]: + files: list[TreeNode] = [] - def collect(node): + def collect(node: TreeNode) -> None: if node.is_dir: for child in node.children: collect(child) @@ -151,7 +168,7 @@ def collect(node): files.append(node) for index in indexes: - node = self._model.get_node(index) + node = self._model.get_node(index) if self._model else None if node: collect(node) return files diff --git a/games/ff12/Archive/Widget.py b/games/ff12/Archive/Widget.py index 7f54114..1e898fa 100644 --- a/games/ff12/Archive/Widget.py +++ b/games/ff12/Archive/Widget.py @@ -1,6 +1,8 @@ from pathlib import Path +from typing import cast from PyQt6.QtCore import Qt +from PyQt6.QtGui import QHideEvent, QShowEvent from PyQt6.QtWidgets import ( QComboBox, QHBoxLayout, @@ -21,7 +23,7 @@ def __init__(self, content_path: Path, parent: QWidget | None = None): super().__init__(parent) self._content_path = content_path self._current_content: ArchiveContentWidget | None = None - self._loader = None + self._loader: ArchiveLoader | None = None v_layout = QVBoxLayout(self) v_layout.setAlignment(Qt.AlignmentFlag.AlignTop) @@ -40,19 +42,21 @@ def __init__(self, content_path: Path, parent: QWidget | None = None): self._content = QVBoxLayout() v_layout.addLayout(self._content) - self._combo_box.currentIndexChanged.connect(self._load_selected) + self._combo_box.currentIndexChanged.connect( # pyright: ignore[reportUnknownMemberType] + self._load_selected + ) - def _reset_combo_box(self): + def _reset_combo_box(self) -> None: self._combo_box.blockSignals(True) self._combo_box.clear() for p in sorted(get_archives(self._content_path)): self._combo_box.addItem(p.name, userData=p) self._combo_box.blockSignals(False) - def _clear_current_content(self): + def _clear_current_content(self) -> None: if self._loader and self._loader.isRunning(): - self._loader.finished.disconnect() - self._loader.error.disconnect() + self._loader.finished.disconnect() # pyright: ignore[reportUnknownMemberType] + self._loader.error.disconnect() # pyright: ignore[reportUnknownMemberType] self._loader.quit() self._loader = None @@ -61,8 +65,8 @@ def _clear_current_content(self): self._current_content.deleteLater() self._current_content = None - def _load_selected(self): - archive_path: Path = self._combo_box.currentData() + def _load_selected(self) -> None: + archive_path = cast(Path | None, self._combo_box.currentData()) if not archive_path: return @@ -71,11 +75,15 @@ def _load_selected(self): self._content.addWidget(self._current_content) self._loader = ArchiveLoader(archive_path) - self._loader.finished.connect(self._on_load_finished_callback) - self._loader.error.connect(self._on_load_error_callback) + self._loader.finished.connect( # pyright: ignore[reportUnknownMemberType] + self._on_load_finished_callback + ) + self._loader.error.connect( # pyright: ignore[reportUnknownMemberType] + self._on_load_error_callback + ) self._loader.start() - def _on_load_finished_callback(self, reader: ArchiveReader): + def _on_load_finished_callback(self, reader: ArchiveReader) -> None: """Called when archive loading is complete.""" if self._current_content: self._current_content.load_data(reader) @@ -84,20 +92,20 @@ def _on_load_finished_callback(self, reader: ArchiveReader): self._loader.deleteLater() self._loader = None - def _on_load_error_callback(self, error_msg): + def _on_load_error_callback(self, _error_msg: str) -> None: """Called when archive loading fails.""" if self._loader: self._loader.deleteLater() self._loader = None - def showEvent(self, event): - super().showEvent(event) + def showEvent(self, a0: QShowEvent | None) -> None: + super().showEvent(a0) self._reset_combo_box() if self._combo_box.count() > 0 and not self._current_content: self._load_selected() - def hideEvent(self, event): - super().hideEvent(event) + def hideEvent(self, a0: QHideEvent | None) -> None: + super().hideEvent(a0) self._clear_current_content() @@ -113,5 +121,5 @@ def __init__(self, parent: QWidget | None = None): self._layout.addWidget(self._view) self.setLayout(self._layout) - def load_data(self, reader: ArchiveReader): + def load_data(self, reader: ArchiveReader) -> None: self._model.set_data(reader) diff --git a/games/ff12/AutoUpdate.py b/games/ff12/AutoUpdate.py index a9673ba..def5377 100644 --- a/games/ff12/AutoUpdate.py +++ b/games/ff12/AutoUpdate.py @@ -6,9 +6,10 @@ import socket import sys import tempfile +import urllib.error import urllib.request import zipfile -from typing import Callable +from typing import Any, Callable, cast from PyQt6.QtCore import ( QDateTime, @@ -32,6 +33,8 @@ from .DateHelper import get_date_from_iso, get_date_time_from_iso +VersionTuple = tuple[int, int, int] + class UpdateChecker(QObject): """ @@ -59,12 +62,12 @@ def __init__( major: int, minor: int, patch: int, - release_type: int, - parent: QMainWindow = None, - update_targets: list[str] = None, - remove_targets: list[str] = None, - skip_version: str = None, - plugin_dir: str = None, + release_type: mobase.ReleaseType, + parent: QMainWindow | None = None, + update_targets: list[str] | None = None, + remove_targets: list[str] | None = None, + skip_version: str | None = None, + plugin_dir: str | None = None, ): """ Initializes the AutoUpdate class with plugin and repository information. @@ -76,7 +79,7 @@ def __init__( major (int): Major version number of the current plugin. minor (int): Minor version number of the current plugin. patch (int): Patch version number of the current plugin. - release_type (int): The type of release (e.g. mobase.ReleaseType.BETA). + release_type (mobase.ReleaseType): The type of release (e.g. mobase.ReleaseType.BETA). parent (QMainWindow, optional): The parent window for UI integration. Defaults to None. update_targets (optional): List of targets to update. Defaults to None. remove_targets (optional): List of targets to remove. Defaults to None. @@ -90,39 +93,39 @@ def __init__( self.current_version = (major, minor, patch) self.release_type = release_type self.parentWindow = parent - self.update_targets = update_targets - self.remove_targets = remove_targets + self.update_targets = update_targets or [] + self.remove_targets = remove_targets or [] self.skip_version = skip_version - self.plugin_dir = plugin_dir + self.plugin_dir = plugin_dir or "" - def on_update_installed(self, callback: Callable[[], None]): + def on_update_installed(self, callback: Callable[[], None]) -> None: """ Registers a callback function to be invoked when an update is successfully installed. Args: callback (Callable[[], None]): The function to be called when an update is successfully installed. """ - self.update_installed.connect(callback) + self.update_installed.connect(callback) # pyright: ignore[reportUnknownMemberType] - def on_update_remind(self, callback: Callable[[int], None]): + def on_update_remind(self, callback: Callable[[int], None]) -> None: """ Registers a callback to be invoked when the user opts to be reminded later about an available update. Args: callback (Callable[[int], None]): The function to be called when the a remind me later button is clicked. """ - self.update_remind.connect(callback) + self.update_remind.connect(callback) # pyright: ignore[reportUnknownMemberType] - def on_version_skipped(self, callback: Callable[[str], None]): + def on_version_skipped(self, callback: Callable[[str], None]) -> None: """ Registers a callback to be invoked when the user skips the current version. Args: callback (Callable[[str], None]): The function to be called when a skip version button is clicked. """ - self.version_skipped.connect(callback) + self.version_skipped.connect(callback) # pyright: ignore[reportUnknownMemberType] - def _get_releases(self): + def _get_releases(self) -> list[dict[str, Any]]: url = ( f"https://api.github.com/repos/{self.repo_owner}/{self.repo_name}/releases" ) @@ -149,11 +152,12 @@ def _get_releases(self): "Connection timed out while trying to fetch releases." ) from e - releases = json.loads(data) - return releases + return cast(list[dict[str, Any]], json.loads(data)) - def _parse_version(self, tag): + def _parse_version(self, tag: str | None) -> VersionTuple | None: # Handles tags like v1.2.3, 1.2.3, v1.2.3-suffix, 1.2.3-suffix + if not tag: + return None tag = tag.lstrip("v") # Remove any suffix after patch number before checking version main_part = tag.split("-")[0] @@ -161,18 +165,18 @@ def _parse_version(self, tag): if len(parts) < 3: return None try: - return tuple(int(p) for p in parts[:3]) + return (int(parts[0]), int(parts[1]), int(parts[2])) except Exception: return None - def _is_newer(self, v1, v2): + def _is_newer(self, v1: VersionTuple | None, v2: VersionTuple | None) -> bool: if v2 is None: return True if v1 is None: return False return v1 > v2 - def check_for_update(self, skip_version=None): + def check_for_update(self, skip_version: str | None = None) -> None: """ Checks GitHub for available updates and prompts the user if a new version is found. @@ -199,12 +203,12 @@ def check_for_update(self, skip_version=None): ver = self._parse_version(tag) if ver and self._is_newer(ver, self.current_version): if latest is None or self._is_newer( - ver, self._parse_version(latest["tag_name"]) + ver, self._parse_version(latest.get("tag_name", "")) ): latest = rel if latest: - latest_ver = self._parse_version(latest.get("tag_name")) + latest_ver = self._parse_version(latest.get("tag_name", "")) skip_ver = self._parse_version(skip_version_val) if not self._is_newer(latest_ver, skip_ver): self._log_skip_update() @@ -223,12 +227,12 @@ def _make_pr_links(self, text: str) -> str: text, ) - def _collect_changelogs(self, latest_release): + def _collect_changelogs(self, latest_release: dict[str, Any]) -> str: try: all_releases = self._get_releases() include_prerelease = self.release_type != mobase.ReleaseType.FINAL current_ver = self.current_version - changelogs = [] + changelogs: list[tuple[VersionTuple, str, str, str]] = [] for rel in all_releases: if not include_prerelease and rel.get("prerelease", False): continue @@ -247,24 +251,26 @@ def _collect_changelogs(self, latest_release): else: notes_md += "\n" if not notes_md: + latest_tag = latest_release.get("tag_name", "") + latest_published = latest_release.get("published_at", "") body = latest_release.get("body", "No patch notes.") body = self._make_pr_links(body) - notes_md = f"## Changes in {latest_release.get('tag_name', '')} []() Date: {get_date_from_iso(latest_release.get('published_at', ''))} ([commits](https://github.com/{self.repo_owner}/{self.repo_name}/commits/{latest_release.get('tag_name', '')}))\n{body}\n" + notes_md = f"## Changes in {latest_tag} []() Date: {get_date_from_iso(latest_published)} ([commits](https://github.com/{self.repo_owner}/{self.repo_name}/commits/{latest_tag}))\n{body}\n" return notes_md except Exception as e: qWarning(f"Failed to collect changelogs: {e}") return "## Error collecting changelogs\nAn error occurred while fetching the changelogs. Please check the log for details." def _create_update_dialog( - self, notes_md, current_version, latest_tag, latest_date_str - ): + self, notes_md: str, current_version: str, latest_tag: str, latest_date_str: str + ) -> type[QDialog]: pluginName = self.name or "Plugin" class UpdateDialog(QDialog): skip_update = pyqtSignal() remind_later = pyqtSignal() - def __init__(self, parent=None): + def __init__(self, parent: QMainWindow | None = None): super().__init__(parent) self.setWindowTitle(f"{pluginName} Update Available") self.setMinimumSize(500, 400) @@ -294,14 +300,23 @@ def __init__(self, parent=None): "Cancel", QDialogButtonBox.ButtonRole.RejectRole ) layout.addWidget(button_box) - update_btn.clicked.connect(self.accept) - remind_btn.clicked.connect(self.remind_later.emit) - skip_btn.clicked.connect(self.skip_update.emit) - cancel_btn.clicked.connect(self.close) + assert update_btn is not None + update_btn.clicked.connect(self.accept) # pyright: ignore[reportUnknownMemberType] + assert remind_btn is not None + remind_btn.clicked.connect(self.remind_later.emit) # pyright: ignore[reportUnknownMemberType] + assert skip_btn is not None + skip_btn.clicked.connect(self.skip_update.emit) # pyright: ignore[reportUnknownMemberType] + assert cancel_btn is not None + cancel_btn.clicked.connect(self.close) # pyright: ignore[reportUnknownMemberType] return UpdateDialog - def _connect_update_dialog(self, dialog, latest_release, latest_tag): + def _connect_update_dialog( + self, + dialog: QDialog, + latest_release: dict[str, Any], + latest_tag: str, + ) -> None: def on_accept(): self._download_and_update(latest_release) dialog.close() @@ -315,15 +330,17 @@ def on_remind(): self.update_remind.emit(remind_time) dialog.close() - dialog.accepted.connect(on_accept) - dialog.skip_update.connect(on_skip) - dialog.remind_later.connect(on_remind) + dialog.accepted.connect(on_accept) # pyright: ignore[reportUnknownMemberType] + cast(Any, dialog).skip_update.connect(on_skip) + cast(Any, dialog).remind_later.connect(on_remind) - def _show_update_dialog(self, latest_release): + def _show_update_dialog(self, latest_release: dict[str, Any]) -> None: notes_md = self._collect_changelogs(latest_release) current_version = f"v{self.current_version[0]}.{self.current_version[1]}.{self.current_version[2]}" latest_tag = latest_release.get("tag_name", "") - latest_date_str = get_date_time_from_iso(latest_release.get("published_at", "")) + latest_date_str = get_date_time_from_iso( + latest_release.get("published_at", "") + ) _app = QApplication.instance() or QApplication(sys.argv) UpdateDialog = self._create_update_dialog( notes_md, current_version, latest_tag, latest_date_str @@ -334,22 +351,24 @@ def _show_update_dialog(self, latest_release): self._connect_update_dialog(dialog, latest_release, latest_tag) dialog.show() - def _log_no_update(self): + def _log_no_update(self) -> None: qInfo(f"No updates available for {self.name}.") - def _log_skip_update(self): + def _log_skip_update(self) -> None: qInfo(f"Skipped update for {self.name}.") - def _download_and_update(self, release): + def _download_and_update(self, release: dict[str, Any]) -> None: asset = self._find_zip_asset(release) if not asset: self._show_error("No zip asset found in release.") return tmpdir = tempfile.mkdtemp() - zip_path = os.path.join(tmpdir, asset["name"]) + asset_name = asset.get("name", "") + zip_path = os.path.join(tmpdir, asset_name) backup_dir = os.path.join(tmpdir, "backup") try: - self._download_asset(asset["browser_download_url"], zip_path) + download_url = asset.get("browser_download_url", "") + self._download_asset(download_url, zip_path) found_targets = self._extract_update_files(zip_path, tmpdir) missing = [t for t in self.update_targets if t not in found_targets] if missing: @@ -381,10 +400,10 @@ def _download_and_update(self, release): finally: shutil.rmtree(tmpdir, ignore_errors=True) - def _backup_targets(self, backup_dir): + def _backup_targets(self, backup_dir: str) -> None: os.makedirs(backup_dir, exist_ok=True) plugin_dir = self.plugin_dir - unique_targets = set((self.update_targets or []) + (self.remove_targets or [])) + unique_targets = set(self.update_targets + self.remove_targets) for target in unique_targets: src_path = os.path.join(plugin_dir, target) dst_path = os.path.join(backup_dir, target) @@ -395,7 +414,7 @@ def _backup_targets(self, backup_dir): os.makedirs(os.path.dirname(dst_path), exist_ok=True) shutil.copy2(src_path, dst_path) - def _restore_targets(self, backup_dir): + def _restore_targets(self, backup_dir: str) -> None: plugin_dir = self.plugin_dir for root, dirs, files in os.walk(backup_dir): rel_root = os.path.relpath(root, backup_dir) @@ -414,7 +433,7 @@ def _restore_targets(self, backup_dir): os.makedirs(os.path.dirname(dest_file), exist_ok=True) shutil.copy2(src_file, dest_file) - def _open_dirs_for_manual_restore(self, backup_dir): + def _open_dirs_for_manual_restore(self, backup_dir: str) -> None: plugin_dir = self.plugin_dir try: os.startfile(plugin_dir) @@ -422,13 +441,14 @@ def _open_dirs_for_manual_restore(self, backup_dir): except Exception: pass - def _find_zip_asset(self, release): - for a in release.get("assets", []): - if a.get("name", "").endswith(".zip"): - return a + def _find_zip_asset(self, release: dict[str, Any]) -> dict[str, Any] | None: + for asset in release.get("assets", []): + name = asset.get("name", "") + if isinstance(name, str) and name.endswith(".zip"): + return asset return None - def _download_asset(self, url, zip_path): + def _download_asset(self, url: str, zip_path: str) -> None: try: with ( urllib.request.urlopen(url, timeout=10) as response, @@ -455,10 +475,10 @@ def _download_asset(self, url, zip_path): "Connection timed out while trying to download asset." ) from e - def _extract_update_files(self, zip_path, tmpdir): + def _extract_update_files(self, zip_path: str, tmpdir: str) -> dict[str, str]: with zipfile.ZipFile(zip_path, "r") as zip_ref: zip_ref.extractall(tmpdir) - found_targets = {} + found_targets: dict[str, str] = {} for root, dirs, files in os.walk(tmpdir): for target in self.update_targets: if target in files: @@ -467,7 +487,7 @@ def _extract_update_files(self, zip_path, tmpdir): found_targets[target] = os.path.join(root, target) return found_targets - def _replace_plugin_files(self, found_targets) -> bool: + def _replace_plugin_files(self, found_targets: dict[str, str]) -> bool: plugin_dir = self.plugin_dir changes_done = False try: @@ -502,11 +522,11 @@ def _replace_plugin_files(self, found_targets) -> bool: return False return True - def _show_error(self, msg): + def _show_error(self, msg: str) -> None: _app = QApplication.instance() or QApplication(sys.argv) QMessageBox.critical(None, f"{self.name} Update", msg) - def _show_restart_dialog(self): + def _show_restart_dialog(self) -> None: _app = QApplication.instance() or QApplication(sys.argv) QMessageBox.information( None, diff --git a/games/ff12/DateHelper.py b/games/ff12/DateHelper.py index 4c7ab19..2e88fb1 100644 --- a/games/ff12/DateHelper.py +++ b/games/ff12/DateHelper.py @@ -6,13 +6,12 @@ def get_date_time_from_iso(date_iso: str) -> str: Convert ISO 8601 string to 'YYYY-MM-DD HH:MM UTC'. Return input if parsing fails or input is not a string. """ - if not isinstance(date_iso, str): + if not isinstance(date_iso, str): # pyright: ignore[reportUnnecessaryIsInstance] return str(date_iso) - try: dt = datetime.fromisoformat(date_iso.replace("Z", "+00:00")) return dt.strftime("%Y-%m-%d %H:%M UTC") - except Exception: + except ValueError: return date_iso @@ -21,11 +20,10 @@ def get_date_from_iso(date_iso: str) -> str: Convert ISO 8601 string to 'YYYY-MM-DD'. Return input if parsing fails or input is not a string. """ - if not isinstance(date_iso, str): + if not isinstance(date_iso, str): # pyright: ignore[reportUnnecessaryIsInstance] return str(date_iso) - try: dt = datetime.fromisoformat(date_iso.replace("Z", "+00:00")) return dt.strftime("%Y-%m-%d") - except Exception: + except ValueError: return date_iso diff --git a/games/ff12/ModDataChecker.py b/games/ff12/ModDataChecker.py index 3071cb9..2411942 100644 --- a/games/ff12/ModDataChecker.py +++ b/games/ff12/ModDataChecker.py @@ -39,7 +39,7 @@ def dataLooksValid( if status is mobase.ModDataChecker.INVALID: status = mobase.ModDataChecker.VALID - elif rp.move_match(name) is not None: + elif rp.move_match(name): status = mobase.ModDataChecker.FIXABLE elif rp.unfold.match(name) and is_directory(entry): @@ -48,7 +48,7 @@ def dataLooksValid( if new_status is not mobase.ModDataChecker.VALID: status = new_status - elif rp.delete.match(name) is not None: + elif rp.delete.match(name): status = mobase.ModDataChecker.FIXABLE else: diff --git a/games/ff12/SaveGame.py b/games/ff12/SaveGame.py index b8df1a2..16f5414 100644 --- a/games/ff12/SaveGame.py +++ b/games/ff12/SaveGame.py @@ -25,7 +25,7 @@ def getName(self) -> str: def getSaveGroupIdentifier(self) -> str: return "Default" - def getSlot(self) -> str: + def getSlot(self) -> int: return int(self._filepath.stem[6:9]) def getSize(self) -> int: @@ -41,7 +41,7 @@ def getCreationTime(self) -> QDateTime: def getSaveMetadata(savepath: Path, save: mobase.ISaveGame) -> Mapping[str, str]: assert isinstance(save, FF12SaveGame) return { - "Slot": save.getSlot(), + "Slot": str(save.getSlot()), "Size": f"{save.getSize() / 1024:.2f} KB", "Created At": format_date(save.getBirthTime()), "Last Saved": format_date(save.getCreationTime()), diff --git a/games/ff12/SettingsManager.py b/games/ff12/SettingsManager.py index 777f1a7..ea75385 100644 --- a/games/ff12/SettingsManager.py +++ b/games/ff12/SettingsManager.py @@ -12,7 +12,7 @@ class SettingName(StrEnum): class SettingsManager: - _instance = None + _instance: "SettingsManager | None" = None def __init__(self, organizer: mobase.IOrganizer, game_name: str): self._organizer = organizer @@ -20,17 +20,17 @@ def __init__(self, organizer: mobase.IOrganizer, game_name: str): SettingsManager._instance = self @staticmethod - def get_instance(): + def get_instance() -> "SettingsManager": if SettingsManager._instance is None: raise RuntimeError("SettingsManager not initialized.") return SettingsManager._instance - def get_setting(self, key: str): + def get_setting(self, key: str) -> mobase.MoVariant: return self._organizer.pluginSetting(self._game_name, key) - def set_setting(self, key: str, value): + def set_setting(self, key: str, value: mobase.MoVariant) -> None: self._organizer.setPluginSetting(self._game_name, key, value) -def settings_manager(): +def settings_manager() -> SettingsManager: return SettingsManager.get_instance() diff --git a/games/ff12/SteamHelper.py b/games/ff12/SteamHelper.py index 0a32a79..b6eba52 100644 --- a/games/ff12/SteamHelper.py +++ b/games/ff12/SteamHelper.py @@ -1,4 +1,6 @@ -import vdf +from typing import Any, cast + +import vdf # pyright: ignore[reportMissingTypeStubs] from ...steam_utils import find_steam_path @@ -14,11 +16,13 @@ def get_last_logged_steam_id() -> str | None: loginusers_path = steam_path / "config" / "loginusers.vdf" try: with open(loginusers_path, "r", encoding="utf-8") as f: - data = vdf.load(f) + # vdf has no stubs; cast breaks pyright's Unknown propagation in strict mode. + data: dict[str, Any] = cast(dict[str, Any], vdf.load(f)) # pyright: ignore[reportUnknownMemberType] + + users: dict[str, Any] = data.get("users", {}) - users = data.get("users", {}) for steam_id, info in users.items(): - if info.get("MostRecent") == "1": + if isinstance(info, dict) and info.get("MostRecent") == "1": # pyright: ignore[reportUnknownMemberType] return steam_id return next(iter(users), None) diff --git a/games/game_ff12.py b/games/game_ff12.py index b4e88a7..b1b2ea4 100644 --- a/games/game_ff12.py +++ b/games/game_ff12.py @@ -50,7 +50,7 @@ def init(self, organizer: mobase.IOrganizer) -> bool: super().init(organizer) SettingsManager(organizer, self.name()) self._register_feature(FF12ModDataChecker()) - self._register_feature(BasicLocalSavegames(self.savesDirectory())) + self._register_feature(BasicLocalSavegames(self)) self._register_feature(BasicGameSaveGameInfo(get_metadata=getSaveMetadata)) organizer.onPluginSettingChanged(self._on_plugin_setting_changed_callback) organizer.onUserInterfaceInitialized( @@ -114,11 +114,14 @@ def documentsDirectory(self) -> QDir: ) steam_id = settings_manager().get_setting(SettingName.STEAM_ID_64) - if steam_id: + if isinstance(steam_id, str) and steam_id: docs_path = QDir(docs_path.absoluteFilePath(steam_id)) return docs_path + def absolutePath(self) -> str: + return self.savesDirectory().absolutePath() + def executables(self): # Windows isn't necessarily installed in "C:\Windows\". cmd_path = shutil.which("cmd.exe") @@ -224,12 +227,20 @@ def _check_for_update(self, window: QMainWindow): if settings_manager().get_setting(SettingName.DISABLE_AUTO_UPDATES) is True: return - remind_time = settings_manager().get_setting(SettingName.SKIP_UPDATE_UNTIL_DATE) + remind_time_raw = settings_manager().get_setting( + SettingName.SKIP_UPDATE_UNTIL_DATE + ) + remind_time = remind_time_raw if isinstance(remind_time_raw, int) else 0 now_secs = int(QDateTime.currentDateTime().toSecsSinceEpoch()) - if remind_time and now_secs is not None and remind_time > now_secs: + if remind_time > now_secs: return + skip_version_raw = settings_manager().get_setting( + SettingName.SKIP_UPDATE_VERSION + ) + skip_version = skip_version_raw if isinstance(skip_version_raw, str) else None + update_checker = UpdateChecker( "FF12 Plugin", "FF12-Modding", @@ -241,9 +252,7 @@ def _check_for_update(self, window: QMainWindow): window, update_targets=["game_ff12.py", "ff12"], remove_targets=["ff12"], - skip_version=settings_manager().get_setting( - SettingName.SKIP_UPDATE_VERSION - ), + skip_version=skip_version, plugin_dir=os.path.dirname(__file__), ) From cc95f79b748b4944d82d743954cea49b9a73a980 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:19:13 +0000 Subject: [PATCH 4/4] [pre-commit.ci] Auto fixes from pre-commit.com hooks. --- games/ff12/AutoUpdate.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/games/ff12/AutoUpdate.py b/games/ff12/AutoUpdate.py index def5377..74842be 100644 --- a/games/ff12/AutoUpdate.py +++ b/games/ff12/AutoUpdate.py @@ -338,9 +338,7 @@ def _show_update_dialog(self, latest_release: dict[str, Any]) -> None: notes_md = self._collect_changelogs(latest_release) current_version = f"v{self.current_version[0]}.{self.current_version[1]}.{self.current_version[2]}" latest_tag = latest_release.get("tag_name", "") - latest_date_str = get_date_time_from_iso( - latest_release.get("published_at", "") - ) + latest_date_str = get_date_time_from_iso(latest_release.get("published_at", "")) _app = QApplication.instance() or QApplication(sys.argv) UpdateDialog = self._create_update_dialog( notes_md, current_version, latest_tag, latest_date_str